Django单元测试

1,058 阅读8分钟

本文是学习了Python的 unittestDjango的自动化测试之后的整理总结。

Django的单元测试使用了Python的 unittest模块,这个模块使用基于类的方式定义测试。Django创建测试用例的类 django.test.TestCase继承自Python的unittest.TestCase 。下图为Django官网给到的类的继承关系图:

django_unittest_classes_hierarchy.png

图-1 Django官网的继承关系图

基本概念

在计算机编程中,单元测试是一种软件测试的方法,通过单元测试对源代码的各个单元的源码进行测试,确定它们是否适合使用。这里的单元(unit)指的是一个或者多个计算机程序模块的集合,包含相关的控制数据、使用程序和操作程序。

test fixture (测试夹具):为了执行一个或者多个测试进行的准备和清除操作。

test case (测试用例):一个单独的测试单元。检查一个特定输入的响应。

test suite (测试套件):一系列测试用例。

test runner (测试运行器):组织测试的执行,提供一个输出给用户。

简单示例

本文通过编写《Django入门》中的demo项目的单元测试来进行练习。

示例-1: 调用创建接口,接口返回状态码为201,并且测试数据库中新增了一条数据。

from django.test import TestCase, Client
from django.urls import reverse
from todo.models import Todo

class TodoTestCase(TestCase):
    def setUp(self):
        todo_data = {
            'content': '粉刷',
            'remark': '我是一个粉刷匠',
            'priority': 5
        }
        Todo.objects.create(**todo_data)
    
    def test_create_todo(self):
        '''测试待办事项的创建'''
        todo_data = {
            'content': '喝茶',
            'remark': '粉刷累了喝杯茶',
            'priority': 3
        }
        url = reverse('todo:create')
        client = Client()
        response = client.post(url, todo_data)
        status_code = response.status_code
        self.assertEqual(status_code, 201)
        todo_queryset = Todo.objects.all()
        self.assertEqual(todo_queryset.count(), 2)

setUp()tearDown() 方法允许你定义在每一个测试方法被执行之前(setUp)和之后(tearDown)要执行的内容。

reverse()获取路径模式对应的url。

Django断言方法

Python的unittest模块有以下断言方法:

方法检查内容
assertEqual(a, b)a == b
assertNotEqual(a, b)a != b
assertTrue(x)bool(x) is True
assertFalse(x)bool(x) is False
assertIs(a, b)a is b
assertIsNot(a, b)a is not b
assertIsNone(x)x is None
assertIsNotNone(x)x is not None
assertIn(a, b)a in b
assertNotIn(a, b)a not in b
assertIsInstance(a, b)isinstance(a, b)
assertNotIsInstance(a, b)not isinstance(a, b)

Django的断言在此基础上新增了一些有用的断言,以下是其中的几个:

1.SimpleTestCase.assertRaisesMessage(expected_exception,expected_message)

断言引发了expected_exception异常(比如ValueError),并且异常的消息为expected_message

2.SimpleTestCase.assertJSONEqual(raw, expected_data, msg=None)

断言JSON片段rowexcepted_data相等。如果不相等,会输出错误信息,以及msg的内容。

3.SimpleTestCase.assertJSONNotEqual(raw, expected_data, msg=None):

断言JSON片段rowexcepted_data不相等。

模拟接口请求

Django提供了一个测试客户端(即Client类),作为一个虚拟的Web浏览器。可以用它来模拟接口请求,测试项目中的视图(View)。

from django.test import Client

client = Client()
response = client.post('/todo/create/', {'content': '喝茶', 'remark': '粉刷累了喝杯茶', 'priority': 3})
status_code = response.status_code # 201

get请求

get(path, data=None, follow=False, secure=False, **extra)

path是请求路径。

data是一个键值对字典,是请求携带的数据。

extra关键字参数用于指定请求头的内容。

followTrue时会跟随任何重定向,并在响应对象中设置redirect_chain属性。

secureTrue会模拟HTTPS请求。

c = Client()
c.get('/some/api/', {'title': '标题', 'content': '内容'}, HTTP_ACCEPT='application/json')

