Python-测试驱动开发第三版-早期发布--全-

Python 测试驱动开发第三版(早期发布)(全)

原文:annas-archive.org/md5/6b450985de9d11d4b2792fe572183821

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

这本书是我尝试与世界分享我从“黑客”到“软件工程师”的旅程。主要讲的是测试,但如你即将看到的,内容还有很多。

我想感谢你阅读它。

如果你购买了一本书,那么我非常感激。如果你正在阅读免费的在线版本,那么我仍然感激你决定把你的一些时间花在上面。谁知道,也许一旦你读完,你会觉得这本书足够好,值得为自己或朋友购买一本真实的复印件。

如果您有任何意见、问题或建议,我很乐意听取。您可以通过 obeythetestinggoat@gmail.com 直接联系我,或者在 Twitter 上找到我@hjwp。您还可以查看网站和我的博客,以及邮件列表

希望您阅读这本书能像我写作时的愉快一样愉快。

为什么我写了一本关于测试驱动开发的书

`‘你是谁,你为什么写这本书,我为什么要读它?’我听到你问。

在我的职业早期,我很幸运地加入了一群 TDD 狂热者,这对我的编程产生了巨大影响,我迫不及待地想与所有人分享。可以说我像一个新皈依者一样充满热情,学习经验对我来说仍然是个新鲜记忆,这就是为什么在 2014 年推出第一版的原因。

当我第一次学习 Python(来自 Mark Pilgrim 优秀的《Dive Into Python》)时,我接触到了 TDD 的概念,心想“是的,我完全能理解其中的道理。”或许当你第一次听说 TDD 时也有类似的反应?听起来是一个非常明智的方法,一种很好的习惯——就像定期用牙线清洁牙齿一样。

然后来了我的第一个大项目,你可以猜到发生了什么——有客户,有截止日期,有很多事情要做,对于 TDD 的任何良好意图都荡然无存。

实际上,一切都挺好。我也挺好。

起初。

起初,我知道我并不真的需要 TDD,因为那只是一个小网站,我可以通过手动检查来轻松测试是否有效。点击这个链接这里,选择那个下拉项那里,然后这个应该发生。简单。整个写测试的事情听起来好像要花很久的时间,而且,从我短短三周的成人编程经验中,我自认为是一个相当不错的程序员。我能搞定。很简单。

然后来了可怕的复杂女神。她很快向我展示了我的经验局限性。

项目逐渐扩展。系统的部分开始依赖于其他部分。我尽力遵循像 DRY(不要重复自己)这样的良好原则,但这只是引入了一些非常危险的领域。很快,我开始涉足多重继承。八层深的类层次结构。eval语句。

我开始害怕改动我的代码。我不再确定依赖关系,如果我改变了这里的代码,可能会发生什么,哦天啊,我想这部分可能继承自它——不,它不是,它被重写了。哦,但它依赖于那个类变量。好吧,只要我重写重写就应该没问题。我只是要检查一下——但是检查变得越来越困难。现在网站有很多部分,手动点击它们都开始变得不切实际。最好让一切继续如此,不再重构,只是将就着用。

不久后,我的代码变得混乱而丑陋。新的开发变得痛苦起来。

不久之后,我有幸在一个名叫 Resolver Systems(现在称为PythonAnywhere)的公司找到了一份工作,那里极限编程(XP)是常规。他们向我介绍了严格的 TDD。

尽管我的以前经验确实使我意识到自动化测试的可能好处,但我在每个阶段都拖延不前。“我的意思是,总体上测试可能是个好主意,但真的吗?所有这些测试?其中一些看起来完全是浪费时间…… 什么?功能测试单元测试?得了吧,这也太过了!还有这个 TDD 的测试/最小代码更改/测试循环?这太愚蠢了!我们不需要这些步步为营!来吧,我们可以看到正确答案是什么,为什么不直接跳到结尾?”

相信我,我对每条规则都有过犹豫,我提出了每一个捷径,我要求对 TDD 每一个看似毫无意义的方面进行解释,最终我看到了其中的智慧。我不记得多少次想到“谢谢,测试”,因为一个功能测试揭示了一个我们永远不会预料到的回归,或者一个单元测试让我避免了一个非常愚蠢的逻辑错误。从心理上讲,这使得开发过程不再那么紧张。它生成的代码非常令人愉快地使用。

所以,让我告诉你全部关于它!

本书的目标

我的主要目标是传授一种方法论——一种进行 Web 开发的方式,我认为这能使 Web 应用更好,开发者更快乐。如果一本书只涵盖你通过 Google 可以找到的材料,那它就没什么意义,所以这本书不是 Python 语法指南,也不是 Web 开发教程本身。相反,我希望教你如何使用 TDD 更可靠地实现我们共同的神圣目标:能运行的干净代码

话虽如此:我会不断地参考一个实际的实例,通过使用 Django、Selenium、jQuery 和 Mocks 等工具从头开始构建 Web 应用程序。我不假设读者对这些工具有任何先前的了解,所以你应该在本书的最后掌握这些工具的基本知识,以及 TDD 的方法论。

在极限编程中,我们总是成对编程,所以我想象自己写这本书,就像是和我的前任自己一起编程,需要解释工具的工作原理并回答为什么要以这种特定方式编码的问题。所以,如果我有时听起来有点自大,那是因为我并不是那么聪明,我必须对自己非常耐心。如果我听起来有点防守,那是因为我是那种总是与别人持不同意见的烦人人物,所以有时候我必须花费很多精力来说服自己接受任何事情。

大纲

我把这本书分成了三部分。

第 I 部分(章节 1–7):基础知识

直接开始构建一个简单的 Web 应用程序,使用 TDD 进行。我们首先编写一个功能测试(使用 Selenium),然后逐步讲解 Django 的基础——模型、视图、模板——并在每个阶段都进行严格的单元测试。我还介绍了 Testing Goat。

第 II 部分(章节 8–[链接即将到来]):Web 开发基础

涵盖了 Web 开发中一些更棘手但不可避免的方面,并展示了测试如何帮助我们处理这些问题:静态文件、部署到生产环境、表单数据验证、数据库迁移以及可怕的 JavaScript。

[链接即将到来](章节 [链接即将到来]–[链接即将到来]):更高级的测试主题

模拟、集成第三方系统、测试固件、Outside-In TDD 和持续集成(CI)。

继续进行一些日常事务…​

本书中使用的约定

本书使用以下排版约定:

斜体

表示新术语、URL、电子邮件地址、文件名和文件扩展名。

固定宽度

用于程序清单,以及在段落内引用程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字。

**固定宽度加粗**

显示用户应该按照字面意义输入的命令或其他文本。

偶尔我会使用符号:

[...]

表示某些内容已被跳过,以缩短输出的长度,或跳转到相关部分。

提示

此元素表示一个提示或建议。

注意

此元素表示一般提示或旁注。

警告

此元素表示警告或注意事项。

提交勘误

发现错误或错别字?本书的源代码可在 GitHub 上获取,我非常乐意接收问题和拉取请求:https://github.com/hjwp/Book-TDD-Web-Dev-Python/

使用代码示例

代码示例可在https://github.com/hjwp/book-example/找到;每章节有对应的分支(例如,https://github.com/hjwp/book-example/tree/chapter_unit_test_first_view)。完整列表及有关使用此代码库的建议,详见[Link to Come]。

如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至support@oreilly.com

本书旨在帮助您完成工作任务。一般而言,如果本书提供示例代码,您可以在自己的程序和文档中使用它。除非您复制了大量代码片段,否则无需征得我们的许可。例如,编写一个使用本书中多个代码片段的程序无需许可。销售或分发 O’Reilly 书籍中的示例代码则需许可。引用本书回答问题并引用示例代码无需许可。将本书中大量示例代码整合到产品文档中需要许可。

我们感谢但不要求署名。通常的署名包括标题、作者、出版商和 ISBN。例如:“Python 测试驱动开发, 第 3 版,作者 Harry J.W. Percival(O’Reilly)。版权 2024 Harry Percival,978-1-098-14871-3。”

如果您认为您使用的示例代码超出了合理使用范围或上述许可,请随时通过permissions@oreilly.com与我们联系。

O’Reilly 在线学习

注意

40 多年来,O’Reilly Media为企业提供技术和商业培训、知识和见解,帮助其取得成功。

我们独特的专家和创新者网络通过书籍、文章和在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台为您提供按需访问实时培训课程、深度学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。更多信息,请访问https://oreilly.com

如何联系我们

请将有关本书的评论和问题发送至出版商:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • 加利福尼亚州塞巴斯托波尔 95472

  • 800-889-8969(美国或加拿大)

  • 707-829-7019(国际或本地)

  • 707-829-0104(传真)

  • support@oreilly.com

  • https://www.oreilly.com/about/contact.xhtml

我们为本书设有网页,列出勘误、示例及其他相关信息。请访问https://learning.oreilly.com/library/view/~/9781098148706

获取有关我们的书籍和课程的新闻和信息,请访问https://oreilly.com

在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media

关注我们的 Twitter:https://twitter.com/oreillymedia

在 YouTube 上观看我们:https://youtube.com/oreillymedia

免费版许可证

如果你正在阅读托管在http://www.obeythetestinggoat.com的免费版这本书,那么许可证是知识共享 署名-非商业性使用-禁止演绎 ¹。我要感谢 O’Reilly 对于许可证的积极态度,大多数出版商都没有这样的前瞻性。

我把这看作是一个“试读再购买”的计划。如果你正在阅读这本书是出于职业原因,我希望如果你喜欢它,你会购买一本——如果不是为了自己,那就为了朋友!O’Reilly 做得很棒,值得你的支持。你可以在主页上找到购买链接

¹(没有衍生条款是因为 O’Reilly 希望对衍生作品保持一定的控制,但他们经常允许这样的权限,所以如果你想基于这本书构建某些东西,不要犹豫,与他们联系。)

先决条件和假设

这是我对你的假设以及你已经知道的内容的概述,以及你需要在你的电脑上准备并安装哪些软件。

Python 3 和编程

我尽量以初学者为考虑对象来写这本书,但是如果你是新手程序员,我假设你已经学会了 Python 的基础知识。所以如果你还没有,先跑一遍 Python 初学者教程或者获取一本介绍性的书籍,比如The Quick Python BookThink Python,又或者,只是为了好玩,Invent Your Own Computer Games with Python,它们都是很好的入门材料。

如果你是一位有经验的程序员,但是对 Python 还很陌生,你应该能够顺利进行。Python 简单易懂。

你应该能够在 Mac、Windows 或 Linux 上跟着这本书。每个操作系统的详细安装说明如下。

提示

这本书是在 Python 3.11 上测试的。如果你使用的是早期版本,你会发现我的命令输出列表中的东西看起来有轻微的差异(例如,追踪不会有 ^^^^^^ 标记错误位置),所以最好是升级,如果可能的话。

如果你考虑使用 PythonAnywhere 而不是本地安装的 Python,则在开始之前你应该去快速看一下 [Link to Come]。

无论如何,我希望你能够访问 Python,并知道如何从命令行启动它,以及如何编辑一个 Python 文件并运行它。如果你有任何疑问,再次查看我之前推荐的三本书籍。

HTML 的工作原理

我还假设你对网络的工作原理有基本的了解 —— HTML 是什么,什么是 POST 请求等等。如果你对这些不确定,你需要找一些基本的 HTML 教程;在http://www.webplatform.org/上有几个。如果你能够弄清楚如何在你的电脑上创建一个 HTML 页面并在浏览器中查看它,并理解表单是什么以及它可能是如何工作的,那么你可能已经没问题了。

Django

本书使用 Django 框架,这可能是 Python 世界中最成熟的 Web 框架。我写这本书的时候假设读者对 Django 没有任何先前的了解,但是如果你是 Python 和 Web 开发的新手,并且对测试也不熟悉,你可能会偶尔发现有一些主题和概念太多了,难以掌握。如果是这样的话,我建议你暂时离开这本书,去看一看 Django 的教程。DjangoGirls 是我知道的最好的、最适合初学者的教程。官方教程也非常适合有经验的程序员。

JavaScript

本书的后半部分有一点 JavaScript。如果您不了解 JavaScript,请直到那时不要担心,如果您发现自己有些困惑,我会在那时推荐一些指南。

继续阅读安装说明。

必要的软件安装

除了 Python,您还需要:

Firefox 网页浏览器

Selenium 实际上可以驱动任何主流浏览器,但我选择了 Firefox,因为它受企业利益的控制最少。

Git 版本控制系统

这适用于任何平台,地址为http://git-scm.com/。在 Windows 上,它附带了 Bash 命令行,这是本书所需的。请参阅 “Windows 注”。

一个包含 Python 3.11、Django 4.2 和 Selenium 4 的虚拟环境

Python 的 virtualenv 和 pip 工具现在与 Python 捆绑在一起(它们以前并不总是如此,所以这是一个大好消息)。接下来是准备虚拟环境的详细说明。

安装 Firefox

Firefox 可在 Windows 和 MacOS 上下载安装,地址为https://www.mozilla.org/firefox/。在 Linux 上,你可能已经安装了它,但如果没有的话,你可以通过包管理器安装。

确保您拥有最新版本,以便“geckodriver”浏览器自动化模块可用。

设置您的虚拟环境

Python 虚拟环境(简称虚拟环境)是您为不同 Python 项目设置环境的方式。它允许您在每个项目中使用不同的软件包(例如,不同版本的 Django,甚至不同版本的 Python)。由于您不是系统范围内安装软件,因此意味着您不需要 root 权限。

让我们创建一个虚拟环境。我假设您在一个名为 goat-book 的文件夹中工作,但您可以根据喜好命名您的工作文件夹。但请确保虚拟环境的名称为 “.venv”。

$ cd goat-book
$ py -3.11 -m venv .venv

在 Windows 上,py 可执行文件是不同 Python 版本的快捷方式。在 Mac 或 Linux 上,我们使用 python3.11

$ cd goat-book
$ python3.11 -m venv .venv

激活和停用虚拟环境

每当您阅读本书时,都应确保您的虚拟环境已“激活”。您可以根据提示符中是否显示 (.venv) 来确定您的虚拟环境是否处于活动状态。但您也可以通过运行 which python 检查当前是否为系统安装的 Python,还是虚拟环境的 Python。

激活虚拟环境的命令是在 Windows 上执行 source .venv/Scripts/activate,在 Mac/Linux 上执行 source .venv/bin/activate。停用的命令只是 deactivate

这样尝试一下:

$ source .venv/Scripts/activate
(.venv)$
(.venv)$ which python
/C/Users/harry/goat-book/.venv/Scripts/python
(.venv)$ deactivate
$
$ which python
/c/Users/harry/AppData/Local/Programs/Python/Python311-32/python
$ source .venv/bin/activate
(.venv)$
(.venv)$ which python
/home/myusername/goat-book/.venv/bin/python
(.venv)$ deactivate
$
$ which python
/usr/bin/python
提示

在编写本书时,请始终确保您的虚拟环境处于活动状态。请注意您的提示符中是否有 (.venv),或者运行 which python 进行检查。

安装 Django 和 Selenium

我们将安装 Django 4.2 和最新的 Selenium²。请确保你的虚拟环境已激活!

(.venv) $ pip install "django<4.3" "selenium"
Collecting django<4.3
  Downloading Django-4.2-py3-none-any.whl (8.0 MB)
     ---------------------------------------- 8.1/8.1 MB 7.6 MB/s eta 0:00:00
Collecting selenium
  Downloading selenium-4.9.0-py3-none-any.whl (6.5 MB)
     ---------------------------------------- 6.5/6.5 MB 6.3 MB/s eta 0:00:00
Installing collected packages: django, selenium
Successfully installed [...] django-4.2 [...] selenium-4.9.0 [...]

检查是否正常工作:

(.venv) $ python -c "from selenium import webdriver; webdriver.Firefox()"

这应该会弹出一个 Firefox 网页浏览器,你需要关闭它。

小提示

如果你看到一个错误,你需要在继续之前进行调试。在 Linux/Ubuntu 上,我遇到了 这个 bug,你需要通过设置一个名为 TMPDIR 的环境变量来修复它。

当你 不可避免 地无法激活你的虚拟环境时,你可能会看到一些错误信息。

如果你是虚拟环境的新手——或者坦白说,即使你不是——在某个时候你肯定会忘记激活它,然后你会盯着一个错误信息。这时我经常遇到。这里是一些需要注意的事项:

ModuleNotFoundError: No module named 'selenium'

或者:

ImportError: No module named django.core.management

一如既往,留意命令提示符中的 (.venv),只需快速输入 source .venv/Scripts/activatesource .venv/bin/activate,就可能是你重新运行它所需要的。

这里还有更多的错误信息,作为参考:

bash: .venv/Scripts/activate: No such file or directory

这意味着你当前不在项目的正确目录中。尝试 cd goat-book 或类似的命令。

或者,如果你确定自己在正确的位置,可能遇到了一个旧版 Python 的 bug,导致无法安装与 Git-Bash 兼容的激活脚本。重新安装 Python 3,确保你有 3.6.3 及以上版本,然后删除并重新创建你的虚拟环境。

如果你看到类似的情况,那很可能是同一个问题,你需要升级 Python:

bash: @echo: command not found
bash: .venv/Scripts/activate.bat: line 4:
      syntax error near unexpected token `(
bash: .venv/Scripts/activate.bat: line 4: `if not defined PROMPT ('

最后一个!如果你看到这个:

'source' is not recognized as an internal or external command,
operable program or batch file.

这是因为你启动了默认的 Windows 命令提示符 cmd,而不是 Git-Bash。关闭它并打开后者。

编码愉快!

注意

这些说明对你不起作用吗?或者你有更好的建议?请联系:obeythetestinggoat@gmail.com!

¹ 不过我不会推荐通过 Homebrew 安装 Firefox:brew 会将 Firefox 二进制文件放在一个奇怪的位置,这会让 Selenium 感到困惑。你可以绕过这个问题,但直接以正常方式安装 Firefox 更简单。

² 你可能会想知道为什么我没有提到 Selenium 的特定版本。这是因为 Selenium 不断更新,以跟上网页浏览器的变化,并且由于我们无法将浏览器固定在特定版本,我们最好使用最新的 Selenium。写作时是版本 4.9。

伴随视频

我录制了一系列包含本书的 10 集视频系列。¹ 它涵盖了第 I 部分的内容。如果你发现通过视频材料学习效果更好,我鼓励你查看它。除了书中内容外,它还可以让你感受 TDD 的“流程”,在测试和代码之间切换,并解释我们的思考过程。

此外,我穿着一件可爱的黄色 T 恤。

来自视频的截图

¹ 这段视频尚未更新至第三版,但内容大致相同。

致谢

还有很多人要感谢,没有你们这本书将不会出现,或者会比现在更糟糕。

首先感谢“Greg”在$OTHER_PUBLISHER,你是第一个鼓励我相信这本书真的可以完成的人。尽管你的雇主在版权问题上持有过于保守的观点,我永远感激你对我的信任。

感谢 Michael Foord,另一位 Resolver Systems 的前员工,因为他自己写了一本书,给了我最初的灵感,并对这个项目给予了持续的支持。也感谢我的老板 Giles Thomas,因为他愚蠢地允许另一个员工写书(尽管我相信他现在已经修改了标准的雇佣合同,不允许写书)。也感谢你的智慧和引导我走上测试的道路。

感谢我的其他同事 Glenn Jones 和 Hansel Dunlop,你们是我宝贵的智囊团,感谢你们在过去一年里对我这个“单曲轨迹”的对话的耐心。

感谢我的妻子 Clementine,以及我的两个家庭,没有你们的支持和耐心,我是无法完成这本书的。对于在原本应该是值得纪念的家庭时光里,我把时间都花在电脑前,我感到非常抱歉。当初开始写这本书时,“闲暇时间写写,听起来很合理啊……”我根本不知道这本书会对我的生活有何影响。没有你们,我做不到这一切。

感谢我的技术审阅人员 Jonathan Hartley,Nicholas Tollervey 和 Emily Bache,谢谢你们的鼓励和宝贵的反馈。特别是 Emily,你实际上认真阅读了每一章节。对 Nick 和 Jon 表示部分认可,但依然要表达永恒的感激之情。有了你们在身边,整个过程变得不再孤单。没有你们,这本书将不过是一个白痴的胡言乱语。

感谢所有愿意花时间给予反馈的人,出于他们纯粹的善良:Gary Bernhardt,Mark Lavin,Matt O’Donnell,Michael Foord,Hynek Schlawack,Russell Keith-Magee,Andrew Godwin,Kenneth Reitz 和 Nathan Stocks。感谢你们比我聪明,并阻止我说出一些愚蠢的话。当然,这本书中依然有很多愚蠢的内容,完全不能让你们负责。

感谢我的编辑 Meghan Blanchette,你是一个非常友善和可爱的“驱使者”,感谢你在时间安排和抑制我一些愚蠢想法方面对这本书的帮助。感谢 O’Reilly 的所有其他人员,包括 Sarah Schneider,Kara Ebrahim 和 Dan Fauxsmith,让我继续使用英国英语。感谢 Charles Roumeliotis 在文体和语法方面的帮助。或许我们在芝加哥学派的引用/标点规则的优缺点上看法不同,但我真的很高兴有你在我身边。还要感谢设计部门为我们的封面提供了一只山羊!

特别感谢所有早期发行读者,感谢你们帮助找错字,提供反馈和建议,在书中帮助我们平滑学习曲线的方方面面,尤其是你们那些充满鼓励和支持的亲切话语。谢谢你,杰森·沃斯、戴夫·波森、杰夫·奥尔、凯文·德·巴尔、crainbf、dsisson、Galeran、迈克尔·艾伦、詹姆斯·奥唐纳、马雷克·图尔诺维克、SoonerBourne、朱尔兹、科迪·法尔默、威廉·文森特、特雷·亨纳、大卫·萨瑟、汤姆·帕金、索尔查·鲍勒、乔恩·波勒、查尔斯·夸斯特、悉达塔·内塔尼、史蒂夫·扬、罗杰·卡马戈、韦斯利·汉森、约翰森·克里斯汀·维尔米尔、伊恩·洛林、肖恩·罗伯逊、哈里·贾亚拉姆、拜亚德·兰德尔、康拉德·科日尔、马修·沃勒、朱利安·哈利、巴里·麦克伦登、西蒙·雅科比、安吉洛·科尔登、杰尔基·卡亚拉、马尼什·贾因、马哈德万·斯里尼瓦桑、康拉德·科日尔、德里克·克拉戈、科斯莫·史密斯、马库斯·凯默林、安德烈亚·科斯坦蒂尼、丹尼尔·帕特里克、瑞安·艾伦、杰森·塞尔比、格雷格·沃恩、乔纳森·桑德克维斯特、理查德·贝利、黛安·索因尼、戴尔·斯图尔特、马克·基顿、约翰·瑞利、拉法尔·西马尼斯基、罗尔·范德古特、伊格纳西奥·雷格罗、TJ·托尔顿、乔纳森·米恩斯、泰奥多尔·诺尔特、孟中洙、克雷格·库克、加布里埃尔·伊维拉扎鲁斯、文森佐·潘多尔福、大卫“farbish2”、尼科·科茨、丹尼尔·冈萨雷斯、赵赵亮及其他许多人。如果我错过了你的名字,你绝对有权感到委屈;我对你们感激不尽,所以请写信给我,我将尽力弥补。

最后感谢你,最新的读者,决定查看这本书!希望你喜欢它。

第二版额外感谢

感谢第二版的出色编辑南·巴伯和苏珊·康纳特,克里斯汀·布朗以及整个 O'Reilly 团队。再次感谢艾米丽和乔纳森进行技术审查,以及爱德华·王为其非常详尽的笔记。任何剩余的错误和不足都是我自己的责任。

还要感谢免费版的读者们,他们提供了评论、建议,甚至一些拉取请求。我肯定错过了这份名单上的一些人,所以如果你的名字不在这里,请原谅,但感谢埃姆雷·戈努拉特斯、耶稣·戈麦斯、乔丹·伯克、詹姆斯·埃文斯、伊恩·休斯顿、杰森·德维特、朗尼·雷尼、斯宾塞·奥格登、苏雷什·尼姆巴尔卡尔、达里乌斯、卡科、勒博德罗、杰夫、邓肯·贝茨、wasabigeek、joegnis、拉尔斯、穆斯塔法、贾里德、克雷格、索尔查、TJ、伊格纳西奥、罗埃尔、尤斯蒂娜、内森、安德烈亚、亚历山大、bilyanhadzhi、mosegontar、sfarzy、henziger、hunterji、das-g、juanriaza、GeoWill、Windsooon、gonulate、玛琪·罗斯韦尔、本·艾略特、拉姆齐·迈卡、彼得·J、1hx、Wi、邓肯·贝茨、马修·森科、内里克“卡苏”卡兹、多米尼克·斯科托及其他许多人。

第三版额外感谢

在撰写本文时,我们仅仅刚刚开始,但提前感谢我的编辑 Rita Fernando,以及感谢我的技术审阅人员 Csanad 和 David 无私地提供帮助!

第一部分:TDD 和 Django 的基础

在这第一部分中,我将介绍测试驱动开发(TDD)的基础。我们将从头开始构建一个真实的 Web 应用程序,在每个阶段都先写测试。

我们将介绍使用 Selenium 进行功能测试,以及单元测试,并看到它们之间的区别。我将介绍 TDD 的工作流程,红/绿/重构。

我还会使用版本控制系统(Git)。我们将讨论何时以及如何进行提交,并将其与 TDD 和 Web 开发工作流程集成。

我们将使用 Django,这是 Python 世界中最流行的 Web 框架(可能)。我试图慢慢地、一步一步地介绍 Django 的概念,并提供大量进一步阅读的链接。如果你是 Django 的完全新手,我强烈建议你花时间去阅读它们。如果你感到有点迷茫,花几个小时阅读一下官方 Django 教程,然后再回到本书。

在第一部分中,你还将会遇见测试山羊……​

小心复制和粘贴

如果你在使用电子版的书籍,当你阅读过程中自然会想要从书中复制和粘贴代码清单。但最好不要这样做:手动输入可以帮助你将信息记入肌肉记忆,并感觉更真实。你也难免会偶尔出现拼写错误,学会调试这些错误是很重要的事情。

除此之外,你会发现 PDF 格式的怪癖经常导致尝试复制/粘贴时出现奇怪的问题……​

第一章:使用功能测试设置 Django

TDD 并不是一种自然而然的事情。这是一种纪律,就像一种武术,就像功夫电影中一样,你需要一个脾气暴躁且不合理的师父来强迫你学习这种纪律。我们的师父就是测试山羊。

服从测试山羊!在没有测试之前什么也不要做

在 Python 测试社区中,测试山羊是 TDD 的非官方吉祥物¹。对不同的人来说,它可能有不同的含义,但对我来说,测试山羊是我脑海中的一种声音,它让我始终坚持测试的正确路径——就像卡通片中肩膀上出现的小天使或小恶魔一样,只是它关心的事情比较特别。我希望通过这本书,也能在你的脑海中种下测试山羊的种子。

所以,即使我们还不太确定它将要做什么,我们已决定要构建一个 Web 应用程序。通常,Web 开发的第一步是安装和配置您的 Web 框架。下载这个,安装那个,配置另一个,运行脚本……但是 TDD 需要一种不同的心态。当您在进行 TDD 时,您始终将测试山羊放在脑海中——像山羊一样专注——喊着“先测试,先测试!”

在 TDD 中,第一步总是一样的:编写一个测试

首先我们编写测试;然后我们运行它并检查它是否按预期失败;只有在此之后我们才继续构建我们的应用程序。用山羊般的声音重复这句话。我知道我自己也是这么做的。

山羊的另一特点是它们一步一步地走。这就是为什么它们很少会从事物上掉下来的原因,无论事物有多陡。正如你在图 1-1 中所见。

一只爬在树上的山羊的图片

图 1-1. 山羊比你想象的更灵活(来源:Caitlin Stewart, on Flickr

我们将采取良好的小步骤;我们将使用Django,这是一个流行的 Python Web 框架,来构建我们的应用程序。

我们要做的第一件事是检查我们是否已经安装了 Django,并且它已准备好供我们使用。我们将通过确认我们可以启动 Django 的开发服务器并实际看到它在我们的本地计算机上提供网页,来检查。我们将使用Selenium浏览器自动化工具来完成这项检查。

创建一个名为functional_tests.py的新 Python 文件,无论您希望将代码放在哪里,都可以输入以下代码。如果您在做这些事情时觉得发出几声山羊般的声音有帮助,那可能会有所帮助:

functional_tests.py

from selenium import webdriver

browser = webdriver.Firefox()
browser.get("http://localhost:8000")

assert "Congratulations!" in browser.title
print("OK")

这是我们的第一个功能测试(FT);稍后我会详细讨论我所说的功能测试,以及它们与单元测试的对比。目前,我们只需确保我们理解它在做什么即可。

  • 启动 Selenium“webdriver”以弹出一个真正的 Firefox 浏览器窗口。

  • 使用它来打开我们预期从本地计算机提供的网页。

  • 检查(进行测试断言)页面的标题是否包含“Congratulations!”。

  • 如果一切顺利,我们会打印 OK。

让我们试着运行它:

$ python functional_tests.py
Traceback (most recent call last):
  File "...goat-book/functional_tests.py", line 4, in <module>
    browser.get("http://localhost:8000")
  File ".../selenium/webdriver/remote/webdriver.py", line 449, in get
    self.execute(Command.GET, {"url": url})
  File ".../selenium/webdriver/remote/webdriver.py", line 440, in execute
    self.error_handler.check_response(response)
  File ".../selenium/webdriver/remote/errorhandler.py", line 245, in
check_response
    raise exception_class(message, screen, stacktrace)
selenium.common.exceptions.WebDriverException: Message: Reached error page: abo
ut:neterror?e=connectionFailure&u=http%3A//localhost%3A8000/[...]
Stacktrace:
RemoteError@chrome://remote/content/shared/RemoteError.sys.mjs:8:8
WebDriverError@chrome://remote/content/shared/webdriver/Errors.sys.mjs:180:6
UnknownError@chrome://remote/content/shared/webdriver/Errors.sys.mjs:507:5
[...]

你应该看到一个浏览器窗口弹出并尝试打开localhost:8000,显示“无法连接”错误页面。如果你切回到控制台,会看到一个大而丑的错误消息,告诉我们 Selenium 遇到了错误页面。然后,你可能会因为它在桌面上留下了 Firefox 窗口而感到恼火。我们稍后会解决这个问题!

注意

如果你在尝试导入 Selenium 时遇到错误,或者试图找到一个叫做“geckodriver”的东西时出错,可能需要回头再看一下“前提条件与假设”部分。

不过,目前我们有一个失败的测试,这意味着我们可以开始构建我们的应用程序了。

让 Django 启动和运行

既然你肯定已经阅读了“前提条件与假设”部分,你应该已经安装了 Django(对吧?)。让 Django 运行起来的第一步是创建一个项目,它将是我们站点的主要容器。Django 为此提供了一个小的命令行工具:

$ django-admin startproject superlists .

别忘了结尾的那个“.”,它很重要!

这将在当前文件夹中创建一个名为manage.py的文件,以及一个名为superlists的子文件夹,其中包含更多内容:

.
├── functional_tests.py
├── geckodriver.log
├── manage.py
└── superlists
    ├── __init__.py
    ├── asgi.py
    ├── settings.py
    ├── urls.py
    └── wsgi.py
注意

确保你的项目文件夹看起来像这样!如果你看到两个嵌套的名为 superlists 的文件夹,那是因为你忘记了上面的“.”。删除它们,然后再试一次,否则路径和工作目录会造成很多混乱。

superlists文件夹用于适用于整个项目的东西—例如settings.py,它用于存储站点的全局配置信息。

但需要注意的主要事项是manage.py。这是 Django 的瑞士军刀之一,其中之一是运行开发服务器。让我们现在试试:

$ python manage.py runserver
Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).

You have 18 unapplied migration(s). Your project may not work properly until
you apply the migrations for app(s): admin, auth, contenttypes, sessions.
Run 'python manage.py migrate' to apply them.
March 17, 2023 - 18:07:30
Django version 4.2, using settings 'superlists.settings'
Starting development server at *http://127.0.0.1:8000/*
Quit the server with CONTROL-C.

那么,Django 的开发服务器现在在我们的机器上运行起来了。

注意

目前可以忽略关于“未应用迁移”的消息。我们将在第五章中讨论迁移。

将其留在那里,并打开另一个命令行窗口。导航到你的项目文件夹,激活你的虚拟环境,然后再次尝试运行我们的测试:

$ python functional_tests.py
OK

命令行上没有太多动作,但你应该注意两件事情:首先,没有出现难看的AssertionError,其次,Selenium 弹出的 Firefox 窗口上显示的页面看起来不同。

提示

如果你看到一个错误提示“没有找到模块 selenium”,那么你可能忘记激活你的虚拟环境了。如果需要的话,再次检查“前提条件与假设”部分。

嗯,它可能看起来不起眼,但这是我们第一个通过的测试!万岁!

如果一切感觉有点像魔术,或者感觉不太真实,为什么不自己打开 Web 浏览器并手动访问http://localhost:8000来查看开发服务器呢?你应该会看到类似于图 1-2 的内容。

如果你愿意,你现在可以退出开发服务器,回到原始的 shell 界面,使用 Ctrl-C。

Django 安装成功屏幕截图

图 1-2. 它成功了!

开始一个 Git 仓库

在我们结束本章之前还有一件事要做:开始将我们的工作提交到版本控制系统(VCS)。如果你是一名经验丰富的程序员,你不需要听我说版本控制的重要性,但如果你是新手,请相信我,VCS 是必不可少的。一旦你的项目超过几周的时间并且代码超过几行,拥有一个工具可以查看旧版本的代码、恢复更改、安全地探索新想法,甚至只是作为备份……这是多么有用,简直无法言喻。测试驱动开发与版本控制密不可分,所以我希望确保你明白它在工作流程中的角色。

所以,我们的第一次提交!如果说有什么迟到的话,那真是有点晚了;我们使用Git作为我们的版本控制系统,因为它是最好的。

让我们从执行git init开始初始化仓库:

$ ls
db.sqlite3  functional_tests.py  geckodriver.log  manage.py  superlists

$ git init .
Initialised empty Git repository in ...goat-book/.git/

现在让我们看看我们想要提交哪些文件:

$ ls
db.sqlite3 functional_tests.py geckodriver.log manage.py superlists

这里有一些我们不希望纳入版本控制的东西:db.sqlite3是数据库文件,geckodriver.log包含 Selenium 的调试输出,最后我们的虚拟环境也不应该在 Git 中。我们将它们全部添加到一个名为.gitignore的特殊文件中,告诉 Git 忽略它们:

$ echo "db.sqlite3" >> .gitignore
$ echo "geckodriver.log" >> .gitignore
$ echo ".venv" >> .gitignore

接下来我们可以添加当前文件夹“.”下的其余内容:

$ git add .
$ git status
On branch main

No commits yet

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)

        new file:   .gitignore
        new file:   functional_tests.py
        new file:   manage.py
        new file:   superlists/__init__.py
        new file:   superlists/__pycache__/__init__.cpython-311.pyc
        new file:   superlists/__pycache__/settings.cpython-311.pyc
        new file:   superlists/__pycache__/urls.cpython-311.pyc
        new file:   superlists/__pycache__/wsgi.cpython-311.pyc
        new file:   superlists/asgi.py
        new file:   superlists/settings.py
        new file:   superlists/urls.py
        new file:   superlists/wsgi.py

糟糕!我们这里有一堆.pyc文件;提交它们是毫无意义的。让我们从 Git 中移除它们并且也将它们加入.gitignore

$ git rm -r --cached superlists/__pycache__
rm 'superlists/__pycache__/__init__.cpython-311.pyc'
rm 'superlists/__pycache__/settings.cpython-311.pyc'
rm 'superlists/__pycache__/urls.cpython-311.pyc'
rm 'superlists/__pycache__/wsgi.cpython-311.pyc'
$ echo "__pycache__" >> .gitignore
$ echo "*.pyc" >> .gitignore

现在让我们看看我们的位置……

$ git status
On branch main

Initial commit

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)

        new file:   .gitignore
        new file:   functional_tests.py
        new file:   manage.py
        new file:   superlists/__init__.py
        new file:   superlists/asgi.py
        new file:   superlists/settings.py
        new file:   superlists/urls.py
        new file:   superlists/wsgi.py

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)

        modified:   .gitignore
提示

你会看到我经常使用git status——以至于我经常将它的别名设置为git st……不过,我不会告诉你如何做到这一点;我留给你自己去探索 Git 别名的秘密!

看起来不错——我们已经准备好做我们的第一次提交了!

$ git add .gitignore
$ git commit

当你输入git commit时,它会弹出一个编辑窗口让你写提交消息。我的看起来像图 1-3。²

git 提交编辑窗口截图

图 1-3. 第一次 Git 提交
注意

如果你真的想深入了解 Git,现在是时候学习如何将你的工作推送到像 GitHub 或 GitLab 这样的基于云的版本控制系统托管服务了。如果你想在不同的电脑上跟着本书学习,这些服务将会很有用。我让你自己去了解它们是如何工作的;它们有很好的文档。或者,你可以等到[链接即将来临]时再用它们来部署。

VCS 讲座就到这里了。恭喜!你已经使用 Selenium 编写了一个功能测试,并且已经以一种可验证、测试驱动、羊认证的 TDD 方式安装并运行了 Django。在继续前往第二章之前,给自己一个当之无愧的鼓励。

¹ 这其实是一个来自 2010 年代中期 Pycon 的一个小段轻松笑话,我正在试图让它变成一件事情。

² 看到一个奇怪的基于终端的编辑器(可怕的 vim)突然弹出来,你不知道该怎么办?或者看到关于账户身份和git config --global user.username的消息?查看 Git 手册及其基本配置部分。 PS - 要退出 vim,按 Esc,然后输入:q!

第二章:使用 unittest 模块扩展我们的功能测试

让我们调整我们的测试,目前它检查的是默认的 Django “it worked” 页面,而不是我们想在站点的真实首页上看到的一些内容。

是时候揭示我们正在构建的 Web 应用程序的类型了:一个待办事项列表网站!我知道,我知道,线上每个其他 Web 开发教程也是一个待办事项列表应用,或者可能是博客或投票应用。我非常跟风。

原因是待办事项列表是一个非常好的例子。在其最基本的形式中,它非常简单—​只是一列文本字符串—​因此很容易启动和运行一个“最小可行”列表应用程序。但是它可以通过各种方式扩展—​不同的持久化模型,添加截止日期、提醒、与其他用户共享,以及改进客户端 UI。并不一定局限于“待办事项”列表;它们可以是任何类型的列表。但关键是它应该允许我展示 Web 编程的所有主要方面以及如何应用 TDD。

使用功能测试来确定一个最小可行应用程序

使用 Selenium 进行的测试可以让我们操作真实的网络浏览器,因此真正让我们从用户的角度看到应用程序的功能。这就是为什么它们被称为功能测试

这意味着 FT 可以成为您的应用程序的一种规范。它倾向于跟踪您可能称之为用户故事的内容,并且遵循用户如何使用特定功能以及应用程序应如何响应他们的方式。

FT 应具有我们可以遵循的人类可读的故事。我们使用随测试代码附带的注释来明确它。创建新的 FT 时,我们可以首先编写注释,以捕捉用户故事的关键点。由于它们是人类可读的,甚至可以与非程序员分享,作为讨论应用程序要求和功能的一种方式。

TDD 和敏捷或精益软件开发方法经常结合在一起,我们经常谈论的一件事就是最小可行应用程序;我们可以构建的最简单有用的东西是什么?让我们从构建它开始,以便我们可以尽快测试一下。

一个最小可行的待办事项列表只需让用户输入一些待办事项,并在下次访问时记住它们即可。

打开functional_tests.py并写一个类似于这样的故事:

functional_tests.py(ch02l001)

from selenium import webdriver

browser = webdriver.Firefox()

# Edith has heard about a cool new online to-do app.
# She goes to check out its homepage
browser.get("http://localhost:8000")

# She notices the page title and header mention to-do lists
assert "To-Do" in browser.title

# She is invited to enter a to-do item straight away

# She types "Buy peacock feathers" into a text box
# (Edith's hobby is tying fly-fishing lures)

# When she hits enter, the page updates, and now the page lists
# "1: Buy peacock feathers" as an item in a to-do list

# There is still a text box inviting her to add another item.
# She enters "Use peacock feathers to make a fly" (Edith is very methodical)

# The page updates again, and now shows both items on her list

# Satisfied, she goes back to sleep

browser.quit()

除了将测试写成注释外,你会注意到我已经更新了assert来查找“To-Do”这个词,而不是 Django 的“Congratulations”。这意味着我们现在期望测试失败。让我们试着运行它。

首先,启动服务器:

$ python manage.py runserver

然后,在另一个终端中运行测试:

$ python functional_tests.py
Traceback (most recent call last):
  File "...goat-book/functional_tests.py", line 10, in <module>
    assert "To-Do" in browser.title
           ^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

这就是我们所谓的预期失败,这实际上是个好消息—​虽然不如测试通过那么好,但至少它因正确的原因而失败;我们可以相当有信心我们已正确编写了测试。

Python 标准库的 unittest 模块

有几个小烦恼我们可能需要处理。首先,“AssertionError”这个消息并不是很有帮助——如果测试能告诉我们实际找到的浏览器标题会更好。另外,桌面上留下了一个 Firefox 窗口,所以最好能自动清理掉它。

一个选项是使用 assert 关键字的第二个参数,类似于:

assert "To-Do" in browser.title, f"Browser title was {browser.title}"

我们还可以使用 try/finally 来清理旧的 Firefox 窗口。

但这些问题在测试中相当常见,在标准库的unittest模块中已经有一些现成的解决方案可以使用。让我们来使用它!在 functional_tests.py 中:

functional_tests.py (ch02l003)

import unittest
from selenium import webdriver

class NewVisitorTest(unittest.TestCase):  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png)
    def setUp(self):  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/3.png)
        self.browser = webdriver.Firefox()  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/4.png)

    def tearDown(self):  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/3.png)
        self.browser.quit()

    def test_can_start_a_todo_list(self):  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/2.png)
        # Edith has heard about a cool new online to-do app.
        # She goes to check out its homepage
        self.browser.get("http://localhost:8000")  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/4.png)

        # She notices the page title and header mention to-do lists
        self.assertIn("To-Do", self.browser.title)  ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/5.png)

        # She is invited to enter a to-do item straight away
        self.fail("Finish the test!")  ![6](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/6.png)

        [...]

        # Satisfied, she goes back to sleep

if __name__ == "__main__":  ![7](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/7.png)
    unittest.main()  ![7](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/7.png)

你可能会注意到这里有几点:

1

测试被组织成类,这些类继承自 unittest.TestCase

2

测试的主体在一个名为 test_can_start_a_todo_list 的方法中。任何以 test_ 开头的方法都是测试方法,并且将由测试运行器运行。你可以在同一个类中拥有多个 test_ 方法。为我们的测试方法取一个好的描述性名称也是个好主意。

3

setUptearDown 是特殊方法,它们在每个测试之前和之后运行。我在这里用它们来启动和停止我们的浏览器。它们有点像 try/finally,因为即使在测试过程中出现错误,tearDown 也会运行。¹ 不会再有未关闭的 Firefox 窗口了!

4

browser,之前是一个全局变量,现在成为了测试类的属性 self.browser。这样我们可以在 setUptearDown 和测试方法之间传递它。

5

我们使用 self.assertIn 而不是简单的 assert 来进行测试断言。unittest 提供了许多像这样的辅助函数,如 assertEqualassertTrueassertFalse 等等。你可以在 unittest 文档 中找到更多信息。

6

self.fail 无论如何都会失败,并输出给定的错误消息。我将其用作完成测试的提醒。

7

最后,我们有了if __name__ == '__main__'子句(如果你之前没见过,这是 Python 脚本检查是否从命令行执行,而不仅仅是被另一个脚本导入的方式)。我们调用unittest.main(),它启动unittest测试运行器,它将自动查找文件中的测试类和方法并运行它们。

如果你读过 Django 的测试文档,你可能看到过叫做LiveServerTestCase的东西,并想知道我们现在是否应该使用它。恭喜你阅读了友好的手册!现在LiveServerTestCase对现在来说有点复杂,但我保证我会在后面的章节中使用它。

让我们试试我们新改进的 FT!²

$ python functional_tests.py
F
======================================================================
FAIL: test_can_start_a_todo_list
(__main__.NewVisitorTest.test_can_start_a_todo_list)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "...goat-book/functional_tests.py", line 18, in
test_can_start_a_todo_list
    self.assertIn("To-Do", self.browser.title)
AssertionError: 'To-Do' not found in 'The install worked successfully!
Congratulations!'

 ---------------------------------------------------------------------
Ran 1 test in 1.747s

FAILED (failures=1)

这好看多了,不是吗?它整理了我们的 Firefox 窗口,给了我们一个格式漂亮的报告,显示运行了多少测试和多少失败了,assertIn还给了我们一个有用的带有调试信息的错误消息。棒极了!

提交

这是进行提交的好时机;这是一个很好的自包含变更。我们扩展了我们的功能测试,包括描述我们设定的任务的注释,我们还重写了它以使用 Python 的unittest模块及其各种测试辅助函数。

执行git status—这应该会告诉你只有functional_tests.py这个文件发生了变化。然后执行git diff -w,它将显示最后一次提交与当前磁盘上的文件之间的差异,使用-w表示“忽略空白变化”。

这应该告诉你,functional_tests.py发生了相当大的变化:

$ git diff -w
diff --git a/functional_tests.py b/functional_tests.py
index d333591..b0f22dc 100644
--- a/functional_tests.py
+++ b/functional_tests.py
@@ -1,15 +1,24 @@
+import unittest
 from selenium import webdriver

-browser = webdriver.Firefox()

+class NewVisitorTest(unittest.TestCase):
+    def setUp(self):
+        self.browser = webdriver.Firefox()
+
+    def tearDown(self):
+        self.browser.quit()
+
+    def test_can_start_a_todo_list(self):
         # Edith has heard about a cool new online to-do app.
         # She goes to check out its homepage
-browser.get("http://localhost:8000")
+        self.browser.get("http://localhost:8000")

         # She notices the page title and header mention to-do lists
-assert "To-Do" in browser.title
+        self.assertIn("To-Do", self.browser.title)

         # She is invited to enter a to-do item straight away
+        self.fail("Finish the test!")

[...]

现在让我们执行:

$ git commit -a

-a表示“自动将任何更改添加到已跟踪的文件”(即,任何我们之前提交过的文件)。它不会添加任何全新的文件(你必须明确使用git add添加它们),但通常,就像这种情况一样,没有新文件,所以这是一个有用的快捷方式。

当编辑器弹出时,请添加一个描述性的提交消息,比如“首次在注释中规定了 FT,并且现在使用单元测试。”

现在我们的 FT 使用了一个真正的测试框架,并且我们已经用占位符注释了我们希望它做什么,我们现在可以非常出色地开始为我们的列表应用编写一些真正的代码了。继续阅读!

¹ 唯一的例外是如果setUp中有一个异常,那么tearDown就不会运行。

² 你是否无法继续前进,因为你想知道那些ch02l00x是什么,就在某些代码清单旁边?它们指的是书本示例库中特定的提交。这都与我书中自己的测试有关。你知道的,关于测试的书中的测试。它们当然也有自己的测试。

第三章:使用单元测试测试简单的首页

我们在上一章结束时有一个功能测试失败,告诉我们它希望我们网站的首页标题中有“待办事项”。现在是开始开发我们的应用程序的时候了。在这一章中,我们将构建我们的第一个 HTML 页面,了解 URL 处理,并使用 Django 的视图函数创建 HTTP 请求的响应。

我们的第一个 Django 应用程序及我们的第一个单元测试

Django 鼓励您将代码结构化为应用程序:理论上,一个项目可以有多个应用程序,您可以使用其他人开发的第三方应用程序,甚至可以在不同项目中重用您自己的应用程序... 尽管我承认我从未真正做到过!不过,应用程序是保持代码组织良好的好方法。

让我们为我们的待办事项列表创建一个应用程序:

$ python manage.py startapp lists

这将在 manage.py 旁边创建一个名为 lists 的文件夹,并在其中创建一些占位文件,如模型、视图以及对我们非常感兴趣的测试:

.
├── db.sqlite3
├── functional_tests.py
├── lists
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── migrations
│   │   └── __init__.py
│   ├── models.py
│   ├── tests.py
│   └── views.py
├── manage.py
└── superlists
    ├── __init__.py
    ├── asgi.py
    ├── settings.py
    ├── urls.py
    └── wsgi.py

单元测试及其与功能测试的区别

就像我们给许多事物贴上的标签一样,单元测试和功能测试之间的界限有时可能会变得有些模糊。不过,基本的区别在于,功能测试从外部,从用户的角度测试应用程序。单元测试从内部,从程序员的角度测试应用程序。

我演示的 TDD 方法使用两种类型的测试来驱动我们应用程序的开发,并确保其正确性。我们的工作流程将看起来有点像这样:

  1. 我们从编写功能测试开始,描述我们的新功能的一个典型示例,从用户的角度来看。

  2. 一旦我们有一个功能测试失败,我们开始考虑如何编写代码来使其通过(或至少通过当前的失败)。现在我们使用一个或多个单元测试来定义我们希望我们的代码如何运行——我们编写的每一行生产代码都应该由(至少)一个我们的单元测试来测试。

  3. 一旦我们有一个失败的单元测试,我们编写尽可能少的应用程序代码,只要能让单元测试通过即可。我们可能会在步骤 2 和步骤 3 之间迭代几次,直到我们认为功能测试将进展一点。

  4. 现在我们可以重新运行我们的功能测试,看它们是否通过或者是否有所进展。这可能会促使我们编写一些新的单元测试,编写一些新代码,等等。

  5. 一旦我们确信核心功能端到端运行正常,我们可以扩展测试以覆盖更多的排列组合和边缘情况,现在只使用单元测试。

你可以看到,从始至终,功能测试在高层驱动我们的开发,而单元测试在低层驱动我们的开发。

功能测试的目标不是覆盖应用程序行为的每一个细节,它们是为了确保所有东西都正确连接起来。单元测试则是详尽检查所有低级细节和边缘情况。

注意

功能测试应该帮助你构建一个真正可用的应用程序,并确保你永远不会意外地破坏它。单元测试应该帮助你编写干净和无 bug 的代码。

现在足够理论了 — 让我们看看它在实践中的表现。

表 3-1. FTs vs 单元测试

FTs 单元测试
每个功能/用户故事一个测试 每个功能多个测试
用户角度的测试 程序员角度的代码测试
可以测试 UI “真正” 工作 测试内部,单个函数或类
确保所有东西正确连接在一起的信心 可以详尽检查排列组合,细节,边缘情况

Django 中的单元测试

让我们看看如何为我们的首页视图编写一个单元测试。打开 lists/tests.py 中的新文件,你会看到类似这样的内容:

lists/tests.py

from django.test import TestCase

# Create your tests here.

Django 已经很贴心地建议我们使用它提供的 TestCase 的特殊版本。它是标准的 unittest.TestCase 的增强版本,带有一些额外的 Django 特定功能,我们将在接下来的几章中了解到。

你已经看到 TDD 周期包括从失败的测试开始,然后编写代码使其通过。那么,在我们甚至达到那一步之前,我们希望知道我们编写的单元测试一定会被我们的自动化测试运行器运行,不管它是什么。在 functional_tests.py 的情况下,我们直接运行它,但这个由 Django 创建的文件有点像魔术。所以,为了确保,让我们写一个故意愚蠢的失败测试:

lists/tests.py (ch03l002)

from django.test import TestCase

class SmokeTest(TestCase):
    def test_bad_maths(self):
        self.assertEqual(1 + 1, 3)

现在让我们调用这个神秘的 Django 测试运行器。像往常一样,这是一个 manage.py 命令:

$ python manage.py test
Creating test database for alias 'default'...
Found 1 test(s).
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_bad_maths (lists.tests.SmokeTest.test_bad_maths)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "...goat-book/lists/tests.py", line 6, in test_bad_maths
    self.assertEqual(1 + 1, 3)
AssertionError: 2 != 3

 ---------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)
Destroying test database for alias 'default'...

很好。机制似乎在工作。这是一个提交的好时机:

$ git status  # should show you lists/ is untracked
$ git add lists
$ git diff --staged  # will show you the diff that you're about to commit
$ git commit -m "Add app for lists, with deliberately failing unit test"

正如你无疑猜到的那样,-m 标志允许你在命令行传递提交消息,这样你就不需要使用编辑器了。选择如何使用 Git 命令行取决于你;我只是展示我见过的主要方式。对我来说,VCS 卫生的主要部分是:确保在提交之前始终审查即将提交的内容

Django 的 MVC、URL 和视图函数

Django 沿着经典的 Model-View-Controller (MVC) 模式结构化。嗯,大体上 是这样。它确实有模型,但 Django 称为视图的东西实际上是控制器,而视图部分实际上由模板提供,但你可以看到总体思路是一致的!

如果你感兴趣,你可以查看 Django FAQ 中讨论的更细节的内容。

无论如何,就像任何 Web 服务器一样,Django 的主要工作是决定当用户请求站点上特定的 URL 时该做什么。Django 的工作流程大致如下:

  1. 一个 HTTP 请求 来自于特定的 URL

  2. Django 使用一些规则来决定哪个 视图 函数应该处理请求(这称为 解析 URL)。

  3. 视图函数处理请求并返回一个 HTTP 响应

所以,我们想测试两件事:

  • 我们能让这个视图函数返回我们需要的 HTML 吗?

  • 我们能告诉 Django 在我们请求站点根目录(“/”)时使用这个视图函数吗?

让我们从第一个开始。

测试一个视图

打开 lists/tests.py,将我们愚蠢的测试更改为类似这样的内容:

lists/tests.py (ch03l003)

from django.test import TestCase
from django.http import HttpRequest  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png)
from lists.views import home_page

class HomePageTest(TestCase):
    def test_home_page_returns_correct_html(self):
        request = HttpRequest()  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png)
        response = home_page(request)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/2.png)
        html = response.content.decode("utf8")  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/3.png)
        self.assertIn("<title>To-Do lists</title>", html)  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/4.png)
        self.assertTrue(html.startswith("<html>"))  ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/5.png)
        self.assertTrue(html.endswith("</html>"))  ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/5.png)

这个新测试中发生了什么?嗯,记住,一个视图函数以 HTTP 请求作为输入,并生成一个 HTTP 响应。所以,为了测试:

1

我们导入 HttpRequest 类,这样我们就可以在测试中创建一个请求对象。这是当用户的浏览器请求页面时 Django 会创建的对象。

2

我们将 HttpRequest 对象传递给我们的 home_page 视图,这给了我们一个响应。你可能不会感到惊讶,响应是一个名为 HttpResponse 的类的实例。

3

然后,我们提取响应的 .content。这些是原始字节,即将发送到用户浏览器的 0 和 1。我们调用 .decode() 将它们转换为 HTML 字符串,这些将发送给用户的内容。

4

现在我们可以进行一些断言:我们知道我们希望在其中的某处有一个 html <title> 标签,标签中包含“待办事项列表”这几个字——因为这是我们在功能测试中指定的内容。

5

然后我们可以做一个粗略的检查,确认它是有效的 html,通过检查它是否以 <html> 标签开头,并在结尾处关闭。

所以,你认为当我们运行测试时会发生什么?

$ python manage.py test
Found 1 test(s).
System check identified no issues (0 silenced).
E
======================================================================
ERROR: lists.tests (unittest.loader._FailedTest.lists.tests)
 ---------------------------------------------------------------------
ImportError: Failed to import test module: lists.tests
Traceback (most recent call last):
[...]
  File "...goat-book/lists/tests.py", line 3, in <module>
    from lists.views import home_page
ImportError: cannot import name 'home_page' from 'lists.views'

这是一个非常可预测且不太有趣的错误:我们试图导入尚未编写的东西。但这仍然是好消息—​对于 TDD 来说,预料之中的异常算作是预期的失败。因为我们既有一个失败的功能测试又有一个失败的单元测试,我们得到了测试山羊的充分祝福,可以继续编码。

终于!我们真正写一些应用代码!

这是令人兴奋的,不是吗?请注意,TDD 意味着长时间的期待只能逐渐化解,并且只能通过微小的增量来解决。特别是因为我们在学习阶段,刚刚开始,我们只允许自己每次只改变(或添加)一行代码—​每次,我们只进行最小的变更来解决当前的测试失败。

我故意夸张一下,但是我们当前的测试失败是什么?我们无法从lists.views导入home_page?好的,让我们解决这个问题—​只解决这个问题。在lists/views.py中:

lists/views.py(ch03l004)

from django.shortcuts import render

# Create your views here.
home_page = None

“你一定在开玩笑!” 我听到你说。

我可以听到你的声音,因为这正是我以前(带有强烈感情地)对我的同事展示 TDD 时所说的话。好吧,忍耐一下,我们稍后再讨论这是否有些过火。但现在,让自己跟随一下,即使有些恼怒,看看我们的测试是否可以帮助我们一次一小步地编写正确的代码。

让我们再次运行测试:

[...]
  File "...goat-book/lists/tests.py", line 9, in
test_home_page_returns_correct_html
    response = home_page(request)
               ^^^^^^^^^^^^^^^^^^
TypeError: 'NoneType' object is not callable

我们仍然收到错误消息,但情况有所改变。不再是导入错误,我们的测试告诉我们我们的home_page“函数”不可调用。这给了我们正当理由将其从None改为实际的函数。在最微小的细节层面,每一次代码变更都可以由测试驱动!

回到lists/views.py

lists/views.py(ch03l005)

from django.shortcuts import render

def home_page():
    pass

再次,我们只需进行最小、最愚蠢的变更,精确解决当前的测试失败。我们的测试希望得到可调用的内容,因此我们提供了可能的最简单可调用的东西,一个不接受任何参数并且不返回任何内容的函数。

让我们再次运行测试,看看它们的反应:

    response = home_page(request)
               ^^^^^^^^^^^^^^^^^^
TypeError: home_page() takes 0 positional arguments but 1 was given

再次,我们的错误消息略有变化,并引导我们修复接下来出现的问题。

单元测试/代码循环

现在我们可以开始进入 TDD 的单元测试/代码循环

  1. 在终端中运行单元测试,看看它们如何失败。

  2. 在编辑器中,进行最小化的代码变更来解决当前的测试失败。

然后重复!

我们对代码正确性越紧张,每次代码变更就越小、越简单—​这个想法是确保每一行代码都经过测试的验证。

这看起来可能很繁琐,起初确实是这样。但一旦你进入了节奏,即使采取微小的步骤,你也会发现自己编码速度很快—​这是我们在工作中编写所有生产代码的方式。

让我们看看我们能多快地进行这个循环:

  • 最小化代码变更:

    lists/views.py(ch03l006)

    def home_page(request):
        pass
    
  • 测试:

        html = response.content.decode("utf8")
               ^^^^^^^^^^^^^^^^
    AttributeError: 'NoneType' object has no attribute 'content'
    
  • 代码—​我们使用django.http.HttpResponse,正如预期的那样:

    lists/views.py (ch03l007)

    from django.http import HttpResponse
    
    def home_page(request):
        return HttpResponse()
    
  • 再次测试:

    AssertionError: '<title>To-Do lists</title>' not found in ''
    
  • 再来一段代码:

    lists/views.py (ch03l008)

    def home_page(request):
        return HttpResponse("<title>To-Do lists</title>")
    
  • 再次测试:

        self.assertTrue(html.startswith("<html>"))
    AssertionError: False is not true
    
  • 再来一段代码:

    lists/views.py (ch03l009)

    def home_page(request):
        return HttpResponse("<html><title>To-Do lists</title>")
    
  • 测试—​快了吗?

        self.assertTrue(html.endswith("</html>"))
    AssertionError: False is not true
    
  • 再努把力:

    lists/views.py (ch03l010)

    def home_page(request):
        return HttpResponse("<html><title>To-Do lists</title></html>")
    
  • 当然?

    $ python manage.py test
    Creating test database for alias 'default'...
    Found 1 test(s).
    System check identified no issues (0 silenced).
    .
     ---------------------------------------------------------------------
    Ran 1 test in 0.001s
    
    OK
    Destroying test database for alias 'default'...
    

好极了!我们有史以来的第一个单元测试通过了!这是如此重要,我认为值得提交:

$ git diff  # should show changes to tests.py, and views.py
$ git commit -am "First unit test and view function"

那就是我将展示的最后一种 git commit 变体了,am 标志一起使用,它将所有更改添加到已跟踪文件并使用命令行中的提交消息。

警告

git commit -am 是最快的组合,但也给出了关于正在提交的内容最少的反馈,所以确保你之前已经执行了 git statusgit diff,并且清楚即将进行的更改。

我们的功能测试告诉我们我们还没有完成。

我们的单元测试通过了,所以让我们回到运行我们的功能测试,看看我们是否有所进展。如果开发服务器还没有运行,请不要忘记重新启动它。

$ python functional_tests.py
F
======================================================================
FAIL: test_can_start_a_todo_list
(__main__.NewVisitorTest.test_can_start_a_todo_list)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "...goat-book/functional_tests.py", line 18, in
test_can_start_a_todo_list
    self.assertIn("To-Do", self.browser.title)
AssertionError: 'To-Do' not found in 'The install worked successfully!
Congratulations!'

 ---------------------------------------------------------------------
Ran 1 test in 1.609s

FAILED (failures=1)

看起来有些不太对劲。这就是我们进行功能测试的原因!

你还记得在本章开头,我们说过我们需要做两件事,首先是创建一个视图函数来为请求产生响应,其次是告诉服务器哪些函数应该响应哪些 URL 吗?多亏了我们的 FT,我们被提醒我们仍然需要做第二件事。

我们如何编写 URL 解析的测试呢?目前我们只是直接导入并调用视图函数进行测试。但我们想要测试 Django 堆栈的更多层。Django,像大多数 Web 框架一样,提供了一个工具来做这件事,称为Django 测试客户端

让我们看看如何使用它,通过向我们的单元测试添加第二个替代测试:

lists/tests.py (ch03l011)

class HomePageTest(TestCase):
    def test_home_page_returns_correct_html(self):
        request = HttpRequest()
        response = home_page(request)
        html = response.content.decode("utf8")
        self.assertIn("<title>To-Do lists</title>", html)
        self.assertTrue(html.startswith("<html>"))
        self.assertTrue(html.endswith("</html>"))

    def test_home_page_returns_correct_html_2(self):
        response = self.client.get("/")  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png)
        self.assertContains(response, "<title>To-Do lists</title>")  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/2.png)

1

我们可以通过 self.client 访问测试客户端,它在任何使用 django.test.TestCase 的测试中都可用。它提供了像 .get() 这样的方法,模拟浏览器发出 http 请求,并将 URL 作为其第一个参数。我们使用它来代替手动创建请求对象并直接调用视图函数

2

Django 还提供了一些断言辅助函数,比如 assertContains,它们可以帮助我们避免手动提取和解码响应内容,并且还有一些其他好处,正如我们将看到的那样。

让我们看看它是如何工作的:

$ python manage.py test
Found 2 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.F
======================================================================
FAIL: test_home_page_returns_correct_html_2
(lists.tests.HomePageTest.test_home_page_returns_correct_html_2)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "...goat-book/lists/tests.py", line 17, in
test_home_page_returns_correct_html_2
    self.assertContains(response, "<title>To-Do lists</title>")
[...]
AssertionError: 404 != 200 : Couldn't retrieve content: Response code was 404
(expected 200)

 ---------------------------------------------------------------------
Ran 2 tests in 0.004s

FAILED (failures=1)
Destroying test database for alias 'default'...

嗯,关于 404 的问题?让我们深入了解一下。

阅读回溯

让我们花一点时间来谈谈如何阅读回溯,因为这是我们在 TDD 中经常要做的事情。你很快就会学会扫描它们并收集相关线索:

======================================================================
FAIL: test_home_page_returns_correct_html_2  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/2.png)
(lists.tests.HomePageTest.test_home_page_returns_correct_html_2)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "...goat-book/lists/tests.py", line 17, in
test_home_page_returns_correct_html_2
    self.assertContains(response, "<title>To-Do lists</title>")  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/3.png)
  File ".../django/test/testcases.py", line 647, in assertContains
    text_repr, real_count, msg_prefix = self._assert_contains(
                                        ^^^^^^^^^^^^^^^^^^^^^^  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/4.png)
  File ".../django/test/testcases.py", line 610, in _assert_contains
    self.assertEqual(
AssertionError: 404 != 200 : Couldn't retrieve content: Response code was 404  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png)
(expected 200)

 ---------------------------------------------------------------------
[...]

1

通常,你首先要查看的地方就是错误本身。有时这就是你需要看到的一切,它会让你立即识别问题。但有时,就像在这种情况下一样,情况并不是那么明显。

2

下一步要仔细检查的是:哪个测试失败了? 它确实是我们期望的那个测试吗——也就是说,我们刚刚编写的测试吗?在这种情况下,答案是肯定的。

3

接着,我们寻找导致失败的测试代码所在的位置。我们从回溯的顶部开始向下查找,查找测试文件的文件名,以检查是哪个测试函数,以及失败来自哪一行代码。在这种情况下,是我们调用assertContains方法的那一行。

4

在 Python 3.11 及更高版本中,你还可以查看小尖括号组成的字符串,它们试图告诉你异常来自哪里。这对于意外异常比我们现在的断言失败更有用。

通常还有第五步,我们会进一步查找我们自己的应用代码中是否涉及该问题。在这种情况下,这都是 Django 代码,但我们将在本书的后面看到许多这样的第五步示例。

汇总一下,我们将回溯解释为告诉我们,在我们尝试对响应内容进行断言时,Django 的测试助手失败,因为它们无法执行该操作,因为响应是 HTML 404“未找到”错误,而不是正常的 200 OK 响应。

换句话说,Django 尚未配置为响应对我们站点根 URL(“/”)的请求。现在让我们来实现这个功能。

urls.py

Django 使用一个名为urls.py的文件来将 URL 映射到视图函数。这种映射也称为路由。整个站点的主urls.py位于superlists文件夹中。让我们去看一下:

superlists/urls.py

"""
URL configuration for superlists project.

The `urlpatterns` list routes URLs to views. For more information please see:
 https://docs.djangoproject.com/en/4.2/topics/http/urls/
Examples:
Function views
 1\. Add an import:  from my_app import views
 2\. Add a URL to urlpatterns:  path('', views.home, name='home')
Class-based views
 1\. Add an import:  from other_app.views import Home
 2\. Add a URL to urlpatterns:  path('', Home.as_view(), name='home')
Including another URLconf
 1\. Import the include() function: from django.urls import include, path
 2\. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path

urlpatterns = [
    path("admin/", admin.site.urls),
]
警告

如果你的urls.py看起来不同,或者提到了一个名为url()而不是path()的函数,那是因为你安装了错误版本的 Django。本书是针对 Django v4 编写的。再看一下先决条件和假设部分,并在继续之前获取正确的版本。

通常情况下,Django 提供了大量有用的注释和默认建议。实际上,第一个示例就是我们想要的!让我们使用它,并进行一些小的更改。

superlists/urls.py (ch03l012)

from django.urls import path  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png)
from lists.views import home_page  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/2.png)

urlpatterns = 
    path("", home_page, name="home"),  ![3
]

1

无需从django.contrib导入admin。Django 的管理站点非常棒,但这是另一本书的话题。

2

但是我们将导入我们的主页视图函数。

3

我们将其连接在这里,作为urlpatterns全局变量中的path()条目。Django 会从所有 URL 中去掉开头的斜杠,所以"/url/path/to"变成了"url/path/to",而基础 URL 就是空字符串""。因此,这个配置表示:“基础 URL 应该指向我们的主页视图”

现在我们可以再次运行我们的单元测试,使用python manage.py test命令:

[...]
..
 ---------------------------------------------------------------------
Ran 2 tests in 0.003s

OK

万岁!

是时候稍作整理了。我们不需要两个单独的测试,让我们将所有内容从直接调用视图函数的低级测试中移出,放入使用 Django 测试客户端的测试中:

lists/tests.py(ch03l013)

class HomePageTest(TestCase):
    def test_home_page_returns_correct_html(self):
        response = self.client.get("/")
        self.assertContains(response, "<title>To-Do lists</title>")
        self.assertContains(response, "<html>")
        self.assertContains(response, "</html>")

但现在真相大白了,我们的功能测试会通过吗?

$ python functional_tests.py
[...]
======================================================================
FAIL: test_can_start_a_todo_list
(__main__.NewVisitorTest.test_can_start_a_todo_list)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "...goat-book/functional_tests.py", line 21, in
test_can_start_a_todo_list
    self.fail("Finish the test!")
AssertionError: Finish the test!

失败了?什么?哦,这只是我们的小提醒?是的?是的!我们有一个网页!

哎呀。嗯,认为这是章节的一个激动人心的结尾。你可能还有点困惑,也许急于听到所有这些测试的理由,别担心,一切都会有的,但我希望你在最后感受到了一丝兴奋。

只需稍作提交,冷静下来,回顾一下我们所涵盖的内容:

$ git diff  # should show our modified test in tests.py, and the new config in urls.py
$ git commit -am "url config, map / to home_page view"

那真是一个精彩的章节!为什么不尝试输入git log命令,可能会使用--oneline选项,来回顾我们的活动:

$ git log --oneline
a6e6cc9 url config, map / to home_page view
450c0f3 First unit test and view function
ea2b037 Add app for lists, with deliberately failing unit test
[...]

还不错——我们涵盖了:

  • 开始一个 Django 应用程序

  • Django 单元测试运行器

  • 功能测试和单元测试之间的区别

  • Django 视图函数、请求和响应对象

  • Django URL 解析和urls.py

  • Django 测试客户端

  • 并从视图返回基本的 HTML。

第四章:我们对所有这些测试究竟做了什么?(以及重构)

现在我们已经看到 TDD 的基础行动了,是时候暂停一下,谈谈我们为什么要这样做了。

我想象着你们中的一些,亲爱的读者,一直在忍受着一些愤怒和沮丧——也许你们中的一些之前进行过一些单元测试,也许你们中的一些只是匆忙之间。你们一直在忍耐着像这样的问题:

  • 所有这些测试难道不是有点过多了吗?

  • 当然,其中一些是多余的吗?功能测试和单元测试之间存在重复。

  • 那些单元测试看起来太琐碎了——测试一个返回常量的单行函数!那不是在浪费时间吗?我们不应该把测试留给更复杂的事情吗?

  • 那么在单元测试/编码周期中的所有这些微小变化呢?我们可以直接跳到最后吗?我的意思是,home_page = None!?真的吗?

  • 你不是真的在生活中 真的 像这样编码吧?

啊,年轻的蚱蜢啊。我也曾经充满了这些问题。但只因为它们确实是好问题。事实上,我一直都在问自己这样的问题,时时刻刻。这些东西真的有价值吗?这是不是某种崇拜船运的表现?

编程就像是从井里提水一样

最终,编程是困难的。通常情况下,我们很聪明,所以我们成功了。TDD 在我们不那么聪明时帮了我们很多。Kent Beck(基本上发明了 TDD)用提水井中的水桶抽水的比喻来解释:当井不太深,水桶没有装满时,这很容易。即使开始时提起一个满满的水桶也相对容易。但是过了一会儿,你会感到疲倦。TDD 就像是一把棘轮,让你保存进度,这样你可以休息一下,并确保你永远不会倒退。

这样你就不必一直聪明到底了。

测试所有东西

图 4-1。测试所有东西(原始插图来源:Allie Brosh, Hyperbole and a Half

好吧,也许 总体上 你愿意承认 TDD 是个好主意,但也许你仍然认为我做得过火了?测试最微小的东西,采取荒谬地多的小步骤?

TDD 是一种 纪律,这意味着它不是什么天生的技能;因为许多好处不是即刻显现的,而是长期的,你必须在当下强迫自己去做。这就是测试山羊形象想要表达的——你需要在这件事上有点固执。

现在,让我们回到正题。

使用 Selenium 进行用户交互测试

上一章末尾我们到了哪里?让我们重新运行测试看看:

$ python functional_tests.py
F
======================================================================
FAIL: test_can_start_a_todo_list
(__main__.NewVisitorTest.test_can_start_a_todo_list)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "...goat-book/functional_tests.py", line 21, in
test_can_start_a_todo_list
    self.fail("Finish the test!")
AssertionError: Finish the test!

 ---------------------------------------------------------------------
Ran 1 test in 1.609s

FAILED (failures=1)

你有没有试过,出现了 问题加载页面无法连接 的错误?我也试过。那是因为我们忘记先用 manage.py runserver 启动开发服务器。做到这一点,你会得到我们需要的失败消息。

TDD 的一个很棒的地方就是你永远不用担心忘记接下来要做什么—只需重新运行你的测试,它们会告诉你接下来需要做什么。

“完成测试”,它说,那我们就这样做吧!打开 functional_tests.py,我们将扩展我们的 FT:

functional_tests.py(ch04l001)

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
import time
import unittest

class NewVisitorTest(unittest.TestCase):
    def setUp(self):
        self.browser = webdriver.Firefox()

    def tearDown(self):
        self.browser.quit()

    def test_can_start_a_todo_list(self):
        # Edith has heard about a cool new online to-do app.
        # She goes to check out its homepage
        self.browser.get("http://localhost:8000")

        # She notices the page title and header mention to-do lists
        self.assertIn("To-Do", self.browser.title)
        header_text = self.browser.find_element(By.TAG_NAME, "h1").text  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png)
        self.assertIn("To-Do", header_text)

        # She is invited to enter a to-do item straight away
        inputbox = self.browser.find_element(By.ID, "id_new_item")  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png)
        self.assertEqual(inputbox.get_attribute("placeholder"), "Enter a to-do item")

        # She types "Buy peacock feathers" into a text box
        # (Edith's hobby is tying fly-fishing lures)
        inputbox.send_keys("Buy peacock feathers")  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/2.png)

        # When she hits enter, the page updates, and now the page lists
        # "1: Buy peacock feathers" as an item in a to-do list table
        inputbox.send_keys(Keys.ENTER)  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/3.png)
        time.sleep(1)  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/4.png)

        table = self.browser.find_element(By.ID, "id_list_table")
        rows = table.find_elements(By.TAG_NAME, "tr")  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png)
        self.assertTrue(any(row.text == "1: Buy peacock feathers" for row in rows))

        # There is still a text box inviting her to add another item.
        # She enters "Use peacock feathers to make a fly"
        # (Edith is very methodical)
        self.fail("Finish the test!")

        # The page updates again, and now shows both items on her list
        [...]

1

我们正在使用 Selenium 提供的两种方法来检查网页:find_elementfind_elements(注意额外的 s,这意味着它将返回多个元素而不仅仅是一个)。每一个都是使用 By.SOMETHING 参数化的,这让我们可以使用不同的 HTML 属性和属性来搜索。

2

我们还使用 send_keys,这是 Selenium 在输入元素上键入的方式。

3

Keys 类(不要忘记导入它)让我们发送像 Enter 这样的特殊键。¹

4

当我们按 Enter 键时,页面将刷新。 time.sleep 的作用是确保浏览器在我们对新页面进行任何断言之前已经加载完成。这称为“显式等待”(一个非常简单的等待方式;我们将在第六章中进行改进)。

提示

注意 Selenium 的 find_element()find_elements() 函数之间的区别。一个返回一个元素并在找不到时引发异常,而另一个返回一个可能为空的列表。

还有,看看那个 any() 函数。这是一个鲜为人知的 Python 内置函数。我甚至不需要解释它,对吧?Python 真是一种乐趣。²

注意

如果你是我这些读者中的一个,不懂 Python,any() 函数内部发生了什么可能需要一些解释。基本语法是列表推导式,如果你还没学过,现在就应该去学了! Trey Hunner 的解释非常出色。 实际上,因为我们省略了方括号,所以我们实际上使用的是生成器表达式而不是列表推导式。了解这两者之间的区别可能不那么重要,但如果你感兴趣,可以看看Guido 自己的这篇博客文章来解释这两者的区别。

让我们看看它的表现:

$ python functional_tests.py
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: h1

解码后,测试显示无法在页面上找到 <h1> 元素。让我们看看如何将其添加到我们主页的 HTML 中。

对功能测试进行大的更改通常是一个好主意。当我首次为本章编写代码时,我没有这样做,后来当我改变主意并且改变混合了一堆其他更改时,我后悔了。您的提交越原子化,越好:

$ git diff  # should show changes to functional_tests.py
$ git commit -am "Functional test now checks we can input a to-do item"

“不要测试常量”规则,以及模板的拯救

让我们来看看我们的单元测试,lists/tests.py。目前,我们正在寻找特定的 HTML 字符串,但这不是测试 HTML 的特别有效的方法。一般来说,单元测试的一个规则是不要测试常量,而测试 HTML 作为文本很像测试一个常量。

换句话说,如果你有一些代码,如下所示:

wibble = 3

测试一个说法并没有多大意义:

from myprogram import wibble
assert wibble == 3

单元测试实际上是关于测试逻辑、流控制和配置的。对我们的 HTML 字符串中确切字符序列进行断言并不能做到这一点。

其实并不完全那么简单,因为 HTML 毕竟也是代码,我们确实希望有一些东西来检查我们编写的代码是否有效,但这是我们功能测试的工作,而不是单元测试的工作。

无论如何,在 Python 中操纵原始字符串并不是处理 HTML 的好方法。有一个更好的解决方案,那就是使用模板。除此之外,如果我们能够将 HTML 保留在以.xhtml结尾的文件中,我们将获得更好的语法高亮!市面上有很多 Python 模板框架,而 Django 也有自己的模板框架,非常好用。让我们使用它。

重构以使用模板

我们现在想做的是使我们的视图函数返回完全相同的 HTML,但只是使用不同的过程。这就是重构——试图改进代码而不改变其功能

最后一点非常重要。如果您在重构的同时尝试添加新功能,那么遇到问题的可能性要高得多。重构实际上是一门完整的学科,甚至有一本参考书:Martin Fowler 的Refactoring

第一条规则是,在没有测试的情况下不能重构。幸运的是,我们正在进行 TDD,所以我们已经领先了。让我们检查一下我们的测试是否通过;它们将确保我们的重构保持行为不变:

$ python manage.py test
[...]
OK

很棒!我们将从将 HTML 字符串放入自己的文件开始。创建一个名为lists/templates的目录以存放模板,并打开一个文件lists/templates/home.xhtml,将 HTML 转移到其中:³

lists/templates/home.xhtml(ch04l002)

<html>
  <title>To-Do lists</title>
</html>

嗯,语法高亮……好多了!现在来修改我们的视图函数:

lists/views.py(ch04l003)

from django.shortcuts import render

def home_page(request):
    return render(request, "home.xhtml")

现在我们不再构建自己的HttpResponse,而是使用 Django 的render()函数。它将请求作为其第一个参数(我们稍后会解释原因),并指定要渲染的模板的名称。Django 会自动搜索任何应用程序目录中名为 templates 的文件夹。然后它根据模板的内容为您构建一个HttpResponse

注意

模板是 Django 的一个非常强大的特性,其主要优势在于将 Python 变量替换为 HTML 文本。我们现在还没有使用这个功能,但在未来的章节中会用到。这就是为什么我们使用render()而不是手动使用内置的open()从磁盘读取文件。

让我们看看它是否有效:

$ python manage.py test
[...]
======================================================================
ERROR: test_home_page_returns_correct_html
(lists.tests.HomePageTest.test_home_page_returns_correct_html)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/2.png)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "...goat-book/lists/tests.py", line 7, in test_home_page_returns_correct_html
    response = self.client.get("/")  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/3.png)
               ^^^^^^^^^^^^^^^^^^^^
[...]
  File "...goat-book/lists/views.py", line 4, in home_page
    return render(request, "home.xhtml")  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/4.png)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../django/shortcuts.py", line 24, in render
    content = loader.render_to_string(template_name, context, request, using=using)
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../django/template/loader.py", line 61, in render_to_string
    template = get_template(template_name, using=using)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../django/template/loader.py", line 19, in get_template
    raise TemplateDoesNotExist(template_name, chain=chain)
django.template.exceptions.TemplateDoesNotExist: home.xhtml  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png)

----------------------------------------------------------------------
Ran 1 test in 0.074s

又一次分析回溯的机会:

1

我们从错误开始:它找不到模板。

2

然后我们再次确认哪个测试失败了:果然,是我们对视图 HTML 的测试。

3

然后我们找到在测试中导致失败的行:当我们请求根 URL(“/”)时。

4

最后,我们寻找导致失败的自己应用代码的部分:就是当我们尝试调用render时。

那么为什么 Django 找不到模板呢?它就在应该在的地方,即lists/templates文件夹中。

问题在于我们尚未正式向 Django 注册我们的 lists 应用程序。不幸的是,只运行startapp命令并在项目文件夹中有明显的应用程序并不够。您必须告诉 Django 您确实是这样想的,并将其添加到settings.py中。加上腰带和裤子。打开它并查找一个名为INSTALLED_APPS的变量,我们将向其中添加lists

superlists/settings.py(ch04l004)

# Application definition

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "lists",
]

你可以看到默认已经有很多应用程序了。我们只需将我们的应用程序添加到列表底部即可。不要忘记最后的逗号—​它可能不是必需的,但有一天当你忘记它时,Python 会连接两行不同行的字符串…​

现在我们可以尝试再次运行测试:

$ python manage.py test
[...]
OK

我们对代码的重构现在已经完成,而且测试表明我们对行为感到满意。现在我们可以修改测试,使其不再测试常量;相反,它们应该只检查我们是否渲染了正确的模板。

检查模板渲染

Django 测试客户端有一个方法,assertTemplateUsed,可以做我们想要的事情:

lists/tests.py(ch04l005)

def test_home_page_returns_correct_html(self):
    response = self.client.get("/")
    self.assertContains(response, "<title>To-Do lists</title>")  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png)
    self.assertContains(response, "<html>")
    self.assertContains(response, "</html>")
    self.assertTemplateUsed(response, "home.xhtml")  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/2.png)

1

我们现在先保留旧的测试,只是为了确保一切都按照我们的想法工作。

2

.assertTemplateUsed让我们检查用于渲染响应的模板(注:它仅适用于测试客户端检索到的响应)。

而且那个测试仍然通过:

Ran 1 tests in 0.016s

OK

因为我总是对我没有见过失败的测试持怀疑态度,所以让我们故意破坏它一下:

lists/tests.py (ch04l006)

self.assertTemplateUsed(response, "wrong.xhtml")

这样我们还会了解它的错误消息是什么样的:

AssertionError: False is not true : Template 'wrong.xhtml' was not a template
used to render the response. Actual template(s) used: home.xhtml

这非常有帮助!让我们把断言改回正确的内容。顺便说一下,我们可以删除旧的断言,并给测试方法一个更具体的名称:

lists/tests.py (ch04l007)

from django.test import TestCase

class HomePageTest(TestCase):
    def test_uses_home_template(self):
        response = self.client.get("/")
        self.assertTemplateUsed(response, "home.xhtml")

但主要的观点是,我们不是测试常量,而是测试我们的实现。太好了!

关于重构

那只是重构的一个绝对微不足道的例子。但正如肯特·贝克在《测试驱动开发:通过示例学习》中所说,“我推荐你真的要这样工作吗?不,我推荐你能够这样工作”。

实际上,当我写这篇文章时,我的第一反应是立即进行测试优先——直接使用assertTemplateUsed函数;删除三个多余的断言,只留下一个检查内容是否与预期渲染一致的断言;然后进行代码更改。但请注意,这实际上会给我留下破坏事物的空间:我本可以将模板定义为包含任何任意字符串,而不仅仅是具有正确的<html><title>标签的字符串。

提示

在重构时,要么修改代码,要么修改测试,但不能同时进行。

总是有一种倾向,跳过几个步骤,一边重构一边进行一些行为调整,但很快你就会对半打不同的文件进行更改,完全失去自己的方向,并且什么都不再起作用。如果你不想像重构猫(图 4-2)那样结束,请坚持小步骤;完全将重构和功能更改分开。

一只冒险的猫,试图通过重构摆脱滑溜的浴缸

图 4-2. 重构猫——确保查看完整的动画 GIF(来源:4GIFs.com)
注意

在本书的过程中,我们还会再次遇到“重构猫”,作为我们过度沉迷于想要一次性改变太多事物时的一个例子。把它想象成测试山羊的小卡通恶魔对手,突然跳到你的另一只肩膀上,给出了不良建议。

在任何重构之后进行提交是个好主意:

$ git status # see tests.py, views.py, settings.py, + new templates folder
$ git add .  # will also add the untracked templates folder
$ git diff --staged # review the changes we're about to commit
$ git commit -m "Refactor home page view to use a template"

我们前页的一点小进展

与此同时,我们的功能测试仍然失败。现在让我们进行实际的代码更改,使其通过。因为我们的 HTML 现在在模板中,所以可以随意进行更改,而不需要编写额外的单元测试。

注意

这是 FT 和单元测试之间的另一个区别;由于 FT 使用真实的网络浏览器,我们将它们用作测试 UI 及其实现的 HTML 的主要工具。

因此,想要一个<h1>

lists/templates/home.xhtml(ch04l008)

<html>
  <head>
    <title>To-Do lists</title>
  </head>
  <body>
    <h1>Your To-Do list</h1>
  </body>
</html>

看看我们的功能测试是否稍微满意:

selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_new_item"]

好的,让我们添加一个带有该 ID 的输入:

lists/templates/home.xhtml(ch04l009)

  [...]
  <body>
    <h1>Your To-Do list</h1>
    <input id="id_new_item" />
  </body>
</html>

现在 FT 说什么?

AssertionError: '' != 'Enter a to-do item'

我们添加我们的占位文本……

lists/templates/home.xhtml(ch04l010)

    <input id="id_new_item" placeholder="Enter a to-do item" />

这给出了:

selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_list_table"]

因此,我们可以继续将表格放在页面上。在这个阶段,它将是空的:

lists/templates/home.xhtml(ch04l011)

    <input id="id_new_item" placeholder="Enter a to-do item" />
    <table id="id_list_table">
    </table>
  </body>

FT 怎么看?

  File "...goat-book/functional_tests.py", line 40, in
test_can_start_a_todo_list
    self.assertTrue(any(row.text == "1: Buy peacock feathers" for row in rows))
AssertionError: False is not true

有点神秘!我们可以使用行号来跟踪它,原来是any()函数,我之前为此感到很自豪——或者更精确地说,是assertTrue,它没有非常明确的失败消息。在unittest中,我们可以将自定义错误消息作为参数传递给大多数assertX方法:

functional_tests.py(ch04l012)

    self.assertTrue(
        any(row.text == "1: Buy peacock feathers" for row in rows),
        "New to-do item did not appear in table",
    )

如果您再次运行 FT,您应该会看到我们的帮助信息:

AssertionError: False is not true : New to-do item did not appear in table

但是现在,为了使其通过,我们需要实际处理用户的表单提交。这是下一章的主题。

现在让我们进行一次提交:

$ git diff
$ git commit -am "Front page HTML now generated from a template"

多亏了一点重构,我们已经设置好视图以渲染模板,停止了测试常量,并且现在很好地开始处理用户输入。

小结:TDD 过程

我们现在已经在实践中看到了 TDD 过程的所有主要方面:

  • 功能测试

  • 单元测试

  • 单元测试/代码循环

  • 重构

现在是时候做一点小结了,也许甚至是一些流程图(原谅我,我在管理顾问的岁月里荒废了。好的一面是,这些流程图将包含递归!)

整个 TDD 过程是什么样子?

  • 我们编写一个测试。

  • 我们运行测试,看到它失败。

  • 我们编写一些最小的代码,使其进展一点。

  • 我们重新运行测试,并重复直到通过(单元测试/代码循环)

  • 然后,我们寻找重构我们的代码的机会,利用我们的测试来确保不会出现任何问题。

  • 并从头再开始!

参见图 4-3。

一个流程图,其中包含用于测试、编码和重构的框,带有标签显示何时向前或向后移动

图 4-3。TDD 过程作为流程图,包括单元测试/代码循环

非常普遍地使用三个词Red, Green, Refactor来描述这个过程。见图 4-4。

Red, Green and Refactor 作为圆圈中的三个节点,箭头在其间流动。

图 4-4。红色、绿色、重构
  • 我们编写一个测试,并看到它失败(“Red”)。

  • 我们在代码和测试之间循环,直到测试通过:“Green”。

  • 然后,我们寻找重构的机会。

  • 根据需要重复!

双重循环 TDD

但是当我们既有功能测试 又有 单元测试时,这又如何应用呢?嗯,你可以将功能测试看作是驱动同一循环的更高层版本,需要一个内部的红/绿/重构循环,以将功能测试从红色变为绿色;参见 图 4-5。

一个内部红/绿/重构循环被外部功能测试的红/绿环绕

图 4-5. 双循环 TDD:内部和外部循环

当出现新的特性或业务需求时,我们编写一个新的(失败的)功能测试来捕获需求的高级视图。它可能不涵盖每一个边界情况,但应足以让我们放心事情在运行。

要让功能测试通过,我们接着进入更低层级的单元测试循环,组装所需的所有移动部件,为所有边界情况添加测试。每当我们在单元测试层面达到绿灯并进行重构时,我们可以回到功能测试层面,引导我们进行下一步工作。一旦两个层次都通过测试,我们可以进行额外的重构或处理边界情况。

我们将在接下来的章节中更详细地探讨这个工作流程的各个部分。

¹ 你也可以只使用字符串 "\n",但 Keys 还让你发送像 Ctrl 这样的特殊键,所以我觉得有必要展示一下它。

² Python 确实 非常有趣,但如果你认为我在这里有点自鸣得意,我也不怪你!事实上,我希望我能意识到这种自满感,并把它看作是我过于聪明的一个警示标志。在下一章中,你会看到我受到了惩罚。

³ 有些人喜欢使用另一个以应用程序命名的子文件夹(即 lists/templates/lists),然后将模板命名为 lists/home.xhtml。这被称为“模板命名空间”。我觉得对于这个小项目来说,这有点复杂了,但在大型项目上可能是值得的。在 Django 教程 中有更多内容。

第五章:保存用户输入:测试数据库

到目前为止,我们已经成功返回了一个包含输入框的静态 HTML 页面。接下来,我们想要获取用户在输入框中输入的文本,并将其发送到服务器,以便我们稍后能够保存并显示给她。

第一次开始为这一章写代码时,我立即想要跳到我认为正确的设计:为列表和列表项创建多个数据库表,一堆不同的 URL 用于添加新的列表和项目,三个新的视图函数,以及大约半打新的单元测试。但我停了下来。虽然我相当确信自己足够聪明可以一次性解决所有这些问题,但 TDD 的要点是允许你在需要时一次只做一件事。所以我决定故意近视,在任何给定的时刻做必要的事情,以使功能测试有所进展。

这将是一个演示如何支持增量、迭代式开发风格的 TDD 的例子——可能不是最快的路径,但你最终会到达¹。这有一个很好的附带好处,它允许我逐步引入新概念,如模型、处理 POST 请求、Django 模板标签等,逐步引入,而不是一次性抛给你。

这些都没有说你不应该试图提前思考和聪明。在下一章中,我们将使用更多的设计和前期思考,并展示它如何与 TDD 结合。但现在让我们继续盲目前行,只做测试告诉我们要做的事情。

将我们的表单连接到发送 POST 请求

在上一章的结束时,测试告诉我们我们无法保存用户的输入:

  File "...goat-book/functional_tests.py", line 40, in
test_can_start_a_todo_list
[...]
AssertionError: False is not true : New to-do item did not appear in table

为了将其发送到服务器,目前我们将使用标准的 HTML POST 请求。有点无聊,但也简单易行——我们可以在书中后面使用各种性感的 HTML5 和 JavaScript。

要使我们的浏览器发送 POST 请求,我们需要做两件事:

  1. <input> 元素添加一个 name= 属性。

  2. method="POST" 将其包装在 <form> 标签内²。

让我们调整我们的模板在lists/templates/home.xhtml

列表/模板/home.xhtml(ch05l001)

    <h1>Your To-Do list</h1>
    <form method="POST">
      <input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />
    </form>
    <table id="id_list_table">

现在,运行我们的功能测试会出现一个稍微神秘的意外错误:

$ python functional_tests.py
[...]
Traceback (most recent call last):
  File "...goat-book/functional_tests.py", line 38, in
test_can_start_a_todo_list
    table = self.browser.find_element(By.ID, "id_list_table")
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_list_table"]

天哪,我们现在在提交表单后,但在能够进行断言之前,正在失败两行之前。 Selenium 似乎无法找到我们的列表表格。为什么会这样?让我们再看看我们的代码:

functional_tests.py

        # When she hits enter, the page updates, and now the page lists
        # "1: Buy peacock feathers" as an item in a to-do list table
        inputbox.send_keys(Keys.ENTER)
        time.sleep(1)

        table = self.browser.find_element(By.ID, "id_list_table")  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png)
        rows = table.find_elements(By.TAG_NAME, "tr")
        self.assertTrue(
            any(row.text == "1: Buy peacock feathers" for row in rows),
            "New to-do item did not appear in table",
        )

1

我们的测试意外地在这一行失败。我们如何找出发生了什么?

调试功能测试

当功能测试因意外失败而失败时,我们可以采取几种方法来调试它:

  • 添加 print 语句,例如显示当前页面文本是什么。

  • 改进错误消息以显示有关当前状态的更多信息。

  • 手动访问网站自己。

  • 使用 time.sleep 在执行过程中暂停测试,以便您可以检查发生了什么事情。³

在本书的过程中,我们会详细查看所有这些内容,但 time.sleep 选项是在 FT 中出现这种错误时首先想到的选项。现在让我们试一试。

方便地,我们在错误发生之前已经有了一个延迟;让我们稍微延长一下:

functional_tests.py(ch05l003)

    # When she hits enter, the page updates, and now the page lists
    # "1: Buy peacock feathers" as an item in a to-do list table
    inputbox.send_keys(Keys.ENTER)
    time.sleep(10)

    table = self.browser.find_element(By.ID, "id_list_table")

根据 Selenium 在您的 PC 上运行的速度,您可能已经一瞥过这个,但当我们再次运行功能测试时,我们有时间看看发生了什么:您应该看到一个看起来像 Figure 5-1 的页面,其中包含大量的 Django 调试信息。

显示 CSRF 错误的 Django DEBUG 页面

图 5-1. 显示 CSRF 错误的 Django DEBUG 页面

Django 的 CSRF 保护涉及将一个小的自动生成的唯一令牌放入每个生成的表单中,以便能够验证 POST 请求确实来自服务器生成的表单。到目前为止,我们的模板一直是纯 HTML,在这一步中,我们首次使用了 Django 的模板魔法。为了添加 CSRF 令牌,我们使用了一个 模板标记,其具有花括号/百分号的语法,{% ... %}——这对于是世界上最烦人的两个键触摸打字组合而闻名:

lists/templates/home.xhtml(ch05l004)

  <form method="POST">
    <input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />
    {% csrf_token %}
  </form>

在渲染过程中,Django 将使用模板标记替换一个包含 CSRF 令牌的 <input type="hidden">。重新运行功能测试现在将使我们回到之前的(预期的)失败状态:

  File "...goat-book/functional_tests.py", line 40, in
test_can_start_a_todo_list
[...]
AssertionError: False is not true : New to-do item did not appear in table

由于我们长时间的 time.sleep 仍然存在,测试将暂停在最终屏幕上,显示新项目文本在表单提交后消失,并且页面刷新显示空表单。这是因为我们还没有配置服务器来处理 POST 请求——它只是忽略它并显示正常的首页。

现在我们可以把我们正常的短 time.sleep 放回去了:

functional_tests.py(ch05l005)

    # "1: Buy peacock feathers" as an item in a to-do list table
    inputbox.send_keys(Keys.ENTER)
    time.sleep(1)

    table = self.browser.find_element(By.ID, "id_list_table")

处理服务器上的 POST 请求

因为我们在表单中没有指定 action= 属性,它默认会提交回渲染时的同一 URL(即 /),由我们的 home_page 函数处理。目前这样也可以,让我们修改视图以处理 POST 请求。

这意味着需要为 home_page 视图编写一个新的单元测试。打开 lists/tests.py,并且在 HomePageTest 中添加一个新的方法:

lists/tests.py(ch05l006)

class HomePageTest(TestCase):
    def test_uses_home_template(self):
        response = self.client.get("/")
        self.assertTemplateUsed(response, "home.xhtml")

    def test_can_save_a_POST_request(self):
        response = self.client.post("/", data={"item_text": "A new list item"})
        self.assertContains(response, "A new list item")

要执行 POST 请求,我们调用 self.client.post,正如您所见,它接受一个 data 参数,其中包含我们要发送的表单数据。然后我们检查来自我们的 POST 请求的文本最终出现在呈现的 HTML 中。这给了我们预期的失败:

$ python manage.py test
[...]
AssertionError: False is not true : Couldn't find 'A new list item' in response

有点夸张的 TDD 风格,我们可以单一地做“可能有效的最简单的事情”来处理这个测试失败,也就是为 POST 请求添加一个 if 和一个新的代码路径,带有一个故意愚蠢的返回值:

lists/views.py(ch05l007)

from django.http import HttpResponse
from django.shortcuts import render

def home_page(request):
    if request.method == "POST":
        return HttpResponse("You submitted: " + request.POST["item_text"])
    return render(request, "home.xhtml")

好的,这样可以让我们的单元测试通过,但实际上这并不是我们想要的。⁴

我们真正想做的是将 POST 提交添加到主页模板中的待办事项表中。

将 Python 变量传递到模板中进行渲染

我们已经有了一点线索,现在是时候开始了解 Django 模板语法的真正威力了,这是将变量从 Python 视图代码传递到 HTML 模板中的关键。

让我们首先看看模板语法如何让我们在模板中包含一个 Python 对象。符号是{{ ... }},它将对象显示为字符串:

lists/templates/home.xhtml (ch05l008)

<body>
  <h1>Your To-Do list</h1>
  <form method="POST">
    <input name="item_text" id="id_new_item" placeholder="Enter a to-do item" /> {% csrf_token %} </form>
  <table id="id_list_table">
    <tr><td>{{ new_item_text }}</td></tr>  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png)
  </table>
</body>

1

这是我们的模板变量。new_item_text将是我们在模板中显示的用户输入的变量名。

让我们调整我们的单元测试,以检查我们是否仍在使用模板:

lists/tests.py (ch05l009)

    def test_can_save_a_POST_request(self):
        response = self.client.post("/", data={"item_text": "A new list item"})
        self.assertContains(response, "A new list item")
        self.assertTemplateUsed(response, "home.xhtml")

而且这将如预期地失败:

AssertionError: No templates used to render the response

很好,我们故意愚弄的返回值现在不再蒙混我们的测试,所以我们可以重新编写我们的视图,并告诉它将 POST 参数传递给模板。render函数的第三个参数是一个字典,它将模板变量名映射到它们的值。

理论上,我们可以将其用于 POST 情况以及默认的 GET 情况,所以让我们删除if request.method == "POST"并简化我们的视图:

lists/views.py (ch05l010)

def home_page(request):
    return render(
        request,
        "home.xhtml",
        {"new_item_text": request.POST["item_text"]},
    )

测试认为什么?

ERROR: test_uses_home_template
(lists.tests.HomePageTest.test_uses_home_template)

[...]
    {"new_item_text": request.POST["item_text"]},
                      ~~~~~~~~~~~~^^^^^^^^^^^^^
[...]
django.utils.datastructures.MultiValueDictKeyError: 'item_text'

一个意外的失败

糟糕,一个意外的失败

如果你记得阅读跟踪 back 的规则,你会发现实际上是不同的测试失败了。我们确实让我们正在处理的实际测试通过了,但单元测试却发现了一个意外的后果,即一个回归:我们打破了没有 POST 请求路径的代码。

这就是进行测试的全部意义。是的,也许我们可以预料到会发生这种情况,但想象一下,如果我们当时心情不好或者注意力不集中:我们的测试刚刚帮我们避免了意外地破坏我们的应用程序,并且因为我们使用了 TDD,我们立即发现了问题。我们不需要等待质量保证团队的检查,也不需要切换到网页浏览器并手动点击我们的网站,我们可以立即着手修复它。具体方法如下:

lists/views.py (ch05l011)

def home_page(request):
    return render(
        request,
        "home.xhtml",
        {"new_item_text": request.POST.get("item_text", "")},
    )

我们使用dict.get提供一个默认值,用于处理普通的 GET 请求时 POST 字典为空的情况。

现在应该通过单元测试了。让我们看看功能测试的反应:

AssertionError: False is not true : New to-do item did not appear in table
小贴士

如果你的功能测试在这一点或本章的任何时候显示不同的错误,并且抱怨StaleElementReferenceException,可能需要增加time.sleep显式等待时间——尝试 2 或 3 秒而不是 1;然后继续阅读下一章,找到一个更加稳健的解决方案。

嗯,不是一个非常有帮助的错误。让我们使用另一种我们的功能测试调试技术:改进错误消息。这可能是最有建设性的技术,因为这些改进的错误消息会继续帮助调试任何未来的错误:

functional_tests.py (ch05l012)

self.assertTrue(
    any(row.text == "1: Buy peacock feathers" for row in rows),
    f"New to-do item did not appear in table. Contents were:\n{table.text}",
)

这样我们就得到了一个更有帮助的错误消息:

AssertionError: False is not true : New to-do item did not appear in table.
Contents were:
Buy peacock feathers

实际上,你知道更好的是什么吗?让那个断言少聪明点!正如你可能从第四章中记得的那样,我为使用any()函数感到非常满意,但我的早期版本读者之一(谢谢,Jason!)建议了一个更简单的实现。我们可以用单个assertIn替换所有四行的assertTrue

functional_tests.py (ch05l013)

    self.assertIn("1: Buy peacock feathers", [row.text for row in rows])

好多了。每当你觉得自己很聪明时,你应该非常担心,因为你可能只是过于复杂化

现在我们免费得到了错误消息:

    self.assertIn("1: Buy peacock feathers", [row.text for row in rows])
AssertionError: '1: Buy peacock feathers' not found in ['Buy peacock feathers']

让我适当地反省一下。

提示

如果相反,你的功能测试似乎在说表格为空(“not found in ['']”),检查你的<input>标签——它是否有正确的name="item_text"属性?它是否有method="POST"?如果没有,用户的输入将不会放在request.POST的正确位置。

关键是功能测试希望我们以“1:”开头列举列表项。

最快的方法是通过另一个快速的“作弊”更改模板来使其通过:

lists/templates/home.xhtml (ch05l014)

    <tr><td>1: {{ new_item_text }}</td></tr>

现在我们来到了self.fail('Finish the test!')。如果去掉这个并完成我们的功能测试,添加检查将第二个项目添加到表格中(复制粘贴是我们的朋友),我们开始看到我们的第一次尝试解决方案真的行不通:

functional_tests.py (ch05l015)

    # There is still a text box inviting her to add another item.
    # She enters "Use peacock feathers to make a fly"
    # (Edith is very methodical)
    inputbox = self.browser.find_element(By.ID, "id_new_item")
    inputbox.send_keys("Use peacock feathers to make a fly")
    inputbox.send_keys(Keys.ENTER)
    time.sleep(1)

    # The page updates again, and now shows both items on her list
    table = self.browser.find_element(By.ID, "id_list_table")
    rows = table.find_elements(By.TAG_NAME, "tr")
    self.assertIn(
        "1: Buy peacock feathers",
        [row.text for row in rows],
    )
    self.assertIn(
        "2: Use peacock feathers to make a fly",
        [row.text for row in rows],
    )

    # Satisfied, she goes back to sleep

确实,功能测试返回了一个错误:

AssertionError: '1: Buy peacock feathers' not found in ['1: Use peacock
feathers to make a fly']

三次重复就重构

但在我们继续之前——在这个功能测试中有一个糟糕的代码异味⁵。我们有三个几乎相同的代码块,检查列表表中的新项目。当我们想要应用 DRY 原则时,我喜欢遵循“三次重复就重构”的口头禅。你可以复制粘贴代码一次,也许试图消除它带来的重复性有点过早,但一旦出现三次,就是时候整理了。

让我们从目前为止的内容开始提交。尽管我们知道我们的网站有一个主要缺陷——它只能处理一个列表项——但它仍然比以前进步了。我们可能需要重写所有内容,也可能不需要,但规则是在进行任何重构之前,始终先提交:

$ git diff
# should show changes to functional_tests.py, home.xhtml,
# tests.py and views.py
$ git commit -a
提示

在进行重构之前,一定要进行提交。

进入我们的功能测试重构:让我们使用一个辅助方法——记住,只有以test_开头的方法才会作为测试运行,所以你可以使用其他方法来达到你的目的:

functional_tests.py (ch05l016)

    def tearDown(self):
        self.browser.quit()

    def check_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])

    def test_can_start_a_todo_list(self):
        [...]

我喜欢把辅助方法放在类的顶部,tearDown和第一个测试之间。让我们在功能测试中使用它:

functional_tests.py(ch05l017)

    # When she hits enter, the page updates, and now the page lists
    # "1: Buy peacock feathers" as an item in a to-do list table
    inputbox.send_keys(Keys.ENTER)
    time.sleep(1)
    self.check_for_row_in_list_table("1: Buy peacock feathers")

    # There is still a text box inviting her to add another item.
    # She enters "Use peacock feathers to make a fly"
    # (Edith is very methodical)
    inputbox = self.browser.find_element(By.ID, "id_new_item")
    inputbox.send_keys("Use peacock feathers to make a fly")
    inputbox.send_keys(Keys.ENTER)
    time.sleep(1)

    # The page updates again, and now shows both items on her list
    self.check_for_row_in_list_table("1: Buy peacock feathers")
    self.check_for_row_in_list_table("2: Use peacock feathers to make a fly")

    # Satisfied, she goes back to sleep

我们再次运行 FT 来检查它是否仍然以相同的方式运行...​

AssertionError: '1: Buy peacock feathers' not found in ['1: Use peacock
feathers to make a fly']

很好。现在我们可以将 FT 重构为其自己的小型原子更改:

$ git diff # check the changes to functional_tests.py
$ git commit -a

回到工作。如果我们要处理多个列表项,我们将需要某种持久化方法,而数据库在这一领域中是一个可靠的解决方案。

Django ORM 和我们的第一个模型

对象关系映射器(ORM)是一个用于处理数据库中表、行和列数据的抽象层。它让我们使用熟悉的面向对象的隐喻来处理数据库,这些隐喻与代码很好地配合。类映射到数据库表,属性映射到列,类的一个实例表示数据库中的一行数据。

Django 提供了一个出色的 ORM,编写使用它的单元测试实际上是学习它的一个绝佳方法,因为它通过指定我们希望它如何工作来练习代码。

让我们在lists/tests.py中创建一个新类:

lists/tests.py(ch05l018)

from django.test import TestCase
from lists.models import Item

class HomePageTest(TestCase):
    [...]

class ItemModelTest(TestCase):
    def test_saving_and_retrieving_items(self):
        first_item = Item()
        first_item.text = "The first (ever) list item"
        first_item.save()

        second_item = Item()
        second_item.text = "Item the second"
        second_item.save()

        saved_items = Item.objects.all()
        self.assertEqual(saved_items.count(), 2)

        first_saved_item = saved_items[0]
        second_saved_item = saved_items[1]
        self.assertEqual(first_saved_item.text, "The first (ever) list item")
        self.assertEqual(second_saved_item.text, "Item the second")

你可以看到,在数据库中创建新记录相对来说是一件相当简单的事情,只需创建一个对象,分配一些属性,并调用.save()函数。Django 还为我们提供了一个通过类属性.objects查询数据库的 API,我们使用最简单的查询.all(),它检索该表的所有记录。结果作为一个称为QuerySet的类似列表的对象返回,我们可以从中提取单个对象,并调用更多函数,如.count()。然后,我们检查保存到数据库中的对象,以确认是否保存了正确的信息。

Django 的 ORM 具有许多其他有用且直观的功能;现在可能是浏览Django 教程的好时机,该教程对这些功能进行了很好的介绍。

注意

我已经以非常冗长的风格编写了这个单元测试,作为介绍 Django ORM 的一种方式。我不建议在“现实生活”中像这样编写模型测试,因为这是在测试框架,而不是测试我们自己的代码。我们实际上将重写这个测试,使其在[待定链接](具体来说,在[待定链接])上更加简洁。

让我们尝试运行单元测试。这里又来了另一个单元测试/代码循环:

ImportError: cannot import name 'Item' from 'lists.models'

非常好,让我们从lists/models.py中给它一些要导入的东西。我们感到很有信心,所以我们将跳过Item = None步骤,直接创建一个类:

lists/models.py(ch05l019)

from django.db import models

# Create your models here.
class Item:
    pass

这使我们的测试达到了如下程度:

[...]
  File "...goat-book/lists/tests.py", line 20, in
test_saving_and_retrieving_items
    first_item.save()
    ^^^^^^^^^^^^^^^
AttributeError: 'Item' object has no attribute 'save'

为了给我们的Item类添加一个save方法,并使其成为一个真正的 Django 模型,我们让它继承自Model类:

lists/models.py(ch05l020)

from django.db import models

class Item(models.Model):
    pass

我们的第一个数据库迁移

接下来发生的事情是一个非常长的回溯,简而言之,与数据库有问题:

django.db.utils.OperationalError: no such table: lists_item

在 Django 中,ORM 的工作是对数据库表进行建模、读写,但有一个第二系统负责创建数据库中的表,称为“迁移”。它的工作是允许你根据对 models.py 文件的更改,添加、删除和修改表和列。

一种思考的方式是把它看作是数据库的版本控制系统。正如我们稍后将看到的,当我们需要升级部署在实时服务器上的数据库时,它特别有用。

目前我们只需要知道如何建立我们的第一个数据库迁移,我们使用 makemigrations 命令来做到这一点:⁶

$ python manage.py makemigrations
Migrations for 'lists':
  lists/migrations/0001_initial.py
    - Create model Item
$ ls lists/migrations
0001_initial.py  __init__.py  __pycache__

如果你感兴趣,你可以去查看迁移文件,你会看到它是对我们在 models.py 中增加内容的表示。

与此同时,我们应该发现我们的测试可以进一步进行。

测试实际上进展得相当顺利

测试实际上进展得相当顺利:

$ python manage.py test
[...]
    self.assertEqual(first_saved_item.text, "The first (ever) list item")
                     ^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'Item' object has no attribute 'text'

这比上次失败晚了整整八行——我们已经完全保存了两个 Item,并检查它们是否保存在数据库中,但 Django 似乎并没有“记住”.text 属性。

如果你是 Python 新手,也许会对我们被允许分配 .text 属性感到惊讶。在像 Java 这样的语言中,你可能会得到一个编译错误。Python 更加宽松。

models.Model 继承的类映射到数据库中的表。默认情况下,它们会得到一个自动生成的 id 属性,这将是数据库中的主键列⁷,但你必须显式定义任何其他列和属性;这是我们如何设置文本列的方式:

lists/models.py (ch05l022)

class Item(models.Model):
    text = models.TextField()

Django 还有许多其他字段类型,如 IntegerFieldCharFieldDateField 等等。我选择了 TextField 而不是 CharField,因为后者在这一点上需要长度限制,这似乎是任意的。你可以在 Django 教程文档 中了解更多关于字段类型的信息。

新字段意味着新迁移

运行测试会给我们带来另一个数据库错误:

django.db.utils.OperationalError: table lists_item has no column named text

这是因为我们向数据库添加了另一个新字段,这意味着我们需要创建另一个迁移。很高兴我们的测试告诉我们!

让我们试一下:

$ python manage.py makemigrations
It is impossible to add a non-nullable field 'text' to item without specifying
a default. This is because the database needs something to populate existing
rows.
Please select a fix:
 1) Provide a one-off default now (will be set on all existing rows with a null
value for this column)
 2) Quit and manually define a default value in models.py.
Select an option:2

啊。它不允许我们添加没有默认值的列。让我们选择选项 2,在 models.py 中设置一个默认值。我认为你会发现语法相当不难理解:

lists/models.py (ch05l023)

class Item(models.Model):
    text = models.TextField(default="")

现在迁移应该已经完成了:

$ python manage.py makemigrations
Migrations for 'lists':
  lists/migrations/0002_item_text.py
    - Add field text to item

因此,在 models.py 中新增两行,两个数据库迁移,因此,我们的模型对象上的 .text 属性现在被识别为特殊属性,因此它确实保存到数据库中,并且测试通过……

$ python manage.py test
[...]

Ran 3 tests in 0.010s
OK

让我们为我们的第一个模型进行提交!

$ git status # see tests.py, models.py, and 2 untracked migrations
$ git diff # review changes to tests.py and models.py
$ git add lists
$ git commit -m "Model for list Items and associated migration"

将 POST 保存到数据库

让我们调整我们首页的 POST 请求测试,并说我们希望视图将一个新项目保存到数据库中,而不仅仅是将其传递到其响应中。我们可以通过在现有测试test_can_save_a_POST_request中添加三行来实现:

lists/tests.py (ch05l025)

def test_can_save_a_POST_request(self):
    response = self.client.post("/", data={"item_text": "A new list item"})

    self.assertEqual(Item.objects.count(), 1)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png)
    new_item = Item.objects.first()  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/2.png)
    self.assertEqual(new_item.text, "A new list item")  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/3.png)

    self.assertContains(response, "A new list item")
    self.assertTemplateUsed(response, "home.xhtml")

1

我们检查数据库中是否保存了一个新的Itemobjects.count()objects.all().count()的简写。

2

objects.first() 相当于 objects.all()[0]

3

我们检查项目的文本是否正确。

这个测试变得有点冗长了。它似乎在测试很多不同的东西。这是另一个代码异味——一个冗长的单元测试要么需要拆分成两个,要么可能表明你正在测试的东西太复杂了。让我们把它加到我们自己的待办事项清单上,或许在一张废纸上:

把它写在这样一个废纸上让我们放心不会忘记,所以我们可以舒心地回到我们正在处理的事情上。我们重新运行测试,看到了一个预期的失败:

    self.assertEqual(Item.objects.count(), 1)
AssertionError: 0 != 1

让我们调整我们的视图:

lists/views.py (ch05l026)

from django.shortcuts import render
from lists.models import Item

def home_page(request):
    item = Item()
    item.text = request.POST.get("item_text", "")
    item.save()

    return render(
        request,
        "home.xhtml",
        {"new_item_text": request.POST.get("item_text", "")},
    )

我编写了一个非常天真的解决方案,你可能已经发现了一个非常明显的问题,那就是我们将会在每次请求首页时保存空项目。让我们把它加到我们稍后要修复的事情清单上。你知道,还有一个非常显而易见的事实,我们目前根本没有办法为不同的人保存不同的列表。我们暂时忽略这一点。

记住,我并不是说在“现实生活”中你应该总是忽视这样的显而易见的问题。每当我们提前发现问题时,就需要做出判断,是停下手头的工作重新开始,还是留到以后再解决。有时候完成手头的工作仍然是值得的,有时候问题可能太严重需要停下来重新思考。

让我们看看单元测试进行得如何……

Ran 3 tests in 0.010s

OK

它们通过了!很好。让我们来看看我们的废纸。我已经加了几件我们关注的其他事情:

让我们从第一个草稿项开始:每次请求都不要保存空项目。我们可以向现有测试添加一个断言,但最好一次只测试一个单元,所以我们添加一个新的测试:

lists/tests.py (ch05l027)

class HomePageTest(TestCase):
    def test_uses_home_template(self):
        [...]

    def test_can_save_a_POST_request(self):
        [...]

    def test_only_saves_items_when_necessary(self):
        self.client.get("/")
        self.assertEqual(Item.objects.count(), 0)

这导致了 1 != 0 的失败。让我们通过重新加上 if request.method 检查并将 Item 创建放在其中来修复它:

lists/views.py (ch05l028)

def home_page(request):
    if request.method == "POST":
        item = Item()
        item.text = request.POST["item_text"]
        item.save()

    return render(
        request,
        "home.xhtml",
        {"new_item_text": request.POST.get("item_text", "")},
    )

这样测试通过:

Ran 4 tests in 0.010s

OK

在 POST 请求后重定向

但是,糟糕的是,这些重复的request.POST访问让我感到非常不高兴。幸运的是,我们即将有机会修复它。视图函数有两个作业:处理用户输入和返回适当的响应。我们已经处理了第一部分,即将用户输入保存到数据库,现在让我们来处理第二部分。

总是在 POST 之后重定向,他们说,所以我们来做吧。我们再次改变我们的单元测试,保存 POST 请求:不再期望响应包含项目,而是期望重定向回主页。

lists/tests.py (ch05l029)

    def test_can_save_a_POST_request(self):
        response = self.client.post("/", data={"item_text": "A new list item"})

        self.assertEqual(Item.objects.count(), 1)
        new_item = Item.objects.first()
        self.assertEqual(new_item.text, "A new list item")

        self.assertRedirects(response, "/")  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png)

    def test_only_saves_items_when_necessary(self):
        [...]

1

我们不再期望模板渲染的 HTML 内容作为响应,所以我们不再使用assertContains调用。相反,我们使用 Django 的assertRedirects辅助函数来检查我们是否返回了 HTTP 302 重定向,回到主页 URL。

这给我们带来了预期的失败:

AssertionError: 200 != 302 : Response didn't redirect as expected: Response
code was 200 (expected 302)

现在我们可以大幅整理我们的视图:

lists/views.py (ch05l030)

from django.shortcuts import redirect, render
from lists.models import Item

def home_page(request):
    if request.method == "POST":
        item = Item()
        item.text = request.POST["item_text"]
        item.save()
        return redirect("/")

    return render(
        request,
        "home.xhtml",
        {"new_item_text": request.POST.get("item_text", "")},
    )

现在测试应该通过了:

Ran 4 tests in 0.010s

OK

我们已经成功了,是时候进行一点重构了!

让我们来看看views.py,看看可能存在哪些改进的机会:

lists/views.py

def home_page(request):
    if request.method == "POST":
        item = Item()  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png)
        item.text = request.POST["item_text"]  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png)
        item.save()  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png)
        return redirect("/")

    return render(
        request,
        "home.xhtml",
        {"new_item_text": request.POST.get("item_text", "")},  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/2.png)
    )

1

有一种更快的方法可以用.objects.create()完成这三行代码

2

现在这行看起来不太对劲,实际上根本行不通。让我们在草稿纸上记下注意事项,解决将列表项传递给模板的问题。这与“显示多个项目”密切相关,因此我们将其放在那个之前:

这是使用 Django 提供的.objects.create()辅助方法重构后的views.py版本,用于一行代码创建对象:

lists/views.py (ch05l031)

def home_page(request):
    if request.method == "POST":
        Item.objects.create(text=request.POST["item_text"])
        return redirect("/")

    return render(
        request,
        "home.xhtml",
        {"new_item_text": request.POST.get("item_text", "")},
    )

更好的单元测试实践:每个测试应该只测试一件事情

让我们解决“POST 测试太长”的代码异味。

良好的单元测试实践说,每个测试应该只测试一件事情。原因是这样可以更容易地追踪错误。如果一个测试在早期断言失败,你不知道后续断言的状态。正如我们将在下一章中看到的那样,如果我们意外地破坏了这个视图,我们想知道是对象保存有问题,还是响应类型有问题。

在你第一次编写完美的单元测试并单个断言的时候可能并不总是,但现在感觉是一个分离我们关注的好时机:

lists/tests.py (ch05l032)

    def test_can_save_a_POST_request(self):
        self.client.post("/", data={"item_text": "A new list item"})
        self.assertEqual(Item.objects.count(), 1)
        new_item = Item.objects.first()
        self.assertEqual(new_item.text, "A new list item")

    def test_redirects_after_POST(self):
        response = self.client.post("/", data={"item_text": "A new list item"})
        self.assertRedirects(response, "/")

现在应该看到五个测试通过而不是四个:

Ran 5 tests in 0.010s

OK

在模板中渲染项目

好多了!回到我们的待办事项清单:

将事项从列表中划掉几乎和看到测试通过一样令人满足!

第三和第四项是“简单”任务的最后一项。我们的视图现在对 POST 请求执行正确操作,将新列表项保存到数据库。现在我们希望 GET 请求加载当前存在的所有列表项,并将它们传递给模板进行渲染。让我们为此编写一个新的单元测试:

lists/tests.py (ch05l033)

class HomePageTest(TestCase):
    def test_uses_home_template(self):
        [...]

    def test_displays_all_list_items(self):
        Item.objects.create(text="itemey 1")
        Item.objects.create(text="itemey 2")
        response = self.client.get("/")
        self.assertContains(response, "itemey 1")
        self.assertContains(response, "itemey 2")

    def test_can_save_a_POST_request(self):
        [...]

预期的失败:

AssertionError: False is not true : Couldn't find 'itemey 1' in response

Django 模板语法有一个用于迭代列表的标签 {% for .. in .. %};我们可以像这样使用它:

lists/templates/home.xhtml (ch05l034)

<table id="id_list_table">
  {% for item in items %}
    <tr><td>1: {{ item.text }}</td></tr>
  {% endfor %}
</table>

这是模板系统的主要优势之一。现在模板将渲染多个 <tr> 行,每个变量 items 中的项目都会有一行。非常棒!在我们继续的过程中,我会介绍一些更多的 Django 模板魔法,但你最终会想要阅读其他部分的 Django 文档

只是改变模板并不能让我们的测试变绿;我们需要实际将项目传递给它,从我们的主页视图:

lists/views.py (ch05l035)

def home_page(request):
    if request.method == "POST":
        Item.objects.create(text=request.POST["item_text"])
        return redirect("/")

    items = Item.objects.all()
    return render(request, "home.xhtml", {"items": items})

这确实使单元测试通过了…事实的时刻来了,功能测试会通过吗?

$ python functional_tests.py
[...]
AssertionError: 'To-Do' not found in 'OperationalError at /'

糟糕,显然不行。让我们使用另一种功能测试调试技术,这是其中最直接的之一:手动访问网站!在你的网络浏览器中打开 http://localhost:8000,你会看到一个 Django 调试页面显示“no such table: lists_item”,如 Figure 5-2 中所示。

OperationalError at / no such table: lists_item

Figure 5-2. 另一个有帮助的调试消息

使用 migrate 创建我们的生产数据库

另一个来自 Django 的有用错误消息,基本上在抱怨我们没有正确设置数据库。你会问为什么在单元测试中一切运行正常?因为 Django 为单元测试创建了一个特殊的 test database;这是 Django 的 TestCase 所做的神奇之一。

要设置我们的“真实”数据库,我们需要显式创建它。SQLite 数据库只是磁盘上的一个文件,在 settings.py 中你会看到,Django 默认会将其放在名为 db.sqlite3 的文件中:

superlists/settings.py

[...]
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "db.sqlite3",
    }
}

我们告诉 Django 它需要创建数据库的所有内容,首先是通过 models.py,然后是在我们创建迁移文件时。要将其应用于创建真正的数据库,我们使用另一个 Django 瑞士军刀 manage.py 命令,migrate

$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, lists, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying auth.0012_alter_user_first_name_max_length... OK
  Applying lists.0001_initial... OK
  Applying lists.0002_item_text... OK
  Applying sessions.0001_initial... OK

现在我们可以刷新 localhost 上的页面,看到我们的错误消失了,并尝试再次运行功能测试:⁸

AssertionError: '2: Use peacock feathers to make a fly' not found in ['1: Buy
peacock feathers', '1: Use peacock feathers to make a fly']

差一点!我们只需要让我们的列表编号正确。另一个令人惊叹的 Django 模板标签,forloop.counter,在这里会有所帮助:

lists/templates/home.xhtml (ch05l036)

  {% for item in items %}
    <tr><td>{{ forloop.counter }}: {{ item.text }}</td></tr>
  {% endfor %}

如果你再试一次,你现在应该会看到功能测试已经到达终点:

$ python functional_tests.py
.
 ---------------------------------------------------------------------
Ran 1 test in 5.036s

OK

万岁!

但是,当它运行时,你可能会注意到有些问题,就像在 Figure 5-3 中看到的那样。

测试的最后运行留下了一些列表项

图 5-3. 测试的最后运行留下了一些列表项

哎呀。看起来之前的测试运行留下了一些东西在我们的数据库里。事实上,如果你再次运行测试,你会发现情况变得更糟:

1: Buy peacock feathers
2: Use peacock feathers to make a fly
3: Buy peacock feathers
4: Use peacock feathers to make a fly
5: Buy peacock feathers
6: Use peacock feathers to make a fly

噢,糟糕。我们快要成功了!我们需要一种自动化的方式来清理这些。暂时来说,如果你愿意,你可以手动删除数据库,并使用 migrate 命令重新创建(你需要先关闭 Django 服务器):

$ rm db.sqlite3
$ python manage.py migrate --noinput

然后(重新启动服务器后!)确保你的功能测试仍然通过。

除了我们功能测试中的小 bug 外,我们已经有了大致正常工作的代码。让我们进行一次提交。

首先执行 git statusgit diff 命令,你会看到 home.xhtmltests.pyviews.py 有变动。让我们将它们加入:

$ git add lists
$ git commit -m "Redirect after POST, and show all items in template"
小贴士

你可能会发现,为每一章节添加标记很有用,比如 git tag end-of-chapter-05

总结

我们在哪?我们的应用程序进展如何,我们学到了什么?

  • 我们已经设置好一个表单,用于通过 POST 方式添加新的列表项。

  • 我们在数据库中设置了一个简单的模型来保存列表项。

  • 我们已经学习了如何为测试数据库创建数据库迁移(自动应用)以及为实际数据库创建数据库迁移(手动应用)。

  • 我们使用了我们的第一对 Django 模板标签:{% csrf_token %}{% for ... endfor %} 循环。

  • 我们使用了两种不同的 FT 调试技术:time.sleep 和改进错误消息。

但是我们自己的待办列表上还有一些事项,比如让 FT 自己清理干净,以及更关键地,为多个列表添加支持。

我是说,我们可以以现在的状态发布这个网站,但人们可能会觉得奇怪,整个人类都必须共享一个待办事项列表。我想这可能会让人们停下来思考我们在地球飞船上是多么紧密相连,我们在这里共享着共同的命运,并且我们必须共同努力解决我们面临的全球问题。

但在实际应用中,这个网站可能并不是非常有用。

哎,算了。

¹ “Geepaw” Hill,另一位 TDD OGs 之一,提倡采用“更多更小的步骤(MMMSS)”,他在一系列博客文章中阐述了这一观点。本章中,我为了效果而过于短视,请不要效仿!但 Geepaw 认为,在现实世界中,当你将工作切分为微小的增量时,不仅最终能达到目标,而且能更快地提供商业价值。

² 你知道吗?其实不需要按钮来使表单提交。我记不得我是什么时候学到这个的了,但读者们提到这很不寻常,所以我想提醒你注意一下。

³ 另一种常见的调试测试的技术是使用breakpoint()来进入像pdb这样的调试器。这对于单元测试比功能测试更有用,因为在功能测试中,通常无法步进到实际的应用程序代码中去。个人而言,我只发现调试器在真正繁琐的算法中有用,而这本书中我们不会看到这种情况。

⁴ 但是我们确实学习了request.methodrequest.POST对吧?我知道这似乎有些过火,但是把事情分解成小的部分确实有很多优点,其中一个是你可以真正地一次只想(或者在这种情况下,学习)一件事情。

⁵ 如果你还不了解这个概念,那么“代码异味”是指一段代码中让你想要重写的东西。Jeff Atwood 在他的博客 Coding Horror 上有一篇合集。作为程序员,你积累的经验越多,你的嗅觉对代码异味的感知也就越加敏锐……

⁶ 如果你之前有一些 Django 的经验,也许你会想知道我们什么时候会运行“migrate”以及“makemigrations”?继续阅读吧;这将在本章稍后讨论。

⁷ 数据库表通常有一个特殊的列称为“主键”,它是表中每一行的唯一标识符。值得提醒一下关系数据库理论的一点小知识,如果你对这个概念或者它的用处不熟悉的话。我在搜索“数据库入门”的时候找到的前三篇文章,当时看来都相当不错。

⁸ 如果此时出现不同的错误,请尝试重新启动开发服务器——它可能对正在发生的数据库变更感到困惑。

第六章:改进功能测试:确保隔离和移除“巫术”睡眠

在我们深入解决全局列表问题之前,让我们先处理一些日常事务。在上一章的结尾,我们注意到不同的测试运行相互干扰,因此我们会解决这个问题。此外,我对代码中到处都是的time.sleep不太满意,它们似乎有些不科学,所以我们会用更可靠的方法替换它们。

这两个变化将使我们朝着测试“最佳实践”迈进,使我们的测试更加确定性和可靠。

在功能测试中确保测试隔离

我们在上一章结束时遇到了一个经典的测试问题:如何确保测试之间的隔离。我们的功能测试每次运行后都会在数据库中留下列表项,这会影响下次运行测试时的结果。

当我们运行 单元 测试时,Django 测试运行器会自动创建一个全新的测试数据库(与真实数据库分开),它可以在每个单独的测试运行之前安全地重置,然后在结束时丢弃。但我们的功能测试目前运行在“真实”的数据库 db.sqlite3 上。

解决这个问题的一种方法是自己“摸索”解决方案,并向 functional_tests.py 添加一些清理代码。setUptearDown方法非常适合这种情况。

但由于这是一个常见的问题,Django 提供了一个名为LiveServerTestCase的测试类来解决这个问题。它将自动创建一个测试数据库(就像在单元测试运行中一样),并启动一个开发服务器,供功能测试运行。尽管作为一个工具它有一些限制,我们稍后需要解决这些限制,但在这个阶段它非常有用,所以让我们来看看它。

LiveServerTestCase 期望由 Django 测试运行器使用 manage.py 运行,它将运行任何以 test_ 开头的文件中的测试。为了保持整洁,让我们为我们的功能测试创建一个文件夹,使它看起来像一个应用程序。Django 需要的只是一个有效的 Python 包目录(即其中包含一个 init.py 文件):

$ mkdir functional_tests
$ touch functional_tests/__init__.py

现在我们想要将我们的功能测试从名为 functional_tests.py 的独立文件移动到 functional_tests 应用程序的 tests.py 中。我们使用 git mv 让 Git 知道这是同一个文件,并且应该有一个单一的历史记录。

$ git mv functional_tests.py functional_tests/tests.py
$ git status # shows the rename to functional_tests/tests.py and __init__.py

此时,你的目录树应该是这样的:

.
├── db.sqlite3
├── functional_tests
│   ├── __init__.py
│   └── tests.py
├── lists
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── migrations
│   │   ├── 0001_initial.py
│   │   ├── 0002_item_text.py
│   │   └── __init__.py
│   ├── models.py
│   ├── templates
│   │   └── home.xhtml
│   ├── tests.py
│   └── views.py
├── manage.py
└── superlists
    ├── __init__.py
    ├── asgi.py
    ├── settings.py
    ├── urls.py
    └── wsgi.py

functional_tests.py 已经不见了,变成了 functional_tests/tests.py。现在,每当我们想运行功能测试时,不再运行python functional_tests.py,而是使用python manage.py test functional_tests

注意

你可以将功能测试混合到 lists 应用的测试中。我倾向于保持它们分开,因为功能测试通常涉及跨不同应用的横切关注点。FT 应该从用户的角度看事情,而你的用户并不关心你是如何在不同应用之间分割工作的!

现在让我们编辑 functional_tests/tests.py 并修改我们的 NewVisitorTest 类以使其使用 LiveServerTestCase

functional_tests/tests.py (ch06l001)

from django.test import LiveServerTestCase
from selenium import webdriver
[...]

class NewVisitorTest(LiveServerTestCase):
    def setUp(self):
        [...]

接下来,不再将访问本地主机端口 8000 硬编码,LiveServerTestCase 给了我们一个叫做 live_server_url 的属性:

functional_tests/tests.py (ch06l002)

    def test_can_start_a_todo_list(self):
        # Edith has heard about a cool new online to-do app.
        # She goes to check out its homepage
        self.browser.get(self.live_server_url)

如果希望,我们也可以从末尾移除 if __name__ == '__main__',因为我们将使用 Django 测试运行器来启动 FT。

TODO — 从这里修复

现在我们可以通过告诉 Django 测试运行器仅运行我们新的 functional_tests 应用的测试来运行我们的功能测试:

$ python manage.py test functional_tests
Creating test database for alias 'default'...
Found 1 test(s).
System check identified no issues (0 silenced).
.
 ---------------------------------------------------------------------
Ran 1 test in 10.519s

OK
Destroying test database for alias 'default'...

FT 依然通过,让我们放心,我们的重构没有出问题。你还会注意到,如果你再次运行测试,之前测试留下的旧列表项都不见了,系统已经自我清理完毕。成功!我们应该将其作为一个原子更改提交:

$ git status # functional_tests.py renamed + modified, new __init__.py
$ git add functional_tests
$ git diff --staged
$ git commit  # msg eg "make functional_tests an app, use LiveServerTestCase"

仅运行单元测试

现在如果我们运行 manage.py test,Django 将运行功能测试和单元测试:

$ python manage.py test
Creating test database for alias 'default'...
Found 7 test(s).
System check identified no issues (0 silenced).
.......
 ---------------------------------------------------------------------
Ran 7 tests in 10.859s

OK
Destroying test database for alias 'default'...

为了仅运行单元测试,我们可以指定只运行 lists 应用的测试:

$ python manage.py test lists
Creating test database for alias 'default'...
Found 6 test(s).
System check identified no issues (0 silenced).
......
 ---------------------------------------------------------------------
Ran 6 tests in 0.009s

OK
Destroying test database for alias 'default'...

附注:升级 Selenium 和 Geckodriver

当我今天再次运行这一章时,我发现当我试图运行它们时 FT 被挂起了。

结果 Firefox 在夜间自动更新了,我的 Selenium 和 Geckodriver 版本也需要升级。快速访问 geckodriver 发布页面 确认有新版本发布。所以需要进行一些下载和升级:

  • 首先快速执行 pip install --upgrade selenium

  • 然后快速下载新版的 geckodriver。

  • 我保存了旧版本的备份副本,并将新版本放在了 PATH 中的某个地方。

  • 并通过 geckodriver --version 快速检查确认新版本已经准备就绪。

然后 FT 又回到了我预期的运行方式。

没有特别的原因会让它在书中的这一点发生;实际上,对你来说现在发生的可能性很小,但某个时候可能会发生,而且既然我们在做一些清理工作,这似乎是一个好地方来谈谈它。

这是使用 Selenium 时必须忍受的事情之一。尽管在 CI 服务器上可能固定浏览器和 Selenium 版本(例如),但是浏览器版本在真实世界中是不断变化的,你需要跟上你的用户的步伐。

注意

如果你的 FT 出现了奇怪的情况,尝试升级 Selenium 总是值得的。

现在回到我们的常规编程。

关于隐式等待和显式等待,以及神秘的 关于隐式等待和显式等待,以及巫术式的time.sleep

让我们来谈谈我们功能测试中的time.sleep

functional_tests/tests.py

        # When she hits enter, the page updates, and now the page lists
        # "1: Buy peacock feathers" as an item in a to-do list table
        inputbox.send_keys(Keys.ENTER)
        time.sleep(1)

        self.check_for_row_in_list_table("1: Buy peacock feathers")

这就是所谓的“显式等待”。这与“隐式等待”相对:在某些情况下,Selenium 会试图在你认为页面正在加载时“自动”等待。它甚至提供了一种名为implicitly_wait的方法,让你控制如果你请求的元素似乎还不在页面上,它会等多久。

实际上,在第一版中,我完全可以依赖隐式等待。问题在于隐式等待总是有些不稳定,随着 Selenium 4 的发布,隐式等待默认被禁用。同时,Selenium 团队普遍认为隐式等待只是一个坏主意, 应该避免使用

因此,这个版本一开始就有显式等待。但问题是那些time.sleep有自己的问题。

目前我们正在等待一秒钟,但谁又能说这是正确的时间呢?对于我们在自己机器上运行的大多数测试,一秒钟时间太长了,这将极大地减慢我们的功能测试运行速度。0.1 秒就足够了。但问题是,如果你将其设置得太低,每隔一段时间你会因为某种原因,笔记本电脑刚好运行得慢而导致虚假失败。即使是 1 秒,也不能完全确定不会出现随机失败,这些随机失败并不表示真正的问题,测试中的假阳性是真正的烦恼(在马丁·福勒的文章中有更多内容)。

小贴士:意外的NoSuchElementExceptionStaleElementException错误通常是你需要显式等待的信号。

所以让我们用一个工具替换我们的sleeps,它只会等待所需的时间,最长到一个合适的超时时间以捕捉任何故障。我们将check_for_row_in_list_table重命名为wait_for_row_in_list_table,并添加一些轮询/重试逻辑:

functional_tests/tests.py(ch06l004)

[...]
from selenium.common.exceptions import WebDriverException
import time

MAX_WAIT = 5  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png)

class NewVisitorTest(LiveServerTestCase):
    def setUp(self):
        [...]
    def tearDown(self):
        [...]

    def wait_for_row_in_list_table(self, row_text):
        start_time = time.time()
        while True:  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/2.png)
            try:
                table = self.browser.find_element(By.ID, "id_list_table")  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/3.png)
                rows = table.find_elements(By.TAG_NAME, "tr")
                self.assertIn(row_text, [row.text for row in rows])
                return  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/4.png)
            except (AssertionError, WebDriverException):  ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/5.png)
                if time.time() - start_time > MAX_WAIT:  ![6](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/6.png)
                    raise  ![6](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/6.png)
                time.sleep(0.5)  ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/5.png)

1

我们将使用一个常量MAX_WAIT来设置我们准备等待的最大时间。5 秒应该足够捕捉任何故障或随机延迟。

2

这里是循环,除非我们到达两种可能的退出路线之一,否则它将一直运行。

3

这是我们从方法旧版本中提取的三行断言。

4

如果我们通过了它们,并且我们的断言通过,我们将从函数中返回并退出循环。

5

但是如果我们捕获到异常,我们会等待一小段时间然后循环重试。我们想要捕获两种类型的异常:WebDriverException,当页面尚未加载并且 Selenium 无法在页面上找到表元素时,以及AssertionError,当表存在,但可能是页面重新加载前的表,因此还没有我们的行。

6

这是我们的第二个逃生路线。如果我们到达这一点,那意味着我们的代码每次尝试都会引发异常,直到超过超时时间。所以这一次,我们重新引发异常,并让它冒泡到我们的测试中,很可能最终会出现在我们的回溯中,告诉我们测试失败的原因。

您是否认为这段代码有点丑陋,让人有点难以理解我们到底在做什么?我同意。稍后([Link to Come]),我们将重构一个通用的wait_for辅助函数,将时间和重新引发逻辑与测试断言分开。但我们会等到我们需要在多个地方使用它。

注意

如果您之前使用过 Selenium,您可能知道它有一些等待的辅助函数。不过,我对它们不是很感冒,虽然真的没有任何客观理由。在本书的过程中,我们将构建一些等待的辅助工具,我认为这将产生漂亮、可读的代码,但当然您应该在自己的时间内查看自制的 Selenium 等待,并看看您是否更喜欢它们。

现在我们可以重命名我们的方法调用,并移除那些神秘的time.sleep

functional_tests/tests.py (ch06l005)

    [...]
    # When she hits enter, the page updates, and now the page lists
    # "1: Buy peacock feathers" as an item in a to-do list table
    inputbox.send_keys(Keys.ENTER)
    self.wait_for_row_in_list_table("1: Buy peacock feathers")

    # There is still a text box inviting her to add another item.
    # She enters "Use peacock feathers to make a fly"
    # (Edith is very methodical)
    inputbox = self.browser.find_element(By.ID, "id_new_item")
    inputbox.send_keys("Use peacock feathers to make a fly")
    inputbox.send_keys(Keys.ENTER)

    # The page updates again, and now shows both items on her list
    self.wait_for_row_in_list_table("1: Buy peacock feathers")
    self.wait_for_row_in_list_table("2: Use peacock feathers to make a fly")
    [...]

然后重新运行测试:

$ python manage.py test
Creating test database for alias 'default'...
Found 7 test(s).
System check identified no issues (0 silenced).
.......
 ---------------------------------------------------------------------
Ran 7 tests in 4.552s

OK
Destroying test database for alias 'default'...

哦耶,我们又通过了,并且注意我们的执行时间减少了几秒钟。现在可能不算太多,但这一切都会累积起来。

为了确认我们已经做对了事情,让我们故意破坏测试的几种方式,并查看一些错误。首先,让我们检查一下,如果我们搜索一些永远不会出现的行文本,我们会得到正确的错误:

functional_tests/tests.py (ch06l006)

def wait_for_row_in_list_table(self, row_text):
    [...]
        rows = table.find_elements(By.TAG_NAME, "tr")
        self.assertIn("foo", [row.text for row in rows])
        return

我们看到我们仍然得到了一个很好的自说明的测试失败消息:

    self.assertIn("foo", [row.text for row in rows])
AssertionError: 'foo' not found in ['1: Buy peacock feathers']
注意

您是否对等待测试失败等待 5 秒感到有点无聊?这是显式等待的一个缺点。在等待足够长时间以确保小故障不会干扰您与等待时间过长以至于期望失败令人痛苦之间存在着棘手的权衡。使 MAX_WAIT 可配置,以便在本地开发时快速,在持续集成(CI)服务器上更为保守,可能是一个不错的主意。请参阅[Link to Come],了解持续集成的简介。

让我们把它改回原样,然后破坏其他东西:

functional_tests/tests.py (ch06l007)

    try:
        table = self.browser.find_element(By.ID, "id_nothing")
        rows = table.find_elements(By.TAG_NAME, "tr")
        self.assertIn(row_text, [row.text for row in rows])
        return
    [...]

确实,我们得到了当页面不包含我们寻找的元素时的错误:

selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_nothing"]

一切看起来都井然有序。让我们把我们的代码恢复到应该的状态,并进行最后一次测试运行:

$ python manage.py test
[...]
OK

很好。随着这小插曲的结束,让我们继续为多个列表使我们的应用程序实际工作。不要忘记先提交!

第七章:逐步工作

现在让我们解决真正的问题,即我们的设计只允许一个全局列表。在本章中,我将演示一个关键的 TDD 技术:如何使用增量、逐步的过程来调整现有代码,从一个可工作状态过渡到另一个可工作状态。测试羊驼,而不是重构猫。

当必要时进行小设计

让我们考虑一下我们希望支持多个列表的工作方式。

目前,我们站点的唯一 URL 是主页,这就是为什么只有一个全局列表的最明显方式支持多个列表。每个列表都有自己的 URL,这样人们可以开始多个列表,或者不同的人可以有不同的列表。这可能如何工作?

不是大设计预先

TDD 与敏捷开发运动密切相关,后者反对大设计预先(Big Design Up Front),即传统软件工程实践,其中经过长时间的需求收集后,会有一个同样漫长的设计阶段,在这个阶段,软件被计划在纸上完成。敏捷哲学认为,通过在实践中解决问题,你比在理论中学到更多,特别是当你尽早将应用程序与真实用户对接时。我们不再有长时间的前期设计阶段,而是尝试尽早发布一个最小可行应用,并根据来自真实世界使用的反馈逐步演进设计。

但这并不意味着思考设计被完全禁止!在上一大章中,我们看到仅仅笨拙地继续前进而不加思考最终可以让我们达到正确答案,但常常稍微思考设计可以帮助我们更快地到达那里。因此,让我们思考一下我们的最小可行列表应用,以及我们需要交付的设计类型:

  • 我们希望每个用户能够存储自己的列表——至少目前是这样。

  • 一个列表由几个项目组成,其主要属性是一小段描述性文本。

  • 我们需要保存用户在一次访问到下一次访问的列表。目前,我们可以为每个用户提供一个唯一的列表 URL。以后,我们可能希望以某种方式自动识别用户并向他们显示他们的列表。

要交付“目前”项目,听起来我们要将列表及其项存储在数据库中。每个列表将有一个唯一的 URL,每个列表项将是与特定列表相关的一小段描述文本。

YAGNI!

一旦你开始考虑设计,就很难停下来。我们脑海中冒出各种其他想法——也许我们想给每个列表一个名称或标题,也许我们想用用户名和密码识别用户,也许我们想在列表中添加一个更长的注释字段以及短描述,也许我们想存储某种排序方式等等。但我们遵循敏捷开发的另一个原则:“YAGNI”(读作 yag-knee),即“你不会需要它!”作为软件开发者,我们喜欢创造东西,有时很难抵制只是因为想法浮现而建造东西的冲动。问题是,往往情况是,无论想法有多酷,你最终都不会用到它。相反,你会有一堆未使用的代码,增加了应用程序的复杂性。YAGNI 是我们用来抵制过度热情的创造冲动的口号。

REST(ish)

我们对我们想要的数据结构有一个想法——Model-View-Controller(MVC)的模型部分。那么视图和控制器部分呢?用户如何使用 Web 浏览器与列表及其项目进行交互?

表述性状态转移(REST)是一种通常用于指导基于 Web API 设计的 Web 设计方法。在设计用户界面站点时,不可能严格遵循 REST 规则,但它们仍然提供了一些有用的启发(如果您想看到真正的 REST API,请跳转到 [Link to Come])。

REST 建议我们有一个 URL 结构,与我们的数据结构匹配,例如列表和列表项目。每个列表可以有自己的 URL:

    /lists/<list identifier>/

这将满足我们在 FT 中指定的要求。要查看列表,我们使用 GET 请求(正常浏览器访问页面)。

要创建全新的列表,我们将有一个特殊的 URL 接受 POST 请求:

    /lists/new

要向现有列表添加新项目,我们将有一个单独的 URL,可以向其发送 POST 请求:

    /lists/<list identifier>/add_item

(再次强调,我们并非试图完全遵循 REST 规则,在这里应该使用 PUT 请求——我们只是借鉴 REST 的灵感。此外,你不能在标准 HTML 表单中使用 PUT。)

总之,本章的草稿如下所示:

逐步实施新设计,使用 TDD

我们如何使用 TDD 实现新设计?让我们再看一下 TDD 流程的流程图,位于 [Link to Come]。

在顶层,我们将结合添加新功能(通过添加新的 FT 和编写新的应用程序代码)和重构我们的应用程序——即重写某些现有实现,以便向用户提供相同功能,但使用我们新设计的一些方面。我们将能够使用现有的功能测试来验证我们不会破坏已经工作的内容,并使用新的功能测试来推动新功能的实现。

在单元测试级别上,我们将添加新的测试或修改现有测试以测试我们想要的更改,并且我们将能够类似地使用我们 修改的单元测试来帮助确保我们在过程中没有破坏任何东西。

内部红绿灯/重构循环被外部 FTs 的红绿灯所包围

图 7-1. 具有功能和单元测试的 TDD 过程

确保我们有一个回归测试

让我们将我们的草稿翻译成一个新的功能测试方法,引入第二个用户,并检查他们的待办事项列表是否与 Edith 的分开。

我们将从第一个例子开始类似地进行。Edith 添加了第一个项目以创建一个待办事项列表,但我们引入了第一个新的断言——Edith 的列表应该存在于自己独特的 URL 上:

functional_tests/tests.py(ch07l005)

def test_can_start_a_todo_list(self):
    # Edith has heard about a cool new online to-do app.
    [...]
    # The page updates again, and now shows both items on her list
    self.wait_for_row_in_list_table("1: Buy peacock feathers")
    self.wait_for_row_in_list_table("2: Use peacock feathers to make a fly")

    # Satisfied, she goes back to sleep

def test_multiple_users_can_start_lists_at_different_urls(self):
    # Edith starts a new to-do list
    self.browser.get(self.live_server_url)
    inputbox = self.browser.find_element(By.ID, "id_new_item")
    inputbox.send_keys("Buy peacock feathers")
    inputbox.send_keys(Keys.ENTER)
    self.wait_for_row_in_list_table("1: Buy peacock feathers")

    # She notices that her list has a unique URL
    edith_list_url = self.browser.current_url
    self.assertRegex(edith_list_url, "/lists/.+")  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png)

1

assertRegexunittest 中的一个辅助函数,用于检查字符串是否与正则表达式匹配。我们用它来检查我们的新的 REST-ish 设计是否已经实现。在 unittest 文档 中查找更多信息。

接下来,我们想象一个新用户加入。我们希望检查当他们访问主页时,他们不会看到 Edith 的任何项目,并且他们会得到自己的独特 URL 用于他们的列表:

functional_tests/tests.py(ch07l006)

    [...]
    self.assertRegex(edith_list_url, "/lists/.+")

    # Now a new user, Francis, comes along to the site.

    ## We delete all the browser's cookies
    ## as a way of simulating a brand new user session ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png)
    self.browser.delete_all_cookies()

    # Francis visits the home page.  There is no sign of Edith's
    # list
    self.browser.get(self.live_server_url)
    page_text = self.browser.find_element(By.TAG_NAME, "body").text
    self.assertNotIn("Buy peacock feathers", page_text)
    self.assertNotIn("make a fly", page_text)

    # Francis starts a new list by entering a new item. He
    # is less interesting than Edith...
    inputbox = self.browser.find_element(By.ID, "id_new_item")
    inputbox.send_keys("Buy milk")
    inputbox.send_keys(Keys.ENTER)
    self.wait_for_row_in_list_table("1: Buy milk")

    # Francis gets his own unique URL
    francis_list_url = self.browser.current_url
    self.assertRegex(francis_list_url, "/lists/.+")
    self.assertNotEqual(francis_list_url, edith_list_url)

    # Again, there is no trace of Edith's list
    page_text = self.browser.find_element(By.TAG_NAME, "body").text
    self.assertNotIn("Buy peacock feathers", page_text)
    self.assertIn("Buy milk", page_text)

    # Satisfied, they both go back to sleep

1

我使用双哈希(##)的惯例来表示“元注释”——关于测试工作方式和原因的注释——这样我们就可以将它们与解释用户故事的 FT 中的常规注释区分开来。它们是给我们未来的自己的消息,否则我们可能会想知道为什么我们在忙着删除 cookies...​

除此之外,新测试相当容易理解。让我们看看当我们运行我们的 FTs 时我们的表现如何:

$ python manage.py test functional_tests
[...]
.F
======================================================================
FAIL: test_multiple_users_can_start_lists_at_different_urls (functional_tests.t
ests.NewVisitorTest.test_multiple_users_can_start_lists_at_different_urls)

 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "...goat-book/functional_tests/tests.py", line 77, in
test_multiple_users_can_start_lists_at_different_urls
    self.assertRegex(edith_list_url, "/lists/.+")
AssertionError: Regex didn't match: '/lists/.+' not found in
'http://localhost:8081/'

 ---------------------------------------------------------------------
Ran 2 tests in 5.786s

FAILED (failures=1)

很好,我们的第一个测试仍然通过,第二个测试失败了,这是我们可能期望的。让我们提交一下,然后去构建一些新的模型和视图:

$ git commit -a

朝着新设计迭代

对于我们的新设计感到兴奋,我有一种无法抑制的冲动,想立即开始更改 models.py,这将破坏一半的单元测试,然后堆叠并一次性更改几乎每一行代码。这是一种自然的冲动,而 TDD 作为一种学科,是一直在与之斗争。听从测试山羊的话,而不是重构猫的话!我们不需要在一个巨大的爆炸中实现我们新的闪亮设计。让我们进行一些小的变更,从一个工作状态到另一个工作状态,我们的设计在每个阶段都温和地指导着我们。

我们的待办事项列表上有四个项目。FT,以其 Regex didn't match 错误,建议我们下一个应该解决的问题是第二个项目——给列表分配自己的 URL 和标识符。让我们试着修复这个问题,只修改那个问题。

URL 来自 POST 后的重定向。在lists/tests.py中,让我们找到test_redirects_after_POST,并更改预期的重定向位置:

lists/tests.py(ch07l007)

def test_redirects_after_POST(self):
    response = self.client.post("/", data={"item_text": "A new list item"})
    self.assertRedirects(response, "/lists/the-only-list-in-the-world/")

这看起来有点奇怪吗?显然,/lists/the-only-list-in-the-world不是我们应用程序最终设计中会出现的 URL。但我们致力于一次只改变一件事情。虽然我们的应用程序只支持一个列表,但这是唯一有意义的 URL。我们仍在前进,因为我们将为列表和主页拥有不同的 URL,这是向更符合 REST 原则的设计迈出的一步。稍后,当我们有多个列表时,将很容易更改。

注意

另一种思考方式是作为解决问题的技巧:我们新的 URL 设计目前尚未实现,所以对于 0 项是有效的。最终,我们想要解决n项,但解决 1 项是迈向目标的一大步。

运行单元测试会得到一个预期的失败:

$ python manage.py test lists
[...]
AssertionError: '/' != '/lists/the-only-list-in-the-world/'
[...]

我们可以调整我们在lists/views.py中的home_page视图:

lists/views.py(ch07l008)

def home_page(request):
    if request.method == "POST":
        Item.objects.create(text=request.POST["item_text"])
        return redirect("/lists/the-only-list-in-the-world/")

    items = Item.objects.all()
    return render(request, "home.xhtml", {"items": items})

Django 的单元测试运行器会发现这不是一个真实的 URL:

$ python3 manage.py test lists
[...]
AssertionError: 404 != 200 : Couldn't retrieve redirection page
'/lists/the-only-list-in-the-world/': response code was 404 (expected 200)

进行第一步,自包含的步骤:一个新的 URL

我们的单例列表 URL 尚不存在。我们在superlists/urls.py中修复这个问题。

superlists/urls.py(ch07l009)

from django.urls import path
from lists import views

urlpatterns = 
    path("", views.home_page, name="home"),
    path("lists/the-only-list-in-the-world/", views.home_page, name="view_list"),  ![1
]

1

我们只需将新的 URL 指向现有的主页视图。这是最小的改变。

提示

小心 URL 中的尾部斜杠,无论是在这里的urls.py还是测试中。它们是常见的错误源。

这使得我们的单元测试通过了:

$ python3 manage.py test lists
[...]
OK

FTs(功能测试)认为怎么样?

$ python3 manage.py test functional_tests
[...]
AssertionError: 'Buy peacock feathers' unexpectedly found in 'Your To-Do
list\n1: Buy peacock feathers'

很好,它们进展了一点,现在我们确认有了一个新的 URL,但实际页面内容仍然相同,显示旧的列表。

分离我们的主页和列表视图功能

现在我们有了两个 URL,但它们实际上做着完全相同的事情。在幕后,它们只是指向同一个函数。继续逐步工作,我们可以开始分解这两个不同 URL 的责任:主页只需显示并对基于其第一个项目创建全新列表做出反应。 列表视图页面需要能够显示现有的列表项并向列表添加新项

让我们分离一些关于我们新 URL 的测试。

打开lists/tests.py,并添加一个名为ListViewTest的新测试类。然后将从HomePageTest移到我们的新类中的名为test_displays_all_list_items的方法,只改变self.client.get()调用的 URL:

lists/tests.py(ch07l010)

class HomePageTest(TestCase):
    def test_uses_home_template(self):
        [...]
    def test_can_save_a_POST_request(self):
        [...]
    def test_redirects_after_POST(self):
        [...]

class ListViewTest(TestCase):
    def test_displays_all_list_items(self):
        Item.objects.create(text="itemey 1")
        Item.objects.create(text="itemey 2")
        response = self.client.get("/lists/the-only-list-in-the-world/")
        self.assertContains(response, "itemey 1")
        self.assertContains(response, "itemey 2")

现在让我们尝试运行这个测试:

$ python3 manage.py test lists
OK

它通过了,因为 URL 仍指向home_page视图。

让我们将其指向一个新的视图:

superlists/urls.py(ch07l011)

from django.urls import path
from lists import views

urlpatterns = [
    path("", views.home_page, name="home"),
    path("lists/the-only-list-in-the-world/", views.view_list, name="view_list"),
]

预料之中的失败,因为目前还没有这样的视图函数:

$ python3 manage.py test lists
[...]
    path("lists/the-only-list-in-the-world/", views.view_list,
name="view_list"),
                                              ^^^^^^^^^^^^^^^
AttributeError: module 'lists.views' has no attribute 'view_list'

一个新的视图函数

够公平。让我们在lists/views.py中创建一个虚拟视图函数:

lists/views.py(ch07l012-0)

def view_list(request):
    pass

还不够好:

ValueError: The view lists.views.view_list didn't return an HttpResponse
object. It returned None instead.

[...]
FAILED (errors=2)

寻找最小的代码更改,让我们只是让视图返回我们现有的 home.xhtml 模板,但里面什么都没有:

lists/views.py (ch07l012-1)

def view_list(request):
    return render(request, "home.xhtml")

现在测试指导我们确保我们的列表视图显示现有的列表项:

AssertionError: False is not true : Couldn't find 'itemey 1' in response

所以让我们直接复制 home_page 的最后两行:

lists/views.py (ch07l012)

def view_list(request):
    items = Item.objects.all()
    return render(request, "home.xhtml", {"items": items})

这使我们通过了单元测试!

Ran 6 tests in 0.035s

OK

FTs 检测到一个回归

像往常一样,当我们开始通过单元测试时,我们运行功能测试以检查事物在“现实生活”中的运行情况:

$ python manage.py test functional_tests
[...]
FF
======================================================================
FAIL: test_can_start_a_todo_list
(functional_tests.tests.NewVisitorTest.test_can_start_a_todo_list)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "...goat-book/functional_tests/tests.py", line 63, in
test_can_start_a_todo_list
[...]
AssertionError: '2: Use peacock feathers to make a fly' not found in ['1: Buy
peacock feathers']

======================================================================
FAIL: test_multiple_users_can_start_lists_at_different_urls (functional_tests.t
ests.NewVisitorTest.test_multiple_users_can_start_lists_at_different_urls)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "...goat-book/functional_tests/tests.py", line 89, in
test_multiple_users_can_start_lists_at_different_urls
    self.assertNotIn("Buy peacock feathers", page_text)
AssertionError: 'Buy peacock feathers' unexpectedly found in 'Your To-Do
list\n1: Buy peacock feathers'

不仅是我们的新测试失败了,而且旧测试也失败了。这告诉我们我们引入了一个回归

他们试图告诉我们什么?

当我们尝试添加第二个项目时,两个测试都失败了。我们必须戴上调试帽子。我们知道主页是工作的,因为测试已经到达了第一个 FT 的第 63 行,所以我们至少已经添加了一个第一项。而且我们的单元测试都通过了,所以我们相当确定我们的 URL 和视图正在执行它们应该执行的操作。让我们快速查看一下这些单元测试,看看它们告诉我们什么:

$ grep -E "class|def" lists/tests.py
class HomePageTest(TestCase):
    def test_uses_home_template(self):
    def test_can_save_a_POST_request(self):
    def test_redirects_after_POST(self):
    def test_only_saves_items_when_necessary(self):
class ListViewTest(TestCase):
    def test_displays_all_list_items(self):
class ItemModelTest(TestCase):
    def test_saving_and_retrieving_items(self):

主页显示了正确的模板,并且可以处理 POST 请求,而 /only-list-in-the-world/ 视图知道如何显示所有项目... 但是它不知道如何处理 POST 请求。啊,这给了我们一个线索。

第二个线索是一个经验法则,当所有单元测试都通过了但功能测试没有通过时,通常指向的是单元测试未覆盖的问题,而在我们的情况下,这通常是一个模板问题。

答案是,我们目前的 home.xhtml 输入表单没有指定一个显式的 POST URL:

lists/templates/home.xhtml

        <form method="POST">

默认情况下,浏览器将 POST 数据发送回当前所在的相同 URL。当我们在主页上时,这没问题,但当我们在 /only-list-in-the-world/ 页面上时,就不行了。

尽快恢复工作状态

现在我们可以深入研究并向我们的新视图添加 POST 请求处理,但这将涉及编写更多的测试和代码,而且在这一点上,我们希望尽快恢复到工作状态。实际上,我们可以做的最快的事情是只使用现有的主页视图,因为它已经工作了,用于所有 POST 请求:

lists/templates/home.xhtml (ch07l013)

    <form method="POST" action="/">

试一下,我们会看到我们的 FTs 回到了一个更愉快的地方:

FAIL: test_multiple_users_can_start_lists_at_different_urls (functional_tests.t
ests.NewVisitorTest.test_multiple_users_can_start_lists_at_different_urls)
[...]
AssertionError: 'Buy peacock feathers' unexpectedly found in 'Your To-Do
list\n1: Buy peacock feathers'

Ran 2 tests in 8.541s
FAILED (failures=1)

我们的回归测试再次通过了,所以我们知道我们已经恢复到了工作状态。新功能可能还没有起作用,但至少旧功能的工作方式和以前一样。

绿色?重构

是时候稍微整理一下了。

红/绿/重构 的过程中,我们已经到达了绿色阶段,所以我们应该看看需要进行重构的地方。我们现在有两个视图,一个用于主页,一个用于单个列表。两者目前都使用相同的模板,并将其中所有当前在数据库中的列表项传递给它。但是 POST 请求仅由主页处理。

感觉我们两个视图的责任有点纠缠在一起。让我们试着稍微解开它们的纠缠。

另一个小步骤:为查看列表创建一个单独的模板

由于主页和列表视图现在是完全不同的页面,它们应该使用不同的 HTML 模板;home.xhtml可以有一个单独的输入框,而一个新模板,list.xhtml,可以负责显示现有项目的表格。

让我们添加一个新的测试来检查它是否使用了一个不同的模板:

lists/tests.py (ch07l014)

class ListViewTest(TestCase):
    def test_uses_list_template(self):
        response = self.client.get("/lists/the-only-list-in-the-world/")
        self.assertTemplateUsed(response, "list.xhtml")

    def test_displays_all_list_items(self):
        [...]

让我们看看它说了什么:

AssertionError: False is not true : Template 'list.xhtml' was not a template
used to render the response. Actual template(s) used: home.xhtml

看起来差不多了,让我们改变一下视图:

lists/views.py (ch07l015)

def view_list(request):
    items = Item.objects.all()
    return render(request, "list.xhtml", {"items": items})

但是,显然,那个模板还不存在。如果我们运行单元测试,我们会得到:

django.template.exceptions.TemplateDoesNotExist: list.xhtml

让我们在lists/templates/list.xhtml创建一个新文件:

$ touch lists/templates/list.xhtml

一个空模板,导致我们出现这个错误——知道测试在那里确保我们填写它:

AssertionError: False is not true : Couldn't find 'itemey 1' in response

一个单独列表的模板将会重复使用我们当前在home.xhtml中拥有的相当多的内容,所以我们可以先简单地复制那个:

$ cp lists/templates/home.xhtml lists/templates/list.xhtml

这样测试就通过了(绿灯)。

$ python manage.py test lists
[...]
OK

现在让我们再做一些整理(重构)。我们说过主页不需要列出项目,它只需要新的列表输入字段,所以我们可以从lists/templates/home.xhtml中删除一些行,并且可能稍微调整h1来说“开始一个新的待办事项列表”:

我将代码更改呈现为一个差异,希望能更清楚地显示我们需要修改的内容:

lists/templates/home.xhtml (ch07l018)

   <body>
-    <h1>Your To-Do list</h1>
+    <h1>Start a new To-Do list</h1>
     <form method="POST" action="/">
       <input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />
       {% csrf_token %}
     </form>
-    <table id="id_list_table">
-      {% for item in items %}
-        <tr><td>{{ forloop.counter }}: {{ item.text }}</td></tr>
-      {% endfor %}
-    </table>
   </body>

我们重新运行单元测试以确保没有出现问题……

OK

很好。

现在实际上没有必要将所有项目传递到我们的home_page视图中的home.xhtml模板中,所以我们可以简化它并删除一些行:

lists/views.py (ch07l019)

     if request.method == "POST":
         Item.objects.create(text=request.POST["item_text"])
         return redirect("/lists/the-only-list-in-the-world/")
-
-    items = Item.objects.all()
-    return render(request, "home.xhtml", {"items": items})
+    return render(request, "home.xhtml")

再次运行单元测试;它们仍然通过:

OK

运行功能测试的时间到了:

AssertionError: '1: Buy milk' not found in ['1: Buy peacock feathers', '2: Buy
milk']

不错!我们的回归测试(第一个 FT)通过了,我们的新测试现在也稍微前进了一点——它告诉我们 Francis 没有得到自己的列表页面(因为他仍然看到了 Edith 的一些列表项)。

也许感觉我们没有取得太多进展,因为从功能上来说,站点的行为几乎与我们开始这一章时完全一样,但这确实是进步。我们已经开始了通往新设计的道路,并且我们已经实施了一些步骤而没有比以前更糟糕。让我们提交到目前为止的进展:

$ git status # should show 4 changed files and 1 new file, list.xhtml
$ git add lists/templates/list.xhtml
$ git diff # should show we've simplified home.xhtml,
           # moved one test to a new class in lists/tests.py added a new view
           # in views.py, and simplified home_page and added a line to urls.py
$ git commit -a # add a message summarising the above, maybe something like
                # "new URL, view and template to display lists"

第三个小步骤:为添加列表项创建一个新的 URL

我们的自己待办事项清单进展到哪里了?

我们在第二项上有点取得了进展,即使世界上仍然只有一个列表。第一项有点吓人。我们能在 3 或 4 项上做些什么吗?

让我们在/lists/new添加一个新的 URL 来添加新的列表项:如果没有别的,这将简化主页视图。

一个用于新列表创建的测试类

打开lists/tests.py,并且移动test_can_save_a_POST_requesttest_redirects_after_POST方法到一个新的类中,然后改变它们提交的 URL:

lists/tests.py (ch07l020)

class NewListTest(TestCase):
    def test_can_save_a_POST_request(self):
        self.client.post("/lists/new", data={"item_text": "A new list item"})
        self.assertEqual(Item.objects.count(), 1)
        new_item = Item.objects.get()
        self.assertEqual(new_item.text, "A new list item")

    def test_redirects_after_POST(self):
        response = self.client.post("/lists/new", data={"item_text": "A new list item"})
        self.assertRedirects(response, "/lists/the-only-list-in-the-world/")
提示

顺便说一句,这是另一个需要注意尾随斜杠的地方。它是/lists/new,没有尾随斜杠。我使用的约定是没有尾随斜杠的 URL 是“动作”URL,用于修改数据库。

尝试运行它:

    self.assertEqual(Item.objects.count(), 1)
AssertionError: 0 != 1
[...]
    self.assertRedirects(response, "/lists/the-only-list-in-the-world/")
[...]
AssertionError: 404 != 302 : Response didn't redirect as expected: Response
code was 404 (expected 302)

第一个失败告诉我们,我们没有将新项目保存到数据库中,第二个失败告诉我们,我们的视图返回了 404 而不是 302 重定向。这是因为我们还没有为/lists/new构建 URL,所以client.post只是得到一个“未找到”的响应。

注意

你还记得我们之前是如何将其拆分成两个测试的吗?如果我们只有一个测试来检查保存和重定向的情况,它会在0 != 1的失败上失败,这将更难调试。问我为什么知道这一点。

用于新列表创建的 URL 和视图

现在让我们构建我们的新 URL:

superlists/urls.py(ch07l021)

urlpatterns = [
    path("", views.home_page, name="home"),
    path("lists/new", views.new_list, name="new_list"),
    path("lists/the-only-list-in-the-world/", views.view_list, name="view_list"),
]

接下来我们得到了no attribute 'new_list',所以让我们在lists/views.py中修复它:

lists/views.py(ch07l022)

def new_list(request):
    pass

然后我们得到了“视图 lists.views.new_list 没有返回一个 HttpResponse 对象”。(这已经变得相当熟悉了!)我们可以返回一个原始的HttpResponse,但既然我们知道我们需要一个重定向,让我们从home_page中借用一行:

lists/views.py(ch07l023)

def new_list(request):
    return redirect("/lists/the-only-list-in-the-world/")

这样做就得到:

    self.assertEqual(Item.objects.count(), 1)
AssertionError: 0 != 1

看起来相当简单。我们从home_page再借用一行:

lists/views.py(ch07l024)

def new_list(request):
    Item.objects.create(text=request.POST["item_text"])
    return redirect("/lists/the-only-list-in-the-world/")

现在所有的事情都过去了:

Ran 7 tests in 0.030s

OK

我们可以运行 FTs 检查我们是否仍然在同一位置:我们的回归测试通过了,新的 FT 也达到了相同的点。

[...]
AssertionError: '1: Buy milk' not found in ['1: Buy peacock feathers', '2: Buy
milk']
Ran 2 tests in 8.972s
FAILED (failures=1)

移除现在多余的代码和测试

我们看起来很好。由于我们的新视图现在正在做大部分home_page过去做的工作,我们应该能够大幅简化它。例如,我们可以移除整个if request.method == 'POST'部分吗?

lists/views.py(ch07l025)

def home_page(request):
    return render(request, "home.xhtml")

是的!

OK

而我们在这个过程中,也可以移除现在多余的test_only_saves_​items_when_necessary测试!

感觉不错吧?视图函数看起来简单多了。我们重新运行测试确保……

Ran 6 tests in 0.016s
OK

还有 FTs 呢?

一个回归测试!将我们的表单指向新 URL

糟糕:

ERROR: test_can_start_a_todo_list
[...]
  File "...goat-book/functional_tests/tests.py", line 52, in
test_can_start_a_todo_list
[...]
    self.wait_for_row_in_list_table("1: Buy peacock feathers")
[...]
    table = self.browser.find_element(By.ID, "id_list_table")
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_list_table"]

ERROR: test_multiple_users_can_start_lists_at_different_urls (functional_tests.
tests.NewVisitorTest.test_multiple_users_can_start_lists_at_different_urls)
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_list_table"]
[...]

Ran 2 tests in 11.592s
FAILED (errors=2)

再次,功能测试(FTs)捕捉到一个棘手的小 bug,这是我们的单元测试单独很难发现的。

这是因为我们的表单仍然指向旧的 URL。在home.xhtmllists.xhtml中,让我们将它们都改为:

lists/templates/home.xhtml,lists/templates/list.xhtml

    <form method="POST" action="/lists/new">

这样应该可以让我们重新工作了:

AssertionError: '1: Buy milk' not found in ['1: Buy peacock feathers', '2: Buy
milk']
[...]
FAILED (failures=1)

这是另一个非常完整的提交,我们对 URL 做了很多改动,我们的views.py看起来更加整洁,我们确信应用程序仍然像以前一样工作得很好。我们在这种工作状态到工作状态的操作中越来越娴熟了!

$ git status # 5 changed files
$ git diff # URLs for forms x2, moved code in views + tests, new URL
$ git commit -a

我们可以划掉待办列表上的一个项目:

迈出改变我们模型的步伐:调整我们的模型

足够处理我们的 URL 了。现在是时候迈出改变我们模型的步伐了。让我们调整模型单元测试。再次,通过 diff 查看更改是一个很好的方法:

lists/tests.py(ch07l029)

@@ -1,5 +1,5 @@
 from django.test import TestCase
-from lists.models import Item
+from lists.models import Item, List

 class HomePageTest(TestCase):
@@ -35,20 +35,30 @@ class ListViewTest(TestCase):
         self.assertContains(response, "itemey 2")

-class ItemModelTest(TestCase):
+class ListAndItemModelsTest(TestCase):
     def test_saving_and_retrieving_items(self):
+        mylist = List()
+        mylist.save()
+
         first_item = Item()
         first_item.text = "The first (ever) list item"
+        first_item.list = mylist
         first_item.save()

         second_item = Item()
         second_item.text = "Item the second"
+        second_item.list = mylist
         second_item.save()

+        saved_list = List.objects.get()
+        self.assertEqual(saved_list, mylist)
+
         saved_items = Item.objects.all()
         self.assertEqual(saved_items.count(), 2)

         first_saved_item = saved_items[0]
         second_saved_item = saved_items[1]
         self.assertEqual(first_saved_item.text, "The first (ever) list item")
+        self.assertEqual(first_saved_item.list, mylist)
         self.assertEqual(second_saved_item.text, "Item the second")
+        self.assertEqual(second_saved_item.list, mylist)

我们创建一个新的List对象,然后通过将其分配为其.list属性来为每个项分配它。我们检查列表是否被正确保存,并检查这两个项是否也保存了它们与列表的关系。您还会注意到,我们可以直接比较列表对象(saved_listmylist)—在幕后,这些将通过检查它们的主键(.id属性)是否相同来进行比较。

又到了另一个单元测试/代码周期。

在最初的几次迭代中,我不会在每次测试运行之间显式展示您输入的代码,我只会展示运行测试时预期的错误消息。我会让您自己来确定每个最小代码更改应该是什么。

小贴士

需要提示吗?回到我们在上上一章中介绍Item模型的步骤看看。

您的第一个错误应该是:

ImportError: cannot import name 'List' from 'lists.models'

解决这个问题,然后您应该会看到:

AttributeError: 'List' object has no attribute 'save'

接下来,您应该会看到:

django.db.utils.OperationalError: no such table: lists_list

因此,我们运行makemigrations

$ python manage.py makemigrations
Migrations for 'lists':
  lists/migrations/0003_list.py
    - Create model List

然后您应该会看到:

    self.assertEqual(first_saved_item.list, mylist)
AttributeError: 'Item' object has no attribute 'list'

外键关系

我们如何为我们的Item赋予一个列表属性?让我们试着像text属性一样天真地去做(顺便说一句,这是您看到迄今为止您的解决方案是否与我的类似的机会):

lists/models.py (ch07l033)

from django.db import models

class List(models.Model):
    pass

class Item(models.Model):
    text = models.TextField(default="")
    list = models.TextField(default="")

通常情况下,测试告诉我们我们需要一个迁移:

$ python manage.py test lists
[...]
django.db.utils.OperationalError: no such column: lists_item.list

$ python manage.py makemigrations
Migrations for 'lists':
  lists/migrations/0004_item_list.py
    - Add field list to item

让我们看看这给我们带来了什么:

AssertionError: 'List object (1)' != <List: List object (1)>

我们还没到那里。仔细看看!=的每一侧。您看到了单引号'吗?Django 只保存了List对象的字符串表示形式。为了保存到对象本身的关系,我们使用ForeignKey告诉 Django 这两个类之间的关系:

lists/models.py (ch07l035)

class Item(models.Model):
    text = models.TextField(default="")
    list = models.ForeignKey(List, default=None, on_delete=models.CASCADE)

那也需要进行迁移。由于最后一个是一个误导,让我们删除它并用新的替换它:

$ rm lists/migrations/0004_item_list.py
$ python manage.py makemigrations
Migrations for 'lists':
  lists/migrations/0004_item_list.py
    - Add field list to item
警告

删除迁移是危险的。偶尔这样做以保持整洁很好,因为我们并不总是第一次就正确编写我们的模型代码!但是,如果您删除了已经应用到某个数据库中的迁移,Django 将会对它所处的状态感到困惑,并且无法应用未来的迁移。您只应在确定迁移未被使用时才这样做。一个很好的经验法则是,您永远不应删除或修改已经提交到您的 VCS 的迁移。

调整其余世界以适应我们的新模型

回到我们的测试中,现在会发生什么?

$ python manage.py test lists
[...]
ERROR: test_displays_all_list_items
django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id
[...]
ERROR: test_redirects_after_POST
django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id
[...]
ERROR: test_can_save_a_POST_request
django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id

Ran 6 tests in 0.021s

FAILED (errors=3)

哎呀!

有一些好消息。虽然很难看到,但我们的模型测试通过了。但我们的三个视图测试却失败了。

问题出在我们在ItemList之间引入的新关系上,它要求每个项都必须有一个父列表,而我们的旧测试和代码还没有准备好。

不过,这正是我们为什么要有测试的原因!让我们再次让它们工作起来。最简单的是ListViewTest;我们只需为我们的两个测试项创建一个父列表:

lists/tests.py (ch07l038)

class ListViewTest(TestCase):
    [...]
    def test_displays_all_list_items(self):
        mylist = List.objects.create()
        Item.objects.create(text="itemey 1", list=mylist)
        Item.objects.create(text="itemey 2", list=mylist)

这样我们只剩下两个失败的测试,都是在尝试向我们的new_list视图进行 POST 请求的测试上。使用我们通常的技术来解码回溯,从错误到测试代码的行,最后埋藏在其中的是导致失败的我们自己的代码行:

  File "...goat-book/lists/tests.py", line 19, in test_redirects_after_POST
    response = self.client.post("/lists/new", data={"item_text": "A new list
item"})
[...]
  File "...goat-book/lists/views.py", line 10, in new_list
    Item.objects.create(text=request.POST["item_text"])

当我们尝试创建一个没有父列表的项目时。因此,我们在视图中进行类似的更改:

lists/views.py (ch07l039)

from lists.models import Item, List
[...]

def new_list(request):
    nulist = List.objects.create()
    Item.objects.create(text=request.POST["item_text"], list=nulist)
    return redirect("/lists/the-only-list-in-the-world/")

然后¹让我们的测试再次通过:

Ran 6 tests in 0.030s

OK

在这一点上,你是不是内心在崩溃? 啊!感觉太不对了;我们为每个新项提交都创建一个新列表,而且我们仍然只是显示所有项,好像它们属于同一个列表! 我知道,我也有同感。逐步的方法让你从工作代码到工作代码,这是反直觉的。我总是觉得应该一口气跳进去,试图一次性修复所有问题,而不是从一个奇怪的半成品状态到另一个。但记住测试的山羊!当你在山上时,你需要非常谨慎地考虑每一步的落脚点,并一步一步地前行,确保每个阶段你所站的地方都不会让你跌入悬崖。

所以,只是为了让我们确信事情已经起作用,我们重新运行 FT:

AssertionError: '1: Buy milk' not found in ['1: Buy peacock feathers', '2: Buy
milk']
[...]

确实,它完全到达了我们之前的地方。我们没有破坏任何东西,而且我们对数据库进行了重大改变。这是一件令人高兴的事情!让我们提交吧:

$ git status # 3 changed files, plus 2 migrations
$ git add lists
$ git diff --staged
$ git commit

然后我们可以划掉待办列表中的另一项:

每个列表应该有自己的 URL

我们可以摆脱愚蠢的the-only-list-in-the-world URL,但是我们应该用什么作为我们列表的唯一标识符呢?现在,也许最简单的方法就是使用数据库中自动生成的id字段。让我们修改ListViewTest,使得这两个测试指向新的 URL。

我们还将更改旧的test_displays_all_items测试,并将其命名为test_displays_only_items_for_that_list,以确保仅显示特定列表的项目:

lists/tests.py (ch07l040)

class ListViewTest(TestCase):
    def test_uses_list_template(self):
        mylist = List.objects.create()
        response = self.client.get(f"/lists/{mylist.id}/")
        self.assertTemplateUsed(response, "list.xhtml")

    def test_displays_only_items_for_that_list(self):
        correct_list = List.objects.create()
        Item.objects.create(text="itemey 1", list=correct_list)
        Item.objects.create(text="itemey 2", list=correct_list)
        other_list = List.objects.create()
        Item.objects.create(text="other list item", list=other_list)

        response = self.client.get(f"/lists/{correct_list.id}/")

        self.assertContains(response, "itemey 1")
        self.assertContains(response, "itemey 2")
        self.assertNotContains(response, "other list item")
注意

你是否在考虑测试中的行间距?我将测试的开头两行组合在一起,设置了测试,中间一行实际调用了被测试的代码,最后是断言。这不是强制性的,但确实有助于看到测试的结构。有些人称这种结构为安排-执行-断言,或假设-当-那时假设数据库包含我们的列表和两个项目,以及另一个列表,我为我们的列表做 GET 请求时,那么我看到我们列表中的项目,而不是其他列表中的项目。

运行单元测试会得到一个预期的 404 错误,还有另一个相关的错误:

FAIL: test_displays_only_items_for_that_list
AssertionError: 404 != 200 : Couldn't retrieve content: Response code was 404
(expected 200)
[...]
FAIL: test_uses_list_template
AssertionError: No templates used to render the response

从 URL 中捕获参数

现在是时候学习如何将参数从 URL 传递给视图:

superlists/urls.py (ch07l041-0)

urlpatterns = [
    path("", views.home_page, name="home"),
    path("lists/new", views.new_list, name="new_list"),
    path("lists/<int:list_id>/", views.view_list, name="view_list"),
]

我们调整了我们的 URL 的正则表达式,包括一个捕获组 <int:list_id>,它将匹配任何数字字符,直到下一个 /,捕获的 id 将作为参数传递给视图。

换句话说,如果我们去到 URL /lists/1/view_list 将在正常的 request 参数之后得到一个第二个参数,即整数 1

但是我们的视图还没有预期的参数!果然,这会引起问题:

ERROR: test_displays_only_items_for_that_list
[...]
TypeError: view_list() got an unexpected keyword argument 'list_id'
[...]
ERROR: test_uses_list_template
[...]
TypeError: view_list() got an unexpected keyword argument 'list_id'
[...]
FAIL: test_redirects_after_POST
[...]
AssertionError: 404 != 200 : Couldn't retrieve redirection page
'/lists/the-only-list-in-the-world/': response code was 404 (expected 200)
[...]
FAILED (failures=1, errors=2)

我们可以在 views.py 中轻松修复这个问题:

lists/views.py (ch07l041)

def view_list(request, list_id):
    [...]

这把我们带到了我们期望的失败,再加上一个only-list-in-the-world仍然挂在某个地方,我相信我们以后可以修复它。

FAIL: test_displays_only_items_for_that_list
[...]
AssertionError: 1 != 0 : Response should not contain 'other list item'
[...]
FAIL: test_redirects_after_POST
AssertionError: 404 != 200 : Couldn't retrieve redirection page
'/lists/the-only-list-in-the-world/': response code was 404 (expected 200)

让我们的列表视图区分发送到模板的项目:

lists/views.py (ch07l042)

def view_list(request, list_id):
    our_list = List.objects.get(id=list_id)
    items = Item.objects.filter(list=our_list)
    return render(request, "list.xhtml", {"items": items})

调整 new_list 到新世界

现在让我们解决only-list-in-the-world的失败:

FAIL: test_redirects_after_POST
[...]
AssertionError: 404 != 200 : Couldn't retrieve redirection page
'/lists/the-only-list-in-the-world/': response code was 404 (expected 200)

让我们看一看哪个测试在抱怨:

lists/tests.py

class NewListTest(TestCase):
    [...]

    def test_redirects_after_POST(self):
        response = self.client.post("/lists/new", data={"item_text": "A new list item"})
        self.assertRedirects(response, "/lists/the-only-list-in-the-world/")

看起来它还没有调整到ListItem的新世界。测试应该说这个视图重定向到它刚刚创建的特定新列表的 URL。

lists/tests.py (ch07l043)

    def test_redirects_after_POST(self):
        response = self.client.post("/lists/new", data={"item_text": "A new list item"})
        new_list = List.objects.get()
        self.assertRedirects(response, f"/lists/{new_list.id}/")

测试仍然失败,但是我们现在可以看一下视图本身,并更改它,使其重定向到正确的位置:

lists/views.py (ch07l044)

def new_list(request):
    nulist = List.objects.create()
    Item.objects.create(text=request.POST["item_text"], list=nulist)
    return redirect(f"/lists/{nulist.id}/")

这让我们回到了通过单元测试的状态:

$ python3 manage.py test lists
[...]
......
 ---------------------------------------------------------------------
Ran 6 tests in 0.033s

OK

功能测试怎么样?我们几乎到了吗?

功能测试检测到另一个回归

嗯,几乎:

F.
======================================================================
FAIL: test_can_start_a_todo_list
(functional_tests.tests.NewVisitorTest.test_can_start_a_todo_list)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "...goat-book/functional_tests/tests.py", line 63, in
test_can_start_a_todo_list
    self.wait_for_row_in_list_table("2: Use peacock feathers to make a fly")
[...]
AssertionError: '2: Use peacock feathers to make a fly' not found in ['1: Use
peacock feathers to make a fly']

 ---------------------------------------------------------------------
Ran 2 tests in 8.617s

FAILED (failures=1)

我们的 FT 实际上是通过的:不同的用户可以得到不同的列表。但是旧测试正在警告我们存在回归。看起来你不能再添加第二个项目到列表中了。这是因为我们的快速脏方法,在每个单独的 POST 提交中创建一个新列表。这正是我们有功能测试的原因!

它与我们待办事项清单上的最后一项相吻合:

另外一个视图来处理向现有列表添加项目

我们需要一个 URL 和视图来处理向现有列表添加新项目(/lists/<list_id>/add_item)。我们现在对这些已经很擅长了,所以让我们迅速合并一个:

lists/tests.py (ch07l045)

class NewItemTest(TestCase):
    def test_can_save_a_POST_request_to_an_existing_list(self):
        other_list = List.objects.create()
        correct_list = List.objects.create()

        self.client.post(
            f"/lists/{correct_list.id}/add_item",
            data={"item_text": "A new item for an existing list"},
        )

        self.assertEqual(Item.objects.count(), 1)
        new_item = Item.objects.get()
        self.assertEqual(new_item.text, "A new item for an existing list")
        self.assertEqual(new_item.list, correct_list)

    def test_redirects_to_list_view(self):
        other_list = List.objects.create()
        correct_list = List.objects.create()

        response = self.client.post(
            f"/lists/{correct_list.id}/add_item",
            data={"item_text": "A new item for an existing list"},
        )

        self.assertRedirects(response, f"/lists/{correct_list.id}/")
注意

你是否想知道 other_list?有点像查看特定列表的测试一样,我们重要的是将项目添加到特定列表中。向数据库添加这个第二个对象阻止了我在实现中使用 List.objects.first() 这样的 hack。是的,那将是一件愚蠢的事情,你可能会对不能做的愚蠢事情测试太过分(毕竟有无数的愚蠢事情)。这是一个判断,但这个感觉值得。关于这个问题我们还有更多的讨论[链接待定]。

因此,按预期失败,列表项未保存,新 URL 目前返回 404:

AssertionError: 0 != 1
[...]
AssertionError: 404 != 302 : Response didn't redirect as expected: Response
code was 404 (expected 302)

最后一个新 URL

现在我们得到了我们期望的 404,让我们为向现有列表添加新项目添加一个新的 URL:

superlists/urls.py (ch07l046)

urlpatterns = [
    path("", views.home_page, name="home"),
    path("lists/new", views.new_list, name="new_list"),
    path("lists/<int:list_id>/", views.view_list, name="view_list"),
    path("lists/<int:list_id>/add_item", views.add_item, name="add_item"),
]

那里有三个看起来非常相似的 URL。让我们在待办事项清单上做个备注;它们看起来是重构的好候选项:

回到测试中,我们得到了通常缺少模块视图对象的错误:

AttributeError: module 'lists.views' has no attribute 'add_item'

最后一个新视图

让我们试试:

lists/views.py(ch07l047)

def add_item(request):
    pass

Aha:

TypeError: add_item() got an unexpected keyword argument 'list_id'

lists/views.py(ch07l048)

def add_item(request, list_id):
    pass

然后:

ValueError: The view lists.views.add_item didn't return an HttpResponse object.
It returned None instead.

我们可以从new_list复制redirect()和从view_list复制List.objects.get()

lists/views.py(ch07l049)

def add_item(request, list_id):
    our_list = List.objects.get(id=list_id)
    return redirect(f"/lists/{our_list.id}/")

这带我们到了:

    self.assertEqual(Item.objects.count(), 1)
AssertionError: 0 != 1

最后我们让它保存我们的新列表项:

lists/views.py(ch07l050)

def add_item(request, list_id):
    our_list = List.objects.get(id=list_id)
    Item.objects.create(text=request.POST["item_text"], list=our_list)
    return redirect(f"/lists/{our_list.id}/")

我们又通过了测试。

Ran 8 tests in 0.050s

OK

直接测试模板上下文

我们有了一个用于将项目添加到现有列表的新视图和 URL;现在我们只需要实际在我们的list.xhtml模板中使用它。所以我们打开它来调整表单标签…​

lists/templates/list.xhtml

    <form method="POST" action="but what should we put here?">

…​哦。为了获取添加到当前列表的 URL,模板需要知道它正在渲染的列表,以及项目是什么。

我们希望能够做这样的事情:

lists/templates/list.xhtml

    <form method="POST" action="/lists/{{ list.id }}/add_item">

为了使其工作,视图将必须将列表传递给模板。让我们在ListViewTest中创建一个新的单元测试:

lists/tests.py(ch07l051)

    def test_passes_correct_list_to_template(self):
        other_list = List.objects.create()
        correct_list = List.objects.create()
        response = self.client.get(f"/lists/{correct_list.id}/")
        self.assertEqual(response.context["list"], correct_list)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png)

1

response.context代表我们将传递给渲染函数的上下文—​Django 测试客户端将其放在response对象上,以帮助测试。

这给了我们:

    self.assertEqual(response.context["list"], correct_list)
                     ~~~~~~~~~~~~~~~~^^^^^^^^
[...]
KeyError: 'list'

因为我们没有将list传递给模板。这实际上给了我们简化的机会:

lists/views.py(ch07l052)

def view_list(request, list_id):
    our_list = List.objects.get(id=list_id)
    return render(request, "list.xhtml", {"list": our_list})

当然,这引入了一个错误,因为模板需要items

FAIL: test_displays_only_items_for_that_list
[...]
AssertionError: False is not true : Couldn't find 'itemey 1' in response

但我们可以在list.xhtml中修复它,同时调整表单的 POST 操作,这正是我们试图做的:

lists/templates/list.xhtml(ch07l053)

    <form method="POST" action="/lists/{{ list.id }}/add_item">  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png) [...]

      {% for item in list.item_set.all %} ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/2.png)
        <tr><td>{{ forloop.counter }}: {{ item.text }}</td></tr> {% endfor %}

1

这是我们新的表单操作。

2

.item_set被称为反向查找。这是 Django ORM 中非常有用的一部分,允许您从不同表格查找对象的相关项...​

这样就通过了单元测试:

Ran 9 tests in 0.040s

OK

FTs 怎么样?

$ python manage.py test functional_tests
[...]
..
 ---------------------------------------------------------------------
Ran 2 tests in 9.771s

OK

万岁!哦,还有一个快速检查我们的待办事项清单:

令人恼火的是,测试山羊也是一个注重细节的人,所以我们必须做这最后一件事情。

在开始之前,我们会提交一次—​确保在进行重构之前有一个可工作状态的提交:

$ git diff
$ git commit -am "new URL + view for adding to existing lists. FT passes :-)"

最终使用 URL 包含进行重构

superlists/urls.py实际上是为适用于整个站点的 URL 而设计的。对于仅适用于lists应用程序的 URL,Django 鼓励我们使用单独的lists/urls.py,以使应用程序更加自包含。制作一个的最简单方法是使用现有的urls.py的副本:

$ cp superlists/urls.py lists/

然后我们将superlists/urls.py中的三行特定于列表的行替换为include()

superlists/urls.py(ch07l055)

from django.urls import include, path
from lists import views as list_views  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/tstdvn-dev-py-3e/img/1.png)

urlpatterns = 
    path("", list_views.home_page, name="home"),
    path("lists/", include("lists.urls")),  ![2
]

1

当我们使用import x as y语法来为views起别名时,这在你的顶级urls.py中是一个良好的实践,因为这样我们可以从多个应用中导入视图——确实,我们稍后在本书中会需要这样做。

2

这里是include。请注意,它可以将 URL 的一部分作为前缀,这将应用于所有包含的 URL(这是我们减少重复的地方,同时给我们的代码更好的结构)。

回到lists/urls.py,我们可以只包含我们的三个 URL 的后半部分,而不包含父urls.py中的其他内容:

lists/urls.py (ch07l056)

from django.urls import path
from lists import views

urlpatterns = [
    path("new", views.new_list, name="new_list"),
    path("<int:list_id>/", views.view_list, name="view_list"),
    path("<int:list_id>/add_item", views.add_item, name="add_item"),
]

重新运行单元测试以确保一切工作正常。

Ran 9 tests in 0.040s

OK

当我看到它时,我不太相信我第一次就做对了。始终怀疑自己的能力是值得的,所以我故意稍微改变了一个 URL,只是为了检查是否会破坏一个测试。它确实会。我们已经有所准备。

欢迎随意尝试自己操作!记得改回来,检查所有测试是否再次通过,然后进行最终提交:

$ git status
$ git add lists/urls.py
$ git add superlists/urls.py
$ git diff --staged
$ git commit

哎呀。一个马拉松章节。但我们涵盖了许多重要主题,从设计思考开始。我们涵盖了像“YAGNI”和“三次错误再重构”这样的经验法则。但更重要的是,我们看到了如何逐步调整现有的代码库,从一个工作状态迭代到另一个工作状态,以向新设计前进。

我会说我们已经相当接近能够发布这个网站了,作为即将占领世界的 superlists 网站的第一个 beta 版。也许需要稍微美化一下…让我们看看在接下来的几章中需要部署它的内容。

¹ 你是否对“nulist”变量的奇怪拼写感到困惑?其他选项有“list”,这将掩盖内置的list()函数,以及new_list,这将掩盖包含它的函数名称。或者是list1listeymylist,但没有一个特别令人满意。

第二部分:网络开发 必不可少的

真正的开发者会将产品发布。

杰夫·阿特伍德

如果这只是一个普通编程领域 TDD 的指南,我们现在可能已经可以自我感到满意了。毕竟,我们已经掌握了 TDD 和 Django 的一些坚实基础;我们已经具备了开始构建网站所需的一切。

但是,真正的开发者会将产品发布,为了能够发布,我们将不得不解决一些更棘手但是不可避免的网络开发问题:静态文件、表单数据验证、令人生畏的 JavaScript,但最棘手的是部署到生产服务器。

在每个阶段,TDD 也可以帮助我们做好这些事情。

在这一部分,我仍然试图保持学习曲线相对平缓,但是我们将会接触到几个重要的新概念和技术。我只能简单涉及每一个——我希望能展示足够的内容让你开始自己的项目时能够入门,但是当你在“现实生活”中应用这些主题时,你也需要自己多加阅读。

例如,如果你在开始阅读本书之前并不熟悉 Django,那么此时花一点时间浏览一下官方 Django 教程可能会很好地补充你到目前为止学到的内容,并且会让你在接下来的几章中更加自信地处理 Django 相关的事务,这样你就可以专注于核心概念。

哦,但是还有很多有趣的东西要来了!你拭目以待吧!

第八章:美化:布局和样式,以及需要测试的内容

我们开始考虑发布我们网站的第一个版本,但目前它看起来有点丑。在本章中,我们将涵盖一些基本的样式内容,包括集成一个名为 Bootstrap 的 HTML/CSS 框架。我们将学习静态文件在 Django 中的工作原理,以及我们需要测试的内容。

测试布局和样式

目前我们的网站确实有点不太吸引人(Figure 8-1)。

注意

如果您使用 manage.py runserver 启动开发服务器,可能会遇到数据库错误“表 lists_item 没有名为 list_id 的列”。您需要更新本地数据库以反映我们在 models.py 中所做的更改。使用 manage.py migrate。如果出现 IntegrityErrors 的问题,只需删除数据库文件¹ 然后重试。

我们不能再让 Python 因为丑陋而声名狼藉了(ugly),所以让我们稍微进行一些修饰。以下是一些我们可能需要的事项:

  • 一个漂亮的大输入字段,用于添加新的和现有的列表

  • 一个大的、引人注目的、居中的框来放置它

如何将 TDD 应用于这些内容?大多数人会告诉你不应该测试美学,他们是对的。这有点像测试常量,通常测试不会增加任何价值。

我们的主页,看起来有点丑。

图 8-1。我们的主页,看起来有点丑…​

但是我们可以测试我们美学的基本行为,也就是我们是否有任何美学。我们只想要确认事物是否工作。例如,我们将使用层叠样式表(CSS)进行样式设置,它们作为静态文件加载。静态文件配置可能有些棘手(特别是当您从自己的计算机移动到托管网站时,后面我们会看到),因此我们需要一种简单的“烟雾测试”,来确认 CSS 已加载。我们不必测试字体和颜色以及每一个像素,但我们可以快速检查每个页面上主输入框的对齐方式,这将使我们确信页面的其余样式也已加载。

让我们在我们的功能测试中添加一个新的测试方法:

functional_tests/tests.py(ch08l001)

class NewVisitorTest(LiveServerTestCase):
    [...]

    def test_layout_and_styling(self):
        # Edith goes to the home page,
        self.browser.get(self.live_server_url)

        # Her browser window is set to a very specific size
        self.browser.set_window_size(1024, 768)

        # She notices the input box is nicely centered
        inputbox = self.browser.find_element(By.ID, "id_new_item")
        self.assertAlmostEqual(
            inputbox.location["x"] + inputbox.size["width"] / 2,
            512,
            delta=10,
        )

一些新的内容在这里。我们首先将窗口大小设置为固定大小。然后找到输入元素,查看其大小和位置,并进行一些数学计算,检查它是否似乎位于页面的中间。assertAlmostEqual 帮助我们处理由于滚动条等因素导致的四舍五入错误和偶尔的怪异行为,通过让我们指定我们希望我们的算术工作在加减 10 像素的范围内。

如果我们运行功能测试,我们会得到:

$ python manage.py test functional_tests
[...]
.F.
======================================================================
FAIL: test_layout_and_styling
(functional_tests.tests.NewVisitorTest.test_layout_and_styling)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "...goat-book/functional_tests/tests.py", line 120, in
test_layout_and_styling
    self.assertAlmostEqual(
AssertionError: 103.333... != 512 within 10 delta (408.666...
difference)

 ---------------------------------------------------------------------
Ran 3 tests in 9.188s

FAILED (failures=1)

这就是预期的失败。尽管如此,这种 FT 很容易出错,所以让我们使用一个快速而肮脏的“作弊”解决方案,来检查当输入框居中时 FT 是否确实通过。我们用它来检查 FT 后,几乎立刻会删除这段代码:

lists/templates/home.xhtml (ch08l002)

<form method="POST" action="/lists/new">
  <p style="text-align: center;">
    <input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />
  </p>
  {% csrf_token %}
</form>

通过了测试,这意味着 FT 起作用了。让我们扩展一下,确保输入框在页面上也是居中对齐的:

functional_tests/tests.py (ch08l003)

    # She starts a new list and sees the input is nicely
    # centered there too
    inputbox.send_keys("testing")
    inputbox.send_keys(Keys.ENTER)
    self.wait_for_row_in_list_table("1: testing")
    inputbox = self.browser.find_element(By.ID, "id_new_item")
    self.assertAlmostEqual(
        inputbox.location["x"] + inputbox.size["width"] / 2,
        512,
        delta=10,
    )

这给我们带来了另一个测试失败:

  File "...goat-book/functional_tests/tests.py", line 132, in
test_layout_and_styling
    self.assertAlmostEqual(
AssertionError: 103.333... != 512 within 10 delta (408.666...

让我们仅提交 FT:

$ git add functional_tests/tests.py
$ git commit -m "first steps of FT for layout + styling"

现在感觉我们有理由为网站的一些更好的样式需求找到一个“合适的”解决方案。我们可以撤销我们的 hacky text-align: center

$ git reset --hard

警告:git reset --hard是“从轨道上射出导弹”Git 命令,所以要小心——它会清除掉你所有未提交的更改。与 Git 的几乎所有其他操作不同,这个操作后没有回头的余地。

美化:使用 CSS 框架

UI 设计很难,特别是现在我们必须处理移动设备、平板等。这就是为什么许多程序员,特别是像我这样的懒人,转向 CSS 框架来解决其中的一些问题。市面上有很多框架可供选择,但最早也是最流行的之一,是 Twitter 的 Bootstrap。让我们来使用它吧。

你可以在http://getbootstrap.com/找到 Bootstrap。

我们将下载它并放入名为static的新文件夹中,放在lists应用程序内:²

$ wget -O bootstrap.zip https://github.com/twbs/bootstrap/releases/download/\
v5.3.0/bootstrap-5.3.0-dist.zip
$ unzip bootstrap.zip
$ mkdir lists/static
$ mv bootstrap-5.3.0-dist lists/static/bootstrap
$ rm bootstrap.zip

Bootstrap 在dist文件夹中提供了一个简单的未自定义安装。暂时我们会使用它,但是在真正的网站上你应该尽量避免这样做——原版的 Bootstrap 非常容易识别,对任何了解情况的人来说都是一个明显的信号,表明你懒得为你的网站定制样式。学习如何使用 Sass 并更改字体,这是必须的!Bootstrap 的文档中有相关信息,或者在https://www.freecodecamp.org/news/how-to-customize-bootstrap-with-sass/ [介绍指南]中也能找到。

我们的lists文件夹最终会变成这个样子:

$ tree lists
lists
├── __init__.py
├── admin.py
├── apps.py
├── migrations
│   ├── [...]
├── models.py
├── static
│   └── bootstrap
│       ├── css
│       │   ├── bootstrap.css
│       │   ├── bootstrap.css.map
│       │   ├── [...]
│       │   └── bootstrap-utilities.rtl.min.css.map
│       └── js
│           ├── bootstrap.bundle.js
│           ├── bootstrap.bundle.js.map
│           ├── [...]
│           └── bootstrap.min.js.map
├── templates
│   ├── home.xhtml
│   └── list.xhtml
├── [...]

查看Bootstrap 文档的“入门”部分;你会看到它希望我们的 HTML 模板包含类似这样的内容:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Bootstrap demo</title>
  </head>
  <body>
    <h1>Hello, world!</h1>
  </body>
</html>

我们已经有了两个 HTML 模板。我们不想在每个模板中添加大量的样板代码,现在是应用“不要重复自己”规则的合适时机,把所有共同的部分合并起来。幸运的是,Django 模板语言通过使用称为模板继承的功能使这一切变得很容易。

Django 模板继承

让我们来小结一下home.xhtmllist.xhtml之间的区别:

$ diff lists/templates/home.xhtml lists/templates/list.xhtml
<     <h1>Start a new To-Do list</h1>
<     <form method="POST" action="/lists/new">
---
>     <h1>Your To-Do list</h1>
>     <form method="POST" action="/lists/{{ list.id }}/add_item">
[...]
>     <table id="id_list_table">
>       {% for item in list.item_set.all %}
>         <tr><td>{{ forloop.counter }}: {{ item.text }}</td></tr>
>       {% endfor %}
>     </table>

它们有不同的标题文本,它们的表单使用不同的 URL。此外,list.xhtml还有额外的<table>元素。

现在我们清楚了共同点和不同点,我们可以让这两个模板继承自一个共同的“超类”模板。我们将从制作list.xhtml的副本开始:

$ cp lists/templates/list.xhtml lists/templates/base.xhtml

我们将其制作成一个基本模板,仅包含常见的样板内容,并标出“块”,即子模板可以自定义的地方:

lists/templates/base.xhtml (ch08l007)

<html>
  <head>
    <title>To-Do lists</title>
  </head>

  <body>
    <h1>{% block header_text %}{% endblock %}</h1>

    <form method="POST" action="{% block form_action %}{% endblock %}">
      <input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />
      {% csrf_token %}
    </form>

    {% block table %}
    {% endblock %}
  </body>

</html>

基本模板定义了一系列称为“块”的区域,这些区域将是其他模板可以连接并添加其自己内容的地方。让我们看看在实践中如何工作,通过修改home.xhtml,使其“继承自”base.xhtml

lists/templates/home.xhtml (ch08l008)

{% extends 'base.xhtml' %}

{% block header_text %}Start a new To-Do list{% endblock %}

{% block form_action %}/lists/new{% endblock %}

您可以看到大量的样板 HTML 消失了,我们只专注于想要自定义的部分。我们对list.xhtml也采取同样的做法:

lists/templates/list.xhtml (ch08l009)

{% extends 'base.xhtml' %}

{% block header_text %}Your To-Do list{% endblock %}

{% block form_action %}/lists/{{ list.id }}/add_item{% endblock %}

{% block table %}
  <table id="id_list_table">
    {% for item in list.item_set.all %}
      <tr><td>{{ forloop.counter }}: {{ item.text }}</td></tr>
    {% endfor %}
  </table>
{% endblock %}

这是我们模板工作方式的重构。我们重新运行 FT 来确保我们没有破坏任何东西…​

AssertionError: 103.333... != 512 within 10 delta (408.666...

毫无疑问,他们仍然达到了之前的目标。这值得提交:

$ git diff -w
# the -w means ignore whitespace, useful since we've changed some html indenting
$ git status
$ git add lists/templates # leave static, for now
$ git commit -m "refactor templates to use a base template"

集成 Bootstrap

现在更容易集成 Bootstrap 想要的样板代码—​我们暂时不加入 JavaScript,只加入 CSS:

lists/templates/base.xhtml (ch08l010)

<!doctype html>
<html lang="en">

  <head>
    <title>To-Do lists</title>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link href="css/bootstrap.min.css" rel="stylesheet">
  </head>
[...]

行和列

最后,让我们实际使用一些 Bootstrap 魔法!您将不得不自己阅读文档,但我们应该能够使用网格系统和justify-content-center类的组合来实现我们想要的效果:

lists/templates/base.xhtml (ch08l011)

  <body>
    <div class="container">

      <div class="row justify-content-center">
        <div class="col-lg-6 text-center">
          <h1>{% block header_text %}{% endblock %}</h1>

          <form method="POST" action="{% block form_action %}{% endblock %}" >
            <input
              name="item_text"
              id="id_new_item"
              placeholder="Enter a to-do item"
            />
            {% csrf_token %}
          </form>
        </div>
      </div>

      <div class="row justify-content-center">
        <div class="col-lg-6">
          {% block table %}
          {% endblock %}
        </div>
      </div>

    </div>
  </body>

(如果你从未见过 HTML 标签跨越多行,那么<input>可能有点让人吃惊。它肯定是有效的,但如果你觉得不合适,你可以选择不使用它;)

提示

如果你以前从未见过,花些时间浏览一下Bootstrap 文档。它是一个装满了在你的网站中使用的有用工具的购物车。

这样可以吗?

AssertionError: 103.333... != 512 within 10 delta (408.666...

嗯。不对。为什么我们的 CSS 没有加载?

Django 中的静态文件

Django,以及任何 Web 服务器,需要知道处理静态文件的两个内容:

  1. 如何区分 URL 请求是静态文件,还是通过视图函数提供的 HTML

  2. 用户想要的静态文件在哪里找

换句话说,静态文件是从 URL 到磁盘文件的映射。

对于项目 1,Django 允许我们定义一个 URL“前缀”,以表示任何以该前缀开头的 URL 都应被视为静态文件请求。默认情况下,前缀是/static/。它在settings.py中定义:

superlists/settings.py

[...]

# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/

STATIC_URL = "static/"

我们将在本节中添加的其余设置,都是关于项目 2:找到实际静态文件在磁盘上的位置。

当我们使用 Django 开发服务器(manage.py runserver)时,我们可以依赖 Django 自动为我们找到静态文件—​它只会查找我们应用程序的任何子文件夹中名为static的文件夹。

现在你看到为什么我们把所有的 Bootstrap 静态文件放到lists/static中了。那么为什么它们现在不起作用呢?因为我们没有使用/static/ URL 前缀。再看一下base.xhtml中 CSS 的链接:

    <link href="css/bootstrap.min.css" rel="stylesheet">

那个href只是碰巧在 bootstrap 文档中出现的。为了让它工作,我们需要将其更改为:

lists/templates/base.xhtml(ch08l012)

    <link href="/static/bootstrap/css/bootstrap.min.css" rel="stylesheet">

现在,当runserver接收到请求时,它知道这是一个静态文件,因为请求以/static/开头。然后它尝试在每个应用程序文件夹的名为static的子文件夹中查找名为bootstrap/css/bootstrap.min.css的文件,它应该能在lists/static/bootstrap/css/bootstrap.min.css找到它。

因此,如果您手动查看,您应该能看到它正常工作,如图 8-2 所示(#list-page-centered)。

列表页面,标题居中。

图 8-2. 我们的网站开始看起来好多了…​

切换到 StaticLiveServerTestCase

如果你运行功能测试,令人恼火的是,它仍然无法通过:

AssertionError: 103.333... != 512 within 10 delta (408.666...

这是因为,虽然runserver自动找到静态文件,LiveServerTestCase却没有。但别担心:Django 开发人员已经制作了一个更神奇的测试类叫做StaticLiveServerTestCase(参见文档)。

让我们切换到那个:

functional_tests/tests.py(ch08l013)

@@ -1,14 +1,14 @@
-from django.test import LiveServerTestCase
+from django.contrib.staticfiles.testing import StaticLiveServerTestCase
 from selenium import webdriver
 from selenium.common.exceptions import WebDriverException
 from selenium.webdriver.common.keys import Keys
 import time

 MAX_WAIT = 10

-class NewVisitorTest(LiveServerTestCase):
+class NewVisitorTest(StaticLiveServerTestCase):

     def setUp(self):

现在它将找到新的 CSS,这将使我们的测试通过:

$ python manage.py test functional_tests
Creating test database for alias 'default'...
...
 ---------------------------------------------------------------------
Ran 3 tests in 9.764s

万岁!

使用 Bootstrap 组件来改善网站的外观

让我们看看是否可以更进一步,利用 Bootstrap 全套工具中的一些其他工具。

jumbotron!

Bootstrap 的第一个版本曾经附带一个名为jumbotron的类,用于突出显示页面上特别重要的内容。它已经不存在了,但像我这样的老手仍然对它念念不忘,因此他们在文档中有一个专门的页面告诉你如何重新创建它。

本质上,我们大幅增大了主页面的页头和输入表单,将其放入一个带有漂亮圆角的灰色框中:

lists/templates/base.xhtml(ch08l014)

  <body>
    <div class="container">

      <div class="row justify-content-center p-5 bg-body-tertiary rounded-3">
        <div class="col-lg-6 text-center">
          <h1 class="display-1 mb-4">{% block header_text %}{% endblock %}</h1>
          [...]

这最终看起来像是图 8-3:

首页,标题和输入周围有一个大灰色框

图 8-3. 页面顶部的大灰色框
提示

当进行设计和布局时,最好有一个可以频繁刷新的窗口。使用python manage.py runserver启动开发服务器,然后浏览至http://localhost:8000来查看您的工作进展。

大型输入

jumbotron 是一个不错的开始,但现在输入框的文本与其他内容相比太小了。幸运的是,Bootstrap 的表单控制类提供了将输入框设置为“大号”的选项:

lists/templates/base.xhtml(ch08l015)

    <input
      class="form-control form-control-lg"
      name="item_text"
      id="id_new_item"
      placeholder="Enter a to-do item"
    />

表格样式

相对于页面的其他部分,表格文本看起来也太小了。在list.xhtml中添加 Bootstrap 的table类可以改善这一点:

lists/templates/list.xhtml (ch08l016)

  <table class="table" id="id_list_table">

黑暗模式啊啊啊啊啊

与我对 Jumbotron 的怀旧不同,这里是相对于 Bootstrap 相对较新的东西,即黑暗模式!

lists/templates/base.xhtml (ch08l017)

<!doctype html>
<html lang="en" data-bs-theme="dark">

看一下图 8-4。我觉得那看起来很棒!

黑暗模式中的列表页面截图。酷。

图 8-4. 列表页面变暗

但这在很大程度上是个人偏好的问题,如果我让我所有的其他截图都用上这么多的墨水,我的编辑器会杀了我,所以我现在要回退它。如果你喜欢,你可以保留它!

一个半体面的页面

我试了几次,但现在我对它还算满意(参见图 8-5)。

带有体面样式的浅色模式列表页面截图。

图 8-5. 列表页面,现在看起来还不错…​

如果你想进一步定制 Bootstrap,你需要深入了解编译 Sass。我已经说过了,但我 绝对 推荐有一天花时间去做这件事。Sass/SCSS 对于普通的 CSS 是一个很大的改进,即使你不使用 Bootstrap,它也是一个很有用的工具。

最后运行一下功能测试,看看是否一切都还正常:

$ python manage.py test functional_tests
[...]
...
 ---------------------------------------------------------------------
Ran 3 tests in 10.084s

OK

就这样了!绝对是提交的时候了:

$ git status # changes tests.py, base.xhtml, list.xhtml, settings.py, + untracked lists/static
$ git add .
$ git status # will now show all the bootstrap additions
$ git commit -m "Use Bootstrap to improve layout"

我们忽略的内容:collectstatic 和其他静态目录

我们之前看到 Django 开发服务器会自动找到应用文件夹中的所有静态文件,并为您提供服务。这在开发过程中是可以的,但当你在真实的 Web 服务器上运行时,你不希望 Django 为您提供静态内容——使用 Python 来提供原始文件是慢而低效的,像 Apache 或 Nginx 这样的 Web 服务器可以为您完成这一切。您甚至可以决定将所有的静态文件上传到 CDN,而不是自己托管它们。

出于这些原因,你希望能够从各个应用文件夹中收集起所有的静态文件,并将它们复制到一个单一的位置,以备部署使用。这就是 collectstatic 命令的作用。

目的地,即收集到的静态文件所在的位置,是在 settings.py 中定义的 STATIC_ROOT。在下一章中,我们将进行一些部署工作,所以现在让我们实验一下。一个常见且简单的放置位置是在我们的 repo 根目录中的一个名为“static”的文件夹中:

.
├── db.sqlite3
├── functional_tests/
├── lists/
├── manage.py
├── static/
└── superlists/

这是指定该文件夹的一个简洁的方法,使其相对于项目基本目录的位置:

superlists/settings.py (ch08l019)

# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/

STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "static"

查看设置文件的顶部,你会看到 BASE_DIR 变量是如何用 pathlib.Path__file__(都是 Python 的很好的内置函数)来定义的(参见 3)。

无论如何,让我们试着运行 collectstatic

$ python manage.py collectstatic

169 static files copied to '...goat-book/static'.

如果我们查看 ./static,我们会发现所有的 CSS 文件:

$ tree -v static/
static/
├── admin
│   ├── css
│   │   ├── autocomplete.css
│   │   ├── [...]
[...]
│               └── xregexp.min.js
└── bootstrap
    ├── css
    │   ├── bootstrap-grid.css
    │   ├── [...]
    │   └── bootstrap.rtl.min.css.map
    └── js
        ├── bootstrap.bundle.js
        ├── [...]
        └── bootstrap.min.js.map

16 directories, 169 files

collectstatic 还找到了所有用于管理站点的 CSS。管理站点是 Django 的一个强大功能,但是对于我们简单的站点来说,我们不需要它,所以现在让我们将其禁用:

superlists/settings.py

INSTALLED_APPS = [
    # "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "lists",
]

我们再试一次:

$ rm -rf static/
$ python manage.py collectstatic

44 static files copied to '...goat-book/static'.

很好。

现在我们知道如何将所有静态文件收集到一个文件夹中,这样 Web 服务器就可以轻松找到它们。我们将在下一章中详细了解所有这些,包括如何测试它!

现在让我们保存对 settings.py 的更改。我们还将顶级 static 文件夹添加到 gitignore 中,因为它只包含我们实际保留在各个应用程序的 static 文件夹中的文件的副本。

$ git diff # should show changes in settings.py plus the new directory*
$ echo /static >> .gitignore
$ git commit -am "set STATIC_ROOT in settings and disable admin"

未被采纳的一些事情

不可避免地,这只是对样式和 CSS 的一个快速浏览,有几个我考虑过要涵盖但最终没有涵盖的主题。以下是一些进一步研究的候选课题:

  • {% static %} 模板标签,实现更干净的代码和少量硬编码的 URL

  • 客户端打包工具,如 npmbower

  • 再次,使用 SASS 自定义 bootstrap

¹ 什么?删除数据库?你疯了吗?并非完全如此。本地开发数据库经常与其迁移不同步,因为我们在开发过程中来来回回,而且里面没有任何重要数据,所以偶尔清理一下是可以的。一旦我们在服务器上有了“生产”数据库,我们会更加小心谨慎。更多信息请参见[待添加链接]。

² 在 Windows 上,你可能没有 wgetunzip,但我相信你可以想办法下载 Bootstrap,解压缩并将 dist 文件夹的内容放入 lists/static/bootstrap 文件夹中。

³ 注意在 Pathlib 处理 __file__ 时,.resolve() 是发生在任何其他操作之前的。在处理 __file__ 时,始终遵循这种模式,否则可能会看到依赖于文件被如何导入的不可预测行为。感谢 Green Nathan 提供的建议!

作者简介

在度过了与 BASIC 玩耍的宁静童年后,使用过像 Thomson T-07 这样的法国 8 位计算机,按下键盘按键时会发出“boop”声音,Harry 在经历了对经济学和管理咨询深感不满的几年后,重新发现了自己真正的极客本性,并有幸加入了一群 XP 狂热者,共同开发了开创性但遗憾关闭的 Resolver One 电子表格软件。他现在在 PythonAnywhere LLP 工作,并在全球的演讲、研讨会和会议上传播 TDD 的福音,怀着一颗新近皈依者的激情和热情。

posted @ 2024-06-17 19:07  绝不原创的飞龙  阅读(15)  评论(0编辑  收藏  举报