前言

本文受《Python测试驱动开发》启发,书中内容很详尽,章节也很多,比较偏向于新手向,从如何安装Django1.11到如何使用Git工具都有教,没接触过这些的人都能看懂,有一定基础的人可以略读,值得一看。
本文绝大部分内容并不来自该书,更多的是我的个人见解和对官方文档的一些整理。

本文主要介绍Djangounittest有区别的地方,以及如何去使用Django内置的测试模块进行测试,适合有一定Python基础和Django基础的人。

自动测试的理念

TDD

TDD(Test Driven Development),中文名是“测试驱动开发”,这是一个编程理念,类似于面向对象编程,你可以不用,但是如果你用了,你会发现你的开发过程会非常的顺畅,非常的有底气,你对代码所做的每一处修改,都在你的可控范围内,出错也很容易找到问题所在,并且先测试再开发的过程更有利于你设计一个功能或者设计整个产品,在你的大脑中会非常清晰地知道你想要实现什么功能,而不需要先想着代码应该怎么实现,就像API文档一样,每个功能、每个模块都可以一目了然,这就是测试的作用。

个人见解

通过这段时间编写测试代码的经历,对自动测试、单元测试有一点小小见解。
Django项目写测试对我来说是一件很痛苦的事情,特别是MTV中的T(Templates),要测试模板是否是对的,就相当于测试前端,不仅要测试UI显示(HTML)是不是对的,(JS)交互是否正常,样式(CSS)是否正确(即UI),还要测试跟Django耦合部分的上下文对不对。
测试模板一般都是看期望的DOM有没有渲染出来,还得测试点击某个按钮是不是能正常工作等等。

然后就是视图或者模型测试,得把所有场景都想象出来,然后对这些场景进行测试,比如权限认证、身份认证、数据库查询等。

如果把整个项目都做一遍测试,把上述的测试项都测试一遍,那这样下来测试代码要比业务代码要多不少,不过这样也是有他的合理性的,毕竟测试全面了,代码稳定性就高了。

但是我觉得不应该把所有的测试代码都放到Django项目里面,应该分三个部分进行:

  • 后端函数、逻辑代码测试
  • 接口测试
  • 前端UI和交互测试
  • 功能测试/需求测试

各部门各司其职,对上述测试点进行测试,用其相对应的专业工具去测,这样效率也能大大提升,并且测试文档也会好写很多,最终提交的项目的结构化也更好。
另外我认为测试代码(即用于进行测试的代码,不是被测试的代码)越少越好,因为如果用代码测试代码,那很难保证测试代码本身不会存在逻辑错误或者其他BUG,各种逻辑函数越少越好,越傻瓜越好,哪怕是面向过程模式的测试代码,这样才能更好拟合用户的操作行为,减少依赖性。

Django中需要测试的部分

Django测试

快速入门

测试代码

from django.test import TestCase
from myapp.models import Animal

class AnimalTestCase(TestCase):
def setUp(self):
Animal.objects.create(name="lion", sound="roar")
Animal.objects.create(name="cat", sound="meow")

def test_animals_can_speak(self):
"""Animals that can speak are correctly identified"""
lion = Animal.objects.get(name="lion")
cat = Animal.objects.get(name="cat")
self.assertEqual(lion.speak(), 'The lion says "roar"')
self.assertEqual(cat.speak(), 'The cat says "meow"')

命令行

# 运行全部测试
python manage.py test
# 运行animal.tests模块下的所有测试
python manage.py test animals.tests
# 自动搜索animal包下的所有test_开头的测试文件,并运行测试
python manage.py test animals
# 仅运行一个测试用例
python manage.py test animals.tests.AnimalTestCase
# 仅运行一个测试用例下的测试点
python manage.py test animals.tests.AnimalTestCase.test_animals_can_speak
# 也可以自动搜索一个目录下的测试文件,并运行测试,跟第三个示例相似
python manage.py test animals/
# 甚至可以指定一个前缀,然后自动运行对应前缀的测试代码
python manage.py test --pattern="tests_*.py"
# 并行运行测试
python manage.py test --parallel
# 运行测试并保存临时数据库
python manage.py test --keepdb

