17.4 总结:为这次新发布打上Git标签
最后要做的一件事是,在 VCS 中为这次发布打上标签——始终能跟踪线上运行的是哪个版本十分重要:
- $ git tag -f LIVE # 需要指定-f,因为我们要替换旧标签
- $ export TAG=`date +DEPLOYED-%F/%H%M`
- $ git tag $TAG
- $ git push -f origin LIVE $TAG
有些人不喜欢使用
push -f
,也不喜欢更新现有的标签,而是使用某种版本号标记每次发布。你觉得哪种方法好就用哪种。
至此,第二部分结束了。接下来我们要进入第三部分,介绍更让人兴奋的话题。真令人期待啊!
部署过程回顾
目前我们部署过好几次了,下面回顾一下整个过程。
- 执行
git push
命令,推送最新的代码。- 部署到过渡服务器,运行功能测试。
- 部署到线上服务器。
- 为最新的版本打上标签。
随着项目的增长,部署过程会变得越来越复杂。如果没有好的自动化方案,部署将变得难以维护,处处都需要自己动手检查和操作。这个话题还有很多内容,不过已经超出本书范畴。记得阅读附录 C,另外再研究一下“持续部署”。
第三部分 高级话题
“噢,天呐,什么,还有一部分?哈利,我累了,已经看了两百多页,觉得无法再看完另一部分了,而且这一部分还是‘高级’话题……我能不能跳过这一部分呢?”
噢,不,你不能。这一部分虽然被称为“高级”话题,但其实有很多对 TDD 和 Web 开发十分重要的知识,因此不能跳过。这一部分甚至比前两部分还重要。
我们会讨论如何集成和测试第三方系统。重用现有的组件对现代 Web 开发十分重要。还会介绍模拟技术和测试隔离,这两种技术是 TDD 的核心,而且在最简单的代码基中也会用到。最后要讨论服务器端调试技术和测试固件,以及如何搭建持续集成(Continuous Integration)环境。这些技术在项目中并不是可有可无的奢侈附属品,它们其实都很重要。
这一部分的学习曲线难免稍微陡峭一些。你可能要多读几遍才能领会,或者第一次操作时可能无法正常运行,需要自己调试一番。但要坚持下去,知识越难,只要学会了,收获也就越大。如果你遇到难题,我十分乐意帮忙,请给我发电子邮件,地址是 obeythetestinggoat@gmail.com。
来吧,我保证最重要的知识就在这一部分!
第 18 章 用户身份验证、探究及去掉探究代码
精美的待办事项清单网站上线好几天了,用户开始提供反馈。他们说:“我们喜欢这个网站,但总是找不到使用过的清单,又很难靠死记硬背来记住 URL。如果网站能记住我们创建过哪些清单就好了。”
还记得亨利 · 福特说过的那句“快马”名言吗? 1 收到用户的反馈后一定要深入分析并思考:“用户真正需要的是什么?满足用户的需求时如何使用自己一直想尝试的酷炫新技术?”
1亨利 · 福特是福特汽车公司的创始人。这里所说的“快马”名言全句是:“If I had asked people what they wanted, they would have said faster horses.”但也有人说福特并没说过这句话。——译者注
很明显,这些用户需要的是某种用户账户系统。那么就直接实现认证功能吧。
我们不会费力气自己存储密码,这是 20 世纪 90 年代的技术,而且存储用户密码还是个安全噩梦,所以还是交给第三方去完成吧。我们要使用一种称为无密码验证的技术。
(如果你坚持要自己存储密码,可以使用 Django 提供的 auth
模块。这个模块很友好也很简单,具体的实现方法留给你自己去发掘。)
18.1 无密码验证
为了不自己存储密码,我们要使用什么身份验证系统呢? OAuth、OpenID,还是“通过 Facebook 登录”?对我来说,这些认证系统都有让人无法接受的缺点——为什么要让 Google 或 Facebook 知道我何时登录过什么网站呢?
本书第 1 版使用的是一个叫“Persona”的实验性项目,这个项目由 Mozilla 一些具有理想主义嬉皮士精神的技术人员研发。但可惜的是,它如今已被废弃。
但我找到了一个不错的替代方案,这种身份验证方式叫作“无密码验证”,你也可以称之为“只用电子邮件验证”。
发明这个系统的人觉得为每个网站都创建密码很麻烦,而且他发现他使用的密码都是随机的,用完就“扔”,根本不会尝试去记,等到再需要登录时使用“忘记密码”功能就好。详情请参阅 Medium 上的文章名为“Passwords are Obselete”。
无密码验证系统的原理是,只使用电子邮件地址确认身份。既然你希望使用“忘记密码”功能,就说明你信任电子邮件地址,那为什么不直捣黄龙呢?如果用户想登录,就生成一个唯一的 URL,通过电子邮件发给用户,用户点击 URL 后即可登录网站。
世界上没有完美的系统,为了给线上网站提供好的登录方案,需要深思熟虑的细节有很多。但这是个实验性项目,不必太费心。
18.2 探索性编程(又名“探究”)
在撰写本章之前,我对无密码验证的认识只限于前文给出的那篇文章中的简要说明。我没见过具体的代码,也不知道应该从哪入手。
我们在第 13、14 章中见识到,可以使用单元测试探索新 API 的用法。但有时你不想写测试,只是想捣鼓一下,看 API 是否能用,目的是学习和领会。这么做绝对可行。学习新工具,或者研究新的可行性方案时,一般都可以适当地把严格的 TDD 流程放在一边,不编写测试或编写少量的测试,先把基本的原型开发出来。测试山羊并不介意暂时睁一只眼闭一只眼。
这种创建原型的过程一般叫作“探究”(spike)。这么叫的原因众所周知。
首先,我研究了现有的 Python 和 Django 身份验证码,比如 django-allauth 和 python-social- auth,但这两个包目前都太过复杂。(回头一想,自己编程多有趣!)
所以,我决定亲自动手,经过一番潜心研究之后,终于写出了刚好能用的代码。下面我要向你展示我的实现过程,然后再过一遍,去掉探索性代码,即把原型替换成经过测试、可在生产环境使用的代码。
你应该自己动手,把这些代码添加到自己的网站中,这样才能得到试验的对象。然后使用自己的电子邮件地址登录试试,证明代码确实可用。
18.2.1 为此次探究新建一个分支
着手探究之前,最好新建一个分支,这样就不用担心探究过程中提交的代码把 VCS 中的生产代码搞乱了:
- $ git checkout -b passwordless-spike
下面在便签上记下希望从这次探究中学到的东西。
18.2.2 前端登录UI
先从前端入手。在导航栏中放一个表单,让用户输入电子邮件地址,并为通过身份验证的用户提供退出链接:
lists/templates/base.html (ch16l001)
<body>
<div class="container">
<div class="navbar">
{% if user.is_authenticated %}
<p>Logged in as {{ user.email}}</p>
<p><a id="id_logout" href="{% url 'logout' %}">Log out</a></p>
{% else %}
<form method="POST" action ="{% url 'send_login_email' %}">
Enter email to log in: <input name="email" type="text" >
{% csrf_token %}
<form>
{% endif %}
</div>
<div class="row">
[...]
18.2.3 从Django中发出邮件
登录过程是这样的。
- 有人想登录时,就生成一个唯一的秘密令牌,存储在数据库中,并与他的电子邮件地址关联起来,然后把令牌发给那个人。
- 他查看电子邮件,里面有个包含令牌的 URL。
- 他点击链接后,我们检查令牌是否存在于数据库中,如果存在就登入相应的用户。
首先,为账户准备一个应用:
- $ python manage.py startapp accounts
然后在 urls.py 中至少设置一个 URL。先是位于顶级的 superlists/urls.py 文件。
superlists/urls.py (ch16l003)
from django.conf.urls import include, url
from lists import views as list_views
from lists import urls as list_urls
from accounts import urls as accounts_urls
urlpatterns = [
url(r'^$', list_views.home_page, name='home'),
url(r'^lists/', include(list_urls)),
url(r'^accounts/', include(accounts_urls)),
]
然后是 accounts
模块中的 urls.py 文件:
accounts/urls.py (ch16l004)
from django.conf.urls import url
from accounts import views
urlpatterns = [
url(r'^send_email$', views.send_login_email, name='send_login_email'),
]
下述视图负责创建与用户在登录表单中输入的电子邮件地址关联的令牌:
accounts/views.py (ch16l005)
import uuid
import sys
from django.shortcuts import render
from django.core.mail import send_mail
from accounts.models import Token
def send_login_email(request):
email = request.POST['email']
uid = str(uuid.uuid4())
Token.objects.create(email=email, uid=uid)
print('saving uid', uid, 'for email', email, file=sys.stderr)
url = request.build_absolute_uri(f'accountslogin?uid={uid}')
send_mail(
'Your login link for Superlists',
f'Use this link to log in:\n\n{url}',
'noreply@superlists',
[email],
)
return render(request, 'login_email_sent.html')
为此,我们要显示一条消息,指明电子邮件已经发出:
accounts/templates/login_email_sent.html (ch16l006)
<html>
<h1>Email sent</h1>
<p>Check your email, you'll find a message with a link that will log you into
the site.</p>
</html>
(这段代码只是临时的,实际使用中应该集成到 base.html 模板里。)
为了让 Django 的 send_mail
函数工作,更重要的是告诉 Django 我们的电子邮件服务器地址。我暂时先使用自己的 Gmail 账户。2 你可以使用任何你想用的电子邮件服务提供商,只要支持 SMTP 就行:
2我刚刚是不是说了一大堆关于使用 Google 登录对隐私有多大影响的话?那为什么现在又要使用 Gmail 呢?是的,这的确相互矛盾。(老实讲,有一天我会抛弃 Gmail 的!)但这里只是用于测试,我又没强制用户使用 Google。
superlists/settings.py (ch16l007)
EMAIL_HOST = 'smtp.gmail.com'
EMAIL_HOST_USER = 'obeythetestinggoat@gmail.com'
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_PASSWORD')
EMAIL_PORT = 587
EMAIL_USE_TLS = True
Gmail,可能需要访问 Google 账户的安全设置页面。如果你在使用双因素身份验证,可能需要设置应用专用的密码;如果没用双因素身份验证,可能也要允许较不安全的应用访问。鉴于此,你可以新建一个 Google 账户,而不使用包含敏感数据的账户。
18.2.4 使用环境变量,避免源码中出现机密信息
每个项目终究都要以一种方式处理“机密信息”——像电子邮件密码或 API 密钥这类不想和整个世界分享的数据。如果你的仓库是私有的,或许还可以存储在 Git 中,但事实往往不是这样。而且这还涉及在开发和生产环境中使用不同的设置。(还记得在第 11 章中我们是如何处理 Django 的 SECRET_KEY
设置的吗?)
这种配置通常使用环境变量存储,上述代码中的 os.environ.get
就是用于读取环境变量的。
为此,我要在运行开发服务器的 shell 中设定环境变量:
- $ export EMAIL_PASSWORD="sekrit"
后面还会在过渡服务器中添加这个环境变量。
18.2.5 在数据库中存储令牌
情况进展如何?
为了把令牌存储在数据库中,我们需要一个模型。这个模型要把电子邮件地址与唯一的 ID
关联起来——这没什么难的:
accounts/models.py (ch16l008)
from django.db import models
class Token(models.Model):
email = models.EmailField()
uid = models.CharField(max_length=255)
18.2.6 自定义身份验证模型
既然已经谈到模型了,那就试验一下如何在 Django 中验证身份吧。
首先,要有一个用户模型。在我刚开始编写时,自定义用户模型还是 Django 的新特性,所以我仔细研读了 Django 的身份验证文档,努力找出最简单的方法:
accounts/models.py (ch16l009)
[...]
from django.contrib.auth.models import (
AbstractBaseUser, BaseUserManager, PermissionsMixin
)
class ListUser(AbstractBaseUser, PermissionsMixin):
email = models.EmailField(primary_key=True)
USERNAME_FIELD = 'email'
#REQUIRED_FIELDS = ['email', 'height']
objects = ListUserManager()
@property
def is_staff(self):
return self.email == 'harry.percival@example.com'
@property
def is_active(self):
return True
这算是一个极简用户模型,它只有一个字段,没有名字、姓氏或用户名之类的,尤其是没有密码字段。我们不必为此操心!
但我要再次说明,这段代码不适合在生产环境使用,里面注释掉了一行代码,还硬编码了我的电子邮件地址。去掉试探代码时会做大幅调整。
此外,还要为用户模型提供一个模型管理器:
accounts/models.py (ch16l010)
[...]
class ListUserManager(BaseUserManager):
def create_user(self, email):
ListUser.objects.create(email=email)
def create_superuser(self, email, password):
self.create_user(email)
现阶段你不用管模型管理器是什么,目前就是需要这么一个东西,有了它才能工作。去掉试探代码时,我们会分析每一段代码,确定哪些是可以在生产环境使用的,彻底弄明白这些代码的作用。
18.2.7 结束自定义Django身份验证功能
就要完成了。最后一步要识别令牌,然后登入用户。做完这一步,便签上记录的事项基本上都可以划掉了。
下面是点击电子邮件中的链接后触发的视图:
accounts/views.py (ch16l011)
import uuid
import sys
from django.contrib.auth import authenticate
from django.contrib.auth import login as auth_login
from django.core.mail import send_mail
from django.shortcuts import redirect, render
[...]
def login(request):
print('login view', file=sys.stderr)
uid = request.GET.get('uid')
user = authenticate(uid=uid)
if user is not None:
auth_login(request, user)
return redirect('/')
authenticate
函数调用 Django 的身份验证框架,我们已经将它配置为使用“自定义的身份验证框架”,作用是验证 UID,返回电子邮件对应的用户。
我们本可以在这个视图中直接结束,但是最好按照 Django 预期的方式组织代码,实现关注点分离:
accounts/authentication.py (ch16l012)
import sys
from accounts.models import ListUser, Token
class PasswordlessAuthenticationBackend(object):
def authenticate(self, uid):
print('uid', uid, file=sys.stderr)
if not Token.objects.filter(uid=uid).exists():
print('no token found', file=sys.stderr)
return None
token = Token.objects.get(uid=uid)
print('got token', file=sys.stderr)
try:
user = ListUser.objects.get(email=token.email)
print('got user', file=sys.stderr)
return user
except ListUser.DoesNotExist:
print('new user', file=sys.stderr)
return ListUser.objects.create(email=token.email)
def get_user(self, email):
return ListUser.objects.get(email=email)
这段代码也不例外,打印了很多调试信息,还有一些重复,不适合在生产环境使用。不过我们现在要的是能用就行。
最后,编写退出视图:
accounts/views.py (ch16l013)
from django.contrib.auth import login as auth_login, logout as auth_logout
[...]
def logout(request):
auth_logout(request)
return redirect('/')
把 login
和 logout
视图添加到 urls.py 中:
accounts/urls.py (ch16l014)
from django.conf.urls import url
from accounts import views
urlpatterns = [
url(r'^send_email$', views.send_login_email, name='send_login_email'),
url(r'^login$', views.login, name='login'),
url(r'^logout$', views.logout, name='logout'),
]
坚持住,就快结束了!在 settings.py 中启用 auth 后端和这个 accounts 应用:
superlists/settings.py (ch16l015)
INSTALLED_APPS = [
#'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'lists',
'accounts',
]
AUTH_USER_MODEL = 'accounts.ListUser'
AUTHENTICATION_BACKENDS = [
'accounts.authentication.PasswordlessAuthenticationBackend',
]
MIDDLEWARE = [
[...]
执行 makemigrations
命令,为令牌和用户模型生成迁移:
- $ python manage.py makemigrations
- Migrations for 'accounts':
- accounts/migrations/0001_initial.py
- - Create model ListUser
- - Create model Token
再执行 migrate
命令构建数据库:
- $ python manage.py migrate
- [...]
- Running migrations:
- Applying accounts.0001_initial... OK
一切就绪!为什么不执行 runserver
命令启动开发服务器,看看实际效果呢(见图 18-1)?
图 18-1:能正常使用!能正常使用!哇哈哈
如果遇到
SMTPSenderRefused
错误消息,可能是你忘了在运行开发服务器的 shell 中设定EMAIL_PASSWORD
环境变量。
大概就是这样!这一过程看着简单,但我当时可是历经磨难。我在 Gmail 账户的安全界面四处设置了好久,又在自定义用户模型的过程中少写了几个属性(因为我没认真阅读文档),甚至还以为自己发现了一个缺陷而换到 Django 开发版本,不过最终证明那并不是缺陷。
在 stderr 中记录错误
探究时尤为重要的一点是能看到代码抛出的异常。Django 很讨厌,默认情况下并没把所有异常都输送到终端,不过可以在 settings.py 中使用
LOGGING
变量让 Django 这么做:superlists/settings.py (ch16l017)
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
},
},
'loggers': {
'django': {
'handlers': ['console'],
},
},
'root': {'level': 'INFO'},
}
Django 使用的是 Python 标准库中的企业级日志包。这个包虽然功能完善,但是学习曲线太陡。第 21 章将简单介绍一下这个包,Django 文档中有更详细的说明。
但不管怎么说,我们都实现了一个可用的方案!下面提交到 passwordless-spike
分支:
- $ git status
- $ git add accounts
- $ git commit -am "spiked in custom passwordless auth backend"
接下来该去掉探究代码了。
18.3 去掉探究代码
去掉探究代码意味着要使用 TDD 重写原型代码。我们现在掌握了足够的信息,知道怎么做才是对的。那第一步做什么呢?当然是编写功能测试!
我们还得继续待在 passwordless-spike
分支中,看功能测试能否在探究代码中通过,然后再回到 master
分支,并且只提交功能测试。
下面是我们编写的第一版功能测试,很简单:
functional_tests/test_login.py
from django.core import mail
from selenium.webdriver.common.keys import Keys
import re
from .base import FunctionalTest
TEST_EMAIL = 'edith@example.com'
SUBJECT = 'Your login link for Superlists'
class LoginTest(FunctionalTest):
def test_cangetemail_link_to_log_in(self):
# 伊迪丝访问这个很棒的超级列表网站
# 第一次注意到导航栏中有“登录”区域
# 看到要求输入电子邮件地址,她便输入了
self.browser.get(self.live_server_url)
self.browser.find_element_by_name('email').send_keys(TEST_EMAIL)
self.browser.find_element_by_name('email').send_keys(Keys.ENTER)
# 出现一条消息,告诉她邮件已经发出
self.wait_for(lambda: self.assertIn(
'Check your email',
self.browser.find_element_by_tag_name('body').text
))
# 她查看邮件,看到一条消息
email = mail.outbox[0] ➊
self.assertIn(TEST_EMAIL, email.to)
self.assertEqual(email.subject, SUBJECT)
# 邮件中有个URL链接
self.assertIn('Use this link to log in', email.body)
url_search = re.search(r'http://.+/.+$', email.body)
if not url_search:
self.fail(f'Could not find url in email body:\n{email.body}')
url = url_search.group(0)
self.assertIn(self.live_server_url, url)
# 她点击了链接
self.browser.get(url)
# 她登录了!
self.wait_for(
lambda: self.browser.find_element_by_link_text('Log out')
)
navbar = self.browser.find_element_by_css_selector('.navbar')
self.assertIn(TEST_EMAIL, navbar.text)
➊ 你是不是因不知如何在测试中获取邮件内容而担心?好消息是,我们暂时可以作弊!运行测试时,Django 允许通过 mail.outbox
属性访问服务器发送的电子邮件。稍后再说明如何检查“真实的”电子邮件(少安毋躁)。
运行这个功能测试,它能通过:
- $ python manage.py test functional_tests.test_login
- [...]
- Not Found: /favicon.ico
- saving uid [...]
- login view
- uid [...]
- got token
- new user
- .
- ---------------------------------------------------------------------
- Ran 1 test in 3.729s
- OK
你甚至会看到我在探究视图的实现时留下的调试输出。现在该还原这些临时改动,然后再以测试驱动的方式重新逐一介绍了。
删除探究代码
- $ git checkout master # 切换到master分支
- $ rm -rf accounts # 删除所有探究代码
- $ git add functional_tests/test_login.py
- $ git commit -m "FT for login via email"
然后再次运行功能测试,让它驱动我们开发:
- $ python manage.py test functional_tests.test_login
- selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
- element: [name="email"]
- [...]
测试首先要求我们添加一个电子邮件地址输入框。Bootstrap 为导航栏提供了内置类,我们将使用它们。这个输入框放在一个表单里:
lists/templates/base.html (ch16l020)
<div class="container">
<nav class="navbar navbar-default" role="navigation">
<div class="container-fluid">
<a class="navbar-brand" href="">Superlists<a>
<form class="navbar-form navbar-right" method="POST" action="#">
<span>Enter email to log in:</span>
<input class="form-control" name="email" type="text" >
{% csrf_token %}
<form>
</div>
</nav>
<div class="row">
[...]
现在功能测试是失败的,因为这个登录表单什么也没做:
- $ python manage.py test functional_tests.test_login
- [...]
- AssertionError: 'Check your email' not found in 'Superlists\nEnter email to log
- in:\nStart a new To-Do list'
我建议你现在像前面说过的那样设置
LOGGING
。现在没必要特别测试这个,目前的测试组件会在出现异常时通知我们。到第 21 章你会发现,这个设置对调试非常有用。
该编写一些 Django 代码了。首先,创建一个名为 accounts
的应用,用于存放与登录有关的所有文件:
- $ python manage.py startapp accounts
你现在还可以提交一次,把应用的占位文件与后面的修改区分开。
下面来重新构建这个极简的用户模型,不过这一次有测试。看看是否比探究时的更简洁。
18.4 一个极简的自定义用户模型
Django 内置的用户模型对记录用户信息做了各种设想,明确要记录的包括名和姓 3,而且强制使用用户名。我坚信,除非真的需要,否则不要存储用户的任何信息。所以,一个只记录电子邮件地址的用户模型对我来说足够了。
3虽然 Django 的重要维护者对这个决策并不后悔,但并非每个人都有名和姓。
我相信你已经知道我们应该创建 tests 文件夹,并在其中创建 init.py 文件,然后删除 tests.py,再新建 test_models.py,写入下述内容:
accounts/tests/test_models.py (ch16l024)
from django.test import TestCase
from django.contrib.auth import get_user_model
User = get_user_model()
class UserModelTest(TestCase):
def test_user_is_valid_with_email_only(self):
user = User(email='a@b.com')
user.full_clean() # 不该抛出异常
测试的结果是一个预期失败:
django.core.exceptions.ValidationError: {'password': ['This field cannot be
blank.'], 'username': ['This field cannot be blank.']}
密码?用户名?不!把模型写成这样如何?
accounts/models.py
from django.db import models
class User(models.Model):
email = models.EmailField()
然后在 settings.py 中把 accounts
应用添加到 INSTALLED_APPS
中,再设定 AUTH_USER_MODEL
:
superlists/settings.py (ch16l026)
INSTALLED_APPS = [
#'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'lists',
'accounts',
]
AUTH_USER_MODEL = 'accounts.User'
现在得到的错误与数据库有关:
django.db.utils.OperationalError: no such table: accounts_user
与之前一样,这表明我们要执行迁移。但当我们执行迁移时,Django 又会抱怨用户模型缺少一些元数据:
- $ python manage.py makemigrations
- Traceback (most recent call last):
- [...]
- if not isinstance(cls.REQUIRED_FIELDS, (list, tuple)):
- AttributeError: type object 'User' has no attribute 'REQUIRED_FIELDS'
唉,Django 啊,这个模型只有一个字段而已,你自己应该能找到问题的答案啊。既然你不能,那我就提供给你吧:
accounts/models.py
class User(models.Model):
email = models.EmailField()
REQUIRED_FIELDS = []
还有疑问吗? 4
4你可能想问,既然我觉得 Django 很笨,为什么不提交“合并请求”(pull request)修正呢?这个问题应该很容易修正。嗯,我保证等我写完这本书之后会这么做的。尖锐的批评就此打住吧!
- $ python manage.py makemigrations
- [...]
- AttributeError: type object 'User' has no attribute 'USERNAME_FIELD'
经过几次失败之后,得到的模型如下所示:
accounts/models.py
class User(models.Model):
email = models.EmailField()
REQUIRED_FIELDS = []
USERNAME_FIELD = 'email'
is_anonymous = False
is_authenticated = True
现在出现的错误稍有不同:
- $ python manage.py makemigrations
- SystemCheckError: System check identified some issues:
- ERRORS:
- accounts.User: (auth.E003) 'User.email' must be unique because it is named as
- the 'USERNAME_FIELD'.
好吧,可以这样修正:
accounts/models.py (ch16l028-1)
email = models.EmailField(unique=True)
现在能成功迁移了:
- $ python manage.py makemigrations
- Migrations for 'accounts':
- accounts/migrations/0001_initial.py
- - Create model User
测试也能通过了:
- $ python manage.py test accounts
- [...]
- Ran 1 tests in 0.001s
- OK
不过用户模型比想象的复杂了一点,除了 email
字段之外,还有自动生成的 ID 字段,用作主键。这个模型可以进一步简化!
把测试当作文档
下面我们要把 email
字段设为主键,5 因而必须把自动生成的 id
字段删除。
5其实电子邮件地址不太适合作为主键。一位深受其害的读者写了一封邮件给我,控诉了他们这十几年来使用电子邮件地址作为主键遇到的各种问题(因为这样无法管理多用户账户)。所以,还是那句话,具体问题具体分析。
我们可以直接这么做,测试也不会失败,然后信心满满地声称这“只是一次重构”。但是,最好为此专门编写一个测试:
accounts/tests/test_models.py (ch16l028-3)
def test_email_is_primary_key(self):
user = User(email='a@b.com')
self.assertEqual(user.pk, 'a@b.com')
如果以后回过头再看代码,这个测试能唤起我们的记忆,想起曾经做过这次修改。
self.assertEqual(user.pk, 'a@b.com')
AssertionError: None != 'a@b.com'
测试可以作为一种文档形式,因为测试体现了你对某个类或函数的需求。如果你忘记了为什么要用某种方法编写代码,可以回过头来看测试,有时就能找到答案。这就是一定要给测试方法起个意思明确的名字的原因。
实现的方式如下(可以先使用 unique=True
,看看结果如何):
accounts/models.py (ch16l028-4)
email = models.EmailField(primary_key=True)
一定不能忘了调整迁移:
- $ rm accounts/migrations/0001_initial.py
- $ python manage.py makemigrations
- Migrations for 'accounts':
- accounts/migrations/0001_initial.py
- - Create model User
现在两个测试都能通过:
- $ python manage.py test accounts
- [...]
- Ran 2 tests in 0.001s
- OK
18.5 令牌模型:把电子邮件地址与唯一的ID关联起来
接下来构建一个令牌模型。下面是个简短的单元测试,能捕获基本的问题——应该能把电子邮件地址关联到唯一的 ID 上,而且 ID 不能在一行中重复出现:
accounts/tests/test_models.py (ch16l030)
from accounts.models import Token
[...]
class TokenModelTest(TestCase):
def test_links_user_with_auto_generated_uid(self):
token1 = Token.objects.create(email='a@b.com')
token2 = Token.objects.create(email='a@b.com')
self.assertNotEqual(token1.uid, token2.uid)
使用 TDD 驱动开发 Django 模型要历经几番波折,因为这涉及迁移,所以我们将像这样迭代多次:微改代码、创建迁移、遇到新错误、删除迁移、重新创建迁移、再修改代码……
- $ python manage.py makemigrations
- Migrations for 'accounts':
- accounts/migrations/0002_token.py
- - Create model Token
- $ python manage.py test accounts
- [...]
- TypeError: 'email' is an invalid keyword argument for this function
我相信你能按部就班走完整个过程。记住,虽然我看不见你,但是测试山羊能!
- $ rm accounts/migrations/0002_token.py
- $ python manage.py makemigrations
- Migrations for 'accounts':
- accounts/migrations/0002_token.py
- - Create model Token
- $ python manage.py test accounts
- AttributeError: 'Token' object has no attribute 'uid'
最终写出的模型代码如下:
accounts/models.py (ch16l033)
class Token(models.Model):
email = models.EmailField()
uid = models.CharField(max_length=40)
得到的错误是:
- $ python manage.py test accounts
- [...]
- self.assertNotEqual(token1.uid, token2.uid)
- AssertionError: '' == ''
现在要决定如何生成随机的唯一 ID 字段。我们可以使用 random
模块,但是 Python 自带的另一个模块是专门用于生成唯一 ID 的,名为“uuid”(universally unique id 的简称)。
它的用法如下:
accounts/models.py (ch16l035)
import uuid
[...]
class Token(models.Model):
email = models.EmailField()
uid = models.CharField(default=uuid.uuid4, max_length=40)
再调整一下迁移,测试便能通过:
- $ python manage.py test accounts
- [...]
- Ran 3 tests in 0.015s
- OK
不错,逐渐走上正轨了——至少模型层完成了。下一章将介绍模拟技术,这是测试外部依赖(例如邮件)的关键技术。
探索性编程、探究及去掉探究代码
探究
为了学习新 API 或调查新方案的可行性而做的探索性编程。没有测试也能探究。最好在一个新分支中探究,去掉探究代码时再回到主分支。
去掉探究代码
把探究所得应用到真实的代码基中。要完全摒弃探究代码,然后从头开始,用 TDD 流程再实现一次。去掉探究代码后实际编写的代码往往与最初有很大不同,而且通常更好。
针对探究代码编写功能测试
该不该这么做要视情况而定。支持这么做的人认为,这样有助于正确编写功能测试——找出测试探究的方法与探究本身一样具有挑战性;不支持这么做的人觉得这样会阻碍思路,写出的代码往往与探究时很像——我们要力求避免这种情况。
第 19 章 使用驭件测试外部依赖或减少重复
本章开始说明如何测试发送电子邮件的代码。通过前面的功能测试,我们得知 Django 提供了获取所发送邮件的方式,即 mail.outbox
属性。不过我想在这一章演示一种十分重要的测试技术,叫作模拟技术。鉴于此,本章的单元测试将假装 Django 没有提供这样的便捷方式。
我是说不能使用 Django 的
mail.outbox
吗?不是。你应该使用它,因为它很便利。但是我想教你模拟技术,因为这是在单元测试中测试外部依赖的通用方式。毕竟你不会一直使用 Django。即便如此,除了发送电子邮件之外还有很多操作,只要与第三方 API 交互都适合使用驭件测试。
19.1 开始之前布好基本管道
首先设置一个基本的视图和 URL。编写一个简单的测试,检查发送登录邮件的 URL 最终会重定向到首页:
accounts/tests/test_views.py
from django.test import TestCase
class SendLoginEmailViewTest(TestCase):
def test_redirects_to_home_page(self):
response = self.client.post('accountssend_login_email', data={
'email': 'edith@example.com'
})
self.assertRedirects(response, '/')
我们已经在 accounts/urls.py 中设置了 url
,也在 superlists/urls.py 中使用 include
引入了,再加上下述代码,这个测试便能通过:
accounts/views.py
from django.core.mail import send_mail
from django.shortcuts import redirect
def send_login_email(request):
return redirect('/')
现在导入 send_mail
函数只是占个位子:
- $ python manage.py test accounts
- [...]
- Ran 4 tests in 0.015s
- OK
好的,有切入点了,下面开始使用模拟技术。
19.2 自己动手模拟(打猴子补丁)
在真实情况中,我们调用 send_mail
时希望 Django 连接电子邮件服务提供商,通过网络把真实的电子邮件发送出去。但这不是我们希望在测试中发生的。代码有外部副作用时也是如此,例如调用 API、发推文、发短信,等等。在单元测试中,我们并不想真的通过互联网发推文或调用 API。可是,我们必须找到一种方法,测试代码是否正确。驭件 1 正是我们寻找的答案。
1我使用的是通用术语“驭件”(mock),而测试狂热者却希望明确区分“测试替身”这类测试工具中的不同概念,包括侦件(spy)、伪件(fake)和桩件(stub)。在这本书中不必纠结它们之间的区别,如果你想深入了解,请阅读 Justin Searls 写的精彩维基“Test Double”。剧透:此文充满各种测试知识。
恰好,Python 的优势之一是它的动态本性,这样十分有利于模拟,我们有时称这样的做法为打猴子补丁。首先,假设调用 send_mail
时要设定邮件主题、发件地址和收件地址,比如像下面这样:
accounts/views.py
def send_login_email(request):
email = request.POST['email']
# send_mail(
# 'Your login link for Superlists',
# 'body text tbc',
# 'noreply@superlists',
# [email],
# )
return redirect('/')
在不真正调用 send_mail
函数的情况下,应该怎么测试呢?答案是在调用 send_login_email
视图之前,测试可以让 Python 在运行时把 send_mail
函数替换成一个伪造的版本。看一下具体的代码:
accounts/tests/test_views.py (ch17l005)
from django.test import TestCase
import accounts.views ➋
class SendLoginEmailViewTest(TestCase):
[...]
def test_sends_mail_to_address_from_post(self):
self.send_mail_called = False
def fake_send_mail(subject, body, from_email, to_list): ➊
self.send_mail_called = True
self.subject = subject
self.body = body
self.from_email = from_email
self.to_list = to_list
accounts.views.send_mail = fake_send_mail ➋
self.client.post('accountssend_login_email', data={
'email': 'edith@example.com'
})
self.assertTrue(self.send_mail_called)
self.assertEqual(self.subject, 'Your login link for Superlists')
self.assertEqual(self.from_email, 'noreply@superlists')
self.assertEqual(self.to_list, ['edith@example.com'])
❶ 定义 fake_send_mail
函数。它看起来与 send_mail
函数很像,但它其实只是使用 self
的一些变量存储了一些关于调用方式的信息。
❷ 然后,在测试执行 self.client.post
之前,把真的 accounts.views.send_mail
函数换成假的——只需一次简单的赋值。
注意,我们没有施什么魔法,只是打破常规,利用 Python 这门动态语言的优势。
在真正调用函数之前,只要命名空间正确,就可以修改用于访问函数的变量(因此才需要导入位于顶层的 accounts
模块,这样方能进入 accounts.views
模块,达到 accounts.views.send_login_email
函数所在的作用域)。
这种做法不只限于单元测试,在任何 Python 代码中都可以像这样打猴子补丁。
你可能要花点时间才能习惯。在深入探讨细节之前,你要说服自己相信这没什么大不了的。
- 为什么要使用
self
传递信息?这只是在fake_send_mail
函数的作用域内外传递信息的便利方式。此外,我们还可以使用可变的对象,例如列表或字典,只要那个对象在伪造函数的外部即可。(如果好奇,可以试试不同的方式,看哪些可行,哪些不可行。) - 一定要在调用真实函数“之前”!我曾无数次呆坐在那里,百思不得其解,为什么驭件不起作用呢?最后才恍然大悟,我没有在调用真实的函数之前替换为伪造的函数。
下面看一下我们自己动手打造的驭件能否驱动开发:
- $ python manage.py test accounts
- [...]
- self.assertTrue(self.send_mail_called)
- AssertionError: False is not true
直接调用 send_mail
试试:
accounts/views.py
def send_login_email(request):
send_mail()
return redirect('/')
测试结果变成了:
TypeError: fake_send_mail() missing 4 required positional arguments: 'subject',
'body', 'from_email', and 'to_list'
看样子猴子补丁起作用了!我们调用了 send_mail
,而它执行的是 fake_send_mail
函数,后者要求提供更多参数。提供参数试试:
accounts/views.py
def send_login_email(request):
send_mail('subject', 'body', 'from_email', ['to email'])
return redirect('/')
测试的结果为:
self.assertEqual(self.subject, 'Your login link for Superlists')
AssertionError: 'subject' != 'Your login link for Superlists'
一切顺利。然后调整代码,改成下面这样:
accounts/views.py
def send_login_email(request):
email = request.POST['email']
send_mail(
'Your login link for Superlists',
'body text tbc',
'noreply@superlists',
[email]
)
return redirect('/')
测试能通过了:
- $ python manage.py test accounts
- Ran 5 tests in 0.016s
- OK
棒极了!我们模拟了 send_email
函数,为正常情况下应该通过互联网发送邮件的代码编写了测试,这样测试和代码就没有出入了。2
2是的,我知道 Django 已经使用 mail.outbox
提供了电子邮件驭件。但是,再次声明,我们要假装没有。如果我们使用的是 Flask 呢?或者,如果这是调用 API,而不是发送邮件呢?
19.3 Python的模拟库
流行的 mock 包从 Python 3.3 起纳入了标准库。3 这个包提供了一个充满魔力的对象,名为 mock
。下面在 Python shell 中试用一下:
3Python 2 用户可以使用 pip install mock
安装。
>>> from unittest.mock import Mock
>>> m = Mock()
>>> m.any_attribute
<Mock name='mock.any_attribute' id='140716305179152'>
>>> type(m.any_attribute)
<class 'unittest.mock.Mock'>
>>> m.any_method()
<Mock name='mock.any_method()' id='140716331211856'>
>>> m.foo()
<Mock name='mock.foo()' id='140716331251600'>
>>> m.called
False
>>> m.foo.called
True
>>> m.bar.return_value = 1
>>> m.bar(42, var='thing')
1
>>> m.bar.call_args
call(42, var='thing')
这个对象很神奇,它能响应任何属性访问或方法调用,可以指明调用的返回值,还可以审查调用时传入的参数是什么。看起来它很适合在单元测试中使用。
19.3.1 使用unittest.patch
如果觉得这不够用,mock
模块还提供了辅助函数 patch
,利用它可以实现前面动手打的猴子补丁。
稍后再讲原理,现在先看具体用法:
accounts/tests/test_views.py (ch17l007)
from django.test import TestCase
from unittest.mock import patch
[...]
@patch('accounts.views.send_mail')
def test_sends_mail_to_address_from_post(self, mock_send_mail):
self.client.post('accountssend_login_email', data={
'email': 'edith@example.com'
})
self.assertEqual(mock_send_mail.called, True)
(subject, body, from_email, to_list), kwargs = mock_send_mail.call_args
self.assertEqual(subject, 'Your login link for Superlists')
self.assertEqual(from_email, 'noreply@superlists')
self.assertEqual(to_list, ['edith@example.com'])
重新运行这个测试,你会发现它仍能通过。大改之后依然能通过的测试最让人怀疑,下面故意搞个破坏:
accounts/tests/test_views.py (ch17l008)
self.assertEqual(to_list, ['schmedith@example.com'])
并在视图中添加一行代码,打印调试信息:
accounts/views.py (ch17l009)
def send_login_email(request):
email = request.POST['email']
print(type(send_mail))
send_mail(
[...]
再次运行测试:
- $ python manage.py test accounts
- [...]
- <class 'function'>
- <class 'unittest.mock.MagicMock'>
- [...]
- AssertionError: Lists differ: ['edith@example.com'] !=
- ['schmedith@example.com']
- [...]
- Ran 5 tests in 0.024s
- FAILED (failures=1)
显然,测试失败了。从失败消息前面的输出可以看出,send_mail
函数的类型在第一个单元测试中是常规的函数,而在第二个单元测试中则是一个驭件。
删掉故意出错的代码,然后深入分析到底发生了什么:
accounts/tests/test_views.py (ch17l011)
@patch('accounts.views.send_mail') ➊
def test_sends_mail_to_address_from_post(self, mock_send_mail): ➋
self.client.post('accountssend_login_email', data={
'email': 'edith@example.com' ➌
})
self.assertEqual(mock_send_mail.called, True) ➍
(subject, body, from_email, to_list), kwargs = mock_send_mail.call_args ❺
self.assertEqual(subject, 'Your login link for Superlists')
self.assertEqual(from_email, 'noreply@superlists')
self.assertEqual(to_list, ['edith@example.com'])
❶ patch
装饰器的参数是要打猴子补丁的函数的点分名称。这一行代码的作用等同于动手替换 accounts.views
中的 send_mail
函数。这个装饰器的优点很多,不仅可以自动把目标替换成驭件,结束后还能自动换回原对象(否则后续使用的仍是打过猴子补丁的版本,这可能对其他测试产生影响)。
❷ patch
通过传给测试方法的参数注入驭件。这个参数的名称随意,不过我习惯在原对象名称前面加上 mock_
。
❸ 像往常一样调用要测试的函数,但是在测试方法中使用的是驭件,所以视图不会真的调用 send_mail
,而是使用 mock_send_mail
。
❹ 下断言,检查驭件在测试过程中有什么变化。先调用驭件……
❺ ……然后拆解出各个位置参数和关键字参数,检查调用时传入的是什么值。(稍后会详细讨论 call_args
。)
彻底弄明白了吗?没有?没关系。后面还会在多个测试中使用驭件,你会逐渐习惯的。
19.3.2 让测试向前迈一小步
暂且回到功能测试,看看是在哪里失败的:
- $ python manage.py test functional_tests.test_login
- [...]
- AssertionError: 'Check your email' not found in 'Superlists\nEnter email to log
- in:\nStart a new To-Do list'
提交电子邮件地址目前没有任何效果,因为表单没向任何地方发送数据。下面在 base.html 中设置:4
4限于本书纸张的尺寸,我把这个 form
标签分成了三行。如果你没见过这种写法,可能觉得有点奇怪,但这是有效的 HTML。如果不喜欢,你可以不这样写。()
lists/templates/base.html (ch17l012)
<form class="navbar-form navbar-right"
method="POST"
action="{% url 'send_login_email' %}">
有没有用?没有,还有错误。为什么呢?因为成功给用户发送电子邮件后没有显示成功消息。下面为此添加一个测试。
19.3.3 测试Django消息框架
我们将使用 Django 的“消息框架”,通常用于显示临时的“成功”或“提醒”消息,指明操作的结果。如果你没用过这个框架,可以看一下它的文档。
测试 Django 的消息有点曲折,要把 follow=True
传给测试客户端,让它获取 302 重定向后的页面,在里面查找消息列表(在显示之前转换为列表)。这个测试如下所示:
accounts/tests/test_views.py (ch17l013)
def test_adds_success_message(self):
response = self.client.post('accountssend_login_email', data={
'email': 'edith@example.com'
}, follow=True)
message = list(response.context['messages'])[0]
self.assertEqual(
message.message,
"Check your email, we've sent you a link you can use to log in."
)
self.assertEqual(message.tags, "success")
测试的结果为:
- $ python manage.py test accounts
- [...]
- message = list(response.context['messages'])[0]
- IndexError: list index out of range
然后像下面这样修改,让测试通过:
accounts/views.py (ch17l014)
from django.contrib import messages
[...]
def send_login_email(request):
[...]
messages.success(
request,
"Check your email, we've sent you a link you can use to log in."
)
return redirect('/')
驭件可能导致与实现紧密耦合
这个框注涉及中级测试技巧。如果第一次没读懂,读完本章和第 23 章之后再回过头来看。
我说过,测试消息有点曲折,我试了好几次才做对。其实,我们已经不在工作中这样测试消息了,而是使用驭件。使用驭件的话,上述测试可以改成这样:
accounts/tests/test_views.py (ch17l014-2)
from unittest.mock import patch, call
[...]
@patch('accounts.views.messages')
def test_adds_success_message_with_mocks(self, mock_messages):
response = self.client.post('accountssend_login_email', data={
'email': 'edith@example.com'
})
expected = "Check your email, we've sent you a link you can use to log in."
self.assertEqual(
mock_messages.success.call_args,
call(response.wsgi_request, expected),
)
我们模拟了
messages
模块,然后检查调用messages.success
时传入了正确的参数:原请求和想看到的消息。使用前面的代码就能让这个测试通过。不过有个问题:对
messages
框架来说,获得相同结果的方式不止一种。视图的代码还可以像这样写:accounts/views.py (ch17l014-3)
messages.add_message(
request,
messages.SUCCESS,
"Check your email, we've sent you a link you can use to log in."
)
此时,未使用驭件的测试仍能通过,但使用驭件的测试将失败。这是因为没有调用
messages.success
,而是调用了messages.add_message
。即便最终结果一样,而且代码也是“正确的”,可测试却出问题了。这就是人们常说的,使用驭件可能导致“与实现紧密耦合”。我们知道,通常最好测试行为,而不测试实现细节;测试发生了什么,而不测试是如何发生的。驭件往往在“如何做”这条道上走得太远,而很少关注“是什么”。
后续章节还会深入讨论驭件的优缺点。
19.3.4 在HTML中添加消息
功能测试有进展吗?啊,还没有。我们要把消息添加到页面中,像下面这样:
lists/templates/base.html (ch17l015)
[...]
</nav>
{% if messages %}
<div class="row">
<div class="col-md-8">
{% for message in messages %}
{% if message.level_tag == 'success' %}
<div class="alert alert-success">{{ message }}</div>
{% else %}
<div class="alert alert-warning">{{ message }}</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
现在该有进展了吧?是的!
- $ python manage.py test accounts
- [...]
- Ran 6 tests in 0.023s
- OK
- $ python manage.py test functional_tests.test_login
- [...]
- AssertionError: 'Use this link to log in' not found in 'body text tbc'
失败消息提醒我们,在电子邮件的正文中找出用于点击登录的链接。
先暂时作弊,直接修改视图中的值:
accounts/views.py
send_mail(
'Your login link for Superlists',
'Use this link to log in',
'noreply@superlists',
[email]
)
这样,功能测试稍微向前走了一点:
- $ python manage.py test functional_tests.test_login
- [...]
- AssertionError: Could not find url in email body:
- Use this link to log in
19.3.5 构建登录URL
接下来该构建某种形式的 URL 了!这一次依然作弊:
accounts/tests/test_views.py (ch17l017)
class LoginViewTest(TestCase):
def test_redirects_to_home_page(self):
response = self.client.get('accountslogin?token=abcd123')
self.assertRedirects(response, '/')
假设令牌通过 GET 参数传递,即放在 ? 后面。现在还不需要它做些什么。
我相信你能构建出所需的 URL 和视图,在这个过程中你会历经下述错误。
- 没有 URL:
AssertionError: 404 != 302 : Response didn't redirect as expected: Response
code was 404 (expected 302)
- 没有视图:
AttributeError: module 'accounts.views' has no attribute 'login'
- 视图出错:
ValueError: The view accounts.views.login didn't return an HttpResponse object.
It returned None instead.
- 测试通过:
- $ python manage.py test accounts
- [...]
- Ran 7 tests in 0.029s
- OK
现在,链接的目标 URL 有了,但是还没什么用,因为我们还没给用户提供令牌。
19.3.6 确认给用户发送了带有令牌的链接
对 send_login_email
视图来说,我们测试了电子邮件的主题、发件地址和收件地址,而包含令牌或 URL 的正文还没有测试。下面为正文编写两个测试:
accounts/tests/test_views.py (ch17l021)
from accounts.models import Token
[...]
def test_creates_token_associated_with_email(self):
self.client.post('accountssend_login_email', data={
'email': 'edith@example.com'
})
token = Token.objects.first()
self.assertEqual(token.email, 'edith@example.com')
@patch('accounts.views.send_mail')
def test_sends_link_to_login_using_token_uid(self, mock_send_mail):
self.client.post('accountssend_login_email', data={
'email': 'edith@example.com'
})
token = Token.objects.first()
expected_url = f'http://testserveraccountslogin?token={token.uid}'
(subject, body, from_email, to_list), kwargs = mock_send_mail.call_args
self.assertIn(expected_url, body)
第一个测试十分简单,检查我们在数据库中创建的令牌是与 POST 请求中的电子邮件地址关联的。
第二个测试是我们第二次使用驭件。再次使用 patch
装饰器模拟 send_mail
函数,但这次关注的是调用时传入的 body
参数。
现在,这些测试是失败的,因为还没创建令牌:
- $ python manage.py test accounts
- [...]
- AttributeError: 'NoneType' object has no attribute 'email'
- [...]
- AttributeError: 'NoneType' object has no attribute 'uid'
创建令牌就能让第一个测试通过:
accounts/views.py (ch17l022)
from accounts.models import Token
[...]
def send_login_email(request):
email = request.POST['email']
token = Token.objects.create(email=email)
send_mail(
[...]
第二个测试提示我们在电子邮件的正文中使用令牌:
[...]
AssertionError:
'http://testserveraccountslogin?token=[...]
not found in 'Use this link to log in'
FAILED (failures=1)
因此,像下面这样在电子邮件中插入令牌:
accounts/views.py (ch17l023)
from django.core.urlresolvers import reverse
[...]
def send_login_email(request):
email = request.POST['email']
token = Token.objects.create(email=email)
url = request.build_absolute_uri( ➊
reverse('login') + '?token=' + str(token.uid)
)
message_body = f'Use this link to log in:\n\n{url}'
send_mail(
'Your login link for Superlists',
message_body,
'noreply@superlists',
[email]
)
[...]
➊ 请注意 request.build_absolute_uri
,这是在 Django 中构建“完整”URL 的一种方式,所得的 URL 包括域名和协议(http/https)。除此之外还有其他方式,不过往往都牵扯“网站”框架,容易导致代码过于复杂。如果你好奇,简单搜索一下就能找到很多这方面的讨论。
我们又解决了两个问题。接下来,需要一个身份验证后端,检查令牌的有效性,然后返回对应的用户。此外,还需要让登录视图在用户通过身份验证后登入用户。
19.4 去除自定义的身份验证后端中的探究代码
接下来要自定义身份验证后端。探究时编写的代码如下所示:
class PasswordlessAuthenticationBackend(object):
def authenticate(self, uid):
print('uid', uid, file=sys.stderr)
if not Token.objects.filter(uid=uid).exists():
print('no token found', file=sys.stderr)
return None
token = Token.objects.get(uid=uid)
print('got token', file=sys.stderr)
try:
user = ListUser.objects.get(email=token.email)
print('got user', file=sys.stderr)
return user
except ListUser.DoesNotExist:
print('new user', file=sys.stderr)
return ListUser.objects.create(email=token.email)
def get_user(self, email):
return ListUser.objects.get(email=email)
这段代码的意思是:
- 检查数据库中是否有指定的 UID;
- 如果没有,返回
None
; - 如果有,提取电子邮件地址,通过这个地址找到现有的用户,或者创建一个新用户。
19.4.1 一个if
语句需要一个测试
如何为这种函数编写测试有个经验法则:一个 if
语句需要一个测试,一个 try/except
语句也需要一个测试。所以这里一共需要三个测试。像下面这样写怎么样?
accounts/tests/test_authentication.py
from django.test import TestCase
from django.contrib.auth import get_user_model
from accounts.authentication import PasswordlessAuthenticationBackend
from accounts.models import Token
User = get_user_model()
class AuthenticateTest(TestCase):
def test_returns_None_if_no_such_token(self):
result = PasswordlessAuthenticationBackend().authenticate(
'no-such-token'
)
self.assertIsNone(result)
def test_returns_new_user_with_correct_email_if_token_exists(self):
email = 'edith@example.com'
token = Token.objects.create(email=email)
user = PasswordlessAuthenticationBackend().authenticate(token.uid)
new_user = User.objects.get(email=email)
self.assertEqual(user, new_user)
def test_returns_existing_user_with_correct_email_if_token_exists(self):
email = 'edith@example.com'
existing_user = User.objects.create(email=email)
token = Token.objects.create(email=email)
user = PasswordlessAuthenticationBackend().authenticate(token.uid)
self.assertEqual(user, existing_user)
在 authenticate.py 中先放一个占位函数:
accounts/authentication.py
class PasswordlessAuthenticationBackend(object):
def authenticate(self, uid):
pass
测试的结果如何?
- $ python manage.py test accounts
- .FE.........
- ======================================================================
- ERROR: test_returns_new_user_with_correct_email_if_token_exists
- (accounts.tests.test_authentication.AuthenticateTest)
- ---------------------------------------------------------------------
- Traceback (most recent call last):
- File "...superlistsaccountstests/test_authentication.py", line 21, in
- test_returns_new_user_with_correct_email_if_token_exists
- new_user = User.objects.get(email=email)
- [...]
- accounts.models.DoesNotExist: User matching query does not exist.
- ======================================================================
- FAIL: test_returns_existing_user_with_correct_email_if_token_exists
- (accounts.tests.test_authentication.AuthenticateTest)
- ---------------------------------------------------------------------
- Traceback (most recent call last):
- File "...superlistsaccountstests/test_authentication.py", line 30, in
- test_returns_existing_user_with_correct_email_if_token_exists
- self.assertEqual(user, existing_user)
- AssertionError: None != <User: User object>
- ---------------------------------------------------------------------
- Ran 12 tests in 0.038s
- FAILED (failures=1, errors=1)
修改代码试试:
accounts/authentication.py (ch17l026)
from accounts.models import User, Token
class PasswordlessAuthenticationBackend(object):
def authenticate(self, uid):
token = Token.objects.get(uid=uid)
return User.objects.get(email=token.email)
一个测试通过了,但却导致另一个失败了:
- $ python manage.py test accounts
- ERROR: test_returns_None_if_no_such_token
- (accounts.tests.test_authentication.AuthenticateTest)
- accounts.models.DoesNotExist: Token matching query does not exist.
- ERROR: test_returns_new_user_with_correct_email_if_token_exists
- (accounts.tests.test_authentication.AuthenticateTest)
- [...]
- accounts.models.DoesNotExist: User matching query does not exist.
下面逐个修正:
accounts/authentication.py (ch17l027)
def authenticate(self, uid):
try:
token = Token.objects.get(uid=uid)
return User.objects.get(email=token.email)
except Token.DoesNotExist:
return None
只剩一个失败测试了:
ERROR: test_returns_new_user_with_correct_email_if_token_exists
(accounts.tests.test_authentication.AuthenticateTest)
[...]
accounts.models.DoesNotExist: User matching query does not exist.
FAILED (errors=1)
这个问题可以像这样解决:
accounts/authentication.py (ch17l028)
def authenticate(self, uid):
try:
token = Token.objects.get(uid=uid)
return User.objects.get(email=token.email)
except User.DoesNotExist:
return User.objects.create(email=token.email)
except Token.DoesNotExist:
return None
这比探究时编写的代码更简洁!
19.4.2 get_user
方法
我们已经实现了供 Django 用于登入新用户的 authenticate
函数。这个协议的第二部分是实现 get_user
方法,它的作用是根据唯一标识符(电子邮件地址)获取用户,如果找不到就返回 None
(如果你记不清了,请看一下本节开头给出的探究代码)。
下面为这两个需求编写几个测试:
accounts/tests/test_authentication.py (ch17l030)
class GetUserTest(TestCase):
def test_gets_user_by_email(self):
User.objects.create(email='another@example.com')
desired_user = User.objects.create(email='edith@example.com')
found_user = PasswordlessAuthenticationBackend().get_user(
'edith@example.com'
)
self.assertEqual(found_user, desired_user)
def test_returns_None_if_no_user_with_that_email(self):
self.assertIsNone(
PasswordlessAuthenticationBackend().get_user('edith@example.com')
)
这时的失败消息是:
AttributeError: 'PasswordlessAuthenticationBackend' object has no attribute
'get_user'
那就定义一个占位方法:
accounts/authentication.py (ch17l031)
class PasswordlessAuthenticationBackend(object):
def authenticate(self, uid):
[...]
def get_user(self, email):
pass
现在失败消息变成了:
self.assertEqual(found_user, desired_user)
AssertionError: None != <User: User object>
慢慢实现这个方法(一步一步来,看测试是否像我们设想的那样失败):
accounts/authentication.py (ch17l033)
def get_user(self, email):
return User.objects.first()
现在第一个断言通过了,失败消息变成了:
self.assertEqual(found_user, desired_user)
AssertionError: <User: User object> != <User: User object>
那就调用 get
,并传入电子邮件地址:
accounts/authentication.py (ch17l034)
def get_user(self, email):
return User.objects.get(email=email)
现在针对返回 None
的测试失败了:
ERROR: test_returns_None_if_no_user_with_that_email
[...]
accounts.models.DoesNotExist: User matching query does not exist.
根据提示,可以这样完成整个方法:
accounts/authentication.py (ch17l035)
def get_user(self, email):
try:
return User.objects.get(email=email)
except User.DoesNotExist:
return None ➊
➊ 这里可以使用 pass
,函数默认会返回 None
。但我们希望它明确返回 None
,所以根据“明了胜于晦涩”原则,应该返回 None
。
现在测试能通过了:
OK
至此,我们得到了一个可用的身份验证后端。
19.4.3 在登录视图中使用自定义的验证后端
最后一步,在登录视图中使用这个后端。首先,在 settings.py 中添加自定义的后端:
superlists/settings.py (ch17l036)
AUTH_USER_MODEL = 'accounts.User'
AUTHENTICATION_BACKENDS = [
'accounts.authentication.PasswordlessAuthenticationBackend',
]
[...]
然后编写几个测试,检查视图的行为。再看一下探究时编写的视图:
accounts/views.py
def login(request):
print('login view', file=sys.stderr)
uid = request.GET.get('uid')
user = auth.authenticate(uid=uid)
if user is not None:
auth.login(request, user)
return redirect('/')
视图要调用 django.contrib.auth.authenticate
。如果返回一个用户,再调用 django.contrib.auth.login
。
现在应该阅读 Django 文档中对身份验证的说明,进一步了解细节。
19.5 使用驭件的另一个原因:减少重复
我们已经使用驭件测试了外部依赖,例如 Django 发送电子邮件的功能。使用驭件的主要原因是隔离外部副作用,这里是想避免在测试中真的发送电子邮件。
本节将说明驭件的另一种用法。此时,没有什么副作用需要担心,但是鉴于一些原因,我们仍然想使用驭件。
如果不使用驭件,测试登录视图要检查用户有没有真的登入,即检查在正确的情况下有没有为用户赋予表明已通过身份验证的会话 cookie。
但是,我们自定义的身份验证后端有几个不同的代码执行路径:令牌无效时返回 None
、用户存在时返回既存用户、令牌有效但用户不存在时新建用户。因此,为了全面测试这个视图,要分别针对这三种情况编写测试。
如果能有效减少测试之间的重复,就有充分的理由使用驭件。这是避免组合爆炸的一种方式。
此外,我们使用的是 Django 的 auth.authenticate
函数,而没有直接调用自己的代码,这样便于以后添加额外的后端。
因此,有必要考虑这里的实现细节(与“驭件可能导致与实现紧密耦合”框注中所述的相反),而使用驭件能避免在多个测试中重复编写实现方式。下面看一下这个测试应该怎样使用驭件编写:
accounts/tests/test_views.py (ch17l037)
from unittest.mock import patch, call
[...]
@patch('accounts.views.auth') ➊
def test_calls_authenticate_with_uid_fromgetrequest(self, mock_auth): ➋
self.client.get('accountslogin?token=abcd123')
self.assertEqual(
mock_auth.authenticate.call_args, ➌
call(uid='abcd123') ➍
)
❶ 我们期望在 views.py 中使用 django.contrib.auth
模块,所以这里模拟它的行为。注意,这里模拟的不是一个函数,而是整个模块,即模拟模块中的全部函数(及其他对象)。
❷ 与之前一样,把被模拟的对象注入测试方法。
❸ 这一次模拟的是一个模块,而不是一个函数。所以,call_args
不能在 mock_auth
模块上检查,而要在 mock_auth.authenticate
函数上检查。因为驭件的所有属性都是驭件,所以 mock_auth.authenticate
函数也是一个驭件。由此可以看出,与自己动手相比,mock
对象是多么好用。
❹ 这一次没有“拆包”调用参数,而是使用更简洁的 call
函数,指明调用时应该传入什么参数——GET 请求中的令牌。(参见下述框注。)
关于驭件的
call_args
驭件的
call_args
属性表示调用驭件时传入的位置参数和关键字参数。这是一个特殊的“调用”对象类型,其实是一个元组,内容为(positional_args, keyword_args)
。其中positional_args
自身也是一个元组,包含各个位置参数;而keyword_args
是个字典。
>>> from unittest.mock import Mock, call
>>> m = Mock()
>>> m(42, 43, 'positional arg 3', key='val', thing=666)
<Mock name='mock()' id='139909729163528'>
>>> m.call_args
call(42, 43, 'positional arg 3', key='val', thing=666)
>>> m.call_args == ((42, 43, 'positional arg 3'), {'key': 'val', 'thing': 666})
True
>>> m.call_args == call(42, 43, 'positional arg 3', key='val', thing=666)
True
所以,在上述测试中还可以这么写:
accounts/tests/test_views.py
self.assertEqual(
mock_auth.authenticate.call_args,
((,), {'uid': 'abcd123'})
)
# 或者这样写
args, kwargs = mock_auth.authenticate.call_args
self.assertEqual(args, (,))
self.assertEqual(kwargs, {'uid': 'abcd123')
不过可以看出,使用
call
辅助函数更简洁。
测试是什么情况呢?第一个错误是:
- $ python manage.py test accounts
- [...]
- AttributeError: <module 'accounts.views' from
- '...superlistsaccountsviews.py'> does not have the attribute 'auth'
在使用驭件的测试中,第一个失败消息经常是
module foo does not have the attribute bar
。这个消息的意思是,你尝试模拟的东西在目标模块中还不存在(或者尚未导入)。
导入 django.contrib.auth
后,错误会变:
accounts/views.py (ch17l038)
from django.contrib import auth, messages
[...]
现在的错误是:
AssertionError: None != call(uid='abcd123')
测试指出,视图根本没有调用 auth.authenticate
函数。下面修正,但是故意做错,看看效果如何:
accounts/views.py (ch17l039)
def login(request):
auth.authenticate('bang!')
return redirect('/')
调用的确实是 bang!
:
- $ python manage.py test accounts
- [...]
- AssertionError: call('bang!') != call(uid='abcd123')
- [...]
- FAILED (failures=1)
下面给 authenticate
提供预期的参数:
accounts/views.py (ch17l040)
def login(request):
auth.authenticate(uid=request.GET.get('token'))
return redirect('/')
现在测试通过了:
- $ python manage.py test accounts
- [...]
- Ran 15 tests in 0.041s
- OK
19.5.1 使用驭件的返回值
接下来,要检查 authenticate
函数是否返回一个用户,供 auth.login
使用。测试像下面这样编写:
accounts/tests/test_views.py (ch17l041)
@patch('accounts.views.auth') ➊
def test_callsauthlogin_with_user_if_there_is_one(self, mock_auth):
response = self.client.get('accountslogin?token=abcd123')
self.assertEqual(
mock_auth.login.call_args, ➋
call(response.wsgi_request, mock_auth.authenticate.return_value) ➌
)
❶ 还是模拟 contrib.auth
模块。
❷ 这一次检查 auth.login
函数的调用参数。
❸ 检查调用的参数是不是视图收到的请求对象,以及 authenticate
函数返回的“用户”对象。因为 authenticate
也是驭件,所以可以使用特殊的 return_value
属性。
调用驭件的结果是得到另一个驭件。不过,我们也可以从调用的原驭件中获得返回的驭件副本。为了解释清楚,我不得不多次使用“驭件”这个词。下面在控制台中演示一下,希望能帮助你理解:
>>> m = Mock()
>>> thing = m()
>>> thing
<Mock name='mock()' id='140652722034952'>
>>> m.return_value
<Mock name='mock()' id='140652722034952'>
>>> thing == m.return_value
True
先不管这些,我们想知道测试的结果如何:
- $ python manage.py test accounts
- [...]
- call(response.wsgi_request, mock_auth.authenticate.return_value)
- AssertionError: None != call(<WSGIRequest: GET 'accountslogin?t[...]
显然,测试指出我们根本没有调用 auth.login
。下面调用它。这一次还是故意做错。
accounts/views.py (ch17l042)
def login(request):
auth.authenticate(uid=request.GET.get('token'))
auth.login('ack!')
return redirect('/')
调用的确实是 ack!
:
TypeError: login() missing 1 required positional argument: 'user'
[...]
AssertionError: call('ack!') != call(<WSGIRequest: GET
'accountslogin?token=[...]
下面修正:
accounts/views.py (ch17l043)
def login(request):
user = auth.authenticate(uid=request.GET.get('token'))
auth.login(request, user)
return redirect('/')
这一次得到了意料之外的失败:
ERROR: test_redirects_to_home_page (accounts.tests.test_views.LoginViewTest)
[...]
AttributeError: 'AnonymousUser' object has no attribute '_meta'
这是因为我们在所有情况下都调用 auth.login
,从而导致针对重定向的测试(目前没有模拟 auth.login
)出问题。为了修正这个问题,我们要添加一个 if
(外加一个测试)。届时,我们将学习如何在类一级上打补丁。
19.5.2 在类一级上打补丁
我们还要编写一个测试,而且也要使用 @patch('accounts.views.auth')
装饰,这样便开始出现重复了。根据“三则重构”原则,可以把 patch
装饰器移到类一级上。这样,测试类中的每一个测试方法都将模拟 accounts.views.auth
。不过,这也意味着之前针对重定向的测试也要注入 mock_auth
变量:
accounts/tests/test_views.py (ch17l044)
@patch('accounts.views.auth') ➊
class LoginViewTest(TestCase):
def test_redirects_to_home_page(self, mock_auth): ➋
[...]
def test_calls_authenticate_with_uid_fromgetrequest(self, mock_auth): ➌
[...]
def test_callsauthlogin_with_user_if_there_is_one(self, mock_auth): ➌
[...]
def test_does_not_login_if_user_is_not_authenticated(self, mock_auth):
mock_auth.authenticate.return_value = None ➍
self.client.get('accountslogin?token=abcd123')
self.assertEqual(mock_auth.login.called, False) ❺
❶ 把 patch
装饰器移到类一级……
❷ 所以第一个测试方法多了一个参数……
❸ 而且可以删掉其他测试上的装饰器。
❹ 在新测试中,调用 self.client.get
之前在 auth.authenticate
驭件上设定 return_value
。
❺ 下断言,在 authenticate
返回 None
时,不应该调用 auth.login
。
现在假失败不见了,得到了意义明确的预期失败:
self.assertEqual(mock_auth.login.called, False)
AssertionError: True != False
像这样调整视图,让测试通过:
accounts/views.py (ch17l045)
def login(request):
user = auth.authenticate(uid=request.GET.get('token'))
if user:
auth.login(request, user)
return redirect('/')
可以收工了吗?
19.6 关键时刻:功能测试能通过吗
我觉得应该看一下功能测试结果如何了。下面修改基模板,为已登录用户和未登录用户显示不同的导航栏(功能测试检查的就是这个):
lists/templates/base.html (ch17l046)
<nav class="navbar navbar-default" role="navigation">
<div class="container-fluid">
<a class="navbar-brand" href="">Superlists<a>
{% if user.email %}
<ul class="nav navbar-nav navbar-right">
<li class="navbar-text">Logged in as {{ user.email }}</li>
<li><a href="#">Log out</a></li>
</ul>
{% else %}
<form class="navbar-form navbar-right"
method="POST"
action="{% url 'send_login_email' %}">
<span>Enter email to log in:</span>
<input class="form-control" name="email" type="text" >
{% csrf_token %}
<form>
{% endif %}
</div>
</nav>
看看测试能否通过:
- $ python manage.py test functional_tests.test_login
- Internal Server Error: accountslogin
- [...]
- File "...superlistsaccountsviews.py", line 31, in login
- auth.login(request, user)
- [...]
- ValueError: The following fields do not exist in this model or are m2m fields:
- last_login
- [...]
- selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
- element: Log out
噢,不!出问题了。如果 settings.py 中还保留着前面设定的 LOGGING
,你应该会看到如上所示的详细调用跟踪。可以看出,问题与 last_login
字段有关。
我觉得这是 Django 的缺陷,可验证框架就是预期用户模型有个 last_login
字段,而我们的用户模型没有。不过,别害怕!解决方法总是有的。
先写一个单元测试重现这个缺陷。因为这与我们自定义的用户模型有关,所以放在 test_models.py 中比较合适:
accounts/tests/test_models.py (ch17l047)
from django.test import TestCase
from django.contrib import auth
from accounts.models import Token
User = auth.get_user_model()
class UserModelTest(TestCase):
def test_user_is_valid_with_email_only(self):
[...]
def test_email_is_primary_key(self):
[...]
def test_no_problem_withauthlogin(self):
user = User.objects.create(email='edith@example.com')
user.backend = ''
request = self.client.request().wsgi_request
auth.login(request, user) # 不该抛出异常
创建一个请求对象和一个用户,然后把它们传给 auth.login
函数。
这个测试会向我们报告错误:
auth.login(request, user) # 不该抛出异常
[...]
ValueError: The following fields do not exist in this model or are m2m fields:
last_login
这个缺陷的具体原因其实跟本书没有多大关系,如果你想追根究底,可以看一下调用跟踪中给出的那几行 Django 源码,再读一下 Django 文档中对信号的说明。
重点是,我们可以像这样修正:
accounts/models.py (ch17l048)
import uuid
from django.contrib import auth
from django.db import models
auth.signals.userloggedin.disconnect(auth.models.update_last_login)
class User(models.Model):
[...]
现在功能测试的结果如何了?
- $ python manage.py test functional_tests.test_login
- [...]
- .
- ---------------------------------------------------------------------
- Ran 1 test in 3.282s
- OK
19.7 理论上正常,那么实际呢
哇呜!你能相信吗?我简直不能相信!下面执行 runserver
命令,亲自检查一下:
- $ python manage.py runserver
- [...]
- Internal Server Error: accountssend_login_email
- Traceback (most recent call last):
- File "...superlistsaccountsviews.py", line 20, in send_login_email
- ConnectionRefusedError: [Errno 111] Connection refused
自己动手检查时,你可能会像我一样遇到一个错误。可能的解决方法有两个。
- 在 settings.py 中重新配置电子邮件。
- 可能要在 shell 中导出电子邮件的密码。
superlists/settings.py (ch17l049)
EMAIL_HOST = 'smtp.gmail.com'
EMAIL_HOST_USER = 'obeythetestinggoat@gmail.com'
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_PASSWORD')
EMAIL_PORT = 587
EMAIL_USE_TLS = True
以及:
- $ export EMAIL_PASSWORD="sekrit"
- $ python manage.py runserver
然后便能看到如图 19-1 所示的界面。
图 19-1:查看你的邮件……
太棒了!
在此之前我一直没提交,因为我在等待一切都能顺利运行的时刻。此时,你可以做一系列单独的提交——登录视图一次、验证后端一次、用户模型一次、修改模板一次。或者,考虑到这些代码都有关联,不能独自运行,也可以做一次大提交:
- $ git status
- $ git add .
- $ git diff --staged
- $ git commit -m "Custom passwordless auth backend + custom user model"
19.8 完善功能测试,测试退出功能
收工之前还有最后一件事要做:测试退出链接。在现有功能测试的基础上添加几步:
functional_tests/test_login.py (ch17l050)
[...]
# 她登录了!
self.wait_for(
lambda: self.browser.find_element_by_link_text('Log out')
)
navbar = self.browser.find_element_by_css_selector('.navbar')
self.assertIn(TEST_EMAIL, navbar.text)
# 现在她要退出
self.browser.find_element_by_link_text('Log out').click()
# 她退出了
self.wait_for(
lambda: self.browser.find_element_by_name('email')
)
navbar = self.browser.find_element_by_css_selector('.navbar')
self.assertNotIn(TEST_EMAIL, navbar.text)
这样修改之后,测试会失败,原因是退出按钮没起作用:
- $ python manage.py test functional_tests.test_login
- [...]
- selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
- element: [name="email"]
实现退出按钮的方法其实很简单:可以使用 Django 内置的退出视图,让它清空用户的会话,然后重定向到我们指定的页面。
accounts/urls.py (ch17l051)
from django.contrib.auth.views import logout
[...]
urlpatterns = [
url(r'^send_login_email$', views.send_login_email, name='send_login_email'),
url(r'^login$', views.login, name='login'),
url(r'^logout$', logout, {'next_page': '/'}, name='logout'),
]
然后在 base.html 中,让退出按钮指向一个真的 URL:
lists/templates/base.html (ch17l052)
<li><a href="{% url 'logout' %}">Log out</a></li>
现在,功能测试都能通过了。其实,整个测试组件都可以通过:
- $ python manage.py test functional_tests.test_login
- [...]
- OK
- $ python manage.py test
- [...]
- Ran 59 tests in 78.124s
- OK
我们离真正安全或可行的登录系统还远着呢!这只是一本书的示例应用,可以就此结束。但是在实际使用中,你得研究很多安全和可用性问题,要做的事还多着呢!这里,我们以身犯险,“自己动手实现加密”——其实依赖现有的登录系统将安全得多。
下一章将充分利用这个登录系统。现在,做次提交,然后阅读总结。
在 Python 中使用模拟技术
模拟技术和外部依赖
编写单元测试时,如果涉及外部依赖,但又不想在测试中真的使用那个依赖,就可以使用模拟技术。驭件用于模拟第三方 API。虽然在 Python 中可以自己创建驭件,但模拟框架(例如
mock
模块)可以提供很多便利,让编写测试变得更简单。更重要的是,能让测试读起来更顺口。打猴子补丁
在运行时替换某个命名空间中的对象。前面的单元测试使用驭件(通过
patch
装饰器)替代有额外副作用的真实函数。Mock 库
Michael Foord(在我加入 PythonAnywhere 之前,他在孕育 PythonAnywhere 的公司工作)开发了很优秀的 Mock 库,现在这个库已经集成到 Python 3 的标准库中。这个库包含了在 Python 中使用模拟技术所需的几乎全部功能。
patch
装饰器
unittest.mock
模块提供的patch
函数可用于模拟要测试的模块中的任何一个对象。patch
一般用来装饰测试方法,不过也可以放在类一级,应用到类中的所有测试方法上。驭件可能导致与实现紧密耦合
如前文的旁注所述,驭件可能导致与实现紧密耦合。鉴于此,除非有足够的理由,否则不应该使用驭件。
驭件能减少测试中的重复
而另一方面,在测试中又没必要重复编写使用某个函数的高层级代码。此时使用驭件能减少重复。
接下来还将更为深入地讨论驭件的优缺点。敬请期待!
第 20 章 测试固件和一个显式等待装饰器
有了一个可以使用的认证系统,现在使用这个系统来识别用户,展示用户创建的所有清单。
为此,要在功能测试中使用已经登录的用户对象。但不能每个测试都走一遍发送登录邮件过程,这么做浪费时间,所以跳过这一步。
这就是分离关注点。功能测试和单元测试的区别在于,前者往往不止有一个断言。但是,理论上一个测试只应该测试一件事,所以没必要在每个功能测试中都测试登录和退出功能。如果能找到一种方法“作弊”,跳过认证,就不用花时间等待执行完重复的测试路径了。
在功能测试中去除重复时不要做得过火了。功能测试的优势之一是,可以捕获应用不同部分之间交互时产生的神秘莫测的表现。
本章专为这一版重写了。如果遇到问题,或者有改进建议,请通过 obeythetestinggoat@gmail.com 告诉我。
20.1 事先创建好会话,跳过登录过程
用户再次访问网站时 cookie 依然存在,这种现象很常见。也就是说,之前用户已经通过认证了。所以这种“作弊”手段并非异想天开。具体的做法如下:
functional_tests/test_my_lists.py
from django.conf import settings
from django.contrib.auth import BACKEND_SESSION_KEY, SESSION_KEY, get_user_model
from django.contrib.sessions.backends.db import SessionStore
from .base import FunctionalTest
User = get_user_model()
class MyListsTest(FunctionalTest):
def create_pre_authenticated_session(self, email):
user = User.objects.create(email=email)
session = SessionStore()
session[SESSION_KEY] = user.pk ➊
session[BACKEND_SESSION_KEY] = settings.AUTHENTICATION_BACKENDS[0]
session.save()
## 为了设定cookie,我们要先访问网站
## 而404页面是加载最快的
self.browser.get(self.live_server_url + "404_no_such_url")
self.browser.add_cookie(dict(
name=settings.SESSION_COOKIE_NAME,
value=session.session_key, ➋
path='/',
))
❶ 在数据库中创建一个会话对象。会话键的值是用户对象的主键,即用户的电子邮件地址。
❷ 然后把一个 cookie 添加到浏览器中,cookie 的值和服务器中的会话匹配。这样再次访问网站时,服务器就能识别已登录的用户。
注意,这种做法仅在使用 LiveServerTestCase
时才有效,所以已创建的 User
和 Session
对象只存在于测试服务器的数据库中。稍后修改实现的方式,让这个测试也能在过渡服务器里的数据库中运行。
Django 会话:通过 cookie 告知服务器,用户已通过身份验证
我斗胆尝试说明 Django 中的会话、cookie 和身份验证。
HTTP 是无状态的,服务器需要通过某种方式识别每次请求是哪个客户端发送的。IP 地址可以共享,因此常用的方案是为每个客户端分配一个唯一的会话ID,存储在 cookie 中,随每次请求发送。服务器会把这个 ID 存储在某处(默认存储在数据库中),从而识别请求是哪个客户端发送的。
在开发服务器中登录网站后,如果愿意,可以自己动手查看会话ID,它默认存储在
sessionid
键名下,如图 20-1 所示。
图 20-1:在调试工具中查看会话 cookie
Django 网站会为所有访客设定会话 cookie,不管有没有登录。
为了识别已登录的用户(即通过身份验证),服务器不会让客户端每次请求都发送用户名和密码,而是把客户端的会话标记为已通过验证的会话,并把它与数据库中的用户 ID 关联起来。
会话是类似字典的数据结构,用户 ID 存储在
django.contrib.auth.SESSION_KEY
设定的键名下。如果想查看会话的值,可以打开./manage.py shell
:
- $ python manage.py shell
- […]
- In [1]: from django.contrib.sessions.models import Session
- # 替换成你浏览器cookie中的会话ID
- In [2]: session = Session.objects.get(
- session_key="8u0pygdy9blo696g3n4o078ygt6l8y0y"
- )
- In [3]: print(session.get_decoded())
- {'authuser_id': 'obeythetestinggoat@gmail.com', 'authuser_backend':
- 'accounts.authentication.PasswordlessAuthenticationBackend'}
你还可以在用户的会话中存储其他信息,作为一种临时跟踪状态的方式。这对未登录的用户也是有效的。如果想这么做,只需在任意视图中使用
request.session
,它与字典的操作方式一样。详情参见 Django 文档对会话的说明。
检查是否可行
要检查这种做法是否可行,我们要使用现有测试中的一些代码。下面分别定义两个方法: wait_to_beloggedin
和 wait_to_beloggedout
。要想在不同的测试中访问这两个方法,要把它们放到 FunctionalTest
类中。此外,我们还得稍微修改一下,让它们可以接收任意的电子邮件地址作为参数:
functional_tests/base.py (ch18l002)
class FunctionalTest(StaticLiveServerTestCase):
[...]
def wait_to_beloggedin(self, email):
self.wait_for(
lambda: self.browser.find_element_by_link_text('Log out')
)
navbar = self.browser.find_element_by_css_selector('.navbar')
self.assertIn(email, navbar.text)
def wait_to_beloggedout(self, email):
self.wait_for(
lambda: self.browser.find_element_by_name('email')
)
navbar = self.browser.find_element_by_css_selector('.navbar')
self.assertNotIn(email, navbar.text)
嗯,还不错,但我不太喜欢这里重复出现的 wait_for
部分。先在便签上记录下来,稍后再修改,让这两个辅助方法可用。
首先,在 test_login.py 中使用它们:
functional_tests/test_login.py (ch18l003)
def test_cangetemail_link_to_log_in(self):
[...]
# 她登录了!
self.wait_to_beloggedin(email=TEST_EMAIL)
# 现在她要退出
self.browser.find_element_by_link_text('Log out').click()
# 她退出了
self.wait_to_beloggedout(email=TEST_EMAIL)
为了确认我们没有破坏现有功能,再次运行登录测试:
- $ python manage.py test functional_tests.test_login
- [...]
- OK
现在可以为“My Lists”页面编写一个占位测试,检查事先创建认证会话的做法是否可行:
functional_tests/test_my_lists.py (ch18l004)
def testloggedin_users_lists_are_saved_as_my_lists(self):
email = 'edith@example.com'
self.browser.get(self.live_server_url)
self.wait_to_beloggedout(email)
# 伊迪丝是已登录用户
self.create_pre_authenticated_session(email)
self.browser.get(self.live_server_url)
self.wait_to_beloggedin(email)
测试的结果为:
- $ python manage.py test functional_tests.test_my_lists
- [...]
- OK
现在是提交的好时机:
- $ git add functional_tests
- $ git commit -m "test_my_lists: precreate sessions, move login checks into base"
JSON 格式的测试固件有危害
使用测试数据预先填充数据库的过程,例如存储 User 对象及其相关的
Session
对象,叫作设置“测试固件”(test fixture)。Django 原生支持把数据库中的数据保存为JSON 格式(使用
manage.py dumpdata
命令)。如果在TestCase
中使用类属性fixtures
,运行测试时 Django 会自动加载 JSON 格式的数据。越来越多的人建议不要使用 JSON 格式的固件。如果修改了模型,这种固件维护起来简直就像噩梦一般。此外,对阅读代码的人来说,JSON 固件中众多的属性值让人分不清哪些是对所测试的行为至关重要的,而哪些只是用于补白的。最后,即便从一开始就共用固件,迟早会有测试需要稍微不同的数据,如此一来,为了区分开,只能到处复制整个固件,从而导致无法区分哪些与测试有关,哪些只是恰巧在那儿。
通常,直接使用 Django ORM 加载数据要简单得多。如果模型中的字段太多,或者模型之间有关联,即使使用 ORM 也有点烦琐。此时,可以使用一个备受推崇的工具,名为
factory_boy
。
20.2 显式等待辅助方法最终版:wait
装饰器
我们的代码多次用到装饰器。下面我们要自己定义一个,学习装饰器的工作原理。
首先,我们要想好装饰器的作用。我们的计划是,让这个装饰器替代 wait_for_row_in_list_table
中的等待 - 重试 - 超时逻辑,以及 wait_to_beloggedin/out
中对 self.wait_for
的调用,就像这样:
functional_tests/base.py (ch18l005)
@wait
def wait_for_row_in_list_table(self, row_text):
table = self.browser.find_element_by_id('id_list_table')
rows = table.find_elements_by_tag_name('tr')
self.assertIn(row_text, [row.text for row in rows])
@wait
def wait_to_beloggedin(self, email):
self.browser.find_element_by_link_text('Log out')
navbar = self.browser.find_element_by_css_selector('.navbar')
self.assertIn(email, navbar.text)
@wait
def wait_to_beloggedout(self, email):
self.browser.find_element_by_name('email')
navbar = self.browser.find_element_by_css_selector('.navbar')
self.assertNotIn(email, navbar.text)
做好准备了吗?装饰器难以理解(我自己花了好长时间才理解,而且每次自己定义时都要仔细回想),但好消息是我们在 self.wait_for
辅助函数中已经接触过函数式编程了。在函数式编程中,我们把一个函数作为参数传给另一个参数,装饰器也是这个道理。装饰器的不同之处在于,它并不执行代码,而是返回指定函数修改后的版本。
我们这个装饰器要返回一个新函数,它不断调用指定的函数,并捕获常规的异常,直到超时为止。下面是首次尝试:
functional_tests/base.py (ch18l006)
def wait(fn): ➊
def modified_fn(): ➌
start_time = time.time()
while True: ➍
try:
return fn()
except (AssertionError, WebDriverException) as e: ➍
if time.time() - start_time > MAX_WAIT:
raise e
time.sleep(0.5)
return modified_fn ➋
❶ 装饰器的作用是修改函数,其参数是一个函数……
❷ ……而返回的则是修改后(或“装饰后”)的版本。
❸ 创建函数的修改版。
❹ 这是我们熟悉的循环,它一直运行着,捕获常规的异常,直到超时为止。
❺ 与之前一样,如果没有异常,立即调用传入的函数,然后返回。
这么定义基本上是可以的,但是还不完全正确,不信运行测试看看:
- $ python manage.py test functional_tests.test_my_lists
- [...]
- self.wait_to_beloggedout(email)
- TypeError: modified_fn() takes 0 positional arguments but 2 were given
与 self.wait_for
不同,这个装饰器依附的函数是有参数的:
functional_tests/base.py
@wait
def wait_to_beloggedin(self, email):
self.browser.find_element_by_link_text('Log out')
wait_to_beloggedin
有两个位置参数,分别是 self
和 email
。但是,经装饰替换为 modified_fn
之后,参数没有了。我们应该怎么做才能把被装饰的 fn
的参数传给 modified_fn
呢?
答案涉及一点 Python 魔法,*args
和 **kwargs
,即人们熟知的“变长参数”(我也是刚知道)。
functional_tests/base.py (ch18l007)
def wait(fn):
def modified_fn(args, *kwargs): ➊
start_time = time.time()
while True:
try:
return fn(args, *kwargs) ➋
except (AssertionError, WebDriverException) as e:
if time.time() - start_time > MAX_WAIT:
raise e
time.sleep(0.5)
return modified_fn
❶ 把 modified_fn
的参数设为 *args
和 **kwargs
,以此指定它可以接受任意个位置参数和关键字参数。
❷ 在函数的定义中指定之后,还要把它们传给我们真正要调用的 fn
。
通过这种方式还可以让装饰器修改函数的参数,但是这里不展开讨论了。现在的重点是,装饰器可用了:
- $ python manage.py test functional_tests.test_my_lists
- [...]
- OK
你知道真正让人高兴的是什么吗? self.wait_for
辅助函数也可以使用 wait
装饰器了,如下所示:
functional_tests/base.py (ch18l008)
@wait
def wait_for(self, fn):
return fn()
妙啊!现在等待 - 重试逻辑封装到一个地方了,而且还可以灵活设定等待,既可以在功能测试内部调用 self.wait_for
,也可以在任何辅助函数上使用 @wait
装饰器。
下一章将把代码部署到过渡服务器上,并在服务器上使用预先验证身份的会话固件。你将看到,这个过程能帮我们找出一两个 bug。
本章所学
装饰器很棒
通过装饰器,可以抽象不同层次的关注点。本章定义的装饰器可以让测试中的断言不同时等待。
谨慎去除功能测试中的重复
没必要让每个功能测试都测试应用的全部功能。在这一章遇到的情况中,我们想避免在每个需要已验证身份的用户的功能测试中走一遍整个登录流程,所以使用测试固件“作弊”,跳过登录过程。在功能测试中,还可能需要跳过其他过程。不过,我要提醒一下,功能测试的目的是捕获应用不同部分之间交互时的异常表现,所以去除重复时一定要谨慎,别过火了。
测试固件
测试固件指运行测试之前要提前准备好的测试数据,通常是指使用一些数据填充数据库。不过如前所示(创建浏览器的 cookie),也会涉及其他准备工作。
避免使用 JSON 固件
Django 提供的
dumpdata
和loaddata
等命令简化了把数据库中的数据导出为 JSON 格式的操作,也可以轻易使用 JSON 格式的数据还原数据库。多数人都不建议使用这种测试固件,因为数据库模式发生变化后这种固件很难维护。请使用 Django ORM,或者factory_boy
这类工具。
第 21 章 服务器端调试技术
做了这么多事之后,我们要停下来想一想:我们定义了几个用于等待的辅助函数,它们有什么用呢?哦,是等待用户登录的。那用户是如何登录的呢?啊,是通过预先构建已验证身份的用户实现的。
21.1 实践是检验真理的唯一标准:在过渡服务器中捕获最后的问题
这个功能测试在本地运行一切顺利,但在过渡服务器中情况如何呢?部署网站试一下。在这个过程中会遇到一个意料之外的问题(体现了过渡服务器的作用)。为了解决这个问题,要找到在测试服务器中管理数据库的方法:
- $ cd deploy_tools
- $ fab deploy --host=elspeth@superlists-staging.ottg.eu
- [...]
然后重启 Gunicorn:
- elspeth@server:$ sudo systemctl daemon-reload
- elspeth@server:$ sudo systemctl restart gunicorn-superlists-staging.ottg.eu
运行功能测试得到的结果如下:
- $ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests
- ======================================================================
- ERROR: test_cangetemail_link_to_log_in
- (functional_tests.test_login.LoginTest)
- ---------------------------------------------------------------------
- Traceback (most recent call last):
- File "...functional_tests/test_login.py", line 22, in
- test_cangetemail_link_to_log_in
- self.assertIn('Check your email', body.text)
- AssertionError: 'Check your email' not found in 'Server Error (500)'
- ======================================================================
- ERROR: testloggedin_users_lists_are_saved_as_my_lists
- (functional_tests.test_my_lists.MyListsTest)
- ---------------------------------------------------------------------
- Traceback (most recent call last):
- File "homeharry...bookexample/functional_tests/test_my_lists.py",
- line 34, in testloggedin_users_lists_are_saved_as_my_lists
- self.wait_to_beloggedin(email)
- File "worskpacefunctional_tests/base.py", line 42, in wait_to_beloggedin
- self.browser.find_element_by_link_text('Log out')
- [...]
- selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
- element: {"method":"link text","selector":"Log out"}
- Stacktrace:
- [...]
- ---------------------------------------------------------------------
- Ran 8 tests in 27.933s
- FAILED (errors=2)
无法登录,不管使用真实的电子邮件系统还是使用已经通过验证的会话都不行。看样子我们全新打造的身份验证系统把服务器搞崩溃了。
下面使用服务器端调试技术找出问题所在。
设置日志
为了记录这个问题,配置 Gunicorn,让它记录日志。在服务器中使用 vi
或 nano
按照下面的方式调整 Gunicorn 的配置:
server: etcsystemd/system/gunicorn-superlists-staging.ottg.eu.service
ExecStart=homeelspeth/sites/superlists-staging.ottg.eu/virtualenvbingunicorn \
--bind unix:tmpsuperlists-staging.ottg.eu.socket \
--capture-output \
--access-logfile ../access.log \
--error-logfile ../error.log \
superlists.wsgi:application
这样配置之后,Gunicorn 会在~/sites/$SITENAME 文件夹中保存访问日志和错误日志。
还要确保 settings.py 中仍有 LOGGING
设置,这样调试信息才能输送到终端。
superlists/settings.py
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
},
},
'loggers': {
'django': {
'handlers': ['console'],
},
},
'root': {'level': 'INFO'},
}
再次重启 Gunicorn,然后运行功能测试,或者手动登录试试。在这些操作执行的过程中,可以使用下面的命令监视日志:
- elspeth@server:$ sudo systemctl daemon-reload
- elspeth@server:$ sudo systemctl restart gunicorn-superlists-staging.ottg.eu
- elspeth@server:$ tail -f error.log # 假设位于 ~/sites/$SITENAME文件夹中
你应该会看到类似下面的错误:
- Internal Server Error: accountssend_login_email
- Traceback (most recent call last):
- File "homeelspeth/sites/superlists-staging.ottg.eu/virtualenv/lib/python3.6/[...]
- response = wrapped_callback(request, callback_args, *callback_kwargs)
- File
- "homeelspeth/sites/superlists-staging.ottg.eu/sourceaccountsviews.py", line
- 20, in send_login_email
- [email]
- [...]
- self.connection.sendmail(from_email, recipients, message.as_bytes(linesep=\r\n))
- File "usrlib/python3.6/smtplib.py", line 862, in sendmail
- raise SMTPSenderRefused(code, resp, from_addr)
- smtplib.SMTPSenderRefused: (530, b'5.5.1 Authentication Required. Learn more
- at\n5.5.1 https://support.google.com/mail/?p=WantAuthError [...]
- - gsmtp', noreply@superlists)
嗯,Gmail 拒绝发送电子邮件,是吗?可那是为什么呢?哦,原来是因为我们没有告诉服务器密码是什么。
21.2 在服务器上通过环境变量设定机密信息
第 11 章讲过在服务器上设定机密信息的一种方式,那时我们在服务器的文件系统中创建了一个一次性的 Python 文件,然后导入,设定 Django 的 SECRET_KEY
设置。
这几章则在 shell 中使用环境变量存储电子邮件的密码。我们也将在过渡服务器上使用这种方式。可以在 Systemd 的配置文件中设定环境变量:
server: etcsystemd/system/gunicorn-superlists-staging.ottg.eu.service
[Service]
User=elspeth
Environment=EMAIL_PASSWORD=yoursekritpasswordhere
WorkingDirectory=homeelspeth/sites/superlists-staging.ottg.eu/source
[...]
在安全方面,使用配置文件有个好处:可以限制权限,只让 root 用户可读。而 Python 源文件做不到这一点。
保存这个文件,然后执行 daemon-reload
和 restart gunicorn
命令。再次运行功能测试,结果如下:
- $ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests
- [...]
- Traceback (most recent call last):
- File "...superlists/functional_tests/test_login.py", line 25, in
- test_cangetemail_link_to_log_in
- email = mail.outbox[0]
- IndexError: list index out of range
- [...]
- selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
- element: {"method":"link text","selector":"Log out"}
my_lists
失败没变,但是登录测试提供了更多信息:功能测试向前进了,网站看起来能发送电子邮件了(服务器日志没有显示错误),但是在 mail.outbox
中找不到邮件……