21.3 调整功能测试,以便通过POP3测试真实的电子邮件

啊,确实应该如此。功能测试现在在真实的服务器中运行,而不是使用 LiveServerTestCase,因此我们不能再检查本地的 django.mail.outbox 中有没有发出的邮件了。

首先,在功能测试中要想办法判断是不是运行在过渡服务器中。在 base.py 中,把 staging_server 变量绑定到 self 上:

functional_tests/base.py (ch18l009)

  1. def setUp(self):
  2. self.browser = webdriver.Firefox()
  3. self.staging_server = os.environ.get('STAGING_SERVER')
  4. if self.staging_server:
  5. self.live_server_url = 'http://' + self.staging_server

然后,构建一个辅助函数,使用 Python 标准库中极其难用的 POP3 客户端从真实的 POP3 电子邮件服务器中获取真实的电子邮件:

functional_tests/test_login.py (ch18l010)

  1. import os
  2. import poplib
  3. import re
  4. import time
  5. [...]
  6. def wait_for_email(self, test_email, subject):
  7. if not self.staging_server:
  8. email = mail.outbox[0]
  9. self.assertIn(test_email, email.to)
  10. self.assertEqual(email.subject, subject)
  11. return email.body
  12. email_id = None
  13. start = time.time()
  14. inbox = poplib.POP3_SSL('pop.mail.yahoo.com')
  15. try:
  16. inbox.user(test_email)
  17. inbox.pass_(os.environ['YAHOO_PASSWORD'])
  18. while time.time() - start < 60:
  19. # 获取最新的10封邮件
  20. count, = inbox.stat()
  21. for i in reversed(range(max(1, count - 10), count + 1)):
  22. print('getting msg', i)
  23. , lines, __ = inbox.retr(i)
  24. lines = [l.decode('utf8') for l in lines]
  25. print(lines)
  26. if f'Subject: {subject}' in lines:
  27. email_id = i
  28. body = '\n'.join(lines)
  29. return body
  30. time.sleep(5)
  31. finally:
  32. if email_id:
  33. inbox.dele(email_id)
  34. inbox.quit()

06 - 图1 测试时我使用的是 Yahoo 账户,你可以使用任何电子邮件服务,只要支持通过 POP3 访问即可。你要在运行功能测试的控制台中设定 YAHOO_PASSWORD 环境变量。

接下来调整功能测试中其他需要改动的地方。首先,针对本地环境和过渡服务器为 test_email 变量设定不同的值:

functional_tests/test_login.py (ch18l011-1)

  1. @@ -7,7 +7,7 @@ from selenium.webdriver.common.keys import Keys
  2. from .base import FunctionalTest
  3. -TEST_EMAIL = 'edith@example.com'
  4. +
  5. SUBJECT = 'Your login link for Superlists'
  6. @@ -33,7 +33,6 @@ class LoginTest(FunctionalTest):
  7. print('getting msg', i)
  8. , lines, = inbox.retr(i)
  9. lines = [l.decode('utf8') for l in lines]
  10. - print(lines)
  11. if f'Subject: {subject}' in lines:
  12. emailid = i
  13. body = '\n'.join(lines)
  14. @@ -49,6 +48,11 @@ class LoginTest(FunctionalTest):
  15. # 伊迪丝访问这个很棒的超级列表网站
  16. # 第一次注意到导航栏中有“登录”区域
  17. # 看到要求输入电子邮件地址,她便输入了
  18. + if self.staging_server:
  19. + test_email = 'edith.testuser@yahoo.com'
  20. + else:
  21. + test_email = 'edith@example.com'
  22. +
  23. self.browser.get(self.live_server_url)

然后使用那个变量,并调用新定义的辅助函数:

functional_tests/test_login.py (ch18l011-2)

  1. @@ -54,7 +54,7 @@ class LoginTest(FunctionalTest):
  2. test_email = 'edith@example.com'
  3. self.browser.get(self.live_server_url)
  4. - self.browser.find_element_by_name('email').send_keys(TEST_EMAIL)
  5. + self.browser.find_element_by_name('email').send_keys(test_email)
  6. self.browser.find_element_by_name('email').send_keys(Keys.ENTER)
  7. # 出现一个消息,告诉她邮件已经发出
  8. @@ -64,15 +64,13 @@ class LoginTest(FunctionalTest):
  9. ))
  10. # 她查看邮件,看到一个消息
  11. - email = mail.outbox[0]
  12. - self.assertIn(TEST_EMAIL, email.to)
  13. - self.assertEqual(email.subject, SUBJECT)
  14. + body = self.wait_for_email(test_email, SUBJECT)
  15. # 邮件中有个URL链接
  16. - self.assertIn('Use this link to log in', email.body)
  17. - url_search = re.search(r'http://.+/.+$', email.body)
  18. + self.assertIn('Use this link to log in', body)
  19. + url_search = re.search(r'http://.+/.+$', body)
  20. if not url_search:
  21. - self.fail(f'Could not find url in email body:\n{email.body}')
  22. + self.fail(f'Could not find url in email body:\n{body}')
  23. url = url_search.group(0)
  24. self.assertIn(self.live_server_url, url)
  25. @@ -80,11 +78,11 @@ class LoginTest(FunctionalTest):
  26. self.browser.get(url)
  27. # 她登录了!
  28. - self.wait_to_beloggedin(email=TEST_EMAIL)
  29. + self.wait_to_beloggedin(email=test_email)
  30. # 现在她要退出
  31. self.browser.find_element_by_link_text('Log out').click()
  32. # 她退出了
  33. - self.wait_to_beloggedout(email=TEST_EMAIL)
  34. + self.wait_to_beloggedout(email=test_email)

不管你信不信,这样改动之后就行了。经功能测试确认,登录功能可以正常使用了——我们可是发送了真实的电子邮件啊!

06 - 图2 我费了好大劲才凑出了检查电子邮件的代码,目前还不雅观,有点脆弱(常见的问题是错误沿用上一次测试的电子邮件)。如果能整理一下,再多试试,我想代码会更可靠。如果不想这么麻烦,可以使用 mailinator.com 这样的服务,只需少量费用就能获得一个一次性的电子邮件地址和查看邮件的 API。

21.4 在过渡服务器中管理测试数据库

现在可以再次运行功能测试,此时又会看到一个失败测试,因为无法创建已经通过认证的会话,所以针对“My Lists”页面的测试失败了:

  1. $ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests
  2.  
  3. ERROR: testloggedin_users_lists_are_saved_as_my_lists
  4. (functional_tests.test_my_lists.MyListsTest)
  5. [...]
  6. selenium.common.exceptions.TimeoutException: Message: Could not find element
  7. with id id_logout. Page text was:
  8. Superlists
  9. Sign in
  10. Start a new To-Do list
  11.  
  12. Ran 8 tests in 72.742s
  13.  
  14. FAILED (errors=1)

失败的真正原因是 create_pre_authenticated_session 函数只能操作本地数据库。我们要找到一种方法,管理服务器中的数据库。

21.4.1 创建会话的Django管理命令

若想在服务器中操作,就要编写一个自成一体的脚本,在服务器中的命令行里运行。大多数情况下都会使用 Fabric 执行这样的脚本。

尝试编写可在 Django 环境中运行的独立脚本(和数据库交互等),有些问题要谨慎处理,例如正确设定 DJANGO_SETTINGS_MODULE 环境变量,还要正确处理 sys.path

我们可不想在这些细节上浪费时间。其实 Django 允许我们自己创建“管理命令”(可以使用 python manage.py 运行的命令),可以把一切琐碎的事情都交给 Django 完成。管理命令保存在应用的 management/commands 文件夹中:

  1. $ mkdir -p functional_tests/management/commands
  2. $ touch functional_tests/management/__init__.py
  3. $ touch functional_tests/management/commands/__init__.py

管理命令的样板代码是一个类,继承自 django.core.management.BaseCommand,而且定义了一个名为 handle 的方法:

functional_tests/management/commands/create_session.py

  1. from django.conf import settings
  2. from django.contrib.auth import BACKEND_SESSION_KEY, SESSION_KEY, get_user_model
  3. User = get_user_model()
  4. from django.contrib.sessions.backends.db import SessionStore
  5. from django.core.management.base import BaseCommand
  6. class Command(BaseCommand):
  7. def add_arguments(self, parser):
  8. parser.add_argument('email')
  9. def handle(self, args, *options):
  10. session_key = create_pre_authenticated_session(options['email'])
  11. self.stdout.write(session_key)
  12. def create_pre_authenticated_session(email):
  13. user = User.objects.create(email=email)
  14. session = SessionStore()
  15. session[SESSION_KEY] = user.pk
  16. session[BACKEND_SESSION_KEY] = settings.AUTHENTICATION_BACKENDS[0]
  17. session.save()
  18. return session.session_key

create_pre_authenticated_session 函数的代码从 test_my_lists.py 文件中提取而来。handle 方法从选项中获取电子邮件地址,返回一个将要存入浏览器 cookie 中的会话键。这个管理命令还会把会话键打印到命令行中。试一下这个命令:

  1. $ python manage.py create_session a@b.com
  2. Unknown command: 'create_session'

还要做一步设置——把 functional_tests 加入 settings.py,让 Django 把它识别为一个可能包含管理命令和测试的真正应用:

superlists/settings.py

  1. +++ bsuperlistssettings.py
  2. @@ -42,6 +42,7 @@ INSTALLED_APPS = [
  3. 'lists',
  4. 'accounts',
  5. + 'functional_tests',
  6. ]

现在这个管理命令可以使用了:

  1. $ python manage.py create_session a@b.com
  2. qnslckvp2aga7tm6xuivyb0ob1akzzwl

06 - 图3 如果报错说缺少 auth_user 表,可能就要执行 manage.py migrate 命令。如果还不行,重新来过,删除 db.sqlite3,再执行 migrate 命令。

21.4.2 让功能测试在服务器上运行管理命令

接下来调整 test_my_lists.py 文件中的测试,让它在本地服务器中运行本地函数,但是在过渡服务器中运行管理命令:

functional_tests/test_my_lists.py (ch18l016)

  1. from django.conf import settings
  2. from .base import FunctionalTest
  3. from .server_tools import create_session_on_server
  4. from .management.commands.create_session import create_pre_authenticated_session
  5. class MyListsTest(FunctionalTest):
  6. def create_pre_authenticated_session(self, email):
  7. if self.staging_server:
  8. session_key = create_session_on_server(self.staging_server, email)
  9. else:
  10. session_key = create_pre_authenticated_session(email)
  11. ## 为了设定cookie,我们要先访问网站
  12. ## 而404页面是加载最快的
  13. self.browser.get(self.live_server_url + "404_no_such_url")
  14. self.browser.add_cookie(dict(
  15. name=settings.SESSION_COOKIE_NAME,
  16. value=session_key,
  17. path='/',
  18. ))
  19. [...]

此外,还要调整 base.py,当处在过渡服务器中时,提供更多信息:

functional_tests/base.py (ch18l017)

  1. from .server_tools import reset_database
  2. [...]
  3. class FunctionalTest(StaticLiveServerTestCase):
  4. def setUp(self):
  5. self.browser = webdriver.Firefox()
  6. self.staging_server = os.environ.get('STAGING_SERVER')
  7. if self.staging_server:
  8. self.live_server_url = 'http://' + self.staging_server
  9. reset_database(self.staging_server)

➊ 这个函数的作用是在两次测试之间还原服务器中的数据库。稍后我会讲解创建会话这段代码的逻辑,以及为什么可以这么做。

21.4.3 直接在Python代码中使用Fabric

除了 fab 命令之外,Fabric 还提供了 API,这样便可以直接在 Python 代码中执行 Fabric 服务器命令。为此,只需通过一个字符串告诉 Fabric 你想连接的主机即可:

functional_tests/server_tools.py

  1. from fabric.api import run
  2. from fabric.context_managers import settings
  3. def getmanage_dot_py(host):
  4. return f'~/sites/{host}/virtualenvbinpython ~/sites/{host}/source/manage.py'
  5. def reset_database(host):
  6. manage_dot_py = getmanage_dot_py(host)
  7. with settings(host_string=f'elspeth@{host}'):
  8. run(f'{manage_dot_py} flush --noinput')
  9. def create_session_on_server(host, email):
  10. manage_dot_py = getmanage_dot_py(host)
  11. with settings(host_string=f'elspeth@{host}'):
  12. session_key = run(f'{manage_dot_py} create_session {email}')
  13. return session_key.strip()

❶ 这个上下文管理器设定主机字符串,格式为 user@server-address(我直接把自己的服务器用户名 elspeth 写进去了,别忘了修改)。

❷ 在上下文中可以像在 fabfile 中那样直接调用 Fabric 命令。

21.4.4 回顾:在本地服务器和过渡服务器中创建会话的方式

充分理解了吗?下面通过 ASCII 图表回顾一下:

  • 在本地
  1. +-----------------------------------+ +-------------------------------------+
  2. | MyListsTest | --> | .management.commands.create_session |
  3. | .create_pre_authenticated_session | | .create_pre_authenticated_session |
  4. | (locally) | | (locally) |
  5. +-----------------------------------+ +-------------------------------------+
  • 在过渡服务器中
  1. +-----------------------------------+ +-------------------------------------+
  2. | MyListsTest | | .management.commands.create_session |
  3. | .create_pre_authenticated_session | | .create_pre_authenticated_session |
  4. | (locally) | | (on server) |
  5. +-----------------------------------+ +-------------------------------------+
  6. | ^
  7. v |
  8. +----------------------------+ +--------+ +------------------------------+
  9. | server_tools | --> | fabric | --> | ./manage.py create_session |
  10. | .create_session_on_server | | "run" | | (on server) |
  11. | (locally) | +--------+ +------------------------------+
  12. +----------------------------+

不论如何,看一下这么做是否可行。首先,在本地运行测试,确认没有造成任何破坏:

  1. $ python manage.py test functional_tests.test_my_lists
  2. [...]
  3. OK

然后在服务器中运行。先把代码推送到服务器中:

  1. $ git push # 要先提交改动
  2. $ cd deploy_tools
  3. $ fab deploy --host=superlists-staging.ottg.eu

再运行测试:

  1. $ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test \
  2. functional_tests.test_my_lists
  3. [...]
  4. [superlists-staging.ottg.eu] Executing task 'reset_database'
  5. ~/sites/superlists-staging.ottg.eu/source/manage.py flush --noinput
  6. [superlists-staging.ottg.eu] out: Syncing...
  7. [superlists-staging.ottg.eu] out: Creating tables ...
  8. [...]
  9. .
  10. ---------------------------------------------------------------------
  11. Ran 1 test in 25.701s
  12.  
  13. OK

看起来没问题。还可以运行全部测试确认一下:

  1. $ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test \
  2. functional_tests
  3. [...]
  4. [superlists-staging.ottg.eu] Executing task 'reset_database'
  5. [...]
  6. Ran 8 tests in 89.494s
  7.  
  8. OK

太棒了!

06 - 图4 我展示了管理测试数据库的一种方法,你也可以试验其他方式。例如,使用 MySQL 或 Postgres,可以打开一个 SSH 隧道连接服务器,使用端口转发直接操作数据库。然后修改 settings.DATABASES,让功能测试使用隧道连接的端口和数据库交互。

警告:小心,不要在线上服务器中运行测试

我们现在遇到一件危险的事,因为编写的代码能直接影响服务器中的数据库。一定要非常非常小心,别在错误的主机中运行功能测试,把生产数据库清空了。

此时,可以考虑使用一些安全防护措施。例如,把过渡环境和生产环境放在不同的服务器中,而且不同的服务器使用不同的口令认证密钥对。

在生产环境的数据副本中运行测试也有同样的危险,我就曾经不小心重复给客户发送了发票(见附录D)。这是前车之鉴啊。

21.5 集成日志相关的代码

结束本章之前,我们要把日志相关的代码集成到应用中。把输出日志的代码放在那儿,并且纳入版本控制,有助于调试以后遇到的登录问题。毕竟有些人不怀好意。

先把 Gunicorn 的配置保存到 deploy_tools 文件夹里的临时文件中:

deploy_tools/gunicorn-systemd.template.service (ch18l020)

  1. [...]
  2. Environment=EMAIL_PASSWORD=SEKRIT
  3. ExecStart=homeelspeth/sites/SITENAME/virtualenvbingunicorn \
  4. --bind unix:tmpSITENAME.socket \
  5. --access-logfile ../access.log \
  6. --error-logfile ../error.log \
  7. superlists.wsgi:application
  8. [...]

然后在配置笔记中加上几点,提醒自己别忘了在 Gunicorn 配置文件中设定电子邮件密码的环境变量:

deploy_tools/provisioning_notes.md (ch18l021)

  1. ## Systemd服务
  2. 参见gunicorn-systemd.template.service
  3. SITENAME替换成具体的域名,例如staging.my-domain.com
  4. * SEKRIT替换成电子邮件密码
  5. [...]

21.6 小结

在服务器中运行新代码总会让一些缺陷和意料之外的问题显露出来。为了解决这些问题,我们做了很多工作,不过在这个过程中也收获颇多。

