• 我是否花了太多时间等待测试运行完毕,导致高效率的心流状态时间缩短?

26.4 架构方案

还有一些架构方案可以帮助测试组件发挥最大的作用,而且特别有助于避免隔离测试的缺点。

这些架构方案大都要求找到系统的边缘,即代码和外部系统(例如数据库、文件系统、万维网或者 UI)交互的地方,然后尝试将外部系统和应用的核心业务逻辑区分开。

26.4.1 端口和适配器(或六边形、简洁)架构

集成测试在系统的边界,也就是代码和外部系统(例如数据库、文件系统或 UI 组件)集成的地方,作用最大。

所以,也就是在边界,隔离测试和驭件的作用最小,因为在边界如果测试和某种实现方式耦合过于紧密,最有可能干扰你,或者在边界需要进一步确认各组件之间是否正确集成。

相反,应用的核心代码(只关注业务逻辑和业务规则的代码,完全在我们控制之中的代码)不太需要整合测试,因为我们能控制也能理解这些代码。

所以,实现需求的一种方法是尽量减少处理边界的代码量。这样,就可以使用隔离测试检查核心业务逻辑,使用整合测试检查集成点。

Steve Freeman 和 Nat Pryce 在他们合著的书 Growing Object-Oriented SoftwareGuided by Tests 中把这种方案称为“端口和适配器”(如图 26-1)。

07 - 图1

图 26-1:端口和适配器(Nat Pryce 绘制)

其实,第 23 章已经朝端口和适配器架构方案努力了,当时我们发现编写隔离的单元测试要把 ORM 代码从主应用中移除,定义为模型层的辅助函数。

这种模式有时也叫“简洁架构”或“六边形架构”。详情参见本章末尾的扩展阅读部分。

26.4.2 函数式核心,命令式外壳

Gary Bernhardt 更进一步,推荐使用他称为“函数式核心,命令式外壳”的架构。应用的“外壳”是边界交互的地方,遵守命令式编程范式,可以使用整合测试、验收测试检查,如果精简到一定程度,甚至完全不用测试。而应用的核心使用函数式编程范式编写(完全没有副作用),因此可以使用完全隔离、纯粹的单元测试,根本无须使用驭件

这个方案的详细说明,参见 Gary 的演讲,主题为 Boundaries。

26.5 小结

我尝试概述了 TDD 流程涉及的深层次注意事项。经过长年的实践经验积累才能领悟这些观点,所以我非常没资格讨论这些事情。我衷心鼓励你怀疑我所说的一切,尝试不同的方案,听听其他人是怎么说的,找到适合自己的方法。

下面列出了一些扩展阅读资料。

扩展阅读

  • “Fast Test, Slow Test”和“Boundaries”

Gary Bernhardt 分别于 2012 年(“Fast Test, Slow Test”)和 2013 年(“Boundaries”)在 Pycon 中所做的演讲。他制作的视频(Destroy All Software)也值得一看。

  • 端口和适配器

Steve Freeman 和 Nat Pryce 在他们合著的书中提出这种架构。Steve Freeman 一场名为“Test-Driven Development”的演讲对此也做了很好的讨论。还可以阅读 Uncle Bob 对简洁架构的说明(“The Clean Architecture”),以及 Alistair Cockburn 提出六边形架构的文章(“Hexagonal Architecture”)。

  • 炽热的岩浆

Casey Kinsey 提醒,尽量避免和数据库交互(演讲“Writing Fast and Efficient Unit Tests for Django”)。

  • 翻转金字塔

如果项目中运行速度慢的高层测试和单元测试的比值太大,可以使用这种形象的比喻努力翻转比值。

  • 整合测试是个骗局

J.B. Rainsberger 写过一篇著名的文章(“Integrated Tests Are A Scam”),痛斥整合测试,声称它会毁了你的生活。还可以阅读几篇后续文章,尤其是这篇防范验收测试(我叫它功能测试)的文章(“Using Integration Tests Mindfully: A Case Study”),以及这篇分析速度慢的测试是如何扼杀效率的文章(“Part 2: Some Hidden Costs of Integration Tests”)。

  • TestDouble 测试维基

