Django Rest Framework 给我使用单元测试时不应引发的验证错误

Django Rest Framework give me validation error that shouldn't be raised when using Unit Tests

我正在创建这个 API 并且在我的测试确定点(合同创建端点)我收到一个无效错误。

错误说我在创建合同时没有将某些必需的属性传递给 API,但我传递了。最奇怪的是,当我尝试从 Web 浏览器手动创建时,问题并没有出现,而是创建了合同

我在这里放了很多代码只是为了复制目的,但真正重要的代码是 ContractSerializertest_contract_creation 函数

这是我的代码:

models.py

from django.core.exceptions import ValidationError
from django.db import models
from django.core.validators import (
    MaxValueValidator,
    MinValueValidator,
    RegexValidator,
)

from core.validators import GreaterThanValidator, luhn_validator


class Planet(models.Model):
    name = models.CharField(
        max_length=50, unique=True, null=False, blank=False
    )

    def __str__(self) -> str:
        return self.name


class Route(models.Model):
    origin_planet = models.ForeignKey(
        Planet,
        on_delete=models.CASCADE,
        related_name="origin",
    )
    destination_planet = models.ForeignKey(
        Planet,
        on_delete=models.CASCADE,
        related_name="destination",
    )
    fuel_cost = models.IntegerField(validators=[GreaterThanValidator(0)])

    class Meta:
        unique_together = (("origin_planet", "destination_planet"),)

    def clean(self) -> None:
        # super().full_clean()
        if self.origin_planet == self.destination_planet:
            raise ValidationError(
                "Origin and destination planets must be different."
            )

    def save(self, *args, **kwargs):
        self.full_clean()
        super().save(*args, **kwargs)

    def __str__(self) -> str:
        return f"{self.origin_planet} - {self.destination_planet}"


class Ship(models.Model):
    ship_model = models.CharField(max_length=50, null=False, unique=True)
    fuel_capacity = models.IntegerField(validators=[GreaterThanValidator(0)])
    weight_capacity = models.IntegerField(validators=[GreaterThanValidator(0)])

    def __str__(self) -> str:
        return self.ship_model


class Pilot(models.Model):
    name = models.CharField(max_length=50, null=False)
    age = models.IntegerField(
        validators=[MinValueValidator(18), MaxValueValidator(60)]
    )
    certification = models.CharField(
        max_length=7,
        validators=[
            RegexValidator(
                r"^[0-9]{7}$", "Only digit characters and length 7"
            ),
            luhn_validator,
        ],
        null=False,
        blank=False,
        unique=True,
    )
    credits = models.PositiveIntegerField(default=0, editable=False)
    location_planet = models.ForeignKey(Planet, on_delete=models.CASCADE)
    ships = models.ManyToManyField(Ship, through="Ownership")

    def __str__(self) -> str:
        return f"{self.name}:{self.certification}"


class Ownership(models.Model):
    """Third table for Pilot and Ship relation"""

    pilot = models.ForeignKey(Pilot, on_delete=models.CASCADE)
    ship = models.ForeignKey(Ship, on_delete=models.CASCADE)
    fuel_level = fuel_level = models.PositiveIntegerField(
        default=100, validators=[MaxValueValidator(100)]
    )

    def __str__(self) -> str:
        return f"{self.pilot} -> {self.ship} fuel_level:{self.fuel_level}"


class Resource(models.Model):
    name = models.CharField(max_length=50, unique=True)

    def save(self, *args, **kwargs):
        self.name = self.name.lower()
        super().save(*args, **kwargs)

    def __str__(self) -> str:
        return self.name


class Contract(models.Model):
    description = models.CharField(max_length=50, null=False)
    route = models.ForeignKey(Route, on_delete=models.CASCADE)
    value = models.IntegerField(validators=[GreaterThanValidator(0)])
    completed = models.BooleanField(default=False)
    resources = models.ManyToManyField(Resource, through="Cargo")

    def cargo_weight(self) -> int:
        return sum([cargo.weight for cargo in self.cargo_set.all()])

    def __str__(self) -> str:
        return f"{self.route} contract - R${self.value}"


class Cargo(models.Model):
    resource = models.ForeignKey(Resource, on_delete=models.CASCADE)
    contract = models.ForeignKey(Contract, on_delete=models.CASCADE)
    weight = models.IntegerField(
        validators=[GreaterThanValidator(0)], null=False
    )

view.py

from django.forms.models import model_to_dict
from django.db.utils import IntegrityError