我们定义了通用的 wait 装饰器,这更符合 Python 习惯,在功能测试中到处都可以使用。我们让测试固件既可以在本地使用,也能在服务器中使用(包括对“真实”电子邮件的测试)。此外,还设定了更牢靠的日志配置。

不过,在部署线上网站之前,最好为用户提供他们真正想要的功能——下一章介绍如何在“My Lists”页面中汇总用户的清单。

在过渡服务器中捕获缺陷时学到的知识

  • 固件也要在远程服务器中使用

    在本地运行测试,使用 LiveServerTestCase 即可以轻松通过 Django ORM 与测试数据库交互。与过渡服务器中的数据库交互就没这么简单了。解决方法之一是使用 Django 管理命令,如前文所示。不过也可以小心探索,找到适合自己的方法。

  • 在服务器中重置数据时要格外小心

    能远程清除服务器中整个数据库的命令极其危险,一定要小心再小心,确保不会意外损坏生产数据。

  • 日志对调试服务器中的问题非常重要

    你至少要能看到服务器产生的错误消息。对较为棘手的缺陷来说,还要能得到临时的“调试输出”,保存在某个文件中。

第 22 章 完成“My Lists”页面:由外而内的TDD

本章我要介绍一种技术,叫“由外而内”的 TDD。一直以来,我们几乎都在使用这种技术。“双循环”TDD 流程就体现了由外而内的思想——先编写功能测试,然后再编写单元测试,其实就是从外部设计系统,再分层编写代码。现在我要明确提出这个概念,再讨论其中涉及的常见问题。

22.1 对立技术:“由内而外”

“由外而内”的对立技术是“由内而外”,接触 TDD 之前,大多数人都凭直觉选择后者。提出一个设计想法之后,有时会自然而然地从最内部、最低层的组件开始实现。

例如,就我们现在面临的问题而言,要想为用户提供一个“My Lists”页面显示已经保存的清单,我们首先会迫不及待地在 List 模型中添加 owner 属性,因为根据需求推断,显然需要这样一个属性。之后,借助这个新属性修改外层代码,例如视图和模板,最后添加 URL 路由,指向新视图。

这么做感觉更自然,因为所用的代码从来不会依赖尚未实现的功能。内层的一切都是构建外层的坚实基础。

不过,像这样由内而外的工作方式也有缺点。

22.2 为什么选择使用“由外而内”

由内而外的技术最明显的问题是它迫使我们抛开 TDD 流程。功能测试第一次失败可能是因为缺少 URL 路由,但我们决定忽略这一点,先为数据库模型对象添加属性。

我们可能已经在脑海中构思好了内层的模样,而且这些想法往往都很好,不过这些都是对真实需求的推测,因为还未构造使用内层组件的外层组件。

这么做可能会导致内层组件太笼统,或者比真实需求功能更强——不仅浪费了时间,还把项目变得更为复杂。另一种常见的问题是,创建内层组件使用的 API 乍看起来对内部设计而言很合适,但之后会发现并不适用于外层组件。更糟的是,最后你可能会发现内层组件完全无法解决外层组件需要解决的问题。

与此相反,使用由外而内的工作方式,可以在外层组件的基础上构思想从内层组件获取的最佳 API。下面以实例说明如何使用由外而内技术。

22.3 “My Lists”页面的功能测试

编写下面这个功能测试时,我们从能接触到的最外层开始(表现层),然后是视图函数(或叫“控制器”),最后是最内层,在这个例子中是模型代码。

既然 create_pre_authenticated_session 函数可以正常使用,那么就可以直接用来编写针对“My Lists”页面的功能测试:

functional_tests/test_my_lists.py (ch19l001-1)

  1. def testloggedin_users_lists_are_saved_as_my_lists(self):
  2. # 伊迪丝是已登录用户
  3. self.create_pre_authenticated_session('edith@example.com')
  4. # 她访问首页,新建一个清单
  5. self.browser.get(self.live_server_url)
  6. self.add_list_item('Reticulate splines')
  7. self.add_list_item('Immanentize eschaton')
  8. first_list_url = self.browser.current_url
  9. # 她第一次看到My Lists链接
  10. self.browser.find_element_by_link_text('My lists').click()
  11. # 她看到这个页面中有她创建的清单
  12. # 而且清单根据第一个待办事项命名
  13. self.wait_for(
  14. lambda: self.browser.find_element_by_link_text('Reticulate splines')
  15. )
  16. self.browser.find_element_by_link_text('Reticulate splines').click()
  17. self.wait_for(
  18. lambda: self.assertEqual(self.browser.current_url, first_list_url)
  19. )

先创建一个包含几个待办事项的清单,然后检查这个列表会出现在新的“My Lists”页面上,而且以清单中的第一个待办事项命名。

接着前面的单元测试,再创建一个清单,确保的确会出现在“My Lists”页面上。与此同时,再检查只有已登录的用户才能看到“My Lists”页面:

functional_tests/test_my_lists.py (ch19l001-2)

  1. [...]
  2. self.wait_for(
  3. lambda: self.assertEqual(self.browser.current_url, first_list_url)
  4. )
  5. # 她决定再建一个清单试试
  6. self.browser.get(self.live_server_url)
  7. self.add_list_item('Click cows')
  8. second_list_url = self.browser.current_url
  9. # 在“My Lists”页面,这个新建的清单也显示出来了
  10. self.browser.find_element_by_link_text('My lists').click()
  11. self.wait_for(
  12. lambda: self.browser.find_element_by_link_text('Click cows')
  13. )
  14. self.browser.find_element_by_link_text('Click cows').click()
  15. self.wait_for(
  16. lambda: self.assertEqual(self.browser.current_url, second_list_url)
  17. )
  18. # 她退出后,“My Lists”链接不见了
  19. self.browser.find_element_by_link_text('Log out').click()
  20. self.wait_for(lambda: self.assertEqual(
  21. self.browser.find_elements_by_link_text('My lists'),
  22. []
  23. ))

上述功能测试使用了一个新的辅助方法,即 add_list_item,它抽象了在正确的输入框中输入文本的操作。这个辅助方法在 base.py 中定义:

functional_tests/base.py (ch19l001-3)

  1. from selenium.webdriver.common.keys import Keys
  2. [...]
  3. def add_list_item(self, item_text):
  4. num_rows = len(self.browser.find_elements_by_css_selector('#id_list_table tr'))
  5. self.get_item_input_box().send_keys(item_text)
  6. self.get_item_input_box().send_keys(Keys.ENTER)
  7. item_number = num_rows + 1
  8. self.wait_for_row_in_list_table(f'{item_number}: {item_text}')

定义好这个辅助方法之后,可以在其他功能测试中像下面这样使用:

functional_tests/test_list_item_validation.py

  1. self.add_list_item('Buy wellies')

我觉得使用这个辅助方法之后,功能测试的可读性提高了不少。我改了 6 处,看看你是否与我一样。

运行全部功能测试,做个提交,然后回到我们正在处理的这个功能测试。你看到的第一个错误应该是这样的:

  1. $ python3 manage.py test functional_tests.test_my_lists
  2. [...]
  3. selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
  4. element: My lists

22.4 外层:表现层和模板

目前,这个测试失败,报错无法找到“My Lists”链接。这个问题可以在表现层,即 base.html 模板里的导航条中解决。最少量的代码改动如下所示:

lists/templates/base.html (ch19l002-1)

  1. {% if user.email %}
  2. <ul class="nav navbar-nav navbar-left">
  3. <li><a href="#">My lists</a></li>
  4. </ul>
  5. <ul class="nav navbar-nav navbar-right">
  6. <li class="navbar-text">Logged in as {{ user.email }}</li>
  7. <li><a href="{% url 'logout' %}">Log out</a></li>
  8. </ul>

显然,这个链接没指向任何页面,不过却能解决这个问题,得到下一个失败消息:

  1. $ python3 manage.py test functional_tests.test_my_lists
  2. [...]
  3. lambda: self.browser.find_element_by_link_text('Reticulate splines')
  4. [...]
  5. selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
  6. element: Reticulate splines

失败消息指出要构建一个页面,用标题列出一个用户的所有清单。先从简单的开始——一个 URL 和一个占位模板。

可以再次使用由外而内技术,先从表现层开始,只写上地址,其他什么都不做:

lists/templates/base.html (ch19l002-2)

  1. <ul class="nav navbar-nav navbar-left">
  2. <li><a href="{% url 'my_lists' user.email %}">My lists</a></li>
  3. </ul>

22.5 下移一层到视图函数(控制器)

这样改还是会得到模板错误,所以要从表现层和 URL 层下移,进入控制器层,即 Django

中的视图函数。一如既往,先写测试:

lists/tests/test_views.py (ch19l003)

  1. class MyListsTest(TestCase):
  2. def test_my_lists_url_renders_my_lists_template(self):
  3. response = self.client.get('listsusers/a@b.com/')
  4. self.assertTemplateUsed(response, 'my_lists.html')

得到的测试结果为:

  1. AssertionError: No templates used to render the response

然后修正这个问题,不过还在表现层,更准确地说是 urls.py:

lists/urls.py

  1. urlpatterns = [
  2. url(r'^new$', views.new_list, name='new_list'),
  3. url(r'^(\d+)/$', views.view_list, name='view_list'),
  4. url(r'^users/(.+)/$', views.my_lists, name='my_lists'),
  5. ]

修改之后会得到一个测试失败消息,告诉我们移到下一层之后要做什么:

  1. AttributeError: module 'lists.views' has no attribute 'my_lists'

从表现层移到视图层,再定义一个最简单的占位视图:

lists/views.py (ch19l005)

  1. def my_lists(request, email):
  2. return render(request, 'my_lists.html')

以及一个最简单的模板:

lists/templates/my_lists.html

  1. {% extends 'base.html' %}
  2. {% block header_text %}My Lists{% endblock %}

现在单元测试通过了,但功能测试毫无进展,报错说“My Lists”页面没有显示清单。功能测试希望这些清单可以点击,而且以第一个待办事项命名:

  1. $ python3 manage.py test functional_tests.test_my_lists
  2. [...]
  3. selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
  4. element: Reticulate splines

22.6 使用由外而内技术,再让一个测试通过

仍然使用功能测试驱动每一步的开发工作。

再次从外层开始,编写模板代码,让“My Lists”页面实现设想的功能。现在,要指定希望从低层获取的 API。

22.6.1 快速重组模板的继承层级

基模板目前没有地方放置新内容了,而且“My Lists”页面不需要新建待办事项表单,所以把表单放到一个块中,需要时才显示:

lists/templates/base.html (ch19l007-1)

  1. <div class="row">
  2. <div class="col-md-6 col-md-offset-3 jumbotron">
  3. <div class="text-center">
  4. <h1>{% block header_text %}{% endblock %}</h1>
  5. {% block list_form %}
  6. <form method="POST" action="{% block form_action %}{% endblock %}">
  7. {{ form.text }}
  8. {% csrf_token %}
  9. {% if form.errors %}
  10. <div class="form-group has-error">
  11. <div class="help-block">{{ form.text.errors }}</div>
  12. </div>
  13. {% endif %}
  14. </form>
  15. {% endblock %}
  16. </div>
  17. </div>
  18. </div>

lists/templates/base.html (ch19l007-2)

  1. <div class="row">
  2. <div class="col-md-6 col-md-offset-3">
  3. {% block table %}
  4. {% endblock %}
  5. </div>
  6. </div>
  7. <div class="row">
  8. <div class="col-md-6 col-md-offset-3">
  9. {% block extra_content %}
  10. {% endblock %}
  11. </div>
  12. </div>
  13. </div>
  14. <script src="staticjquery-3.1.1.min.js"></script>
  15. [...]

22.6.2 使用模板设计API

同时,在 my_lists.html 中覆盖 list_form 块,把块中的内容清空:

lists/templates/my_lists.html

  1. {% extends 'base.html' %}
  2. {% block header_text %}My Lists{% endblock %}
  3. {% block list_form %}{% endblock %}

然后只在 extra_content 块中编写代码:

lists/templates/my_lists.html

  1. [...]
  2. {% block list_form %}{% endblock %}
  3. {% block extra_content %}
  4. <h2>{{ owner.email }}'s lists</h2> ➊
  5. <ul>
  6. {% for list in owner.list_set.all %} ➋
  7. <li><a href="{{ list.get_absolute_url }}">{{ list.name }}</a></li> ➌
  8. {% endfor %}
  9. </ul>
  10. {% endblock %}

在这个模板中我们做了几个设计决策,这会对内层代码产生一定影响。

❶ 需要一个名为 owner 的变量,在模板中表示用户。

❷ 想使用 owner.list_set.all 遍历用户创建的清单(我碰巧知道 Django ORM 提供了这个属性)。

❸ 想使用 list.name 获取清单的名字,目前清单以其中的第一个待办事项命名。

06 - 图5 由外而内的 TDD 有时叫作“一厢情愿式编程”,我想你能看出为什么。我们先编写高层代码,这些代码建立在设想的低层基础之上,可是低层尚未实现。

再次运行功能测试,确认没有造成任何破坏,同时查看有无进展:

  1. $ python manage.py test functional_tests
  2. [...]
  3. selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
  4. element: Reticulate splines
  5.  
  6. ---------------------------------------------------------------------
  7. Ran 8 tests in 77.613s
  8.  
  9. FAILED (errors=1)

虽然没进展,但至少没造成任何破坏。该提交了:

  1. $ git add lists
  2. $ git diff --staged
  3. $ git commit -m "url, placeholder view, and first-cut templates for my_lists"

22.6.3 移到下一层:视图向模板中传入什么

现在,视图层要回应需求,为模板层提供所需的对象。这里要提供的是清单属主:

lists/tests/test_views.py (ch19l011)

  1. from django.contrib.auth import get_user_model
  2. User = get_user_model()
  3. [...]
  4. class MyListsTest(TestCase):
  5. def test_my_lists_url_renders_my_lists_template(self):
  6. [...]
  7. def test_passes_correct_owner_to_template(self):
  8. User.objects.create(email='wrong@owner.com')
  9. correct_user = User.objects.create(email='a@b.com')
  10. response = self.client.get('listsusers/a@b.com/')
  11. self.assertEqual(response.context['owner'], correct_user)

测试结果为:

  1. KeyError: 'owner'

那就这么修改:

lists/views.py (ch19l012)

  1. from django.contrib.auth import get_user_model
  2. User = get_user_model()
  3. [...]
  4. def my_lists(request, email):
  5. owner = User.objects.get(email=email)
  6. return render(request, 'my_lists.html', {'owner': owner})

这样修改之后,新测试通过了,但还是能看到前一个测试导致的错误。只需在这个测试中添加一个用户即可:

lists/tests/test_views.py (ch19l013)

  1. def test_my_lists_url_renders_my_lists_template(self):
  2. User.objects.create(email='a@b.com')
  3. [...]

现在测试全部通过:

  1. OK

22.7 视图层的下一个需求:新建清单时应该记录属主

下移到模型层之前,视图层还有一部分代码要用到模型:如果当前用户已经登录网站,需要一种方式把新建的清单指派给一个属主。

初期编写的测试如下所示:

lists/tests/test_views.py (ch19l014)

  1. class NewListTest(TestCase):
  2. [...]
  3. def test_list_owner_is_saved_if_user_is_authenticated(self):
  4. user = User.objects.create(email='a@b.com')
  5. self.client.force_login(user)
  6. self.client.post('listsnew', data={'text': 'new item'})
  7. list_ = List.objects.first()
  8. self.assertEqual(list_.owner, user)

➊ 为了让测试客户端利用已登录用户的身份发送请求,必须先调用 force_login()

这个测试得到的失败消息如下:

  1. AttributeError: 'List' object has no attribute 'owner'

为了修正这个问题,可以尝试编写如下代码:

lists/views.py (ch19l015)

  1. def new_list(request):
  2. form = ItemForm(data=request.POST)
  3. if form.is_valid():
  4. list_ = List()
  5. list_.owner = request.user
  6. list_.save()
  7. form.save(for_list=list_)
  8. return redirect(list_)
  9. else:
  10. return render(request, 'home.html', {"form": form})

但这个视图其实解决不了问题,因为还不知道怎么保存清单的属主:

  1. self.assertEqual(list_.owner, user)
  2. AttributeError: 'List' object has no attribute 'owner'

抉择时刻:测试失败时是否要移入下一层

为了让这个测试通过,就目前的情况而言,要下移到模型层。但还有一个失败测试,要做的工作太多,现在下移可不明智。

可以采用另一种策略,使用驭件把测试和下层组件更明显地隔离开

一方面,使用驭件要做的工作更多,而且驭件会让测试代码更难读懂。另一方面,如果应用更复杂,外部和内部之间的分层更多,测试就会涉及 3~5 层,在深入最底层实现关键功能之前,这些测试一直处于失败状态。测试无法通过,单就一层而言,我们就无法确定它是否能正常运行,只有等到最底层实现之后才有答案。

