前言
本文,旨在说明python Django如何编写单元测试,从“背景”,“测试要求”,“代码编写”,“如何运行”,“检验测试覆盖度” 这几个方面来说明
附上django的官方文档单元测试章节=>这里
背景
python中主要的单元测试框架有以下几种:
unittest
标准库,最出名。django中原生自带的单元测试库就是对unittest对封装
点击这里=>最基本的unittest的属性讲解和编写思路
Django 的默认测试库是 unittest,使用它时,要写的样板文件比较多。
下面的两个库所需的样板文件较少:
这两个库分别是对 pytest 和 nose 库的封装。它们不仅能运行 unittest 式的测试用例,还能测试任何以 test_ 开头的函数(类/目录/模志)等。
剩下比较知名的有:
pytest:第三方模块,第二出名。是unittest的替代(编写更少代码完成相同事),
Doctest:通过在函数最开头,写上类似交互式的语句来测试
Unittest2:第三方模块,是unitest的升级版。对API进行了改善以及更好的诊断语法
测试要求
具体到django各个组件到具体要求:
- 视图: 数据的呈现、数据的修改、自定义的 CBV 方法。
- 数据模型: 数据模型的创建/更新/删除,数据模型的方法,模型管理器的方法。
- 表单:表单方法,clean()、自定义项。
- 验证器:对每一个自定义的验证器编写多个测试方法,确保这些验证器不会对网站数据造成破坏。
- 信号:由于它们的作用比较间接,不进行测试可能会引起困惑。
- 过滤器:由于它们本质是函数,故测试应该较容易。
- 模板 Tag:由于它们能完成任何功能,并且可以接受模板上下文对象,对它们进行测试非常有难度。这也意味着需要对它们进行测试,因为如果不测试可能会存在边界条件问题。
代码编写
推荐做法
- 目录结构
catalog/
/tests/
__init__.py
test_models.py
test_forms.py
test_views.py
- 不要写需要测试的测试代码,意思是测试代码必须简洁明了
- 尽可能测试所有自己编写的代码,不测试的部分是Django 核心包或者第三方包
- 每个测试函数只测试一样事情=>一个单元测试不应该对多个视图、数据模型、表单或者类中的多个方法的行为同时进行测试。它应该只对单个视图、数据模型、表单、函数或者方法进行测试
- 当需要类似但不同数据的测试方法时,可以对代码复制粘贴,然后传递不同的参数
-
单元测试不应该测试本函数或方法以外的事物。因此在测试过程中不应该访问外部的 API、接收邮件、调用挂钩等。但是要测试的函数很可能会包含外部 API,此时有两种可选方法:
- 一、将单元测试变成集成测试
- 二、使用 Mock 库来模拟外部 API 应答
- 对每个测试都要写明测试目的文档,即便是小小的 docstring 也能帮很大的忙
-
测试覆盖的基本原则
当新增功能和修复问题后,假设之前的覆盖率是 65%,那么修改后如果测试覆盖率低于 65%,代码就不要合并进来,从而保证的测试的覆盖率。
测试覆盖率缓慢地提高是好的,说明项目的质量一直在改善,绝不可跳跃式的提高。
Django unittest 基本情况
TestCase
大多数测试的最佳基类是 django.test.TestCase。
此测试类在运行测试之前,会单独新建一个测试数据库来进行数据库的操作方面的测试(默认在测试完成后销毁)。并在自己的事务中,运行每个测试函数。
该类还拥有一个测试客户端,您可以使用该客户端,模拟在视图级别与代码交互的用户。
TestCase默认情况(数据库相关)
- 如果用原生的unittest库进行,测试之前(setUp())要先创建测试数据库连接,测试之后将数据库摧毁连接。但是Django框架中的TestCase,它已经帮你实现的一些逻辑方便用于测试,所以我们不需要在setUp和tearDown函数中实现这些逻辑
- TestCase在测试开始时,判断当前连接的数据库是否支持事务特性,如支持,则开启事务操作;在测试结束时,同样判断是否支持事务特性,如支持,执行事务回滚,然后关闭所有链接
- 每次测试会单独新建一个测试数据库来进行数据库的操作方面的测试,默认在测试完成后销毁。
注意:若migrations的文档过多时,那么大量时间将消耗在数据库的创建。django有提供命令使用进行单元测试过后不删除数据库。 这个命令就是: --keepdb - 在测试方法中对数据库进行增删操作,最后都会被清除。也就是说,在test_add中插入的数据,在test_add测试结束后插入的数据会被清除。
- 测试数据库的默认字符集
#在创建测试数据库时,数据库的默认字符集也许不是我们想要的例如latin1。可以通过在数据库配置中指定TEST_CHARSET, TEST_COLLATION 参数,来指定字符集以及排序规则 DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'xxxx', 'USER': 'xxxx', 'PASSWORD': '', 'HOST': 'localhost', 'PORT': '3306', 'OPTIONS': { 'charset': 'utf8mb4', 'init_command': 'SET default_storage_engine=INNODB', }, 'TEST_CHARSET': 'utf8', 'TEST_COLLATION': 'utf8_general_ci', }, }
setUp()和setUpTestData()
1.对于每一个测试方法都会将setUp()和tearDown()方法执行一遍。即setUp() 在每个测试函数之前被调用,以设置可能被测试修改的任何对象(每个测试函数,都将获得这些对象的 “新” 版本)2.setUpTestData()和tearDownTestData() 用于类级别设置,在测试运行开始的时侯,会调用一次。您可以使用它来创建一个我们将使用,但不在任何测试中修改的作者对象。
class AuthorModelTest(TestCase): @classmethod def setUpTestData(cls): Author.objects.create(first_name='Big', last_name='Bob')
提供适用于Django的功能更强的断言方法
TestCase子类的以test*开头的测试方法使用 Assert 断言来测试条件是真,假或相等(AssertTrue, AssertFalse, AssertEqual)。如果条件评估不如预期,则测试将失败,并将错误报告给控制台。
AssertTrue, AssertFalse, AssertEqual 是 unittest 提供的标准断言。框架中还有其他标准断言,还有 Django 特定的断言,来测试视图是否重定向(assertRedirects),或测试是否已使用特定模板(assertTemplateUsed)
以下是比较有用的断言方法:
- assertRaises()
- assertCountEqual()
- assertDictEqual()
- assertFormError()
- assertContains() 先检测状态 200,然后再检查 response.content
- assertHTMLEqual() 忽略空白符的不同
- assertJSONEqual()
例如:假如要比较两个列表,如果用 assertEqual,那么需要先对列表进行相同的排序操作,然后才能进行比较,而使用assertCountEqual() 则不用这么麻烦。
编码时一些常见场景
测试接口
这文章写得不错,点击这里
例如:通过指定接口测试一个对象的增删改查
# flavors/test_api.py import json from django.core.urlresolvers import reverse from django.test import TestCase from flavors.models import Flavor class DjangoRestFrameworkTests(TestCase): def setUp(self):
# 别误会,下面创建的两个对象是用于“查(集合,个体详细)”,“删”
# “增”操作要在测试函数里面通过调用指定url来创建测试
Flavor.objects.get_or_create(title="title1", slug="slug1") Flavor.objects.get_or_create(title="title2", slug="slug2") self.create_read_url = reverse("flavor_rest_api") self.read_update_delete_url = reverse("flavor_rest_api", kwargs={"slug": "slug1"}) def test_list(self): response = self.client.get(self.create_read_url) self.assertContains(response, "title1") self.assertContains(response, "title2") def test_detail(self): response = self.client.get(self.read_update_delete_url) data = json.loads(response.content) content = {"id": 1, "title": "title1", "slug": "slug1", "scoops_remaining": 0} self.assertEquals(data, content) def test_create(self): post = {"title": "title3", "slug": "slug3"} response = self.client.post(self.create_read_url, post) data = json.loads(response.content) self.assertEquals(response.status_code, 201) content = {"id": 3, "title": "title3", "slug": "slug3", "scoops_remaining": 0} self.assertEquals(data, content) self.assertEquals(Flavor.objects.count(), 3) def test_delete(self): response = self.client.delete(self.read_update_delete_url) self.assertEquals(response.status_code, 204) self.assertEquals(Flavor.objects.count(), 1)
settings变量的修改
注意:django单元测试时为了模拟生产环境,会修改settings中的变量,例如, 把DEBUG变量修改为True, 把ALLOWED_HOSTS修改为[*]
若干需要在单元测试时修改setting配置。例如,django在单元测试时会将settings.DEBUG 设置为True, 而我们需要将其设置为False
方式1: 直接在修改
class BaseApiTest(TestCase): def setUp(self): #testcase DEBUG = False settings.DEBUG = False def test_b(self): self.assertEqual(2, 1+1) def tearDown(self): pass
方式2: 通过装饰器修改
from django.test.utils import override_settings class BaseTest(TestCase): def setUp(self): pass # 利用该装饰器,可以在但个测试函数内修改settings变量, 而不影响 @override_settings(DEBUG=False) def test_b(self): self.assertEqual(2, 1+1) def tearDown(self): pass
Celery异步任务的测试
在代码中几乎肯定是会有celery异步任务,若想对异步任务进行单元测试。可以将CELERY_ALWAYS_EAGER=True, BROKER_BACKEND='memory'
from xxx.celery import app
@app.task(bind=True) def add(self,x, y): return x + y class TaskTest(TestCase): def setUp(self): settings.CELERY_ALWAYS_EAGER = True def test_add(self): self.assertEqual(2, add.apply_async((1,1))) def tearDown(self): pass
通过 Mock 使得单元测试不与外界交互
单元测试不应该测试本函数或方法以外的事物。因此在测试过程中不应该访问外部的 API、接收邮件、调用挂钩等。但是要测试的函数很可能会包含外部 API,此时有两种可选方法:
- 一、将单元测试变成集成测试
- 二、使用 Mock 库来模拟外部 API 应答
使用 Mock 库能非常容易地将某库的功能进行临时修改,从而使它们能返回我们想到的数据。这篇文章写得不错=>这里
如何运行
# 测试整一个工程 $ ./manage.py test # 只测试某个应用,并且指定不要销毁测试数据库 $ ./manage.py test app --keepdb # 只测试一个Case $ ./manage.py test animals.tests.StudentTestCase # 只测试一个方法 $ ./manage.py test animals.tests.StudentTestCase.test_add #显示更多测试信息节 #如果您想获得有关测试运行的更多信息,可以更改详细程度。例如,要列出测试成功和失败(以及有关如何设置测试数据库的大量信息),您可以将详细程度设置为 “2”,如下所示: python3 manage.py test --verbosity 2 # 允许的详细级别为 0, 1 ,2 和 3,默认值为 “1” #注:pycharm中,TestCase类左边有个箭头,点击即可测试。
如果要运行测试的子集,可以通过指定包,模块,TestCase子类或方法的完整路径(包含点)来执行此操作
例如:
- 目录结构
catalog/ /tests/ __init__.py test_models.py test_forms.py test_views.py
python3 manage.py test catalog.tests # Run the specified module
python3 manage.py test catalog.tests.test_models # Run the specified module
python3 manage.py test catalog.tests.test_models.YourTestClass # Run the specified class
python3 manage.py test catalog.tests.test_models.YourTestClass.test_one_plus_one_equals_two # Run the specified method
测试覆盖度工具
测试覆盖度即是对开发人员进行督促的工具,也是用来评估项目状态的度量。牢记我们只需对自己的代码进行测试,不对 Django 和第三方包进行测试。
使用coverage工具运行测试并生成覆盖报告,coverage工具使用=>这里
在 <project_root> 目录下,运行:
$ coverage run manage.py test --settings=twoscoops.settings.test
可能会返回:
Creating test database for alias "default"...
..
-----------------------------------------------
Ran 2 tests in 0.008s
OK
Destroying test database for alias "default"...
通过这种方式,我们只对自己的代码进行测试。
生成报告!
coverage.py 还能生成 HTML 格式的报告。在 <project_root> 目录下,运行:
$ coverage html --omit="admin.py"
之后在当前目录下会生成一个新的目录 htmlcov/,可以在目录下打开 index.html文件。点击里面的各模块列表,其中的红色部分是不好的。
集成测试
集成测试是将单独的模块组合成一个整体进行测试,它在单元测试之后进行。集成测试的例子有:
- 使用 Selenium 测试应用是否能在浏览器中正确运行
- 与第三方 API 进行真实测试
- 与 requestb.in 或 httpbin 交互来确认出站请求的有效性
- 使用 runscope.com 来确保 API 能正常运行
集成测试的缺点:
- 设置集成测试要花很多时间
- 运行速度非常慢。因此它是对整个系统进行测试
- 有错误抛出时,很难定位。例如,一个只对某类浏览器有影响的错误可能是由数据库层的 Unicode 转换引起的
- 相比单元测试更脆弱。某个组件中的一个小修改都可能会破坏它。