生命财产安全相关的应用必须要测试。
相关包: coverage.py。
如何组织测试
为不同的组件创建单独的测试文件,不要全部放在 tests.py 中(该文件要删除),如:
popsicles/
__init__.py
admin.py
forms.py
models.py
test_forms.py
test_models.py
test_views.py
views.py
如上所示,测试模块名必须以 test_ 开头
如何写单元测试
每个测试函数只测试一样事情
一个单元测试不应该对多个视图、数据模型、表单或者类中的多个方法的行为同时进行测试。它应该只对单个视图、数据模型、表单、函数或者方法进行测试。
当然,实际中各组件都会有关联,因此,针对一个特定的测试,应该构建一个绝对最小化的环境。例如:
# flavors/test_api.py
import json
from django.core.urlresolvers import reverse
from django.test import TestCase
from flavors.models import Flavor
class FlavorAPITests(TestCase):
def setUp(self):
Flavor.objects.get_or_create(title="A Title", slug="a-slug")
def test_list(self):
url = reverse("flavor_object_api")
response = self.client.get(url)
self.assertEquals(response.status_code, 200)
data = json.loads(response.content)
self.assertEquals(len(data), 1)
如上所示,我们使用 setup() 方法为本次测试创建尽可能少的记录。
下面是一个较大的示例:
# 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):
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)
# Are both titles in the content?
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)
测试视图中,尽可能用 Request Factory
django.test.client.RequestFactory 可以创建一个可作为视图第一个参数的 reqeust 实例。这种实例比标准 Django 测试客户端更具隔离性。但是由于它不支持中间件(包括会话和认证功能),因此在编写测试代码时需要多做一些工作。
举例来说,假设要对一个经过中间件类处理过的视图进行测试(如需要会话功能),可以如下处理:
from django.contrib.auth.models import AnonymousUser
from django.contrib.sessions.middleware import SessionMiddleware
from django.test import TestCase, RequestFactory
from .views import cheese_flavors
def add_middleware_to_request(request, middleware_class):
middleware = middleware_class()
middleware.process_request(request)
return request
def add_middleware_to_response(request, middleware_class):
middleware = middleware_class()
middleware.process_request(request
return request
class SavoryIceCreamTest(TestCase):
def setUp(self):
# Every test needs access to the request factory.
self.factory = RequestFactory()
def test_cheese_flavors(self):
request = self.factory.get('/cheesy/broccoli/')
request.user = AnonymousUser()
# Annotate the request object with a session
request = add_middleware_to_request(request, SessionMiddleware)
request.session.save()
# process and test the request
response = cheese_flavors(request)
self.assertContains(response, "bleah!")
不要写需要测试的测试代码
测试代码必须尽量简单明了。
编写测试时不适用 DRY
当需要类似但不同数据的测试方法时,可以对代码复制粘贴,然后传递不同的参数。
不要依赖 Fixture
使用 Fixture 问题多多,它难以维护,难以跟踪项目数据的更新。
可创建测试数据的一些库:
- factory boy
- model mommy
- mock
需要测试的东西
尽可能测试所有东西!
视图: 数据的呈现、数据的修改、自定义的 CBV 方法。 数据模型: 数据模型的创建/更新/删除,数据模型的方法,模型管理器的方法。 表单:表单方法,clean()、自定义项。 验证器:对每一个自定义的验证器编写多个测试方法,确保这些验证器不会对网站数据造成破坏。 信号:由于它们的作用比较间接,不进行测试可能会引起困惑。 过滤器:由于它们本质是函数,故测试应该较容易。 模板 Tag:由于它们能完成任何功能,并且可以接受模板上下文对象,对它们进行测试非常有难度。这也意味着需要对它们进行测试,因为如果不测试可能会存在边界条件问题。
项目中不需要测试的部分是那些已经在 Django 核心包或者第三方包中已经测试过的那部分。
测试失败的情况比测试成功的情况更重要
通过 Mock 使得单元测试不与外界交互
单元测试不应该测试本函数或方法以外的事物。因此在测试过程中不应该访问外部的 API、接收邮件、调用挂钩等。但是要测试的函数很可能会包含外部 API,此时有两种可选方法:
- 一、将单元测试变成集成测试
- 二、使用 Mock 库来模拟外部 API 应答
使用 Mock 库能非常容易地将某库的功能进行临时修改,从而使它们能返回我们想到的数据。
下面的例子中, Mock 对 icecreamapi 模块中的对象进行了修改:
import mock
import unittest
import icecreamapi
from flavors.exceptions import CantListFlavors
from flavors.utils import list_flavors_sorted
class TestIceCreamSorting(unittest.TestCase):
# Set up monkeypatch of icecreamapi.get_flavors()
@mock.patch.object(icecreamapi, "get_flavors")
def test_flavor_sort(self, get_flavors):
# Instructs icecreamapi.get_flavors() to return an unordered list.
get_flavors.return_value = ['chocolate', 'vanilla', 'strawberry', ]
# list_flavors_sorted() calls the icecreamapi.get_flavors()
# function. Since we've monkeypatched the function, it will always
# return ['chocolate', 'strawberry', 'vanilla', ]. Which the.
# list_flavors_sorted() will sort alphabetically
flavors = list_flavors_sorted()
self.assertEqual(
flavors,
['chocolate', 'strawberry', 'vanilla', ]
)
下面是测试失败情况下的代码:
@mock.patch.object(icecreamapi, "get_flavors")
def test_flavor_sort_failure(self, get_flavors):
# Instructs icecreamapi.get_flavors() to throw a FlavorError.
get_flavors.side_effect = icecreamapi.FlavorError()
# list_flavors_sorted() catches the icecreamapi.FlavorError()
# and passes on a CantListFlavors exception.
with self.assertRaises(CantListFlavors):
list_flavors_sorted()
下面是对 python requests 连接进行修改的例子:
@mock.patch.object(requests, "get")
def test_request_failure(self, get)
"""Test if the target site is innaccessible."""
get.side_effect = requests.exception.ConnectionError()
with self.assertRaises(CantListFlavors):
list_flavors_sorted()
@mock.patch.object(requests, "get")
def test_request_failure(self, get)
"""Test if we can handle SSL problems elegantly."""
get.side_effect = requests.exception.SSLError()
with self.assertRaises(CantListFlavors):
list_flavors_sorted()
使用功能更强的断言方法
假如要比较两个列表,如果用 assertEqual,那么需要先对列表进行相同的排序操作,然后才能进行比较,而使用 unittests.ListItemsEqual() 则不用这么麻烦。
以下是比较有用的断言方法:
- assertRaises()
- Python 2.7: ListItemsEqual(), Python 3+ assertCountEqual()
- assertDictEqual()
- assertFormError()
- assertContains() 先检测状态 200,然后再检查 response.content
- assertHTMLEqual() 忽略空白符的不同
- assertJSONEqual()
更多参考: python2 assert-methods python3 assert-methods django assertions
对每个测试都要写明测试目的文档
即便是小小的 docstring 也能帮很大的忙。
集成测试
集成测试是将单独的模块组合成一个整体进行测试,它在单元测试之后进行。集成测试的例子有:
- 使用 Selenium 测试应用是否能在浏览器中正确运行
- 与第三方 API 进行真实测试
- 与 requestb.in 或 httpbin 交互来确认出站请求的有效性
- 使用 runscope.com 来确保 API 能正常运行
集成测试的缺点:
- 设置集成测试要花很多时间
- 运行速度非常慢。因此它是对整个系统进行测试
- 有错误抛出时,很难定位。例如,一个只对某类浏览器有影响的错误可能是由数据库层的 Unicode 转换引起的
- 相比单元测试更脆弱。某个组件中的一个小修改都可能会破坏它。
持续集成
对于任何大小的项目,都应该设置一个持续集成 CI 服务器,对提交后的项目进行测试。
测试的必要性
Tests are the Programmer’s stone, transmuting fear into boredom.” -Kent Beck
测试虽然花时间,但是在处理升级时非常有用,因此花的时间是非常值得的。
当进行升级时:
- 先在本地升级相关包或库
- 运行测试
- 修正测试过程中抛出的所有错误
- 再进行一些手工检查
如果没有测试,每次升级时,都要对所有的使用情景进行一次次的手工操作测试。
测试覆盖的游戏
测试覆盖度即是对开发人员进行督促的工具,也是用来评估项目状态的度量。
设置覆盖测试
我们只需对自己的代码进行测试,不对 Django 和第三方包进行测试。
开始编写测试
运行测试并生成覆盖报告
在 <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 文件。点击里面的各模块列表,其中的红色部分是不好的。
测试覆盖的基本原则
当新增功能和修复问题后,假设之前的覆盖率是 65%,那么修改后如果测试覆盖率低于 65%,代码就不要合并进来,从而保证的测试的覆盖率。
测试覆盖率缓慢地提高是好的,说明项目的质量一直在改善,绝不可跳跃式的提高。
其它的测试库
Django 的默认测试库是 unittest,使用它时,要写的样板文件比较多。
下面的两个库所需的样板文件较少:
这两个库分别是对 pytest 和 nose 库的封装。它们不仅能运行 unittest 式的测试用例,还能测试任何以 test_ 开头的函数(类/目录/模志)等。下面是一个简单的例子:
# test_models.py
from pytest import raises
from cones.models import Cone
def test_good_choice():
assert Cone.objects.filter(type='sugar').count() == 1
def test_bad_cone_choice():
with raises(Cone.DoesNotExist):
Cone.objects.get(type='spaghetti')