前言

我试图通过这本书与世人分享我从黑客变成软件工程师的过程。本书主要介绍测试,但很快你就会发现,除此之外还有很多其他内容。

感谢你阅读本书。

如果你购买了本书,我十分感激。如果你看的是免费在线版,我仍然要感谢你,因为你确定这本书值得花时间来阅读。谁知道呢,说不定等你读完之后,会决定为自己或朋友买一本纸质书。

如果你有任何评论、疑问或建议,希望你能写信告诉我。你可以通过电子邮件直接和我联系,地址是 obeythetestinggoat@gmail.com;或者在 Twitter 上联系我,我的用户名是 @hjwp。你还可以访问本书的网站和博客(http://www.obeythetestinggoat.com/),以及邮件列表(https://groups.google.com/forum/#!forum/obeythetestinggoat-book)。

希望阅读本书能让你身心愉悦,就像我在写作本书时感到享受一样。

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

我知道你会问:“你是谁,为什么要写这本书,我为什么要读这本书?”

我至今仍然处在编程事业的初期。人们说,不管从事什么工作,都要历经从新手到熟手的过程,最终有可能成为大师。我要说的是,我最多算是个熟练的程序员。但我很幸运,在事业的早期阶段就结识了一群测试驱动开发(Test-Driven Development,TDD)的狂热爱好者,这对我的编程事业产生了极大影响,让我迫不及待地想和所有人分享这段经历。可以说,我很积极地做出了最近这次转变,而且这段学习经历现在还历历在目,我希望能让初学者感同身受。

我在开始学习 Python 时(看的是 Mark Pilgrim 写的 Dive Into Python),偶然知道了 TDD 的概念。我当时就认为:“是的,我绝对知道这个概念的意义所在。”或许你第一次听说 TDD 时也有类似的反应吧。它听起来像是一个非常合理的方案,一个需要养成的非常好的习惯——就像经常刷牙。

随后我做了第一个大型项目。你可能猜到了,有项目就会有客户,有最后期限,有很多事情要做。于是,所有关于 TDD 的好想法都被抛诸脑后。

的确,这对项目没什么影响,对我也没影响。

但只是在初期如此。

一开始,我知道并不真的需要使用 TDD,因为我做的是个小网站,手动检查就能轻易测试出是否能用。在这儿点击链接,在那儿选中下拉菜单选项,就应该有预期的效果,很简单。编写整套测试程序听起来似乎要花费很长时间,而且经过整整三周成熟的代码编写经历,我自负地认为自己已经成为一名出色的程序员了,我能顺利完成这个项目,这没什么难度。

随后,项目变得复杂得可怕,这很快暴露了我的经验不足。

项目不断变大。系统的不同部分之间要开始相互依赖。我尽量遵守良好的开发原则,例如“不要自我重复”(Don't Repeat Yourself,DRY),却被带进了一片危险地带。我很快就用到了多重继承,类的继承有八个层级深,还用到了 eval 语句。

我不敢修改代码,不再像以前一样知道什么依赖什么,也不知道修改某处的代码可能会导致什么后果。噢,天呐,我觉得那部分继承自这里,不,不是继承,是重新定义了,可是却依赖那个类变量。嗯,好吧,如果我再次重定义以前重定义的部分,应该就可以了。我会检查的,可是检查变得更难了。网站中的内容越来越多,手动点击变得不切实际了。最好别动这些能运行的代码,不要重构,就这么凑合吧。

很快,代码就变得像一团麻,丑陋不堪。开发新功能变得很痛苦。

在此之后不久,我幸运地在 Resolver Systems 公司(现在叫 PythonAnywhere)找到了一份工作。这个公司遵循极限编程(Extreme Programming,XP)开发理念。他们向我介绍了严密的 TDD。

虽然之前的经验的确让我认识到自动化测试的好处,但我在每个阶段都心存疑虑。“我的意思是,测试通常来说可能是个不错的主意,但果真如此吗?全部都要测试吗?有些测试看起来完全是在浪费时间……什么?除了单元测试之外还要做功能测试?得了吧,这是多此一举!还要走一遍测试驱动开发中的‘测试 小幅度代码改动 测试’循环?太荒谬了!我们不需要这种婴儿学步般的过程!既然我们知道正确的答案是什么,为什么不直接跳到最后一步呢?”

相信我!我审视过每一条规则,给每一条捷径提出过建议,为 TDD 的每一个看似毫无意义的做法寻找过理由,最终,我发现了采用 TDD 的明智之处。我记不清在心里说过多少次“谢谢你,测试”,因为功能测试能揭示我们可能永远都无法预测的回归,单元测试能让我避免犯很愚蠢的逻辑错误。从心理学上讲,TDD 大大降低了开发过程中的压力,而且写出的代码让人赏心悦目。

那么,让我告诉你关于 TDD 的一切吧!

写作本书的目的

我写这本书的主要目的是要传授一种用于 Web 开发的方法,它可以让 Web 应用变得更好,也能让开发者更愉快。一本书如果只包含一些上网搜索就能找到的知识,那它就没多大的意思了,所以本书不是 Python 句法指南,也不是 Web 开发教程。我希望教会你的,是如何使用 TDD 理念,更加稳妥地实现我们共同的神圣目标——简洁可用的代码

即便如此,我仍会从零开始使用 Django、Selenium、jQuery 和 Mock 等工具开发一个 Web 应用,不断提到一个真实可用的示例。阅读本书之前,你无须了解这些工具。读完本书后,你会充分了解这些工具,并掌握 TDD 理念。

在极限编程实践中,我们总是结对编程。写这本书时,我设想自己和以前的自己结成对子,向以前的我解释如何使用这些工具,回答为什么要用这种特别的方式编写代码。所以,如果我表现得有点儿屈尊俯就,那是因为我不是那么聪明,我要对自己很有耐心。如果觉得我说话冒犯了你,那是因为我有点儿烦人,经常不认同别人的说法,所以有时要花很多时间论证,说服自己接受他人的观点。

本书结构

我将这本书分成了三个部分。

  • 第一部分(第 1~7 章):基础知识

开门见山,介绍如何使用 TDD 开发一个简单的 Web 应用。我们会先(用 Selenium)写一个功能测试,然后介绍 Django 的基础知识,包括模型、视图和模板。在每个阶段,我们都会编写严格的单元测试。除此之外,我还会向你引荐测试山羊。

  • 第二部分(第 8~17 章):Web 开发要素

介绍 Web 开发过程中一些棘手但不可避免的问题,并展示如何通过测试解决这些问题,包括静态文件、部署到生产环境、表单数据验证、数据库迁移和令人畏惧的 JavaScript。

  • 第三部分(第 18~26 章):高级话题

介绍模拟技术、集成第三方系统、测试固件、由外而内的 TDD 流程以及持续集成(Continuous Integration,CI)。

排版约定

本书使用了下列排版约定。

  • 黑体

表示新术语或强调的内容。

  • 等宽字体(Constant width

表示程序片段,以及正文中出现的变量、函数名、数据库、数据类型、环境变量、语句和关键字等。

  • 加粗等宽字体(Constant width bold

表示应该由用户输入的命令或其他文本。

偶尔使用 […] 符号表示省略了一些内容,截断较长的输出,或者跳到相关的内容。

01 - 图1 该图标表示提示或建议。

01 - 图2 该图标表示提示、建议或一般注记。

01 - 图3 该图标表示警告或警示。

提交勘误

发现了错误或错别字?本书的相关资源放在 GitHub 上,欢迎你随时提交工单和拉取请求:https://github.com/hjwp/Book-TDD-Web-Dev-Python/

如果发现中文版有错误或错别字,欢迎提交勘误至 http://www.ituring.com.cn/book/2052

使用代码示例

代码示例可到 https://github.com/hjwp/bookexample/ 下载,各章的代码都放在单独的分支中,请到 http://www.ituring.com.cn/book/2052“随书下载”处下载。附录 J 中有这个仓库的使用方法。

本书是要帮你完成工作的。一般来说,如果本书提供了示例代码,你可以把它用在你的程序或文档中。除非你使用了很大一部分代码,否则无须联系我们获得许可。比如,用本书的几个代码片段写一个程序就无须获得许可,销售或分发 O'Reilly 图书的示例光盘则需要获得许可;引用本书中的示例代码回答问题无须获得许可,将书中大量的代码放到你的产品文档中则需要获得许可。

我们很希望但并不强制要求你在引用本书内容时加上引用说明。引用说明一般包括书名、作者、出版社和 ISBN。比如:“Test-Driven Development with Python, 2nd edition, by Harry Percival (O'Reilly). Copyright 2017 Harry Percival, 978-1-491-95870-4.”

如果你觉得自己对示例代码的用法超出了上述许可的范围,欢迎你通过 permissions@oreilly.com 与我们联系。

O'Reilly Safari

01 - 图4

Safari(以前叫 Safari Books Online,http://www.safaribooksonline.com)是会员制平台,为企业、政府、教学人员和个人提供培训和参考资料。

会员可以访问上千种图书、培训视频、学习路径、交互式教程和精心制定的播放列表。这些资源由 250 多家出版社提供,包括 O'Reilly Media、Harvard Business Review、Prentice Hall Professional、Addison-Wesley Professional、Microsoft Press、Sams、Que、Peachpit Press、Adobe、Focal Press、Cisco Press、John Wiley & Sons、Syngress、Morgan Kaufmann、IBM Redbooks、Packt、Adobe Press、FT Press、Apress、Manning、New Riders、McGraw-Hill、Jones & Bartlett 和 Course Technology,等等。

详情请访问 http://oreilly.com/safari

联系我们

请把对本书的评价和问题发给出版社。美国:

O'Reilly Media, Inc.

1005 Gravenstein Highway North

Sebastopol, CA 95472

中国:

北京市西城区西直门南大街 2 号成铭大厦 C 座 807 室(100035)

奥莱利技术咨询(北京)有限公司

O'Reilly 的每一本书都有专属网页,你可以在那儿找到本书的相关信息,包括勘误表、示例代码以及其他信息。本书的网站地址是:

http://shop.oreilly.com/product/0636920029533.do

对于本书的评论和技术性问题,请发送电子邮件到:bookquestions@oreilly.com

要了解更多 O'Reilly 图书、培训课程、会议和新闻的信息,请访问以下网站:

http://www.oreilly.com

我们在 Facebook 的地址如下:http://facebook.com/oreilly

请关注我们的 Twitter 动态:http://twitter.com/oreillymedia

我们的 YouTube 视频地址如下:http://www.youtube.com/oreillymedia

电子书

如需购买本书电子版,请扫描以下二维码。

{%}

准备工作和应具备的知识

我假设读者具备了如下的知识,电脑中还应该安装一些软件。

了解Python 3,会编程

写这本书时,我考虑到了初学者。但如果你刚接触编程,我假设你已经学习了 Python 基础知识。如果还没学,请阅读一份 Python 初学者教程,或者买一本入门书,比如 Dive Into Python 或《“笨办法”学 Python》,或者出于兴趣,看一下《Python 游戏编程快速上手》。这三本都是很好的入门书。

如果你是经验丰富的程序员,但刚接触 Python,阅读本书应该没问题。Python 简单易懂。

本书中我用的是 Python 3。我在 2013—2014 年写这本书时,Python 3 已经发布好几年了,全世界的开发者正处在一个拐点上,他们更倾向于选择使用 Python 3。可以参照本书内容在 Mac、Windows 和 Linux 中实践。在各种操作系统中安装 Python 的详细说明后文会介绍。

01 - 图6 本书内容在 Python 3.6 中测试过。如果你使用的是较低的版本,可能会发现细微的差别(比如 f 字符串句法),所以如果可以,最好升级 Python。

我不建议使用 Python 2,因为它和 Python 3 之间的区别太大。如果碰巧你的下一个项目使用的是 Python 2,仍然可以运用从本书中学到的知识。不过,当你得到的程序输出和本书不一样时,要花时间判断是因为用了 Python 2,还是因为你确实犯了错——这么做太浪费时间了。

如果你想使用 PythonAnywhere(这是我就职的 PaaS 创业公司),不愿在本地安装 Python,可以先快速阅读一遍附录 A。

无论如何,我希望你能使用 Python,知道如何从命令行启动 Python,也知道如何编辑和运行 Python 文件。再次提醒,如果你有任何疑问,看一下我前面推荐的三本书。

01 - 图7 如果你已经安装了 Python 2,担心再安装 Python 3 会破坏之前的版本,那大可以放心,Python 3 和 Python 2 可以相安无事地共存于同一个系统中,使用虚拟环境的话(本书就是)更是如此。

HTML的工作方式

我还假定你基本了解 Web 的工作方式,知道什么是 HTML、什么是 POST 请求等。如果你对这些概念不熟悉,那么需要找一份 HTML 基础教程看一下,webplateform 网站上列出了一些。如果你知道如何在电脑中创建 HTML 页面,并在浏览器中查看,也知道表单以及它的工作方式,那么或许你就符合我的要求。

Django

本书使用 Django 框架,这(或许)是 Python 世界最为人认可的 Web 框架。本书不要求读者事先了解 Django,但如果你刚接触 Python、刚接触 Web 开发,也刚接触测试,偶尔会觉得话题太多,有太多的概念要理解。如果发生了这样的情况,我建议你先把本书放下,找份 Django 教程看看。DjangoGirls 是我所知的对新手最友好的教程。官方教程(https://docs.djangoproject.com/en/1.11/intro/tutorial01/)对有经验的程序员来说是不错的选择。

Django 的安装说明参见后文。

JavaScript

本书后半部分有少量 JavaScript。如果你不了解 JavaScript,先别担心。如果你觉得有些看不懂,我到时会推荐一些参考资料给你。

关于 IDE

如果你来自 Java 或.NET 领域,可能非常想使用 IDE(集成开发环境)编写 Python 代码。IDE 中有各种实用的工具,例如 VCS 集成。Python 领域也有一些很棒的 IDE。刚开始我也用过一个 IDE,它对我最初的几个项目很有用。

我能建议(只是建议)你别用 IDE 吗?至少在阅读本书时别用。在 Python 领域,IDE 不是那么重要。写作本书时,我假定你只使用一个简单的文本编辑器和命令行。某些时候,它们是你所能使用的全部工具(例如在服务器中),所以刚开始时值得花时间学习使用基本的工具,理解它们是如何工作的。即便当你读完本书后决定继续使用 IDE 和其中的实用工具,这些基本的工具还是唾手可得。

需要安装的软件

除了 Python 之外,还要安装以下软件。

  • Firefox Web 浏览器

Selenium 其实能驱动任意一款主流浏览器,不过以 Firefox 举例最简单,因为它跨平台。而且使用 Firefox 还有另外一个好处——和公司利益没有多少关联。

  • Git 版本控制系统

Git 可在任何一个平台上使用。Windows 安装环境带有 Bash 命令行,这是本书所需的。

  • 装有 Python 3、Django 1.11 和 Selenium 3 的虚拟环境

Python 3.4+ 现在自带 virtualenv 和 pip(早期版本没有,这是一大进步)。搭建虚拟环境的详细说明参见后文。

  • Geckodriver

这是通过 Selenium 远程控制 Firefox 的驱动。在“安装 Firefox 和 Geckodriver”一节会给出下载链接。

针对 Windows 的说明

Windows 用户有时会觉得被开源世界忽略了,因为 macOS 和 Linux 太普遍了,很容易让人忘记在 Unix 之外还有一个世界。使用反斜线作为目录分隔符?盘符?这些是什么?不过,阅读本书时仍然可以在 Windows 中实践。下面是一些小提示。

  1. 在 Windows 中安装 Git 时,一定要选择“Run Git and included Unix tools from the Windows command prompt”(在 Windows 命令提示符中运行 Git 和所含的 Unix 工具)。选择这个选项之后就能使用 Git Bash 了。把 Git Bash 作为主要命令提示符,你就能使用所有实用的 GNU 命令行工具,例如 lstouchgrep,而且目录分隔符也使用斜线表示。
  2. 在 Git 安装程序中,还要勾选“Use Windows'default console”(使用 Windows 的默认控制台),否则 Python 在 Git Bash 窗口中无法正常使用。
  3. 安装 Python 3 时,除非已经安装了 Python 2 且想继续将它用作默认版本,否则一定要选中“Add Python 3.6 to PATH”(把 Python 3.6 添加到系统路径中,如图 P-1 所示),这样才能在命令行中顺利运行 Python。

01 - 图8 测试所有软件是否正确安装的方法是,打开 Git Bash 命令提示符,在任意一个文件夹中执行命令 pythonpip

{%}

图P-1:从安装程序将 Python 加入系统路径

针对 MacOS 的说明

MacOS 比 Windows 稍微正常一点儿,不过在 Python 3.4 之前,安装 pip 还是一项极具挑战性的任务。Python 3.4 发布后,安装方法变得简单明了。

  • 使用下载的安装程序就能安装 Python 3.6,省去了很多麻烦。而且,这个安装程序也会自动安装 pip
  • Git 安装程序也能顺利运行。

测试这些软件是否正常安装的方法和 Windows 类似:打开一个终端,然后在任意位置执行命令 gitpython3pip。如果遇到问题,搜索关键字“system path”和“command not found”,就能找到解决问题的合适资源。

01 - 图10 或许你还应该检验一下 Homebrew。在 Mac 安装众多 Unix 工具的方式中,它是唯一可信赖的。1 虽然现在 Python 安装程序做得不错,但将来你可能会用到 Homebrew。要使用 Homebrew,需下载大小为 1.1 GB 的 Xcode。不过这有个好处——你得到了一个 C 编译器。

1不过我不建议使用 Homebrew 安装 Firefox,因为 Homebrew 会把 Firefox 二进制文件放到一个陌生的位置,Selenium 找不到。虽然这个问题可以解决,但是以常规的方式安装更简单。

Git默认使用的编辑器和其他基本配置

后文我会逐步介绍如何使用 Git,不过现在最好做些配置。例如,首次提交时,默认情况下会弹出 vi,这可能让你手足无措。鉴于 vi 有两种模式,因此你有两个选择。其一,学一些基本的 vi 命令(按 i 键进入插入模式,输入文本后再按 键返回常规模式,然后输入 :wq 写入文件并退出)。学会这些命令后,你就加入了一个互助会,这里的人们知道怎么使用这个古老而让人崇敬的文本编辑器。

另外一个选择是直接拒绝这种穿越到 20 世纪 70 年代的荒唐行为,而是配置 Git,让它使用你选择的编辑器。按 键,再输入 :q!,退出 vi,然后修改 Git 使用的默认编辑器。具体方法参见介绍 Git 基本配置的文档。

安装Firefox和Geckodriver

https://www.mozilla.org/firefox/ 可下载 Windows 和 macOS 的 Firefox 安装包。Linux 可能已经预装了 Firefox;如果没有,使用包管理器安装。

Geckodriver 可从 https://github.com/mozilla/geckodriver/releases 下载。下载后解压,放到系统路径中的某个位置。

  • 对 macOS 或 Linux 来说,可以放在~/.local/bin 目录中。
  • 对 Windows 来说,可以放在 Python 的 Scripts 文件夹中。

为了确认是否成功安装,打开一个 Bash 控制台,执行下述命令:

  1. geckodriver --version
  2. geckodriver 0.17.0
  3.  
  4. The source code of this program is available at
  5. https://github.com/mozilla/geckodriver.
  6.  
  7. This program is subject to the terms of the Mozilla Public License 2.0.
  8. You can obtain a copy of the license at https://mozilla.org/MPL/2.0/.

如果无法执行这个命令,可能是因为~/.local/bin 不在 PATH 中(针对 Mac 和 Linux 系统)。这个文件夹最好加到 PATH 中,因为使用 pip install --user 安装的 Python 包都存储在这里。把这个文件夹添加到 .bashrc 文件中的方法如下所示 2:

2.bashrc 是 Bash 的初始化文件,在家目录中。每次运行 Bash 都会运行这个文件。

  1. echo 'PATH=~/.local/bin:$PATH' >> ~/.bashrc

然后关闭终端,重新打开,再次确认能否执行 geckodriver --version 命令。

搭建虚拟环境

Python 项目所需的环境使用 virtualenv(virtual environment 的简称)搭建。在不同项目的虚拟环境中可以使用不同的包(例如不同版本的 Django,甚至是不同版本的 Python)。而且虚拟环境中的包不是全局安装的,因此无须 root 权限。

Python 从 3.4 版开始集成了用于搭建虚拟环境的 virtualenv 工具,不过我始终建议使用 virtualenvwrapper 这个辅助工具。先安装 virtualenvwrapper(对 Python 版本没有要求):

  1. # 在Windows中
  2. pip install virtualenvwrapper
  3. # 在macOS/Linux中
  4. pip install --user virtualenvwrapper
  5. # 然后让Bash自动加载virtualenvwrapper
  6. echo "source virtualenvwrapper.sh" >> ~/.bashrc
  7. source ~/.bashrc

01 - 图11 在 Windows 中,virtualenvwrapper 只能在 Git Bash 中使用,而不能在常规的命令行中使用。

virtualenvwrapper 把所有虚拟环境都放在一个地方,而且为激活和停用虚拟环境提供了便利的工具。

下面创建一个名为“superlists”3 的虚拟环境,并在里面安装 Python 3:

3你可能会问为什么叫“superlists”?我可不想剧透!下一章你就知道了。

  1. # 在macOS/Linux中
  2. mkvirtualenv --python=python3.6 superlists
  3. # 在Windows中
  4. mkvirtualenv --python=`py -3.6 -c"import sys; print(sys.executable)"` superlists
  5. #(为了得到一个装有Python 3.6的虚拟环境,我们绕了点弯子)

激活和停用虚拟环境

阅读本书时,一定要先“激活”你的虚拟环境。你之所以能看出我们处在虚拟环境中,通常是因为提示符中有 (superlists),例如:

  1. $
  2. (superlists) $

创建虚拟环境之后,就直接激活了虚拟环境。你可以执行 which python 命令再次确认:

  1. (superlists) $ which python
  2. homeharry/.virtualenvssuperlistsbin/python
  3. #(在Windows中会显示为下面这样
  4. # CUsers/IEUser/.virtualenvssuperlistsScripts/python)
  5.  
  6. (superlists) $ deactivate
  7. $ which python
  8. usrbin/python
  9. $ python --version
  10. Python 2.7.12 # 在我的设备中,虚拟环境外部的“python”默认为Python 2
  11.  
  12. $ workon superlists
  13. (superlists) $ which python
  14. homeharry/.virtualenvssuperlistsbin/python
  15. (superlists) $ python --version
  16. Python 3.6.0

01 - 图12 激活虚拟环境的命令是 workon superlists。若想确认有没有激活,可以看命令提示符中有没有 (superlists) $,或者执行 which python 命令。

安装Django和Selenium

我们将安装 Django 1.11 和最新版 Selenium,即 Selenium 3:

  1. (superlists) $ pip install "django<1.12" "selenium<4"
  2. Collecting django==1.11.3
  3. Using cached Django-1.11.3-py2.py3-none-any.whl
  4. Collecting selenium<4
  5. Using cached selenium-3.4.3-py2.py3-none-any.whl
  6. Installing collected packages: django, selenium
  7. Successfully installed django-1.11.3 selenium-3.4.3

无法激活虚拟环境时可能会看到的一些错误消息

对刚接触虚拟环境的人来说,肯定会经常忘记激活虚拟环境(说实话,老手也经常犯这个错,比如我)。这时,你会看到一个错误消息,其中的重要部分如下所示:

  1. ImportError: No module named selenium

或者是:

  1. ImportError: No module named django.core.management

如果遇到这种错误,不要慌,先看看命令提示符中有没有 (superlists)。通常只需执行 workon superlists 就能解决问题。

除此之外,可能还会遇到这个错误:

  1. bash: workon: command not found

这表明你前面少做了一步,没有把 virtualenvwrapper 添加到 .bashrc 中。从前文中找到 echo source virtualenvwrapper.sh 命令,再执行一遍。

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

这表明你打开的是 Windows 的默认命令提示符 cmd,而不是 Git Bash。关掉 cmd,打开 Git Bash。

编程快乐!

01 - 图13 上述说明对你没什么用,或者你有更好的说明?请给我发电子邮件吧,地址是 obeythetestinggoat@gmail.com

配套视频

我为本书录制了一套十集的配套视频(http://oreil.ly/1svTFqB)1,主要针对第一部分的内容。如果你更适合通过视频学习,建议你看看。除了书中的内容之外,这套视频还能让你直观地感受 TDD 流程,了解如何在测试和代码之间切换,与此同时保持思路清晰。

1这套视频没有针对第 2 版更新,不过内容基本上依然适用。

我还特意穿了一件亮黄色 T 恤。

01 - 图14

致谢

要感谢的人很多,没有他们就不会有这本书,就算有也会写得比现在差。

首先感谢某出版社的 Greg,他是第一个鼓励我、让我相信我能写完这本书的人。虽然你们公司在版权问题上思想过于退化,但我还是要感激你个人对我的信任。

感谢 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。你们比我聪慧得多,让我避免说一些愚蠢的事情。当然,书中还有很多愚蠢的内容,不过责任肯定不在你们。

感谢我的编辑 Meghan Blanchette,她是一位非常友善可爱的监工。谢谢你为我规划时间,制止我愚蠢的想法,让本书的写作在正确的轨道上行进。感谢 O'Reilly 出版社其他所有为我提供帮助的人,包括 Sarah Schneider、Kara Ebrahim 和 Dan Fauxsmith,感谢你们让我继续使用英式英语。感谢 Charles Roumeliotis 在行文风格和语法上给我的帮助。虽然我们对芝加哥学派引用和标点符号规则的优势有不同观点,但有你在身边我仍然高兴。感谢设计部门为封面绘制了一头山羊。

特别感谢预览版的读者,感谢你们挑出拼写错误,给我反馈和建议,感谢你们提出各种有助于使本书学习曲线变平滑的方法,感谢你们中的大多数人给我鼓励和支持,让我一直写下去。感谢 Jason Wirth、Dave Pawson、Jeff Orr、Kevin De Baere、crainbf、dsisson、Galeran、Michael Allan、James O'Donnell、Marek Turnovec、Sooner Bourne、julz、Cody Farmer、William Vincent、Trey Hunner、David Souther、Tom Perkin、Sorcha Bowler、Jon Poler、Charles Quast、Siddhartha Naithani、Steve Young、Roger Camargo、Wesley Hansen、Johansen Christian Vermeer、Ian Laurain、Sean Robertson、Hari Jayaram、Bayard Randel、Konrad Korzel、Matthew Waller、Julian Harley、Barry McClendon、Simon Jakobi、Angelo Cordon、Jyrki Kajala、Manish Jain、Mahadevan Sreenivasan、Konrad Korzel、Deric Crago、Cosmo Smith、Markus Kemmerling、Andrea Costantini、Daniel Patrick、Ryan Allen、Jason Selby、Greg Vaughan、Jonathan Sundqvist、Richard Bailey、Diane Soini、Dale Stewart、Mark Keaton、Johan Wärlander、Simon Scarfe、Eric Grannan、Marc-Anthony Taylor、Maria McKinley、John McKenna、Rafał Szymański、Roel van der Goot、Ignacio Reguero、TJ Tolton、Jonathan Means、Theodor Nolte、Jungsoo Moon、Craig Cook、Gabriel Ewilazarus、Vincenzo Pandolfo、David“farbish2”、Nico Coetzee、Daniel Gonzalez、Jared Contrascere、赵亮等很多人。如果我遗漏了你的名字,你绝对有权感到委屈。我当然非常感激你,请给我写封信,我会尽我所能把你的名字加上。

最后,我要感谢你,现在的读者,感谢你决定阅读这本书,希望你喜欢。

第2版附加致谢

感谢第 2 版的编辑 Nan Barber,感谢 Susan Conant、Kristen Brown 和 O'Reilly 整个团队。再次感谢 Emily 和 Jonathan,感谢你们的技术审阅,还要感谢 Edward Wong 细致的笔记。倘若书中还有错误和不足,责任都在我。

感谢免费版的读者们,感谢你们提出的意见和建议,有些读者甚至发起了拉取请求。感谢 Emre Gonulates、Jésus Gómez、Jordon Birk、James Evans、Iain Houston、Jason DeWitt、Ronnie Raney、Spencer Ogden、Suresh Nimbalkar、Darius、Caco、LeBodro、Jeff、wasabigeek、joegnis、Lars、Mustafa、Jared、Craig、Sorcha、TJ、Ignacio、Roel、Justyna、Nathan、Andrea、Alexandr、bilyanhadzhi、mosegontar、sfarzy、henziger、hunterji、das-g、juanriaza、GeoWill、Windsooon、gonulate,等等。我肯定遗漏了一些名字,为此我深感抱歉。

第一部分 TDD和Django基础

第一部分我要介绍测试驱动开发(Test-Driven Development,TDD)的基础知识。我们会从零开始开发一个真实的 Web 应用,而且每个阶段都要先写测试。

这一部分涵盖使用 Selenium 完成的功能测试以及单元测试,还会介绍二者之间的区别。我会介绍 TDD 流程,我称之为“单元测试 / 编写代码”循环。我们还要做些重构,说明怎么结合 TDD 使用。因为版本控制对重要的软件工程来说是基本需求,所以我们还会用到版本控制系统(Git)。我会介绍何时以及如何提交,如何把提交集成到 TDD 和 Web 开发的流程中。

我们要使用 Django,它(或许)是 Python 领域之中最受欢迎的 Web 框架。我会试着慢慢介绍 Django 的概念,一次一个,除此之外还会提供很多扩展阅读资料的链接。如果你完全是刚接触 Django,那么我极力推荐你花时间阅读这些资料。如果你感觉有点儿茫然,花几小时读一遍 Django 的官方教程,然后再回来阅读本书。

你还会结识测试山羊……

01 - 图15 复制粘贴时要小心

如果你看的是电子版,那么在阅读的过程之中就会很自然地会想要复制粘贴书中的代码清单。如果不这么做的话效果会更好:动手输入能形成肌肉记忆,感觉也更真实。你偶尔会打错字,这是无法避免的,调试错误也是一项需要学习的重要技能。

除此之外,你还会发现 PDF 格式相当诡异,复制粘贴时经常会有意想不到的事情发生……

第 1 章 使用功能测试协助安装Django

TDD 不是天生就会的技术,而是像武术一样的一种技能。就像在功夫电影中一样,你需要一个脾气不好、不可理喻的师傅来强制你学习。我们的师傅是测试山羊。

1.1 遵从测试山羊的教诲,没有测试什么也别做

在 Python 测试社区中,测试山羊是 TDD 的非官方吉祥物。测试山羊对不同的人有不同的意义。对我来说,它是我脑海中的一个声音,告诉我要一直走在测试这条正确的道路上,就像卡通片中浮现在肩膀上的天使或魔鬼一样,只是没那么咄咄逼人。我希望借由这本书,让测试山羊也扎根于你的脑海中。

虽然还不太确定要做什么,但我们已经决定要开发一个网站。Web 开发的第一步通常是安装和配置 Web 框架。下载这个,安装那个,配置那个,运行这个脚本……但是,使用 TDD 时要转换思维方式。做测试驱动开发时,你的心里要一直记着测试山羊,像山羊一样专注,咩咩地叫着:“先测试,先测试!”

在 TDD 的过程中,第一步始终一样:编写测试

首先要编写测试,然后运行,看是否和预期一样失败,只有失败了才能继续下一步——编写应用程序。请模仿山羊的声调复述这个过程。我就是这么做的。

山羊的另一个特点是一次只迈一步。因此,不管山壁多么陡峭,它们都不会跌落。看看图 1-1 里的这只山羊!

{%}

图 1-1:山羊比你想象的要机敏(来源:Flickr 用户 Caitlin Stewart)

我们会碎步向前。使用流行的 Python Web 框架 Django 开发这个应用。

首先,要检查是否安装了 Django,并且能够正常运行。检查的方法是,在本地电脑中能否启动 Django 的开发服务器,并在浏览器中查看能否打开网页。使用浏览器自动化工具 Selenium 完成这个任务。

在你想保存项目代码的地方新建一个 Python 文件,命名为 functional_tests.py,并输入以下代码。如果你喜欢一边输入代码一边像山羊那样轻声念叨,或许会有所帮助:

functional_tests.py

  1. from selenium import webdriver
  2. browser = webdriver.Firefox()
  3. browser.get('http://localhost:8000')
  4. assert 'Django' in browser.title

这是我们编写的第一个功能测试(Functional Test,FT)。后面我会深入说明什么是功能测试,以及它和单元测试的区别。现在,只要能理解这段代码做了什么就行。

  • 启动一个 Selenium webdriver,打开一个真正的 Firefox 浏览器窗口。
  • 在这个浏览器中打开我们期望本地电脑伺服的网页。
  • 检查(做一个测试断言)这个网页的标题中是否包含单词“Django”。

我们尝试运行一下:

  1. $ python functional_tests.py
  2. File ".../selenium/webdriver/remote/webdriver.py", line 268, in get
  3. self.execute(Command.GET, {'url': url})
  4. File ".../selenium/webdriver/remote/webdriver.py", line 256, in execute
  5. self.error_handler.check_response(response)
  6. File ".../selenium/webdriver/remote/errorhandler.py", line 194, in
  7. check_response
  8. raise exception_class(message, screen, stacktrace)
  9. selenium.common.exceptions.WebDriverException: Message: Reached error page: abo
  10. ut:neterror?e=connectionFailure&u=http%3A//localhost%3A8000/[...]

你应该会看到弹出了一个浏览器窗口,尝试打开 localhost:8000,然后显示“无法连接”错误页面。这时回到终端,你会看到一个显眼的错误消息,说 Selenium 遇到了一个错误页面。接着,你会看到 Firefox 窗口停留在桌面上,等待你关闭。这可能会让你生气,我们稍后会修正这个问题。

01 - 图17 如果看到关于导入 Selenium 的错误,或者让你查找“geckodriver”错误,或许你应该往前翻,看一下“准备工作和应具备的知识”。

现在,得到了一个失败测试。这意味着,我们可以开始开发应用了。

别了,罗马数字

很多介绍 TDD 的文章都喜欢以罗马数字为例,闹了笑话,甚至我一开始也是这么写的。如果你好奇,可以查看我在 GitHub 的页面,地址是 https://github.com/hjwp/

以罗马数字为例有好也有坏。把问题简化,合理地限制在某一范围内,让你能很好地解说 TDD。

但问题是不切实际。因此我才决定要从零开始开发一个真实的 Web 应用,以此为例介绍 TDD。这是一个简单的 Web 应用,我希望你能把从中学到的知识运用到下一个真实的项目中。

1.2 让Django运行起来

你肯定已经读过“准备工作和应具备的知识”了,也安装了 Django。使用 Django 的第一步是创建项目,我们的网站就放在这个项目中。Django 为此提供了一个命令行工具:

  1. $ django-admin.py startproject superlists

这个命令会创建一个名为 superlists 的文件夹,并在其中创建一些文件和子文件夹:

  1. .
  2. ├── functional_tests.py
  3. ├── geckodriver.log
  4. └── superlists
  5. ├── manage.py
  6. └── superlists
  7. ├── __init__.py
  8. ├── settings.py
  9. ├── urls.py
  10. └── wsgi.py

在 superlists 文件夹中还有一个名为 superlists 的文件夹。这有点让人困惑,不过确实需要如此。回顾 Django 的历史,你会找到出现这种结构的原因。现在,重要的是知道 superlists/superlists 文件夹的作用是保存应用于整个项目的文件,例如 settings.py 的作用是存储网站的全局配置信息。

你还会注意到 manage.py。这个文件是 Django 的瑞士军刀,作用之一是运行开发服务器。我们来试一下。执行命令 cd superlists,进入顶层文件夹 superlists(我们会经常在这个文件夹中工作),然后执行:

  1. $ python manage.py runserver
  2. Performing system checks...
  3.  
  4. System check identified no issues (0 silenced).
  5.  
  6. You have 13 unapplied migration(s). Your project may not work properly until
  7. you apply the migrations for app(s): admin, auth, contenttypes, sessions.
  8. Run 'python manage.py migrate' to apply them.
  9.  
  10. Django version 1.11.3, using settings 'superlists.settings'
  11. Starting development server at http://127.0.0.1:8000/
  12. Quit the server with CONTROL-C.

01 - 图18 暂时先不管关于“未应用迁移”的消息,第 5 章将讨论迁移。

这样,Django 的开发服务器便在设备中运行起来了。让这个命令一直运行着,再打开一个命令行窗口(进入刚刚打开的文件夹),在其中再次运行测试:

  1. $ python functional_tests.py
  2. $

01 - 图19 因为打开了新的终端窗口,所以要先执行 workon superlists 命令激活虚拟环境。

我们在命令行中没执行多少操作,但你应该注意两件事:第一,没有丑陋的 AssertionError 了;第二,Selenium 弹出的 Firefox 窗口中显示的页面不一样了。

这虽然看起来没什么大不了,但毕竟是我们第一个通过的测试啊!值得庆祝。

如果感觉有点神奇,不太现实,为什么不手动查看开发服务器,打开浏览器访问 http:// localhost:8000 呢?你会看到如图 1-2 所示的页面。

{%}

图 1-2:Django 可用了

如果想退出开发服务器,可以回到第一个 shell 中,按 Ctrl-C 键。

1.3 创建Git仓库

结束这章之前,还要做一件事:把作品提交到版本控制系统(Version Control System,VCS)。如果你是一名经验丰富的程序员,就无须再听我宣讲版本控制了。如果你刚接触 VCS,请相信我,它是必备工具。当项目在几周内无法完成,代码越来越多时,你需要一个工具查看旧版代码、撤销改动、放心地试验新想法,或者只是做个备份。测试驱动开发和版本控制关系紧密,所以我一定要告诉你如何在开发流程中使用版本控制系统。

好的,来做第一次提交。如果现在提交已经晚了,我表示歉意。我们使用 Git 作为 VCS,因为它是最棒的。

我们先把 functional_tests.py 移到 superlists 文件夹中。然后执行 git init 命令,创建仓库:

  1. $ ls
  2. superlists functional_tests.py geckodriver.log
  3. $ mv functional_tests.py superlists/
  4. $ cd superlists
  5. $ git init .
  6. Initialised empty Git repository in ...superlists/.git/

自此工作目录都是顶层 superlists 文件夹

从现在起,我们会把顶层文件夹 superlists 作为工作目录。

(简单起见,我在命令列表中都将使用 superlists/ 表示这个目录。但实际上,这个目录的真实地址可能是 homekind-reader-username/my-python-projectssuperlists。)

我提供的输入命令都假定在这个目录中执行。同样,如果我提到一个文件的路径,也是相对于这个顶层目录而言。因此,superlists/settings.py 是指次级文件夹 superlists 中的 settings.py。

理解了吗?如果有疑问,就查找 manage.py,你要和这个文件在同一个目录中。

现在,看一下要提交的文件:

  1. $ ls
  2. db.sqlite3 manage.py superlists functional_tests.py

db.sqlite3 是数据库文件,无须纳入版本控制。前面见过的 geckodriver.log 是 Selenium 的日志文件,也无须跟踪变化。我们要把这两个文件添加到一个特殊的文件 .gitignore 中,让 Git 忽略它们:

  1. $ echo "db.sqlite3" >> .gitignore
  2. $ echo "geckodriver.log" >> .gitignore

接下来,我们可以添加当前文件夹(“.”)中的其他内容了:

  1. $ git add .
  2. $ git status
  3. On branch master
  4.  
  5. Initial commit
  6.  
  7. Changes to be committed:
  8. (use "git rm --cached <file>..." to unstage)
  9.  
  10. new file: .gitignore
  11. new file: functional_tests.py
  12. new file: manage.py
  13. new file: superlists/__init__.py
  14. new file: superlists/__pycache__/__init__.cpython-36.pyc
  15. new file: superlists/__pycache__/settings.cpython-36.pyc
  16. new file: superlists/__pycache__/urls.cpython-36.pyc
  17. new file: superlists/__pycache__/wsgi.cpython-36.pyc
  18. new file: superlists/settings.py
  19. new file: superlists/urls.py
  20. new file: superlists/wsgi.py

糟糕,添加了很多 .pyc 文件,这些文件没必要提交。将其从 Git 中删掉,并添加到 .gitignore 中:

  1. $ git rm -r --cached superlists/__pycache__
  2. rm 'superlists/__pycache__/__init__.cpython-36.pyc'
  3. rm 'superlists/__pycache__/settings.cpython-36.pyc'
  4. rm 'superlists/__pycache__/urls.cpython-36.pyc'
  5. rm 'superlists/__pycache__/wsgi.cpython-36.pyc'
  6. $ echo "__pycache__" >> .gitignore
  7. $ echo "*.pyc" >> .gitignore

现在,来看一下进展到哪里了。(你会看到,我使用 git status 的次数太多了,所以经常会使用别名 git st。我不会告诉你怎么做,你要自己探索 Git 别名的秘密!)

  1. $ git status
  2. On branch master
  3.  
  4. Initial commit
  5.  
  6. Changes to be committed:
  7. (use "git rm --cached <file>..." to unstage)
  8.  
  9. new file: .gitignore
  10. new file: functional_tests.py
  11. new file: manage.py
  12. new file: superlists/__init__.py
  13. new file: superlists/settings.py
  14. new file: superlists/urls.py
  15. new file: superlists/wsgi.py
  16.  
  17. Changes not staged for commit:
  18. (use "git add <file>..." to update what will be committed)
  19. (use "git checkout -- <file>..." to discard changes in working directory)
  20.  
  21. modified: .gitignore

看起来不错,可以做第一次提交了:

  1. $ git add .gitignore
  2. $ git commit

输入 git commit 后,会弹出一个编辑器窗口,让你输入提交消息。我写的消息如图 1-3 所示。1

1是不是 vi 弹出后你不知道该做什么?或者,你是不是看到了一个消息,内容是关于账户识别的,其中还显示了 git config --global user.username ?再次看一下“准备工作和应具备的知识”,里面有一些简单说明。

{%}

图 1-3:首次 Git 提交

01 - 图22 如果你迫切想完成整个 Git 操作,此时还要学习如何把代码推送到云端的 VCS 托管服务中,例如 GitHub 或 BitBucket。如果你在阅读本书的过程中使用不同的电脑,会发现这么做很有用。具体的操作留给你去发掘,GitHub 和 BitBucket 的文档写得都很好。要不,你可以等到第 9 章,到时我们会使用其中一个服务做部署。

对 VCS 的介绍结束。祝贺你!你使用 Selenium 编写了一个功能测试,安装了 Django,并且使用 TDD 方式,以测试山羊赞许的、先写测试这种有保障的方式运行了 Django。在继续阅读第 2 章之前,先表扬一下自己吧,这是你应得的奖励。

第 2 章 使用unittest模块扩展功能测试

测试目前检查的是 Django 默认的“可用了”页面,修改一下,让其检查我们想在真实的网站首页中看到的内容。

是时候公布我们要开发什么类型的 Web 应用了——一个待办事项清单网站。开发这种网站说明我们始终追随时尚:很多年前所有的 Web 开发教程都介绍如何开发博客,后来一窝蜂地又介绍论坛和投票应用,现在时兴的是待办事项清单。

不过待办事项清单是个很好的例子。很明显,这种应用简单,只显示一个由文本字符串组成的列表,因此很容易得到一个最简可用的应用。而且可以使用各种方式扩展功能,例如使用不同的持久模型、添加最后期限、提醒和分享功能,还可以改进客户端 UI。另外,不必只局限于列出待办事项,这种应用可以列出任何事项。最重要的一点是,通过这种应用,可以演示 Web 开发过程中的所有主要步骤,以及如何在各个步骤中运用 TDD 理念。

2.1 使用功能测试驱动开发一个最简可用的应用

使用 Selenium 实现的测试可以驱动真正的网页浏览器,让我们能从用户的角度查看应用是如何运作的。因此,我们把这类测试叫作功能测试

这意味着,功能测试在某种程度上可以作为应用的说明书。功能测试的作用是跟踪用户故事(User Story),模拟用户使用某个功能的过程,以及应用应该如何响应用户的操作。

术语:功能测试 = 验收测试= 端到端测试

我所说的功能测试,有些人喜欢称之为验收测试(acceptance test)或端到端测试(endto-end test)。这类测试最重要的作用是从外部观察整个应用是如何运作的。另一个术语是黑箱测试(black box test),因为这种测试对所要测试的系统内部一无所知。

功能测试应该有一个人类可读、容易理解的故事。为了叙事清楚,可以把测试代码和代码注释结合起来使用。编写新功能测试时,可以先写注释,勾勒出用户故事的重点。这样写出的测试人类可读,甚至可以作为一种讨论应用需求和功能的方式分享给非程序员看。

TDD 常与敏捷软件开发方法结合在一起使用,我们经常提到的一个概念是“最简可用的应用”,即我们能开发出来的最简单的而且可以使用的应用。下面我们就来开发一个最简可用的应用,尽早试水。

最简可用的待办事项清单其实只要能让用户输入一些待办事项,并且用户下次访问应用时这些事项还在即可。

打开 functional_tests.py,编写一个类似下面的故事:

functional_tests.py

  1. from selenium import webdriver
  2. browser = webdriver.Firefox()
  3. # 伊迪丝听说有一个很酷的在线待办事项应用
  4. # 她去看了这个应用的首页
  5. browser.get('http://localhost:8000')
  6. # 她注意到网页的标题和头部都包含“To-Do”这个词
  7. assert 'To-Do' in browser.title
  8. # 应用邀请她输入一个待办事项
  9. # 她在一个文本框中输入了“Buy peacock feathers”(购买孔雀羽毛)
  10. # 伊迪丝的爱好是使用假蝇做饵钓鱼
  11. # 她按回车键后,页面更新了
  12. # 待办事项表格中显示了“1: Buy peacock feathers”
  13. # 页面中又显示了一个文本框,可以输入其他的待办事项
  14. # 她输入了“Use peacock feathers to make a fly”(使用孔雀羽毛做假蝇)
  15. # 伊迪丝做事很有条理
  16. # 页面再次更新,她的清单中显示了这两个待办事项
  17. # 伊迪丝想知道这个网站是否会记住她的清单
  18. # 她看到网站为她生成了一个唯一的URL
  19. # 而且页面中有一些文字解说这个功能
  20. # 她访问那个URL,发现她的待办事项列表还在
  21. # 她很满意,去睡觉了
  22. browser.quit()

我们有个词来形容注释

我开始在 Resolver 工作时,出于善意习惯在代码中加入密密麻麻的详细注释。我的同事告诉我:“哈利,我们有个词来形容注释,我们把注释叫作谎言。”我很诧异:可我在学校学到的是,注释是好的习惯啊?

他们说得夸张了。注释有其作用,可以添加上下文,说明代码的目的。他们的意思是,简单重复代码意图的注释毫无意义,例如:

  1. # 把wibble的值增加 1
  2. wibble += 1

这样的注释不仅毫无意义,还有一定危险,如果更新代码后没有修改注释,会误导别人。我们要努力做到让代码可读,使用有意义的变量名和函数名,保持代码结构清晰,这样就不再需要通过注释说明代码做了什么,只要偶尔写一些注释说明为什么这么做。

有些情况下注释很重要。后文会看到,Django 在其生成的文件中用到了很多注释,这是解说 API 的一种方式。而且还在功能测试中使用注释描述用户故事——把测试提炼成一个连贯的故事,确保我们始终从用户的角度测试。

这个领域中还有很多有趣的知识,例如行为驱动开发(Behaviour Driven Development,详情参见附录 E)和测试 DSL(Domain Specific Language,领域特定语言)。这些知识已经超出本书的范畴了。

你可能已经发现了,除了在测试中加入注释之外,我还修改了 assert 这行代码,让其查找单词“To-Do”,而不是“Django”。这意味着现在我们期望测试失败。运行这个测试。

首先,启动服务器:

  1. $ python manage.py runserver

然后在另一个 shell 中运行测试:

  1. $ python functional_tests.py
  2. Traceback (most recent call last):
  3. File "functional_tests.py", line 10, in <module>
  4. assert 'To-Do' in browser.title
  5. AssertionError

这就是预期失败。其实失败是好消息,虽不像测试通过那么令人振奋,但至少事出有因,证明测试编写得正确。

2.2 Python标准库中的unittest模块

上述测试中有几个恼人的问题需要处理。首先,“AssertionError”消息没什么用,如果测试能指出在浏览器的标题中到底找到了什么就好了。其次,Firefox 窗口一直停留在桌面上,如果能自动将其关闭就好了。

要解决第一个问题,可以使用 assert 关键字的第二个参数,写成:

  1. assert 'To-Do' in browser.title, "Browser title was " + browser.title

Firefox 窗口可在 try/finally 语句中关闭。但这种问题在测试中很常见,标准库中的 unittest 模块已经提供了现成的解决方法。使用这种方法吧!在 functional_tests.py 中写入如下代码:

functional_tests.py

  1. from selenium import webdriver
  2. import unittest
  3. class NewVisitorTest(unittest.TestCase):
  4. def setUp(self):
  5. self.browser = webdriver.Firefox()
  6. def tearDown(self):
  7. self.browser.quit()
  8. def test_can_start_a_list_and_retrieve_it_later(self):
  9. # 伊迪丝听说有一个很酷的在线待办事项应用
  10. # 她去看了这个应用的首页
  11. self.browser.get('http://localhost:8000')
  12. # 她注意到网页的标题和头部都包含“To-Do”这个词
  13. self.assertIn('To-Do', self.browser.title)
  14. self.fail('Finish the test!')
  15. # 应用邀请她输入一个待办事项
  16. [其余的注释和之前一样]
  17. if __name__ == '__main__':
  18. unittest.main(warnings='ignore')

你可能注意到以下几个地方了。

❶ 测试组织成类的形式,继承自 unittest.TestCase

❷ 测试的主要代码写在名为 test_can_start_a_list_and_retrieve_it_later 的方法中。名字以 test_ 开头的方法都是测试方法,由测试运行程序运行。类中可以定义多个测试方法。为测试方法起个有意义的名字是个好主意。

setUptearDown 是特殊的方法,分别在各个测试方法之前和之后运行。我使用这两个方法打开和关闭浏览器。注意,这两个方法有点类似 try/except 语句,就算测试中出错了,也会运行 tearDown 方法。1 测试结束后,Firefox 窗口不会一直停留在桌面上了。

1唯有一个特例:如果 setUp 方法抛出异常,tearDown 方法就不会运行。

❹ 使用 self.assertIn 代替 assert 编写测试断言。unittest 提供了很多这种用于编写测试断言的辅助函数,如 assertEqualassertTrueassertFalse 等。更多断言辅助函数参见 unittest 的文档。

❺ 不管怎样,self.fail 都会失败,生成指定的错误消息。我使用这个方法提醒测试结束了。

❻ 最后是 if __name__ == '__main__' 分句(如果你之前没见过这种用法,我告诉你,Python 脚本使用这个语句检查自己是否在命令行中运行,而不是在其他脚本中导入)。我们调用 unittest.main() 启动 unittest 的测试运行程序,这个程序会在文件中自动查找测试类和方法,然后运行。

warnings='ignore' 的作用是禁止抛出 ResourceWarning 异常。写作本书时这个异常会抛出,但你阅读时我可能已经把这个参数去掉了。你可以把这个参数删掉,看一下效果。

01 - 图23 如果你阅读 Django 关于测试的文档,可能会看到有个名为 LiveServerTestCase 的类,而且想知道我们现在能否使用它。你能阅读这份友好的手册真是值得表扬!目前来说,LiveServerTestCase 有点复杂,但我保证后面的章节会用到。

来试一下这个测试。

  1. $ python functional_tests.py
  2. F
  3. ======================================================================
  4. FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
  5. ---------------------------------------------------------------------
  6. Traceback (most recent call last):
  7. File "functional_tests.py", line 18, in
  8. test_can_start_a_list_and_retrieve_it_later
  9. self.assertIn('To-Do', self.browser.title)
  10. AssertionError: 'To-Do' not found in 'Welcome to Django'
  11.  
  12. ---------------------------------------------------------------------
  13. Ran 1 test in 1.747s
  14.  
  15. FAILED (failures=1)

这样是不是更好了?这个测试清理了 Firefox 窗口,显示了一个排版精美的报告,指出运行了几个测试,其中有几个测试失败了,而且 assertIn 还显示了一个有利于调试的错误消息。太棒了!

2.3 提交

现在是提交代码的好时机,因为已经做了一次完整的修改。我们扩展了功能测试,加入注释说明我们要在最简可用的待办事项清单应用中执行哪些操作。我们还使用 Python 中的 unittest 模块及其提供的各种测试辅助函数重写了测试。

执行 git status 命令,你会发现只有 functional_tests.py 文件的内容变化了。然后执行 git diff 命令,查看上一次提交和当前硬盘中保存内容之间的差异,你会发现 functional_tests.py 文件的变动很大:

  1. $ git diff
  2. diff --git a/functional_tests.py b/functional_tests.py
  3. index d333591..b0f22dc 100644
  4. --- a/functional_tests.py
  5. +++ b/functional_tests.py
  6. @@ -1,6 +1,45 @@
  7. from selenium import webdriver
  8. +import unittest
  9.  
  10. -browser = webdriver.Firefox()
  11. -browser.get('http://localhost:8000')
  12. +class NewVisitorTest(unittest.TestCase):
  13.  
  14. -assert 'Django' in browser.title
  15. + def setUp(self):
  16. + self.browser = webdriver.Firefox()
  17. +
  18. + def tearDown(self):
  19. + self.browser.quit()
  20. [...]

现在执行下述命令:

  1. $ git commit -a

-a 的意思是:自动添加已跟踪文件(即已经提交的各文件)中的改动。上述命令不会添加全新的文件(你要使用 git add 命令手动添加这些文件)。不过就像这个例子一样,经常没有添加新文件,因此这是个很有用的简便用法。

弹出编辑器后,写入一个描述性的提交消息,比如“使用注释编写规格的首个功能测试,而且使用了 unittest”。

现在我们身处一个绝妙的位置,可以开始为这个清单应用编写真正的代码了。请继续往下阅读。

有用的 TDD 概念

  • 用户故事

    从用户的角度描述应用应该如何运行。用来组织功能测试。

  • 预期失败

    意料之中的失败。

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

上一章结束时功能测试是失败的,失败消息指出功能测试希望网站的首页标题中包含“To-Do”这个词。现在要开始编写这个应用了。

警告:要动真格的了

我故意把前两章写得这么友好和简单。从现在开始,要真正编写代码了。提前给你打个预防针:有些地方会出错。你看到的结果可能和我说的不一样。这是好事,因为这才是磨练意志的学习经历。

出现这种情况,一个可能的原因是,我表述不清,让你误解了我的本意。你要退一步想想要实现的是什么,在编辑哪个文件,想让用户做些什么,在测试什么,为什么要测试?有可能你编辑了错误的文件或函数,或者运行了其他测试。我觉得停下来想一想能更好地学习 TDD,比照搬所有操作、复制粘贴代码强得多。

还有一种原因,可能真有问题。认真阅读错误消息(详情请参见 3.5 节的“阅读调用跟踪”),你会找到原因的。可能是漏掉了一个逗号或末尾的斜线,或者某个Selenium查找方法少写了一个“s”。但是,正如 Zed Shaw 所说,这种调试也是学习过程的重要组成部分,所以一定要坚持到底!

如果你真的卡住了,随时可以给我发电子邮件,或者在谷歌小组(https://groups.google.com/forum/#!forum/obeythetestinggoat-book)中发帖。祝你调试愉快!

3.1 第一个Django应用,第一个单元测试

Django 鼓励以应用的形式组织代码。这么做,一个项目中可以放多个应用,而且可以使用其他人开发的第三方应用,也可以重用自己在其他项目中开发的应用。我承认,我自己从没真正这么做过。不过,应用的确是组织代码的好方法。

为待办事项清单创建一个应用:

  1. $ python manage.py startapp lists

这个命令会在 superlists 文件夹中创建子文件夹 lists,与 superlists 子文件夹相邻,并在 lists 中创建一些占位文件,用来保存模型、视图以及目前最关注的测试:

  1. superlists/
  2. ├── db.sqlite3
  3. ├── functional_tests.py
  4. ├── lists
  5. ├── admin.py
  6. ├── apps.py
  7. ├── __init__.py
  8. ├── migrations
  9. └── __init__.py
  10. ├── models.py
  11. ├── tests.py
  12. └── views.py
  13. ├── manage.py
  14. └── superlists
  15. ├── __init__.py
  16. ├── __pycache__
  17. ├── settings.py
  18. ├── urls.py
  19. └── wsgi.py

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

正如给事物所贴的众多标签一样,单元测试和功能测试之间的界线有时不那么清晰。不过,二者之间有个基本区别:功能测试站在用户的角度从外部测试应用,单元测试则站在程序员的角度从内部测试应用。

我遵从的 TDD 方法同时使用这两种类型测试应用。采用的工作流程大致如下。

(1) 先写功能测试,从用户的角度描述应用的新功能。

(2) 功能测试失败后,想办法编写代码让它通过(或者说至少让当前失败的测试通过)。此时,使用一个或多个单元测试定义希望代码实现的效果,保证为应用中的每一行代码(至少)编写一个单元测试。

(3) 单元测试失败后,编写最少量的应用代码,刚好让单元测试通过。有时,要在第 2 步和第 3 步之间多次往复,直到我们觉得功能测试有一点进展为止。

(4) 然后,再次运行功能测试,看能否通过,或者有没有进展。这一步可能促使我们编写一些新的单元测试和代码等。

由此可以看出,这整个过程中,功能测试站在高层驱动开发,而单元测试则从低层驱动我们做些什么。

这个过程看起来是不是有点儿烦琐?有时确实如此,但功能测试和单元测试的目的不完全一样,而且最终写出的测试代码往往区别也很大。

01 - 图24 功能测试的作用是帮助你开发具有所需功能的应用,还能保证你不会无意中破坏这些功能。单元测试的作用是帮助你编写简洁无错的代码。

理论讲得够多了,下面来看一下如何实践。

3.3 Django中的单元测试

来看一下如何为首页视图编写单元测试。打开新生成的文件 lists/tests.py,你会看到类似下面的内容:

lists/tests.py

  1. from django.test import TestCase
  2. # 在这里编写测试

Django 建议我们使用 TestCase 的一个特殊版本。这个版本由 Django 提供,是标准版 unittest.TestCase 的增强版,添加了一些 Django 专用的功能,后面几章会介绍。

你已经知道 TDD 循环要从失败的测试开始,然后再编写代码让其通过。但在此之前,不管单元测试的内容是什么,我们都想知道自动化测试运行程序能否运行我们编写的单元测试。直接运行 functional_tests.py。但 Django 生成的这个文件有点儿神奇,所以要确认一下。来故意编写一个会失败的愚蠢测试:

lists/tests.py

  1. from django.test import TestCase
  2. class SmokeTest(TestCase):
  3. def test_bad_maths(self):
  4. self.assertEqual(1 + 1, 3)

现在,要启动神奇的 Django 测试运行程序。和之前一样,这也是一个 manage.py 命令:

  1. $ python manage.py test
  2. Creating test database for alias 'default'...
  3. F
  4. ======================================================================
  5. FAIL: test_bad_maths (lists.tests.SmokeTest)
  6. ---------------------------------------------------------------------
  7. Traceback (most recent call last):
  8. File "...superlistsliststests.py", line 6, in test_bad_maths
  9. self.assertEqual(1 + 1, 3)
  10. AssertionError: 2 != 3
  11. ---------------------------------------------------------------------
  12. Ran 1 test in 0.001s
  13.  
  14. FAILED (failures=1)
  15. System check identified no issues (0 silenced).
  16. Destroying test database for alias 'default'...

太好了,看起来能运行单元测试。现在是提交的好时机:

  1. $ git status # 会显示一个消息,说没跟踪lists/
  2. $ git add lists
  3. $ git diff --staged # 会显示将要提交的内容差异
  4. $ git commit -m "Add app for lists, with deliberately failing unit test"

你猜得没错,-m 标志的作用是让你在命令行中编写提交消息,这样就不需要使用编辑器了。如何使用 Git 命令行取决于你自己,我只是向你展示我经常见到的用法。不管使用哪种方法,提交时要遵守一个主要原则:提交前一定要审查想提交的内容。

3.4 Django中的MVC、URL和视图函数

总的来说,Django 遵守了经典的模型 - 视图 - 控制器(ModelView-Controller,MVC)模式,但并没严格遵守。Django 确实有模型,但视图更像是控制器,模板其实才是视图。不过,MVC 的思想还在。如果你有兴趣,可以看一下 Django 常见问题解答(https://docs.djangoproject.com/en/1.11/faq/general/)中的详细说明。

抛开这些,Django 和任何一种 Web 服务器一样,主要任务是决定用户访问网站中的某个 URL 时做些什么。Django 的工作流程有点儿类似下述过程。

(1) 针对某个 URL 的 HTTP 请求进入。

(2) Django 使用一些规则决定由哪个视图函数处理这个请求(这一步叫作解析 URL)。

(3) 选中的视图函数处理请求,然后返回 HTTP 响应

因此要测试两件事。

  • 能否解析网站根路径(“/”)的 URL,将其对应到我们编写的某个视图函数上?
  • 能否让视图函数返回一些 HTML,让功能测试通过?

先编写第一个测试。打开 lists/tests.py,把之前编写的愚蠢测试改成如下代码:

lists/tests.py

  1. from django.urls import resolve
  2. from django.test import TestCase
  3. from lists.views import home_page
  4. class HomePageTest(TestCase):
  5. def test_root_url_resolves_to_home_page_view(self):
  6. found = resolve('/')
  7. self.assertEqual(found.func, home_page)

这段代码是什么意思呢?

resolve 是 Django 内部使用的函数,用于解析 URL,并将其映射到相应的视图函数上。检查解析网站根路径“/”时,是否能找到名为 home_page 的函数。

❷ 这个函数是什么?这是接下来要定义的视图函数,其作用是返回所需的 HTML。从 import 语句可以看出,要把这个函数保存在文件 lists/views.py 中。

那么,你觉得运行这个测试后会有什么结果?

  1. $ python manage.py test
  2. ImportError: cannot import name 'home_page'

这个结果很容易预料,不过错误消息有点无趣:我们试图导入还没定义的函数。但是这对 TDD 来说算是好消息,预料之中的异常也算是预期失败。现在,功能测试和单元测试都失败了,在测试山羊的庇护下,我们可以编写代码了。

3.5 终于可以编写一些应用代码了

很兴奋吧?但要提醒一下:使用 TDD 时要耐着性子,步步为营。尤其是学习和起步阶段,一次只能修改(或添加)一行代码。每一次修改的代码要尽量少,让失败的测试通过即可。

我是故意这么极端的。还记得这个测试为什么会失败吗?因为我们无法从 lists.views 中导入 home_page。很好,来修正这个问题——仅修正这一个而已。在 lists/views.py 中写入下面的代码:

lists/views.py

  1. from django.shortcuts import render
  2. # 在这里编写视图
  3. home_page = None

你可能会想:“不是开玩笑吧?”

我知道你会这么想,因为我的同事第一次给我演示 TDD 时我也是这么想的。耐心一点,稍后会分析这么做是否太极端。现在,就算你有点儿恼怒,也请跟着我一起做,看添加的这段代码能否帮助我们编写正确的代码,向前迈进一小步。

再次运行测试:

  1. $ python manage.py test
  2. Creating test database for alias 'default'...
  3. E
  4. ======================================================================
  5. ERROR: test_root_url_resolves_to_home_page_view (lists.tests.HomePageTest)
  6. ---------------------------------------------------------------------
  7. Traceback (most recent call last):
  8. File "...superlistsliststests.py", line 8, in
  9. test_root_url_resolves_to_home_page_view
  10. found = resolve('')
  11. File "...django/urls/base.py", line 27, in resolve
  12. return get_resolver(urlconf).resolve(path)
  13. File ".../django/urls/resolvers.py", line 392, in resolve
  14. raise Resolver404({'tried': tried, 'path': new_path})
  15. django.urls.exceptions.Resolver404: {'tried': [[<RegexURLResolver
  16. <RegexURLPattern list> (admin:admin) ^admin/>]], 'path': ''}
  17.  
  18. ---------------------------------------------------------------------
  19. Ran 1 test in 0.002s
  20.  
  21. FAILED (errors=1)
  22. System check identified no issues (0 silenced).
  23. Destroying test database for alias 'default'...

阅读调用跟踪

花点儿时间讲解如何阅读调用跟踪,因为在 TDD 中经常要做这件事。很快你就能学会如何扫视调用跟踪,找出解决问题的线索:

  1. ======================================================================
  2. ERROR: test_root_url_resolves_to_home_page_view (lists.tests.HomePageTest)
  3. ——————————————————————————————————-
  4. Traceback (most recent call last):
  5. File "superlistsliststests.py", line 8, in
  6. test_root_url_resolves_to_home_page_view
  7. found = resolve('')
  8. File "…django/urls/base.py", line 27, in resolve
  9. return get_resolver(urlconf).resolve(path)
  10. File "…/django/urls/resolvers.py", line 392, in resolve
  11. raise Resolver404({'tried': tried, 'path': new_path})
  12. django.urls.exceptions.Resolver404: {'tried': [[<RegexURLResolver
  13. <RegexURLPattern list> (admin:admin) ^admin/>]], 'path': ''}
  14. ——————————————————————————————————-
  15. […]

❶ 首先应该查看错误本身。有时你只需查看这一处,就能立即找出问题所在。但某些时候,比如这个例子,原因就不是那么明显。

❷ 接下来要确认哪个测试失败了。是刚才编写按预期会失败的那个测试吗?在这个例子中,就是这个测试。

❸ 然后查看导致失败的测试代码。要从调用跟踪的顶部往下看,找出错误发生在哪个测试文件中哪个测试函数的哪一行代码。在这个例子中,错误发生在调用 resolve 函数解析“/”的那一行代码。

通常还有第四步,即继续往下看,查找问题牵涉的应用代码。在这个例子中全是 Django 的代码,不过在本书后面的内容中有很多示例都用到了这一步。

把以上几步的结果综合起来,可以解读出这个调用跟踪:尝试解析“/”时,Django 抛出了 404 错误。也就是说,Django 无法找到“/”的 URL 映射。下面来解决这个问题。

3.6 urls.py

测试表明,需要一个 URL 映射。Django 用 urls.py 文件把 URL 映射到视图函数上。在文件夹 superlists/superlists 中有个主 urls.py 文件,这个文件应用于整个网站。看一下其中的内容:

superlists/urls.py

  1. """superlists URL Configuration
  2. The `urlpatterns` list routes URLs to views. For more information please see:
  3. https://docs.djangoproject.com/en/1.11/topics/http/urls/
  4. Examples:
  5. Function views
  6. 1. Add an import: from my_app import views
  7. 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home')
  8. Class-based views
  9. 1. Add an import: from other_app.views import Home
  10. 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home')
  11. Including another URLconf
  12. 1. Import the include() function: from django.conf.urls import url, include
  13. 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls'))
  14. """
  15. from django.conf.urls import url
  16. from django.contrib import admin
  17. urlpatterns = [
  18. url(r'^admin/', admin.site.urls),
  19. ]

和之前一样,这个文件中也有很多 Django 生成的辅助注释和默认建议。

url 条目的前半部分是正则表达式,定义适用于哪些 URL。后半部分说明把请求发往何处:发给导入的视图函数,或是别处的 urls.py 文件。

第一个示例条目使用的正则表达式是^$,表示空字符串。这和网站的根路径,即我们要测试的“/”一样吗?分析一下,如果把这行代码的注释去掉会发生什么事呢?

01 - 图25 如果你从未见过正则表达式,现在相信我所说的就行。不过你要记在心上,稍后去学习如何使用正则表达式。

我们还将去掉 admin URL,因为暂时用不到 Django 的管理后台。

superlists/urls.py

  1. from django.conf.urls import url
  2. from lists import views
  3. urlpatterns = [
  4. url(r'^$', views.home_page, name='home'),
  5. ]

执行命令 python manage.py test,再次运行测试:

  1. [...]
  2. TypeError: view must be a callable or a list/tuple in the case of include().

确实有进展,不再显示 404 错误了。

调用跟踪有点乱,不过最后一行却指出了问题所在:单元测试把地址“”和文件 listsviews.py 中的 home_page = None 连接起来了,现在测试抱怨 home_page 无法调用。由此我们知道,要调整一下,把 home_pageNone 变成真正的函数。记住,每次改动代码都由测试驱使。

回到文件 lists/views.py,把内容改成:

lists/views.py

  1. from django.shortcuts import render
  2. # 在这里编写视图
  3. def home_page():
  4. pass

现在测试的结果如何?

  1. $ python manage.py test
  2. Creating test database for alias 'default'...
  3. .
  4. ---------------------------------------------------------------------
  5. Ran 1 test in 0.003s
  6.  
  7. OK
  8. System check identified no issues (0 silenced).
  9. Destroying test database for alias 'default'...

太好了,第一个测试终于通过了!这是一个重要时刻,我觉得值得提交一次:

  1. $ git diff # 会显示urls.py、tests.py和views.py中的变动
  2. $ git commit -am "First unit test and url mapping, dummy view"

这是我要介绍的最后一种 git commit 用法,把 am 标志放在一起使用,意思是添加所有已跟踪文件中的改动,而且使用命令行中输入的提交消息。

01 - 图26 git commit -am 是最快捷的方式,但关于提交内容的反馈信息最少,所以在此之前要先执行 git statusgit diff,弄清楚要把哪些改动放入仓库。

3.7 为视图编写单元测试

该为视图编写测试了。此时,不能使用什么都不做的函数了,我们要定义一个函数,向浏览器返回真正的 HTML 响应。打开 lists/tests.py,添加一个新测试方法。我会解释每一行代码的作用。

lists/tests.py

  1. from django.urls import resolve
  2. from django.test import TestCase
  3. from django.http import HttpRequest
  4. from lists.views import home_page
  5. class HomePageTest(TestCase):
  6. def test_root_url_resolves_to_home_page_view(self):
  7. found = resolve('')
  8. self.assertEqual(found.func, home_page)
  9. def test_home_page_returns_correct_html(self):
  10. request = HttpRequest()
  11. response = home_page(request)
  12. html = response.content.decode('utf8')
  13. self.assertTrue(html.startswith('<html>'))
  14. self.assertIn('<title>To-Do lists<title>', html)
  15. self.assertTrue(html.endswith('</html>'))

这个新测试方法都做了些什么呢?

❶ 创建了一个 HttpRequest 对象,用户在浏览器中请求网页时,Django 看到的就是 HttpRequest 对象。

❷ 把这个 HttpRequest 对象传给 home_page 视图,得到响应。听说响应对象是 HttpResponse 类的实例时,你应该不会觉得奇怪。

❸ 然后,提取响应的 .content。得到的结果是原始字节,即发给用户浏览器的 0 和 1。随后,调用 .decode(),把原始字节转换成发给用户的 HTML 字符串。

❹ 希望响应以 标签开头,并在结尾处关闭该标签。

❺ 希望响应中有一个 标签,其内容包含单词“To-Do lists”——因为在功能测试中做了这项测试。

再次说明,单元测试由功能测试驱动,而且更接近于真正的代码。编写单元测试时,按照程序员的方式思考。

运行单元测试,看看进展如何:

  1. TypeError: home_page() takes 0 positional arguments but 1 was given