第 16 章 服务器端认证,在 Python 中使用模拟技术

16.1 探究登录视图

我们已经写好了可以使用的客户端代码,尝试把认证判定数据发给服务器中的登录视图。下面开始编写这个视图,然后再创建后台认证函数。

探究时编写的登录视图如下所示:

def persona_login(request):
    print("login view", file=sys.stderr)
    # user = PersonaAuthenticationBackend().authenticate(request.POST["assertion"])
    user = authenticate(assertion=request.POST["assertion"]) # authenticate 是我们自定义的认证函数,这个函数的作用是验证客户端发送的判定数据
    if user is not None:
        login(request, user) # login 是 Django 原生的登录函数。它把一个会话对象存储到服务器中,并且和用户的 cookie 关联起来,这样在以后的请求中我们就知道这个用户已经通过认证
    return redirect("/")

authenticate 函数要通过互联网访问 Mozilla 的服务器。在单元测试中我们需要模拟 authenticate 函数的功能。

16.2 在 Python 代码中使用模拟技术

流行的 mock 已经集成到 Python 3.3 中。(在 Python2 中,可以执行命令 pip3 install mock 安装,然后把后文中出现的 from unittest.mock 换成 from mock )这个包提供了一个神奇的对象 Mock,有点像 Sinon 驭件对象,不过功能更强大:

>>> from unittest.mock import Mock
>>> m = Mock()
>>> m.any_attribute
<Mock name='mock.any_attribute' id='4384429224'>
>>> m.foo
<Mock name='mock.foo' id='4396036952'>
>>> m.any_method()
<Mock name='mock.any_method()' id='4396102096'>
>>> m.foo()
<Mock name='mock.foo()' id='4396102208'>
>>> m.called
False
>>> m.foo.called
True
>>> m.bar.return_value = 1
>>> m.bar()
1

使用驭件对象模拟 authenticate 函数的功能应该很灵巧。下面介绍如何模拟。

16.2.1 通过模拟 authenticate 函数测试视图

测试的结果:``python3 manage.py test accounts

表明我们试图模拟的函数还不存在,需要把 authenticate 函数导入 views.py(虽然我们要自己定义 authenticate 函数,不过还是要从 django.contrib.auth 中导入。只要我们在 settings.py 中配置好,Django 就会自动换用我们自己定义的函数。这么做有个好处,如果以后要使用第三方库代替 authenticate 函数,无需修改 views.py):

现在测试的结果表明,我们需要把登录视图和一个 URL 联系起来。

为了通过测试,持续进行编写:

到目前为止一切顺利。我们模拟并测试了一个 Python 函数。

16.2.2 确认视图确实登录了用户

但是,如果 authenticate 函数返回一个用户,authenticate 视图也要通过调用 Django 中的 auth.login 函数,让用户真正登录网站。所以 authenticate 函数不能返回空响应——既然这个视图处理的是 Ajax 请求,那么就无需返回 HTML,返回一个简单的“OK”字符串就行:

这个测试检查的是想得到的响应。下面我们要测试用户确实正确登录了。我们使用的方法是检查 Django 测试客户端,看它是否正确设定了会话 cookie。

接下来解释什么是会话、cookie,以及在 Django 中怎么认证用户。

HTTP 是无状态的,因此服务器需要一种在每次请求中识别不同的客户端的方法。IP 地址可以共用,所以一般使用的方法是为每个客户端指定一个唯一的会话 ID。会话 ID 存储在 cookie 中,每次请求都会提交给服务器。服务器在某处存储会话 ID(默认情况下存入数据库),这样它就知道各个请求来自哪个特定的客户端。

使用开发服务器登录网站时,如果需要,其实可以手动查看自己的会话 ID。默认情况下,会话 ID 存储在 sessionid 键下。

不管用户是否登录,只要访问使用 Django 开发的网站,就会为访问者设定会话 cookie。

如果网站以后需要识别已经登录且通过认证的客户端,不用要求客户端在每次请求中都发送用户名和密码,服务器可以把客户端的会话标记为已通过验证,并且在数据库中把会话 ID 和用户 ID 关联起来。

会话的数据结构有点儿像字典,用户 ID 存储在哪个键下由 django.contrib.auth.SESSION_KEY 决定。如果需要,可以在 manage.py 的终端控制台查看会话:

在用户的会话中还可以存储其他任何需要的信息,作为临时记录某种状态的方式。对未登录的用户也可以这么做。在任意一个视图中使用 request.session 即可,用法和字典一样。可以参考 Django 文档中对会话的说明arrow-up-right