注意事项

  • 测试时不会使用真实数据库,测试运行完之后会自动删除临时数据库,除非指定一个参数--keepdb,指定了这个参数之后数据库会保存下来(SQLite数据库后端会保存为test_开头的文件),方便查看运行结果

接口和工具

请求客户端

模拟浏览器发起请求,跟用requests模块类似,用来测试RestfulAPI很方便

from django.test import Client
c = Client()
response = c.post('/login/', {'username': 'john', 'password': 'smith'})
response.status_code # 200
response = c.get('/customer/details/')
response.content # b'<!DOCTYPE html...'

参数

  • enforce_csrf_checks:启用csrf检查
  • HTTP_USER_AGENT:指定UA
  • 包括其他的HTTP_HEADERS都可以传递到Client

接口

  • .get(path, data=None, follow=False, secure=False, **extra)
  • .post(path, data=None, content_type=MULTIPART_CONTENT, follow=False, secure=False, **extra)
  • .head(path, data=None, follow=False, secure=False, **extra)
  • .options(path, data='', content_type='application/octet-stream', follow=False, secure=False, **extra)
  • .put(path, data='', content_type='application/octet-stream', follow=False, secure=False, **extra)
  • .patch(path, data='', content_type='application/octet-stream', follow=False, secure=False, **extra)
  • .delete(path, data='', content_type='application/octet-stream', follow=False, secure=False, **extra)
  • .trace(path, follow=False, secure=False, **extra)
  • .login(**credentials)
  • .force_login(user, backend=None)
  • .logout()

get

  • data:传递一个字典,会将请求URL拼接成 ?a=1&b=2 这种类型
  • follow:跟踪重定向返回,此时返回值response会带有一个属性redirect_chain,这个属性可以看到所有重定向中经过的URL
  • secure:设置成True时会模拟成一个HTTPS请求

post

  • data:请求的数据,可以传递文件对象
  • content_type:这个值会设置Content-Type请求头,具体可以设置的值参考Content-Type

trace

用于模拟浏览器的diagnostic探针(Chrome浏览器会有这个请求,跟OPTIONS请求一起的)

login

用于登录,登录之后client会带有cookies和session,之后可以用于测试所有需要登录的视图,要想提高这个接口的速度可以添加配置项:

PASSWORD_HASHERS = [
'django.contrib.auth.hashers.MD5PasswordHasher',
]

force_login

也是用于登录,而且is_active=False的用户也可以登录,甚至不需要提供凭证也能登录。
在不做其他任何设置的话,这个接口速度也会比login快(因为密码哈希算法是很耗时的)

持久化 属性

  • Client.cookies
  • Client.session

注意事项

  • 不需要运行服务器也可以工作
  • 请求路径不需要包含全路径,只需要url路由就行

请求响应Response

响应对象来源于上一节中的请求方法

属性

  • client:请求的客户端,来源于上一节中的客户端对象
  • content:返回的内容,是一个bytes类型
  • context:渲染模板时传入的context值,是用于测试模板是否正确渲染的最重要属性。如果用的是其他的模板引擎,就要用context_data来访问
  • request:触发响应的请求对象,跟wsgi_request的不同是这个参数是一个字典,只包含一些基本的请求数据,比如端口、请求方法等
  • wsgi_request:生成响应的测试处理程序生成的WSGIRequest实例。(跟views.py里面定义的那些视图函数的request参数是一样的)
  • status_code:状态码
  • templates:响应所使用的模板对象,可以用.name获取模板文件名(路径)
  • resolver_match:响应所使用的视图函数,可以用.func获取视图函数,然后可以用self.assertEqual(response.resolver_match.func, my_view)来进行比对

方法

  • .json(**kwargs):返回json格式

注意事项

  • 响应对象还可以像字典那样取值

测试用例类

SimpleTestCase

一个非常简单的测试用例类,是unittest.TestCase的一个子类。
如果你要做很多的数据库查询操作,就不要用这个类了,改为用 TransactionTestCase 或 TestCase(django的TestCase)