你在自己的项目中有可能也会遇到这样的抉择时刻。两种方式都要探讨。先走捷径,放任测试失败不管。下一章再回到这里,探讨如何使用增强隔离的方式。

下面做次提交,并且为这次提交打上标签,以便下一章能找到这个位置:

  1. $ git commit -am "new_list view tries to assign owner but cant"
  2. $ git tag revisit_this_point_with_isolated_tests

22.8 下移到模型层

使用由外而内技术得出了两个需求,需要在模型层实现:其一,想使用 .owner 属性为清单指派一个属主;其二,想使用 API owner.list_set.all 获取清单的属主。

针对这两个需求,先编写一个测试:

lists/tests/test_models.py (ch19l018)

  1. from django.contrib.auth import get_user_model
  2. User = get_user_model()
  3. [...]
  4. class ListModelTest(TestCase):
  5. def testgetabsolute_url(self):
  6. [...]
  7. def test_lists_can_have_owners(self):
  8. user = User.objects.create(email='a@b.com')
  9. list_ = List.objects.create(owner=user)
  10. self.assertIn(list_, user.list_set.all())

又得到了一个失败的单元测试:

  1. list_ = List.objects.create(owner=user)
  2. [...]
  3. TypeError: 'owner' is an invalid keyword argument for this function

简单些,把模型写成下面这样:

  1. from django.conf import settings
  2. [...]
  3. class List(models.Model):
  4. owner = models.ForeignKey(settings.AUTH_USER_MODEL)

可是我们希望属主可有可无。明确表明需求比含糊其辞强,而且测试还可以作为文档,所以再编写一个测试:

lists/tests/test_models.py (ch19l020)

  1. def test_list_owner_is_optional(self):
  2. List.objects.create() # 不该抛出异常

正确的模型实现如下所示:

lists/models.py

  1. from django.conf import settings
  2. [...]
  3. class List(models.Model):
  4. owner = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True)
  5. def get_absolute_url(self):
  6. return reverse('view_list', args=[self.id])

现在运行测试,会看到以前见过的数据库错误:

  1. return Database.Cursor.execute(self, query, params)
  2. django.db.utils.OperationalError: no such column: lists_list.owner_id

因为需要做一次迁移:

  1. $ python manage.py makemigrations
  2. Migrations for 'lists':
  3. lists/migrations/0006_list_owner.py
  4. - Add field owner to list

快成功了,不过还有几个错误:

  1. ERROR: test_redirects_after_POST (lists.tests.test_views.NewListTest)
  2. [...]
  3. ValueError: Cannot assign "<SimpleLazyObject:
  4. <django.contrib.auth.models.AnonymousUser object at 0x7f364795ef90>>":
  5. "List.owner" must be a "User" instance.
  6. ERROR: test_can_save_a_POST_request (lists.tests.test_views.NewListTest)
  7. [...]
  8. ValueError: Cannot assign "<SimpleLazyObject:
  9. <django.contrib.auth.models.AnonymousUser object at 0x7f364795ef90>>":
  10. "List.owner" must be a "User" instance.

现在回到视图层,做些清理工作。注意,这些错误发生在针对 new_list 视图的测试中,而且用户没有登录。仅当用户登录后才应该保存清单的属主。在第 19 章中定义的 .is_authenticated() 函数现在有用处了(用户未登录时,Django 使用 AnonymousUser 类表示用户,此时 .is_authenticated() 函数的返回值始终是 False):

lists/views.py (ch19l023)

  1. if form.is_valid():
  2. list_ = List()
  3. if request.user.is_authenticated:
  4. list_.owner = request.user
  5. list_.save()
  6. form.save(for_list=list_)
  7. [...]

这样修改之后,测试通过了:

  1. $ python manage.py test lists
  2. [...]
  3. .......................................
  4. ---------------------------------------------------------------------
  5. Ran 39 tests in 0.237s
  6.  
  7. OK

现在是提交的好时机:

  1. $ git add lists
  2. $ git commit -m "lists can have owners, which are saved on creation."

最后一步:实现模板需要的.name属性

使用的由外而内设计方式还有最后一个需求,即清单根据其中第一个待办事项命名:

lists/tests/test_models.py (ch19l024)

  1. def test_list_name_is_first_item_text(self):
  2. list_ = List.objects.create()
  3. Item.objects.create(list=list_, text='first item')
  4. Item.objects.create(list=list_, text='second item')
  5. self.assertEqual(list_.name, 'first item')

lists/models.py (ch19l025)

  1. @property
  2. def name(self):
  3. return self.item_set.first().text

你可能无法相信,这样测试就能通过了,而且“My Lists”页面(如图 22-1)也能使用了。

  1. $ python manage.py test functional_tests
  2. [...]
  3. Ran 8 tests in 93.819s
  4.  
  5. OK

06 - 图6

图 22-1:光彩夺目的“My Lists”页面(也证明我在 Windows 中测试过)

Python 中的 @property 修饰器

如果你没见过 @property 修饰器,我告诉你,它的作用是把类中的方法转变成与属性一样,可以在外部访问。

这是 Python 语言一个强大的特性,因为很容易用它实现“鸭子类型”(duck typing),无须修改类的接口就能改变属性的实现方式。也就是说,如果想把 .name 改成模型真正的属性,在数据库中存储文本型数据,整个过程是完全透明的,只要兼顾其他代码,就能继续使用 .name 获取清单名,完全不用知道这个属性的具体实现方式。几年前,Raymond Hettinger 在Pycon 上针对这个话题做过一次出色的演讲。这个演讲对新手友好,我极力推荐你观看(除此之外,还涵盖众多符合 Python 风格的类设计实践方式)。

当然了,就算没使用 @property 修饰器,在 Django 的模板语言中还是会调用 .name 方法。不过这是 Django 专有的特性,不适用于一般的 Python 程序。

但这个过程中有作弊。测试山羊正满怀猜疑。实现下一层时前一层还有失败的测试。下一章要看一下增强测试隔离性如何编写测试。

由外而内的 TDD

  • 由外而内的 TDD

    一种编写代码的方法,由测试驱动,从外层开始(表现层,GUI),然后逐步向内层移动,通过视图层或控制器层,最终达到模型层。这种方法的理念是由实际需要使用的功能驱动代码的编写,而不是在低层猜测需求。

  • 一厢情愿式编程

    由外而内的过程有时也叫“一厢情愿式编程”。其实,任何 TDD 形式都涉及一厢情愿。我们总是为还未实现的功能编写测试。

  • 由外而内技术的缺点

    由外而内技术也不是万灵药。这种技术鼓励我们关注用户立即就能看到的功能,但不会自动提醒我们为不是那么明显的功能编写关键测试,例如安全相关的功能。你自己要记得编写这些测试。

第 23 章 测试隔离和“倾听测试的心声”

前一章对视图层的失败单元测试放任不管,而是进入模型层编写更多的测试和更多的代码,以便让这个测试通过。

测试侥幸通过了,因为我们的应用很简单。我要强调一点,在复杂的应用中,选择这么做是很危险的。尚未确定高层是否真正完成之前就进入低层是一种冒险行为。

06 - 图7 感谢 Gary Bernhardt,他看了前一章的草稿,建议我深入介绍测试隔离。

确保各层之间相互隔离确实需要投入更多的精力(以及更多可怕的驭件),可是这么做能促使我们得到更好的设计。本章就以实例说明这一点。

23.1 重温抉择时刻:视图层依赖于尚未编写的模型代码

重温前一章的抉择时刻,那时 new_list 视图无法正常运行,因为清单还没有 .owner 属性。

我们要逆转时光,通过前面做的标签检出以前的代码,看一下使用隔离性更好的测试效果如何。

  1. $ git checkout -b more-isolation # 为这次实验新建一个分支
  2. $ git reset --hard revisit_this_point_with_isolated_tests

失败的测试是下面这个:

lists/tests/test_views.py

  1. class NewListTest(TestCase):
  2. [...]
  3. def test_list_owner_is_saved_if_user_is_authenticated(self):
  4. user = User.objects.create(email='a@b.com')
  5. self.client.force_login(user)
  6. self.client.post('listsnew', data={'text': 'new item'})
  7. list_ = List.objects.first()
  8. self.assertEqual(list_.owner, user)

尝试使用的解决方法如下所示:

lists/views.py

  1. def new_list(request):
  2. form = ItemForm(data=request.POST)
  3. if form.is_valid():
  4. list_ = List()
  5. list_.owner = request.user
  6. list_.save()
  7. form.save(for_list=list_)
  8. return redirect(list_)
  9. else:
  10. return render(request, 'home.html', {"form": form})

此时,这个视图测试是失败的,因为还未编写模型层:

  1. self.assertEqual(list_.owner, user)
  2. AttributeError: 'List' object has no attribute 'owner'

06 - 图8 如果没有签出以前的代码并还原 lists/models.py,就不会看到这个错误。这一步一定要做。本章的目标之一是,看一下是否真能为还不存在的模型层编写测试。

23.2 首先尝试使用驭件实现隔离

清单还没有属主,但可以使用一些模拟技术让视图层测试认为有属主:

lists/tests/test_views.py (ch20l003)

  1. from unittest.mock import patch
  2. [...]
  3. @patch('lists.views.List')
  4. @patch('lists.views.ItemForm')
  5. def test_list_owner_is_saved_if_user_is_authenticated(
  6. self, mockItemFormClass, mockListClass
  7. ):
  8. user = User.objects.create(email='a@b.com')
  9. self.client.force_login(user)
  10. self.client.post('listsnew', data={'text': 'new item'})
  11. mock_list = mockListClass.return_value
  12. self.assertEqual(mock_list.owner, user)

❶ 模拟 List 模型的功能,获取视图创建的任何一个清单。

❷ 此外,还要模拟 ItemForm。如若不然,调用 form.save() 时,表单会抛出错误,因为无法在想创建的 Item 对象中使用驭件做外键。一旦开始使用驭件,就很难停手!

❸ 通过测试方法的参数注入驭件时,要按照声明驭件的相反顺序传入。有多个驭件时,方法的签名就是这么奇怪,习惯就好。

❹ 视图访问的清单实例是 List 驭件的返回值。

❺ 现在可以声明断言,判断清单对象是否设定了 .owner 属性。

现在运行测试应该可以通过:

  1. $ python manage.py test lists
  2. [...]
  3. Ran 37 tests in 0.145s
  4. OK

如果没通过,确保 views.py 中的视图代码和我前面给出的一模一样,使用的是 List(),而不是 List.objects.create

06 - 图9 使用驭件有个局限,必须按照特定的方式使用 API。这是使用驭件对象要做出的妥协之一。

使用驭件的side_effect属性检查事件发生的顺序

这个测试的问题是,无意中把代码写错也可能侥幸通过测试。假设在指定属主之前不小心调用了 save 方法:

lists/views.py

  1. if form.is_valid():
  2. list_ = List()
  3. list_.save()
  4. list_.owner = request.user
  5. form.save(for_list=list_)
  6. return redirect(list_)

按照测试现在这种写法,它依旧可以通过:

  1. OK

所以,严格来说,不仅要检查指定了属主,还要确保在清单对象上调用 save 方法之前就已经指定了。

使用驭件检查事件发生顺序的方法如下。可以模拟一个函数,作为侦件,检查调用这个侦件时周围的状态:

lists/tests/test_views.py (ch20l005)

  1. @patch('lists.views.List')
  2. @patch('lists.views.ItemForm')
  3. def test_list_owner_is_saved_if_user_is_authenticated(
  4. self, mockItemFormClass, mockListClass
  5. ):
  6. user = User.objects.create(email='a@b.com')
  7. self.client.force_login(user)
  8. mock_list = mockListClass.return_value
  9. def check_owner_assigned():
  10. self.assertEqual(mock_list.owner, user)
  11. mock_list.save.side_effect = check_owner_assigned
  12. self.client.post('listsnew', data={'text': 'new item'})
  13. mock_list.save.assert_called_once_with()

❶ 定义一个函数,在这个函数中就希望先发生的事件声明断言,即检查是否设定了清单的属主。

❷ 把这个检查函数赋值给后续事件的 side_effect 属性。当视图在驭件上调用 save 方法时,才会执行其中的断言。要保证在测试的目标函数调用前完成此次赋值。

❸ 最后,要确保设定了 side_effect 属性的函数一定会被调用,也就是要调用 .save() 方法。否则断言永远不会运行。

06 - 图10 使用驭件的副作用时有两个常见错误:第一,side_effect 属性赋值太晚,也就是在调用测试目标函数之后才赋值;第二,忘记检查是否调用了引起副作用的函数。说“常见”,是因为撰写本章时我多次同时犯了这两个错误。

现在,如果仍然使用前面有错误的代码,即指定属主和调用 save 方法的顺序不对,就会看到如下错误:

  1. FAIL: test_list_owner_is_saved_if_user_is_authenticated
  2. (lists.tests.test_views.NewListTest)
  3. [...]
  4. File "...superlistslistsviews.py", line 17, in new_list
  5. list_.save()
  6. [...]
  7. File "...superlistsliststests/test_views.py", line 74, in
  8. check_owner_assigned
  9. self.assertEqual(mock_list.owner, user)
  10. AssertionError: <MagicMock name='List().owner' id='140691452447208'> != <User:
  11. User object>

注意看这个失败消息,它先尝试保存,然后才执行 side_effect 属性对应的函数。

可以按照下面的方式修改,让测试通过:

lists/views.py

  1. if form.is_valid():
  2. list_ = List()
  3. list_.owner = request.user
  4. list_.save()
  5. form.save(for_list=list_)
  6. return redirect(list_)

测试结果为:

  1. OK

但是这个测试写得很丑!

23.3 倾听测试的心声:丑陋的测试表明需要重构

当你发现必须像这样编写测试,而且要做许多工作时,很有可能表明测试试图向你诉说什么。准备测试所需的数据用了 8 行代码(用户驭件用了 2 行,请求对象又用了 3 行,还有 3 行设定副作用函数),太多了。

这个测试试图告诉我们,视图做的工作太多了,既要创建表单,又要创建清单对象,还要决定是否保存清单的属主。

前面已经说过,可以把一部分工作交给表单类完成,把视图变得简单且易于理解一些。为什么要在视图中创建清单对象?或许 ItemForm.save 能代劳?为什么视图要决定是否保存 request.user ?这项任务也可以交给表单完成。

既然要把更多的任务交给表单,感觉应该为这个表单起个新名字。可以叫它 NewListForm,因为这个名字能更好地表明它的作用。最终,视图或许可以写成这样吧:

lists/views.py

  1. # 先不输入这段代码,只是假设可以这么写
  2. def new_list(request):
  3. form = NewListForm(data=request.POST)
  4. if form.is_valid():
  5. list_ = form.save(owner=request.user) # 创建List和Item对象
  6. return redirect(list_)
  7. else:
  8. return render(request, 'home.html', {"form": form})

这样多简洁!下面来看一下如何为这种写法编写完全隔离的测试。

23.4 以完全隔离的方式重写视图测试

首次尝试为这个视图编写的测试组件集成度太高,数据库层和表单层的功能完成之后才能通过。现在使用另一种方式,提高测试的隔离度。

23.4.1 为了新测试的健全性,保留之前的整合测试组件

NewListTest 类重名为 NewListViewIntegratedTest,再把尝试使用驭件保存属主的测试代码删掉,换成整合版本,而且暂时为这个测试方法加上 skip 修饰器:

lists/tests/test_views.py (ch20l008)

  1. import unittest
  2. [...]
  3. class NewListViewIntegratedTest(TestCase):
  4. def test_can_save_a_POST_request(self):
  5. [...]
  6. @unittest.skip
  7. def test_list_owner_is_saved_if_user_is_authenticated(self):
  8. user = User.objects.create(email='a@b.com')
  9. self.client.force_login(user)
  10. self.client.post('listsnew', data={'text': 'new item'})
  11. list_ = List.objects.first()
  12. self.assertEqual(list_.owner, user)

06 - 图11 你听说过“集成测试”(integration test)这个术语吗?想知道它和“整合测试”(integrated test)的区别吗?请看第 26 章框注中的定义。

  1. $ python manage.py test lists
  2. [...]
  3. Ran 37 tests in 0.139s
  4. OK

23.4.2 完全隔离的新测试组件

从头开始编写测试,看看隔离测试能否驱动我们写出 new_list 视图的替代版本。把这个视图命名为 new_list2,放在旧版视图旁边。写好之后,再换用这个新视图,看看以前的整合测试是否仍然都能通过。

lists/views.py (ch20l009)

  1. def new_list(request):
  2. [...]
  3. def new_list2(request):
  4. pass

23.4.3 站在协作者的角度思考问题

重写测试时若想实现完全隔离,必须丢掉以前对测试的认识。以前我们认为视图的真正作用是操作数据库等,现在则要站在协作对象的角度,思考视图如何与之交互。

站在新的角度上,发现视图的主要协作者是表单对象。所以,为了完全掌控表单,以及按照我们一厢情愿想要的方式定义表单的功能,使用驭件模拟表单。

