Django和Django REST的测试--实用工具和最佳实践

404 阅读14分钟

如果你是一个开发人员,你已经知道测试在任何软件项目中是多么重要。特别是自动测试,因为它可以帮助你证实你的编码,并迅速看到你的程序做了什么你想做的事。它还可以帮助证实你的代码中的任何新的变化,没有破坏任何以前的功能。

说到这里,这是否意味着,如果你有自动测试,那么你的项目就不会有任何错误?并非如此。正如计算机科学家Edsger W. Dijkstra曾经说过:

“Program testing can be used to show the presence of bugs, but never to show their absence”.

这句话帮助我们理解,我们不能确定一个程序是完美的,但是,测试是帮助我们发现错误并进行修复和改进的根本。作为一个从事软件开发的人,我可以说,当发现一个错误时,比你的客户或生产中的用户要好得多。在这篇文章中,我将谈论自动测试的基本知识,有用的工具,以及Django项目的良好实践,重点是API。

Django和Django REST框架的测试

Django有一个非常好的测试文档Django REST框架也是如此。因此,在这篇博客中,我将谈论这两个框架中的主要工具,以及你可以做什么和用什么来改善你的测试。

主要的测试类

Django为测试提供了几个类。我在这里要谈的是TestCase,如果你的应用程序使用了数据库,它就非常有用。
正如其名,要在你的Django项目中创建一个测试案例,你将定义一个继承自TestCase的类。通过这样做,你可以使用该类的所有方法和属性,这将有助于你创建和执行你的测试。然后,你将定义不同的函数,对应于测试案例中的每个单元测试。让我们来看看一个测试用例的结构的例子:

# your test file
from django.test import TestCase


# class to define a test case for login
class UserLoginTestCase(TestCase):

    ...
    # some setup here, explained later

    def test_correct_login(self):
        # unit test
        # Corroborate the expected scenario
        ...
    
    def test_if_password_incorrect_then_cant_login(self):
        # unit test
        # Corroborate that user's password needs to be only the correct one
        ...
    
    def test_if_user_not_registered_cant_login(self):
        # unit test
        # Corroborate that user's are able to login only if they're registered
    
    ...

另外,你可以使用Django的客户端类,它模拟了一个假的浏览器,所以你可以进行HTTP请求,并测试你的Django API如何响应。

由于我们专注于API测试,我想谈谈APITestCase,这是Django REST框架的一个类,是TestCase的镜像,但使用不同的客户端类:APIClient。这个客户端扩展了TestClient,意味着它具有相同的功能,并增加了其他的功能,如credentials 功能。这个函数对于覆盖认证头信息非常有用,例如,使用OAuth1、OAuth2或任何简单的令牌认证方案。
所以,如果你正在做一个使用Django REST框架的项目,你可以用这种方式改变前面的例子:

# your test file
from rest_framework.test import APITestCase


# class to define a test case of login
class UserLoginTestCase(APITestCase):

    ...

主要测试功能

前面提到的类都有一组方法,可以帮助你完成测试工作。通过这些函数,你能够证实你的软件按照预期工作,并制作必要的测试夹具。
测试夹具是一个环境,你可以使用一致性来创建运行你的测试。使用固定装置,你可以确保在执行前满足某些条件。例如,在你的测试数据库中有一个确定的数据集,或者创建需要的对象。下面是一个主要的列表:

  • setUptearDown: 这些是在每个单元测试之前(setUp)和之后(tearDown)要执行的函数。这些对固定装置非常有用。
  • setUpClasstearDownClass: 与 setUp 和 tearDown 类似,这些函数在整个测试用例中在(setUpClass)和(tearDownClass)之后执行。这意味着它对一个测试用例只执行一次。因为它们被Django用来做重要的配置,如果你在你的测试用例类中覆盖了这些方法,别忘了在函数里面调用super 实现。
  • setUpTestData: 这个函数可以用来有一个类级的原子块来定义整个测试案例的数据。这意味着在测试用例的所有单元测试最终完成后,这个函数会自动回滚数据库中的变化。这主要是用来加载数据到你的测试数据库。
  • 断言方法。Django有一套来自unittest的断言方法,并有另一个为该框架创建的方法。这些方法,如assertEqual,assertIsNone,assertTrue, 等,可以在单元测试中用来检查必须满足的不同条件。例如,在登录的单元测试中,你可以断言响应的状态代码为200,因为你要确保如果数据被正确发送,那么你的应用程序必须返回带有该代码的响应。