得到的是两个失败的测试。处理用户登录以及标记会话的 Django 函数是 django.contrib.auth.login 。所以我们还要历经几次 TDD 循环,最终才能编写出视图函数:

测试结果为:OK。至此,我们得到了一个可以使用的登录视图。

使用驭件测试登录

测试是否正确调用 Django 中 login 函数的另一种方法也是模拟 login 函数:

这种测试方式的优点是,不依赖 Django 测试客户端,也不用知道 Django 会话的工作方式,只需要知道你要调用的函数名即可。

缺点是几乎都在测试实现方式,但没测试行为,而且和 Django 中实现登录功能的函数名和 API 结合地太过紧密。

16.3 模拟网络请求,去除自定义认证后台中的探究代码

接下来我们要自定义认证后台。探究时编写的代码如下所示:

这段代码的意思是:

  • 使用 requests.post 把判定数据发送给 Mozilla

  • 然后检查响应码(resp.ok),再检查响应的 JSON 数据中 status 字段的值是否为 okay

  • 最后,从响应中提取电子邮件地址,通过这个地址找到现有的用户,如果找不到就创建一个新用户

16.3.1 一个 if 语句需要一个测试

如何为这种函数编写测试有个经验法则:一个 if 语句需要一个测试,一个 try/except 语句需要一个测试。所以一共需要四个测试。先编写第一个:

authentication.py 中,我们先编写好一些占位代码:

此时,我们需要把 requests 库添加到 requirements.txt 中,否则下一次部署会失败。

然后执行一下测试,观察测试结果,最终写出的代码如下:

测试全部通过了。

接下来,检查 authenticate 函数在发现请求的响应中有错误时是否返回 None:

这个测试直接就能通过,因为不管什么情况,现在返回的都是 None。

16.3.2 在类上使用 patch 修饰器

接下来要检查响应的 JSON 数据中 status 字段是否为 okay。编写这个测试会涉及到一些重复代码:

一切都很顺利,测试仍能通过。

现在我们该测试能通过认证的情况了,看 authenticate 函数是否返回一个用户对象。我们期望下面这个测试失败:

下面开始编写代码,先用一个”作弊“的实现方式,直接获取在数据库中找到的第一个用户:

这段代码让所有测试都通过了,这是因为如果数据库中没有用户,objects.first() 会返回 None。我们要保证运行每个测试时数据库中都至少有一个用户,让其他情况更可行一些:

下面,我们开始编写在响应出错或状态不是 okay 的情况下防范认证失败的代码:

这么写居然能修正两个测试,下节再分析,现在先取回正确的用户,让最后一个测试也通过:

16.3.3 进行布尔值比较时要留意驭件

那么为什么 test_returns_none_if_response_errors 没有失败?因为我们模拟了 requests.post,response 是驭件对象。或许你还记得,它返回的所有属性和也都是驭件。(其实,只有 patch 修饰符时才会发生这种情况,response 其实是 MagicMock 对象,比 mock 模拟的层级还深,有点儿像字典。详情arrow-up-right)所以,在下面这行代码中:

response 其实是一个驭件,response.json() 也是驭件,response.json()["status"] 还是驭件。最终,我们是拿一个驭件和字符串 "okay" 进行比较,结果自然是 False,因此 authenticate 函数的返回值是 None。我们要把测试的表述改得更明确一些,把响应的 JSON 数据声明为一个空字典:

此时,测试的结果为:

这个问题可以使用下面的方式修正:

现在的测试结果为 OK。

16.3.4 需要时创建用户

如果传入 authenticate 函数的判定数据经 Persona 确认有效,而且数据库中没有这个人的用户记录,应用应该创建一个新用户。相应的测试如下:

当应用的代码尝试使用电子邮件地址查找一个现有用户时,这个测试会失败。

所以我们添加一个 try/except 语句,暂时返回一个没设定任何属性的用户:

测试仍然失败,但这一次发生在测试尝试使用电子邮件查找新用户时,所以修正的方法是给 email 属性指定正确的电子邮件地址:

修改之后,测试能通过了。

16.3.5 get_user 方法

接下来要为认证后台定义 get_user 方法。这个方法的作用是使用用户的电子邮件地址取回用户记录,如果找不到用户记录就返回 None。

针对这两个要求的几个测试如下所示:

一步一步编写代码,最终的代码如下所示:

现在第二个测试通过了。而且我们得到了一个可以使用的认证后台。下面我们可以编写自定义的用户模型了。

16.4一个最简单的自定义用户模型

Django 原生的用户模型对记录什么用户信息做了各种设想,明确要记录的包括名和姓,而且强制使用用户名。我坚信,除非真的需要,否则不要存储用户的任何信息。所以,一个只记录电子邮件地址的用户模型就足够了。

测试的结果是一个预期失败,原来的模型要求有用户名、密码等,我们把模型写成这样:

然后在 settings.py 中使用 AUTH_USER_MODEL 变量设定使用这个模型。同时,把前面写好的认证后台添加到配置中:

现在,Django 告诉我们有些错误(注意,这是在执行 makemigrations 时才提示这些错误),因为自定义的用户模型需要一些元信息。

于是再添加:

还有问题:

于是修改:

最后做一次迁移。

16.4.1 稍微有点儿失望

现在,有几个测试很奇怪,出乎意料地失败了。好像 Django 坚持要求用户模型中有 last_login 字段。

再次做一次迁移,把之前的迁移文件删掉,重新创建:

现在测试都能通过了。

16.4.2 把测试当做文档

接下来我们要把 email 字段设为主键,因此必须要把自动生成的 id 字段删除。虽然警告可能表明我们要做些修改,不过最好先为这次改动编写一个测试:

如果以后回过头来再看代码,这个测试能唤起我们的记忆,知道曾经做过这次修改。

测试可以作为一种文档形式,因为测试体现了你对某个类或函数的需求。如果你忘记了为什么要使用某种方法编写代码,可以回过头来看测试,有事就能找到答案。

实现的方式如下(可以先使用 unique=True 看看结果如何):

这么写可以让测试通过了,于是再清理一下迁移,确保所有设定都能应用到数据库中。

16.4.3 用户已经通过认证

用户模型还需要一个属性才算完整:标准的 Django 用户模型提供了一个 API,其中包含很多方法arrow-up-right,大多数我们都不需要,但有一个能用到:.is_authenticated()

测试结果说 User 没有该属性,解决办法是:

于是测试都可以通过了。

16.5 关键时刻:功能测试能通过么

现在可以看一下功能测试的结果了。下面来修改基模板。首先,已登录用户和未登录用户看到的导航条应该不同:

然后,把几个上下文变量传入 initialize 方法:

试一下功能测试,可以发现通过了。

接下来可以做次提交了,你可以做一系列单独的提交——登录视图、认证后台、用户模型、修改模型。或者,考虑到这些代码都有关联,不能独自运行,也可以做一次大提交:

16.6 完善功能测试,测试退出功能

我们要扩展功能测试,确认网站能持久保持已登录状态,也就是说这并不是我们在客户端 JavaScript 代码中设定的状态,服务器也知道这个状态,而且刷新页面后会保持已登录状态。同时,我们还要测试用户能退出。

先编写测试代码:

我们重复编写了十分类似的代码,所以可以定义几个辅助函数:

然后,把功能扩展成这样:

另外,改进 wait_for_element_with_id 函数中的失败消息有助于看清发生了什么:

这样修改之后,可以看到,测试失败的原因是退出按钮没起作用。

实现退出功能的方法其实很简单,我们可以使用 Django 原生的退出视图arrow-up-right。这个视图会清空用户的会话,然后重定向到我们指定的页面:

然后在 base.html 中,把退出按钮写成一个普通的 URL 链接:

现在,功能测试都能通过了。其实,整个测试组件都可以通过。

在 Python 中使用模拟技术

  • Mock 库

    • Michael Foord 开发了很优秀的 Mock 库,现在这个库已经集成到 Python 3 的标准库中。这个库包含了在 Python 中使用模拟技术所需的几乎全部功能。

  • patch 修饰器

    • unittest.mock 模块提供了一个函数叫做 patch,可用来模拟要测试的模块中任何一个对象。patch 一般用来修饰测试方法,不过也可以修饰测试类,然后应用到类中的所有测试方法上。

  • 驭件是真值,可能会掩盖错误

    • 要知道,模拟的对象在 if 语句中的表现可能有违常规。驭件是真值,而且还能掩盖错误,因为驭件有所有的属性和方法。

  • 驭件太多会让代码变味

    • 在测试中过多地使用驭件会导致测试和实现联系十分紧密。有时无法避免出现这种情况。但一般而言,你可以找到一种组织代码的方式,可以避免使用太多驭件。

Last updated