lists/tests/test_views.py (ch20l010)

  1. from unittest.mock import patch
  2. from django.http import HttpRequest
  3. from lists.views import new_list2
  4. [...]
  5. @patch('lists.views.NewListForm')
  6. class NewListViewUnitTest(unittest.TestCase):
  7. def setUp(self):
  8. self.request = HttpRequest()
  9. self.request.POST['text'] = 'new list item'
  10. def test_passes_POST_data_to_NewListForm(self, mockNewListForm):
  11. new_list2(self.request)
  12. mockNewListForm.assert_called_once_with(data=self.request.POST)

❶ 使用 Django 提供的 TestCase 类太容易写成整合测试。为了确保写出纯粹隔离的单元测试,只能使用 unittest.TestCase

❷ 模拟 NewListForm 类(尚未定义)。类中的所有测试方法都会用到这个驭件,所以在类上模拟。

❸ 在 setUp 方法中手动创建了一个简单的 POST 请求,没有使用(太过整合的)Django 测试客户端。

❹ 然后检查视图要做的第一件事:在视图中使用正确的构造方法初始化它的协作者,即 NewListForm,传入的数据从请求中读取。

在这个测试的结果中首先会看到一个失败消息,报错视图中还没有 NewListForm

  1. AttributeError: <module 'lists.views' from '...superlistslistsviews.py'>
  2. does not have the attribute 'NewListForm'

先编写一个占位表单类:

lists/views.py (ch20l011)

  1. from lists.forms import ExistingListItemForm, ItemForm, NewListForm
  2. [...]

以及:

lists/forms.py (ch20l012)

  1. class ItemForm(forms.models.ModelForm):
  2. [...]
  3. class NewListForm(object):
  4. pass
  5. class ExistingListItemForm(ItemForm):
  6. [...]

看到了一个真正的失败消息:

  1. AssertionError: Expected 'NewListForm' to be called once. Called 0 times.

按照如下的方式编写代码:

lists/views.py (ch20l012-2)

  1. def new_list2(request):
  2. NewListForm(data=request.POST)

测试结果为:

  1. $ python manage.py test lists
  2. [...]
  3. Ran 38 tests in 0.143s
  4. OK

继续编写测试。如果表单中的数据有效,要在表单对象上调用 save 方法:

lists/tests/test_views.py (ch20l013)

  1. from unittest.mock import patch, Mock
  2. [...]
  3. @patch('lists.views.NewListForm')
  4. class NewListViewUnitTest(unittest.TestCase):
  5. def setUp(self):
  6. self.request = HttpRequest()
  7. self.request.POST['text'] = 'new list item'
  8. self.request.user = Mock()
  9. def test_passes_POST_data_to_NewListForm(self, mockNewListForm):
  10. new_list2(self.request)
  11. mockNewListForm.assert_called_once_with(data=self.request.POST)
  12. def test_saves_form_with_owner_if_form_valid(self, mockNewListForm):
  13. mock_form = mockNewListForm.return_value
  14. mock_form.is_valid.return_value = True
  15. new_list2(self.request)
  16. mock_form.save.assert_called_once_with(owner=self.request.user)

据此,可以写出如下视图:

lists/views.py (ch20l014)

  1. def new_list2(request):
  2. form = NewListForm(data=request.POST)
  3. form.save(owner=request.user)

如果表单中的数据有效,让视图做一个重定向,把我们带到一个页面,查看表单刚刚创建的对象。所以要模拟视图的另一个协作者——redirect 函数:

lists/tests/test_views.py (ch20l015)

  1. @patch('lists.views.redirect')
  2. def test_redirects_to_form_returned_object_if_form_valid(
  3. self, mock_redirect, mockNewListForm
  4. ):
  5. mock_form = mockNewListForm.return_value
  6. mock_form.is_valid.return_value = True
  7. response = new_list2(self.request)
  8. self.assertEqual(response, mock_redirect.return_value)
  9. mock_redirect.assert_called_once_with(mock_form.save.return_value)

❶ 模拟 redirect 函数,不过这次直接在方法上模拟。

patch 修饰器先应用最内层的那个,所以这个驭件在 mockNewListForm 之前传入方法。

❸ 指定测试的是表单中数据有效的情况。

❹ 检查视图的响应是否为 redirect 函数的结果。

❺ 然后检查调用 redirect 函数时传入的参数是否为在表单上调用 save 方法得到的对象。

据此,可以编写如下视图:

lists/views.py (ch20l016)

  1. def new_list2(request):
  2. form = NewListForm(data=request.POST)
  3. list_ = form.save(owner=request.user)
  4. return redirect(list_)

测试结果为:

  1. $ python manage.py test lists
  2. [...]
  3. Ran 40 tests in 0.163s
  4. OK

然后测试表单提交失败的情况——如果表单中的数据无效,渲染首页的模板:

lists/tests/test_views.py (ch20l017)

  1. @patch('lists.views.render')
  2. def test_renders_home_template_with_form_if_form_invalid(
  3. self, mock_render, mockNewListForm
  4. ):
  5. mock_form = mockNewListForm.return_value
  6. mock_form.is_valid.return_value = False
  7. response = new_list2(self.request)
  8. self.assertEqual(response, mock_render.return_value)
  9. mock_render.assert_called_once_with(
  10. self.request, 'home.html', {'form': mock_form}
  11. )

测试的结果为:

  1. AssertionError: <HttpResponseRedirect status_code=302, "te[114 chars]%3E"> !=
  2. <MagicMock name='render()' id='140244627467408'>

06 - 图12 在驭件上调用断言方法时一定要运行测试,确认它会失败。因为输入断言函数时太容易出错,会导致调用的模拟方法没有任何作用。(我会写成 asssert_called_once_with,用了三个 s。你自己可以试一下!)

故意犯个错误,确保测试全面:

lists/views.py (ch20l018)

  1. def new_list2(request):
  2. form = NewListForm(data=request.POST)
  3. list_ = form.save(owner=request.user)
  4. if form.is_valid():
  5. return redirect(list_)
  6. return render(request, 'home.html', {'form': form})

测试本不应该通过却通过了!那就再写一个测试:

lists/tests/test_views.py (ch20l019)

  1. def test_does_not_save_if_form_invalid(self, mockNewListForm):
  2. mock_form = mockNewListForm.return_value
  3. mock_form.is_valid.return_value = False
  4. new_list2(self.request)
  5. self.assertFalse(mock_form.save.called)

这个测试会失败:

  1. self.assertFalse(mock_form.save.called)
  2. AssertionError: True is not false

最终得到了一个精简的视图:

lists/views.py

  1. def new_list2(request):
  2. form = NewListForm(data=request.POST)
  3. if form.is_valid():
  4. list_ = form.save(owner=request.user)
  5. return redirect(list_)
  6. return render(request, 'home.html', {'form': form})

测试结果为:

  1. $ python manage.py test lists
  2. [...]
  3. Ran 42 tests in 0.163s
  4. OK

23.5 下移到表单层

已经写好了视图函数,这个视图基于设想的表单 NewItemForm,而且这个表单现在还不存在。

需要在表单对象上调用 save 方法创建一个新清单,还要使用通过验证的 POST 数据创建一个新待办事项。如果直接使用 ORM,save 方法可以写成这样:

  1. class NewListForm(models.Form):
  2. def save(self, owner):
  3. list_ = List()
  4. if owner:
  5. list_.owner = owner
  6. list_.save()
  7. item = Item()
  8. item.list = list_
  9. item.text = self.cleaned_data['text']
  10. item.save()

这种实现方式依赖于模型层的两个类,即 ItemList。那么隔离性好的测试应该怎么写呢?

  1. class NewListFormTest(unittest.TestCase):
  2. @patch('lists.forms.List')
  3. @patch('lists.forms.Item')
  4. def test_save_creates_new_list_and_item_from_post_data(
  5. self, mockItem, mockList
  6. ):
  7. mock_item = mockItem.return_value
  8. mock_list = mockList.return_value
  9. user = Mock()
  10. form = NewListForm(data={'text': 'new item text'})
  11. form.is_valid()
  12. def check_item_text_and_list():
  13. self.assertEqual(mock_item.text, 'new item text')
  14. self.assertEqual(mock_item.list, mock_list)
  15. self.assertTrue(mock_list.save.called)
  16. mock_item.save.side_effect = check_item_text_and_list
  17. form.save(owner=user)
  18. self.assertTrue(mock_item.save.called)

❶ 为表单模拟两个来自下部模型层的协作者。

❷ 必须调用 is_valid() 方法,这样表单才会把通过验证的数据存储到 .cleaned_data 字典中。

❸ 使用 side_effect 方法确保保存新待办事项对象时,使用已经保存的清单,而且待办事项中的文本正确。

❹ 一如既往,再次确认调用了副作用函数。

唉,这个测试写得好丑!

始终倾听测试的心声:从应用中删除ORM代码

测试又在诉说什么:Django ORM 很难模拟,而且表单类需要较深入地了解 ORM 的工作方式。再次使用一厢情愿式编程,想想表单想用什么样的简单 API 呢?下面这种怎么样:

  1. def save(self):
  2. List.create_new(first_item_text=self.cleaned_data['text'])

又冒出个想法:要不在 List 类 1 中定义一个辅助函数,封装保存新清单对象及相关的第一个待办事项这部分逻辑。

1你很可能想定义一个单独的函数,但是放在类中有利于记住它在哪儿。更重要的是,还能表明这个函数的作用。嗯,我保证等我写完这本书之后会这么做的。尖锐的批评就此打住吧!

那就先为这个想法编写测试:

lists/tests/test_forms.py (ch20l021)

  1. import unittest
  2. from unittest.mock import patch, Mock
  3. from django.test import TestCase
  4. from lists.forms import (
  5. DUPLICATE_ITEM_ERROR, EMPTY_ITEM_ERROR,
  6. ExistingListItemForm, ItemForm, NewListForm
  7. )
  8. from lists.models import Item, List
  9. [...]
  10. class NewListFormTest(unittest.TestCase):
  11. @patch('lists.forms.List.create_new')
  12. def test_save_creates_new_list_from_post_data_if_user_not_authenticated(
  13. self, mock_Listcreatenew
  14. ):
  15. user = Mock(is_authenticated=False)
  16. form = NewListForm(data={'text': 'new item text'})
  17. form.is_valid()
  18. form.save(owner=user)
  19. mock_Listcreatenew.assert_called_once_with(
  20. first_item_text='new item text'
  21. )

既然已经测试了这种情况,那就再写个测试检查用户已经通过认证的情况吧:

lists/tests/test_forms.py (ch20l022)

  1. @patch('lists.forms.List.create_new')
  2. def test_save_creates_new_list_with_owner_if_user_authenticated(
  3. self, mock_Listcreatenew
  4. ):
  5. user = Mock(is_authenticated=True)
  6. form = NewListForm(data={'text': 'new item text'})
  7. form.is_valid()
  8. form.save(owner=user)
  9. mock_Listcreatenew.assert_called_once_with(
  10. first_item_text='new item text', owner=user
  11. )

可以看出,这个测试易读多了。下面开始实现新表单。先从 import 语句开始:

lists/forms.py (ch20l023)

  1. from lists.models import Item, List

此时驭件说要定义一个占位的 create_new 方法:

  1. AttributeError: <class 'lists.models.List'> does not have the attribute
  2. 'create_new'

lists/models.py

  1. class List(models.Model):
  2. def get_absolute_url(self):
  3. return reverse('view_list', args=[self.id])
  4. def create_new():
  5. pass

几步之后,最终写出如下的 save 方法:

lists/forms.py (ch20l025)

  1. class NewListForm(ItemForm):
  2. def save(self, owner):
  3. if owner.is_authenticated:
  4. List.create_new(first_item_text=self.cleaned_data['text'], owner=owner)
  5. else:
  6. List.create_new(first_item_text=self.cleaned_data['text'])

而且测试也通过了:

  1. $ python manage.py test lists
  2. Ran 44 tests in 0.192s
  3. OK

把 ORM 代码放到辅助方法中

从编写隔离测试的过程中我们学会了一项技能——“ORM 辅助方法”。

使用 Django 的ORM 可以通过十分易读的句法(肯定比纯 SQL 好得多)快速完成工作。但有些人喜欢尽量减少应用中使用的 ORM 代码量,尤其不喜欢在视图层和表单层使用 ORM 代码。

一个原因是,测试这几层时更容易;另一个原因是,必须定义辅助方法,这样能更清晰地表述域逻辑。请对比这段代码:

  1. list = List()
  2. list.save()
  3. item = Item()
  4. item.list = list_
  5. item.text = self.cleaned_data['text']
  6. item.save()

和这段代码:

  1. List.create_new(first_item_text=self.cleaned_data['text'])

辅助方法同样可用于读写查询。假设有这样一行代码:

  1. Book.objects.filter(in_print=True, pub_date__lte=datetime.today())

和如下的辅助方法相比,孰好孰坏一目了然:

  1. Book.all_available_books()

定义辅助方法时,可以起个适当的名字,表明它们在业务逻辑中的作用。使用辅助方法不仅可以让代码的条理变得更清晰,还能把所有 ORM 调用都放在模型层,因此整个应用不同部分之间的耦合更松散。

23.6 下移到模型层

在模型层不用再编写隔离测试了,因为模型层的目的就是与数据库结合在一起工作,所以编写整合测试更合理:

lists/tests/test_models.py (ch20l026)

  1. class ListModelTest(TestCase):
  2. def testgetabsolute_url(self):
  3. list_ = List.objects.create()
  4. self.assertEqual(list_.get_absolute_url(), f'lists{list_.id}/')
  5. def testcreatenew_creates_list_and_first_item(self):
  6. List.create_new(first_item_text='new item text')
  7. new_item = Item.objects.first()
  8. self.assertEqual(new_item.text, 'new item text')
  9. new_list = List.objects.first()
  10. self.assertEqual(new_item.list, new_list)

测试的结果为:

  1. TypeError: create_new() got an unexpected keyword argument 'first_item_text'

根据测试结果,可以先把实现方式写成这样:

lists/models.py (ch20l027)

  1. class List(models.Model):
  2. def get_absolute_url(self):
  3. return reverse('view_list', args=[self.id])
  4. @staticmethod
  5. def create_new(first_item_text):
  6. list_ = List.objects.create()
  7. Item.objects.create(text=first_item_text, list=list_)

注意,一路走下来,直到模型层,由视图层和表单层驱动,得到了一个设计良好的模型,但是 List 模型还不支持属主。

现在,测试清单应该有一个属主。添加如下测试:

lists/tests/test_models.py (ch20l028)

  1. from django.contrib.auth import get_user_model
  2. User = get_user_model()
  3. [...]
  4. def testcreatenew_optionally_saves_owner(self):
  5. user = User.objects.create()
  6. List.create_new(first_item_text='new item text', owner=user)
  7. new_list = List.objects.first()
  8. self.assertEqual(new_list.owner, user)

既然已经打开这个文件,那就再为 owner 属性编写一些测试吧:

lists/tests/test_models.py (ch20l029)

  1. class ListModelTest(TestCase):
  2. [...]
  3. def test_lists_can_have_owners(self):
  4. List(owner=User()) # 不该抛出异常
  5. def test_list_owner_is_optional(self):
  6. List().full_clean() # 不该抛出异常

这两个测试和前一章使用的测试几乎一样,不过我稍微改了些,不让它们保存对象。因为对这个测试而言,内存中有这些对象就行了。

06 - 图13 尽量多用内存中(未保存)的模型对象,这样测试运行得更快。

测试的结果为:

  1. $ python manage.py test lists
  2. [...]
  3. ERROR: testcreatenew_optionally_saves_owner
  4. TypeError: create_new() got an unexpected keyword argument 'owner'
  5. [...]
  6. ERROR: test_lists_can_have_owners (lists.tests.test_models.ListModelTest)
  7. TypeError: 'owner' is an invalid keyword argument for this function
  8. [...]
  9. Ran 48 tests in 0.204s
  10. FAILED (errors=2)

然后按照前一章使用的方式实现模型:

lists/models.py (ch20l030-1)

  1. from django.conf import settings
  2. [...]
  3. class List(models.Model):
  4. owner = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True)
  5. [...]

此时,测试的结果中有各种完整性失败,执行迁移后才能解决这些问题:

  1. django.db.utils.OperationalError: no such column: lists_list.owner_id

执行迁移后再运行测试,会看到下面三个失败:

  1. ERROR: testcreatenew_optionally_saves_owner
  2. TypeError: create_new() got an unexpected keyword argument 'owner'
  3. [...]
  4. ValueError: Cannot assign "<SimpleLazyObject:
  5. <django.contrib.auth.models.AnonymousUser object at 0x7f5b2380b4e0>>":
  6. "List.owner" must be a "User" instance.
  7. ValueError: Cannot assign "<SimpleLazyObject:
  8. <django.contrib.auth.models.AnonymousUser object at 0x7f5b237a12e8>>":
  9. "List.owner" must be a "User" instance.

先处理第一个失败。这个失败由 create_new 方法导致:

lists/models.py (ch20l030-3)

  1. @staticmethod
  2. def create_new(first_item_text, owner=None):
  3. list_ = List.objects.create(owner=owner)
  4. Item.objects.create(text=first_item_text, list=list_)