属性

  • allow_database_queries:是否允许数据库查询操作,默认是False。防止其他SimpleTestCase运行数据库查询的时候不在一个事务里面,可能是防冲突

注意事项

  • 要设置类变量,要用setUpClass和tearDownClass方法

TransactionTestCase

继承自SimpleTestCase,多了一些数据库的操作。在每次测试开始时将数据库重置为已知状态,以便于测试和使用ORM。

TestCase

最常用的类,继承自TransactionTestCase,如果你的Django项目没有用到数据库,用SimpleTestCase会好很多。
里面的测试会封装在两个atomic()块中(一个便捷的在事务里面操作数据库的方法),一个包在TestCase外面,一个包在所有的test方法外。
如果要测试某些特定的数据库事务行为,请使用TransactionTestCase。

方法

  • setUpTestData:这个方法专门用于往数据库插入数据(创建模型数据),用这个方法会比用setUp快

注意事项

  • 如果测试在没有事务支持的数据库上运行(例如,使用MyISAM引擎的MySQL),则在每次测试之前都会调用setUpTestData(),从而抵消了速度优势。
  • 不要在测试方法中修改setUpTestData()中创建的任何对象。在类中完成设置工作后,再对内存对象的修改将会在其他测试方法之间持续。如果确实需要修改它们,可以在setUp中使用refresh_from_db()重新加载它们。(暂时没用过,不确保解释准确性,只做简单翻译)

LiveServerTestCase

这是一个比较特殊的类,他会在setUp的时候把服务器启动,tearDown的时候关闭服务器,从名字上也可以看出来,这个类下面的测试都是跟服务相关联的,用这个类可以使得Django的虚拟客户端(上面说的那个)或者Selenium客户端测试起来更加的快捷方便,这个类可以用在功能测试上(官方也是说很适合用在功能测试上)

属性

  • live_server_url:服务器的监听地址,IP都是localhost,端口会用一个可以使用的端口

官方示例

from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from selenium.webdriver.firefox.webdriver import WebDriver

class MySeleniumTests(StaticLiveServerTestCase):
fixtures = ['user-data.json']

@classmethod
def setUpClass(cls):
super(MySeleniumTests, cls).setUpClass()
cls.selenium = WebDriver()
cls.selenium.implicitly_wait(10)

@classmethod
def tearDownClass(cls):
cls.selenium.quit()
super(MySeleniumTests, cls).tearDownClass()

def test_login(self):
self.selenium.get('%s%s' % (self.live_server_url, '/login/'))
username_input = self.selenium.find_element_by_name("username")
username_input.send_keys('myuser')
password_input = self.selenium.find_element_by_name("password")
password_input.send_keys('secret')
self.selenium.find_element_by_xpath('//input[@value="Log in"]').click()

注意事项

  • 尽量避免在多个测试中同时操作数据库(在并行测试的模式下)

特殊事项

client对象自动初始化

任何的测试用例类都会自带一个self.client属性,所以你不需要每次都自己定义一个client,除非你要在一个测试里面用多个客户端来进行测试。并且每个测试的client对象是互不干扰的,不用担心session和cookie会影响到别的测试。

自定义client对象

你可以定义一个继承自Client类的子类,然后在你的TestCase下设置一个类变量client_class赋值为你的自定义Client类

预加载数据

可以设置TransactionTestCase类的一个类变量值fixtures,这个值是一个列表类型,里面的元素可以是一个文件名,或者是一个含有fixtures的Django APP。
这个功能暂时不做过多解释,知道有什么用就行。

在不同的跟路由配置下运行

你的程序(或者说项目)如果要给其他人用,或者允许别人部署的时候可以做很多的自定义配置,那你就不能只单纯地认为你的测试或者说你的项目在你自己配置好的路由下能正常运行就算是验收通过了,你还要去确认在别的路由配置下,仍能正常访问和使用各个功能,所以做其他的路由测试非常重要。一般如果又要在自己的配置下能正常运行,又要在自定义配置下运行,那你的测试只需要测试自定义配置下的情况就可以了。自定义配置的话,能配多奇葩就多奇葩,因为你无法预测用户的行为,你只能去预防最坏的情况。