from rest_framework import viewsets
from rest_framework.reverse import reverse
from rest_framework.exceptions import ValidationError

from space_travel import serializers
from core import models


class ResourceViewSet(viewsets.ModelViewSet):
    queryset = models.Resource.objects.all()
    serializer_class = serializers.ResourceSerializer


class ShipViewSet(viewsets.ModelViewSet):
    queryset = models.Ship.objects.all()
    serializer_class = serializers.ShipSerializer


class PlanetViewSet(viewsets.ModelViewSet):
    queryset = models.Planet.objects.all()
    serializer_class = serializers.PlanetSerializer


class PilotViewSet(viewsets.ModelViewSet):
    queryset = models.Pilot.objects.all()
    serializer_class = serializers.PilotSerializer


class RouteViewSet(viewsets.ModelViewSet):
    queryset = models.Route.objects.all()

    def get_serializer_class(self):
        if self.action in ["retrieve", "list"]:
            # This serializer will show a hyperlinked
            # representation for origin/destination fields
            return serializers.RouteReadSerializer

        # This serializer will use th planet names for writing
        # instead of hyperlinked representations
        return serializers.RouteWriteSerializer


class ContractViewSet(viewsets.ModelViewSet):
    queryset = models.Contract.objects.all()
    serializer_class = serializers.ContractSerializer

serializers.py

from typing import Dict

from django.urls.base import reverse

from rest_framework import serializers
from rest_framework.reverse import reverse

from core import models


class ResourceSerializer(serializers.HyperlinkedModelSerializer):
    class Meta:
        model = models.Resource
        fields = "__all__"


class PlanetSerializer(serializers.HyperlinkedModelSerializer):
    class Meta:
        model = models.Planet
        fields = "__all__"


class RouteWriteSerializer(serializers.ModelSerializer):
    """This serializers will be used only for write operations
    like update, create"""

    origin_planet = serializers.SlugRelatedField(
        slug_field="name",
        queryset=models.Planet.objects.all(),
    )
    destination_planet = serializers.SlugRelatedField(
        slug_field="name",
        queryset=models.Planet.objects.all(),
    )

    class Meta:
        model = models.Route
        fields = "__all__"


class RouteReadSerializer(serializers.HyperlinkedModelSerializer):
    class Meta:
        model = models.Route
        fields = "__all__"


class PilotSerializer(serializers.HyperlinkedModelSerializer):
    location_planet = serializers.SlugRelatedField(
        many=False, queryset=models.Planet.objects.all(), slug_field="name"
    )
    ships = serializers.SlugRelatedField(
        queryset=models.Ship.objects.all(), many=True, slug_field="ship_model"
    )

    class Meta:
        model = models.Pilot
        fields = "__all__"

    def to_representation(self, instance: models.Pilot) -> Dict:
        """This method is responsible for represent the ship list
        in a payload mode (should access the ownership table):
            {
                url: {schema}://{domain}/{path to the ship},
                fuel_level: {fuel level} (comes from the ownership table)
            }
        """
        representation = super().to_representation(instance)
        representation["ships"] = []

        for ownership in instance.ownership_set.all():
            ship_url = reverse(  # Get the ship url
                "space_travel:ship-detail",
                args=[ownership.ship.pk],
                request=self._context["request"],
            )
            representation["ships"].append(
                {"url": ship_url, "fuel_level": ownership.fuel_level}
            )

        return representation


class ShipSerializer(serializers.HyperlinkedModelSerializer):
    class Meta:
        model = models.Ship
        fields = "__all__"


class CargoSerializer(serializers.ModelSerializer):
    resource = serializers.SlugRelatedField(
        queryset=models.Resource.objects.all(),
        slug_field="name",
        read_only=False,
    )

    class Meta:
        model = models.Cargo
        fields = ["resource", "weight"]