你可以避免使用这些函数,或者使用它们的任何组合。它们不是必需的,但它们非常有用,会对你有很大的帮助。

测试发现与数据库

你必须把你的测试类放在哪里?默认情况下,Django会识别当前工作目录下任何符合模式的文件test*.py 。也就是说,目录下任何以test 开头的文件,当然也有.py 的扩展名。在里面,Django会执行测试案例类中以test 开始的任何函数。
当你运行你的测试时,你可以传递一个参数表示所需的模式,以防你想使用一个不同的模式。有了这种行为,放置测试用例的地方可以是任何地方,但我建议将测试分为你项目中可能有的几个Django应用程序。例如,如果你有一个名为users 的 Django 应用,其中有与你项目中的用户相关的模型和功能,你可以在其中定义一个test 文件夹作为模块(添加init.py 文件),其中包含所有涵盖该重要部分的测试:对登录、注册、用户数据、账户删除等的测试。类似这样的东西:

my_project
   |__django_apps
       |__users
          |__test
             |__ __init__.py
             |__ test_login.py
             |__ test_signup.py
             |__ test_delete_user.py
             ...
        ...
    ...
...

关于数据库,当你默认运行你的测试时,Django会创建一个数据库,然后在最终完成后将其销毁。这是为了避免你的生产和/或开发数据库,以及你的测试数据库之间的冲突。你可以为测试定义和定制一个数据库,在DATABASES 字典中,添加另一个名为TEST 的数据库。类似的情况:

DATABASES = {
    'default': {
        'ENGINE': '<engine>',
        'NAME': '<database name>',
        'USER': '<database user>',
        ...
    },
    ...
    'TEST': {
        # testing database customization here
            'NAME': '<your testing database name>',
            # some other customization, for example the user user, password, etc
        },
}

看一下TEST 字典的可用键。如果你不定义它,Django会创建一个数据库,将其命名为与你在default 设置中的数据库相同,附加上_test 的后缀。

有用的工具

在本节中,我将谈论两个对固定装置非常有帮助的外部工具:FakerFactory Boy

Faker

Faker是一个Python包,用于生成虚假但真实的数据。你可以使用它的功能将数据加载到你的测试数据库中,为你要测试的请求生成数据,并为模型生成数据等。Faker有一个巨大而多样的可能性。你可以在执行时生成,例如,名字、姓氏、电话号码、日期、密码、电子邮件等。

此外,你可以向函数传递参数以生成具有不同约束条件的数据,例如,你可以获得一个密码,指定是否要使用特殊字符,所需的长度,以及是否要有大写字母,等等。看看Faker提供者,看看所有不同的虚假但真实的数据,你可以为你的测试生成。而不是使用例如user_test@mail.com ,你的测试案例可以有更接近现实的数据,提高质量。

Factory Boy

如果你在测试中使用工厂,你可以在一个类中定义你想在测试数据库中拥有的数据结构。然后在你的测试中,将更容易拥有你的模型的实例,并以所需的结构加载数据。Factory Boy是一个使用工厂来取代静态的、难以维护的固定装置的工具。你也可以把它和Faker结合起来,让工厂拥有虚假但真实的数据。让我们看一个例子。

在这个例子中,我们可以看到一个Django的静态夹具,在一个叫做Person的模型中加载数据到测试数据库,这个模型有一个名字和姓氏。这个JSON文件定义了两个实例:

[  {    "model": "myapp.person",    "pk": 1,    "fields": {      "first_name": "John",      "last_name": "Lennon"    }  },  {    "model": "myapp.person",    "pk": 2,    "fields": {      "first_name": "Paul",      "last_name": "McCartney"    }  }]

现在我们来看看使用Factory Boy和Faker的类似定义:

from factory import django, Faker
from myapp import models 

class PersonFactory(factory.django.DjangoModelFactory):

    class Meta:
        model = models.Person
    
    first_name = Faker('first_name')
    last_name = Faker('last_name')