要设置其他的路由(或者其他配置项也可以,修改参数就行),就要在你的TestCase或者其下的测试方法用一个装饰器@override_settings(ROOT_URLCONF=...)

多数据库

如果你的项目用到了多个数据库,就要设置一个类变量multi_db = True,他会在每次测试之前清空所有数据库。如果为False,则整个测试流程只会用到settings.py里面的default数据库。

覆盖配置项

不要直接修改django.conf.settings里面的配置项,否则你剩下的测试都会使用新的配置项。

代码示例:

with self.settings(LOGIN_URL='/other/login/'):
response = self.client.get('/sekrit/')
self.assertRedirects(response, '/other/login/?next=/sekrit/')

或者仅添加(后添加、前添加)、修改某个值(对于列表类型的配置项有用),可以使用另一个方法(注意里面的key的含义):

with self.modify_settings(MIDDLEWARE={
'append': 'django.middleware.cache.FetchFromCacheMiddleware',
'prepend': 'django.middleware.cache.UpdateCacheMiddleware',
'remove': [
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
],
}):
response = self.client.get('/')

还有如前面所说的@override_settings装饰器,前面已做过介绍,这里不再赘述。
另外还有一个跟modify_settings一样用法的装饰器@modify_settings,就是对整个test或TestCase都进行修改的方法。

override_settings要在modify_settings之前。

重写设置时,请确保正确处理应用程序代码使用缓存或类似功能的情况,即使更改了设置,该功能也会保留状态。Django提供django.test.signals.setting_changed信号,允许您注册回调以在设置更改时清除或重置状态。
Django本身使用此信号重置各种数据:

Overridden settings Data reset
TEMPLATES Template engines
SERIALIZATION_MODULES Serializers cache
LOCALE_PATHS, LANGUAGE_CODE Default translation and loaded translations
MEDIA_ROOT, DEFAULT_FILE_STORAGE Default file storage

邮件

如果你用的是Django的TestCase类(包括子类),那所有类运行前都会清空测试邮件的outbox

Assertions断言

除了Python标准库unittest里面的那些断言之外,Django还提供了很多特有的断言方法。

SimpleTestCase(及其子类)方法:

  • .assertRaisesMessage(expected_exception, expected_message, callable, *args, **kwargs)
  • .assertFieldOutput(fieldclass, valid, invalid, field_args=None, field_kwargs=None, empty_value='')
  • .assertFormError(response, form, field, errors, msg_prefix='')
  • .assertFormsetError(response, formset, form_index, field, errors, msg_prefix='')
  • .assertContains(response, text, count=None, status_code=200, msg_prefix='', html=False)
  • .assertNotContains(response, text, status_code=200, msg_prefix='', html=False)
  • .assertTemplateUsed(response, template_name, msg_prefix='', count=None)
  • .assertTemplateNotUsed(response, template_name, msg_prefix='')
  • .assertRedirects(response, expected_url, status_code=302, target_status_code=200, msg_prefix='', fetch_redirect_response=True)
  • .assertHTMLEqual(html1, html2, msg=None)
  • .assertHTMLNotEqual(html1, html2, msg=None)
  • .assertXMLEqual(xml1, xml2, msg=None)
  • .assertXMLNotEqual(xml1, xml2, msg=None)
  • .assertInHTML(needle, haystack, count=None, msg_prefix='')
  • .assertJSONEqual(raw, expected_data, msg=None)
  • .assertJSONNotEqual(raw, expected_data, msg=None)

TransactionTestCase(及其子类)方法:

  • .assertQuerysetEqual(qs, values, transform=repr, ordered=True, msg=None)
  • .assertNumQueries(num, func, *args, **kwargs)

assertRaisesMessage

调用callable并且匹配exception类型和exception的message,如果匹配不到就是fail。
这个方法是unittest.TestCase.assertRaisesRegex()的简单实现,这个方法的expected_message参数不是一个正则表达式。
可以不传callable参数,这时候他就变成一个上下文管理器,示例:

with self.assertRaisesMessage(ValueError, 'invalid literal for int()'):
int('a')

assertFieldOutput

判断表单的输入是否正确。

参数:

  • fieldclass:需要测试的字段类
  • valid:期望的字段值,字典类型
  • invalid:当数据不正确时的提示信息,字典类型
  • field_args:实例化字段类时要传递的参数,根据你要测试的字段类填写
  • field_kwargs:跟field_args相同作用,传递关键字参数
  • empty_value:当字段为空时期望得到的信息

示例代码:

self.assertFieldOutput(EmailField, {'a@a.com': 'a@a.com'}, {'aaa': ['Enter a valid email address.']})

assertFormError

判断表单的某个字段是不是会报errors(列表)参数种的错误。

参数:

  • form:Form对象
  • field:字段的name,就是定义字段时的变量名,如果field有一个值是None的话,就会去检查non-field错误,你可以通过form.non_field_errors()获取那些non-field错误
  • errors:错误信息,是一个字符串类型,或者包含你想要测试的所有错误信息的列表

assertFormsetError

assertFormError差不多,不过是对FormSet对象的一个断言。

参数:

  • form_index:FormSet中的表单索引,如果为None,就会去检查non-form错误,你可以通过formset.non_form_errors()获取那些non-form错误
  • field:与assertFormError一样
  • errors:与assertFormError一样

assertContains

用来判断Response对象的状态码和是否包含text参数的内容。

参数:

  • text:预期包含的内容
  • count:如果不为None,则表示预期会包含多少个text的内容
  • html:如果为True,代表对比的内容为HTML格式字符串,在大多数情况下,空格被忽略,属性排序不重要

assertNotContains

作用跟assertContains相反。除了没有count参数之外,其他的参数含义跟assertContains一样。

assertTemplateUsed

判断response所使用的模板是不是预期中的模板。
count参数是一个整数,表示应该呈现模板的次数。默认值为None,这意味着模板应渲染一次或多次。

也可以用上下文管理器的方式调用,如:

with self.assertTemplateUsed('index.html'):
render_to_string('index.html')
with self.assertTemplateUsed(template_name='index.html'):
render_to_string('index.html')

assertTemplateNotUsed

与assertTemplateUsed的作用相反。

assertRedirects

用于判断response是否如预期一样进行了重定向。
如果你的请求使用了follow参数,则expect_urltarget_status_code会是最终重定向到的url和状态码。

参数:

  • expected_url:期望跳转到的目标地址
  • status_code:期望访问原始url时返回的状态码,默认是302
  • target_status_code:期望跳转后的url返回的状态码,默认是200
  • fetch_redirect_response:如果为False,则不会加载最终页面。由于测试客户端无法获取外部url,如果expected_url不是Django应用程序的一部分,这个就很有用。

assertHTMLEqual

判断两个html文本是否一样,有一些注意事项:

  • HTML的节点tag前后的空格和换行会被忽略
  • 所有类型的空白符都是一样的,比如tab和空格
  • 所有的标签都是隐式闭合的
  • HTML节点的属性排序不重要
  • 没有值的属性,他的值等于他的名字

以下的例子是正确的例子,不会触发AssertionError:

self.assertHTMLEqual(
'<p>Hello <b>world!</p>',
'''<p>
Hello <b>world! <b/>
</p>'''
)
self.assertHTMLEqual(
'<input type="checkbox" checked="checked" id="id_accept_terms" />',
'<input id="id_accept_terms" type="checkbox" checked>'
)

传递的两个参数都必须是有效的HTML格式字符串,否则会触发AssertionError

assertHTMLNotEqual

跟assertHTMLEqual作用相反。

assertQuerysetEqual

判断QuerySet是不是返回了特定的列表

参数:

  • transform:用于转换qs和期望值的函数,默认是repr,就变成了字符串对比
  • ordered:默认是True, 如果qs的排序没定义,且这个值为False,且比较的是多个值,则会抛出ValueError错误