通过extra传递的内容需要遵循 CGI (Common Gateway Interface)规范,传递给CGI程序的环境变量HTTP_开头的时候,会设置对应的HTTP请求头的值,比如HTTP_ACCEPT='application/json'会设置请求头字段ACCEPT'application/json'

post请求

post(path, data=None,content_type=MULTIPART_CONTENT, follow=False, secure=False, **extra)

content_typeapplication/json的时候,data为字典、列表或者元组时,会使用json.dumps() 进行序列化。

如果设置其他的content_type,使用content_type作为请求头的Content-Type的内容,data不做处理。

如果不设置content_type的值,data会以multipart/form-data的内容类型传输。

测试文件上传的话,可以这样写:

with open(path, 'rb') as file:
    url = reverse('some_url_pattern_name')
    file_name = 'some_file_name'
    data = {
        'file': file,
        'file_name': file_name,
    }
    c = Client()
    res = c.post(url, data)

其他请求

其他请求的模拟和getpost类似:

1. head(path, data=None, follow=False, secure=False, **extra)

2. options(path, data='', content_type='application/octet-stream', follow=False, secure=False, **extra)

3. put(path, data='', content_type='application/octet-stream', follow=False, secure=False, **extra)

4. patch(path, data='', content_type='application/octet-stream', follow=False, secure=False, **extra)

5. delete(path, data='', content_type='application/octet-stream', follow=False, secure=False, **extra)

6. trace(path, follow=False, secure=False, **extra)

测试数据库

如果测试依赖数据库访问,比如创建/查询模块,必须要使用django.test.TestCase 而不是unittest.TestCase。需要数据库的测试不会使用“真正的”数据库,它会为测试单独创建一个数据库,然后在测试被执行完之后销毁该数据库

示例1 中,在测试前使用代码创建了一条数据:

class TodoTestCase(TestCase):
    def setUp(self):
        todo_data = {
            'content': '粉刷',
            'remark': '我是一个粉刷匠',
            'priority': 5
        }
        Todo.objects.create(**todo_data)

如果想要在测试之前,在数据库中添加很多数据,可以使用夹具(fixture)。一个夹具是一个包含数据库序列化内容的文件集合,Django知道怎么将它们导入数据库中。

夹具导入数据在setUp之前发生。

TransactionTestCase.fixtures提供了数据库夹具。这样使用:

class SomeTestCase(TestCase):
    fixtures = ['data1.json', 'data2']

这会用loaddata加载fixtures中的文件中的数据到测试数据库中,文件的后缀名需要是序列化器(serializer )的名称(xml, json, jsonl, yaml)。如果没有使用文件后缀,就会查找所有序列化类型的后缀名的文件,比如data2会查找data2.xml, data2.json, data2.jsonl, data2.yaml

可以使用dumpdata从数据库中的数据中生成夹具文件,语法如下:

django-admin dumpdata [app_label[.ModelName] [app_label[.ModelName] ...]]

示例-2

1.使用dumpdata从现有数据库中的数据生成一个json文件:

python3 manage.py dumpdata todo.Todo --output todo/tests/todo.json 

生成的todo.json文件内容如下:

[{"model": "todo.todo", "pk": 1, "fields": {"content": "第一件事就是写文1", "remark": "写文使人快乐", "priority": 3, "completed": false, "created_time": "2021-10-05T12:25:59.514Z", "modified_time": "2021-10-05T12:25:59.514Z"}}]

2.写测试用例

class TodoTestCaseOne(TestCase):
    fixtures = ['todo/tests/fixtures/todo.json']

    def test_fixtures(self):
        '''测试夹具导入的数据'''
        todo_queryset = Todo.objects.all()
        self.assertEqual(todo_queryset.count(), 1)

这里的todo_queryset拿到的数据是之前通过夹具导入数据库的。

3.运行测试用例

使用python3 manage.py test执行测试。

Mock(模拟)

unittest.mock提供了:

Mock类,用于创建模拟对象。

patch()装饰器,用于方便地模拟被测试模块中的类或者对象。

Mock类

