测试
对于现代Web开发人员来说,自动化测试是一种非常有用的消除bug的方法。您可以使用一组测试 - 测试套件 - 来解决或避免许多问题:
- 在编写新代码时,可以使用测试来验证代码是否按预期工作。
- 当您重构或修改旧代码时,可以使用测试来确保您的更改不会意外地影响应用程序的运行。
测试Web应用程序是一项复杂的任务,因为Web应用程序由多层逻辑组成 :从HTTP级请求处理到表单验证和处理,再到模板渲染。使用Django的测试执行框架和各种实用程序,您可以模拟请求,插入测试数据,检查应用程序的输出,并通常验证您的代码正在执行它应该做的事情。
在Django中编写测试的首选方法是使用Python标准库中内置的unittest
模块。您还可以使用任何其他Python测试框架; Django为这种集成提供了API和工具。
编写和运行测试
Django的单元测试使用Python标准库模块:unittest
。 该模块使用基于类的方法定义测试。在tests.py
文件中编写测试文件:
from django.test import TestCase
from learning_logs.models import Topic, Post, User
import time
# Create your tests here.
class TopicTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username='TestUser', email='[email protected]', password='password')
Topic.objects.create(owner=self.user, text='topic test 1')
Topic.objects.create(owner=self.user, text='topic test 2')
def test_topic_user(self):
topic_1 = Topic.objects.get(text='topic test 1')
topic_2 = Topic.objects.get(text='topic test 2')
self.assertEqual(topic_1.owner, self.user)
self.assertEqual(topic_2.owner, self.user)
Django在执行setUp()
部分操作时,并不会真正向数据库表中插入数据。所以不用关心testDown()
清理工作。
使用python manage.py test
运行测试:
(venv) ulysses@ulysses:~/PycharmProjects/django_ulysses$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.364s
OK
Destroying test database for alias 'default'...
Django会使用内置的测试用例检测装置来检测基于unittest的测试用例。默认情况下,这将在当前工作目录下的任何名为test*.py
的文件中发现测试。从输出结果看,当运行测试时会创建一个测试用的数据库,之后Django会运行这些测试。如果测试通过就会出现上面显示的ok
信息,一旦测试出现问题就会提示详细的错误信息,如:
FAIL: test_was_published_recently_with_future_poll (polls.tests.PollMethodTests)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/dev/mysite/polls/tests.py", line 16, in test_was_published_recently_with_future_poll
self.assertIs(future_poll.was_published_recently(), False)
AssertionError: True is not False
----------------------------------------------------------------------
Ran 1 test in 0.003s
FAILED (failures=1)
可以通过向./manage.py测试提供任意数量的“测试标签”来指定要运行的特定测试。每个测试标签都可以是包、模块、TestCase子类或测试方法的完整Python路径。例如:
# Run all the tests in the animals.tests module
$ ./manage.py test animals.tests
# Run all the tests found within the 'animals' package
$ ./manage.py test animals
# Run just one test case
$ ./manage.py test animals.tests.AnimalTestCase
# Run just one test method
$ ./manage.py test animals.tests.AnimalTestCase.test_animals_can_speak
需要数据库支持的测试(即模型测试)时,不会使用真实的数据库,Django 会为测试创建单独的空白数据库。无论测试是通过还是失败,测试数据库都会在执行完所有测试后被销毁
。可以使用test --keepdb
选项阻止测试数据库被销毁。这将在运行之间保留测试数据库。如果数据库不存在,将首先创建它。还将应用任何迁移以使其保持最新。
在进行测试时,Django会无视DEBUG
的设置,所有测试都会在DEBUG=False
的环境下运行(除非设置--debug-mode
)。每次测试后都不会清除缓存,如果在生产中运行测试,运行manage.py test fooapp
可以将数据从测试插入到实时系统的缓存中。
test命令
django-admin test [test_label [test_label …]] ,运行所有应用下的单元测试
-
--failfast
: 在测试失败后立即停止运行测试并报告失败; -
--testrunner TESTRUNNER
:控制用于执行测试的测试运行器类,使用TEST_RUNNER
设置提供的值。 -
--noinput, --no-input
:禁止所有用户提示。 典型的提示是关于删除现有测试数据库的警告。 -
--keepdb, -k
:在测试运行之间保留测试数据库。 这样做的优点是可以跳过create和destroy操作,这可以大大减少运行测试的时间,特别是在大型测试套件中。 如果测试数据库不存在,它将在第一次运行时创建,然后为每次后续运行保留。 在运行测试套件之前,任何未应用的迁移也将应用于测试数据库。 -
--reverse, -r
: 按相反的执行顺序
对测试用例进行排序。 这可能有助于调试未正确隔离的测试的副作用。 使用此选项时,将保留按测试类分组。 -
--debug-mode
:设置DEBUG=True
, 这可能有助于解决测试失败问题; -
--debug-sql, -d
:为失败的测试启用SQL日志记录。 如果–verbosity为2,则还会输出传递测试中的查询; -
--parallel [N]
: 在单独的并行进程中运行测试。 由于现代处理器具有多个内核,因此可以更快地运行测试。默认情况下–parallel根据multiprocessing.cpu_count()为每个核心运行一个进程。 您可以通过将其作为选项的值来调整进程数,例如, --parallel=4,或者通过设置DJANGO_TEST_PROCESSES
环境变量。 -
--tag TAGS
仅运行标有指定标签的测试。 可以多次指定并与test --exclude-tag结合使用。 -
exclude-tag EXCLUDE_TAGS
排除使用指定标记标记的测试。 可以多次指定并与test --tag结合使用。
测试工具
Django提供了一小组在编写测试时派上用场的工具。
The test client
测试客户端是一个Python类,用它来充当虚拟Web浏览器,可以以编程方式测试视图并与Django的应用程序进行交互。
测试客户端的功能:
- 以URL的形式模拟
POST
和GET
请求,从HTTP的首部状态码到页面的所有内容; - 查看重定向链(如果有)并检查每个步骤的URL和状态代码;
- 使用包含特定值的模板上下文来渲染指定的Django模板;
总结来说就是,使用Django的测试客户端来确定正在呈现正确的模板,并且模板传递正确的上下文数据。一个简单的例子,在test_client.py中测试GET
中文和英文的首页:
from django.test import TestCase, Client
class ClientTestCase(TestCase):
def setUp(self):
self.client = Client()
def test_home_page_en(self):
response = self.client.get('/en/')
self.assertEqual(response.status_code, 200)
self.assertTrue('Weicome to my site' in response.content.decode('utf-8'))
def test_home_page_zh(self):
response = self.client.get('/zh-hans/')
self.assertEqual(response.status_code, 200)
self.assertTrue('欢迎来到我的网站' in response.content.decode('utf-8'))
运行测试:
(venv) ulysses@ulysses:~/PycharmProjects/django_ulysses$ python manage.py test learning_logs.test_client
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.034s
OK
Destroying test database for alias 'default'...
测试用户登录,和创建新topic,使用POST
请求:
def setUp(self):
User.objects.create_user('Admin', '[email protected]', 'admin12345')
self.login = {'username': 'Admin', 'password': 'admin12345'}
self.client = Client()
def test_login(self):
"""测试登录页面"""
response = self.client.post('/en/users/login/', **self.login)
self.assertEqual(response.status_code, 200)
# self.assertTrue(self.client.login(**self.login))
def test_post_topic(self):
"""登录后,创建topic再读取"""
self.client.login(**self.login)
self.client.post('/en/new_topic/', {'text': '红尘多可笑,痴情最无聊'})
topic = Topic.objects.get(text='红尘多可笑,痴情最无聊')
self.assertIsNotNone(topic)
response = self.client.get(f'/en/topic/{topic.id}/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['topic'], topic)
有关test client可以参考官方文档:https://docs.djangoproject.com/en/2.1/topics/testing/tools/#the-test-client
LiveServerTestCase
LiveServerTestCase与TransactionTestCase基本相同,只有一个额外功能:它在安装时在后台启动一个实时Django服务器,并在拆卸时将其关闭。 这允许使用除Django虚拟客户端之外的自动测试客户端(例如Selenium客户端)在浏览器内执行一系列功能测试并模拟真实用户的操作。
实时服务器侦听localhost并绑定到端口0,端口0使用操作系统分配的空闲端口。 在测试期间,可以使用self.live_server_url
访问服务器的URL。首先安装selenium:
pip install selenium
在test_liveserver.py中添加一个使用Selenium客户端的测试:
from django.test import LiveServerTestCase
from selenium.webdriver import Chrome, ChromeOptions
import re
class MySeleniumTests(LiveServerTestCase):
""" 定位UI元素
ID = "id"
XPATH = "xpath"
LINK_TEXT = "link text"
PARTIAL_LINK_TEXT = "partial link text"
NAME = "name"
TAG_NAME = "tag name"
CLASS_NAME = "class name"
CSS_SELECTOR = "css selector"
"""
# host = 'localhost'
# port = 0
@classmethod
def setUpClass(cls):
super().setUpClass()
options = ChromeOptions()
options.add_argument('--headless')
cls.selenium = Chrome(options)
cls.selenium.implicitly_wait(10)
@classmethod
def tearDownClass(cls):
cls.selenium.quit()
super().tearDownClass()
def test_home_page(self):
"""
测试打开主页
cls.live_server_url: 'http://%s:%s' % (cls.host, cls.server_thread.port)
"""
self.selenium.get(f"{self.live_server_url}/en/")
self.assertTrue(re.search('Weicome\s+to\s+my\s+site', self.selenium.page_source))
使用--headless
参数后,会打开一个无界面的浏览器。
测试登录,使用find_element_by_[]
来找寻网页上的可交互元素,使用click()
模拟显示的鼠标的点击:
def test_login(self):
timeout = 5
self.selenium.get(f"{self.live_server_url}/en/")
self.selenium.find_element_by_link_text('Login').click()
WebDriverWait(self.selenium, timeout).until(
lambda driver: driver.find_element_by_name('username'))
self.selenium.find_element_by_name('username').clear()
self.selenium.find_element_by_name('username').send_keys('Admin')
self.selenium.find_element_by_name('password').clear()
self.selenium.find_element_by_name('password').send_keys('admin12345')
self.selenium.find_element_by_name('submit').click()
# time.sleep(2)
element = WebDriverWait(self.selenium, timeout).until(
lambda driver: driver.find_element_by_id('navbarDropdown'))
self.assertTrue(re.search('Weicome\s+to\s+my\s+site', self.selenium.page_source))
# self.selenium.find_element_by_id('navbarDropdown').click()
element.click()
# time.sleep(2)
element = WebDriverWait(self.selenium, timeout).until(
lambda driver: driver.find_element_by_name('profile'))
element.click()
# self.selenium.find_element_by_name('profile').click()
self.assertTrue(re.search('<h2>Admin</h2>', self.selenium.page_source))
在切换页面时,使用WebDriverWait
检查是否加载了新的一页。
测试覆盖率
码覆盖率描述了已测试的源代码量。 它显示了代码的哪些部分是通过测试运行的,哪些不是。它是测试应用程序的重要部分。Django可以轻松地coverage.py
集成,后者是一种用于测量Python程序代码覆盖率的工具。 首先,安装 coverage: pip install coverage
。接下来,从包含manage.py的项目文件夹中运行以下命令:coverage run --source='.' manage.py test myapp
这将运行测试并收集项目中已执行文件的覆盖率数据。
可以通过键入以下命令来查看此数据的报告:coverage report
.
(venv) ulysses@ulysses:~/PycharmProjects/django_ulysses$ coverage run --source='.' manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
----------------------------------------------------------------------
Ran 0 tests in 0.000s
OK
Destroying test database for alias 'default'...
(venv) ulysses@ulysses:~/PycharmProjects/django_ulysses$ coverage report
Name Stmts Miss Cover
---------------------------------------------------------------------------------------------------------------------------------
django_ulysses/__init__.py 2 0 100%
django_ulysses/database_router.py 0 0 100%
django_ulysses/settings.py 62 0 100%
django_ulysses/sitemaps.py 21 6 71%
使用coverage report -m
查看miss
的具体行号:
(venv) ulysses@ulysses:~/PycharmProjects/django_ulysses$ coverage report -m
Name Stmts Miss Cover Missing
-------------------------------------------------------------------------------------------------------------------------------------------
django_ulysses/__init__.py 2 0 100%
django_ulysses/database_router.py 0 0 100%
django_ulysses/settings.py 62 0 100%
django_ulysses/sitemaps.py 21 6 71% 13, 16, 21, 29, 32, 35
django_ulysses/urls.py 11 0 100%
django_ulysses/wsgi.py 8 8 0% 15-30
learning_logs/__init__.py 0 0 100%
使用coverage html
来生成完整的网页形式的报告:
测试用例的特征
夹具装载
如果数据库中没有任何数据,则基于数据库网站的测试用例没有多大用处。 在TestCase.setUpTestData()
使用ORM方法添加数据,使测试更具可读性,也可以选择使用fixture添加数据。
一个fixture就是导入Django数据库的数据集合。假如在项目指定的fixture文件目录settings.FIXTURE_DIRS
下已经有了数据,就可以指定一个fixtures
类属性来使用它:
from django.test import TestCase
from myapp.models import Animal
class AnimalTestCase(TestCase):
fixtures = ['mammals.json', 'birds']
def setUp(self):
# Test definitions as before.
call_setup_methods()
def test_fluffy_animals(self):
# A test that uses the fixtures.
call_some_test_code()
多数据库测试支持
在进行测试时,Django设置一个测试数据库,该数据库对应于settings
中DATABASES定义中定义的每个数据库。 但是,运行Django TestCase所花费的大部分时间都被刷新调用所消耗,这确保了在每次测试运行开始时都有一个干净的数据库。 如果有多个数据库,则需要多次刷新(每个数据库一次)。这可能是一项耗时的活动 ,特别是如果测试不需要测试多数据库活动。
作为优化,Django仅在每次测试运行开始时刷新default
默认数据库。 如果您的设置包含多个数据库,并且您的测试要求每个数据库都是干净的,则可以使用测试套件上的multi_db
属性来请求完全刷新。
class TestMyViews(TestCase):
multi_db = True
def test_index_page_view(self):
call_some_test_code()
在运行TestMyViews下的用例时,所有的测试数据库都会被刷新。multi_db标志还会影响TransactionTestCase.fixtures
加载到哪些数据库。 默认情况下(当multi_db = False
时),夹具仅加载到默认
数据库中。 如果multi_db = True
,则会将夹具加载到所有
数据库中。
重载系统设置
使用SimpleTestCase.settings()
临时更改某一设置并在运行测试代码后可以恢复为原始值。 针对这点,Django提供了一个标准的Python上下文管理器,可以像这样使用:
from django.test import TestCase
class LoginTestCase(TestCase):
def test_login(self):
# First check for the default behavior
response = self.client.get('/sekrit/')
self.assertRedirects(response, '/accounts/login/?next=/sekrit/')
# Then override the LOGIN_URL setting
with self.settings(LOGIN_URL='/other/login/'):
response = self.client.get('/sekrit/')
self.assertRedirects(response, '/other/login/?next=/sekrit/')
例子中在with语句模块中重写了LOGIN_URL
,在这之外LOGIN_URL
就会回复原值。
使用SimpleTestCase.modify_settings()
可以修改列表
内的系统设置:
from django.test import TestCase
class MiddlewareTestCase(TestCase):
def test_cache_middleware(self):
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()
装饰器:
rom django.test import TestCase, override_settings
@override_settings(LOGIN_URL='/other/login/')
class LoginTestCase(TestCase):
def test_login(self):
response = self.client.get('/sekrit/')
self.assertRedirects(response, '/other/login/?next=/sekrit/')
断言
python内置的测试模块unittest.TestCase
提供了诸如assertTrue
、assertEqual
、assertIsNotNone
之类的断言,Django的TestCase
提供了除这些之外的断言。大多数这些断言方法给出的失败消息可以使用msg_prefix
参数进行自定义。 该字符串将以断言生成的任何失败消息为前缀。 这使您可以提供其他详细信息,以帮助您确定测试套件中故障的位置和原因,可以参考Assertions
添加测试标签
使用@tag()
装饰器可以给测试添加标签来选择要进行那些测试。
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):
...
在运行测试用例时,可以使用./manage.py test --tag=fast
来选择。
邮件服务
如果您的任何Django视图使用Django的电子邮件功能发送电子邮件,您可能不希望每次使用该视图运行测试时都发送电子邮件。出于这个原因,Django的测试运行器会自动将所有Django发送的电子邮件重定向到虚拟发件箱。这使您可以测试发送电子邮件的各个方面 ,从发送到每封邮件内容到邮件数量 ,而无需实际发送邮件。
进行测试时,会使用django.core.mail.outbox
来作为进行邮件接受方,而它也只会在使用locmem
邮件后端时被调用。使用案例,检查django.core.mail.outbox的长度和内容:
from django.core import mail
from django.test import TestCase
class EmailTest(TestCase):
def test_send_email(self):
# Send message.
mail.send_mail(
'Subject here', 'Here is the message.',
'[email protected]', ['[email protected]'],
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')
测试用的mail.outbox
在每次开始运行TestCase
时,都会被清空。可以使用mail.outbox = []
手动清空。
管理命令测试
可以使用call_command()
函数测试管理命令。输出可以重定向到StringIO:
from io import StringIO
from django.core.management import call_command
from django.test import TestCase
class ClosepollTest(TestCase):
def test_command_output(self):
out = StringIO()
call_command('closepoll', stdout=out)
self.assertIn('Expected output', out.getvalue())