class ContractSerializer(serializers.HyperlinkedModelSerializer):
    origin_planet = serializers.SlugRelatedField(
        slug_field="name",
        queryset=models.Planet.objects.all(),
        many=False,
        write_only=True,
    )
    destination_planet = serializers.SlugRelatedField(
        slug_field="name",
        queryset=models.Planet.objects.all(),
        many=False,
        write_only=True,
    )
    route = RouteReadSerializer(many=False, read_only=True)
    cargo_set = CargoSerializer(many=True)

    class Meta:
        model = models.Contract
        fields = [
            "description",
            "value",
            "route",
            "origin_planet",
            "destination_planet",
            "cargo_set",
        ]

    def to_representation(self, instance: models.Contract) -> Dict:
        representation = super().to_representation(instance)
        representation["cargos"] = representation.pop("cargo_set")
        representation["total_weight"] = instance.cargo_weight()

        return representation

    def create(self, validated_data: Dict) -> models.Contract:
        route = models.Route.objects.get(
            origin_planet=validated_data.pop("origin_planet"),
            destination_planet=validated_data.pop("destination_planet"),
        )
        cargos = validated_data.pop("cargo_set")
        contract = models.Contract.objects.create(
            **validated_data, route=route
        )
        for cargo in cargos:
            contract.resources.add(
                cargo.pop("resource"), through_defaults=cargo
            )

        return contract

test_contract_endpoint.py

import random

from django.test import TestCase
from django.urls import reverse

from rest_framework import status
from rest_framework.test import APIClient

from core.models import Cargo, Contract, Planet, Resource, Route


def link_list(endpoint: str, domain: bool = False):
    link_path = reverse(f"space_travel:{endpoint}-list")
    return link_path if not domain else f"http://testserver{link_path}"


def link_details(endpoint: str, _id: int, domain: bool = False):
    link_path = reverse(f"space_travel:{endpoint}-detail", args=[_id])
    return link_path if not domain else f"http://testserver{link_path}"


class TestContractEndpoint(TestCase):
    def setUp(self):
        self.client = APIClient()
        self.endpoints = {
            "contract": "contract",
            "route": "route",
            "planet": "planet",
        }

        self.sample_pl1 = Planet.objects.get_or_create(name="Andvari")[0]
        self.sample_pl2 = Planet.objects.get_or_create(name="Demeter")[0]

        self.sample_resources = [
            Resource.objects.create(name=f"resource {n}") for n in range(4)
        ]
        self.sample_route = Route.objects.create(
            origin_planet=self.sample_pl1,
            destination_planet=self.sample_pl2,
            fuel_cost=100,
        )
        self.sample_contract = Contract.objects.create(
            route=self.sample_route,
            value=1000,
            description="Contract to deliver water and food to Demeter",
        )
        self.sample_contract.resources.set(
            self.sample_resources, through_defaults={"weight": 500}
        )

    def test_contract_creation(self):
        """Test if the contract creation are automatically setting the route
        passing the origin and destination planets names and connecting the
        resources to the contract through the Cargo model."""

        resources = [
            Resource.objects.get_or_create(name="minerals")[0],
            random.choice(self.sample_resources),
        ]
        payload = {
            "description": "Contract to deliver minerals to Saturn",
            "origin_planet": self.sample_pl1.name,
            "destination_planet": self.sample_pl2.name,
            "value": 1000,
            "cargo_set": [
                {"resource": resources[0].name, "weight": 545},
                {"resource": resources[1].name, "weight": 876},
            ],
        }
        res = self.client.post(link_list(self.endpoints["contract"]), payload)

        self.assertEqual(res.status_code, status.HTTP_201_CREATED)

        contract = Contract.objects.filter(
            description=payload["description"],
            route=self.sample_route,
            value=payload["value"],
            resources__in=[resource.id for resource in resources],
        ).first()
        self.assertTrue(contract)

        filtered_cargos = Cargo.objects.filter(contract=contract)
        self.assertEqual(filtered_cargos.count(), 2)
        for idx, cargo in enumerate(filtered_cargos):
            self.assertEqual(cargo.weight, payload["cargo"][idx]["weight"])

现在这是我收到的验证错误: {'cargo_set': [ErrorDetail(string='This field is required.', code='required')]}

这是我在网络浏览器中手动创建的合同打印件,传递给它的有效负载与单元测试相同:

在对源代码进行一些研究后,我注意到我的 cargo_set 被实例化为 ListSerializer,问题是当 field.get_value() 在 [=24= 中被调用时].我实际上不知道为什么,但是 parse_html_list() 中的正则表达式没有被匹配。这是我唯一注意到的可能是问题所在

我很乐意提供任何帮助。谢谢!

这似乎与我遇到的问题相同

确保使用发送请求 JSON 编码的 APITest。

为了解决这个问题,我更改了我的测试用例以使用 DRF 提供的 APIClient:

from rest_framework.test import APIClient

client = APIClient()

在我的 settings.py 中,我将以下内容添加到配置中:

REST_FRAMEWORK = {
    ...
    'TEST_REQUEST_DEFAULT_FORMAT': 'json'
}