Justin Searls 的在线资源(https://github.com/testdouble/contributing-tests/wiki/Test-Driven-Development)对相关概念做出了准确的定义,还讨论了测试的优缺点,而且总结了各项操作的正确做法。

  • 务实的角度

Martin Fowler(《重构》的作者)提出一种合理平衡的务实方案(http://martinfowler.com/bliki/UnitTest.html)。

在不同的测试类型之间正确权衡

  • 务实为本

    花费大量时间纠结编写何种测试往往得不偿失。最好跟着感觉走,先编写下意识觉得应该编写的测试,然后再根据需要修改。在实践中学习。

  • 关注想从测试中得到什么

    我们的目标是正确性、好的设计和快速的反馈循环。不同类型的测试以不同的方式达到这些目标。表 26-1 列出了一些自问的好问题。

  • 架构很重要

    架构在某种程度上决定了所需的测试类型。业务逻辑与外部依赖隔离得越好,代码的模块化程度越高,在单元测试、集成测试和端到端测试之间便能达到越好的平衡。

遵从测试山羊的教诲

回过头再看测试山羊。

你可能会说:“唉,哈利,大概 17 章之前,测试山羊就没那么有趣了。”请容许我唠叨几句,我要用测试山羊表达一些重要的观点。

测试很难

当我看到“遵从测试山羊的教诲”这句话时,第一印象是它道出了一个事实:测试很难——不是说测试本身难,而是难在坚持,一直做下去。

走捷径少写几个测试感觉更容易。而且心理上更难接受测试,因为付出的努力和得到的回报太不成正比。现在花时间编写的测试不会立即显出功效,要等到很久以后才有作用——或许几个月之后避免在重构过程中引入问题,或者升级依赖时捕获回归。或许测试会以一种很难衡量的方式回报你,促使你写出设计更好的代码,但你却认为就算没有测试也能写出如此优雅的代码。

为本书编写测试框架时,(http://github.com/hjwp/Book-TDD-Dev-Python/tree/master/tests)我自己也开始犯这种错误了。书中的代码很复杂,所以本身也有测试,但我偷懒了,测试覆盖度并不理想,现在我后悔了,因为测试写得笨拙又丑陋(好了,我已经开源这个测试框架,指责、嘲笑我吧)。

让CI构建始终能通过

需要真正付出心力的另一个领域是持续集成。读过第 24 章我们知道,CI 构建有时会出现意料之外的奇怪问题。出现这种问题时,如果觉得在自己的设备中正常就行,很容易放任不管。但是,如果不小心,你会开始容忍 CI 中有失败的测试组件,久而久之,CI 构建便失去了意义。若想再次让 CI 构建运行起来,工作量更大。千万别落入这个圈套。只要坚持,终究会找到测试失败的原因,而且能找到解决的方法,再次让构建通过,发挥决断作用。

像重视代码一样重视测试

别再把测试看成真正的代码的陪衬,把它当作你在开发的产品的一部分,精心雕琢,注重美观,就算发布出去也不会羞于面对众人检视。这么想有助于你接受测试。

做测试的原因有很多:可能是测试山羊告诉你要做;可能是你知道就算不能立即得到回报,但终究会得到;可能是出于责任感、职业素养、强迫症或者想挑战自己;还可能是因为测试值得实践。但终极原因是,测试让软件开发变得更有乐趣。

别忘了给吧台服务员小费

没有 O'Reilly Media 出版社的支持,我不可能写成这本书。如果你看的是在线免费版,希望你能考虑买一本。如果你自己不需要,或许可以作为礼物送给朋友。

别见外

希望你喜欢这本书。请一定要和我联系,告诉我你的想法!

附录 A PythonAnywhere

本书假设你在自己的电脑上运行 Python 和编程。当然,这不是如今做 Python 编程的唯一方式,你也可以使用在线平台,例如 PythonAnywhere(碰巧,我就在这工作)。

在阅读本书的过程中,你可以使用 PythonAnywhere,但是要做些调整和修改:测试时要设置一个 Web 应用而不是用测试服务器;要使用 Xvfb 运行功能测试;而且读到部署那几章时,要升级到付费账户。虽然可以这么做,但还是使用自己的电脑更方便。

倘若你确实想试一下,可以参照下文所述去做。

如果你还没有 PythonAnywhere 的账户,先要注册一个,免费的就行。

然后,在控制台页面启动一个 Bash Console。大多数工作都将在这个控制台中完成。

A.1 使用Xvfb在Firefox中运行Selenium会话

首先要知道,PythonAnywhere 是只有终端的环境,所以没有显示器就无法打开 Firefox。但我们可以使用虚拟显示器。

读第 1 章编写第一个测试时,你会发现无法正常运行。第一个测试如下所示,可以在 PythonAnywhere 提供的编辑器中输入:

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

但(在 Bash 终端)运行时会看到如下错误:

  1. (superlists)$ python functional_tests.py
  2. Traceback (most recent call last):
  3. File "tests.py", line 3, in ^lt;module>
  4. browser = webdriver.Firefox()
  5. [...]
  6. selenium.common.exceptions.WebDriverException: Message: 'geckodriver' executable
  7. needs to be in PATH.

这是因为 PythonAnywhere 所用的 Firefox 是旧版本,不需要 Geckodriver。但是,我们要把 Selenium 3 换成 Selenium 2:

  1. (superlists) $ pip install "selenium^lt;3"
  2. Collecting selenium^lt;3
  3. Installing collected packages: selenium
  4. Found existing installation: selenium 3.4.3
  5. Uninstalling selenium-3.4.3:
  6. Successfully uninstalled selenium-3.4.3
  7. Successfully installed selenium-2.53.6

现在又会遇到一个问题:

  1. (superlists)$ python functional_tests.py
  2. Traceback (most recent call last):
  3. File "tests.py", line 3, in ^lt;module>
  4. browser = webdriver.Firefox()
  5. [...]
  6. selenium.common.exceptions.WebDriverException: Message: The browser appears to
  7. have exited before we could connect. If you specified a log_file in the
  8. FirefoxBinary constructor, check it for details.

Firefox 无法启动,因为没有运行所需的显示器,毕竟 PythonAnywhere 是服务器环境。解决方法是使用 Xvfb(X Virtual Framebuffer 的简称)。在没有真正的显示器的服务器中,Xvfb 会启动一个“虚拟”显示器,供 Firefox 使用。

xvfb-run 命令的作用是,在 Xvfb 中执行下一个命令。使用这个命令就会看到预期失败:

  1. (superlists)$ xvfb-run -a python functional_tests.py
  2. Traceback (most recent call last):
  3. File "tests.py", line 11, in ^lt;module>
  4. assert 'Django' in browser.title
  5. AssertionError

记住,只要想运行功能测试,就要使用 xvfb-run -a 命令。

A.2 以PythonAnywhere Web应用的方式安装Django

随后要使用 django-admin.py startproject 命令创建 Django 项目。但是,不要使用 manage.py runserver 启动本地开发服务器,我们将把网站设置为真正的 PythonAnywhere Web 应用。

打开“Web”标签页,点击按钮添加一个新 Web 应用。选择“Manual configuration”(手动配置),然后再选“Python 3.4”。

在下一个界面中输入虚拟环境的名称(“superlists”),提交后,会自动补全为 homeyourusername/.virtualenvs/superlists。

最后,找到编辑 wsgi 文件的链接,找出针对 Django 的那一部分,去掉注释。点击“Save”,再点击“Reload”,刷新 Web 应用。

从现在开始,不要在控制台中运行 localhost:8000 上的测试服务器,你可以使用 PythonAnywhere 为 Web 应用分配的真实 URL 了:

  1. browser.get('http://my-username.pythonanywhere.com')

07 - 图2 每次修改代码后都要点击“Reload Web App”(重新加载 Web 应用)按钮,更新网站。

效果更好! 1 在第 7 章换用 LiveServerTestCaseself.live_server_url 之前,在 PythonAnywhere 中必须这样指向功能测试,而且每次运行功能测试之前都要点击“Reload”。

1也可以在终端里运行开发服务器,但有个问题,PythonAnywhere 的终端不一定运行在同一台服务器中,所以无法保证运行测试的终端和运行服务器的终端是同一个。而且,如果在终端里运行服务器,没有简单的方法视觉检查网站的外观。

A.3 清理/tmp目录

Selenium 和 Xvfb 会在 /tmp 目录中留下很多垃圾,如果关闭的方式不优雅,情况会更糟(所以前文才要使用 try/finally 语句)。

遗留的东西太多,可能会用完存储配额。所以要经常清理 /tmp 目录:

  1. $ rm -rf tmp*

A.4 截图

在第 5 章中,我建议使用 time.sleep 在功能测试运行的过程中暂停一会儿,这样才能在屏幕上看到 Selenium 浏览器。在 PythonAnywhere 做不到这一点,因为浏览器运行在虚拟显示器中。不过你可以检查线上网站,或者别管应该看到什么,相信我说的就行了。

对运行在虚拟显示器中的测试做视觉检查,最好的方法是使用截图。如果你想知道怎么做,看一下第 24 章,那里有一些示例代码。

A.5 关于部署

读到第 9 章时,你可以选择继续使用 PythonAnywhere,也可以选择学习如何配置真实的服务器。我建议选择后者,因为这样能学到更多。

如果想一直使用 PythonAnywhere,可以再注册一个 PythonAnywhere 账户,用作过渡网站(作弊嫌疑很大)。或者,为现有账户再添加一个域名。但部署那一章的内容就用不到了(在 PythonAnywhere 上无须 Nginx、Gunicorn 或域套接字)。

遇到下列情形之一时,你需要一个付费账户。

  • 如果过渡网站不使用 PythonAnywhere 提供的域名。
  • 如果不想在 PythonAnywhere 提供的域名上运行功能测试(因为别的域名不在白名单上)。
  • 读到第 11 章,如果想使用 PythonAnywhere 账户运行 Fabric(因为需要 SSH)。

如果你想“作弊”,可以在现有的 Web 应用中以“过渡”模式运行功能测试,并跳过涉及 Fabric 的部分——这算是一种妥协吧。其实,你可以先升级账户,然后立即取消,在 30 天保障期内申请退款。

07 - 图3 如果阅读本书时你从头至尾都使用 PythonAnywhere,我很想听听你是怎么做到的。请给我发电子邮件,地址是 obeythetestinggoat@gmail.com

附录 B 基于类的Django视图

本附录接续第 15 章。第 15 章实现了 Django 表单的验证功能,还重构了视图。结束时,视图仍然使用函数实现。

不过,Django 领域现在流行使用基于类的视图(Class-Based View,CBV)。在这个附录中,我们要重构应用,把视图函数改写成基于类的视图。更确切地说,我们要尝试使用基于类的通用视图(Class-Based Generic View,CBGV)。

B.1 基于类的通用视图

基于类的视图和基于类的通用视图有个区别。基于类的视图(class-based view,CBV)只是定义视图函数的另一种方式,对视图要做的事情没有太多假设,和视图函数相比主要的优势是可以创建子类。不过也要付出一定代价,基于类的视图比传统的基于函数的视图可读性差(这一点有争论)。普通的 CBV 的作用是让多个视图重用相同的逻辑,因为我们想遵守 DRY 原则。如果使用基于函数的视图,重用逻辑要使用辅助函数或修饰器。理论上,使用类实现更优雅。

基于类的通用视图也是一种基于类的视图,但它尝试为常见操作提供现成的解决方案,例如从数据库中获取对象后传入模板,获取一组对象,使用 ModelForm 保存 POST 请求中用户输入的数据,等等。看起来现在需要的就是这种视图,不过稍后就会发现魔鬼藏在细节中。

这里我想说,我并不常用任何一种基于类的视图。我完全能看到这种视图的合理之处,而且在 Django 应用中有很多地方都非常适合使用 CBGV。但是,只要需求稍微高一点儿,例如想使用多个模型,就会发现基于类的视图比传统的视图函数难读得多(这一点也有争论)。

不过,因为必须使用基于类的视图提供的几个定制选项,通过这种实现方式能学到很多这种视图的工作方式,以及如何为这种视图编写单元测试。

我希望为基于函数的视图编写的单元测试也能正常测试基于类的视图。看一下具体该怎么做。

B.2 使用FormView实现首页

网站的首页只是在模板中显示一个表单:

lists/views.py

  1. def home_page(request):
  2. return render(request, 'home.html', {'form': ItemForm()})

看过可选视图(https://docs.djangoproject.com/en/1.11/ref/class-based-views/)之后,我们发现 Django 提供了一个通用视图,叫 FormView。看一下怎么用:

lists/views.py (ch31l001)

  1. from django.views.generic import FormView
  2. [...]
  3. class HomePageView(FormView):
  4. template_name = 'home.html'
  5. form_class = ItemForm

指定想使用哪个模板和表单。然后,只需更新 urls.py,把含有 lists.views.home_page 那行代码改成:

superlists/urls.py (ch31l002)

  1. [...]
  2. urlpatterns = [
  3. url(r'^$', list_views.HomePageView.as_view(), name='home'),
  4. url(r'^lists/', include(list_urls)),
  5. ]

运行所有测试确认,这很简单:

  1. $ python manage.py test lists
  2. [...]
  3.  
  4. Ran 34 tests in 0.119s
  5. OK
  6.  
  7. $ python manage.py test functional_tests
  8. [...]
  9. Ran 5 tests in 15.160s
  10. OK

目前为止一切顺利。把一行代码的视图函数换成有两行代码的类,而且可读性依然不错。现在是提交的好时机。

B.3 使用form_valid定制CreateView

下面改写新建清单的视图,也就是 new_list 函数。现在这个视图如下所示:

lists/views.py

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

浏览可用的 CBGV 列表之后,发现需要的或许是 CreateView,而且知道要使用 ItemForm 类,下面看一下具体该怎么做,以及测试能否提供帮助:

lists/views.py (ch31l003)

  1. from django.views.generic import FormView, CreateView
  2. [...]
  3. class NewListView(CreateView):
  4. form_class = ItemForm
  5. def new_list(request):
  6. [...]

我要在 views.py 中保留原来的视图函数,这样才能从中复制代码,等一切都能正常运行之后再删除。这么做没什么危害,只要修改 URL 映射就行。这一次要这么改:

lists/urls.py (ch31l004)

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

然后运行测试。有 6 个错误:

  1. $ python manage.py test lists
  2. [...]
  3.  
  4. ERROR: test_can_save_a_POST_request (lists.tests.test_views.NewListTest)
  5. TypeError: save() missing 1 required positional argument: 'for_list'
  6.  
  7. ERROR: test_for_invalid_input_passes_form_to_template
  8. (lists.tests.test_views.NewListTest)
  9. django.core.exceptions.ImproperlyConfigured: TemplateResponseMixin requires
  10. either a definition of 'template_name' or an implementation of
  11. 'get_template_names()'
  12.  
  13. ERROR: test_for_invalid_input_renders_home_template
  14. (lists.tests.test_views.NewListTest)
  15. django.core.exceptions.ImproperlyConfigured: TemplateResponseMixin requires
  16. either a definition of 'template_name' or an implementation of
  17. 'get_template_names()'
  18.  
  19. ERROR: test_invalid_list_items_arent_saved (lists.tests.test_views.NewListTest)
  20. django.core.exceptions.ImproperlyConfigured: TemplateResponseMixin requires
  21. either a definition of 'template_name' or an implementation of
  22. 'get_template_names()'
  23.  
  24. ERROR: test_redirects_after_POST (lists.tests.test_views.NewListTest)
  25. TypeError: save() missing 1 required positional argument: 'for_list'
  26.  
  27. ERROR: test_validation_errors_are_shown_on_home_page
  28. (lists.tests.test_views.NewListTest)
  29. django.core.exceptions.ImproperlyConfigured: TemplateResponseMixin requires
  30. either a definition of 'template_name' or an implementation of
  31. 'get_template_names()'
  32.  
  33.  
  34. FAILED (errors=6)

先解决前 3 个——设定模板应该就可以了吧?

lists/views.py (ch31l005)

  1. class NewListView(CreateView):
  2. form_class = ItemForm
  3. template_name = 'home.html'

现在只剩两个错误了,可以看出,这两个错误都发生在通用视图的 form_valid 方法中。这个方法可以重新定义来定制 CBGV 的行为。从这个方法的名字可以看出,视图认为表单中的数据有效之后才会运行这个方法。可以从以前的视图函数中把 if form.is_valid(): 之后的代码复制过来:

lists/views.py (ch31l006)

  1. class NewListView(CreateView):
  2. template_name = 'home.html'
  3. form_class = ItemForm
  4. def form_valid(self, form):
  5. list_ = List.objects.create()
  6. form.save(for_list=list_)
  7. return redirect(list_)

这样测试就能全部通过了:

  1. $ python manage.py test lists
  2. Ran 34 tests in 0.119s
  3. OK
  4. $ python manage.py test functional_tests
  5. Ran 5 tests in 15.157s
  6. OK

而且,为了遵守 DRY 原则,可以使用 CBV 的主要优势之一——继承,节省两行代码:

lists/views.py (ch31l007)

  1. class NewListView(CreateView, HomePageView):
  2. def form_valid(self, form):
  3. list_ = List.objects.create()
  4. form.save(for_list=list_)
  5. return redirect(list_)

测试应该仍能全部通过:

  1. OK

07 - 图4 其实在面向对象编程中这么做并不好。继承意味着“是一个什么”这种关系,但是说新建清单视图“是一个”首页视图或许没有什么意义。所以,或许最好别这么做。

不管做没做最后一步,你觉得和以前的版本相比怎么样?我觉得还不错。不用写样板代码了,而且视图代码还相当易读。目前,CBGV 得了一分,和基于函数的视图平局。

B.4 一个更复杂的视图,既能查看清单,也能向清单中添加待办事项

我做了好多尝试才写出这个视图。不得不说,虽然测试能告诉我做得对不对,但是在寻找实现步骤的过程中并不能给出实质帮助,大多数情况下我都在反复实验,尝试使用 get_context_dataget_form_kwargs 等函数。

不过,在实现的过程中我意识到一件事:编写多个只测试一件事的测试很重要。所以又回头重写了第 10~12 章的部分内容。

B.4.1 测试有指引作用,但时间不长

下面是一种实现方式。首先,我觉得需要使用 DetailView,显示对象的详情:

lists/views.py (ch31l009)

  1. from django.views.generic import FormView, CreateView, DetailView
  2. [...]
  3. class ViewAndAddToList(DetailView):
  4. model = List

然后在 urls.py 中设置:

lists/urls.py (ch31l010)

  1. url(r'^(\d+)/$', views.ViewAndAddToList.as_view(), name='view_list'),

测试的结果为:

  1. [...]
  2. AttributeError: Generic detail view ViewAndAddToList must be called with either
  3. an object pk or a slug.
  4. FAILED (failures=5, errors=6)

失败消息的意思并不明确,在谷歌中搜索一番之后我才知道要使用正则表达式具名捕获组:

lists/urls.py (ch31l011)

  1. @@ -3,6 +3,6 @@ from lists import views
  2. urlpatterns = [
  3. url(r'^new$', views.NewListView.as_view(), name='new_list'),
  4. - url(r'^(\d+)/$', views.view_list, name='view_list'),
  5. + url(r'^(?P<pk>\d+)/$', views.ViewAndAddToList.as_view(), name='view_list')
  6. ]

接下来出现的错误中有一个相当有帮助:

  1. [...]
  2. django.template.exceptions.TemplateDoesNotExist: lists/list_detail.html
  3. FAILED (failures=5, errors=6)

这个很容易解决:

lists/views.py (ch31l012)

  1. class ViewAndAddToList(DetailView):
  2. model = List
  3. template_name = 'list.html'

这次改动后,只剩 5 个失败和 2 个错误了:

  1. [...]
  2. ERROR: test_displays_item_form (lists.tests.test_views.ListViewTest)
  3. KeyError: 'form'
  4. FAILED (failures=5, errors=2)

B.4.2 现在不得不反复实验

我发现这个视图不仅要显示对象的详情,还要能新建对象。所以这个视图要继承 DetailView CreateView,可能还要添加 form-class 属性:

lists/views.py (ch31l013)

  1. class ViewAndAddToList(DetailView, CreateView):
  2. model = List
  3. template_name = 'list.html'
  4. form_class = ExistingListItemForm

但是这么改,得到了很多错误:

  1. [...]
  2. TypeError: __init__() missing 1 required positional argument: 'for_list'

而且那个 KeyError: 'form' 错误还在。

现在,错误消息没什么用了,而且下一步该做什么也不明确。我不得不反复实验。不过,测试仍能告诉我做得对还是把情况变得更糟了。

首先尝试使用 get_form_kwargs,但没什么用,不过我发现可以使用 get_form

lists/views.py (ch31l014)

  1. def get_form(self):
  2. self.object = self.get_object()
  3. return self.form_class(for_list=self.object, data=self.request.POST)

但必须给 self.object 赋值才能使用 get_form,对此我一直心里不安。不过现在只剩 3 个错误了,显然是因为表单还没传入模板。

  1. django.core.exceptions.ImproperlyConfigured: No URL to redirect to. Either
  2. provide a url or define a get_absolute_url method on the Model.

B.4.3 测试再次发挥作用

对最后的这个失败,测试又变有用了。解决的方法很简单,在 Item 类中定义 get_absolute_url 方法,让待办事项指向所属的清单页面即可:

lists/models.py (ch31l015)

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

B.4.4 这是最终结果吗

最终写出的视图类如下所示:

lists/views.py

  1. class ViewAndAddToList(DetailView, CreateView):
  2. model = List
  3. template_name = 'list.html'
  4. form_class = ExistingListItemForm
  5. def get_form(self):
  6. self.object = self.get_object()
  7. return self.form_class(for_list=self.object, data=self.request.POST)

B.5 新旧版对比

比较一下旧版和新版:

lists/views.py

  1. def view_list(request, list_id):
  2. list_ = List.objects.get(id=list_id)
  3. form = ExistingListItemForm(for_list=list_)
  4. if request.method == 'POST':
  5. form = ExistingListItemForm(for_list=list_, data=request.POST)
  6. if form.is_valid():
  7. form.save()
  8. return redirect(list_)
  9. return render(request, 'list.html', {'list': list_, "form": form})

虽然新版代码从 9 行缩减到 7 行,但我还是觉得基于函数的视图稍微容易理解一点儿,因为旧版没隐藏那么多细节,毕竟“明确表述比含糊其辞强”,这是 Python 的禅理。我的意思是,谁知道 SingleObjectMixin 是什么呢?而且更讨厌的是,如果在 get_form 方法中不给 self.object 赋值,整个类都不能用。这一点太烦人。

不过,我猜有些人喜欢这种实现方式。

B.6 为CBGV编写单元测试有最佳实践吗

实现这个视图类之后,我发现有些单元测试有点儿太关注高层。这并不意外,因为使用 Django 测试客户端的视图测试或许更应该叫整合测试。

测试会告诉我做得对不对,但不能始终提示具体应该怎么修正错误。

有时,我想知道有没有一种编写测试的方法,更贴近实现方式,例如这么编写测试:

lists/tests/test_views.py

  1. def test_cbv_gets_correct_object(self):
  2. our_list = List.objects.create()
  3. view = ViewAndAddToList()
  4. view.kwargs = dict(pk=our_list.id)
  5. self.assertEqual(view.get_object(), our_list)

但这么做有个问题,必须对 Django CBV 的内部机理有一定了解,才能正确设定这种测试。而且最后还是会被复杂的继承体系弄得十分糊涂。

B.7 记住:编写多个只有一个断言的隔离视图测试有所帮助

在这个附录中我得出一个结论:编写多个简短的单元测试比编写少量含有很多断言的测试有用得多。

看看这个庞大的测试:

lists/tests/test_views.py

  1. def test_validation_errors_sent_back_to_home_page_template(self):
  2. response = self.client.post('listsnew', data={'text': ''})
  3. self.assertEqual(List.objects.all().count(), 0)
  4. self.assertEqual(Item.objects.all().count(), 0)
  5. self.assertTemplateUsed(response, 'home.html')
  6. expected_error = escape("You can't have an empty list item")
  7. self.assertContains(response, expected_error)

它肯定没有下面这 3 个单独的测试有用:

lists/tests/test_views.py

  1. def test_invalid_input_means_nothing_saved_to_db(self):
  2. self.post_invalid_input()
  3. self.assertEqual(List.objects.all().count(), 0)
  4. self.assertEqual(Item.objects.all().count(), 0)
  5. def test_invalid_input_renders_list_template(self):
  6. response = self.post_invalid_input()
  7. self.assertTemplateUsed(response, 'list.html')
  8. def test_invalid_input_renders_form_with_errors(self):
  9. response = self.post_invalid_input()
  10. self.assertIsinstance(response.context['form'], ExistingListItemForm)
  11. self.assertContains(response, escape(empty_list_error))

因为对前一种方式来说,如果靠前的断言失败了,后面的断言就不会执行。所以,如果视图不小心把 POST 请求中的无效数据存入数据库,前面的断言会失败,这样就无法确认使用的模板是否正确以及有没有渲染表单。使用后一种方式则能更轻易地分辨出到底哪一部分能用,哪一部分不能用。

从 CBGV 中学到的经验

  • 基于类的通用视图可以做任何事

    虽然不一定总是知道到底怎么回事,但使用基于类的视图几乎可以做任何事。

  • 只有一个断言的单元测试有助于重构

    有单元测试检查什么可用什么不可用,使用不同的基本范式修改视图的实现方式就容易多了。

附录 C 使用Ansible配置服务器

用 Fabric 自动把新版源码部署到服务器上,但配置新服务器的过程以及更新 Nginx 和 Gunicorn 配置文件的操作都还是手动完成。

这类操作越来越多地交给“配置管理”或“持续部署”工具完成。其中,Chef 和 Puppet 最受欢迎,而在 Python 领域则是 Salt 和 Ansible。

在这些工具中,Ansible 最容易上手,只需两个文件就可以使用:

  1. pip2 install --user ansible # 可惜只能用Python 2

清单文件 deploy_tools/inventory.ansible 定义可以在哪些服务器中运行:

deploy_tools/inventory.ansible

  1. [live]
  2. superlists.ottg.eu ansible_become=yes ansible_ssh_user=elspeth
  3. [staging]
  4. superlists-staging.ottg.eu ansible_become=yes ansible_ssh_user=elspeth
  5. [local]
  6. localhost ansible_ssh_user=root ansible_ssh_port=6666 ansible_host=127.0.0.1

(local 条目只是个示例,在我的设备中是个 Virtualbox 虚拟主机,并且为 22 和 80 端口设定了端口转发。)

C.1 安装系统包和Nginx

另一个文件是“脚本”(playbook),定义在服务器中做什么。这个文件的内容使用 YAML 句法编写:

deploy_tools/provision.ansible.yaml

  1. ---
  2. - hosts: all
  3. vars:
  4. host: "{{ inventory_hostname }}"
  5. tasks:
  6. - name: Deadsnakes PPA to get Python 3.6
  7. apt_repository:
  8. repo='ppa:fkrull/deadsnakes'
  9. - name: make sure required packages are installed
  10. apt: pkg=nginx,git,python3.6,python3.6-venv state=present
  11. - name: allow long hostnames in nginx
  12. lineinfile:
  13. dest=etcnginx/nginx.conf
  14. regexp='(\s+)#? ?server_names_hash_bucket_size'
  15. backrefs=yes
  16. line='\1server_names_hash_bucket_size 64;'
  17. - name: add nginx config to sites-available
  18. template: src=./nginx.conf.j2 dest=etcnginx/sites-available/{{ host }}
  19. notify:
  20. - restart nginx
  21. - name: add symlink in nginx sitesenabled
  22. file:
  23. src=etcnginx/sites-available/{{ host }}
  24. dest=etcnginx/sitesenabled/{{ host }}
  25. state=link
  26. notify:
  27. - restart nginx

inventory_hostname 变量是目标服务器的域名。为了方便引用,我在 vars 部分把它重命名成了“host”。

在“tasks”部分,使用 apt 安装所需的软件,再使用正则表达式替换 Nginx 配置,允许使用长域名,然后使用模板创建 Nginx 配置文件。这个模板由第 9 章保存在 deploy_tools/nginx.template.conf 中的模板文件修改而来,不过现在指定使用一种模板引擎——Jinja2,和 Django 的模板句法很像:

deploy_tools/nginx.conf.j2

  1. server {
  2. listen 80;
  3. server_name {{ host }};
  4. location /static {
  5. alias home{{ ansible_ssh_user }}/sites/{{ host }}/static;
  6. }
  7. location {
  8. proxy_set_header Host {{ host }};
  9. proxy_pass http:/unix:tmp{{ host }}.socket;
  10. }
  11. }

C.2 配置Gunicorn,使用处理程序重启服务

脚本剩余的内容如下:

deploy_tools/provision.ansible.yaml

  1. - name: write gunicorn service script
  2. template:
  3. src=./gunicorn.service.j2
  4. dest=etcsystemd/system/gunicorn-{{ host }}.service
  5. notify:
  6. - restart gunicorn
  7. handlers:
  8. - name: restart nginx
  9. service: name=nginx state=restarted
  10. - name: restart gunicorn
  11. systemd:
  12. name=gunicorn-{{ host }}
  13. daemon_reload=yes
  14. enabled=yes
  15. state=restarted

创建 Gunicorn 配置文件还要使用模板:

deploy_tools/gunicorn.service.j2

  1. [Unit]
  2. Description=Gunicorn server for {{ host }}
  3. [Service]
  4. User={{ ansible_ssh_user }}
  5. WorkingDirectory=home{{ ansible_ssh_user }}/sites/{{ host }}/source
  6. Restart=on-failure
  7. ExecStart=home{{ ansible_ssh_user }}/sites/{{ host }}/virtualenvbingunicorn \
  8. --bind unix:tmp{{ host }}.socket \
  9. --access-logfile ../access.log \
  10. --error-logfile ../error.log \
  11. superlists.wsgi:application
  12. [Install]
  13. WantedBy=multi-user.target

然后定义两个处理程序,重启 Nginx 和 Gunicorn。Ansible 很智能,如果多个步骤都调用同一个处理程序,它会等前一个执行完再调用下一个。

这样就行了!执行配置操作的命令如下:

  1. ansible-playbook -i inventory.ansible provision.ansible.yaml --limit=staging --ask-become-pass

详细信息参阅 Ansible 的文档。

C.3 接下来做什么

我只是简单介绍了 Ansible 的功能。部署过程的自动化程度越高,你对部署也越自信。接下来,你可以完成下面几件事。

C.3.1 把Fabric执行的部署操作交给Ansible

已经看到 Ansible 可以帮助完成配置过程中的某些操作,其实它可以完成几乎所有部署操作。你可以试一下,看能否扩写脚本把当前 Fabric 部署脚本中的所有操作都交给 Ansible 完成,包括必要情况下重启时发出提醒。

C.3.2 使用Vagrant搭建本地虚拟主机

在过渡网站中运行测试能让我们相信网站上线后也能正常运行。不过也可以在本地设备中使用虚拟主机完成这项操作。

下载 Vagrant 和 Virtualbox,看你能否使用 Vagrant 在自己的电脑中搭建一个开发服务器,以及使用 Ansible 脚本把代码部署到这个服务器中。设置功能测试运行程序,让功能测试能在本地虚拟主机中运行。

如果在团队中工作,编写一个 Vagrant 配置脚本特别有用,因为它能帮助新加入的开发者搭建和你们使用的一模一样的服务器。

附录 D 测试数据库迁移

Django-migrations 及其前身 South 已经出现好多年了,所以一般没必要测试数据库迁移。但有时我们会使用一种危险的迁移,即引入新的数据完整性约束。我第一次在过渡环境中运行这种迁移脚本时,遇到了一个错误。

在大型项目中,如果有敏感数据,在生产数据中执行迁移之前,你可能想先在一个安全的环境中测试,增加一些自信。你可以在本书开发的示例应用中先练习一下。

测试迁移的另一个常见原因是测速——执行迁移时经常要把网站下线,而且如果数据集较大,用时并不短。所以最好提前知道迁移要执行多长时间。

D.1 尝试部署到过渡服务器

在第 17 章,当我第一次尝试部署新添加的验证约束条件时,遇到了如下问题:

  1. $ cd deploy_tools
  2. $ fab deploy:host=elspeth@superlists-staging.ottg.eu
  3. [...]
  4. Running migrations:
  5. Applying lists.0005_list_item_unique_together...Traceback (most recent call
  6. last):
  7. File "usrlocal/lib/python3.6/dist-packages/django/db/backends/utils.py",
  8. line 61, in execute
  9. return self.cursor.execute(sql, params)
  10. File
  11. "usrlocal/lib/python3.6/dist-packages/django/db/backends/sqlite3/base.py",
  12. line 475, in execute
  13. return Database.Cursor.execute(self, query, params)
  14. sqlite3.IntegrityError: columns list_id, text are not unique
  15. [...]

情况是这样,数据库中某些现有的数据违反了完整性约束条件,所以当我尝试应用约束条件时,数据库表达了不满。

为了处理这种问题,需要执行“数据迁移”。首先,要在本地搭建一个测试环境。

D.2 在本地执行一个用于测试的迁移

使用线上数据库的副本测试迁移。

07 - 图5 测试时使用真实数据一定要小心小心再小心。例如,数据中可能有客户的真实电子邮件地址,但你并不想不小心给他们发送一堆测试邮件。我可是栽过跟头的。

D.2.1 输入有问题的数据

在线上网站中新建一个清单,输入一些重复的待办事项,如图 D-1 所示。

07 - 图6

图 D-1:一个清单,待办事项有重复

D.2.2 从线上网站中复制测试数据

从线上网站中复制数据库:

  1. $ scp elspeth@superlists.ottg.eu:\
  2. homeelspeth/sites/superlists.ottg.eu/database/db.sqlite3 .
  3. $ mv ../database/db.sqlite3 ../database/db.sqlite3.bak
  4. $ mv db.sqlite3 ../database/db.sqlite3

D.2.3 确认的确有问题

现在,本地有一个还未执行迁移的数据库,而且数据库中有一些问题数据。如果尝试执行 migrate 命令,会看到一个错误:

  1. $ python manage.py migrate --migrate
  2. python manage.py migrate
  3. Operations to perform:
  4. [...]
  5. Running migrations:
  6. [...]
  7. Applying lists.0005_list_item_unique_together...Traceback (most recent call
  8. last):
  9. [...]
  10. return Database.Cursor.execute(self, query, params)
  11. sqlite3.IntegrityError: columns list_id, text are not unique

D.3 插入一个数据迁移

数据迁移是一种特殊的迁移,目的是修改数据库中的数据,而不是变更模式。应用完整性约束之前,先要执行一次数据迁移,把重复数据删除。具体方法如下:

  1. $ git rm lists/migrations/0005_list_item_unique_together.py
  2. $ python manage.py makemigrations lists --empty
  3. Migrations for 'lists':
  4. 0005_auto_20140414_2325.py:
  5. $ mv lists/migrations/0005_*.py lists/migrations/0005_remove_duplicates.py

有关数据迁移的详情,请参阅 Django 文档。下面是修改现有数据的方法:

lists/migrations/0005_remove_duplicates.py

  1. # encoding: utf8
  2. from django.db import models, migrations
  3. def find_dupes(apps, schema_editor):
  4. List = apps.get_model("lists", "List")
  5. for list_ in List.objects.all():
  6. items = list_.item_set.all()
  7. texts = set()
  8. for ix, item in enumerate(items):
  9. if item.text in texts:
  10. item.text = '{} ({})'.format(item.text, ix)
  11. item.save()
  12. texts.add(item.text)
  13. class Migration(migrations.Migration):
  14. dependencies = [
  15. ('lists', '0004_item_list'),
  16. ]
  17. operations = [
  18. migrations.RunPython(find_dupes),
  19. ]

重新创建以前的迁移

使用 makemigrations 重新创建以前的迁移,确保这是第 6 个迁移,而且还明确依赖于 0005,即那个数据迁移:

  1. $ python manage.py makemigrations
  2. Migrations for 'lists':
  3. 0006_auto_20140415_0018.py:
  4. - Alter unique_together for item (1 constraints)
  5. $ mv lists/migrations/0006_* lists/migrations/0006_unique_together.py

D.4 一起测试这两个迁移

现在可以在线上数据中测试了:

  1. $ cd deploy_tools
  2. $ fab deploy:host=elspeth@superlists-staging.ottg.eu
  3. [...]

还要重启服务器中的 Gunicorn 服务:

  1. elspeth@server:$ sudo systemctl restart gunicorn-superlists.ottg.eu

然后可以在过渡网站中运行功能测试:

  1. $ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests
  2. [...]
  3. ....
  4. ---------------------------------------------------------------------
  5. Ran 4 tests in 17.308s
  6.  
  7. OK

看起来一切正常。现在部署到线上服务器:

  1. $ fab deploy --host=superlists.ottg.eu
  2. [superlists.ottg.eu] Executing task 'deploy'
  3. [...]

最后,执行 git add lists/migrationsgit commit 等命令。

D.5 小结

这个练习的主要目的是编写一个数据迁移,在一些真实的数据中测试。当然,这只是测试迁移的千万种方式之一。你还可以编写自动化测试,比较运行迁移前后数据库中的内容,确认数据还在。也可以为数据迁移中的辅助函数单独编写单元测试。你可以再多花点儿时间统计迁移所用的时间,然后实验多种提速的方法,例如,把迁移的步骤分得更细或更笼统。

记住,这种需求很少见。根据我的经验,我使用的迁移有 99% 都不需要测试。不过,在你的项目中可能需要。希望读过这个附录之后,你知道怎么着手测试迁移。

关于测试数据库迁移

  • 小心引入约束的迁移

    99% 的迁移都没问题,但是如果迁移为现有的列引入了新的约束条件,就像前面那个例子,一定要小心。

  • 测试迁移的执行速度

    一旦项目变大,你就应该考虑测试迁移所用的时间。执行数据库迁移时往往要下线网站,因为修改模式可能要锁定数据表(取决于使用的数据库种类),直到操作完成为止。所以最好在过渡网站中测试迁移要用多长时间。

  • 使用生产数据的副本时要格外小心

    为了测试迁移,要在过渡网站的数据库中填充与生产数据等量的数据。具体怎么做不在本书范畴之内,不过我要提醒一下:如果直接转储生产数据库导入过渡网站的数据库中,一定要十分小心,因为生产数据中包含真实客户的详细信息。有一次在过渡服务器中自动处理刚导入的生产数据副本时,我不小心发送了好几百张错误的发票。那个下午过得可不愉快。

附录 E 行为驱动开发

行为驱动开发(behaviour-driven development,BDD)我用得不多,算不上什么专家,但就目前我所接触的,我认为值得简要介绍一下。本附录将使用 BDD 工具转换一些“常规的”功能测试。

E.1 BDD是什么

严格来说,BDD 是一种方法论,而不是工具集。BDD 测试的是应用的行为,确定是否与我们期望用户看到的一致。因此,本书展示的部分基于 Selenium 的功能测试其实也可以称为 BDD。

但这种测试方法通常与 BDD 采用的特定工具集联系紧密,其中最重要的是 Gherkin 句法,这是编写功能测试(或验收测试)的 DSL,对人类而言可读性高。Gherkin 源自 Ruby 世界,与名为 Cucumber 的测试运行程序捆绑。

在 Python 世界,有几个等效的测试运行工具,例如 Lettuce 和 Behave。目前为止,只有 Behave 兼容 Python 3,因此我们将使用它。此外,还将使用一个插件 behave-django。

获取示例代码

我将使用第 22 章的示例。待办事项清单网站已经具备基本功能,我们想添加一个新功能:已登录用户应该能在某个地方查看自己编制的清单。在此之前,所有清单都是匿名创建的。

如果你一直跟着本书操作,我假设你能跳回到那一点。如果想从我的仓库中拉取,要使用 chapter_17 分支。

E.2 基本的准备工作

下面为 BDD 特性描述创建一个 features 目录,在里面再添加一个 steps 目录(马上告诉你它的作用),并为这个新功能创建一个占位文件:

  1. $ mkdir -p features/steps
  2. $ touch features/my_lists.feature
  3. $ touch features/steps/my_lists.py
  4. $ tree features
  5. features
  6. ├── my_lists.feature
  7. └── steps
  8. └── my_lists.py

然后安装 behave-django,并把它添加到 settings.py 中:

  1. $ pip install behave-django

superlists/settings.py

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

最后运行 python manage.py,做健全性检查:

  1. $ python manage.py behave
  2. Creating test database for alias 'default'...
  3. 0 features passed, 0 failed, 0 skipped
  4. 0 scenarios passed, 0 failed, 0 skipped
  5. 0 steps passed, 0 failed, 0 skipped, 0 undefined
  6. Took 0m0.000s
  7. Destroying test database for alias 'default'...

E.3 使用Gherkin句法以“特性描述”的形式编写功能测试

目前,我们的功能测试使用人类可读的注释描述新功能,这叫用户故事,其间穿插着执行故事中每一步的 Selenium 代码。

BDD 要求严格区分这二者:先使用 Gherkin 句法(有时让人觉得拗口)编写人类可读的故事,这叫“特性描述”(feature);然后,把每一行 Gherkin 代码映射到一个函数上,那个函数包含实现那一“步”(step)所需的 Selenium 代码。

“My Lists”页面这个新功能的特性描述可以写成下面这样:

features/my_lists.feature

  1. Feature: My Lists
  2. As a logged-in user
  3. I want to be able to see all my lists in one page
  4. So that I can find them all after I've written them
  5. Scenario: Create two lists and see them on the My Lists page
  6. Given I am a logged-in user
  7. When I create a list with first item "Reticulate Splines"
  8. And I add an item "Immanentize Eschaton"
  9. And I create a list with first item "Buy milk"
  10. Then I will see a link to "My lists"
  11. When I click the link to "My lists"
  12. Then I will see a link to "Reticulate Splines"
  13. And I will see a link to "Buy milk"
  14. When I click the link to "Reticulate Splines"
  15. Then I will be on the "Reticulate Splines" list page

E.3.1 As-a/I want to/So that

顶部是“As-a/I want to/So that”子句。这部分是可选的,没有对应的可执行代码。这只是一种形式,让用户知道“你是谁、你想做什么”,有助于团队成员理解每个功能的背景。

E.3.2 Given/When/Then

“Given/When/Then”是 BDD 测试的真正核心。这三部分与单元测试的“设置 - 使用 - 断言”模式匹配,分别表示设置 假设阶段、使用 行动阶段以及断言 / 观察阶段。详情参见 Cucumber 维基页面(https://github.com/cucumber/cucumber/wiki/Given-When-Then)。

E.3.3 并不始终完美契合

如你所见,用户故事不是总能完美分成这三步的。我们可以使用 And 子句扩充步骤,而且我还添加了多个 When 和随后的 Then 子句,进一步描绘“My Lists”页面。

E.4 编写步骤函数

下面编写 Gherkin 句法描述的特性对应的“步骤”函数,即真正使用代码实现。

生成占位步骤

运行 behave,它会告诉我们需要实现的每一步:

  1. $ python manage.py behave
  2. Feature: My Lists # features/my_lists.feature:1
  3. As a logged-in user
  4. I want to be able to see all my lists in one page
  5. So that I can find them all after I've written them
  6. Scenario: Create two lists and see them on the My Lists page #
  7. features/my_lists.feature:6
  8. Given I am a logged-in user # None
  9. Given I am a logged-in user # None
  10. When I create a list with first item "Reticulate Splines" # None
  11. And I add an item "Immanentize Eschaton" # None
  12. And I create a list with first item "Buy milk" # None
  13. Then I will see a link to "My lists" # None
  14. When I click the link to "My lists" # None
  15. Then I will see a link to "Reticulate Splines" # None
  16. And I will see a link to "Buy milk" # None
  17. When I click the link to "Reticulate Splines" # None
  18. Then I will be on the "Reticulate Splines" list page # None
  19.  
  20.  
  21. Failing scenarios:
  22. features/my_lists.feature:6 Create two lists and see them on the My Lists
  23. page
  24.  
  25.  
  26. 0 features passed, 1 failed, 0 skipped
  27. 0 scenarios passed, 1 failed, 0 skipped
  28. 0 steps passed, 0 failed, 0 skipped, 10 undefined
  29. Took 0m0.000s
  30.  
  31. You can implement step definitions for undefined steps with these snippets:
  32.  
  33. @given(u'I am a logged-in user')
  34. def step_impl(context):
  35. raise NotImplementedError(u'STEP: Given I am a logged-in user')
  36.  
  37. @when(u'I create a list with first item "Reticulate Splines"')
  38. def step_impl(context):
  39. [...]

而且你会发现,输出有不同的颜色,如图 E-1 所示。

07 - 图7

图 E-1:Behave 在控制台输出带有不同颜色的内容

Behave 建议我们复制粘贴这些片段,在此基础上构建步骤。

E.5 定义第一步

下面尝试实现“Given I am a logged-in user”这一步。我直接从 functional_tests/test_my_lists.py 中复制 self.create_pre_authenticated_session 的代码,然后稍做调整(例如删掉服务器端版本,不过后面再加上也容易)。

features/steps/my_lists.py

  1. from behave import given, when, then
  2. from functional_tests.management.commands.create_session import \
  3. create_pre_authenticated_session
  4. from django.conf import settings
  5. @given('I am a logged-in user')
  6. def given_i_amloggedin(context):
  7. session_key = create_pre_authenticated_session(email='edith@example.com')
  8. ## 为了设定cookie,我们要先访问网站
  9. ## 而404页面是加载最快的
  10. context.browser.get(context.get_url("404_no_such_url"))
  11. context.browser.add_cookie(dict(
  12. name=settings.SESSION_COOKIE_NAME,
  13. value=session_key,
  14. path='/',
  15. ))

context 变量需要稍做说明:它有点像是全局变量,因为我们将通过它存储在步骤之间共享的信息,把它传给要执行的每一步。这里,我们假定它存储了一个浏览器对象和 server_url。我们将多次使用这个变量,就像在使用 unittest 编写功能测试时经常使用 self 一样。

E.6 environment.py中与setUptearDown等价的函数

各步可以修改 context 中的状态,不过前期准备工作,即与 setUp 等价的操作,在 environment.py 文件中设置:

features/environment.py

  1. from selenium import webdriver
  2. def before_all(context):
  3. context.browser = webdriver.Firefox()
  4. def after_all(context):
  5. context.browser.quit()
  6. def before_feature(context, feature):
  7. pass

E.7 再次运行

可以再运行一次,做健全性检查,确认新编写的步骤是否可行,以及能否启动浏览器:

  1. $ python manage.py behave
  2. [...]
  3. 1 step passed, 0 failed, 0 skipped, 9 undefined

输出的内容很多,不过可以看到,第一步通过了。下面定义余下的步骤。

E.8 在步骤中捕获参数

我们将说明如何在步骤描述中捕获参数。接下来的一步是:

features/my_lists.feature

  1. When I create a list with first item "Reticulate Splines"

自动生成的步骤定义如下:

features/steps/my_lists.py

  1. @given('I create a list with first item "Reticulate Splines"')
  2. def step_impl(context):
  3. raise NotImplementedError(
  4. u'STEP: When I create a list with first item "Reticulate Splines"'
  5. )

我们希望以任意的第一个待办事项创建清单。所以,如果能通过某种方式捕获双引号中的内容就好了,这样就可以把它作为参数传给更为通用的函数。这是 BDD 的一个常见需求,Behave 为此提供了优雅的句法。还记得 Python 为字符串格式化提供的新句法吗?

features/steps/my_lists.py (ch35l006)

  1. [...]
  2. @when('I create a list with first item "{first_item_text}"')
  3. def create_a_list(context, first_item_text):
  4. context.browser.get(context.get_url('/'))
  5. context.browser.find_element_by_id('id_text').send_keys(first_item_text)
  6. context.browser.find_element_by_id('id_text').send_keys(Keys.ENTER)
  7. wait_for_list_item(context, first_item_text)

很棒吧?

07 - 图8 在步骤中捕获参数是 BDD 句法最为强大的功能之一。

与在 Selenium 测试中一样,我们要显式等待。依旧使用 base.py 中的 @wait 装饰器:

features/steps/my_lists.py (ch35l007)

  1. from functional_tests.base import wait
  2. [...]
  3. @wait
  4. def wait_for_list_item(context, item_text):
  5. context.test.assertIn(
  6. item_text,
  7. context.browser.find_element_by_css_selector('#id_list_table').text
  8. )

与之类似,我们也可以把待办事项添加到现有清单中,查看或点击链接:

features/steps/my_lists.py (ch35l008)

  1. from selenium.webdriver.common.keys import Keys
  2. [...]
  3. @when('I add an item "{item_text}"')
  4. def add_an_item(context, item_text):
  5. context.browser.find_element_by_id('id_text').send_keys(item_text)
  6. context.browser.find_element_by_id('id_text').send_keys(Keys.ENTER)
  7. wait_for_list_item(context, item_text)
  8. @then('I will see a link to "{link_text}"')
  9. @wait
  10. def see_a_link(context, link_text):
  11. context.browser.find_element_by_link_text(link_text)
  12. @when('I click the link to "{link_text}"')
  13. def click_link(context, link_text):
  14. context.browser.find_element_by_link_text(link_text).click()

注意,我们甚至可以在步骤上使用 @wait 装饰器。

最后是稍微复杂一些的步骤,描述自己在某个清单的页面上:

features/steps/my_lists.py (ch35l009)

  1. @then('I will be on the "{first_item_text}" list page')
  2. @wait
  3. def on_list_page(context, first_item_text):
  4. first_row = context.browser.find_element_by_css_selector(
  5. '#id_list_table tr:first-child'
  6. )
  7. expected_row_text = '1: ' + first_item_text
  8. context.test.assertEqual(first_row.text, expected_row_text)

现在运行,得到第一个预期失败:

  1. $ python manage.py behave
  2.  
  3. Feature: My Lists # features/my_lists.feature:1
  4. As a logged-in user
  5. I want to be able to see all my lists in one page
  6. So that I can find them all after I've written them
  7. Scenario: Create two lists and see them on the My Lists page #
  8. features/my_lists.feature:6
  9. Given I am a logged-in user #
  10. features/steps/my_lists.py:19
  11. When I create a list with first item "Reticulate Splines" #
  12. features/steps/my_lists.py:31
  13. And I add an item "Immanentize Eschaton" #
  14. features/steps/my_lists.py:39
  15. And I create a list with first item "Buy milk" #
  16. features/steps/my_lists.py:31
  17. Then I will see a link to "My lists" #
  18. functional_tests/base.py:12
  19. Traceback (most recent call last):
  20. [...]
  21. File "features/steps/my_lists.py", line 49, in see_a_link
  22. context.browser.find_element_by_link_text(link_text)
  23. [...]
  24. selenium.common.exceptions.NoSuchElementException: Message: Unable to
  25. locate element: My lists
  26.  
  27. [...]
  28.  
  29. Failing scenarios:
  30. features/my_lists.feature:6 Create two lists and see them on the My Lists
  31. page
  32.  
  33. 0 features passed, 1 failed, 0 skipped
  34. 0 scenarios passed, 1 failed, 0 skipped
  35. 4 steps passed, 1 failed, 5 skipped, 0 undefined

从输出可以看出,我们在“用户故事”上走了多远:我们成功创建了两个清单,但是“My Lists”链接未出现。

E.9 与行间式功能测试比较

我不会完整实现整个功能,不过你可以看出,这与行间式功能测试一样,能驱动我们向前开发。

下面回顾一下行间测试,比较一下:

functional_tests/test_my_lists.py

  1. def testloggedin_users_lists_are_saved_as_my_lists(self):
  2. # 伊迪丝是已登录用户
  3. self.create_pre_authenticated_session('edith@example.com')
  4. # 她访问首页,新建一个清单
  5. self.browser.get(self.live_server_url)
  6. self.add_list_item('Reticulate splines')
  7. self.add_list_item('Immanentize eschaton')
  8. first_list_url = self.browser.current_url
  9. # 她第一次看到“My Lists”链接
  10. self.browser.find_element_by_link_text('My lists').click()
  11. # 她看到这个页面中有她创建的清单
  12. # 而且清单根据第一个待办事项命名
  13. self.wait_for(
  14. lambda: self.browser.find_element_by_link_text('Reticulate splines')
  15. )
  16. self.browser.find_element_by_link_text('Reticulate splines').click()
  17. self.wait_for(
  18. lambda: self.assertEqual(self.browser.current_url, first_list_url)
  19. )
  20. # 她决定再建一个清单试试
  21. self.browser.get(self.live_server_url)
  22. self.add_list_item('Click cows')
  23. second_list_url = self.browser.current_url
  24. # 这个新建的清单也在“My Lists”页面显示出来了
  25. self.browser.find_element_by_link_text('My lists').click()
  26. self.wait_for(
  27. lambda: self.browser.find_element_by_link_text('Click cows')
  28. )
  29. self.browser.find_element_by_link_text('Click cows').click()
  30. self.wait_for(
  31. lambda: self.assertEqual(self.browser.current_url, second_list_url)
  32. )
  33. # 她退出后,“My Lists”链接不见了
  34. self.browser.find_element_by_link_text('Log out').click()
  35. self.wait_for(lambda: self.assertEqual(
  36. self.browser.find_elements_by_link_text('My lists'),
  37. []
  38. ))

虽然不能一一对应比较,但是可以看看代码行数,见表 E-1。

表E-1:比较代码行数

BDD 标准的功能测试
特性描述文件:20(3 个可选) 测试函数的主体:45
步骤文件:56 行 辅助函数:23

这样比较并不严谨,但是可以认为特性描述文件和“标准的功能测试”的测试函数主体是等价的,都表示测试的主体“故事”,而步骤定义和辅助函数表示“隐藏的”实现细节。如果把行数加在一起,总行数相差不多,但是二者的结果不一样:BDD 测试写出的故事更简洁,而且更多内容隐藏到实现细节中了。

E.10 BDD得到的测试代码结构更好

对我而言,真正吸引人的是,BDD 工具迫使我们思考测试代码的结构。在行间式功能测试中,实现需要多少行代码就可以写多少行,用户故事是通过注释描述的。我们很难控制自己不从别处或同一个测试的前面复制粘贴代码。到目前为止,你可以看到,我只定义了几个辅助函数(例如 get_item_input_box)。

与之相比,BDD 句法则强制我们为每一步编写单独的函数,因此很多代码都是可以重用的。

  • 新建清单。
  • 把待办事项添加到现有清单中。
  • 点击特定文本的链接。
  • 断言我在查看某个清单的页面。

通过 BDD 写出的代码与业务逻辑匹配得更好,而且能分层抽象,把功能测试的故事与实现的代码分开。

这样做最终的好处是,如果想更换编程语言,理论上可以原样保留 Gherkin 句法编写的特性描述,丢掉 Python 代码实现的步骤,再用新语言重新编写。

E.11 与页面模式比较

第 25 章举了一个使用“页面模式”的示例。页面模式是组织 Selenium 测试的面向对象方式。下面回顾一下它的用法:

functional_tests/test_sharing.py

  1. from .lists_page import ListsPage
  2. [...]
  3. class SharingTest(FunctionalTest):
  4. def test_can_share_a_list_with_another_user(self):
  5. # [...]
  6. self.browser.get(self.live_server_url)
  7. list_page = ListPage(self).add_list_item('Get help')
  8. # 她看到“分享这个清单”选项
  9. share_box = list_page.get_share_box()
  10. self.assertEqual(
  11. share_box.get_attribute('placeholder'),
  12. 'your-friend@example.com'
  13. )
  14. # 她分享自己的清单之后,页面更新了
  15. # 提示已经分享给Oniciferous
  16. list_page.share_list_with('oniciferous@example.com')

Page 类的定义如下:

functional_tests/lists_pages.py

  1. class ListPage(object):
  2. def __init__(self, test):
  3. self.test = test
  4. def get_table_rows(self):
  5. return self.test.browser.find_elements_by_css_selector('#id_list_table tr')
  6. @wait
  7. def wait_for_row_in_list_table(self, item_text, item_number):
  8. row_text = '{}: {}'.format(item_number, item_text)
  9. rows = self.get_table_rows()
  10. self.test.assertIn(row_text, [row.text for row in rows])
  11. def get_item_input_box(self):
  12. return self.test.browser.find_element_by_id('id_text')

可以看出,不管是使用页面模式还是其他结构,完全可以在行间式功能测试中做同样的抽象,实现某种 DSL。但是,我们应该自律,而不能靠框架去约束。

07 - 图9 其实在 BDD 中也可以使用页面模式,在实现步骤时通过它浏览网站中的页面。

E.12 BDD可能没有行间注释的表达力强

另一方面,我觉得 Gherkin 句法有点不够灵活。行间式注释表达力强,而且可读性高,但 BDD 的特性描述有点拗口:

functional_tests/test_my_lists.py

  1. # 伊迪丝是已登录用户
  2. # 她访问首页,新建一个清单
  3. # 她第一次看到“My Lists”链接
  4. # 她看到这个页面中有她创建的清单
  5. # 而且清单根据第一个待办事项命名
  6. # 她决定再建一个清单试试
  7. # 这个新建的清单在“My Lists”页面也显示出来了
  8. # 她退出后,“My Lists”链接不见了
  9. [...]

与呆板的“Given/Then/When”结构相比,功能测试中的行间注释可读性更高,也显得更自然,而且在一定程度上,更能从用户的角度思考问题。(Gherkin 也支持在特性描述文件中编写“注释”,这能在一定程度上缓解上述问题,但是我想用的人并不多。)

E.13 非程序员会编写测试吗

有一点我还没有提到:BDD 最初的动机之一是让非程序员(可能是业务代表或客户代表)能够编写 Gherkin 句法。我十分怀疑现实中有没有人这么做;即便有,与 BDD 的其他优势相比,我想这也不算什么。

E.14 目前的结论

我才刚接触 BDD,还不能得出什么强有力的结论。我觉得“强制”把功能测试分成不同的步骤十分吸引人,因为这样便于在功能测试中大量重用代码,而且能把关注点明确分开,一边是对故事的描述,另一边是具体实现。此外,BDD 还能让我们站在业务逻辑的角度思考问题,而不是想着“应该如何使用 Selenium 去做”。

但是,世界上没有免费的午餐。与功能测试中行间注释的无拘无束相比,Gherkin 句法太死板、不够灵活。

我还想知道,当功能描述由一两个变成十几个、步骤定义由四五个变成几百行代码之后,BDD 能否适应。

总之,我觉得 BDD 绝对值得深入研究,我的下一个个人项目可能会使用它。

感谢 Daniel Pope、Rachel Willmer 和 Jared Contrascere 对本附录的反馈。

BDD 总结

  • 有助于编写结构良好、可重用的测试代码

    BDD 能分离关注点,把功能测试拆分成人类可读的“特性描述”文件(使用 Gherkin 句法)和步骤函数的实现,这样有助于编写可重用、易于管理的测试代码。

  • 可能有失可读性

    Gherkin 句法虽然追求的是人类可读性,但是并没有充分发挥人类语言的灵活性,因此可能无法像行间注释那样注重细节、明确表明意图。

  • 多尝试总是好的

    我不断强调,我还未在真实的项目中用过BDD,因此你要对我讲的内容持怀疑态度。但是我强烈推荐你使用BDD。我将试着在我的下一个项目中使用它,同时也建议你这么做。

附录 F 构建一个REST API:JSON、Ajax和 JavaScript模拟技术

表现层状态转化(REpresentational State Transfer,REST)是一种设计 Web 服务的方案,读取和更新的是“资源”(resource)。设计通过 Web 使用的 API 时,这是首选方案。

我们设计的 Web 应用还用不到 API,那为什么现在就要设计一个呢?其中一个动机是想让网站更加动态,从而提升用户体验。在清单中添加待办事项后,我们不想等待页面刷新,而是想使用 JavaScript 向 API 发送异步请求,让用户感受网站的交互性。

但更重要的一点或许是,有了 API,我们就可以通过浏览器之外的机制与后端应用交互。例如,客户端可以是移动应用,也可以是命令行应用,而且其他开发者还可以围绕后端构建库和工具。

本附录将说明如何自己动手构建一个 API。附录 G 再介绍 Django 生态系统中的一个流行工具——DjangoRestFramework。

F.1 本附录采用的方案

我们构建的 API 不涵盖应用的全部功能,而是假设已经有清单了。根据 REST 架构,URL 和 HTTP 方法(常用的有 GET 和 POST,不过也有比较少用的,例如 PUT 和 DELETE)之间有一定的对应关系,我们可以据此设计方案。

维基百科中的 REST 词条对此有很好的概述,简单来说:

  • 新 URL 结构为 /apilists{id}/;
  • 通过 GET 请求获取清单详情(包括清单中的全部待办事项)的 JSON 格式;
  • 通过 POST 请求添加待办事项。我们将使用第 25 章结束时的代码。

F.2 选择测试方案

如果构建的 API 对客户端一无所知,或许应该好好想想测试应该下行到哪一层。对功能测试来说,我们仍然要启动一个真实的服务器(可以使用 LiveServerTestCase),然后使用 requests 库与之交互。我们应该仔细考虑如何设置固件(如果使用 API 自身,测试之间牵涉的依赖太多),以及哪一层的单元测试对我们最有用。或者,干脆只使用 Django 测试客户端做一层测试。

现在的情况是,我们要为基于浏览器的客户端构建 API。我们想在线上网站使用这个 API,而且对应用之前的功能没有任何影响。因此,我们依然要让功能测试做最高层的测试,通过功能测试检查 JavaScript 和 API 之间的集成情况。

这样一来,低层测试就要使用 Django 测试客户端了。下面开始构建。

F.3 基本结构

首先,编写一个功能测试,检查新的 URL 结构能正常响应 GET 请求(状态码为 200),而且响应是 JSON 格式(而不是 HTML 格式):

lists/tests/test_api.py

  1. import json
  2. from django.test import TestCase
  3. from lists.models import List, Item
  4. class ListAPITest(TestCase):
  5. base_url = '/apilists{}/'
  6. def testgetreturns_json_200(self):
  7. list_ = List.objects.create()
  8. response = self.client.get(self.base_url.format(list_.id))
  9. self.assertEqual(response.status_code, 200)
  10. self.assertEqual(response['contenttype'], 'application/json')

➊ 使用类级常量指定 URL 是本附录采用的新方式,这样做便无须重复硬编码 URL。此外,还可以调用 reverse,进一步减少重复。

然后,引入 urls 文件:

superlists/urls.py

  1. from django.conf.urls import include, url
  2. from accounts import urls as accounts_urls
  3. from lists import views as list_views
  4. from lists import api_urls
  5. from lists import urls as list_urls
  6. urlpatterns = [
  7. url(r'^$', list_views.home_page, name='home'),
  8. url(r'^lists/', include(list_urls)),
  9. url(r'^accounts/', include(accounts_urls)),
  10. url(r'^api/', include(api_urls)),
  11. ]

和:

lists/api_urls.py

  1. from django.conf.urls import url
  2. from lists import api
  3. urlpatterns = [
  4. url(r'^lists/(\d+)/$', api.list, name='api_list'),
  5. ]

API 的核心代码可以放在 api.py 文件中,只需三行代码就行了:

lists/api.py

  1. from django.http import HttpResponse
  2. def list(request, list_id):
  3. return HttpResponse(content_type='application/json')

测试应该能通过。现在就有了基础结构。

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

F.4 返回实质内容

接下来,我们要让 API 返回一些实质内容,即清单中各待办事项的 JSON 表示形式:

lists/tests/test_api.py (ch36l002)

  1. def testgetreturns_items_for_correct_list(self):
  2. other_list = List.objects.create()
  3. Item.objects.create(list=other_list, text='item 1')
  4. our_list = List.objects.create()
  5. item1 = Item.objects.create(list=our_list, text='item 1')
  6. item2 = Item.objects.create(list=our_list, text='item 2')
  7. response = self.client.get(self.base_url.format(our_list.id))
  8. self.assertEqual(
  9. json.loads(response.content.decode('utf8')),
  10. [
  11. 'id': item1.id, 'text': item1.text},
  12. 'id': item2.id, 'text': item2.text},
  13. ]
  14. )

➊ 这个测试主要要注意这一点。我们期望响应是 JSON 格式,使用 json.loads() 是因为测试 Python 对象比直接处理原始的 JSON 字符串容易。

实现时则要反过来,使用 json.dumps()

lists/api.py

  1. import json
  2. from django.http import HttpResponse
  3. from lists.models import List, Item
  4. def list(request, list_id):
  5. list_ = List.objects.get(id=list_id)
  6. item_dicts = [
  7. {'id': item.id, 'text': item.text}
  8. for item in list_.item_set.all()
  9. ]
  10. return HttpResponse(
  11. json.dumps(item_dicts),
  12. content_type='application/json'
  13. )

这是使用列表推导的好机会!

F.5 添加对POST请求的支持

这个 API 还要允许通过 POST 请求向清单中添加新待办事项。先采用常规方式:

lists/tests/test_api.py (ch36l004)

  1. def test_POSTing_a_new_item(self):
  2. list_ = List.objects.create()
  3. response = self.client.post(
  4. self.base_url.format(list_.id),
  5. {'text': 'new item'},
  6. )
  7. self.assertEqual(response.status_code, 201)
  8. new_item = list_.item_set.get()
  9. self.assertEqual(new_item.text, 'new item')

实现同样简单,基本上与常规的视图所做的一样,不过这里要返回 201 响应,而不能重定向:

lists/api.py (ch36l005)

  1. def list(request, list_id):
  2. list_ = List.objects.get(id=list_id)
  3. if request.method == 'POST':
  4. Item.objects.create(list=list_, text=request.POST['text'])
  5. return HttpResponse(status=201)
  6. item_dicts = [
  7. [...]

这样便可以了:

  1. $ python manage.py test lists
  2. [...]
  3.  
  4. Ran 52 tests in 0.177s
  5.  
  6. OK

07 - 图10 构建 REST API 的要点之一是,要充分利用 HTTP 状态码。

F.6 使用Sinon.js测试客户端Ajax

没有模拟库是无法测试 Ajax 的。不同的测试框架和工具采用不同的模拟库,而 Sinon 是通用的。稍后你将看到,Sinon 还提供了 JavaScript 驭件。

下载 Sinon,将其放到 listsstatictests/ 文件夹中。

下面编写第一个 Ajax 测试:

listsstatictests/tests.html (ch36l007)

  1. <div id="qunit-fixture">
  2. <form>
  3. <input name="text" >
  4. <div class="has-error">Error text<div>
  5. </form>
  6. <table id="id_list_table">
  7. </table>
  8. </div>
  9. <script src="../jquery-3.1.1.min.js"></script>
  10. <script src="../list.js"></script>
  11. <script src="qunit-2.0.1.js"></script>
  12. <script src="sinon-1.17.6.js"></script>
  13. <script>
  14. /* global sinon */
  15. var server;
  16. QUnit.testStart(function () {
  17. server = sinon.fakeServer.create();
  18. });
  19. QUnit.testDone(function () {
  20. server.restore();
  21. });
  22. QUnit.test("errors should be hidden on keypress", function (assert) {
  23. [...]
  24. QUnit.test("should get items by ajax on initialize", function (assert) {
  25. var url = 'getitems';
  26. window.Superlists.initialize(url);
  27. assert.equal(server.requests.length, 1);
  28. var request = server.requests[0];
  29. assert.equal(request.url, url);
  30. assert.equal(request.method, 'GET');
  31. });
  32. </script>

❶ 在固件 div 元素中添加一个元素,表示清单表格。

❷ 导入 sinon.js(要先下载,并放到正确的文件夹中)。

❸ QUnit 中的 testStarttestDone 对应于 Python 测试中的 setUptearDown。这里,我们让 Sinon 启动 Ajax 测试工具(fakeServer),并将它赋值给全局作用域中的 server 变量。

❹ 然后通过 server 对代码发送的 Ajax 请求下断言。这里,我们测试请求的目标 URL 和所用的 HTTP 方法。

为了发送 Ajax 请求,我们将使用 jQuery 提供的 Ajax 辅助方法,这比使用浏览器底层的标准 XMLHttpRequest 对象要简单得多

listsstaticlist.js

  1. @@ -1,6 +1,10 @@
  2. window.Superlists = {};
  3. -window.Superlists.initialize = function () {
  4. +window.Superlists.initialize = function (url) {
  5. $('input[name="text"]').on('keypress', function () {
  6. $('.has-error').hide();
  7. });
  8. +
  9. + $.get(url);
  10. +
  11. };
  12. +

现在测试应该能通过:

  1. 5 assertions of 5 passed, 0 failed.
  2. 1. errors should be hidden on keypress (1)
  3. 2. errors aren't hidden if there is no keypress (1)
  4. 3. should get items by ajax on initialize (3)

好吧,我们能向服务器发送 GET 请求了,但是具体的操作呢?我们应该如何测试“异步”请求,应该如何处理(最终得到的)响应呢?

使用Sinon测试Ajax请求的异步行为

这是人们喜欢 Sinon 的主要原因。我们可以通过 server.respond() 准确控制异步代码的流程:

listsstatictests/tests.html (ch36l009)

  1. QUnit.test("should fill in lists table from ajax response", function (assert) {
  2. var url = 'getitems';
  3. var responseData = [
  4. {'id': 101, 'text': 'item 1 text'},
  5. {'id': 102, 'text': 'item 2 text'},
  6. ];
  7. server.respondWith('GET', url, [
  8. 200, {"ContentType": "application/json"}, JSON.stringify(responseData)
  9. ]);
  10. window.Superlists.initialize(url);
  11. server.respond();
  12. var rows = $('#id_list_table tr');
  13. assert.equal(rows.length, 2);
  14. var row1 = $('#id_list_table tr:first-child td');
  15. assert.equal(row1.text(), '1: item 1 text');
  16. var row2 = $('#id_list_table tr:last-child td');
  17. assert.equal(row2.text(), '2: item 2 text');
  18. });

❶ 设定一些响应数据供 Sinon 使用。我们设定了状态码、首部以及希望服务器返回的 JSON 响应——这是最重要的。

❷ 然后调用要测试的函数。

❸ 关键时刻到了。随后,我们可以在任何需要的地方调用 server.respond(),触发 Ajax 循环中所有的异步代码,即用于处理响应的那些回调。

❹ 然后检查 Ajax 回调有没有在表格的行中填充新的待办事项。实现如下所示:

listsstaticlist.js (ch36l010)

  1. if (url) {
  2. $.get(url).done(function (response) {
  3. var rows = '';
  4. for (var i=0; i<response.length; i++) {
  5. var item = response[i];
  6. rows += '\n<tr><td>' + (i+1) + ': ' + item.text + '</td></tr>';
  7. }
  8. $('#id_list_table').html(rows);
  9. });
  10. }

07 - 图11 我们很幸运,因为 jQuery 是使用 .done() 函数为 Ajax 注册回调的。如果使用标准的 JavaScript Promise.then() 回调,异步操作就要多一层。不过,QUnit 也能处理,详情参见 async 函数的文档。其他测试框架为此也提供了类似的函数。

F.7 在模板中连接各部分,确认这样是否真的可行

我们先做个破坏,把 lists.html 模板中用于显示清单表格的 {% for %} 循环删掉:

lists/templates/list.html

  1. @@ -6,9 +6,6 @@
  2. {% block table %}
  3. <table id="id_list_table" class="table">
  4. - {% for item in list.item_set.all %}
  5. - <tr><td>{{ forloop.counter }}: {{ item.text }}</td></tr>
  6. - {% endfor %}
  7. </table>
  8. {% if list.owner %}

07 - 图12 这会导致其中一个单元测试失败,可以先暂时删除那个测试。

优雅降级和渐进增强

删除非 Ajax 版本的清单页面之后,就无法优雅降级了,即没有在禁用 JavaScript 的情况下依然能正常使用的版本。

以前,优雅降级通常是为了提供辅助功能,因为供视觉障碍人士使用的“屏幕阅读器”通常不支持 JavaScript,所以完全依赖 JavaScript 就把这部分用户排除在外了。但据我了解,现在这已经不是什么大问题了。但有些用户甚至会基于安全方面的原因而禁用 JavaScript。

另一个常见的问题是,为不同的浏览器提供不同程度的 JavaScript 支持。当你开始迈向“现代的”前端开发和 ES2015 时,尤其要注意这个问题。

简单来说,最好始终提供非 JavaScript 版本的“后援”。如果已经先构建好了无须 JavaScript 就能正常使用的网站,就千万不要轻易把“陈旧的”HTML 版本删除。我这么做只是因为删除后更便于说明我想讲的内容。

这样做会导致功能测试失败:

  1. $ python manage.py test functional_tests.test_simple_list_creation
  2. [...]
  3. FAIL: test_can_start_a_list_for_one_user
  4. [...]
  5. File "...superlists/functional_tests/test_simple_list_creation.py", line
  6. 32, in test_can_start_a_list_for_one_user
  7. self.wait_for_row_in_list_table('1: Buy peacock feathers')
  8. [...]
  9. AssertionError: '1: Buy peacock feathers' not found in []
  10. [...]
  11. FAIL: test_multiple_users_can_start_lists_at_different_urls
  12.  
  13. FAILED (failures=2)

下面在基模板中添加一个 {% scripts %} 块,这样便可以在清单页面有选择地覆盖:

lists/templates/base.html

  1. <script src="staticlist.js"></script>
  2. {% block scripts %}
  3. <script>
  4. $(document).ready(function () {
  5. window.Superlists.initialize();
  6. });
  7. </script>
  8. {% endblock scripts %}
  9. </body>

然后,在 list.html 中稍微修改一下调用 initialize 的方式,传入正确的 URL:

lists/templates/list.html (ch36l016)

  1. {% block scripts %}
  2. <script>
  3. $(document).ready(function () {
  4. var url = "{% url 'api_list' list.id %}";
  5. window.Superlists.initialize(url);
  6. });
  7. </script>
  8. {% endblock scripts %}

你猜怎么着?测试通过了!

  1. $ python manage.py test functional_tests.test_simple_list_creation
  2. [...]
  3. Ran 2 test in 11.730s
  4.  
  5. OK

这是个不错的开始!

如果这时运行功能测试,你会发现其他功能测试中有几个失败。接下来我们就要处理这些失败。此外,这里还在使用通过表单处理 POST 请求的过时方法,页面需要刷新,离时下流行的单页应用还有段距离。但我们正向着目标前进!

F.8 实现Ajax POST,包括CSRF令牌

首先为清单表格设定一个 id,以便在 JavaScript 代码中引用它:

lists/templates/base.html

  1. <h1>{% block header_text %}{% endblock %}</h1>
  2. {% block list_form %}
  3. <form id="id_item_form" method="POST" action="{% block form_action %}{% endblock %}">
  4. {{ form.text }}
  5. [...]

然后使用 ID 调整 JavaScript 测试中的固件,并加上页面当前的 CSRF 令牌:

listsstatictests/tests.html

  1. @@ -9,9 +9,14 @@
  2. <body>
  3. <div id="qunit"></div>
  4. <div id="qunit-fixture">
  5. - <form>
  6. + <form id="id_item_form">
  7. <input name="text" >
  8. - <div class="has-error">Error text<div>
  9. + <input type="hidden" name="csrfmiddlewaretoken" value="tokey" >
  10. + <div class="has-error">
  11. + <div class="help-block">
  12. + Error text
  13. + <div>
  14. + </div>
  15. </form>

测试如下:

listsstatictests/tests.html (ch36l019)

  1. QUnit.test("should intercept form submit and do ajax post", function (assert) {
  2. var url = 'listitemsapi';
  3. window.Superlists.initialize(url);
  4. $('#id_item_form input[name="text"]').val('user input');
  5. $('#id_item_form input[name="csrfmiddlewaretoken"]').val('tokeney');
  6. $('#id_item_form').submit();
  7. assert.equal(server.requests.length, 2);
  8. var request = server.requests[1];
  9. assert.equal(request.url, url);
  10. assert.equal(request.method, "POST");
  11. assert.equal(
  12. request.requestBody,
  13. 'text=user+input&csrfmiddlewaretoken=tokeney'
  14. );
  15. });

❶ 模拟用户操作,填表后点击提交按钮。

❷ 预期会有第二个 Ajax 请求(第一个是针对清单表格的 GET 请求)。

❸ 检查 POST 请求的 requestBody。可以看出,它的值经过 URL 编码了,这虽然不是最易于测试的,但是可读性尚可。

实现方式如下:

listsstaticlist.js

  1. [...]
  2. $('#id_list_table').html(rows);
  3. });
  4. var form = $('#id_item_form');
  5. form.on('submit', function(event) {
  6. event.preventDefault();
  7. $.post(url, {
  8. 'text': form.find('input[name="text"]').val(),
  9. 'csrfmiddlewaretoken': form.find('input[name="csrfmiddlewaretoken"]').val(),
  10. });
  11. });

现在 JavaScript 测试能通过了,但是功能测试却失败了,因为虽然 POST 请求成功了,但是却没有更新页面,显示新添加的待办事项:

  1. $ python manage.py test functional_tests.test_simple_list_creation
  2. [...]
  3. AssertionError: '2: Use peacock feathers to make a fly' not found in ['1: Buy
  4. peacock feathers']

F.9 JavaScript中的模拟技术

我们希望处理完 Ajax POST 请求后,客户端能更新表格中的待办事项。其实这与重新加载页面所做的事情是一样的,我们要从服务器中获取清单中当前的待办事项,然后在表格中显示出来。

看来,我们需要一个辅助函数。

listsstaticlist.js

  1. window.Superlists = {};
  2. window.Superlists.updateItems = function (url) {
  3. $.get(url).done(function (response) {
  4. var rows = '';
  5. for (var i=0; i<response.length; i++) {
  6. var item = response[i];
  7. rows += '\n<tr><td>' + (i+1) + ': ' + item.text + '</td></tr>';
  8. }
  9. $('#id_list_table').html(rows);
  10. });
  11. };
  12. window.Superlists.initialize = function (url) {
  13. $('input[name="text"]').on('keypress', function () {
  14. $('.has-error').hide();
  15. });
  16. if (url) {
  17. window.Superlists.updateItems(url);
  18. var form = $('#id_item_form');
  19. [...]

这只算是一次重构。经确认,现在 JavaScript 测试依然能通过:

  1. 12 assertions of 12 passed, 0 failed.
  2. 1. errors should be hidden on keypress (1)
  3. 2. errors aren't hidden if there is no keypress (1)
  4. 3. should get items by ajax on initialize (3)
  5. 4. should fill in lists table from ajax response (3)
  6. 5. should intercept form submit and do ajax post (4)

那么,我们应该如何测试 Ajax POST 请求成功后调用了 updateItems 呢?我们可不想傻傻地重复编写模拟服务器响应的代码,然后自己动手检查待办事项表格……要不使用驭件试试?

首先,设置一个“沙盒”,让它跟踪我们创建的所有驭件,并在每次测试之后把被模拟的东西还原。

listsstatictests/tests.html (ch36l023)

  1. var server, sandbox;
  2. QUnit.testStart(function () {
  3. server = sinon.fakeServer.create();
  4. sandbox = sinon.sandbox.create();
  5. });
  6. QUnit.testDone(function () {
  7. server.restore();
  8. sandbox.restore();
  9. });

.restore() 是重点,它的作用是在每次测试之后还原被模拟的东西。

listsstatictests/tests.html (ch36l024)

  1. QUnit.test("should call updateItems after successful post", function (assert) {
  2. var url = 'listitemsapi';
  3. window.Superlists.initialize(url);
  4. var response = [
  5. 201,
  6. {"ContentType": "application/json"},
  7. JSON.stringify({}),
  8. ];
  9. server.respondWith('POST', url, response);
  10. $('#id_item_form input[name="text"]').val('user input');
  11. $('#id_item_form input[name="csrfmiddlewaretoken"]').val('tokeney');
  12. $('#id_item_form').submit();
  13. sandbox.spy(window.Superlists, 'updateItems');
  14. server.respond();
  15. assert.equal(
  16. window.Superlists.updateItems.lastCall.args,
  17. url
  18. );
  19. });

❶ 首先要注意,初始化之后才能设置服务器响应。这是因为我们想设置的是提交表单时发送的 POST 请求的响应,而不是一开始那个 GET 请求的响应。(还记得第 16 章所学的知识吗? JavaScript 测试最难掌握的技术之一便是控制执行顺序。)

❷ 同样,仅当开始那个 GET 请求处理完毕之后,我们才开始模拟辅助函数。sandbox.spy 调用的作用与 Python 测试中的 patch 一样,把指定对象替换为驭件。

❸ 模拟的 updateItems 函数现在多了一些属性,例如 lastCalllastCall.args(类似于 Python 驭件的 call_args)。

让测试通过之前,我们想故意犯个错,确认它的确能测试我们想测试的行为:

listsstaticlist.js

  1. $.post(url, {
  2. 'text': form.find('input[name="text"]').val(),
  3. 'csrfmiddlewaretoken': form.find('input[name="csrfmiddlewaretoken"]').val(),
  4. }).done(function () {
  5. window.Superlists.updateItems();
  6. });

收效不错,但功能测试还未全部通过:

  1. 12 assertions of 13 passed, 1 failed.
  2. [...]
  3. 6. should call updateItems after successful post (1, 0, 1)
  4. 1. failed
  5. Expected: "listitemsapi"
  6. Result: []
  7. Diff: "listitemsapi"[]
  8. Source: file://...superlistslistsstatic/tests/tests.html:124:15

据此修正:

listsstaticlist.js

  1. }).done(function () {
  2. window.Superlists.updateItems(url);
  3. });

现在功能测试通过了!或者,至少部分通过了。其他测试还有问题,稍后再回来解决。

结束重构:让测试与代码匹配

现在,我有点不舒心,因为重构还没结束。下面稍微让单元测试与代码匹配一些:

listsstatictests/tests.html

  1. @@ -50,9 +50,19 @@ QUnit.testDone(function () {
  2. });
  3. -QUnit.test("should get items by ajax on initialize", function (assert) {
  4. +QUnit.test("should call updateItems on initialize", function (assert) {
  5. var url = 'getitems';
  6. + sandbox.spy(window.Superlists, 'updateItems');
  7. window.Superlists.initialize(url);
  8. + assert.equal(
  9. + window.Superlists.updateItems.lastCall.args,
  10. + url
  11. + );
  12. +});
  13. +
  14. +QUnit.test("updateItems should get correct url by ajax", function (assert) {
  15. + var url = 'getitems';
  16. + window.Superlists.updateItems(url);
  17. assert.equal(server.requests.length, 1);
  18. var request = server.requests[0];
  19. @@ -60,7 +70,7 @@ QUnit.test("should get items by ajax on initialize", function (assert) {
  20. assert.equal(request.method, 'GET');
  21. });
  22. -QUnit.test("should fill in lists table from ajax response", function (assert) {
  23. +QUnit.test("updateItems should fill in lists table from ajax response", function (assert) {
  24. var url = 'getitems';
  25. var responseData = [
  26. {'id': 101, 'text': 'item 1 text'},
  27. @@ -69,7 +79,7 @@ QUnit.test("should fill in lists table from ajax response", function [...]
  28. server.respondWith('GET', url, [
  29. 200, {"ContentType": "application/json"}, JSON.stringify(responseData)
  30. ]);
  31. - window.Superlists.initialize(url);
  32. + window.Superlists.updateItems(url);
  33. server.respond();

现在测试的结果如下:

  1. 14 assertions of 14 passed, 0 failed.
  2. 1. errors should be hidden on keypress (1)
  3. 2. errors aren't hidden if there is no keypress (1)
  4. 3. should call updateItems on initialize (1)
  5. 4. updateItems should get correct url by ajax (3)
  6. 5. updateItems should fill in lists table from ajax response (3)
  7. 6. should intercept form submit and do ajax post (4)
  8. 7. should call updateItems after successful post (1)

F.10 数据验证:留给读者的练习

如果运行全部测试,你会发现有两个针对验证的功能测试失败了:

  1. $ python manage.py test
  2. [...]
  3. ERROR: test_cannot_add_duplicate_items
  4. (functional_tests.test_list_item_validation.ItemValidationTest)
  5. [...]
  6. ERROR: test_error_messages_are_cleared_on_input
  7. (functional_tests.test_list_item_validation.ItemValidationTest)
  8. [...]
  9. selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
  10. element: .has-error

我不会告诉你具体应该怎么解决,下面仅给出所需的单元测试:

lists/tests/test_api.py (ch36l027)

  1. from lists.forms import DUPLICATE_ITEM_ERROR, EMPTY_ITEM_ERROR
  2. [...]
  3. def post_empty_input(self):
  4. list_ = List.objects.create()
  5. return self.client.post(
  6. self.base_url.format(list_.id),
  7. data={'text': ''}
  8. )
  9. def test_for_invalid_input_nothing_saved_to_db(self):
  10. self.post_empty_input()
  11. self.assertEqual(Item.objects.count(), 0)
  12. def test_for_invalid_input_returns_error_code(self):
  13. response = self.post_empty_input()
  14. self.assertEqual(response.status_code, 400)
  15. self.assertEqual(
  16. json.loads(response.content.decode('utf8')),
  17. {'error': EMPTY_ITEM_ERROR}
  18. )
  19. def test_duplicate_items_error(self):
  20. list_ = List.objects.create()
  21. self.client.post(
  22. self.base_url.format(list_.id), data={'text': 'thing'}
  23. )
  24. response = self.client.post(
  25. self.base_url.format(list_.id), data={'text': 'thing'}
  26. )
  27. self.assertEqual(response.status_code, 400)
  28. self.assertEqual(
  29. json.loads(response.content.decode('utf8')),
  30. {'error': DUPLICATE_ITEM_ERROR}
  31. )

以及 JavaScript 测试:

listsstatictests/tests.html (ch36l029-2)

  1. QUnit.test("should display errors on post failure", function (assert) {
  2. var url = 'listitemsapi';
  3. window.Superlists.initialize(url);
  4. server.respondWith('POST', url, [
  5. 400,
  6. {"ContentType": "application/json"},
  7. JSON.stringify({'error': 'something is amiss'})
  8. ]);
  9. $('.has-error').hide();
  10. $('#id_item_form').submit();
  11. server.respond(); // post
  12. assert.equal($('.has-error').is(':visible'), true);
  13. assert.equal($('.has-error .help-block').text(), 'something is amiss');
  14. });
  15. QUnit.test("should hide errors on post success", function (assert) {
  16. [...]

此外,你还要修改 base.html 模板,让它既能显示 Django 错误(主页现在就能显示),也能显示 JavaScript 错误:

lists/templates/base.html (ch36l031)

  1. @@ -51,17 +51,21 @@
  2. <div class="col-md-6 col-md-offset-3 jumbotron">
  3. <div class="text-center">
  4. <h1>{% block header_text %}{% endblock %}</h1>
  5. +
  6. {% block list_form %}
  7. <form id="id_item_form" method="POST" action="{% block [...]
  8. {{ form.text }}
  9. {% csrf_token %}
  10. - {% if form.errors %}
  11. - <div class="form-group has-error">
  12. - <div class="help-block">{{ form.text.errors }}</div>
  13. + <div class="form-group has-error">
  14. + <div class="help-block">
  15. + {% if form.errors %}
  16. + {{ form.text.errors }}
  17. + {% endif %}
  18. </div>
  19. - {% endif %}
  20. + </div>
  21. </form>
  22. {% endblock %}
  23. +
  24. </div>
  25. </div>
  26. </div>

最终,运行 JavaScript 测试应该得到类似下面的结果:

  1. 20 assertions of 20 passed, 0 failed.
  2. 1. errors should be hidden on keypress (1)
  3. 2. errors aren't hidden if there is no keypress (1)
  4. 3. should call updateItems on initialize (1)
  5. 4. updateItems should get correct url by ajax (3)
  6. 5. updateItems should fill in lists table from ajax response (3)
  7. 6. should intercept form submit and do ajax post (4)
  8. 7. should call updateItems after successful post (1)
  9. 8. should not intercept form submit if no api url passed in (1)
  10. 9. should display errors on post failure (2)
  11. 10. should hide errors on post success (1)
  12. 11. should display generic error if no error json (2)

全部测试应该都能通过,包括所有功能测试:

  1. $ python manage.py test
  2. [...]
  3. Ran 81 tests in 62.029s
  4. OK

太棒了!!!

这就是我们自己动手使用 Django 构建的 REST API。如果需要提示,可以查看代码示例仓库中的代码(https://github.com/hjwp/bookexample/tree/appendix_rest_api)。

不过,我不建议使用 Django 自己动手构建 REST API,在此之前,你至少应该考察一下 DjangoRestFramework,详情参见附录 G。继续前行吧!

REST API 小贴士

  • 不要重复编写URL

    与面向浏览器的应用相比,API 的 URL 更为重要。尽量减少在测试中硬编码 URL 的次数。

  • 不要直接处理原始JSON 字符串

    json.loadsjson.dumps 常伴你左右。

  • JavaScript 测试应该使用Ajax 模拟库

    Sinon 不错。Jasmine 自带了,Angular 也是。

  • 牢记优雅降级和渐进增强

    尤其是把静态网站变成由 JavaScript 驱动的网站时,至少要让网站的核心功能在没有 JavaScript 时依然能使用。

附录 G DjangoRestFramework

在附录 F 中,我们自己动手构建了一个 REST API。现在,来看看 DjangoRestFramework,这是很多 Python/Django 开发者构建 API 时的首选工具。Django 的目的是为构建数据库驱动的网站提供各种基础工具(ORM、模板等),而 DjangoRestFramework 的目的则是为构建 API 提供全部工具,从而避免一次次地编写样板代码。

撰写本附录时,我苦苦思索,怎样使用 DjangoRestFramework 构建一个与前面自己动手实现的那个一模一样的 API 呢?若想在 DjangoRestFramework 中得到同样的 URL 结构和 JSON 数据结构,面临巨大的挑战。在实现的过程中,我感觉自己是在与 DjangoRestFramework 做斗争。

这提醒了我,让我陷入沉思。构建 DjangoRestFramework 的人比我聪明得多,他们见过的 REST API 也比我多得多。如果他们觉得应该以某种方式处理,或许就表明我应该采用那种方式。我应该站在他们的角度思考问题,而不应该固执己见。

“别与框架做斗争”,这是我听过的至理名言之一。如果不能顺应框架,可能就要想想自己到底需不需要使用框架。

我们将以附录 F 构建的 API 为蓝本,尝试使用 DjangoRestFramework 重写。

G.1 安装

DjangoRestFramework 使用 pip install 命令就能安装。我使用的是创作本书时的最新版——3.5.4 版:

  1. $ pip install djangorestframework

然后把 rest_framework 添加到 settings.py 中的 INSTALLED_APPS 设置中:

superlists/settings.py

  1. INSTALLED_APPS = [
  2. #'django.contrib.admin',
  3. 'django.contrib.auth',
  4. 'django.contrib.contenttypes',
  5. 'django.contrib.sessions',
  6. 'django.contrib.messages',
  7. 'django.contrib.staticfiles',
  8. 'lists',
  9. 'accounts',
  10. 'functional_tests',
  11. 'rest_framework',
  12. ]

G.2 串化器(具体而言是ModelSerializer

DjangoRestFramework 官网的教程是学习这个框架的好资源。一开始你就会遇到串化器(serializer),这里具体而言是 ModelSerializer。DjangoRestFramework 通过串化器把 Django 数据库模型转换为交换数据所需的 JSON(或其他格式)。

lists/api.py (ch37l003)

  1. from lists.models import List, Item
  2. [...]
  3. from rest_framework import routers, serializers, viewsets
  4. class ItemSerializer(serializers.ModelSerializer):
  5. class Meta:
  6. model = Item
  7. fields = ('id', 'text')
  8. class ListSerializer(serializers.ModelSerializer):
  9. items = ItemSerializer(many=True, source='item_set')
  10. class Meta:
  11. model = List
  12. fields = ('id', 'items',)

G.3 Viewset(具体而言是ModelViewSet)和路由器

DjangoRestFramework 使用 ModelViewSet 定义通过 API 与某个模型对象的交互方式。我们只需指明想操作的是哪个模型(通过 queryset 属性),以及如何序列化模型对象(serializer_class),余下的工作由 ModelViewSet 自动完成,即自动构建相关视图,供列出、获取、更新,甚至是删除对象。

为了从特定的清单中获取待办事项,只需定义这样一个 ViewSet:

lists/api.py (ch37l004)

  1. class ListViewSet(viewsets.ModelViewSet):
  2. queryset = List.objects.all()
  3. serializer_class = ListSerializer
  4. router = routers.SimpleRouter()
  5. router.register(r'lists', ListViewSet)

DjangoRestFramework 通过路由器自动构建 URL 配置,并把它们映射到 ViewSet 提供的功能上。

现在,我们可以修改 urls.py,绕开旧的 API 代码,指向新的路由器,看看测试的情况如何:

superlists/urls.py (ch37l005)

  1. [...]
  2. # from lists.api import urls as api_urls
  3. from lists.api import router
  4. urlpatterns = [
  5. url(r'^$', list_views.home_page, name='home'),
  6. url(r'^lists/', include(list_urls)),
  7. url(r'^accounts/', include(accounts_urls)),
  8. # url(r'^api/', include(api_urls)),
  9. url(r'^api/', include(router.urls)),
  10. ]

结果好多测试都失败了:

  1. $ python manage.py test lists
  2. [...]
  3. django.urls.exceptions.NoReverseMatch: Reverse for 'api_list' not found.
  4. 'api_list' is not a valid view function or pattern name.
  5. [...]
  6. AssertionError: 405 != 400
  7. [...]
  8. AssertionError: {'id': 2, 'items': [{'id': 2, 'text': 'item 1'}, {'id': 3,
  9. 'text': 'item 2'}]} != [{'id': 2, 'text': 'item 1'}, {'id': 3, 'text': 'item
  10. 2'}]
  11.  
  12. ---------------------------------------------------------------------
  13. Ran 54 tests in 0.243s
  14.  
  15. FAILED (failures=4, errors=10)

先看那 10 个错误,报错消息都说无法反转 api_list。这是因为 DjangoRestFramework 路由器使用的命名约定与前面我们自己制定的不同。从调用跟踪可以看出,这些错误发生在渲染模板时。具体而言,是 list.html 模板。我们只需修改一处就能修正这些错误——把 api_list 改成 list-detail

lists/templates/list.html (ch37l006)

  1. <script>
  2. $(document).ready(function () {
  3. var url = "{% url 'list-detail' list.id %}";
  4. });
  5. </script>

这样修改之后,只剩下 4 个失败了:

  1. $ python manage.py test lists
  2. [...]
  3. FAIL: test_POSTing_a_new_item (lists.tests.test_api.ListAPITest)
  4. [...]
  5. FAIL: test_duplicate_items_error (lists.tests.test_api.ListAPITest)
  6. [...]
  7. FAIL: test_for_invalid_input_returns_error_code
  8. (lists.tests.test_api.ListAPITest)
  9. [...]
  10. FAIL: testgetreturns_items_for_correct_list
  11. (lists.tests.test_api.ListAPITest)
  12. [...]
  13. FAILED (failures=4)

暂且关闭所有验证测试,后面再想办法解决:

lists/tests/test_api.py (ch37l007)

  1. [...]
  2. def DONTtest_for_invalid_input_nothing_saved_to_db(self):
  3. [...]
  4. def DONTtest_for_invalid_input_returns_error_code(self):
  5. [...]
  6. def DONTtest_duplicate_items_error(self):
  7. [...]

现在只有 2 个失败了:

  1. FAIL: test_POSTing_a_new_item (lists.tests.test_api.ListAPITest)
  2. [...]
  3. self.assertEqual(response.status_code, 201)
  4. AssertionError: 405 != 201
  5. [...]
  6. FAIL: testgetreturns_items_for_correct_list
  7. (lists.tests.test_api.ListAPITest)
  8. [...]
  9. AssertionError: {'id': 2, 'items': [{'id': 2, 'text': 'item 1'}, {'id': 3,
  10. 'text': 'item 2'}]} != [{'id': 2, 'text': 'item 1'}, {'id': 3, 'text': 'item
  11. 2'}]
  12. [...]
  13. FAILED (failures=2)

先看最后 1 个失败。

DjangoRestFramework 的默认配置得到的数据结构与我们自己动手构建时稍有不同,GET 请求清单得到的响应中有清单的 ID,而且清单中的待办事项在 items 键名下。因此,为了让测试通过,我们要稍微修改一下单元测试:

lists/tests/test_api.py (ch37l008)

  1. @@ -23,10 +23,10 @@ class ListAPITest(TestCase):
  2. response = self.client.get(self.base_url.format(our_list.id))
  3. self.assertEqual(
  4. json.loads(response.content.decode('utf8')),
  5. - [
  6. + {'id': our_list.id, 'items': [
  7. {'id': item1.id, 'text': item1.text},
  8. {'id': item2.id, 'text': item2.text},
  9. - ]
  10. + ]}
  11. )

现在能通过 GET 请求获取清单中的待办事项了(稍后将看到,随之一起返回的还有很多其他数据),那通过 POST 请求添加新待办事项呢?

G.4 通过POST请求添加待办事项的URL

这次我不再与框架做斗争了,而是顺应 DjangoRestFramework。向清单的 ViewSet 发送 POST 请求是可以添加待办事项,但极为麻烦。

最简单的方法是向待办事项的 ViewSet 发送 POST 请求,而不是清单的 ViewSet:

lists/api.py (ch37l009)

  1. class ItemViewSet(viewsets.ModelViewSet):
  2. serializer_class = ItemSerializer
  3. queryset = Item.objects.all()
  4. [...]
  5. router.register(r'items', ItemViewSet)

这意味着我们要稍微修改测试,把 POST 测试从 ListAPITest 中移出来,放到新的测试类 ItemsAPITest 中:

lists/tests/test_api.py (ch37l010)

  1. @@ -1,3 +1,4 @@
  2. import json
  3. +from django.core.urlresolvers import reverse
  4. from django.test import TestCase
  5. from lists.models import List, Item
  6. @@ -31,9 +32,13 @@ class ListAPITest(TestCase):
  7. +
  8. +class ItemsAPITest(TestCase):
  9. + base_url = reverse('item-list')
  10. +
  11. def test_POSTing_a_new_item(self):
  12. list_ = List.objects.create()
  13. response = self.client.post(
  14. - self.base_url.format(list_.id),
  15. - {'text': 'new item'},
  16. + self.base_url,
  17. + {'list': list_.id, 'text': 'new item'},
  18. )
  19. self.assertEqual(response.status_code, 201)

现在测试的结果为:

  1. django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id

序列化待办事项时,如果没有指定清单的 ID,就不能知道待办事项属于哪个清单:

lists/api.py (ch37l011)

  1. class ItemSerializer(serializers.ModelSerializer):
  2. class Meta:
  3. model = Item
  4. fields = ('id', 'list', 'text')

为此,还要修改另一个有点联系的测试:

lists/tests/test_api.py (ch37l012)

  1. @@ -25,8 +25,8 @@ class ListAPITest(TestCase):
  2. self.assertEqual(
  3. json.loads(response.content.decode('utf8')),
  4. {'id': our_list.id, 'items': [
  5. - {'id': item1.id, 'text': item1.text},
  6. - {'id': item2.id, 'text': item2.text},
  7. + {'id': item1.id, 'list': our_list.id, 'text': item1.text},
  8. + {'id': item2.id, 'list': our_list.id, 'text': item2.text},
  9. ]}
  10. )

G.5 调整客户端代码

现在,这个 API 不再返回一个包含清单中所有待办事项的扁平数组,而是返回一个对象,待办事项都在它的 .items 属性中。因此,我们要稍微调整一下 updateItems 函数:

listsstaticlist.js (ch37l013)

  1. @@ -3,8 +3,8 @@ window.Superlists = {};
  2. window.Superlists.updateItems = function (url) {
  3. $.get(url).done(function (response) {
  4. var rows = '';
  5. - for (var i=0; i<response.length; i++) {
  6. - var item = response[i];
  7. + for (var i=0; i<response.items.length; i++) {
  8. + var item = response.items[i];
  9. rows += '\n<tr><td>' + (i+1) + ': ' + item.text + '</td></tr>';
  10. }
  11. $('#id_list_table').html(rows);

而且,因为获取清单和添加待办事项的 URL 都变了,所以我们还要稍微调整一下 initialize 函数。我们不再使用多个参数,而是传入包含所需配置的 params 对象:

listsstaticlist.js

  1. @@ -11,23 +11,24 @@ window.Superlists.updateItems = function (url) {
  2. });
  3. };
  4. -window.Superlists.initialize = function (url) {
  5. +window.Superlists.initialize = function (params) {
  6. $('input[name="text"]').on('keypress', function () {
  7. $('.has-error').hide();
  8. });
  9. - if (url) {
  10. - window.Superlists.updateItems(url);
  11. + if (params) {
  12. + window.Superlists.updateItems(params.listApiUrl);
  13. var form = $('#id_item_form');
  14. form.on('submit', function(event) {
  15. event.preventDefault();
  16. - $.post(url, {
  17. + $.post(params.itemsApiUrl, {
  18. + 'list': params.listId,
  19. 'text': form.find('input[name="text"]').val(),
  20. 'csrfmiddlewaretoken': form.find('input[name="csrfmiddlewaretoken"]').val(),
  21. }).done(function () {
  22. $('.has-error').hide();
  23. - window.Superlists.updateItems(url);
  24. + window.Superlists.updateItems(params.listApiUrl);
  25. }).fail(function (xhr) {
  26. $('.has-error').show();
  27. if (xhr.responseJSON && xhr.responseJSON.error) {

据此修改 list.html 中的代码:

lists/templates/list.html (ch37l014)

  1. $(document).ready(function () {
  2. window.Superlists.initialize({
  3. listApiUrl: "{% url 'list-detail' list.id %}",
  4. itemsApiUrl: "{% url 'item-list' %}",
  5. listId: {{ list.id }},
  6. });
  7. });

经过一番修改之后,基本的功能测试又能通过了:

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

为了解决前面暂时忽略的错误,还要做一些修改。如果不知道如何修改,可以参照本附录的仓库(https://github.com/hjwp/bookexample/blob/appendix_DjangoRestFrameworklistsapi.py)。

G.6 DjangoRest-Framework的优势

你可能想知道为什么要使用这个框架。

G.6.1 用配置代替代码

第一个优势是,以前的过程式视图函数变成了声明式句法:

lists/api.py

  1. def list(request, list_id):
  2. list_ = List.objects.get(id=list_id)
  3. if request.method == 'POST':
  4. form = ExistingListItemForm(for_list=list_, data=request.POST)
  5. if form.is_valid():
  6. form.save()
  7. return HttpResponse(status=201)
  8. else:
  9. return HttpResponse(
  10. json.dumps({'error': form.errors['text'][0]}),
  11. content_type='application/json',
  12. status=400
  13. )
  14. item_dicts = [
  15. {'id': item.id, 'text': item.text}
  16. for item in list_.item_set.all()
  17. ]
  18. return HttpResponse(
  19. json.dumps(item_dicts),
  20. content_type='application/json'
  21. )

如果与使用 DjangoRestFramework 得到的最终版本相比,你会发现,我们完全是在配置:

lists/api.py

  1. class ItemSerializer(serializers.ModelSerializer):
  2. text = serializers.CharField(
  3. allow_blank=False, error_messages={'blank': EMPTY_ITEM_ERROR}
  4. )
  5. class Meta:
  6. model = Item
  7. fields = ('id', 'list', 'text')
  8. validators = [
  9. UniqueTogetherValidator(
  10. queryset=Item.objects.all(),
  11. fields=('list', 'text'),
  12. message=DUPLICATE_ITEM_ERROR
  13. )
  14. ]
  15. class ListSerializer(serializers.ModelSerializer):
  16. items = ItemSerializer(many=True, source='item_set')
  17. class Meta:
  18. model = List
  19. fields = ('id', 'items',)
  20. class ListViewSet(viewsets.ModelViewSet):
  21. queryset = List.objects.all()
  22. serializer_class = ListSerializer
  23. class ItemViewSet(viewsets.ModelViewSet):
  24. serializer_class = ItemSerializer
  25. queryset = Item.objects.all()
  26. router = routers.SimpleRouter()
  27. router.register(r'lists', ListViewSet)
  28. router.register(r'items', ItemViewSet)

G.6.2 自带的功能

第二个优势是,使用 DjangoRestFramework 的 ModelSerializer、ViewSet 和路由器得到的 API 比我们自己动手构建的 API 更具扩展性。

  • 现在,清单和待办事项相关的所有 URL 都自动支持全部 HTTP 方法,包括 GET、POST、PUT、PATCH、DELETE 和 OPTIONS。
  • 而且在 http://localhost:8000/apilistshttp://localhost:8000apiitems 可以浏览自动生成的 API 文档(你可以自己试试,如图 G-1 所示)。

07 - 图13

图 G-1:自动为 API 用户生成的文档

除此之外,DjangoRestFramework 还有很多优势,详情参见文档(http://www.djangorestframework.org/topics/documenting-your-api/#self-describing-apis)。不过这两个功能对 API 的用户而言是十分重要的。

综上,DjangoRestFramework 是构建 API 的优秀工具,几乎能根据现有的模型结构自动生成 API。如果你使用 Django,在自己动手实现 API 之前绝对应该先考察一下 DjangoRestFramework。

DjangoRestFramework 小贴士

  • 别与框架做斗争

    若想提高效率,通常最好顺应框架的约定,否则就不要使用框架,或者在较低的层级定制。

  • 根据最小惊讶原则,使用路由器和ViewSet

    DjangoRestFramework 的优势之一是,使用它提供的工具(如路由器和 ViewSet)得到的 API 是可预料的,端点、URL 结构和不同 HTTP 方法的响应都有合理的默认配置。

  • 查看可浏览的 API 文档

    在浏览器中访问 API 的端点。DjangoRestFramework 能检测到你访问 API 时使用的是“常规的”Web 浏览器,此时它会显示自身的精美文档,可供你分享给你的用户。

附录 H 速查表

人们都喜欢速查表,所以我根据每章末尾旁注中的总结制作了这个速查表,目的是提醒你,并且链接到具体章节,以此唤起你的记忆。希望这个速查表有用。

H.1 项目开始阶段

  • 先构思一个用户故事,然后转换成第一个功能测试。
  • 选择一个测试框架——unittest 不错,py.testnoseGreen 也有一定优势。
  • 运行功能测试,得到第一个预期失败。
  • 选择一个 Web 框架,例如 Django,然后弄清如何在选中的框架中运行单元测试。
  • 针对目前失败的功能测试编写第一个单元测试,看着它失败。
  • 做第一次提交,把代码提交到 VCS(例如 Git)中。

相关内容:第 1、2、3 章。

H.2 TDD基本流程

  • 双循环 TDD(图 H-1)。
  • 遇红 变绿 重构。
  • 三角法。
  • 便签。
  • “三则重构”原则。
  • “从一个可运行状态到另一个可运行状态”。
  • “YAGNI”原则。

07 - 图14

图 H-1:包含功能测试和单元测试的 TDD 流程

相关内容:第 4、5、7 章。

H.3 测试不止要在开发环境中运行

  • 尽早进行系统测试。确保各组件能正常协作,包括 Web 组件、静态内容和数据库。
  • 搭建和生产环境一样的过渡环境,在这个环境中运行功能测试。
  • 自动部署过渡环境和生产环境:
    • PaaS 与 VPS;
    • Fabric;
    • 配置管理工具(Chef、Puppet、Salt 和 Ansible);
    • Vagrant。
  • 彻底弄清楚部署的主要步骤:数据库、静态文件、依赖、如何定制设定,等等。
  • 尽早搭建 CI 服务器,运行测试不能只靠自律。

相关内容:第 9、11、24 章,附录 C。

H.4 通用的测试最佳实践

  • 每个测试只能测试一件事。
  • 应用的一个源码文件对应一个测试文件。
  • 不管函数和类多么简单,都至少要编写一个占位测试。
  • “别测试常量”。
  • 尝试测试行为,而不是实现方式。
  • 不能顺着代码的逻辑思考,还要考虑边缘情况和有错误的情况。

相关内容:第 4、13、14 章。

H.5 Selenium/功能测试最佳实践

  • 相较隐式等待,多使用显式等待和交互等待模式。
  • 避免编写重复的测试代码——可以在基类中定义辅助方法,也可以使用页面模式。
  • 避免重复测试同一个功能。如果测试中有耗时操作(例如登录),可以找一种方法在其他测试中跳过这一步(但要小心看起来无关的功能相互之间的异常交互)。
  • 使用 BDD 工具,作为组织功能测试的另一种方式。

相关内容:第 21、24、25 章。

H.6 由外而内,测试隔离与整合测试,模拟技术

别忘了编写测试的初衷。

  • 确保正确性,避免回归。
  • 有利于写出简洁可维护的代码。
  • 实现一种快速高效的工作流程。

记住这几点之后,再看不同的测试类型以及各自的优缺点。

  • 功能测试
    • 从用户的角度出发,最大程度上保证应用可以正常运行。
    • 但反馈循环用时长。
    • 而且无法帮助我们写出简洁的代码。
  • 整合测试(依赖于 ORM 或 Django 测试客户端等)
    • 编写速度快。
    • 易于理解。
    • 发现任何集成问题都会提醒你。
    • 但并不总能得到好的设计(这取决于你自己)。
    • 而且一般运行速度比隔离测试慢。
  • 隔离测试(使用驭件)
    • 涉及的工作量最大。
    • 可能难以阅读和理解。
    • 但这种测试最能引导你实现更好的设计。
    • 而且运行速度最快。

如果你发现编写测试时要使用很多驭件,而且感觉很痛苦,那么记得要“倾听测试的心声”——使用模拟技术写出的丑陋测试试图告诉你,代码可以简化。

相关内容:第 22、23、26 章。

附录 I 接下来做什么

下面是我建议你接下来可以研究的一些事情,目的是提升测试技能,以及把(写作本书时的)新技术应用到 Web 开发中。

如果以后不再添加附录,我希望至少为每个话题写一篇博客文章,也编写一些示例代码。所以请读者一定要访问 http://www.obeythetestinggoat.com,看有没有更新。

或者你可以抢在我前面,自己写博客文章,记录你尝试其中任何一件事的过程。

我很乐意回答问题以及为这些话题提供提示和指引,所以如果你想尝试做某件事,但卡住了,别犹豫,联系我吧,电子邮件地址是 obeythetestinggoat@gmail.com

I.1 提醒——站内提醒以及邮件提醒

如果有人把清单分享给某个用户,能提醒这个用户就好了。

你可以使用 django-notifications,在用户下次刷新页面时显示一个消息。在这个功能的功能测试中需要两个浏览器。

或者,也可以通过电子邮件提醒。研究一下 Django 对测试电子邮件的支持,然后你会发现,测试的过程需要发送真的电子邮件。使用 IMAPClient 库可以从网页邮件测试账户中获取真实的电子邮件。

I.2 换用Postgres

SQLite 对小型数据库来说很好,但如果有不止一个 Web 职程处理请求,那它就无法胜任了。现在,Postgres 是大家最喜欢的数据库,请弄清怎么安装及配置 Postgres。

你要找一个文件保存本地、过渡服务器和生产服务器中 Postgres 的用户名和密码。因为出于安全的考虑,你或许不想把这些信息放入代码仓库。你得找到一种方法,让部署脚本把这些信息传入命令行。流行的解决方法之一是在环境变量中存储这些信息。

你可以实验一下,看单元测试在 SQLite 中运行比在 Postgres 中运行快多少。为此,你可以在本地设备中使用 SQLite,仅做测试,但在 CI 服务器中使用 Postgres。

I.3 在不同的浏览器中运行测试

Selenium 支持各种浏览器,包括 Chrome 和 Internet Exploder。尝试在这两种浏览器中运行功能测试组件,看看有没有什么异常表现。

你还应该试试无界面浏览器,比如 PhantomJS。

根据我的经验,在不同的浏览器中测试能暴露 Selenium 测试中的各种条件竞争,而且可能还要更多地使用交互等待模式(尤其是在 PhantomJS 中)。

I.4 400和500测试

专业的网站需要漂亮的错误页面。400 页面的测试方法很简单,但如果想测试 500 页面,或许得编写一个故意抛出异常的视图。

I.5 Django管理后台

假设有个用户发邮件声称某个匿名清单是他的。为此,想实现一种手动解决方案,由网站的管理员在管理后台中手动修改记录。

弄清楚怎么启用和使用管理后台。编写一个功能测试,首先由一个未登录的普通用户创建一个清单,然后管理员登录,进入管理后台,把这个清单指派给这个用户,然后这个用户即可在“My Lists”页面看到这个清单。

I.6 编写一些安全测试

扩展针对登录、“My Lists”页面和分享功能的测试,看看需要怎么编写测试确保用户只能做有权限做的事情。

I.7 测试优雅降级

如果 Persona 不可用会发生什么?是否至少可以向用户显示一个致歉消息?

  • 提示:模拟 Persona 服务不可用的方式之一是修改主机文件(路径是 etchosts 或 c:\Windows\Sytem32\drivers\etc)。记得在测试的 tearDown 方法中撤销改动。
  • 要同时考虑服务器端和客户端。

I.8 缓存和性能测试

弄清楚如何安装和配置 memcached,以及如何使用 Apache 的 ab 工具运行性能测试。在有缓存和没有缓存两种情况下,网站的性能如何?你能否编写一个自动化测试,如果检测到没启用缓存就失败?应该怎么处理可怕的缓存失效问题?测试能否帮你确认缓存失效逻辑是可靠的?

I.9 JavaScript MVC框架

现今,在客户端实现“模型 - 视图 - 控制器”(ModelView-Controller,MVC)模式的 JavaScript 库比较流行。这种库喜欢使用待办事项清单应用做演示,所以把这个网站改写成单页网站应该很容易。在单页网站中,添加清单的所有操作都由 JavaScript 代码完成。

选一个框架,Backbone.js 或 Angular.js,探究一下怎么实现。在各种框架中编写单元测试都有各自的方式。学习一种方式,一直使用下去,看你是否喜欢。

I.10 异步和websocket

假设两个用户同时编辑同一个清单,如果能看到实时更新,即一个用户添加待办事项之后,另一个用户立即就能看到,是不是很棒?这种功能可以通过使用 websocket 在客户端和服务器之间建立持久连接实现。

研究一种 Python 异步 Web 服务器,Tornado、gevent 或 Twisted,看你能否用它实现动态提醒。

测试时需要两个浏览器实例(就像在分享功能的测试中一样),检查不刷新页面的情况下,操作提醒是否会出现在另一个浏览器实例中。

I.11 换用py.test

使用 py.test 编写单元测试不用写那么多样板代码。尝试使用 py.test 改写一些单元测试。或许需要使用插件才能和 Django 无缝配合。

I.12 试试coverage.py

Ned Batchelder 开发的 coverage.py 能告诉你测试的覆盖度如何,即测试覆盖百分之多少的代码。目前,我们使用的是严格的 TDD,因此理论上覆盖度应该始终为 100%,不过再确认一下更好。而且对于没有从头开始编写测试的项目来说,这个工具是十分有用的。

I.13 客户端加密

这个比较有趣:如果用户太偏执,不再相信 NSA(美国国家安全局),觉得把清单放在云端不安全该怎么办?你能不能使用 JavaScript 构建一个加密系统,在待办事项发给服务器之前,让用户输入密码,加密自己的清单。

针对这种功能的测试,可以这么写:管理员登录 Django 管理后台,查看用户的清单,确认清单中的待办事项在数据库中是否使用密文存储。

I.14 你的建议

你觉得我应该在这个附录中写些什么?提些建议吧!

附录 J 示例源码

本书的所有示例代码都在我的一个 GitHub 仓库中(https://github.com/hjwp/bookexample/)。如果你想对比你我的代码,可以看一下那个仓库。

每一章都有自己的分支,分支名与章序一样,例如 chapter_01。

注意,各分支包含对应那一章的所有提交,因此是那一章结束时最终得到的代码。

此外,各章分别的代码示例也可至图灵社区下载,详情请见 http://www.ituring.com.cn/2052 “随书下载”处。

J.1 使用Git检查自己的进度

如果你想锻炼自己的 Git 技能,可以把我的仓库添加为远程仓库

  1. git remote add harry https://github.com/hjwp/bookexample.git
  2. git fetch harry

若想查看第 4 章结束后你我的代码有什么差异,可以这样做:

  1. git diff harry/chapter_philosophy_and_refactoring

Git 能处理多个远程仓库,即便你已经把自己的代码推送到 GitHub 或 Bitbucket,依然可以这么做。

注意,类中方法的顺序在你我的代码中可能不完全相同,这可能导致差异不好读。

J.2 下载各章代码的ZIP文件

如果鉴于某些原因,阅读某一章时你想“从头做起”,或者跳过某一章,1 抑或你就是不想使用 Git,可以下载代码的 ZIP 文件,详情请见 http://www.ituring.com.cn/2052“随书下载”处。

1我不建议你跳着读。我在撰写时并没有考虑各章的独立性,后面的章节要依赖前面的章节,跳着读可能会更让你不明所以……

J.3 不要完全依赖我的代码

除非真的卡住,不知道怎么做了,否则不要偷看答案。前面说过,自己动手调试错误能学到很多,而且当你自己开发时,可没有我的仓库供你对比,也没有现成的答案供你参考。

参考书目

[dip] Mark Pilgrim, Dive Into Python

[lpthw] Zed A. Shaw, Learn Python The Hard Way

[iwp] Al Sweigart, Invent Your Own Computer Games With Python

[tddbe] Kent Beck, TDD By Example, Addison-Wesley

[refactoring] Martin Fowler, Refactoring, Addison-Wesley

[seceng] Ross Anderson, Security Engineering, Second Edition, Addison-Wesley

[jsgoodparts] Douglas Crockford, JavaScript: The Good Parts,O'Reilly

[twoscoops] Daniel Greenfield and Audrey Roy, Two Scoops of Django

[mockfakestub] Emily Bache, Mocks, Fakes and Stubs

[GOOSGBT] Steve Freeman and Nat Pryce, Growing Object-Oriented Software Guided by Tests, Addison-Wesley

作者简介

Harry 的童年很美好,他在 Thomson T-07(当时在法国很流行,按键后会发出“啵噗”声)这种 8 位电脑上摆弄 BASIC,长大后做了几年经管顾问,但完全不快乐。而后他发现了自己真正的极客潜质,又很幸运地遇到了一些极限编程狂热者,参与开发了电子制表软件的先驱 Resolver One,不过很可惜,这个软件现在已经退出历史舞台。他目前在 PythonAnywhere LLP 公司工作,而且在各种演讲、研讨会和开发者大会上积极推广 TDD。

封面介绍

本书封面上的动物是开司米山羊。虽然所有山羊都长有开司米,但人类只选择培育这种山羊,产出能满足商用数量的开司米,所以一般只有这种山羊叫“开司米山羊”。因此,开司米山羊是一种驯养的家山羊。

开司米山羊长有一层异常柔软顺滑的内层绒毛,外覆一层粗糙的羊毛——这就是山羊的两层羊毛。开司米在冬季长成,目的是补充外层羊毛(这种毛叫“针毛”)的御寒能力。开司米中毛发的卷曲量决定了它的重量和保暖性能。

“开司米”这个名字出自印度次大陆上的克什米尔山谷地区。在这一地区,纺织品已经出现几千年了。现在的克什米尔地区,开司米山羊数量不断减少,所以不再出口开司米纤维。现在,大多数开司米毛织品都出自阿富汗、伊朗、蒙古国和印度,以及占主导地位的中国。

开司米山羊的羊毛有多种颜色和颜色搭配。雄性和雌性都长有犄角,夏季可用于散热,干农活时主人也能用它们更好地控制其他山羊。

封面图片出自 Wood 的 Animate Creation 一书。

看完了

如果您对本书内容有疑问,可发邮件至contact@turingbook.com,会有编辑或作译者协助答疑。也可访问图灵社区,参与本书讨论。

如果是有关电子书的建议或问题,请联系专用客服邮箱:ebook@turingbook.com。

在这里可以找到我们:

  • 微博 @图灵教育 : 好书、活动每日播报
  • 微博 @图灵社区 : 电子书和好文章的消息
  • 微博 @图灵新知 : 图灵教育的科普小组
  • 微信 图灵访谈 : ituring_interview,讲述码农精彩人生
  • 微信 图灵教育 : turingbooks   

091507240605ToBeReplacedWithUserId