Django Rest Framework 给我使用单元测试时不应引发的验证错误
Django Rest Framework give me validation error that shouldn't be raised when using Unit Tests
我正在创建这个 API 并且在我的测试确定点(合同创建端点)我收到一个无效错误。
错误说我在创建合同时没有将某些必需的属性传递给 API,但我传递了。最奇怪的是,当我尝试从 Web 浏览器手动创建时,问题并没有出现,而是创建了合同
我在这里放了很多代码只是为了复制目的,但真正重要的代码是 ContractSerializer
和 test_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'
}
我正在创建这个 API 并且在我的测试确定点(合同创建端点)我收到一个无效错误。
错误说我在创建合同时没有将某些必需的属性传递给 API,但我传递了。最奇怪的是,当我尝试从 Web 浏览器手动创建时,问题并没有出现,而是创建了合同
我在这里放了很多代码只是为了复制目的,但真正重要的代码是 ContractSerializer
和 test_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'
}