有了这个,你可以使用Factory Boy提供的函数,例如,PersonFactory.create_batch(2) ,在你的测试数据库中即时创建2个具有不同的、现实的姓和名的Person的实例。在JSON情况下,如果你想有10个实例,你将需要添加它们并明确定义新的名字和姓氏。如果你想拥有巨大的数据集,JSON文件将是巨大的,难以维护,而在工厂案例中,你只需要调用一个函数。另外,如果模型中的Person发生变化怎么办?在JSON情况下,你必须逐一更新每个定义的实例。而在工厂化的情况下,你只需要改变一个类的定义。Factory Boy的文档中有一节叫做普通配方,你可以从库中找到有用的工具、实践和提示。下面是一个有趣的功能列表:

  • 你可以使用RelatedFactorySubFactory 类创建包括模型关系的模型工厂。
  • 使用create 函数在工厂中创建一个新的模型实例并将其保存到测试数据库中。如果你想拥有实例,但不把它们保存到数据库中,可以使用build 来代替。
  • 以类似的方式,你可以使用create_batch(N) 创建工厂中模型的 N 个实例并将其保存到数据库中,如果你不想保存它们,可以使用build_batch(N)
  • 你可以在工厂中定义一个属性,在一组选择中选择一个,就像Django模型的选择字段,使用Faker的random_element 。在下一节,我们将看到一个这样的例子。

测试实例与良好实践

在这一节中,我想总结并展示一个小例子。这就是小小的现实。在这个项目中,我们有一个名为用户的Django应用,在这里我们有用户模型,它有一个用户名、电话和系统中的一个类别,可能是管理员、普通用户和访客。用户可以注册并登录到该应用中。

模型和工厂的定义

# model.py in the Django app called users
from django.db import models
from django.contrib.auth.models import AbstractUser


# class to define choices
class Category(models.TextChoices):
    GUEST = 'G', 'Guest user'
    COMMON_USER = 'C', 'Common user'
    ADMIN = 'A', 'Admin user'


# user model that inherits from AbstractUser
# there is no need to define a username field
# because is defined in the parent class
class User(AbstractUser):
    phone_number = models.CharField(max_length=50, blank=True, null=True)
    category = models.CharField(
        max_length=1,
        choices=Category.choices,
        default='G',
    )

现在,让我们看看用户工厂的实现。这个文件在用户Django应用程序的一个测试文件夹内:

# factory.py inside users/test
from faker import Faker as FakerClass
from typing import Any, Sequence
from factory import django, Faker, post_generation

from users.models import User, Category


CATEGORIES_VALUES = [x[0] for x in Category.choices]


class UserFactory(django.DjangoModelFactory):

    class Meta:
        model = User
    
    username = Faker('user_name')
    phone_number = Faker('phone_number')
    category = Faker('random_element', elements=CATEGORIES_VALUES)

    @post_generation
    def password(self, create: bool, extracted: Sequence[Any], **kwargs):
        password = (
            extracted
            if extracted
            else FakerClass().password(
                length=30,
                special_chars=True,
                digits=True,
                upper_case=True,
                lower_case=True,
            )
        )
        self.set_password(password)

关于这一点,有几件事:

  • 在工厂内部的Meta 类中,我们必须指出相应的模型。
  • 在类别属性中,我们使用Faker的random_element ,从类别选择列表中随机选择一个。我们定义了CATEGORIES_VALUES ,以指定我们要取哪一部分的选择。这是因为选择列表中的每个元素都是一个元组,其中第一部分是值,第二部分是解释性的文本。所以,那个CATEGORIES_VALUES 集的每个元素都只有那个第一部分。
  • 通过post_generator 装饰器和password 函数,我们表明在创建一个用户实例的时候,我们可以传递一个想要使用的密码,或者使用Faker功能生成一个。

测试案例

现在,让我们看看用户注册功能的测试实例。这个例子项目的认证部分是用dj-rest-auth实现的。我就不谈这个了,如果你想了解更多,请看一下这个博客。这是用户Django应用程序的测试文件夹内的test_singup.py文件:

from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase, APIClient

from users.test.factory import UserFactory
from users.models import User, Category
from faker import Faker