assertNumQueries

声明当用*args和**kwargs调用func时,将执行num个数据库查询。

如果kwargs中存在一个“using”键,它将用作检查查询数的数据库别名。如果希望使用using参数调用函数,可以通过使用lambda包装调用来添加额外的参数:

self.assertNumQueries(7, lambda: my_function(using=7))

也可以用上下文管理器

with self.assertNumQueries(2):
Person.objects.create(name="Aaron")
Person.objects.create(name="Daniel")

注意事项

  • 大部分断言方法可以用msg_prefix参数指定断言失败时的提示消息前缀

给测试方法打标签

没什么特别需要说明的地方,直接上例子:

from django.test import tag

class SampleTestCase(TestCase):

@tag('fast')
def test_fast(self):
...

@tag('slow')
def test_slow(self):
...

@tag('slow', 'core')
def test_slow_but_core(self):

@tag('slow', 'core')
class SampleTestCase(TestCase):
pass

然后可以在命令行运行测试的时候传递参数筛选需要测试的那些测试方法:

# 只运行fast标签的测试
python manage.py test --tag=fast
# 运行fast和core测试
python manage.py test --tag=fast --tag=core
# 排除slow测试
python manage.py test --tag=core --exclude-tag=slow

exclude-tag比tag的优先级要高,所以如果你的某个测试含有了tag的标签,并且也包含了exclude-tag的标签,那这个测试就不会运行。

邮件发送测试

测试的时候肯定不会想要每次运行测试都发一封真实的邮件,所以Django的邮件测试会运行在一个虚拟的outbox。
在测试运行期间,每个传出的电子邮件都保存在django.core.mail.outbox中。这是已发送的所有EmailMessage实例的简单列表。outbox是一个特殊的属性,仅在使用locmem邮件后端时才创建。它通常不作为django.core.mail模块的一部分存在,您不能直接导入它。下面的代码显示了如何正确访问此属性:

from django.core import mail
from django.test import TestCase

# 每个TestCase运行前都会清空outbox,也可以像下面的代码一样手动清空
mail.outbox = []

class EmailTest(TestCase):
def test_send_email(self):
# Send message.
mail.send_mail(
'Subject here', 'Here is the message.',
'from@example.com', ['to@example.com'],
fail_silently=False,
)

# Test that one message has been sent.
self.assertEqual(len(mail.outbox), 1)

# Verify that the subject of the first message is correct.
self.assertEqual(mail.outbox[0].subject, 'Subject here')

服务器命令行管理命令

就是运行python manage.py的那些命令,官方文档也没有过多介绍,直接看代码示例:

from django.core.management import call_command
from django.test import TestCase
from django.utils.six import StringIO

class ClosepollTest(TestCase):
def test_command_output(self):
out = StringIO()
call_command('closepoll', stdout=out)
self.assertIn('Expected output', out.getvalue())

跳过部分测试

当你提前知道你的某些测试会测试失败的时候你就可以用这个功能。比如你的某个测试需要特定的库才能正常运行,这时你就可以用@skipIf装饰器装饰你的测试,并且测试的时候会报告给你哪个测试被跳过了还有跳过的原因。

除了unittest内置的一些跳过装饰器(.skipIf.skipUnless)之外,Django还内置了其他的装饰器,这些装饰器不是用来检测布尔值,而是用来检测数据库功能的,如果测试的数据库不支持你指定的功能,那就会跳过测试。要想查看数据库的所有特性(功能),可以去看一下django.db.backends.BaseDatabaseFeatures这个类

示例代码:

class MyTests(TestCase):
@skipIfDBFeature('supports_transactions')
def test_transaction_behavior(self):
# ... conditional test code
pass

class MyTests(TestCase):
@skipUnlessDBFeature('supports_transactions')
def test_transaction_behavior(self):
# ... conditional test code
pass

其他

这篇文档中可能有一些接口或者方法没有介绍到,常用或者有用的都介绍到了,剩下一些适用面较少、不常见的内容,需要移步到官方文档查看其介绍:Django Testing环境变量了。