unittest.mock.Mock类创建一个Mock对象,它包含一些可选的参数来指定Mock对象的行为:

  • spec:可以是一个字符串列表或者一个作为模拟对象说明的对象(一个类或者一个实例)。如果传递一个对象,那么会对该对象调用dir函数,产生一个对象的属性的字符串列表,访问任何不在此列表中的属性会抛出 AttributeError
  • spec_set:一个spec的更严格的变体。如果使用了spec_set,当尝试在模拟对象上设置或获取一个spec_set中不存在的属性时,会抛出AttributeError
  • side_effectmock被调用时 被调用的函数。 side_effect
  • return_valuemock被调用时 的返回值。 return_value
  • unsafe:默认访问任何以assert, assret,asert, aseert,assrt开头的属性名时,会抛出 AttributeErrorunsafe=True会允许对这些属性的访问。
  • wrapsmock对象包裹的项。如果wraps不为None,调用mock时会传递调用到被包裹的对象中。
  • namemock的名称。
>>> mock = Mock(side_effect=KeyError('foo'))
>>> mock()
Traceback (most recent call last):
 ...
KeyError: 'foo'

patch 装饰器

patch()模拟的位置不是在哪里定义的路径,是在哪里使用的路径。假设我们想测试的项目有以下的结构:

a.py
    -> Defines SomeClass

b.py
    -> from a import SomeClass
    -> some_function instantiates SomeClass

a.py文件中,定义了某个类:SomeClass,在b.py中导入了这个类,在函数some_function中实例化了SomeClass,现在要使用patch()模拟SomeClass,使用的是:

@patch('b.SomeClass')

但如果使用的是import asome_function使用的是a.SomeClass,则要使用:

@patch('a.SomeClass')

示例-3

1.准备三个文件,c.py文件调用了b.py的函数,b.py调用了a.py的文件,在获取待办事项列表接口中调用c.py中的函数。

a.py:

def a_function():
    return True

b.py

from a import a_function

def b_function():
    a_result = a_function()
    if a_result:
        return 'a_function返回的结果为真'
    else:
        raise Exception('a_function返回的结果为假')

b文件中,当a_function函数返回值为假时,会抛出一个异常,最终导致接口请求返回400的错误(其实服务端代码的异常不应该返回400状态码,这里仅仅练习用)。

c.py:

from b import b_function

def c_function():
    b_function()

views.py:

...
from .models import Todo
from .utils.c import c_function


class TodoListView(View):
    ...
    
    def get(self, request, pk):
      	'''获取清单列表'''
        try:
            c_function()
            todo_list = Todo.objects.all()
            ...

2.测试获取待办事项列表接口时,模拟这个情况下a_function的返回值:

class TodoTestCaseTwo(TestCase):
    def setUp(self):
        todo_data = {
            'content': '粉刷',
            'remark': '我是一个粉刷匠',
            'priority': 5
        }
        Todo.objects.create(**todo_data)
    
    @patch('todo.utils.b.a_function', return_value=True)
    def test_get_todo_a_true(self, mock_a):
        '''测试获取待办事件列表

        模拟的a_function的返回值为True
        '''
        todo_queryset = Todo.objects.all() # 这里能拿到数据
        print(todo_queryset.count(), 'todo_queryset.count()\n')
        url = reverse('todo:list')
        client = Client()
        response = client.get(url) # 但是这里View内部的代码拿到的todo表的数据为空
        status_code = response.status_code
        self.assertEqual(status_code, 200)
    
    @patch('todo.utils.b.a_function', return_value=False)
    def test_get_todo_a_false(self, mock_a):
        '''测试获取待办事件列表

        模拟的a_function的返回值为False
        '''
        url = reverse('todo:list')
        client = Client()
        response = client.get(url)
        status_code = response.status_code
        self.assertEqual(status_code, 400)

运行单元测试

使用manage.py应用程序的 test 指令运行测试:

python3 manage.py test

默认情况下,会查找当前工作目录下的所有test*.py(文件名以test开头)文件中的 所有测试用例(测试用例是unittest.TestCase的子类),自动在测试用例外创建一个测试套件,然后运行该测试套件。

在测试用例中以test开头的方法,代表测试方法。

源码地址