回到视图层

视图层以前的两个整合测试失败了,怎么回事呢?

  1. ValueError: Cannot assign "<SimpleLazyObject:
  2. <django.contrib.auth.models.AnonymousUser object at 0x7fbad1cb6c10>>":
  3. "List.owner" must be a "User" instance.

啊,原来是因为以前的视图没有分清谁才是清单的属主:

lists/views.py

  1. if form.is_valid():
  2. list_ = List()
  3. list_.owner = request.user
  4. list_.save()

这一刻才意识到以前的代码没有满足需求。修正这个问题,让所有测试都通过:

lists/views.py (ch20l031)

  1. def new_list(request):
  2. form = ItemForm(data=request.POST)
  3. if form.is_valid():
  4. list_ = List()
  5. if request.user.is_authenticated:
  6. list_.owner = request.user
  7. list_.save()
  8. form.save(for_list=list_)
  9. return redirect(list_)
  10. else:
  11. return render(request, 'home.html', {"form": form})
  12. def new_list2(request):
  13. [...]

06 - 图14 整合测试的好处之一是,可以捕获这种无法轻易预测的交互。我们忘了编写测试检查用户没有通过验证的情况,可是整合测试会由上而下使用整个组件,最终模型层出现了错误,提醒我们忘了一些事。

  1. $ python manage.py test lists
  2. [...]
  3. Ran 48 tests in 0.175s
  4. OK

23.7 关键时刻,以及使用模拟技术的风险

换掉以前的视图,使用新视图试试。调换视图可以在 urls.py 中完成:

lists/urls.py

  1. [...]
  2. url(r'^new$', views.new_list2, name='new_list'),

还得删除整合测试类上的 unittest.skip 修饰器,看看为清单属主编写的新代码是否真得可用:

lists/tests/test_views.py (ch20l033)

  1. class NewListViewIntegratedTest(TestCase):
  2. def test_can_save_a_POST_request(self):
  3. [...]
  4. def test_list_owner_is_saved_if_user_is_authenticated(self):
  5. [...]
  6. self.assertEqual(list_.owner, user)

那么测试的结果如何呢?啊,情况不妙!

  1. ERROR: test_list_owner_is_saved_if_user_is_authenticated
  2. [...]
  3. ERROR: test_can_save_a_POST_request
  4. [...]
  5. ERROR: test_redirects_after_POST
  6. (lists.tests.test_views.NewListViewIntegratedTest)
  7. File "...superlistslistsviews.py", line 30, in new_list2
  8. return redirect(list_)
  9. [...]
  10. TypeError: argument of type 'NoneType' is not iterable
  11. FAILED (errors=3)

测试隔离有个很重要的知识点:虽然它有可能帮助你为单独各层做出好的设计,但无法自动验证各层之间的集成情况。

上述测试结果表明,视图期望表单返回一个待办事项:

lists/views.py

  1. list_ = form.save(owner=request.user)
  2. return redirect(list_)

但没让表单返回任何值:

lists/forms.py

  1. def save(self, owner):
  2. if owner.is_authenticated:
  3. List.create_new(first_item_text=self.cleaned_data['text'], owner=owner)
  4. else:
  5. List.create_new(first_item_text=self.cleaned_data['text'])

23.8 把层与层之间的交互当作“合约”

除了隔离的单元测试之外,就算什么都没写,功能测试最终也能发现这个失误。但理想情况下,我们希望尽早得到反馈——功能测试可能要运行好几分钟,应用变大之后甚至可能要几个小时。在这种问题发生之前有没有办法避免呢?

理论上讲,有办法:把层与层之间的交互看成一种“合约”。只要模拟一层的行为,就要在心里记住,层与层之间现在有了隐形合约,这一层的驭件或许可以转移到下一层的测试中。

遗忘的合约如下所示:

lists/tests/test_views.py

  1. @patch('lists.views.redirect')
  2. def test_redirects_to_form_returned_object_if_form_valid(
  3. self, mock_redirect, mockNewListForm
  4. ):
  5. mock_form = mockNewListForm.return_value
  6. mock_form.is_valid.return_value = True
  7. response = new_list2(self.request)
  8. self.assertEqual(response, mock_redirect.return_value)
  9. mock_redirect.assert_called_once_with(mock_form.save.return_value)

➊ 模拟的 form.save 方法返回一个对象,我们希望在视图中使用这个对象。

23.8.1 找出隐形合约

现在要审查 NewListViewUnitTest 类中的每个测试,看看各驭件在隐形合约中表述了什么:

lists/tests/test_views.py

  1. def test_passes_POST_data_to_NewListForm(self, mockNewListForm):
  2. [...]
  3. mockNewListForm.assert_called_once_with(data=self.request.POST)
  4. def test_saves_form_with_owner_if_form_valid(self, mockNewListForm):
  5. mock_form = mockNewListForm.return_value
  6. mock_form.is_valid.return_value = True
  7. new_list2(self.request)
  8. mock_form.save.assert_called_once_with(owner=self.request.user)
  9. def test_does_not_save_if_form_invalid(self, mockNewListForm):
  10. [...]
  11. mock_form.is_valid.return_value = False
  12. [...]
  13. @patch('lists.views.redirect')
  14. def test_redirects_to_form_returned_object_if_form_valid(
  15. self, mock_redirect, mockNewListForm
  16. ):
  17. [...]
  18. mock_redirect.assert_called_once_with(mock_form.save.return_value)
  19. @patch('lists.views.render')
  20. def test_renders_home_template_with_form_if_form_invalid(
  21. [...]

❶ 需要传入 POST 请求中的数据,以便初始化表单。

❷ 表单对象要能响应 is_valid() 方法,而且要根据输入值判断返回 True 还是 False

❸ 表单对象要能响应 .save 方法,而且传入的参数值是 request.user,然后根据用户是否登录做相应处理。

❹ 表单对象的 .save 方法应该返回一个新清单对象,以便视图把用户重定向到显示这个对象的页面。

仔细分析表单测试,可以看出,其实只明确测试了➌。➊和➋很幸运,因为这是 Django 中 ModelForm 的默认特性,而且针对父类 ItemForm 的测试涵盖了这两点。

但➍却成了漏网之鱼。

06 - 图15 使用由外而内的 TDD 技术编写隔离测试时,要记住每个测试在合约中对下一层应该实现的功能做出的隐含假设,而且记得稍后要回来测试这些假设。你可以在便签上记下来,也可以使用 self.fail 编写占位测试。

23.8.2 修正由于疏忽导致的问题

下面添加一个新测试,确保表单返回刚刚保存的清单:

lists/tests/test_forms.py (ch20l038-1)

  1. @patch('lists.forms.List.create_new')
  2. def test_save_returns_new_list_object(self, mock_Listcreatenew):
  3. user = Mock(is_authenticated=True)
  4. form = NewListForm(data={'text': 'new item text'})
  5. form.is_valid()
  6. response = form.save(owner=user)
  7. self.assertEqual(response, mock_Listcreatenew.return_value)

其实,这是个很好的示例——和 List.create_new 之间有隐形合约,希望这个方法返回刚创建的清单对象。下面为这个需求添加一个占位测试:

lists/tests/test_models.py (ch20l038-2)

  1. class ListModelTest(TestCase):
  2. [...]
  3. def testcreatereturns_new_list_object(self):
  4. self.fail()

得到一个失败测试,告诉我们要修正表单对象的 save 方法:

  1. AssertionError: None != <MagicMock name='create_new()' id='139802647565536'>
  2. FAILED (failures=2, errors=3)

修正方法如下:

lists/forms.py (ch20l039-1)

  1. class NewListForm(ItemForm):
  2. def save(self, owner):
  3. if owner.is_authenticated:
  4. return List.create_new(first_item_text=self.cleaned_data['text'], owner=owner)
  5. else:
  6. return List.create_new(first_item_text=self.cleaned_data['text'])

这才刚开始。下面应该看一下占位测试:

  1. [...]
  2. FAIL: testcreatereturns_new_list_object
  3. self.fail()
  4. AssertionError: None
  5. FAILED (failures=1, errors=3)

编写这个测试:

lists/tests/test_models.py (ch20l039-2)

  1. def testcreatereturns_new_list_object(self):
  2. returned = List.create_new(first_item_text='new item text')
  3. new_list = List.objects.first()
  4. self.assertEqual(returned, new_list)

测试结果为:

  1. AssertionError: None != <List: List object>

然后加上返回值:

lists/models.py (ch20l039-3)

  1. @staticmethod
  2. def create_new(first_item_text, owner=None):
  3. list_ = List.objects.create(owner=owner)
  4. Item.objects.create(text=first_item_text, list=list_)
  5. return list_

现在整个测试组件都可以通过了:

  1. $ python manage.py test lists
  2. [...]
  3. Ran 50 tests in 0.169s
  4.  
  5. OK

23.9 还缺一个测试

以上就是由测试驱动开发出来的保存清单属主功能,这个功能可以正常使用。不过,功能测试却无法通过:

  1. $ python manage.py test functional_tests.test_my_lists
  2. selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
  3. element: Reticulate splines

失败的原因是有一个功能没实现,即清单对象的 .name 属性。这里还可以使用前一章的测试和代码:

lists/tests/test_models.py (ch20l040)

  1. def test_list_name_is_first_item_text(self):
  2. list_ = List.objects.create()
  3. Item.objects.create(list=list_, text='first item')
  4. Item.objects.create(list=list_, text='second item')
  5. self.assertEqual(list_.name, 'first item')

(再次说明,因为这是模型层测试,所以使用 ORM 没问题。你可能想使用驭件编写这个测试,不过这么做没什么意义。)

lists/models.py (ch20l041)

  1. @property
  2. def name(self):
  3. return self.item_set.first().text

现在功能测试可以通过了:

  1. $ python manage.py test functional_tests.test_my_lists
  2.  
  3. Ran 1 test in 21.428s
  4.  
  5. OK

23.10 清理:保留哪些整合测试

现在一切都可以正常运行了,要删除一些多余的测试,还要决定是否保留以前的整合测试。

23.10.1 删除表单层多余的代码

可以把以前针对 ItemForm 类中 save 方法的测试删掉:

lists/tests/test_forms.py

  1. --- aliststests/test_forms.py
  2. +++ bliststests/test_forms.py
  3. @@ -23,14 +23,6 @@ class ItemFormTest(TestCase):
  4. self.assertEqual(form.errors['text'], [EMPTY_ITEM_ERROR])
  5. - def test_form_save_handles_saving_to_a_list(self):
  6. - list_ = List.objects.create()
  7. - form = ItemForm(data={'text': 'do me'})
  8. - new_item = form.save(for_list=list_)
  9. - self.assertEqual(new_item, Item.objects.first())
  10. - self.assertEqual(new_item.text, 'do me')
  11. - self.assertEqual(new_item.list, list_)
  12. -

对应用的代码而言,可以把 forms.py 中两个多余的 save 方法删掉:

lists/forms.py

  1. --- alistsforms.py
  2. +++ blistsforms.py
  3. @@ -22,11 +22,6 @@ class ItemForm(forms.models.ModelForm):
  4. self.fields['text'].error_messages['required'] = EMPTY_ITEM_ERROR
  5. - def save(self, for_list):
  6. - self.instance.list = for_list
  7. - return super().save()
  8. -
  9. -
  10. class NewListForm(ItemForm):
  11. @@ -52,8 +47,3 @@ class ExistingListItemForm(ItemForm):
  12. e.error_dict = {'text': [DUPLICATE_ITEM_ERROR]}
  13. self.updateerrors(e)
  14. -
  15. -
  16. - def save(self):
  17. - return forms.models.ModelForm.save(self)
  18. -

23.10.2 删除以前实现的视图

现在,可以把以前的 new_list 视图完全删掉,再把 new_list2 重命名为 new_list

lists/tests/test_views.py

  1. -from lists.views import new_list, new_list2
  2. +from lists.views import new_list
  3. class HomePageTest(TestCase):
  4. @@ -75,7 +75,7 @@ class NewListViewIntegratedTest(TestCase):
  5. request = HttpRequest()
  6. request.user = User.objects.create(email='a@b.com')
  7. request.POST['text'] = 'new list item'
  8. - new_list2(request)
  9. + new_list(request)
  10. list_ = List.objects.first()
  11. self.assertEqual(list_.owner, request.user)
  12. @@ -91,21 +91,21 @@ class NewListViewUnitTest(unittest.TestCase):
  13. def test_passes_POST_data_to_NewListForm(self, mockNewListForm):
  14. - new_list2(self.request)
  15. + new_list(self.request)
  16. [.. several more]

lists/urls.py

  1. --- alistsurls.py
  2. +++ blistsurls.py
  3. @@ -3,7 +3,7 @@ from django.conf.urls import url
  4. from lists import views
  5. urlpatterns = [
  6. - url(r'^new$', views.new_list2, name='new_list'),
  7. + url(r'^new$', views.new_list, name='new_list'),
  8. url(r'^(\d+)/$', views.view_list, name='view_list'),
  9. url(r'^users/(.+)/$', views.my_lists, name='my_lists'),
  10. ]

lists/views.py (ch20l047)

  1. def new_list(request):
  2. form = NewListForm(data=request.POST)
  3. if form.is_valid():
  4. list_ = form.save(owner=request.user)
  5. [...]

然后检查所有测试是否仍能通过:

  1. OK

23.10.3 删除视图层多余的代码

最后要决定保留哪些整合测试(如果需要保留的话)。

一种方法是全部删除,让功能测试捕获集成问题。这么做完全可行。

不过,从前文得知,如果在集成各层时犯了小错误,整合测试可以提醒你。可以保留部分测试,作为完整性检查,以便得到快速反馈。

要不就保留下面这三个测试吧:

lists/tests/test_views.py (ch20l048)

  1. class NewListViewIntegratedTest(TestCase):
  2. def test_can_save_a_POST_request(self):
  3. self.client.post('listsnew', data={'text': 'A new list item'})
  4. self.assertEqual(Item.objects.count(), 1)
  5. new_item = Item.objects.first()
  6. self.assertEqual(new_item.text, 'A new list item')
  7. def test_for_invalid_input_doesnt_save_but_shows_errors(self):
  8. response = self.client.post('listsnew', data={'text': ''})
  9. self.assertEqual(List.objects.count(), 0)
  10. self.assertContains(response, escape(EMPTY_ITEM_ERROR))
  11. def test_list_owner_is_saved_if_user_is_authenticated(self):
  12. user = User.objects.create(email='a@b.com')
  13. self.client.force_login(user)
  14. self.client.post('listsnew', data={'text': 'new item'})
  15. list_ = List.objects.first()
  16. self.assertEqual(list_.owner, user)

如果最终决定保留中间层的测试,我认为这三个不错,因为我觉得它们涵盖了大部分集成操作:测试了整个组件,从请求直到数据库,而且覆盖了视图最重要的三个用例。

23.11 总结:什么时候编写隔离测试,什么时候编写整合测试

Django 提供的测试工具为快速编写整合测试提供了便利。测试运行程序能帮助我们创建一个存在于内存中的数据库,运行速度很快,而且在两次测试之间还能重建数据库。使用 TestCase 类和测试客户端测试视图很简单,可以检查是否修改了数据库中的对象,确认 URL 映射是否可用,还能检查渲染模板的情况。这些工具降低了测试的门槛,而且对整个组件而言也能获得不错的覆盖度。

但是,从设计的角度来说,这种整合测试比不上严格的单元测试和由外而内的 TDD,因为它没有后者的优势全面。

就本章的示例而言,可以比较一下修改前后的代码:

  1. def new_list(request):
  2. form = ItemForm(data=request.POST)
  3. if form.is_valid():
  4. list_ = List()
  5. if not isinstance(request.user, AnonymousUser):
  6. list_.owner = request.user
  7. list_.save()
  8. form.save(for_list=list_)
  9. return redirect(list_)
  10. else:
  11. return render(request, 'home.html', {"form": form})
  12. def new_list(request):
  13. form = NewListForm(data=request.POST)
  14. if form.is_valid():
  15. list_ = form.save(owner=request.user)
  16. return redirect(list_)
  17. return render(request, 'home.html', {'form': form})

如果想省点儿事,不走隔离测试这条路,你会下功夫重构视图函数吗?我知道写作本书草稿时我不会。我希望自己在真实的项目中会这么做,但也不能保证。可是编写隔离测试却让你看清代码复杂在何处。

23.11.1 以复杂度为准则

不得不说,处理复杂问题时才能体现隔离测试的优势。本书中的例子非常简单,还不太值得这么做。就算是本章的例子,我也能说服自己,完全不用编写这些隔离测试。

可一旦应用变得复杂,比如视图和模型之间分了更多层、需要编写辅助方法或自己的类,那多编写一些隔离测试或许就能从中受益了。

23.11.2 两种测试都要写吗

功能测试组件能告诉我们集成各部分代码时是否有问题。隔离测试能帮助我们设计出更好的代码,还能验证细节的处理是否正确。那么中间层集成测试还有其他作用吗?

我想,如果集成测试能更快地提供反馈,或者能更精确地找出集成问题的原因所在,那么答案就是肯定的。集成测试的优势之一是,它在调用跟踪中提供的调试信息比功能测试详细。

甚至还可以把各组件分开——可以编写一个速度快、隔离的单元测试组件,完全不用 manage.py,因为这些测试不需要 Django 测试运行程序提供的任何数据库清理操作。然后使用 Django 提供的工具编写中间层测试,最后使用功能测试检查与过渡服务器交互的各层。如果各层提供的功能循序渐进,或许就可以采用这种方案。

到底怎么做,要根据实际情况而定。我希望读过这一章之后,你能体会到如何权衡。第 26 章会继续讨论这个话题。

23.11.3 继续前行

对新版代码很满意,那就合并到主分支吧:

  1. $ git add .
  2. $ git commit -m "add list owners via forms. more isolated tests"
  3. $ git checkout master
  4. $ git checkout -b master-noforms-noisolation-bak # 也可以做个备份
  5. $ git checkout master
  6. $ git reset --hard more-isolation # 把主分支重设到这个分支

现在,运行功能测试要花很长时间,我想知道我们能不能做些什么来改善这种状况。

不同测试类型以及解耦 ORM 代码的利弊

功能测试

  • 从用户的角度出发,最大程度上保证应用可以正常运行。
  • 但是,反馈循环用时长。
  • 无法帮助我们写出简洁的代码。

整合测试(依赖于 ORM 或 Django 测试客户端等)

  • 编写速度快。
  • 易于理解。
  • 发现任何集成问题都会提醒你。
  • 但是,并不总能得到好的设计(这取决于你自己)。
  • 一般运行速度比隔离测试慢。

隔离测试(使用驭件)

  • 涉及的工作量最大。
  • 可能难以阅读和理解。
  • 但是,这种测试最能引导你实现更好的设计。
  • 运行速度最快。

解耦应用代码和 ORM 代码

力求隔离测试的后果之一是,我们不得不从视图和表单等处删除 ORM 代码,把它们放到辅助函数或者辅助方法中。如果从解耦应用代码和 ORM 代码的角度看,这么做有好处,还能提高代码的可读性。当然,所有事情都一样,要结合实际情况判断是否值得付出额外精力去做。

第 24 章 持续集成

网站越变越大,运行所有功能测试的时间也越来越长。如果时长一直增加,我们很可能不再运行功能测试。

为了避免发生这种情况,可以搭建一个“持续集成”(Continuous Integration,简称 CI)服务器,自动运行功能测试。这样,在日常开发中,只需运行当下关注的功能测试,整个测试组件则交给 CI 服务器自动运行。如果不小心破坏了某项功能,CI 服务器会通知我们。单元测试的运行速度一直很快,每隔几秒就可以运行一次。

现在,开发者喜欢使用的 CI 服务器是 Jenkins。Jenkins 使用 Java 开发,经常出问题,界面也不漂亮,但大家都在用,而且插件系统很棒,下面安装并运行 Jenkins。

24.1 安装Jenkins

CI 托管服务有很多,基本上都提供了一个立即就能使用的 Jenkins 服务器。我知道的就有 Sauce Labs、Travis、Circle-CI 和 ShiningPanda,可能还有更多。假设要在自己有控制权的服务器上安装所需的一切软件。

06 - 图16 把 Jenkins 安装在过渡服务器或生产服务器上可不是个好主意,因为有很多操作要交给 Jenkins 完成,比如重新引导过渡服务器。

要从 Jenkins 的官方 apt 仓库中安装最新版,因为 Ubuntu 默认安装的版本对本地化和 Unicode 支持还有些恼人的问题,而且默认配置也没监听外网:

  1. root@server:$ wget -q -O - https://pkg.jenkins.io/debian/jenkins-ci.org.key |\
  2. apt-key add -
  3. root@server:$ echo deb http://pkg.jenkins.io/debian-stable binary/ | tee \
  4. etcapt/sources.list.d/jenkins.list
  5. root@server:$ apt-get update
  6. root@server:$ apt-get install jenkins

(这是从 Jenkins 网站上查到的安装说明。)

此外还要安装几个依赖:

  1. root@server:$ apt-get install firefox python3-venv xvfb
  2. # 以及构建fabric3所需的依赖
  3. root@server:$ apt-get install build-essential libssl-dev libffi-dev

我们还要下载、解压和安装 geckodriver(我写到这里时,版本为 v0.17;你阅读时,记得换成最新版):

  1. root@server:$ wget https://github.com/mozilla/geckodriver/releases\
  2. downloadv0.17.0/geckodriver-v0.17.0-linux64.tar.gz
  3. root@server:$ tar -xvzf geckodriver-v0.17.0-linux64.tar.gz
  4. root@server:$ mv geckodriver usrlocal/bin
  5. root@server:$ geckodriver --version
  6. geckodriver 0.17.0

增加一些交换内存

Jenkins 是内存消耗大户。如果在 RAM 很小的虚拟主机上运行,会由于内存不足而崩溃。这时,要增加一些交换内存:

  1. $ fallocate -l 4G /swapfile
  2. $ mkswap /swapfile
  3. $ chmod 600 /swapfile
  4. $ swapon /swapfile

这样内存就充足了。

24.2 配置Jenkins

现在可以访问服务器的 URL/IP,通过 8080 端口访问 Jenkins,你会看到如图 24-1 所示的界面。

06 - 图17

图 24-1:Jenkins 解锁界面

24.2.1 首次解锁

解锁界面告诉我们,首次使用时要从磁盘中读取一个文件,解锁服务器。切换到终端,使用下述命令打印那个文件的内容:

  1. root@server$ cat varlib/jenkins/secrets/initialAdminPassword

24.2.2 现在建议安装的插件

接下来会让你选择安装“推荐的”插件。系统推荐的插件还不错。(作为自尊心强的技术宅,我们会本能地点击“自定义”。一开始我也是这么做的,但是自定义界面也没什么。别担心,稍后会再添加一些插件。)

24.2.3 配置管理员用户

接下来,设置登录 Jenkins 的用户名和密码,如图 24-2 所示。

06 - 图18

图 24-2:Jenkins 管理员用户配置界面

登录后会看到一个欢迎界面(如图 24-3 所示)。

06 - 图19

图 24-3:看到一个男仆(左上角),好奇怪

24.2.4 添加插件

依次点击这些链接:Manage Jenkins(管理 Jenkins)→ Manage Plugins(管理插件)→ Available(可用插件)。

我们将安装下述插件:

  • ShiningPanda
  • Xvfb

点击“Install”(如图 24-4 所示)。

{%}

图 24-4:安装插件中……

24.2.5 告诉Jenkins到哪里寻找Python 3和Xvfb

我们要告诉 ShiningPanda 插件,Python 3 安装在哪里(通常是 usrbin/python3,不过也可以执行 which python3 命令查看)。

  • Manage Jenkins(管理 Jenkins)→ Global Tool Configuration(全局工具配置)。

  • Python → Python installations(Python 安装位置)→ Add Python(添加 Python,如图 24-5 所示;可以放心忽略提醒消息)。

  • Xvfb installation(Xvfb 安装位置)→ Add Xvfb installation(添加 Xvfb 安装位置);在安装目录中输入 usrbin。

06 - 图21

图 24-5:Python 安装在哪里

24.2.6 设置HTTPS

为了提升 Jenkins 的安全性,最后还要设置 HTTPS。为此,我们将让 Nginx 使用自签名的证书,把发给 443 端口的请求转发给 8080 端口。这样设置之后,甚至可以让防火墙阻断 8080 端口。具体设置方法这里不详细讨论,你可以参照下述链接给出的说明。

  • Jenkins 官方的 Ubuntu 安装指南。
  • 如何创建自签名 SSL 证书。
  • 如何把 HTTP 重定向到 HTTPS。

24.3 设置项目

现在 Jenkins 基本配置好了,下面设置项目。

  • 点击“New Item”按钮。
  • 名称输入“Superlists”,选择“Freestyle project”,然后点击“OK”。
  • 添加 Git 仓库,如图 24-6 所示。

{%}

图 24-6:从 Git 仓库中获取源码

  • 设为每小时轮询一次(如图 24-7,看一下帮助文本,触发构建操作还有很多其他方式)。

{%}

图 24-7:轮询 GitHub,获取改动

  • 在一个 Python 3 虚拟环境中运行测试。

  • 单元测试和功能测试分开运行,如图 24-8 所示。

{%}

图 24-8:虚拟环境中执行的构建步骤

24.4 第一次构建

点击“Build Now”(现在构建)按钮,然后查看“Console Output”(终端输出),应该会看到类似下面的内容:

  1. Started by user harry
  2. Building in workspace varlib/jenkins/jobs/Superlists/workspace
  3. Fetching changes from the remote Git repository
  4. Fetching upstream changes from https://github.com/hjwp/bookexample.git
  5. Checking out Revision d515acebf7e173f165ce713b30295a4a6ee17c07 (origin/master)
  6. [workspace] $ binsh -xe tmpshiningpanda7260707941304155464.sh
  7. + pip install -r requirements.txt
  8. Requirement already satisfied (use --upgrade to upgrade): Django==1.11 in
  9. varlib/jenkins/shiningpanda/jobs/ddc1aed1/virtualenvs/d41d8cd9/lib/python3.3/
  10. site-packages
  11. (from -r requirements.txt (line 1))
  12. Requirement already satisfied (use --upgrade to upgrade): gunicorn==17.5 in
  13. varlib/jenkins/shiningpanda/jobs/ddc1aed1/virtualenvs/d41d8cd9/lib/python3.3/
  14. site-packages
  15. (from -r requirements.txt (line 3))
  16. Downloading/unpacking requests==2.0.0 (from -r requirements.txt (line 4))
  17. Running setup.py egg_info for package requests
  18. Installing collected packages: requests
  19. Running setup.py install for requests
  20. Successfully installed requests
  21. Cleaning up...
  22. + python manage.py test lists accounts
  23. ...................................................................
  24. ---------------------------------------------------------------------
  25. Ran 67 tests in 0.429s
  26. OK
  27. Creating test database for alias 'default'...
  28. Destroying test database for alias 'default'...
  29. + python manage.py test functional_tests
  30. EEEEEE
  31. ======================================================================
  32. ERROR: functional_tests.test_layout_and_styling (unittest.loader._FailedTest)
  33. ---------------------------------------------------------------------
  34. ImportError: Failed to import test module: functional_tests.test_layout_and_styling
  35. [...]
  36. ImportError: No module named 'selenium'
  37. Ran 6 tests in 0.001s
  38. FAILED (errors=6)
  39. Build step 'Virtualenv Builder' marked build as failure

啊,在虚拟环境中要安装 Selenium。

在构建步骤中添加一步,手动安装 Selenium:

  1. pip install -r requirements.txt
  2. python manage.py test accounts lists
  3. pip install selenium
  4. python manage.py test functional_tests

06 - 图25 有些人喜欢使用 test-requirements.txt 文件指定测试(不是主应用)需要的包。

然后再次点击“Build Now”(现在构建)按钮。

接下来会发生下面两件事中的一件。你可能会看到控制台输出了类似这样的错误消息:

  1. self.browser = webdriver.Firefox()
  2. [...]
  3. selenium.common.exceptions.WebDriverException: Message: 'The browser appears to
  4. have exited before we could connect. The output was: b"\\n(process:19757):
  5. GLib-CRITICAL **: g_slice_set_config: assertion \'sys_page_size == 0\'
  6. failed\\nError: no display specified\\n"'
  7. [...]
  8. selenium.common.exceptions.WebDriverException: Message: connection refused

也可能构建完全挂起(我至少遇到过一次)。出现这种情况的原因是 Firefox 无法启动,因为没有显示器可用。

24.5 设置虚拟显示器,让功能测试能在无界面的环境中运行

从调用跟踪中可以看出,Firefox 无法启动,因为服务器没有显示器。

这个问题有两种解决方法。第一种,换用无界面浏览器(headless browser),例如 PhantomJS 或 SlimerJS。这种工具绝对有存在的意义,最大的特点是运行速度快,但也有缺点。首先,它们不是“真正的”Web 浏览器,所以无法保证能捕获用户使用真正的浏览器时遇到的全部怪异行为。其次,它们在 Selenium 中的表现差异很大,因此要花费大量精力重写功能测试。

06 - 图26 我只把无界面浏览器当作开发工具,目的是在开发者的设备中提升功能测试的运行速度。在 CI 服务器上运行测试则使用真正的浏览器。

第二种解决方法是设置虚拟显示器:让服务器认为自己连接了显示器,这样 Firefox 就能正常运行了。这种工具很多,我们要使用的是“Xvfb”(X Virtual Framebuffer)1,因为它安装和使用都很简单,而且还有一个合用的 Jenkins 插件(现在知道为什么之前要安装它了吧)。

1如果想在 Python 代码中控制虚拟显示器,可以试试 pyvirtualdisplay。

回到项目页面,点击“Configure”(配置),找到“Build Environment”(构建环境)部分。启用虚拟显示器的方法很简单,勾选“Start Xvfb before the build, and shut it down after”(构建前启动 Xvfb,并在构建完成后关闭)即可,如图 24-9 所示。

{%}

图 24-9:有时配置方式很简单

现在构建过程顺利多了:

  1. [...]
  2. Xvfb starting$ usrbin/Xvfb :2 -screen 0 1024x768x24 -fbdir
  3. varlib/jenkins/2013-11-04_03-27-221510012427739470928xvfb
  4. [...]
  5. + python manage.py test lists accounts
  6. ...............................................................
  7. ---------------------------------------------------------------------
  8. Ran 63 tests in 0.410s
  9. OK
  10. Creating test database for alias 'default'...
  11. Destroying test database for alias 'default'...
  12. + pip install selenium
  13. Requirement already satisfied (use --upgrade to upgrade): selenium in
  14. varlib/jenkins/shiningpanda/jobs/ddc1aed1/virtualenvs/d41d8cd9/lib/python3.5/
  15. site-packages
  16. Cleaning up...
  17. + python manage.py test functional_tests
  18. ......F.
  19. ======================================================================
  20. FAIL: test_can_start_a_list_for_one_user
  21. (functional_tests.test_simple_list_creation.NewVisitorTest)
  22. ---------------------------------------------------------------------
  23. Traceback (most recent call last):
  24. File "...superlists/functional_tests/test_simple_list_creation.py", line
  25. 43, in test_can_start_a_list_for_one_user
  26. self.wait_for_row_in_list_table('2: Use peacock feathers to make a fly')
  27. File "...superlists/functional_tests/base.py", line 51, in
  28. wait_for_row_in_list_table
  29. raise e
  30. File "...superlists/functional_tests/base.py", line 47, in
  31. wait_for_row_in_list_table
  32. self.assertIn(row_text, [row.text for row in rows])
  33. AssertionError: '2: Use peacock feathers to make a fly' not found in ['1: Buy
  34. peacock feathers']
  35. ---------------------------------------------------------------------
  36. Ran 8 tests in 89.275s
  37. FAILED (errors=1)
  38. Creating test database for alias 'default'...
  39. [{'secure': False, 'domain': 'localhost', 'name': 'sessionid', 'expiry':
  40. 1920011311, 'path': '/', 'value': 'a8d8bbde33nreq6gihw8a7r1cc8bf02k'}]
  41. Destroying test database for alias 'default'...
  42. Build step 'Virtualenv Builder' marked build as failure
  43. Xvfb stopping
  44. Finished: FAILURE

就快成功了!为了调试错误,还需要截图。

06 - 图28  Jenkins 性能不足,所以不一定总会出现。你可能会看到不同的错误,或者根本没错误。不管怎样,下面介绍的截图工具和处理条件竞争的工具总有一天会用到。继续读吧!

24.6 截图

为了调试远程设备中意料之外的失败,最好能看到失败时的屏幕图片,或者还可以转储页面的 HTML。这些操作可在功能测试类中的 tearDown 方法里自定义逻辑实现。为此,要深入 unittest 的内部,使用私有属性 _outcomeForDoCleanups,不过像下面这样写也行:

functional_tests/base.py (ch21l006)

  1. import os
  2. from datetime import datetime
  3. [...]
  4. SCREEN_DUMP_LOCATION = os.path.join(
  5. os.path.dirname(os.path.abspath(__file__)), 'screendumps'
  6. )
  7. [...]
  8. def tearDown(self):
  9. if self.testhas_failed():
  10. if not os.path.exists(SCREEN_DUMP_LOCATION):
  11. os.makedirs(SCREEN_DUMP_LOCATION)
  12. for ix, handle in enumerate(self.browser.window_handles):
  13. self._windowid = ix
  14. self.browser.switch_to_window(handle)
  15. self.take_screenshot()
  16. self.dump_html()
  17. self.browser.quit()
  18. super().tearDown()
  19. def testhas_failed(self):
  20. # 有点令人费解,但我找不到更好的方法了
  21. return any(error for (method, error) in self._outcome.errors)

首先,必要时创建存放截图的目录。然后,遍历所有打开的浏览器选项卡和页面,调用一些 Selenium 提供的方法(get_screenshot_as_filebrowser.page_source)截图以及转储 HTML:

functional_tests/base.py (ch21l007)

  1. def take_screenshot(self):
  2. filename = self.getfilename() + '.png'
  3. print('screenshotting to', filename)
  4. self.browser.get_screenshot_as_file(filename)
  5. def dump_html(self):
  6. filename = self.getfilename() + '.html'
  7. print('dumping page HTML to', filename)
  8. with open(filename, 'w') as f:
  9. f.write(self.browser.page_source)

最后,使用一种方式生成唯一的文件名标识符。文件名中包括测试方法和测试类的名字,以及一个时间戳:

functional_tests/base.py (ch21l008)

  1. def getfilename(self):
  2. timestamp = datetime.now().isoformat().replace(':', '.')[:19]
  3. return '{folder}/{classname}.{method}-window{windowid}-{timestamp}'.format(
  4. folder=SCREEN_DUMP_LOCATION,
  5. classname=self.__class__.__name__,
  6. method=self._testMethodName,
  7. windowid=self._windowid,
  8. timestamp=timestamp
  9. )

可以先在本地测试一下,故意让某个测试失败,例如使用 self.fail(),会看到类似下面的输出:

  1. [...]
  2. screenshotting to ...superlists/functional_tests/screendumps/MyListsTest.test
  3. loggedin_users_lists_are_saved_as_my_lists-window0-2014-03-09T11.19.12.png
  4. dumping page HTML to ...superlists/functional_tests/screendumps/MyListsTest.t
  5. estloggedin_users_lists_are_saved_as_my_lists-window0-[...]

删掉 self.fail(),然后提交,再推送:

  1. $ git diff # 显示base.py中的改动
  2. $ echo "functional_tests/screendumps" >> .gitignore
  3. $ git commit -am "add screenshot on failure to FT runner"
  4. $ git push

在 Jenkins 中重新构建时,会看到类似下面的输出:

  1. screenshotting to varlib/jenkins/jobs/Superlists...functional_tests/
  2. screendumps/LoginTest.test_login_with_persona-window0-2014-01-22T17.45.12.png
  3. dumping page HTML to varlib/jenkins/jobs/Superlists...functional_tests/
  4. screendumps/LoginTest.test_login_with_persona-window0-2014-01-22T17.45.12.html

可以在“工作空间”中查看这些文件。工作空间是 Jenkins 用来存储源码以及运行测试所在的文件夹,如图 24-10 所示。

{%}

图 24-10:访问项目的工作空间

然后查看截图,如图 24-11 所示。

{%}

图 24-11:截图看起来正常

24.7 如有疑问,增加超时试试

嗯,显然没什么线索。老话说得好,如有疑问,增加超时:

functional_tests/base.py

  1. MAX_WAIT = 20

然后在 Jenkins 中点击“Buid now”(现在构建),再次构建,确认测试是否能通过,如图 24-12 所示。

{%}

图 24-12:前景更明朗

Jenkins 使用蓝色表示构建成功。居然没用绿色,真让人失望。不过看到太阳从云中探出头来,心情又舒畅了。这个图标表示成功构建和失败构建的平均比值正在发生变化,而且是向好的一面发展。

24.8 使用PhantomJS运行QUnit JavaScript测试

差点儿忘了还有一种测试——JavaScript 测试。现在的“测试运行程序”是真正的 Web 浏览器。若想在 Jenkins 中运行 JavaScript 测试,需要一种命令行测试运行程序。借此机会使用 PhantomJS。

24.8.1 安装node

别再假装用不到 JavaScript 了,做 Web 开发,离不开它。因此,要在自己的电脑中安装 node.js,这一步不可避免。

安装方法参见 node.js 下载页面中的说明。Windows 和 Mac 系统都有安装包,而且各种流行的 Linux 发行版都有各自的包 2。

2一定要下载最新版。在 Ubuntu 中别用默认的包,要使用 PPA。

安装好 node 之后,可以执行下面的命令安装 PhantomJS:

  1. root@server $ npm install -g phantomjs # -g的意思是系统全局安装

接下来要下载 QUnit/PhantomJS 测试运行程序。测试运行程序有很多(为了运行本书中的 QUnit 测试,我甚至还自己写过一个简单的),不过最好使用 QUnit 插件页面提到的那个。写作本书时,这个运行程序的仓库地址是 https://github.com/jonkemp/qunit-phantomjs-runner。只需要一个文件,runner.js。

最终得到的文件夹结构如下:

  1. $ tree listsstatictests/
  2. listsstatictests/
  3. ├── qunit-2.0.1.css
  4. ├── qunit-2.0.1.js
  5. ├── runner.js
  6. └── tests.html
  7.  
  8. 0 directories, 4 files

试一下这个运行程序:

  1. $ phantomjs listsstatictests/runner.js listsstatictests/tests.html
  2. Took 24ms to run 2 tests. 2 passed, 0 failed.

保险起见,故意破坏一个测试:

listsstaticlist.js (ch21l019)

  1. $('input[name="text"]').on('keypress', function () {
  2. // $('.has-error').hide();
  3. });

果然,测试失败了:

  1. $ phantomjs listsstatictests/runner.js listsstatictests/tests.html
  2.  
  3. Test failed: errors should be hidden on keypress
  4. Failed assertion: expected: false, but was: true
  5. file://...superlistslistsstatic/tests/tests.html:27:15
  6.  
  7. Took 27ms to run 2 tests. 1 passed, 1 failed.

很好!再改回去,提交并推送运行程序,然后将其添加到 Jenkins 的构建步骤中:

  1. $ git checkout listsstaticlist.js
  2. $ git add listsstatictests/runner.js
  3. $ git commit -m "Add phantomjs test runner for javascript tests"
  4. $ git push

24.8.2 在Jenkins中添加构建步骤

再次编辑项目配置,为每个 JavaScript 测试文件添加一个构建步骤,如图 24-13 所示。

{%}

图 24-13:为 JavaScript 单元测试添加构建步骤

还要在服务器中安装 PhantomJS:

  1. root@server:$ add-apt-repository -y ppa:chris-lea/node.js
  2. root@server:$ apt-get update
  3. root@server:$ apt-get install nodejs
  4. root@server:$ npm install -g phantomjs

至此,编写了完整的 CI 构建步骤,能运行所有测试!

  1. Started by user harry
  2. Building in workspace varlib/jenkins/jobs/Superlists/workspace
  3. Fetching changes from the remote Git repository
  4. Fetching upstream changes from https://github.com/hjwp/bookexample.git
  5. Checking out Revision 936a484038194b289312ff62f10d24e6a054fb29 (origin/chapter_1
  6. Xvfb starting$ usrbin/Xvfb :1 -screen 0 1024x768x24 -fbdir varlib/jenkins/20
  7. [workspace] $ binsh -xe tmpshiningpanda7092102504259037999.sh
  8. + pip install -r requirements.txt
  9. [...]
  10. + python manage.py test lists
  11. .................................
  12. ---------------------------------------------------------------------
  13. Ran 43 tests in 0.229s
  14. OK
  15. Creating test database for alias 'default'...
  16. Destroying test database for alias 'default'...
  17. + python manage.py test accounts
  18. ..................
  19. ---------------------------------------------------------------------
  20. Ran 18 tests in 0.078s
  21. OK
  22. Creating test database for alias 'default'...
  23. Destroying test database for alias 'default'...
  24. [workspace] $ binsh -xe tmphudson2967478575201471277.sh
  25. + phantomjs listsstatictests/runner.js listsstatictests/tests.html
  26. Took 32ms to run 2 tests. 2 passed, 0 failed.
  27. + phantomjs listsstatictests/runner.js accountsstatictests/tests.html
  28. Took 47ms to run 11 tests. 11 passed, 0 failed.
  29. [workspace] $ binsh -xe tmpshiningpanda7526089957247195819.sh
  30. + pip install selenium
  31. Requirement already satisfied (use --upgrade to upgrade): selenium in varlib/
  32. Cleaning up...
  33. [workspace] $ binsh -xe tmpshiningpanda2420240268202055029.sh
  34. + python manage.py test functional_tests
  35. ........
  36. ---------------------------------------------------------------------
  37. Ran 8 tests in 76.804s
  38. OK

如果我太懒,不想在自己的设备中运行整个测试组件,CI 服务器可以代劳——真是太好了。测试山羊的另一个代理人正在网络空间里监视我们呢!

24.9 CI服务器能完成的其他操作

Jenkins 和 CI 服务器的作用只介绍了皮毛。例如,还可以让 CI 服务器在监控仓库的新提交方面变得更智能。

或者做些更有趣的事,除了运行普通的功能测试之外,还可以使用 CI 服务器自动运行过渡服务器中的测试。如果所有功能测试都能通过,你可以添加一个构建步骤,把代码部署到过渡服务器中,然后在过渡服务器中再运行功能测试。这样整个过程又多了一步可以自动完成,而且可以保证过渡服务器始终使用最新的代码。

有些人甚至使用 CI 服务器把最新发布的代码部署到生产服务器中。

CI 和 Selenium 最佳实践

  • 尽早为自己的项目搭建 CI 服务器

    一旦运行功能测试所花的时间超过几秒钟,你就会发现自己根本不想再运行了。把这个任务交给 CI 服务器吧,确保所有测试都能在某处运行。

  • 测试失败时截图和转储 HTML

    如果你能看到测试失败时网页是什么样,调试就容易得多。截图和转储 HTML 有助于调试 CI 服务器中的失败,而且对本地运行的测试也很有用。

  • 时刻准备调整超时

    CI 服务器的运行速度可能没有你的笔记本电脑快,尤其是同时运行多个测试、负载较高时。你要时刻准备调整超时,把它设为较大的值,尽量降低随机失败的概率。

  • 想办法把 CI 和过渡服务器连接起来

    使用 LiveServerTestCase 的测试在开发环境中不会遇到什么问题,但若想得到十足的保障,就要在真正的服务器中运行测试。想办法让 CI 服务器把代码部署到过渡服务器中,然后在过渡服务器中运行功能测试。这么做还有个附带好处:测试自动化部署脚本是否可用。

第 25 章 简单的社会化功能、页面模式以及练习

“现在一切都要社会化”的调侃是不是有点过时了?不管怎样,现在一切都是经过 A/B 测试和大数据分析能得到超多点击量的列表,类似“创意导师认为将颠覆你观念的十件事”。不管是否真能激发创意,列表都更容易传播。那我们就让用户能和其他人协作完成他们的列表吧。

在实现这个功能的过程中,我们将使用页面对象模式(Page Object pattern)改进功能测试。

我不告诉你具体做法,而是让你自己编写单元测试和应用代码。别担心,我不会让你完全自己动手,会告诉你大概步骤和一些提示。

25.1 有多个用户以及使用addCleanup的功能测试

开始吧。这个功能测试需要两个用户:

functional_tests/test_sharing.py (ch22l001)

  1. from selenium import webdriver
  2. from .base import FunctionalTest
  3. def quit_if_possible(browser):
  4. try: browser.quit()
  5. except: pass
  6. class SharingTest(FunctionalTest):
  7. def test_can_share_a_list_with_another_user(self):
  8. # 伊迪丝是已登录用户
  9. self.create_pre_authenticated_session('edith@example.com')
  10. edith_browser = self.browser
  11. self.addCleanup(lambda: quit_if_possible(edith_browser))
  12. # 她的朋友Oniciferous也在使用这个清单网站
  13. oni_browser = webdriver.Firefox()
  14. self.addCleanup(lambda: quit_if_possible(oni_browser))
  15. self.browser = oni_browser
  16. self.create_pre_authenticated_session('oniciferous@example.com')
  17. # 伊迪丝访问首页,新建一个清单
  18. self.browser = edith_browser
  19. self.browser.get(self.live_server_url)
  20. self.add_list_item('Get help')
  21. # 她看到“分享这个清单”选项
  22. share_box = self.browser.find_element_by_css_selector(
  23. 'input[name="sharee"]'
  24. )
  25. self.assertEqual(
  26. share_box.get_attribute('placeholder'),
  27. 'your-friend@example.com'
  28. )

这一节有个功能值得注意:addCleanup 函数,它的文档可以在线查看。这个函数可以代替 tearDown 函数,清理测试中使用的资源。如果资源在测试运行的过程中才用到,最好使用 addCleanup 函数,因为这样就不用在 tearDown 函数中花时间区分哪些资源需要清理,哪些不需要清理。

addCleanup 函数在 tearDown 函数之后运行,所以在 quit_if_possible 函数中才要使用 try/except 语句,因为不管 edith_browseroni_browser 中哪一个的值是 self.browser,测试结束时 tearDown 函数都会关闭这个浏览器。

还要把测试方法 create_pre_authenticated_session 从 test_my_lists.py 移到 base.py 中。

好了,看一下测试结果如何:

  1. $ python manage.py test functional_tests.test_sharing
  2. [...]
  3. Traceback (most recent call last):
  4. File "...superlists/functional_tests/test_sharing.py", line 31, in
  5. test_can_share_a_list_with_another_user
  6. [...]
  7. selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
  8. element: input[name="sharee"]

太好了,看样子可以创建两个用户会话,而且得到了一个意料之中的失败,因为页面中没有填写电子邮件地址的输入框,无法分享给别人。

现在做一次提交,因为至少已经编写了一个占位功能测试,也移动了 create_pre_authenticated_session 函数,接下来要重构功能测试:

  1. $ git add functional_tests
  2. $ git commit -m "New FT for sharing, move session creation stuff to base"

25.2 页面模式

在继续之前,我想再展示一种减少功能测试中重复代码的方式,叫作“页面对象”。

我们为功能测试构建了几个辅助方法,例如这里使用的 add_list_item。但如果不断构建辅助方法,测试也会变得臃肿不堪。我曾处理过一个超过 1500 行的功能测试基类,想改一下都十分困难。

此时便可以使用页面对象,尽量把网站中不同类型页面的所有信息和辅助方法放在一处。下面来看如何在网站中使用页面对象。首先是一个表示清单页面的类:

functional_tests/list_page.py

  1. from selenium.webdriver.common.keys import Keys
  2. from .base import wait
  3. class ListPage(object):
  4. def __init__(self, test):
  5. self.test = test
  6. def get_table_rows(self):
  7. return self.test.browser.find_elements_by_css_selector('#id_list_table tr')
  8. @wait
  9. def wait_for_row_in_list_table(self, item_text, item_number):
  10. expected_row_text = f'{item_number}: {item_text}'
  11. rows = self.get_table_rows()
  12. self.test.assertIn(expected_row_text, [row.text for row in rows])
  13. def get_item_input_box(self):
  14. return self.test.browser.find_element_by_id('id_text')
  15. def add_list_item(self, item_text):
  16. new_item_no = len(self.get_table_rows()) + 1
  17. self.get_item_input_box().send_keys(item_text)
  18. self.get_item_input_box().send_keys(Keys.ENTER)
  19. self.wait_for_row_in_list_table(item_text, new_item_no)
  20. return self

❶ 使用表示当前测试的对象初始化,这样就能声明断言、通过 self.test.browser 访问浏览器实例,以及使用 self.test.wait_for 函数。

❷ 从 base.py 中复制一些现有的辅助方法过来,不过稍微做了一点调整……

❸ 例如,使用了这个新方法。

❹ 返回 self 只是一种便利措施,以便串接方法(稍后就会用到)。

下面看一下如何在测试中使用页面对象:

functional_tests/test_sharing.py (ch22l004)

  1. from .list_page import ListPage
  2. [...]
  3. # 伊迪丝访问首页,新建一个清单
  4. self.browser = edith_browser
  5. list_page = ListPage(self).add_list_item('Get help')

继续改写测试,只要想访问列表页面中的元素,就使用页面对象:

functional_tests/test_sharing.py (ch22l008)

  1. # 她看到“分享这个清单”选项
  2. share_box = list_page.get_share_box()
  3. self.assertEqual(
  4. share_box.get_attribute('placeholder'),
  5. 'your-friend@example.com'
  6. )
  7. # 她分享自己的清单之后,页面更新了
  8. # 提示已经分享给Oniciferous
  9. list_page.share_list_with('oniciferous@example.com')

我们要在 ListPage 类中添加以下三个方法:

functional_tests/list_page.py (ch22l009)

  1. def get_share_box(self):
  2. return self.test.browser.find_element_by_css_selector(
  3. 'input[name="sharee"]'
  4. )
  5. def get_shared_with_list(self):
  6. return self.test.browser.find_elements_by_css_selector(
  7. '.list-sharee'
  8. )
  9. def share_list_with(self, email):
  10. self.get_share_box().send_keys(email)
  11. self.get_share_box().send_keys(Keys.ENTER)
  12. self.test.wait_for(lambda: self.test.assertIn(
  13. email,
  14. [item.text for item in self.get_shared_with_list()]
  15. ))

页面模型背后的思想是,把网站中某个页面的所有信息都集中放在一个地方,如果以后想要修改这个页面,比如简单的调整 HTML 布局,功能测试只需改动一个地方,不用到处修改多个功能测试。

接下来要继续重构其他功能测试。在这里我就不细说了,你可以试着自己完成,感受一下在 DRY 原则和测试可读性方面要做哪些折中处理。

25.3 扩展功能测试测试第二个用户和“My Lists”页面

把分享功能的用户故事写得更详细点儿。伊迪丝在她的清单页面看到这个清单已经分享给 Oniciferous,然后 Oniciferous 登录,看到这个清单出现在“My Lists”页面中,或许显示在“分享给我的清单”中:

functional_tests/test_sharing.py (ch22l010)

  1. from .my_lists_page import MyListsPage
  2. [...]
  3. list_page.share_list_with('oniciferous@example.com')
  4. # 现在Oniciferous在他的浏览器中访问清单页面
  5. self.browser = oni_browser
  6. MyListsPage(self).go_to_my_lists_page()
  7. # 他看到了伊迪丝分享的清单
  8. self.browser.find_element_by_link_text('Get help').click()

为此,要在 MyListPage 类中再定义一个方法:

functional_tests/my_lists_page.py (ch22l011)

  1. class MyListsPage(object):
  2. def __init__(self, test):
  3. self.test = test
  4. def go_to_my_lists_page(self):
  5. self.test.browser.get(self.test.live_server_url)
  6. self.test.browser.find_element_by_link_text('My lists').click()
  7. self.test.wait_for(lambda: self.test.assertEqual(
  8. self.test.browser.find_element_by_tag_name('h1').text,
  9. 'My Lists'
  10. ))
  11. return self

这个方法最好放到 test_my_lists.py 中,或许还可以再定义一个 MyListsPage 类。

现在,Oniciferous 也可以在这个清单中添加待办事项:

functional_tests/test_sharing.py (ch22l012)

  1. # 在清单页面,Oniciferous看到这个清单属于伊迪丝
  2. self.wait_for(lambda: self.assertEqual(
  3. list_page.get_list_owner(),
  4. 'edith@example.com'
  5. ))
  6. # 他在这个清单中添加一个待办事项
  7. list_page.add_list_item('Hi 伊迪丝!')
  8. # 伊迪丝刷新页面后,看到Oniciferous添加的内容
  9. self.browser = edith_browser
  10. self.browser.refresh()
  11. list_page.wait_for_row_in_list_table('Hi Edith!', 2)

为此,要在 ListPage 类中添加一个方法:

functional_tests/list_page.py (ch22l013)

  1. class ListPage(object):
  2. [...]
  3. def get_list_owner(self):
  4. return self.test.browser.find_element_by_id('id_list_owner').text

早就该运行功能测试了,看看这些测试能否通过:

  1. $ python manage.py test functional_tests.test_sharing
  2.  
  3. share_box = list_page.get_share_box()
  4. [...]
  5. selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
  6. element: input[name="sharee"]

这个失败在预料之中,因为还没在页面中添加输入框,填写电子邮件地址,分享给别人。做次提交:

  1. $ git add functional_tests
  2. $ git commit -m "Create Page objects for list pages, use in sharing FT"

25.4 留给读者的练习

做完 25.4 节的练习之后,我才算完全明白自己在做什么。

——Iain H.(读者

若想牢固掌握所学,没什么比得上自己动手实践。所以,我希望你能试着去做下述练习。

大致步骤如下。

(1) 在 list.html 添加一个新区域,先写一个表单,表单中包含一个输入框,用来输入电子邮件地址。功能测试应该会前进一步。

(2) 需要一个视图,处理表单。先在模板中定义 URL,例如 lists//share。

(3) 然后,编写第一个单元测试,驱动我们定义占位视图。我们希望这个视图处理 POST 请求,响应是重定向,指向清单页面,所以这个测试可以命名为 ShareListTest.test_ post_redirects_to_lists_page

(4) 编写占位视图,只需两行代码,一行用于查找清单,一行用于重定向。

(5) 可以再编写一个单元测试,在测试中创建一个用户和一个清单,在 POST 请求中发送电子邮件地址,然后检查 list_.shared_with.all()(类似于“My Lists”页面使用的那个 ORM 用法)中是否包含这个用户。shared_with 属性还不存在,我们使用的是由外而内的方式。

(6) 所以在这个测试通过之前,要下移到模型层。下一个测试要写入 test_models.py 中。在这个测试中,可以检查清单能否响应 shared_with.add 方法。这个方法的参数是用户的电子邮件地址。然后检查清单的 shared_with.all() 查询集合中是否包含这个用户。

(7) 然后需要用到 ManyToManyField。或许你会看到一条错误消息,提示 related_name 有冲突,查阅 Django 的文档之后你会找到解决办法。

(8) 需要执行一次数据库迁移。

(9) 然后,模型测试应该可以通过。回过头来修正视图测试。

(10) 可能会发现重定向视图的测试失败,因为视图发送的 POST 请求无效。可以选择忽略无效的输入,也可以调整测试,发送有效的 POST 请求。

(11) 然后回到模板层。“My Lists”页面需要一个

    元素,使用 for 循环列出分享给这个用户的清单。我们还想在清单页面显示这个清单分享给谁了,并注明这个清单的属主是谁。各元素的类和 ID 参见功能测试。如果需要,还可以为这几个需求编写简单的单元测试。

    (12) 执行 runserver 命令让网站运行起来,或许能帮助你解决问题,以及调整布局和外观。如果使用隐私浏览器会话,可以同时登录多个用户。

    最终,可能会得到类似图 25-1 所示的页面。

    {%}

    图 25-1:分享清单

    页面模式以及真正留给读者的练习

    • 在功能测试中运用 DRY 原则

      功能测试多起来后,就会发现不同的测试使用了 UI 的同一部分。尽量避免在多个功能测试中使用重复的常量,例如某个 UI 元素 HTML 代码中的 ID 和类。

    • 页面模式

      把辅助方法移到 FunctionalTest 基类中会把这个类变得臃肿不堪。可以考虑把处理网站特定部分的全部逻辑保存到单独的页面对象中。

    • 留给读者的练习

      希望你真的会做这个练习!试着遵守由外而内的开发方式,如果卡住了,偶尔也可以手动测试。当然,真正留给读者的练习是,在你的下一个项目中使用 TDD。希望它能给你带来愉悦的体验!

    下一章做个总结,探讨测试的“最佳实践”。

    第 26 章 测试运行速度的快慢和炽热的岩浆

    “数据库是炽热的岩浆!”

    —— Casey Kinsey

    在第 23 章之前,书中几乎所有的“单元”测试或许都应该叫作整合测试,因为这些测试要不依赖于数据库,要不使用 Django 测试客户端,请求、响应和视图函数之间的中间层有太多细节都被隐藏了。

    有种说法认为,真正的单元测试一定要隔离,因为单元测试只应该测试软件单独的一部分。如果涉及数据库,那就不是单元测试。数据库是炽热的岩浆!

    一些 TDD 老手说,你应该尽力编写完全隔离的单元测试,而不要编写整合测试。测试社区一直都有这样的争论,有时还很白热化。

    我只是个狂妄的年轻人,对这场争论的细节并不太了解。但在这一章里,我想试着分析人们为什么如此在意这件事,然后给出一些建议,告诉你什么时候勉强可以使用整合测试(我承认很多时候我都是这样做的),什么时候值得争取编写更纯粹的单元测试。

    术语:测试的不同类型

    • 隔离测试(纯粹的单元测试)与整合测试

      单元测试的主要作用应该是验证应用的逻辑是否正确。隔离测试只能测试一部分代码,测试是否通过与其他任何外部代码都没有关系。我所说的纯粹的单元测试是指,对一个函数的测试而言,只有这个函数能让测试失败。如果这个函数依赖于其他系统且破坏这个系统会导致测试失败,就说明这是整合测试。这个系统可以是外部系统,例如数据库,也可以是我们无法控制的另一个函数。不管怎样,只要破坏系统会导致测试失败,这个测试就没有完全隔离,因此也就不是纯粹的单元测试。整合测试并非不好,只不过可能意味着同时测试两个功能。

    • 集成测试

      集成测试用于检查被你控制的代码是否能和你无法控制的外部系统完好集成。集成测试往往也是整合测试。

    • 系统测试

      如果说集成测试检查的是与外部系统的集成情况,那么系统测试就是检查应用内部多个系统之间的集成情况。例如,检查数据库、静态文件和服务器配置在一起是否能正常运行。

    • 功能测试和验收测试

      验收测试的作用是从用户的角度检查系统是否能正常运行。(用户能接受这种行为吗?)验收测试很难不写成全栈端到端测试。在前文中,使用功能测试代替验收测试和系统测试。

    请原谅我的自命不凡,下面我要使用一些哲学术语,以黑格尔辩证法的结构讨论这些问题。

    • 正题:纯粹的单元测试运行速度快。
    • 反题:编写纯粹的单元测试有哪些风险?
    • 合题:讨论一些最佳实践,例如“端口和适配器”“函数式核心,命令式外壳”以及我们到底想从测试中得到什么。

    26.1 正题:单元测试除了运行速度超快之外还有其他优势

    关于单元测试你经常会听到一种说法:单元测试运行速度快多了。其实,我不觉得这是单元测试的主要优势,不过速度的确值得一谈。

    26.1.1 测试运行得越快,开发速度越快

    在其他条件相同的情况下,单元测试运行的速度越快越好。可以适当推理出,所有测试运行的速度都是越快越好。

    本书前文已经概括了 TDD 测试 / 编写代码循环。你已经开始习惯 TDD 流程,时而编写最少量的代码,时而运行测试。以后,一分钟内你要多次运行单元测试,一天之内要多次运行功能测试。

    所以,简单而言,测试运行的时间越长,等待测试运行完毕的时间就越长,因此也就拖慢了开发进度。而且问题还不止于此。

    26.1.2 神赐的心流状态

    现在从社会学角度分析。我们程序员有自己的文化,有自己的族群信仰。这个族群分成很多群体,例如崇拜 TDD 的群体(你现在已经成为其中一员)。有些人喜欢 vi,还有些人离经叛道,喜欢 emacs。但我们都认同一件事:神赐的心流状态——这是一种精神上的练习,我们自己的冥想方式。我们的精神完全专注,几个小时弹指一挥间就过去,代码自然而然地从指间流出,问题虽然乏味棘手,但难不倒我们。

    如果花时间等待慢吞吞的测试组件运行完毕,肯定无法进入心流状态。只要超过几秒钟,你的注意力就会分散,环境也会变化,导致心流状态消失。心流状态就像梦境一样,只要消失,至少要花 15 分钟才能重现。

    26.1.3 经常不想运行速度慢的测试,导致代码变坏

    如果测试组件运行得慢,你会失去耐心,不想运行测试,这会导致问题横行。我们也许会羞于重构代码,因为知道重构后要花很多时间等待所有测试运行完毕。这两种情况都会导致代码变坏。

    26.1.4 现在还行,不过随着时间推移,整合测试会变得越来越慢

    你可能觉得没事,测试组件中有很多整合测试,超过 50 个,但运行只用了 0.2 秒。

    可是要知道,这个应用很简单。一旦应用变得复杂,数据库中的表和列越来越多,整合测试就会变得越来越慢。在两个测试之间让 Django 重建数据库所用的时间会越来越长。

    26.1.5 别只听我一个人说

    Gary Bernhardt 的测试经验比我丰富,他在演讲“Fast Test, Slow Test”中生动地阐述了这些观点。推荐你看一下演讲视频。

    26.1.6 单元测试能驱使我们实现好的设计

    但是,比上述几点更重要的好处或许我在第 23 章已经说过。为了编写隔离性好的单元测试,必须知道依赖下一层中的什么功能,而且要使用整合测试无法实现的解耦式架构,这有助于设计出更好的代码。

    26.2 纯粹的单元测试有什么问题

    说完优点我们要来个大转折。编写隔离的单元测试也有其危害,尤其是对于我们(包括我和你)这些 TDD 新手而言。

    26.2.1 隔离的测试难读也难写

    回忆一下我第一个隔离的单元测试,是不是很丑?我承认,重构时把代码移到表单中有些改进,但想一下如果没这么做呢?代码基中就会有一个十分难读的测试。就算是这个测试的最终版本,也仍有一些比较难理解的部分。

    26.2.2 隔离测试不会自动测试集成情况

    稍后我们会得知,隔离测试只测试当前关注的单元,而且是在隔离的环境中测试,这种测试本性如此,不测试各单元之间的集成情况。

    这个问题众所周知,也有很多缓解的方法。不过前文已经说过,这些缓解措施对程序员来说意味着要付出很多艰苦努力:程序员要记住各单元的界面,要分清每个组件需要履行的合约,除了要为单元的内部功能编写测试之外,还得为合约编写测试。

    26.2.3 单元测试几乎不能捕获意料之外的问题

    单元测试能帮助你捕获差一错误和逻辑混乱导致的错误,这些错误在编写代码时经常会出现,我们知道这一点,所以这些错误在意料之中。不过出现预料之外的问题时,单元测试不会提醒你。如果忘记创建数据库迁移,单元测试不会提醒你;如果中间层自作聪明转义了 HTML 实体,从而影响数据的渲染方式,显示成“唐纳德 • 拉姆斯菲尔德的 XX”,单元测试也不会提醒你。

    26.2.4 使用驭件的测试可能和实现方式联系紧密

    最后还有个问题,使用驭件的测试可能和实现方式之间过度耦合。如果你选择使用 List.objects.create() 创建对象,但是驭件希望你使用 List().save(),这时就算两种用法的实际效果一样,测试也会失败。如果不小心,还可能导致测试本该具有的一个好处缺失,即鼓励重构。如果想修改一个内部 API,你会发现自己要修改很多使用驭件的测试和合约测试。

    注意,处理你无法控制的 API 时,这可能不单是一个问题那么简单。你可能还记得我们如何拐弯抹角地测试表单:创建两个 Django 模型驭件,然后使用 side_effect 检查环境的状态。如果编写的代码完全在自己的控制之中,你可能想设计自己的内部 API,这样写出的代码更简洁,而且测试时不用拐这么多弯。

    26.2.5 这些问题都可以解决

    但是,倡导编写隔离测试的人会过来告诉你,这些问题都可以缓解,你要熟练编写隔离测试,还得进入神赐的心流状态。

    现在我们论证到哪一步了?

    26.3 合题:我们到底想从测试中得到什么

    退一步想一下,我们想从测试中得到什么好处,为什么一开始要编写测试?

    26.3.1 正确性

    我们希望应用没有问题,不管是差一错误之类的低层逻辑错误,还是高层问题,例如软件最终应该提供用户所需的功能。我们想知道是否引入了回归,导致以前能用的功能失效,

    而且想在用户察觉之前发现。我们还期望测试告诉我们应用可以正常运行。

    26.3.2 简洁可维护的代码

    我们希望代码遵守 YAGNI 和 DRY 等原则;希望代码清晰地表明意图,使用合理的方式分成多个组件,而且各组件作用明确、容易理解;希望从测试中获取自信,可以放心地不断重构应用,这样才不会害怕尝试改进设计;还希望测试能主动帮我们找到正确的设计。

    26.3.3 高效的工作流程

    最后,我们希望测试能帮助实现一种快速高效的工作流程;希望测试有助于减轻开发压力,而且避免让我们犯一些愚蠢的错误;希望测试能让我们始终处于心流状态,因为心流状态不仅令人享受,而且助人提高工作效率;希望测试尽快对我们的工作做出反馈,这样就能尝试新想法,并尽早改进。而且,改进代码时,如果测试不能提供帮助,我们也不想让它成为障碍。

    26.3.4 根据所需的优势评估测试

    我觉得应该编写多少测试,以及功能测试、整合测试和隔离测试的量怎么分配,没有通用的规则,因为每个项目的情况不同。但可以把所有测试都纳入考虑范围(如表 26-1 所示),然后考量各种测试,看它们能否提供你需要的优势,由此做出判断。

    表26-1:不同类型的测试如何帮助我们达成目标

    目标 一些考量事项
    正确性 • 站在用户的角度看,功能测试的数量是否足够保证应用真的能正常运行?
    • 各种边界情况彻底测试了吗?感觉这是低层隔离测试的任务。
    • 有没有编写测试检查所有组件之间是否能正确配合?要不要编写一些整合测试,或者只用功能测试就行?
    简洁可维护的代码 • 测试有没有给我重构代码的自信,而且可以无所畏惧地频繁重构?
    • 测试有没有帮我得到一个好的设计?如果整合测试较多、隔离测试较少,我要投入精力为应用的哪一部分编写更多隔离测试,才能得到关于设计更全面的反馈?
    高效的工作流程 • 反馈循环的速度令我满意吗?我什么时候能得到问题的提醒?有没有某种方法可以让提醒更早出现?
    • 如果高层功能测试很多,运行时间很长,要花整晚时间才能得到意外回归的反馈,有没有一种方法可以让我编写速度更快的测试,整合测试也行,让我早点儿得到反馈?
    • 如果需要,我能否运行整个测试组件的一个子集?