class UserSignUpTestCase(APITestCase):

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.user_object = UserFactory.build()
        cls.user_saved = UserFactory.create()
        cls.client = APIClient()
        cls.signup_url = reverse('rest_register')
        cls.faker_obj = Faker()

    def test_if_data_is_correct_then_signup(self):
        # Prepare data
        signup_dict = {
            'username': self.user_object.username,
            'password1': 'test_Pass',
            'password2': 'test_Pass',
            'phone_number': self.user_object.phone_number,
            'category': self.user_object.category,
        }
        # Make request
        response = self.client.post(self.signup_url, signup_dict)
        # Check status response
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(User.objects.count(), 2)
        # Check database
        new_user = User.objects.get(username=self.user_object.username)
        self.assertEqual(
            new_user.category,
            self.user_object.category,
        )
        self.assertEqual(
            new_user.phone_number,
            self.user_object.phone_number,
        )

    def test_if_username_already_exists_dont_signup(self):
        # Prepare data with already saved user
        signup_dict = {
            'username': self.user_saved.username,
            'password1': 'test_Pass',
            'password2': 'test_Pass',
            'phone_number': self.user_saved.phone_number,
            'category': self.user_saved.category,
        }
        # Make request
        response = self.client.post(self.signup_url, signup_dict)
        # Check status response
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
        self.assertEqual(
            str(response.data['username'][0]),
            'A user with that username already exists.',
        )
        # Check database
        # Check that there is only one user with the saved username
        username_query = User.objects.filter(username=self.user_saved.username)
        self.assertEqual(username_query.count(), 1)

好吧,在这个文件中,我们有一个测试案例,定义为UserSignUpTestCase,里面有两个单元测试,一个是测试预期案例,另一个是测试错误案例。我想列出几个要点来讨论好的做法:

  • 看一下单元测试的名称:你可以随心所欲地命名它们(除非你改变模式,否则总是以test 开始)。一个好的做法是做一个能描述它的名字。这将有助于你在测试和调试时,例如,如果一个测试失败,你想追踪错误。
  • 在setUpClass方法中,我们调用super() 函数,记住它对Django很重要。此外,我们还设置了所需的实例,如API客户端、一个用户和一个faker实例。此外,我们用create 函数将一个用户加载到数据库中。
  • 注意,在setUpClass方法中定义的sign_up URL,使用了Django的reverse 。如果你愿意,你可以直接输入URL字符串。但是使用反向,你获得一个输入相关名称的URL。在dj-rest-auth包中,相关名称是rest_register 。我建议你在你的项目中使用反向,以提高可维护性。当你定义URL时,给它们一个名字,然后在测试中使用反向。之后,如果一个URL改变了,你不需要在测试中改变任何东西,因为反向解决了这个问题。
  • 为单元测试选择的一般结构是:
    • 准备好数据。你可以使用用build 生成的实例来获取请求的数据。
    • 提出请求。使用APIClient来提出请求。
    • 检查响应。检查响应数据(如果有数据)和状态。在使用Django REST框架时,我推荐使用status。有了它,你就能以一种更简洁的方式获得HTTP状态码。如果你不想这样做,你可以直接写上数字。
    • 检查数据库。如果请求在数据库中产生了变化,请证实一下,以确保该功能正确地修改了它。如果你有不修改数据库的案例,测试数据库保持不变是一个好的做法(就像第二个单元测试一样)。

这是一个有两个单元测试的测试案例的例子。你可以添加更多的单元测试,例如,测试请求中的某些数据是否不正确或用户不能注册。此外,你可以在该文件(或其他文件)中添加更多的测试案例类,例如,测试应用程序的登录功能。

运行你的测试

一旦你编码了几个测试,你可以在项目的主文件夹(有manage.py文件的文件夹)中用这个命令执行它们。

  • python manage.py test 这将搜索该文件夹下的所有测试文件,然后执行它们,显示每个单元测试的错误或成功。

自定义测试的执行

你可以自定义测试的执行,例如,你可以表示要测试:

  • 只有一个Django应用程序,指定<Django app name>
    • python manage.py test users
  • 只有一个Django应用中的一个文件,指定为<Django app name>.path.to.file
    • python manage.py test users.test.test_signup
  • 只有一个测试案例类,指定:<Django app name>.path.to.file.ClassName
    • python manage.py test users.test.test_signup.UserSignUpTestCase
  • 只有一个单元测试函数,指定<Django app name>.path.to.file.ClassName.function_name
    • python manage.py test users.test.test_signup.UserSignUpTestCase.test_if_data_is_correct_then_signup

你也可以在命令中添加一些标志来定制执行,例如。

  • --keepdb 以避免在执行之间擦除数据库。这可以提高速度。
  • --parallel 并行运行测试,以提高在多核硬件上的速度。如果你这样做,请确保它们被很好地隔离开来。

总结

在这篇文章中,我介绍了一些使用Django和Django REST框架进行测试的基础知识。此外,我还展示了一些有用的工具和良好的实践,这些工具和实践更侧重于API的测试。我强调了一些例子,以及如何使用命名的框架运行和定制你的测试。我希望你喜欢阅读,我希望这个博客可以帮助你测试和改善你自己的Django API项目。