“单元测试/编写代码”循环

现在可以开始适应 TDD 中的单元测试 / 编写代码循环了。

(1) 在终端里运行单元测试,看它们是如何失败的。

(2) 在编辑器中改动最少量的代码,让当前失败的测试通过。

然后不断重复。

想保证编写的代码无误,每次改动的幅度就要尽量小。这么做才能确保每一部分代码都有对应的测试监护。

乍一看工作量很大,初期也的确如此。但熟练之后你便会发现,即使步伐迈得很小,编程的速度也很快。我们在工作中就是这样编写实际代码的。

看一下这个循环可以运转多快。

  • 小幅代码改动:

lists/views.py

  1. def home_page(request):
  2. pass
  • 运行测试:
  1. html = response.content.decode('utf8')
  2. AttributeError: 'NoneType' object has no attribute 'content'
  • 编写代码——如你所料,使用 django.http.HttpResponse

lists/views.py

  1. from django.http import HttpResponse
  2.  
  3. # 在这里编写视图
  4. def home_page(request):
  5. return HttpResponse()
  • 再运行测试:
  1. self.assertTrue(html.startswith('<html>'))
  2. AssertionError: False is not true
  • 再编写代码:

lists/views.py

  1. def home_page(request):
  2. return HttpResponse('<html>')
  • 运行测试:
  1. AssertionError: '<title>To-Do lists</title>' not found in '<html>'
  • 编写代码:

lists/views.py

  1. def home_page(request):
  2. return HttpResponse('<html><title>To-Do lists</title>')
  • 运行测试——快通过了吧?
  1. self.assertTrue(html.endswith('</html>'))
  2. AssertionError: False is not true
  • 加油,最后一击:

lists/views.py

  1. def home_page(request):
  2. return HttpResponse('<html><title>To-Do lists</title></html>')
  • 通过了吗?
  1. $ python manage.py test
  2. Creating test database for alias 'default'...
  3. ..
  4. ---------------------------------------------------------------------
  5. Ran 2 tests in 0.001s
  6.  
  7. OK
  8. System check identified no issues (0 silenced).
  9. Destroying test database for alias 'default'...

确实通过了。现在要运行功能测试。如果已经关闭了开发服务器,别忘了启动。感觉这是最后一次运行测试了吧,真是这样吗?

  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 19, in
  8. test_can_start_a_list_and_retrieve_it_later
  9. self.fail('Finish the test!')
  10. AssertionError: Finish the test!
  11.  
  12. ---------------------------------------------------------------------
  13. Ran 1 test in 1.609s
  14.  
  15. FAILED (failures=1)

失败了,怎么会?哦,原来是那个提醒?是吗?是的!我们成功编写了一个网页!

好吧,我觉得这样结束本章很刺激。你可能还有点儿摸不着头脑,或许还想知道怎么调整这些测试,别担心,后面的章节会讲。我只是想在临近收尾的时候让你兴奋一下。

要做一次提交,平复一下心情,再回想学到了什么:

  1. $ git diff # 会显示tests.py中的新测试方法,以及views.py中的视图
  2. $ git commit -am "Basic view now returns minimal HTML"

这一章内容真丰富啊!为什么不执行 git log 命令回顾一下我们取得的进展呢?或许还可以指定 --oneline 标志:

  1. $ git log --oneline
  2. a6e6cc9 Basic view now returns minimal HTML
  3. 450c0f3 First unit test and url mapping, dummy view
  4. ea2b037 Add app for lists, with deliberately failing unit test
  5. [...]

不错,本章介绍了以下知识。

  • 新建 Django 应用。
  • Django 的单元测试运行程序。
  • 功能测试和单元测试之间的区别。
  • Django 解析 URL 的方法,urls.py 文件的作用。
  • Django 的视图函数,请求和响应对象。
  • 如何返回简单的 HTML。

有用的命令和概念

  • 启动Django 的开发服务器

    1. python manage.py runserver
  • 运行功能测试

    1. python functional_tests.py
  • 运行单元测试

    1. python manage.py test
  • “单元测试/ 编写代码”循环

    (1) 在终端里运行单元测试。

    (2) 在编辑器中改动最少量的代码。

    (3) 重复上两步。

第 4 章 测试(及重构)的目的

现在已经实际演练了基本的 TDD 流程,该停下来说说为什么这么做了。

我想象得出很多读者心中都积压了一些挫败感,某些读者可能以前写过单元测试,另一些读者可能只想快速学会如何测试。你们心中有些疑问,比如说:

  • 编写的测试是不是有点儿多了?
  • 其中一些测试肯定有重复吧,比如功能测试和单元测试之间?
  • 我的意思是,你为什么要在单元测试中导入 django.core.urlresolvers 呢?这不是在测试作为第三方代码的 Django 吗?我觉得没必要这么做,这么想对吗?
  • 单元测试有点儿太琐碎了,测试一行声明代码,而且只让函数返回一个常量,这么做难道不是在浪费时间?我们是不是应该把时间腾出来为复杂功能编写测试?
  • “单元测试 / 编写代码”循环中的小幅改动有必要吗?我们应该可以直接跳到最后一步吧?我想说,home_page = None ?真的有必要吗?
  • 难道现实中你真这样编写代码吗?

年轻人啊!以前我也这样满腹疑问。这些确实是很好的问题,其实,到现在我也经常问自己这些问题。真的值得做这些事吗?这么做是不是有点儿盲目?

4.1 编程就像从井里打水

编程其实很难,我们的成功往往得益于自己的聪明才智。假如我们不那么聪明,TDD 就能助我们一臂之力。Kent Beck(TDD 理念基本上就是他发明的)打了个比方。试想你用绳子从井里提一桶水,如果井不太深,而且桶不是很满,提起来很容易。就算提满满一桶水,刚开始也很容易。但要不了多久你就累了。TDD 理念好比是一个棘轮,你可以使用它保存当前的进度,休息一会儿,而且能保证进度绝不倒退。这样你就没必要一直那么聪明了(如图 4-1)。

{%}

图 4-1:全部都要测试 1

1原画出自 Allie Brosh 的网站 Hyperbole and a Half。——译者注

好吧,或许你基本上接受 TDD 是个好主意,但仍然认为我做得太极端了,有必要测试得这么细、步子这么小吗?

测试是一种技能,不是天生就会的。因为很多结果不会立刻显现,需要等待很长一段时间,所以目前你要强迫自己这么做。这就是测试山羊的图片想要表达的——你要对测试顽固一点儿。

细化测试每个函数的好处

就目前而言,测试简单的函数和常量看起来有点傻。

你可能觉得不遵守这么严格的规则,漏掉一些单元测试,应该也算得上是 TDD。但是在这本书中,我所演示的是完整而严格的 TDD 流程。像学习武术中的招式一样,在不受影响的可控环境中才能让技能变成肌肉记忆。现在看起来之所以琐碎,是因为我们刚开始举的例子很简单。程序变复杂后问题就来了,到时你就知道测试的重要性了。你要面临的危险是,复杂性逐渐靠近,而你可能没发觉,但不久之后你就会变成温水煮青蛙。

我赞成为简单的函数编写细化的简单测试,关于这一观点我还有这么两点要说。

首先,既然测试那么简单,写起来就不会花很长时间。所以,别抱怨了,只管写就是了。

其次,占位测试很重要。先为简单的函数写好测试,当函数变复杂后,这道心理障碍就容易迈过去。你可能会在函数中添加一个 if 语句,几周后再添加一个 for 循环,不知不觉间就将其变成一个基于元类(meta-class)的多态树状结构解析器了。因为从一开始你就编写了测试,每次修改都会自然而然地添加新测试,最终得到的是一个测试良好的函数。相反,如果你试图判断函数什么时候才复杂到需要编写测试的话,那就太主观了,而且情况会变得更糟,因为没有占位测试,此时开始编写测试需要投入很多精力,每次改动代码都冒着风险,你开始拖延,很快青蛙就煮熟了。

不要试图找一些不靠谱的主观规则,去判断什么时候应该编写测试,什么时候可以全身而退。我建议你现在遵守我制定的训练方法,因为所有技能都一样,只有花时间学会了规则才能打破规则。

接下来继续实践。

4.2 使用Selenium测试用户交互

前一章结束时进展到哪里了?重新运行测试找出答案:

  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 19, in
  8. test_can_start_a_list_and_retrieve_it_later
  9. self.fail('Finish the test!')
  10. AssertionError: Finish the test!
  11.  
  12. ---------------------------------------------------------------------
  13. Ran 1 test in 1.609s
  14.  
  15. FAILED (failures=1)

你运行了吗?是不是看到一个错误,说加载页面出错或者无法连接?我也看到了。这是因为运行测试之前没有使用 manage.py runserver 启动开发服务器。运行这个命令,然后你会看到我们期待的那个失败消息。

02 - 图2 TDD 的优点之一是,永远不会忘记接下该做什么——重新运行测试就知道要做的事了。

失败消息说“Finish the test”(结束这个测试),那么就来结束它吧!打开 functional_tests.py 文件,扩充其中的功能测试:

functional_tests.py

  1. from selenium import webdriver
  2. from selenium.webdriver.common.keys import Keys
  3. import time
  4. import unittest
  5. class NewVisitorTest(unittest.TestCase):
  6. def setUp(self):
  7. self.browser = webdriver.Firefox()
  8. def tearDown(self):
  9. self.browser.quit()
  10. def test_can_start_a_list_and_retrieve_it_later(self):
  11. # 伊迪丝听说有一个很酷的在线待办事项应用
  12. # 她去看了这个应用的首页
  13. self.browser.get('http://localhost:8000')
  14. # 她注意到网页的标题和头部都包含“To-Do”这个词
  15. self.assertIn('To-Do', self.browser.title)
  16. header_text = self.browser.find_element_by_tag_name('h1').text
  17. self.assertIn('To-Do', header_text)
  18. # 应用邀请她输入一个待办事项
  19. inputbox = self.browser.find_element_by_id('id_new_item')
  20. self.assertEqual(
  21. inputbox.get_attribute('placeholder'),
  22. 'Enter a to-do item'
  23. )
  24. # 她在一个文本框中输入了“Buy peacock feathers”(购买孔雀羽毛)
  25. # 伊迪丝的爱好是使用假蝇做鱼饵钓鱼
  26. inputbox.send_keys('Buy peacock feathers')
  27. # 她按回车键后,页面更新了
  28. # 待办事项表格中显示了“1: Buy peacock feathers”
  29. inputbox.send_keys(Keys.ENTER)
  30. time.sleep(1)
  31. table = self.browser.find_element_by_id('id_list_table')
  32. rows = table.find_elements_by_tag_name('tr')
  33. self.assertTrue(
  34. any(row.text == '1: Buy peacock feathers' for row in rows)
  35. )
  36. # 页面中又显示了一个文本框,可以输入其他的待办事项
  37. # 她输入了“Use peacock feathers to make a fly”(使用孔雀羽毛做假蝇)
  38. # 伊迪丝做事很有条理
  39. self.fail('Finish the test!')
  40. # 页面再次更新,她的清单中显示了这两个待办事项
  41. [...]

❶ 我们使用了 Selenium 提供的几个用来查找网页内容的方法:find_element_by_tag_namefind_element_by_idfind_elements_by_tag_name(注意有个 s,也就是说这个方法会返回多个元素)。

❷ 我们还使用了 send_keys,这是 Selenium 在输入框中输入内容的方法。

Keys 类(别忘了导入)的作用是发送回车键等特殊的按键。2

2这里可以直接使用字符串 "\n",但因为 Keys 还能发送 Ctrl 等特殊的按键,所以我觉得有必要用一下 Keys 类。

❹ 按下回车键后页面会刷新。time.sleep 的作用是等待页面加载完毕,这样才能针对新页面下断言。这叫“显式等待”(特别简单,第 6 章将加以改进)。

02 - 图3 小心 Selenium 中 find_element_by…find_elements_by… 这两类函数的区别。前者返回一个元素,如果找不到就抛出异常;后者返回一个列表,这个列表可能为空。

还有,留意一下 any 函数,它是 Python 中的原生函数,却鲜为人知。不用我解释这个函数的作用了吧?使用 Python 编程就是这么惬意。

不过,如果你不懂 Python 的话,我告诉你,any 函数的参数是个生成器表达式(generator expression),类似于列表推导(list comprehension),但比它更为出色。你需要仔细研究这个概念。你可以搜索 Guido 名为“From List Comprehensions to Generator Expressions”的文章。读完之后你就会知道,这个函数可不仅仅是为了让编程惬意。

看一下测试进展如何:

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

解释一下,测试报错在页面中找不到

元素。看一下如何在首页的 HTML 中加入这个元素。

大幅修改功能测试后往往有必要提交一次。初稿中我没这么做,想通之后就后悔了,可是已经和其他代码混在一起提交了。其实提交得越频繁越好:

  1. $ git diff # 会显示对functional_tests.py的改动
  2. $ git commit -am "Functional test now checks we can input a to-do item"

4.3 遵守“不测试常量”规则,使用模板解决这个问题

看一下 lists/tests.py 中的单元测试。现在,要查找特定的 HTML 字符串,但这不是测试 HTML 的高效方法。一般来说,单元测试的规则之一是不测试常量。以文本形式测试 HTML 很大程度上就是测试常量。

换句话说,如果有如下的代码:

  1. wibble = 3

在测试中就不太有必要这么写:

  1. from myprogram import wibble
  2. assert wibble == 3

单元测试要测试的其实是逻辑、流程控制和配置。编写断言检测 HTML 字符串中是否有指定的字符序列,不是单元测试应该做的。

而且,在 Python 代码中插入原始字符串真的不是处理 HTML 的正确方式。我们有更好的方法,那就是使用模板。如果把 HTML 放在一个扩展名为 .html 的文件中,先不说其他好处,单就得到更好的句法高亮支持这一点而言也值了。Python 领域有很多模板框架,Django 有自己的模板系统,而且很好用。来使用这个模板系统吧。

4.3.1 使用模板重构

现在要做的是让视图函数返回完全一样的 HTML,但使用不同的处理方式。这个过程叫作重构,即在功能不变的前提下改进代码。

功能不变是最重要的。如果重构时添加了新功能,很可能会产生问题。重构本身也是一门学问,有专门的参考书——Martin Fowler 写的《重构》。

重构的首要原则是不能没有测试。幸好我们在做测试驱动开发,测试已经有了。检查一下测试能否通过,测试能通过才能保证重构前后的表现一致:

  1. $ python manage.py test
  2. [...]
  3. OK

很好!先把 HTML 字符串提取出来写入单独的文件。新建用于保存模板的文件夹 lists/ templates,然后新建文件 lists/templates/home.html,再把 HTML 写入这个文件 3。

3有些人喜欢使用和应用同名的子文件夹(即 lists/templates/lists),然后使用 lists/home.html 引用这个模板,这叫作“模板命名空间”。我觉得对小型项目来说使用模板命名空间太复杂了,不过在大型项目中可能有用武之地。详情参阅 Django 教程(https://docs.djangoproject.com/en/1.11/intro/tutorial03/#write-views-that-actually-do-something)。

lists/templates/home.html

  1. <html>
  2. <title>To-Do lists</title>
  3. </html>

高亮显示的句法,漂亮多了!接下来修改视图函数:

lists/views.py

  1. from django.shortcuts import render
  2. def home_page(request):
  3. return render(request, 'home.html')

现在不自己构建 HttpResponse 对象了,转而使用 Django 中的 render 函数。这个函数的第一个参数是请求对象(原因稍后说明),第二个参数是渲染的模板名。Django 会自动在所有的应用目录中搜索名为 templates 的文件夹,然后根据模板中的内容构建一个 HttpResponse 对象。

02 - 图4 模板是 Django 中一个很强大的功能,使用模板的主要优势之一是能把 Python 变量代入 HTML 文本。现在还没用到这个功能,不过后面的章节会用到。这就是为什么使用 renderrender_to_string(稍后用到),而不用原生的 open 函数手动从硬盘中读取模板文件。

看一下模板是否起作用了:

  1. $ python manage.py test
  2. [...]
  3. ======================================================================
  4. ERROR: test_home_page_returns_correct_html (lists.tests.HomePageTest)
  5. ---------------------------------------------------------------------
  6. Traceback (most recent call last):
  7. File "...superlistsliststests.py", line 17, in
  8. test_home_page_returns_correct_html
  9. response = home_page(request)
  10. File "...superlistslistsviews.py", line 5, in home_page
  11. return render(request, 'home.html')
  12. File "usrlocal/lib/python3.6/dist-packages/django/shortcuts.py", line 48,
  13. in render
  14. return HttpResponse(loader.render_to_string(args, *kwargs),
  15. File "usrlocal/lib/python3.6/dist-packages/django/template/loader.py", line
  16. 170, in render_to_string
  17. t = get_template(template_name, dirs)
  18. File "usrlocal/lib/python3.6/dist-packages/django/template/loader.py", line
  19. 144, in get_template
  20. template, origin = find_template(template_name, dirs)
  21. File "usrlocal/lib/python3.6/dist-packages/django/template/loader.py", line
  22. 136, in find_template
  23. raise TemplateDoesNotExist(name)
  24. django.template.base.TemplateDoesNotExist: home.html
  25.  
  26. ---------------------------------------------------------------------
  27. Ran 2 tests in 0.004s

又遇到一次分析调用跟踪的机会。

❶ 先看错误是什么:测试无法找到模板。

❷ 然后确认是哪个测试失败:很显然是测试视图 HTML 的测试。

❸ 然后找到导致失败的是测试中的哪一行:调用 home_page 函数那行。

❹ 最后,在应用的代码中找到导致失败的部分:调用 render 函数那段。

那为什么 Django 找不到模板呢?模板在 lists/templates 文件夹中,它就该放在这个位置啊。

原因是还没有正式在 Django 中注册 lists 应用。执行 startapp 命令以及在项目文件夹中存放一个应用还不够,你要告诉 Django 确实要开发一个应用,并把这个应用添加到文件 settings.py 中。这么做才能保证万无一失。打开 settings.py,找到变量 INSTALLED_APPS,把 lists 加进去:

superlists/settings.py

  1. # Application definition
  2. INSTALLED_APPS = [
  3. 'django.contrib.admin',
  4. 'django.contrib.auth',
  5. 'django.contrib.contenttypes',
  6. 'django.contrib.sessions',
  7. 'django.contrib.messages',
  8. 'django.contrib.staticfiles',
  9. 'lists',
  10. ]

可以看出,默认已经有很多应用了。只需把 lists 加到列表的末尾。别忘了在行尾加上逗号,这么做虽然不是必须的,但如果忘了,Python 会把不在同一行的两个字符串连起来,到时你就傻眼了。

现在可以再运行测试看看:

  1. $ python manage.py test
  2. [...]
  3. self.assertTrue(html.endswith('</html>'))
  4. AssertionError: False is not true

糟糕,还是无法通过。

02 - 图5 你能否看到这个错误,取决于你使用的文本编辑器是否会在文件的最后添加一个空行。如果没看到,你可以跳过下面几段,直接跳到测试通过那部分。

不过确实有进展。看起来测试找到模板了,但最后三个断言失败了。很显然输出的末尾出了问题。我使用 print (repr(html)) 调试这个问题,发现是因为转用模板后在响应的末尾引入了一个额外的空行(\n)。按下面的方式修改可以让测试通过:

lists/tests.py

  1. self.assertTrue(html.strip().endswith('</html>'))

这么做有点像作弊,不过 HTML 文件末尾的空白并不重要。再运行测试看看:

  1. $ python manage.py test
  2. [...]
  3. OK

对代码的重构结束了,测试也证实了重构前后的表现一致。现在可以修改测试,不再测试常量,检查是否渲染了正确的模板。

4.3.2 Django测试客户端

测试是否正确渲染模板的一种方法是在测试中手动渲染模板,然后与视图返回的结果做比较。为此,可以利用 Django 提供的 render_to_string 函数:

lists/tests.py

  1. from django.template.loader import render_to_string
  2. [...]
  3. def test_home_page_returns_correct_html(self):
  4. request = HttpRequest()
  5. response = home_page(request)
  6. html = response.content.decode('utf8')
  7. expected_html = render_to_string('home.html')
  8. self.assertEqual(html, expected_html)

但这样测试有点笨拙,而且转来转去调用 .decode().strip() 太牵扯精力。其实,Django 提供的测试客户端(Test Client)才是检查使用哪个模板的原生方式。相应的测试如下所示:

lists/tests.py

  1. def test_home_page_returns_correct_html(self):
  2. response = self.client.get('')
  3. html = response.content.decode('utf8')
  4. self.assertTrue(html.startswith('<html>'))
  5. self.assertIn('<title>To-Do lists<title>', html)
  6. self.assertTrue(html.strip().endswith('</html>'))
  7. self.assertTemplateUsed(response, 'home.html')

❶ 不再手动创建 HttpRequest 对象,也不再直接调用视图函数,而是调用 self.client.get,并传入要测试的 URL。

❷ 暂时保留这一行,确保一切与之前一样正常。

.assertTemplateUsed 是 Django TestCase 类提供的测试方法,用于检查响应是使用哪个模板渲染的(注意,这个方法只能测试通过测试客户端获取的响应)。

这个测试依然能通过:

  1. Ran 2 tests in 0.016s
  2. OK

我对没失败的测试始终有所怀疑,所以故意做点破坏:

lists/tests.py

  1. self.assertTemplateUsed(response, 'wrong.html')

这样还能看看错误消息是什么:

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

消息的内容很有帮助!现在把断言改回去,顺便把旧的断言删掉。此外,还可以把原来的 test_root_url_resolves 测试删除,因为 Django 测试客户端已经隐式测试过了。我们把两个冗长的测试精简成了一个!

lists/tests.py (ch04l010)

  1. from django.test import TestCase
  2. class HomePageTest(TestCase):
  3. def test_uses_home_template(self):
  4. response = self.client.get('/')
  5. self.assertTemplateUsed(response, 'home.html')

注意,这里的重点是“不要测试常量,而应该测试实现方式”。很好! 4

4你是不是发现某些代码清单旁有 ch04l0xx 这样的文本,而且还不知道那是什么意思?这是本书示例仓库中特定提交(https://github.com/hjwp/bookexample/commits/chapter_philosophy_and_refactoring)的引用,为本书中的测试(https://github.com/hjwp/Book-TDD-Web-Dev-Python/tree/master/tests)使用,也就是这本关于测试的书中的测试的测试;显然,测试自身也需要测试。

为什么不一直使用 Django 测试客户端?

你可能会问:“为什么不从一开始就使用 Django 测试客户端呢?”在现实中,我确实会这么做。但鉴于一些原因,我想告诉你如何“手动”实现。首先,这样可以逐个介绍相关概念,尽量使学习曲线保持平缓;其次,你可能不会一直使用 Django 构建应用,而且相关的测试工具也不是永远可用——但是直接调用函数,然后检查响应是永远可以采用的方法。

此外,Django 测试客户端也有不足之处,后文会讨论完全隔离的单元测试与由测试客户端推动向前的“整合”测试之间的区别。但就目前而言,测试客户端尚且算是务实的选择。

4.4 关于重构

这个重构的例子很烦琐。但正如 Kent Beck 在 Test-Driven Development: By Example 一书中所说的:“我是推荐你在实际工作中这么做吗?不是。我只是建议你要知道怎么按照这种方式做。”

其实,写这一部分时我的第一反应是先修改代码,直接使用 assertTemplateUsed 函数,删除那三个多余的断言,只在渲染得到的结果中检查期望看到的内容,然后再修改代码。但要注意,如果真这么做了可能就会犯错,因为我可能不会在模板中编写正确的 标签,而是随便写一些字符串。

02 - 图6 重构时,修改代码或者测试,但不能同时修改。

在重构的过程中总有超前几步的冲动,想对应用的功能做些改动,不久,修改的文件就会变得越来越多,最终你会忘记自己身在何处,而且一切都无法正常运行了。如果你不想让自己变成“重构猫”(如图 4-2),迈的步子就要小一点,把重构和功能调整完全分开来做。

{%}

图 4-2:重构猫——记得要看完整的动态 GIF 图

02 - 图8 后面会再次遇到重构猫,用来说明头脑发热、一次修改很多内容带来的后果。你可以把这只猫想象成卡通片中浮现在另一个肩膀上的魔鬼,它和测试山羊的观点是对立的,总给些不好的建议。

重构后最好做一次提交:

  1. $ git status # 会看到tests.py、views.py、settings.py以及新建的templates文件夹
  2. $ git add . # 还会添加尚未跟踪的templates文件夹
  3. $ git diff --staged # 审查我们想提交的内容
  4. $ git commit -m "Refactor home page view to use a template"

4.5 接着修改首页

现在功能测试还是失败的。修改代码,让它通过。因为 HTML 现在保存在模板中,可以尽情修改,无须编写额外的单元测试。我们需要一个

元素:

lists/templates/home.html

  1. <html>
  2. <head>
  3. <title>To-Do lists</title>
  4. </head>
  5. <body>
  6. <h1>Your To-Do list</h1>
  7. </body>
  8. </html>

看一下功能测试是否认同这次修改:

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

不错,继续修改:

lists/templates/home.html

  1. [...]
  2. <h1>Your To-Do list</h1>
  3. <input id="id_new_item" >
  4. <body>
  5. [...]

现在呢?

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

加上占位文字:

lists/templates/home.html

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

得到了下述错误:

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

因此要在页面中加入表格。目前表格是空的:

lists/templates/home.html

  1. <input id="id_new_item" placeholder="Enter a to-do item" >
  2. <table id="id_list_table">
  3. <table>
  4. </body>

现在功能测试的结果如何?

  1. File "functional_tests.py", line 43, in
  2. test_can_start_a_list_and_retrieve_it_later
  3. any(row.text == '1: Buy peacock feathers' for row in rows)
  4. AssertionError: False is not true

有点儿晦涩。可以使用行号找出问题所在,原来是前面我沾沾自喜的那个 any 函数导致的,或者更准确地说是 assertTrue,因为没有提供给它明确的失败消息。可以把自定义的错误消息传给 unittest 中的大多数 assertX 方法:

functional_tests.py

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

再次运行功能测试,应该会看到我们编写的消息:

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

不过现在如果想让测试通过,就要真正处理用户提交的表单,但这是下一章的话题。

现在做个提交吧:

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

多亏这次重构,视图能渲染模板了,也不再测试常量了,现在准备好处理用户的输入了。

4.6 总结:TDD流程

至此,我们已经在实践中见识了 TDD 流程中涉及的所有主要概念。

  • 功能测试。
  • 单元测试。
  • “单元测试 / 编写代码”循环。
  • 重构。

现在要稍微总结一下,或许可以画个流程图。请原谅我,做了这么多年管理顾问,养成了这个习惯。不过流程图也有好的一面,能清楚地表明流程中的循环。

TDD 的总体流程是什么呢?参见图 4-3。

02 - 图9

图 4-3:TDD 的总体流程

首先编写一个测试,运行这个测试看着它失败。然后编写最少量的代码取得一些进展,再运行测试。如此不断重复,直到测试通过为止。最后,或许还要重构代码,测试能确保不破坏任何功能。

如果既有功能测试,又有单元测试,该怎么运用这个流程呢?你可以把功能测试当作循环的一种高层视角,而“编写代码让功能测试通过”这一步则是另一个小型 TDD 循环,这个小循环使用单元测试,如图 4-4 所示。

02 - 图10

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

编写一个功能测试,看着它失败。接下来,“编写代码让功能测试通过”这一步是一个小型 TDD 循环:编写一个或多个单元测试,然后进入“单元测试 / 编写代码”循环,直到单元测试通过为止。然后回到功能测试,查看是否有进展。这一步还可以多编写一些应用代码,再编写更多的单元测试,如此一直循环下去。

涉及功能测试时应该怎么重构呢?这就要使用功能测试检查重构前后的表现是否一致。不过,你可以修改、添加或删除单元测试,或者使用单元测试循环修改实现方式。

功能测试是应用能否正常运行的最终评判,而单元测试只是整个开发过程中的一个辅助工具。

这种看待事物的方式有时叫作“双循环测试驱动开发”。本书的优秀技术审校人员之一 Emily Bache 写了一篇博客,从不同的视角讨论了这个话题,推荐你阅读,名为“Coding Is Like Cooking”。

接下来的章节会更深入地探索这个工作流程中的各个组成部分。

如何检查你的代码,以及在必要时跳着阅读

书中使用的所有代码示例都可以到我放在 GitHub 中的仓库(https://github.com/hjwp/bookexample/)中获取。因此,如果你想拿自己的代码和我的比较,可以到这个仓库中看一下。

每一章的代码都放在单独的分支中,各分支采用简短形式命名,比如说本章的示例在 chapter_philosophy_and_refactoring 这个分支中。这是本章结束时代码的快照。

所有分支的代码请至 http://www.ituring.com.cn/book/2052 下载。附录 J 说明了如何使用 Git 比较你我的代码。

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

要获取用户输入的待办事项,发送给服务器,这样才能使用某种方式保存待办事项,然后再显示给用户查看。

刚开始写这一章时,我立即采用了我认为正确的设计方式:为清单和待办事项创建几个模型,为新建清单和待办事项创建一组不同的 URL,编写三个新视图函数,又为这些操作编写六七个新的单元测试。不过我还是忍住了没这么做。虽然我十分确定自己很聪明,能一次处理所有问题,但是 TDD 的重要思想是必要时一次只做一件事。所以我决定放慢脚步,每次只做必要的操作,让功能测试向前迈出一小步即可。

这么做是为了演示 TDD 对迭代式开发方法的支持——这种方法不是最快的,但最终仍能把你带到目的地。使用这种方法还有个不错的附带好处:我可以一次只介绍一个新概念,例如模型、处理 POST 请求和 Django 模板标签等,不必一股脑儿全抛给你。

并不是说你不能事先考虑后面的事,或者不能发挥自己的聪明才智。下一章我们会稍微多使用一点儿设计和预见思维,展示如何在 TDD 过程中运用这些思维方式。不过现在,我们要坚持自己是无知的,测试让做什么就做什么。

5.1 编写表单,发送POST请求

上一章末尾,测试指出无法保存用户的输入。现在,要使用标准的 HTML POST 请求。虽然有点无聊,但发送过程很简单。后文我们会见识到各种有趣的 HTML5 和 JavaScript 用法。

为了让浏览器发送 POST 请求,我们要做两件事。

(1) 给 元素指定 name= 属性。

(2) 把它放在

标签中,并为
标签指定 method="POST" 属性。

据此调整一下 lists/templates/home.html 中的模板:

lists/templates/home.html

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

现在运行功能测试,会看到一个晦涩难懂、预料之外的错误:

  1. $ python functional_tests.py
  2. [...]
  3. Traceback (most recent call last):
  4. File "functional_tests.py", line 40, in
  5. test_can_start_a_list_and_retrieve_it_later
  6. table = self.browser.find_element_by_id('id_list_table')
  7. [...]
  8. selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
  9. element: [id="id_list_table"]

如果功能测试出乎意料地失败了,可以做下面几件事,找出问题所在。

  • 添加 print 语句,输出页面中当前显示的文本是什么。
  • 改进错误消息,显示当前状态的更多信息。
  • 亲自手动访问网站。
  • 在测试执行过程中使用 time.sleep 暂停。

本书会分别介绍这几种调试方法,不过我发现自己经常使用 time.sleep。下面试一下这种方法。

其实,在错误发生之前就已经休眠了。那就延长休眠时间:

functional_tests.py

  1. # 按回车键后,页面更新了
  2. # 待办事项表格中显示了“1: Buy peacock feathers”
  3. inputbox.send_keys(Keys.ENTER)
  4. time.sleep(10)
  5. table = self.browser.find_element_by_id('id_list_table')

如果 Selenium 运行得很慢,你可能已经发现了这一问题。现在再次运行功能测试,就有机会看看到底发生了什么:你会看到一个如图 5-1 所示的页面,显示了 Django 提供的很多调试信息。

{%}

图 5-1:Django 中的调试页面,显示有 CSRF 错误

安全:异常有趣!

如果你从未听说过跨站请求伪造(Cross-Site Request Forgery,CSRF)漏洞,现在就去查资料吧。和所有安全漏洞一样,研究起来很有趣。CSRF 是一种不寻常的、使用系统的巧妙方式。

在大学攻读计算机科学学位时,出于责任感,我报名学习了安全课程单元。这个单元可能很枯燥乏味,但我觉得最好还是学一下。结果证明,这是所有课程中最吸引人的单元,充满了黑客的乐趣,你要在特定的心境下思考如何通过意想不到的方式使用系统。

我要推荐学这门课程时使用的课本,Ross Anderson 写的 Security Engineering。这本书没有深入讲解纯粹的加密机制,而是讨论了很多意料之外的话题,例如开锁、伪造银行票据和喷墨打印机墨盒的经济原理,以及如何使用重放攻击(replay attack)戏弄南非空军的飞机等。这是本大部头书,大约 3 英寸厚,但相信我,绝对值得一读。

Django 针对 CSRF 的保护措施是在生成的每个表单中放置一个自动生成的令牌,通过这个令牌判断 POST 请求是否来自同一个网站。之前的模板都是纯粹的 HTML,在这里要首次体验 Django 模板的魔力,使用模板标签(template tag)添加 CSRF 令牌。模板标签的句法是花括号和百分号形式,即 {% … %}——这种写法很有名,要连续多次同时按两个键,是世界上最麻烦的输入方式。

lists/templates/home.html

  1. <form method="POST">
  2. <input name="item_text" id="id_new_item" placeholder="Enter a to-do item" >
  3. {% csrf_token %}
  4. <form>

渲染模板时,Django 会把这个模板标签替换成一个 元素,其值是 CSRF 令牌。现在运行功能测试,会看到一个预期失败:

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

因为 time.sleep 还在,所以测试会在最后一屏上暂停。可以看到,提交表单后新添加的待办事项不见了,页面刷新后又显示了一个空表单。这是因为还没连接服务器让它处理 POST 请求,所以服务器忽略请求,直接显示常规首页。

其实,现在可以删掉 time.sleep 了:

functional_tests.py

  1. # 待办事项表格中显示了“1: Buy peacock feathers”
  2. inputbox.send_keys(Keys.ENTER)
  3. time.sleep(1)
  4. table = self.browser.find_element_by_id('id_list_table')

5.2 在服务器中处理POST请求

还没为表单指定 action= 属性,因此提交表单后默认返回之前渲染的页面(即“/”),这个页面由视图函数 home_page 处理。下面修改这个视图函数,让它能处理 POST 请求。

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

lists/tests.py (ch05l005)

  1. def test_uses_home_template(self):
  2. response = self.client.get('')
  3. self.assertTemplateUsed(response, 'home.html')
  4. def test_can_save_a_POST_request(self):
  5. response = self.client.post('', data={'item_text': 'A new list item'})
  6. self.assertIn('A new list item', response.content.decode())

为了发送 POST 请求,我们调用 self.client.post,传入 data 参数,指定想发送的表单数据。然后再检查 POST 请求渲染得到的 HTML 中是否有指定的文本。运行测试后,会看到预期的失败:

  1. $ python manage.py test
  2. [...]
  3. AssertionError: 'A new list item' not found in '<html>\n <head>\n
  4. <title>To-Do lists</title>\n </head>\n <body>\n <h1>Your To-Do
  5. list</h1>\n <form method="POST">\n <input name="item_text"
  6. [...]
  7. </body>\n</html>\n'

为了让测试通过,可以添加一个 if 语句,为 POST 请求提供一个不同的代码执行路径。按照典型的 TDD 方式,先故意编写一个愚蠢的返回值:

lists/views.py

  1. from django.http import HttpResponse
  2. from django.shortcuts import render
  3. def home_page(request):
  4. if request.method == 'POST':
  5. return HttpResponse(request.POST['item_text'])
  6. return render(request, 'home.html')

这样单元测试就能通过了,但这并不是我们真正想要做的。我们真正想要做的是把 POST 请求提交的数据添加到首页模板的表格里。

5.3 把Python变量传入模板中渲染

前面已经粗略展示了 Django 的模板句法,现在是时候领略它的真正强大之处了,即从视图的 Python 代码中把变量传入 HTML 模板。

先介绍在模板中使用哪种句法引入 Python 对象。要使用的符号是 {{ … }},它会以字符串的形式显示对象:

lists/templates/home.html

  1. <body>
  2. <h1>Your To-Do list</h1>
  3. <form method="POST">
  4. <input name="item_text" id="id_new_item" placeholder="Enter a to-do item" >
  5. {% csrf_token %}
  6. <form>
  7. <table id="id_list_table">
  8. <tr><td>{{ new_item_text }}</td></tr>
  9. </table>
  10. </body>

这里要调整单元测试,检查是否依然使用这个模板:

lists/tests.py

  1. def test_can_save_a_POST_request(self):
  2. response = self.client.post('/', data={'item_text': 'A new list item'})
  3. self.assertIn('A new list item', response.content.decode())
  4. self.assertTemplateUsed(response, 'home.html')

跟预期一样,这个测试会失败:

  1. AssertionError: No templates used to render the response

很好,故意编写的愚蠢返回值已经骗不过测试了,因此要重写视图函数,把 POST 请求中的参数传入模板。render 函数的第三个参数是一个字典,把模板变量的名称映射在值上:

lists/views.py (ch05l009)

  1. def home_page(request):
  2. return render(request, 'home.html', {
  3. 'new_item_text': request.POST['item_text'],
  4. })

然后再运行单元测试:

  1. ERROR: test_uses_home_template (lists.tests.HomePageTest)
  2. [...]
  3. File "...superlistslistsviews.py", line 5, in home_page
  4. 'new_item_text': request.POST['item_text'],
  5. [...]
  6. django.utils.datastructures.MultiValueDictKeyError: "'item_text'"

看到的是意料之外的失败

如果你记得阅读调用跟踪的方法,就会发现这次失败其实发生在另一个测试中。我们让正在处理的测试通过了,但是这个单元测试却导致了一个意想不到的结果,或者称之为“回归”:破坏了没有 POST 请求时执行的那条代码路径。

这就是测试的要义所在。不错,发生这样的事是可以预料的,但如果运气不好或者没有注意到呢?这时测试就能避免破坏应用功能,而且,因为我们在使用 TDD,所以能立即发现什么地方有问题。无须等待质量保证团队的反馈,也不用打开浏览器自己动手在网站中点来点去,直接就能修正问题。这次失败的修正方法如下:

lists/views.py

  1. def home_page(request):
  2. return render(request, 'home.html', {
  3. 'new_item_text': request.POST.get('item_text', ''),
  4. })

如果不理解这段代码,可以查阅 dict.get 的文档。

这个单元测试现在应该可以通过了。看一下功能测试的结果如何:

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

02 - 图12 如果你现在或者在本章其他地方看到的功能测试报错与这里不同,而是与 StaleElementReferenceException 有关,你可能就需要增加 time.sleep 的显式等待时间了——可以试试把 1 改成 2 或 3。更可靠的解决方法参见下一章。

错误消息没太大帮助。使用另一种功能测试的调试技术:改进错误消息。这或许是最有建设性的技术,因为改进后的错误消息一直存在,可以协助调试以后出现的错误:

functional_tests.py (ch05l011)

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

➊ 你以前可能没有见过这种句法,这是 Python 新的 f 字符串句法(算是 Python 3.6 最令人激动的新特性)。只需在字符串前面加上一个 f,你就能使用花括号插入局部变量。详情请参见 Python 3.6 的版本说明。

改进后,测试给出了更有用的错误消息:

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

知道怎么改效果更好吗?稍微让断言别那么灵巧。你可能还记得,函数 any 让我很满意,但预览版的一个读者(感谢 Jason)建议我使用一种更简单的实现方式,把六行 assertTrue 换成一行 assertIn

functional_tests.py (ch05l012)

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

这样好多了。自作聪明时一定要小心,因为你可能把问题过度复杂化了。修改之后自动获得了下面的错误消息:

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

就当这是我应得的惩罚吧。

02 - 图13 如果功能测试指出的错误是表格为空(not found in []),那就检查一下 标签,看看有没有正确设定 name="item_text" 属性。如果没有这个属性,用户的输入就不能与 request.POST 中正确的键关联。

上述错误消息的意思是,功能测试在枚举列表中的项目时希望第一个项目以“1:”开头。让测试通过最快的方法是修改模板时“作弊”:

lists/templates/home.html

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

“遇红 变绿 重构”和三角法

“单元测试 / 编写代码”循环有时也叫“遇红 变绿 重构”。

  • 先写一个会失败的单元测试(遇红)。
  • 编写尽可能简单的代码让测试通过(变绿),就算作弊也行
  • 重构,改进代码,让其更合理。

那么,在重构阶段应该做些什么呢?如何判断什么时候应该把作弊的代码改成令我们满意的实现方式呢?

一种方法是消除重复:如果测试中使用了神奇常量(例如列表项目前面的“1:”),而且应用代码中也用了这个常量,这就算是重复,此时就应该重构。把神奇常量从应用代码中删掉往往意味着你不能再作弊了。

我觉得这种方法有点不太明确,所以经常使用第二种方法,这种方法叫作“三角法”:如果编写无法让你满意的作弊代码(例如返回一个神奇的常量)就能让测试通过,就再写一个测试,强制自己编写更好的代码。现在就要使用这种方法,扩充功能测试,检查输入的第二个列表项目中是否包含“2:”。

现在功能测试能执行到 self.fail('Finish the test!') 了。如果扩充功能测试,检查表格中添加的第二个待办事项(复制粘贴是好帮手),我们会发现刚才使用的简单处理方式不奏效了:

functional_tests.py

  1. # 页面中还有一个文本框,可以输入其他的待办事项
  2. # 她输入了“Use peacock feathers to make a fly”(使用孔雀羽毛做假蝇)
  3. # 伊迪丝做事很有条理
  4. inputbox = self.browser.find_element_by_id('id_new_item')
  5. inputbox.send_keys('Use peacock feathers to make a fly')
  6. inputbox.send_keys(Keys.ENTER)
  7. time.sleep(1)
  8. # 页面再次更新,清单中显示了这两个待办事项
  9. table = self.browser.find_element_by_id('id_list_table')
  10. rows = table.find_elements_by_tag_name('tr')
  11. self.assertIn('1: Buy peacock feathers', [row.text for row in rows])
  12. self.assertIn(
  13. '2: Use peacock feathers to make a fly',
  14. [row.text for row in rows]
  15. )
  16. # 伊迪丝想知道这个网站是否会记住她的清单
  17. # 她看到网站为她生成了一个唯一的URL
  18. # 页面中有一些文字解说这个功能
  19. self.fail('Finish the test!')
  20. # 她访问那个URL,发现待办事项清单还在

很显然,这个功能测试会返回一个错误:

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

5.4 事不过三,三则重构

在继续之前,先看一下功能测试中的代码异味。1 检查清单表格中新添加的待办事项时,用了三个几乎一样的代码块。编程中有个原则叫作不要自我重复(Don't Repeat Yourself,DRY),按照真言“事不过三,三则重构”的说法,运用这个原则。复制粘贴一次,可能还不用删除重复,但如果复制粘贴了三次,就该删除重复了。

1如果你没遇到过这个概念,我告诉你,“代码异味”表明一段代码需要重写。Jeff Atwood 在他的博客 Coding Horror 中搜集了很多这方面的资料。编程经验越丰富,你的鼻子就会变得越灵敏,能够嗅出代码中的异味。

要先提交目前已编写的代码。虽然网站还有重大瑕疵(只能处理一个待办事项),但仍然取得了一定进展。这些代码可能要全部重写,也可能不用,不管怎样,重构之前一定要提交:

  1. $ git diff
  2. # 会看到functional_tests.py、home.html、tests.py和views.py中的变动
  3. $ git commit -a

然后重构功能测试。可以定义一个行间函数,不过这样会稍微搅乱测试流程,还是用辅助方法吧。记住,只有名字以 test_ 开头的方法才会作为测试运行,可以根据需求使用其他方法。

functional_tests.py

  1. def tearDown(self):
  2. self.browser.quit()
  3. def check_for_row_in_list_table(self, row_text):
  4. table = self.browser.find_element_by_id('id_list_table')
  5. rows = table.find_elements_by_tag_name('tr')
  6. self.assertIn(row_text, [row.text for row in rows])
  7. def test_can_start_a_list_and_retrieve_it_later(self):
  8. [...]

我喜欢把辅助方法放在类的顶部,置于 tearDown 和第一个测试之间。下面在功能测试中使用这个辅助方法:

functional_tests.py

  1. # 她按回车键后,页面更新了
  2. # 待办事项表格中显示了“1: Buy peacock feathers”
  3. inputbox.send_keys(Keys.ENTER)
  4. time.sleep(1)
  5. self.check_for_row_in_list_table('1: Buy peacock feathers')
  6. # 页面中又显示了一个文本框,可以输入其他的待办事项
  7. # 她输入了“Use peacock feathers to make a fly”(使用孔雀羽毛做假蝇)
  8. # 伊迪丝做事很有条理
  9. inputbox = self.browser.find_element_by_id('id_new_item')
  10. inputbox.send_keys('Use peacock feathers to make a fly')
  11. inputbox.send_keys(Keys.ENTER)
  12. time.sleep(1)
  13. # 页面再次更新,她的清单中显示了这两个待办事项
  14. self.check_for_row_in_list_table('1: Buy peacock feathers')
  15. self.check_for_row_in_list_table('2: Use peacock feathers to make a fly')
  16. # 伊迪丝想知道这个网站是否会记住她的清单
  17. [...]

再次运行功能测试,看重构前后的表现是否一致:

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

很好。接下来,提交这次针对功能测试的小重构:

  1. $ git diff # 查看functional_tests.py中的改动
  2. $ git commit -a

继续开发工作。如果要处理不止一个待办事项,需要某种持久化存储,在 Web 应用领域,数据库是一种成熟的解决方案。

5.5 Django ORM和第一个模型

对象关系映射器(Object-Relational Mapper,ORM)是一个数据抽象层,描述存储在数据库中的表、行和列。处理数据库时,可以使用熟悉的面向对象方式,写出更好的代码。在 ORM 的概念中,类对应数据库中的表,属性对应列,类的单个实例表示数据库中的一行数据。

Django 对 ORM 提供了良好的支持,学习 ORM 的绝佳方法是在单元测试中使用它,因为单元测试能按照指定方式使用 ORM。

下面在 lists/tests.py 文件中新建一个类:

lists/tests.py

  1. from lists.models import Item
  2. [...]
  3. class ItemModelTest(TestCase):
  4. def test_saving_and_retrieving_items(self):
  5. first_item = Item()
  6. first_item.text = 'The first (ever) list item'
  7. first_item.save()
  8. second_item = Item()
  9. second_item.text = 'Item the second'
  10. second_item.save()
  11. saved_items = Item.objects.all()
  12. self.assertEqual(saved_items.count(), 2)
  13. first_saved_item = saved_items[0]
  14. second_saved_item = saved_items[1]
  15. self.assertEqual(first_saved_item.text, 'The first (ever) list item')
  16. self.assertEqual(second_saved_item.text, 'Item the second')

由上述代码可以看出,在数据库中创建新记录的过程很简单:先创建一个对象,再为一些属性赋值,然后调用 .save() 函数。Django 提供了一个查询数据库的 API,即类属性 .objects。再使用可能是最简单的查询方法 .all(),取回这个表中的全部记录。得到的结果是一个类似列表的对象,叫 QuerySet。从这个对象中可以提取出单个对象,然后还可以再调用其他函数,例如 .count()。接着,检查存储在数据库中的对象,看保存的信息是否正确。

Django 中的 ORM 有很多有用且直观的功能。现在可能是略读 Django 教程(https://docs.djangoproject.com/en/1.11/intro/tutorial01/)的好时机,这个教程很好地介绍了 ORM 的功能。

02 - 图14 这个单元测试写得很啰唆,因为我想借此介绍 Django ORM。我不建议你在现实中也这么写。第 15 章会重写这个测试,尽可能做到精简。

术语:单元测试和集成测试的区别以及数据库

追求纯粹的人会告诉你,真正的单元测试绝不能涉及数据库操作。我刚编写的测试或许叫作“整合测试”(integrated test)更确切,因为它不仅测试代码,还依赖于外部系统,即数据库。

现在可以忽略这种区别,因为有两种测试,一种是功能测试,从用户的角度出发,站在一定高度上测试应用;另一种从程序员的角度出发,做底层测试。

在第 23 章会再次讨论单元测试和整合测试。

试着运行单元测试。接下来要进入另一次“单元测试 / 编写代码”循环:

  1. ImportError: cannot import name 'Item'

很好。下面在 lists/models.py 中写入一些代码,让它有内容可导入。我们有自信,因此会跳过编写 Item = None 这一步,直接创建类:

lists/models.py

  1. from django.db import models
  2. class Item(object):
  3. pass

这些代码让测试向前进展到了:

  1. first_item.save()
  2. AttributeError: 'Item' object has no attribute 'save'

为了给 Item 类提供 save 方法,也为了让这个类变成真正的 Django 模型,要让它继承 Model 类:

lists/models.py

  1. from django.db import models
  2. class Item(models.Model):
  3. pass

5.5.1 第一个数据库迁移

再次运行测试,会看到一个数据库错误:

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

在 Django 中,ORM 的任务是模型化数据库。创建数据库其实是由另一个系统负责的,叫作迁移(migration)。迁移的任务是,根据你对 models.py 文件的改动情况,添加或删除表和列。

你可以把迁移想象成数据库使用的版本控制系统。后面会看到,把应用部署到线上服务器升级数据库时,迁移十分有用。

现在只需要知道如何创建第一个数据库迁移——使用 makemigrations 命令创建迁移:2

2你可能想知道什么时候应该运行迁移,什么时候应该创建迁移。别急,后面会讲到。

  1. $ python manage.py makemigrations
  2. Migrations for 'lists':
  3. lists/migrations/0001_initial.py
  4. - Create model Item
  5. $ ls lists/migrations
  6. 0001_initial.py __init__.py __pycache__

如果好奇,可以看一下迁移文件中的内容,你会发现,这些内容表明了在 models.py 文件中添加的内容。

与此同时,应该会发现测试又取得了一点进展。

5.5.2 测试向前走得挺远

其实,测试向前走得还挺远:

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

这离上次失败的位置整整八行。在这八行代码中,保存了两个待办事项,检查它们是否存入了数据库。可是,Django 似乎不记得有 .text 属性。

如果你刚接触 Python,可能会觉得意外,为什么一开始能给 .text 属性赋值呢?在 Java 等语言中,本应该得到一个编译错误。但是 Python 要宽松一些。

继承 models.Model 的类映射到数据库中的一个表。默认情况下,这种类会得到一个自动生成的 id 属性,作为表的主键,但是其他列都要自行定义。定义文本字段的方法如下:

lists/models.py

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

Django 提供了很多其他字段类型,例如 IntegerFieldCharFieldDateField 等。使用 TextField 而不用 CharField,是因为后者需要限制长度,但是就目前而言,这个字段的长度是随意的。关于字段类型的更多介绍可以阅读 Django 教程(https://docs.djangoproject.com/en/1.11/intro/tutorial02/#creating-models)和文档(https://docs.djangoproject.com/en/1.11/ref/models/fields/)。

5.5.3 添加新字段就要创建新迁移

运行测试,会看到另一个数据库错误:

  1. django.db.utils.OperationalError: no such column: lists_item.text

出现这个错误的原因是在数据库中添加了一个新字段,所以要再创建一个迁移。测试能告诉我们这一点真是太好了!

创建迁移试试:

  1. $ python manage.py makemigrations
  2. You are trying to add a non-nullable field 'text' to item without a default; we
  3. can't do that (the database needs something to populate existing rows).
  4. Please select a fix:
  5. 1) Provide a one-off default now (will be set on all existing rows with a null
  6. value for this column)
  7. 2) Quit, and let me add a default in models.py
  8. Select an option:2

这个命令不允许添加没有默认值的列。选择第二个选项,然后在 models.py 中设定一个默认值。我想你会发现所用的句法无须过多解释:

lists/models.py

  1. class Item(models.Model):
  2. text = models.TextField(default='')

现在应该可以顺利创建迁移了:

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

在 models.py 中添加了两行新代码,创建了两个数据库迁移,由此得到的结果是,模型对象上的 .text 属性能被识别为一个特殊属性了,因此属性的值能保存到数据库中,测试也能通过了:

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

下面提交创建的第一个模型:

  1. $ git status # 看到tests.py和models.py以及两个没跟踪的迁移文件
  2. $ git diff # 审查tests.py和models.py中的改动
  3. $ git add lists
  4. $ git commit -m "Model for list Items and associated migration"

5.6 把POST请求中的数据存入数据库

接下来,要修改针对首页中 POST 请求的测试。希望视图把新添加的待办事项存入数据库,而不是直接传给响应。为了测试这个操作,要在现有的测试方法 test_can_save_a_POST_request 中添加三行新代码:

lists/tests.py

  1. def test_can_save_a_POST_request(self):
  2. response = self.client.post('/', data={'item_text': 'A new list item'})
  3. self.assertEqual(Item.objects.count(), 1)
  4. new_item = Item.objects.first()
  5. self.assertEqual(new_item.text, 'A new list item')
  6. self.assertIn('A new list item', response.content.decode())
  7. self.assertTemplateUsed(response, 'home.html')

❶ 检查是否把一个新 Item 对象存入数据库。objects.count()objects.all().count() 的简写形式。

objects.first() 等价于 objects.all()[0]

❸ 检查待办事项的文本是否正确。

这个测试变得有点儿长,看起来要测试很多不同的东西。这也是一种代码异味。长的单元测试可以分解成两个,或者表明测试太复杂。我们把这个问题记在自己的待办事项清单中,或许可以写在一张便签 3 上:

3这张便签由几张图片合成。——译者注

02 - 图15

记在便签上就不会忘记。然后在合适的时候再回来解决。再次运行测试,会看到一个预期失败:

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

修改一下视图:

lists/views.py

  1. from django.shortcuts import render
  2. from lists.models import Item
  3. def home_page(request):
  4. item = Item()
  5. item.text = request.POST.get('item_text', '')
  6. item.save()
  7. return render(request, 'home.html', {
  8. 'new_item_text': request.POST.get('item_text', ''),
  9. })

我使用的方法很天真,你或许能发现有一个明显的问题:每次请求首页都保存一个无内容的待办事项。把这个问题记在便签上,稍后再解决。要知道,除了这个明显的严重问题之外,目前还无法为不同的用户创建不同的清单。暂且忽略这些问题。

记住,并不是说在实际的开发中始终要把这种明显的问题忽略。预见到问题时,要做出判断,是停止正在做的事从头再来,还是暂时不管,以后再解决。有时完成手头的工作是可以接受的做法,但有些时候问题可能很严重,必须停下来重新思考。

看一下单元测试的进展如何……通过了,太好了!现在可以做些重构:

lists/views.py

  1. return render(request, 'home.html', {
  2. 'new_item_text': item.text
  3. })

看一下便签,我添加了好几件事:

02 - 图16

先看第一个问题。虽然可以在现有的测试中添加一个断言,但最好让单元测试一次只测试一件事。那么,定义一个新测试方法吧:

lists/tests.py

  1. class HomePageTest(TestCase):
  2. [...]
  3. def test_only_saves_items_when_necessary(self):
  4. self.client.get('/')
  5. self.assertEqual(Item.objects.count(), 0)

这个测试得到的是 1 != 0 失败。下面来修正这个问题。注意,虽然对视图函数的逻辑改动幅度很小,但代码的实现方式有很多细微的变动:

lists/views.py

  1. def home_page(request):
  2. if request.method == 'POST':
  3. new_item_text = request.POST['item_text']
  4. Item.objects.create(text=new_item_text)
  5. else:
  6. new_item_text = ''
  7. return render(request, 'home.html', {
  8. 'new_item_text': new_item_text,
  9. })

❶ 使用一个名为 new_item_text 的变量,其值是 POST 请求中的数据,或者是空字符串。

.objects.create 是创建新 Item 对象的简化方式,无须再调用 .save() 方法。

这样修改之后,测试就通过了:

  1. Ran 4 tests in 0.010s
  2. OK

5.7 处理完POST请求后重定向

可是 new_item_text = '' 还是让我高兴不起来。幸好现在可以顺带解决这个问题。视图函数有两个作用:一是处理用户输入,二是返回适当的响应。前者已经完成了,即把用户的输入保存到数据库中。下面来看后者。

人们都说处理完 POST 请求后一定要重定向,那么接下来就实现这个功能吧。再次修改针对保存 POST 请求数据的单元测试,不让它渲染包含待办事项的响应,而是重定向到首页:

lists/tests.py

  1. def test_can_save_a_POST_request(self):
  2. response = self.client.post('', data={'item_text': 'A new list item'})
  3. self.assertEqual(Item.objects.count(), 1)
  4. new_item = Item.objects.first()
  5. self.assertEqual(new_item.text, 'A new list item')
  6. self.assertEqual(response.status_code, 302)
  7. self.assertEqual(response['location'], '')

不需要再拿响应中的 .content 属性值和渲染模板得到的结果比较,因此把相应的断言删掉了。现在,响应是 HTTP 重定向,状态码是 302,让浏览器指向一个新地址。

修改之后运行测试,得到的结果是 200 != 302 错误。现在可以大幅度清理视图函数了:

lists/views.py(ch05l028)

  1. from django.shortcuts import redirect, render
  2. from lists.models import Item
  3. def home_page(request):
  4. if request.method == 'POST':
  5. Item.objects.create(text=request.POST['item_text'])
  6. return redirect('/')
  7. return render(request, 'home.html')

现在,测试应该可以通过了:

  1. Ran 4 tests in 0.010s
  2. OK

更好的单元测试实践方法:一个测试只测试一件事

现在视图函数处理完 POST 请求后会重定向,这是习惯做法,而且单元测试也一定程度上缩短了,不过还可以做得更好。

良好的单元测试实践方法要求,一个测试只能测试一件事。因为这样便于查找问题。如果一个测试中有多个断言,一旦前面的断言导致测试失败,就无法得知后面的断言情况如何。下一章会看到,如果不小心破坏了视图函数,我们想知道到底是保存对象时出错了,还是响应的类型不对。

刚开始可能无法写出只有一个断言的完美单元测试,不过现在似乎是把正在开发的功能分开测试的好机会:

lists/tests.py

  1. def test_can_save_a_POST_request(self):
  2. self.client.post('', data={'item_text': 'A new list item'})
  3. self.assertEqual(Item.objects.count(), 1)
  4. new_item = Item.objects.first()
  5. self.assertEqual(new_item.text, 'A new list item')
  6. def test_redirects_after_POST(self):
  7. response = self.client.post('', data={'item_text': 'A new list item'})
  8. self.assertEqual(response.status_code, 302)
  9. self.assertEqual(response['location'], '/')

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

  1. Ran 5 tests in 0.010s
  2. OK

5.8 在模板中渲染待办事项

感觉好多了!再看待办事项清单。

02 - 图17

把问题从清单上划掉几乎和看着测试通过一样让人满足。

第三个问题是最后一个容易解决的问题。要编写一个新单元测试,检查模板是否也能显示多个待办事项:

lists/tests.py

  1. class HomePageTest(TestCase):
  2. [...]
  3. def test_displays_all_list_items(self):
  4. Item.objects.create(text='itemey 1')
  5. Item.objects.create(text='itemey 2')
  6. response = self.client.get('/')
  7. self.assertIn('itemey 1', response.content.decode())
  8. self.assertIn('itemey 2', response.content.decode())

02 - 图18 看到测试中的空行了吗?我把设置测试的开头两行放在一起,中间放一行,调用要测试的代码,最后再下断言。这不是强制要求,但是有助于分清测试的结构。设置 - 使用 - 断言,这是单元测试的典型结构。

这个测试和预期一样会失败:

  1. AssertionError: 'itemey 1' not found in '<html>\n <head>\n [...]

Django 的模板句法中有一个用于遍历列表的标签,即 {% for .. in .. %}。可以按照下面的方式使用这个标签:

lists/templates/home.html

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

这是模板系统的主要优势之一。现在模板会渲染多个 行,每一行对应 items 变量中的一个元素。这么写很优雅!后文我还会介绍更多 Django 模板的魔力,但总有一天你要阅读 Django 文档,学习模板的其他用法。

只修改模板还不能让测试通过,还要在首页的视图中把待办事项传入模板:

lists/views.py

  1. def home_page(request):
  2. if request.method == 'POST':
  3. Item.objects.create(text=request.POST['item_text'])
  4. return redirect('/')
  5. items = Item.objects.all()
  6. return render(request, 'home.html', {'items': items})

这样单元测试就能通过了。关键时刻到了,功能测试能通过吗?

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

很显然不能。要使用另一种功能测试调试技术,也是最直观的一种:手动访问网站。在浏览器中打开 http://localhost:8000,你会看到一个 Django 调试页面,提示“no such table: lists_item”(没有这个表:lists_item),如图 5-2 所示。

{%}

图 5-2:又一个很有帮助的调试信息

5.9 使用迁移创建生产数据库

又是一个 Django 生成的很有帮助的错误消息,大意是说没有正确设置数据库。你可能会问:“为什么在单元测试中一切都运行良好呢?”这是因为 Django 为单元测试创建了专用的测试数据库——这是 Django 中 TestCase 所做的神奇事情之一。

为了设置好真正的数据库,要创建一个数据库。SQLite 数据库只是硬盘中的一个文件。你会在 Django 的 settings.py 文件中发现,默认情况下,Django 把数据库保存为 db.sqlite3,放在项目的基目录中:

superlists/settings.py

  1. [...]
  2. # Database
  3. # https://docs.djangoproject.com/en/1.11/ref/settings/#databases
  4. DATABASES = {
  5. 'default': {
  6. 'ENGINE': 'django.db.backends.sqlite3',
  7. 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
  8. }
  9. }

我们已经在 models.py 文件和后来创建的迁移文件中告诉 Django 创建数据库所需的一切信息,为了创建真正的数据库,要使用 Django 中另一个强大的 manage.py 命令——migrate

  1. $ python manage.py migrate
  2. Operations to perform:
  3. Apply all migrations: admin, auth, contenttypes, lists, sessions
  4. Running migrations:
  5. Applying contenttypes.0001_initial... OK
  6. Applying auth.0001_initial... OK
  7. Applying admin.0001_initial... OK
  8. Applying admin.0002_logentry_remove_auto_add... OK
  9. Applying contenttypes.0002_remove_content_type_name... OK
  10. Applying auth.0002_alter_permission_name_max_length... OK
  11. Applying auth.0003_alter_user_email_max_length... OK
  12. Applying auth.0004_alter_user_username_opts... OK
  13. Applying auth.0005_alter_user_last_login_null... OK
  14. Applying auth.0006_require_contenttypes_0002... OK
  15. Applying auth.0007_alter_validators_add_error_messages... OK
  16. Applying auth.0008_alter_user_username_max_length... OK
  17. Applying lists.0001_initial... OK
  18. Applying lists.0002_item_text... OK
  19. Applying sessions.0001_initial... OK

现在,可以刷新 localhost 上的页面了,你会发现错误页面不见了 4。然后再运行功能测试试试:

4如果你看到了另一个错误页面,重启开发服务器试试。Django 可能被发生在眼皮子底下的事情搞糊涂了。

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

快成功了,只需要让清单显示正确的序号即可。另一个出色的 Django 模板标签 forloop.counter 能帮忙解决这个问题:

lists/templates/home.html

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

再试一次,应该会看到功能测试运行到最后了:

  1. self.fail('Finish the test!')
  2. AssertionError: Finish the test!

不过运行测试时,你可能会注意到有什么地方不对劲,如图 5-3 所示。

{%}

图 5-3:有上一次运行测试时遗留下来的待办事项

哦,天呐。看起来上一次运行测试时在数据库中遗留了数据。如果再次运行测试,会发现待办事项又多了:

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

啊,离成功就差一点点了。需要一种自动清理机制。你可以手动清理,方法是先删除数据库再执行 migrate 命令新建:

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

清理之后要确保功能测试仍能通过。

除了功能测试中这个小问题之外,我们的代码基本上都可以正常运行了。下面做一次提交吧。

先执行 git status,再执行 git diff,你应该会看到对 home.html、tests.py 和 views.py 所做的改动。然后提交这些改动:

  1. $ git add lists
  2. $ git commit -m "Redirect after POST, and show all items in template"

02 - 图21 你可能会觉得在每一章结束时做个标记很有用,例如在本章结束时可以这么做:git tag end-of-chapter-05

5.10 回顾

这一章我们做了什么呢?

  • 编写了一个表单,使用 POST 请求把新待办事项添加到清单中。
  • 创建了一个简单的数据库模型,用来存储待办事项。
  • 学习了如何创建数据库迁移,既针对测试数据库(自动运行),也针对真实的数据库(手动运行)。
  • 用到了两个 Django 模板标签:{% csrf_token %}{% for … endfor %} 循环。
  • 至少用到了三种功能测试调试技术:行间 print 语句、time.sleep 以及改进错误消息。

但待办事项清单中还有两件事没做,其中一项是“功能测试运行完毕后清理战场”,还有一项或许更紧急——“支持多个清单”。

02 - 图22

我想说的是,虽然网站现在这个样子可以发布,但用户可能会觉得奇怪:为什么所有人要共用一个待办事项清单。我想这会让人们停下来思考一些问题:我们彼此之间有怎样的联系?在地球这艘宇宙飞船上有着怎样的共同命运?要如何团结起来解决共同面对的全球性问题?

可实际上,这个网站还没什么用。

先这样吧。

有用的 TDD 概念

  • 回归

    新添加的代码破坏了应用原本可以正常使用的功能。

  • 意外失败

    测试在意料之外失败了。这意味着测试中有错误,或者测试帮我们发现了一个回归,因此要在代码中修正。

  • 遇红 变绿 重构 描述TDD 流程的另一种方式。先编写一个测试看着它失败(遇红),然后编写代码让测试通过(变绿),最后重构,改进实现方式。

  • 三角法

    添加一个测试,专门为某些现有的代码编写用例,以此推断出普适的实现方式(在此之前的实现方式可能作弊了)。

  • 事不过三,三则重构

    判断何时删除重复代码时使用的经验法则。如果两段代码很相似,往往还要等到第三段相似代码出现,才能确定重构时哪一部分是真正共通、可重用的。

  • 记在便签上的待办事项清单

    在便签上记录编写代码过程中遇到的问题,等手头的工作完成后再回过头来解决。

第 6 章 改进功能测试:确保隔离,去掉含糊的休眠

在深入分析和解决真正的问题之前,先来做些清理工作。前一章末尾指出,两次运行的测试之间会彼此影响,那就来修正这个问题吧。此外,我对代码中多次出现的 time.sleep 也不满意;这样做似乎有点不科学,我们将采用更可靠的方式实现。

02 - 图23

这两项改动都向着测试“最佳实践”迈进,能让测试更确定、更可靠。

6.1 确保功能测试之间相互隔离

前一章结束时留下了一个典型的测试问题:如何隔离测试。运行功能测试后待办事项一直存在于数据库中,这会影响下次测试的结果。

运行单元测试时,Django 的测试运行程序会自动创建一个全新的测试数据库(和应用真正使用的数据库不同),运行每个测试之前都会清空数据库,等所有测试都运行完之后,再删除这个数据库。但是功能测试目前使用的是应用真正使用的数据库 db.sqlite3。

这个问题的解决方法之一是自己动手,在 functional_tests.py 中添加执行清理任务的代码。这样的任务最适合在 setUptearDown 方法中完成。

不过从 1.4 版开始,Django 提供的一个新类 LiveServerTestCase 可以代我们完成这一任务。这个类会自动创建一个测试数据库(跟单元测试一样),并启动一个开发服务器,让功能测试在其中运行。虽然这个工具有一定局限性(稍后解决),不过在现阶段十分有用。下面学习如何使用。

LiveServerTestCase 必须使用 manage.py,由 Django 的测试运行程序运行。从 Django 1.6 开始,测试运行程序查找所有名字以 test 开头的文件。为了保持文件结构清晰,要新建一个文件夹保存功能测试,让它看起来就像一个应用。Django 对这个文件夹的要求只有一个——必须是有效的 Python 模块,即文件夹中要有一个 init.py 文件。

  1. $ mkdir functional_tests
  2. $ touch functional_tests/__init__.py

然后要移动功能测试,把独立的 functional_tests.py 文件移到 functional_tests 应用中,并把它重命名为 tests.py。使用 git mv 命令完成这个操作,让 Git 知道文件移动了:

  1. $ git mv functional_tests.py functional_tests/tests.py
  2. $ git status # 显示文件重命名为functional_tests/tests.py,而且新增了__init__.py

现在的目录结果如下所示:

  1. .
  2. ├── db.sqlite3
  3. ├── functional_tests
  4. ├── __init__.py
  5. └── tests.py
  6. ├── lists
  7. ├── admin.py
  8. ├── apps.py
  9. ├── __init__.py
  10. ├── migrations
  11. ├── 0001_initial.py
  12. ├── 0002_item_text.py
  13. ├── __init__.py
  14. └── __pycache__
  15. ├── models.py
  16. ├── __pycache__
  17. ├── templates
  18. └── home.html
  19. ├── tests.py
  20. └── views.py
  21. ├── manage.py
  22. └── superlists
  23. ├── __init__.py
  24. ├── __pycache__
  25. ├── settings.py
  26. ├── urls.py
  27. └── wsgi.py

functional_tests.py 不见了,变成了 functional_tests/tests.py。现在,运行功能测试不执行 python functional_tests.py 命令,而是使用 python manage.py test functional_tests 命令。

02 - 图24 功能测试可以和 lists 应用测试混在一起,不过我喜欢把两种测试分开,因为功能测试检测的功能往往存在于不同应用中。功能测试以用户的视角看待事物,而用户并不关心你如何把网站分成不同的应用。

接下来编辑 functional_tests/tests.py,修改 NewVisitorTest 类,让它使用 LiveServerTestCase

functional_tests/tests.py (ch06l001)

  1. from django.test import LiveServerTestCase
  2. from selenium import webdriver
  3. from selenium.webdriver.common.keys import Keys
  4. import time
  5. class NewVisitorTest(LiveServerTestCase):
  6. def setUp(self):
  7. [...]

继续往下修改。访问网站时,不用硬编码的本地地址(localhost:8000),可以使用 LiveServerTestCase 提供的 live_server_url 属性:

functional_tests/tests.py (ch06l002)

  1. def test_can_start_a_list_and_retrieve_it_later(self):
  2. # 伊迪丝听说有一个很酷的在线待办事项应用
  3. # 她去看了这个应用的首页
  4. self.browser.get(self.live_server_url)

还可以删除文件末尾的 if __name__ == '__main__' 代码块,因为之后都使用 Django 的测试运行程序运行功能测试。

现在能使用 Django 的测试运行程序运行功能测试了,指明只运行 functional_tests 应用中的测试:

  1. $ python manage.py test functional_tests
  2. Creating test database for alias 'default'...
  3. F
  4. ======================================================================
  5. FAIL: test_can_start_a_list_and_retrieve_it_later
  6. (functional_tests.tests.NewVisitorTest)
  7. ---------------------------------------------------------------------
  8. Traceback (most recent call last):
  9. File "...superlists/functional_tests/tests.py", line 65, in
  10. test_can_start_a_list_and_retrieve_it_later
  11. self.fail('Finish the test!')
  12. AssertionError: Finish the test!
  13.  
  14. ---------------------------------------------------------------------
  15. Ran 1 test in 6.578s
  16.  
  17. FAILED (failures=1)
  18. System check identified no issues (0 silenced).
  19. Destroying test database for alias 'default'...

功能测试和重构前一样,能运行到 self.fail。如果再次运行测试,你会发现,之前的测试不再遗留待办事项了,因为功能测试运行完之后把它们清理掉了。成功了,现在应该提交这次小改动:

  1. $ git status # 重命名并修改了functional_tests.py,新增了__init__.py
  2. $ git add functional_tests
  3. $ git diff --staged -M
  4. $ git commit # 提交消息举例:"make functional_tests an app, use LiveServerTestCase"

git diff 命令中的 -M 标志很有用,意思是“检测移动”,所以 git 会注意到 functional_tests.py 和 functional_tests/tests.py 是同一个文件,显示更合理的差异(去掉这个旗标试试)。

只运行单元测试

现在,如果执行 manage.py test 命令,Django 会运行功能测试和单元测试:

  1. $ python manage.py test
  2. Creating test database for alias 'default'...
  3. ......F
  4. ======================================================================
  5. FAIL: test_can_start_a_list_and_retrieve_it_later
  6. [...]
  7. AssertionError: Finish the test!
  8.  
  9. ---------------------------------------------------------------------
  10. Ran 7 tests in 6.732s
  11.  
  12. FAILED (failures=1)

如果只想运行单元测试,可以指定只运行 lists 应用中的测试:

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

有用的命令(更新版)

  • 运行功能测试

    1. python manage.py test functional_tests
  • 运行单元测试

    1. python manage.py test lists

如果我说“运行测试”,而你不确定我指的是哪一种测试怎么办?可以回顾一下第 4 章最后一节中的流程图,试着找出我们处在哪一步。通常只在所有单元测试都通过后才会运行功能测试。如果不清楚,两种测试都运行试试吧!

6.2 升级Selenium和Geckodriver

今天再次检查这一章时,我发现功能测试停在那里不动了。

后来我才发现,是因为 Firefox 在夜里自动更新了,而 Selenium 和 Geckodriver 也要随之升级。Geckodriver 的发布页面也证实确实有新版发布了。所以,我们要按照下述步骤进行下载和升级。

  • 执行 pip install --upgrade selenium 命令。
  • 下载新版 Geckodriver。
  • 备份旧版,放在某处,把新版放在 PATH 中的某个位置。
  • 执行 geckodriver --version 命令,确认新版是否可用。

升级之后,功能测试又能按预期运行了。

在此处讲解这个问题没有特殊的原因。当你阅读到这里时也不一定会遇到这个问题,但是你总会遇到的。再加上我们又在做清理工作,所以我觉得现在是最适合的时机。

这是使用 Selenium 时你必须忍受的一件事。虽然可以固定所用的浏览器和 Selenium 版本(例如在 CI 服务器上),但是现实中的浏览器是不断进化的,你要跟上用户的步伐。

02 - 图25 只要发现功能测试遇到奇怪的问题,就可以升级 Selenium 试试。

回到常规的编程上来。

6.3 隐式等待、显式等待和含糊的time.sleep

来看看功能测试中的 time.sleep

functional_tests/tests.py

  1. # 她按回车键后,页面更新了
  2. # 待办事项清单中显示了“1: Buy peacock feathers”
  3. inputbox.send_keys(Keys.ENTER)
  4. time.sleep(1)
  5. self.check_for_row_in_list_table('1: Buy peacock feathers')

这叫“显式等待”,与之相对的是“隐式等待”:某些情况下,当 Selenium 认为页面正在加载时,它会“自动”等待一会儿。如果要在页面中查找的元素尚未出现,还可以使用 Selenium 提供的 implicitly_wait 方法指明要等多久。

其实本书第 1 版完全依赖隐式等待。但是,隐式等待有点奇怪,而且从 Selenium 3 开始,隐式等待变得极度不可靠。此外,Selenium 团队普遍认为隐式等待不是个好主意,应该避免使用。

因此,第 2 版从一开始就使用显式等待。但问题是,time.sleep 自身也有问题。我们现在等待了 1 秒,但谁又能说这是合理的时间呢?对于在自己的设备上运行的多数测试来说,1 秒太长了,严重拖慢了功能测试,等待 0.1 秒就行了。但问题是,如果真等待这么短的时间,常常会导致测试假失败——因为在某些情况下,笔记本电脑的速度可能稍慢一些。话说回来,即便等待了 1 秒,也无法绝对避免不是由真正的问题导致的失败。测试中的假阳性确实烦人(关于这一话题的详细讨论参见 Martin Fowler 写的一篇文章,名为“Eradicating Non-Determinism in Tests”)。

02 - 图26 如果遇到意料之外的 NoSuchElementExceptionStaleElementException 错误,通常就表明没有显式等待。去掉 time.sleep 试试看会不会出现这样的错误。

下面调整休眠的实现方式,让测试等待足够长的时间,以便捕获可能出现的问题。我们将 check_for_row_in_list_table 重命名为 wait_for_row_in_list_table,并添加一些轮询 / 重试逻辑:

functional_tests/tests.py (ch06l004)

  1. from selenium.common.exceptions import WebDriverException
  2. MAX_WAIT = 10
  3. [...]
  4. def wait_for_row_in_list_table(self, row_text):
  5. start_time = time.time()
  6. while True:
  7. try:
  8. table = self.browser.find_element_by_id('id_list_table')
  9. rows = table.find_elements_by_tag_name('tr')
  10. self.assertIn(row_text, [row.text for row in rows])
  11. return
  12. except (AssertionError, WebDriverException) as e:
  13. if time.time() - start_time > MAX_WAIT:
  14. raise e
  15. time.sleep(0.5)

❶ 通过 MAX_WAIT 常量设定准备等待的最长时间。10 秒应该足够捕获潜在的问题或不可预知的缓慢因素了。

❷ 这个循环一直运行,直到遇到两个出口中的一个为止。

❸ 这个三行断言跟这个方法的旧版一样。

❹ 如果能顺利运行而且断言通过了,就退出函数、跳出循环。

❺ 但如果捕获到了异常,就再等一小段时间,然后重新循环。我们要捕获两种异常:一种是 WebDriverException,在页面未加载或 Selenium 未在页面上找到表格元素时抛出;另一种是 AssertionError,因为页面中虽有表格,但它可能在页面重新加载之前就存在,里面还是没有我们要找的行。

❻ 这是第二个出口。如果执行到这里,说明代码不断抛出异常,已经超时。因此这里再次抛出异常,向上冒泡,最终可能出现在调用跟踪中,指明测试失败的原因。

是不是觉得这段代码有点蹩脚、有点不明所以?我同意。后文会做重构,定义一个通用的 wait_for 辅助方法,把计时、重新抛出异常的逻辑与测试断言分开。不过,这要等到需要在多个地方使用它时再做。

02 - 图27 如果你以前用过 Selenium,可能知道它提供了一些用于等待的辅助函数。我不太喜欢使用这些函数。本书将构建几个用于等待的辅助工具,我觉得这样能让代码更优雅、更易于阅读。不过,你绝对应该学习一下 Selenium 自带的那些辅助函数,然后判断要不要使用。

下面调用新的方法,并把含糊的 time.sleep 去掉:

functional_tests/tests.py (ch06l005)

  1. [...]
  2. # 她按回车键后,页面更新了
  3. # 待办事项清单中显示了“1: Buy peacock feathers”
  4. inputbox.send_keys(Keys.ENTER)
  5. self.wait_for_row_in_list_table('1: Buy peacock feathers')
  6. # 页面中还有一个文本框,可以输入其他的待办事项
  7. # 她输入了“Use peacock feathers to make a fly”(使用孔雀羽毛做假蝇)
  8. # 伊迪丝做事很有条理
  9. inputbox = self.browser.find_element_by_id('id_new_item')
  10. inputbox.send_keys('Use peacock feathers to make a fly')
  11. inputbox.send_keys(Keys.ENTER)
  12. # 页面再次更新,清单中显示了这两个待办事项
  13. self.wait_for_row_in_list_table('2: Use peacock feathers to make a fly')
  14. self.wait_for_row_in_list_table('1: Buy peacock feathers')
  15. [...]

然后再次运行测试:

  1. $ python manage.py test
  2. Creating test database for alias 'default'...
  3. ......F
  4. ======================================================================
  5. FAIL: test_can_start_a_list_and_retrieve_it_later
  6. (functional_tests.tests.NewVisitorTest)
  7. ---------------------------------------------------------------------
  8. Traceback (most recent call last):
  9. File "...superlists/functional_tests/tests.py", line 73, in
  10. test_can_start_a_list_and_retrieve_it_later
  11. self.fail('Finish the test!')
  12. AssertionError: Finish the test!
  13.  
  14. ---------------------------------------------------------------------
  15. Ran 7 tests in 4.552s
  16.  
  17. FAILED (failures=1)
  18. System check identified no issues (0 silenced).
  19. Destroying test database for alias 'default'...

结果跟之前一样,不过测试的执行时间比之前短了几秒。虽然现在只快了一点,但是随着测试的增多,速度优势便会慢慢显现。

为了确认我们做得对不对,下面将故意破坏测试,看看会出现什么错误。首先看一下能否检测永远不会出现在一行里的文本:

functional_tests/tests.py (ch06l006)

  1. rows = table.find_elements_by_tag_name('tr')
  2. self.assertIn('foo', [row.text for row in rows])
  3. return

我们将看到一个意思明确的测试失败消息:

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

改回去,然后再破坏其他地方:

functional_tests/tests.py (ch06l007)

  1. try:
  2. table = self.browser.find_element_by_id('id_nothing')
  3. rows = table.find_elements_by_tag_name('tr')
  4. self.assertIn(row_text, [row.text for row in rows])
  5. return
  6. [...]

显然,得到的错误指明页面中没有要查找的元素:

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

看起来一切正常。改回原样,最后再运行测试确认一下:

  1. $ python manage.py test
  2. [...]
  3. AssertionError: Finish the test!

很好。短暂离题之后,下面回到正轨,说明如何实现多个清单。

本章运用的测试“最佳实践”

  • 确保测试隔离,管理全局状态

    不同的测试之间不能彼此影响,也就是说每次测试结束后都要还原永久状态。Django 的测试运行程序可以帮助我们创建测试数据库,这个数据库在每次测试结束后都会清空(详情请参见第 23 章)。

  • 避免使用“含糊的”休眠

    一旦需要等待什么加载,我们的第一反应便是使用 time.sleep。但是这样做带来的问题是,时间的长度是两眼一抹黑:要么太短,容易导致假失败;要么太长,会拖慢测试。我推荐使用重试循环,它可以轮询应用,尽早向前行进。

  • 不要依赖Selenium的隐式等待

    Selenium 确实有理论上的“隐式”等待,但是在不同浏览器上的实现各不相同。而且在写作本书时,隐式等待在 Selenium 3 的 Firefox 驱动上极度不可靠。Python 之禅说道:“明了胜于晦涩”,因此首选显式等待。

第 7 章 步步为营

现在就来解决当前存在的问题。目前,我们的设计只允许创建一个全局清单。这一章将说明一个关键的 TDD 技术:如何使用递增的步进式方法修改现有代码,而且保证代码在修改前后都能正常运行。我们要做测试山羊,而不是重构猫。

7.1 必要时做少量的设计

请想一想应该如何支持多个清单。目前的功能测试(与设计文档最接近)是这样的:

functional_tests/tests.py

  1. # 伊迪丝想知道这个网站是否会记住她的清单
  2. # 她看到网站为她生成了一个唯一的URL
  3. # 页面中有一些解说这个功能的文字
  4. self.fail('Finish the test!')
  5. # 她访问那个URL,发现待办事项清单还在
  6. # 她很满意,去睡觉了

不过,我们要在此基础上扩展一下,让用户不能相互查看各自的清单,而且每个用户都有自己的 URL,能访问自己的清单。怎么实现这样的设计呢?

7.1.1 不要预先做大量设计

TDD 和软件开发中的敏捷运动联系紧密。敏捷运动反对传统软件工程实践中预先做大量设计的做法,因为除了要花费大量时间收集需求之外,设计阶段还要用等量的时间在纸上规划软件。敏捷理念则认为,在实践中解决问题比理论分析能学到更多,而且让应用尽早接受真实用户的检验效果更好。无须花这么多时间提前设计,而要尽早把最简可用的应用放出来,根据实际使用中得到的反馈逐步向前推进设计。

这并不是说要完全禁止思考设计。前一章我们看到,不经思考呆头呆脑往前走,最终也能找到正确答案,不过稍微思考一下设计往往能帮助我们更快地找到答案。那么,下面分析一下这个最简可用的应用,想想应该使用哪种设计方式。

  • 想让每个用户都能保存自己的清单,目前来说,至少能保存一个清单。
  • 清单由多个待办事项组成,待办事项的主要属性应该是一些描述性文字。
  • 要保存清单,以便多次访问。现在,可以为用户提供一个唯一的 URL,指向他们的清单。以后或许需要一种自动识别用户的机制,然后把他们的清单显示出来。

为了实现第一条,看样子要把清单和其中的待办事项存入数据库。每个清单都有一个唯一的 URL,而且清单中的每个待办事项都是一些描述性文字,和所在的清单关联。

7.1.2 YAGNI

关于设计的思考一旦开始就很难停下来,我们会冒出各种想法:或许想给每个清单起个名字或加个标题,或许想使用用户名和密码识别用户,或许想给清单添加一个较长的备注和简短的描述,或许想存储某种顺序,等等。但是,要遵守敏捷理念的另一个信条:“YAGNI”(读作 yag-knee)。它是“You ain't gonna need it”(你不需要这个)的简称。作为软件开发者,我们从创造事物中获得乐趣。有时我们冒出一个想法,觉得可能需要,便无法抵御内心的冲动想要开发出来。可问题是,不管想法有多好,大多数情况下最终你都用不到这个功能。应用中会残留很多没用的代码,还增加了应用的复杂度。YAGNI 是个真言,可以用来抵御热切的创造欲。

7.1.3 REST(式)

我们已经知道怎么处理数据结构,即使用“模型 - 视图 - 控制器”中的模型部分。那视图和控制器部分怎么办呢?在 Web 浏览器中用户怎么处理清单和待办事项呢?

“表现层状态转化”(representational state transfer,REST)是 Web 设计的一种方式,经常用来引导基于 Web 的 API 设计。设计面向用户的网站时,不必严格遵守 REST 规则,可是从中能得到一些启发。(如果想看看真实的 REST API 是什么样子,可以跳到附录 F。)

REST 建议 URL 结构匹配数据结构,即这个应用中的清单和其中的待办事项。清单有各自的 URL:

  1. lists<list identifier>/

这个 URL 满足了功能测试中提出的需求。若想查看某个清单,我们可以发送一个 GET 请求(就是在普通的浏览器中访问这个页面)。

若想创建全新的清单,可以向一个特殊的 URL 发送 POST 请求:

  1. listsnew

若想在现有的清单中添加一个新待办事项,我们可以向另外一个 URL 发送 POST 请求:

  1. lists<list identifier>/add_item

(再次说明,我们不会严格遵守 REST 规则,只是从中得到启发。比如,按照 REST 规则,这里应该使用 PUT 请求,但是标准的 HTML 表单无法发送 PUT 请求。)

概括起来,本章的便签如下所示:

02 - 图28

7.2 使用TDD实现新设计

应该如何使用 TDD 实现新的设计呢?再回顾一下 TDD 的流程图,如图 7-1 所示。

02 - 图29

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

在流程的外层,既要添加新功能(添加新的功能测试,再编写新的应用代码),也要重构应用的代码,即重写部分现有的实现,保持应用的功能不变,但使用新的设计方式。我们通过现有的功能测试确保不破坏现有的功能,同时通过新功能测试驱动开发新功能。

在单元测试层,要添加新测试或者修改现有的测试以检查想改动的功能,没改动的测试则用来保证这个过程没有破坏现有的功能。

7.3 确保出现回归测试

下面根据便签上的待办事项编写一个新的功能测试方法,引入第二个用户,并确认他的待办事项清单与伊迪丝的清单是分开的。

这个功能测试的开头与第一个功能测试基本一样——伊迪丝提交第一个待办事项后,应用创建一个新清单。不过这次要多添加一个断言,确认伊迪丝的清单是独立的,有唯一的 URL:

functional_tests/tests.py (ch07l005)

  1. def test_can_start_a_list_for_one_user(self):
  2. # 伊迪丝听说有一个很酷的在线待办事项应用
  3. # 她去看了这个应用的首页
  4. [...]
  5. # 页面再次更新,她的清单中显示了这两个待办事项
  6. self.wait_for_row_in_list_table('2: Use peacock feathers to make a fly')
  7. self.wait_for_row_in_list_table('1: Buy peacock feathers')
  8. # 她很满意,然后去睡觉了
  9. def test_multiple_users_can_start_lists_at_different_urls(self):
  10. # 伊迪丝新建一个待办事项清单
  11. self.browser.get(self.live_server_url)
  12. inputbox = self.browser.find_element_by_id('id_new_item')
  13. inputbox.send_keys('Buy peacock feathers')
  14. inputbox.send_keys(Keys.ENTER)
  15. self.wait_for_row_in_list_table('1: Buy peacock feathers')
  16. # 她注意到清单有个唯一的URL
  17. edith_list_url = self.browser.current_url
  18. self.assertRegex(edith_list_url, 'lists.+')

assertRegexunittest 提供的辅助函数,用于检查字符串是否匹配正则表达式。我们利用它检查新的 REST 式设计能否实现。详情请参见 unittest 的文档。

然后,我们假设有一个新用户正在访问网站。当新用户访问首页时,要测试他不能看到伊迪丝的待办事项,而且他的清单有自己的唯一 URL:

functional_tests/tests.py (ch07l006)

  1. [...]
  2. self.assertRegex(edith_list_url, 'lists.+')
  3. # 现在一名叫作弗朗西斯的新用户访问了网站
  4. ## 我们使用一个新浏览器会话 ➊
  5. ## 确保伊迪丝的信息不会从cookie中泄露出去
  6. self.browser.quit()
  7. self.browser = webdriver.Firefox()
  8. # 弗朗西斯访问首页
  9. # 页面中看不到伊迪丝的清单
  10. self.browser.get(self.live_server_url)
  11. page_text = self.browser.find_element_by_tag_name('body').text
  12. self.assertNotIn('Buy peacock feathers', page_text)
  13. self.assertNotIn('make a fly', page_text)
  14. # 弗朗西斯输入一个新待办事项,新建一个清单
  15. # 他不像伊迪丝那样兴趣盎然
  16. inputbox = self.browser.find_element_by_id('id_new_item')
  17. inputbox.send_keys('Buy milk')
  18. inputbox.send_keys(Keys.ENTER)
  19. self.wait_for_row_in_list_table('1: Buy milk')
  20. # 弗朗西斯获得了他的唯一URL
  21. francis_list_url = self.browser.current_url
  22. self.assertRegex(francis_list_url, 'lists.+')
  23. self.assertNotEqual(francis_list_url, edith_list_url)
  24. # 这个页面还是没有伊迪丝的清单
  25. page_text = self.browser.find_element_by_tag_name('body').text
  26. self.assertNotIn('Buy peacock feathers', page_text)
  27. self.assertIn('Buy milk', page_text)
  28. # 两人都很满意,然后去睡觉了

➊ 按照习惯,我使用两个 # 表示“元注释”。元注释的作用是说明测试的工作方式以及为什么这么做。使用两个井号是为了和功能测试中解说用户故事的常规注释区分开。这个元注释是发给未来自己的消息,如果没有这条消息,到时你可能会觉得奇怪,想知道到底为什么要退出浏览器再启动一个新会话。

除了元注释之外,就不需要对这个新测试多做解释了。看一下运行功能测试后的情况如何:

  1. $ python manage.py test functional_tests
  2. [...]
  3. .F
  4. ======================================================================
  5. FAIL: test_multiple_users_can_start_lists_at_different_urls
  6. (functional_tests.tests.NewVisitorTest)
  7. ---------------------------------------------------------------------
  8. Traceback (most recent call last):
  9. File "...superlists/functional_tests/tests.py", line 83, in
  10. test_multiple_users_can_start_lists_at_different_urls
  11. self.assertRegex(edith_list_url, 'lists.+')
  12. AssertionError: Regex didn't match: 'lists.+' not found in
  13. 'http://localhost:8081/'
  14. ---------------------------------------------------------------------
  15. Ran 2 tests in 5.786s
  16. FAILED (failures=1)

很好,第一个测试仍能通过,而第二个测试也如我们所料失败了。先提交一次,然后再编写一些新模型和新视图:

  1. $ git commit -a

7.4 逐步迭代,实现新设计

我太兴奋了,迫切地想实现新设计,这种欲望太强烈,谁也无法阻拦,我真想现在就开始修改 models.py。但这么做可能会导致一半的单元测试失败,我们不得不一行一行修改代码,而且要一次改完,工作量太大。有这样的冲动很自然,但 TDD 理念一直反对这么做。我们要遵从测试山羊的教诲,不能听信重构猫的谗言。无须一次性实现光鲜亮丽的整个设计,改动的幅度要小一些,每一步都要遵照设计思想的指引,保证修改后应用仍能正常运行。

在待办事项清单中还有四个问题没解决。无法匹配正则表达式的那个功能测试提醒我们,接下来要解决的是第二个问题,即为每个清单添加唯一的 URL 和标识符。下面解决且只解决这个问题。

清单的 URL 出现在重定向 POST 请求之后。在文件 lists/tests.py 中,找到 test_redirects_after_POST,修改重定向期望转向的地址:

lists/tests.py

  1. self.assertEqual(response.status_code, 302)
  2. self.assertEqual(response['location'], 'liststhe-only-list-in-the-world/')

这个 URL 看起来是不是有点儿奇怪?在应用的最终设计中显然不会使用 liststhe-only-list-in- the-world/ 这个 URL。可是我们承诺过,一次只做一项改动,既然应用现在只支持一个清单,那这就是唯一合理的 URL。我们还在向前进,到时候清单和首页的地址都会变,这是更符合 REST 式设计的一个实现步骤。稍后我们会支持多个清单,也会提供简单的方法修改 URL。

02 - 图30 我们可以换种想法,把这看成是一种解决问题的技术:新的 URL 设计还没实现,所以这个 URL 可用于没有待办事项的清单。最终要设法解决包含 n 个待办事项的清单,不过解决包含一个待办事项的清单是个好的开始。

运行单元测试,会看到一个预期失败:

  1. $ python manage.py test lists
  2. [...]
  3. AssertionError: '/' != 'liststhe-only-list-in-the-world/'

可以修改文件 lists/views.py 中的 home_page 视图:

lists/views.py

  1. def home_page(request):
  2. if request.method == 'POST':
  3. Item.objects.create(text=request.POST['item_text'])
  4. return redirect('liststhe-only-list-in-the-world/')
  5. items = Item.objects.all()
  6. return render(request, 'home.html', {'items': items})

这么修改,功能测试显然会失败,因为网站中并没有这个 URL。毫无疑问,如果运行功能测试,你会看到测试在尝试提交第一个待办事项后失败,提示无法找到显示清单的表格。出现这个错误的原因是,/the-only-list-in-the-world/ 这个 URL 还不存在。

  1. File "...superlists/functional_tests/tests.py", line 57, in
  2. test_can_start_a_list_for_one_user
  3. [...]
  4. selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
  5. element: [id="id_list_table"]
  6. [...]
  7. File "...superlists/functional_tests/tests.py", line 79, in
  8. test_multiple_users_can_start_lists_at_different_urls
  9. self.wait_for_row_in_list_table('1: Buy peacock feathers')
  10. [...]
  11. selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
  12. element: [id="id_list_table"]

不仅新添加的测试失败了,原来那个也失败了。这表明出现了回归。接下来就为这个唯一的清单提供一个 URL,重回正常状态。

7.5 自成一体的第一步:新的URL

打开 lists/tests.py,添加一个新测试类,命名为 ListViewTest。然后把 HomePageTest 类中的 test_displays_all_list_items 方法复制到这个新类中。给这个方法重新命名,再做些修改:

lists/tests.py (ch07l009)

  1. class ListViewTest(TestCase):
  2. def test_displays_all_items(self):
  3. Item.objects.create(text='itemey 1')
  4. Item.objects.create(text='itemey 2')
  5. response = self.client.get('liststhe-only-list-in-the-world/')
  6. self.assertContains(response, 'itemey 1')
  7. self.assertContains(response, 'itemey 2')

➊ 这里用到一个新的辅助方法:现在不必再使用有点儿烦人的 assertInresponse.content.decode() 了,Django 提供了 assertContains 方法,它知道如何处理响应以及响应内容中的字节。

运行这个测试,看看情况:

  1. self.assertContains(response, 'itemey 1')
  2. [...]
  3. AssertionError: 404 != 200 : Couldn't retrieve content: Response code was 404

这是使用 assertContains 的附加好处——它直接指出测试失败的原因是新 URL 不存在,而且返回的是 404 响应。

7.5.1 一个新URL

现在那个唯一的清单 URL 还不存在,要在 superlists/urls.py 中解决这个问题。

02 - 图31 留意 URL 末尾的斜线,在测试中和 urls.py 中都要小心,因为这个斜线往往就是问题的根源。

superlists/urls.py

  1. urlpatterns = [
  2. url(r'^$', views.home_page, name='home'),
  3. url(r'^lists/the-only-list-in-the-world/$', views.view_list, name='view_list'),
  4. ]

再次运行测试,得到的结果如下:

  1. AttributeError: module 'lists.views' has no attribute 'view_list'

7.5.2 一个新视图函数

这个结果无须过多解说。下面在 lists/views.py 中定义一个新视图函数:

lists/views.py

  1. def view_list(request):
  2. pass

现在测试的结果变成了:

  1. ValueError: The view lists.views.view_list didn't return an HttpResponse
  2. object. It returned None instead.
  3. [...]
  4. FAILED (errors=1)

失败的只有一个了,而且为我们指明了方向。把 home_page 视图的最后两行复制过来,看能否骗过测试:

lists/views.py

  1. def view_list(request):
  2. items = Item.objects.all()
  3. return render(request, 'home.html', {'items': items})

再次运行单元测试,测试应该能通过了:

  1. Ran 7 tests in 0.016s
  2. OK

再运行功能测试,看看情况如何:

  1. FAIL: test_can_start_a_list_for_one_user
  2. [...]
  3. File "...superlists/functional_tests/tests.py", line 67, in
  4. test_can_start_a_list_for_one_user
  5. [...]
  6. AssertionError: '2: Use peacock feathers to make a fly' not found in ['1: Buy
  7. peacock feathers']
  8. FAIL: test_multiple_users_can_start_lists_at_different_urls
  9. [...]
  10. AssertionError: 'Buy peacock feathers' unexpectedly found in 'Your To-Do
  11. list\n1: Buy peacock feathers'
  12. [...]

两个功能测试都有一点进展,不过依然失败了。我们要尽快重回正常状态,让第一个功能测试再次通过。失败消息中有什么线索呢?

可以看出,失败发生在尝试添加第二个待办事项时——看来得调试一番了。我们知道首页是正常的,因为功能测试能执行到第 67 行,也就是至少添加了一个待办事项。而且,单元测试都能通过,因此可以确定 URL 和视图能正常运作——首页使用正确的模板显示、能处理 POST 请求,only-list-in-the-world 视图知道如何显示所有待办事项……但是它不知道怎样处理 POST 请求。啊,这就是线索。

根据经验,第二个线索是,当所有单元测试都能通过而功能测试不能通过时,问题通常是由单元测试没有覆盖的事物引起的——这往往是模板的问题。

最终我们找到了问题的根源:home.html 中的输入表单没有明确指定 POST 的目标 URL。

lists/templates/home.html

  1. <form method="POST">

默认情况下,浏览器把 POST 数据发回表单当前所在的 URL。这样的话,在首页能正常运行,但到 only-list-in-the-world 页面就不行了。

找到根源后,我们本可以为新视图添加处理 POST 请求的功能,但是这样还得编写一堆测试和代码,而我们的目的是尽早重回正常状态。其实,修正这个问题最快速的方法是使用现在能正常运行的首页视图处理所有 POST 请求:

lists/templates/home.html

  1. <form method="POST" action="/">

再次运行测试,你会发现功能测试回到之前的状态了:

  1. FAIL: test_multiple_users_can_start_lists_at_different_urls
  2. [...]
  3. AssertionError: 'Buy peacock feathers' unexpectedly found in 'Your To-Do
  4. list\n1: Buy peacock feathers'
  5. Ran 2 tests in 8.541s
  6. FAILED (failures=1)

原先的测试再次通过,由此可以确认我们又回到了正常状态。新功能也许还不可用,但至少旧的功能依旧正常。

7.6 变绿了吗?该重构了

该清理一下测试了。

遇红 变绿 重构流程中,已经走到“变绿”这一步,接下来该重构了。现在我们有两个视图,一个用于首页,一个用于单个清单。目前,这两个视图共用一个模板,而且传入了数据库中的所有待办事项。如果仔细查看单元测试中的方法,或许会发现某些部分需要修改:

  1. $ grep -E "class|def" lists/tests.py
  2. class HomePageTest(TestCase):
  3. def test_uses_home_template(self):
  4. def test_displays_all_list_items(self):
  5. def test_can_save_a_POST_request(self):
  6. def test_redirects_after_POST(self):
  7. def test_only_saves_items_when_necessary(self):
  8. class ListViewTest(TestCase):
  9. def test_displays_all_items(self):
  10. class ItemModelTest(TestCase):
  11. def test_saving_and_retrieving_items(self):

完全可以把 HomePageTest 中的 test_displays_all_list_items 方法删除,因为不需要了。如果现在执行 manage.py test lists 命令,应该会看到运行了 6 个测试,而不是 7 个:

  1. Ran 6 tests in 0.016s
  2. OK

而且,首页模板其实不用再显示所有的待办事项,而应该只显示一个输入框让用户新建清单。

7.7 再迈一小步:一个新模板,用于查看清单

既然首页和清单视图是不同的页面,它们就应该使用不同的 HTML 模板。home.html 可以只包含一个输入框,新模板 list.html 则在表格中显示现有的待办事项。

下面添加一个新测试,检查是否使用了不同的模板:

lists/tests.py

  1. class ListViewTest(TestCase):
  2. def test_uses_list_template(self):
  3. response = self.client.get('liststhe-only-list-in-the-world/')
  4. self.assertTemplateUsed(response, 'list.html')
  5. def test_displays_all_items(self):
  6. [...]

assertTemplateUsed 是 Django 测试客户端提供的强大方法之一。看一下测试的结果如何:

  1. AssertionError: False is not true : Template 'list.html' was not a template
  2. used to render the response. Actual template(s) used: home.html

很好!然后修改视图:

lists/views.py

  1. def view_list(request):
  2. items = Item.objects.all()
  3. return render(request, 'list.html', {'items': items})

不过很显然,这个模板还不存在。如果运行单元测试,会得到如下结果:

  1. django.template.exceptions.TemplateDoesNotExist: list.html

新建一个文件,保存为 lists/templates/list.html:

  1. $ touch lists/templates/list.html

这个模板是空的,测试会显示如下错误——幸好有测试,我们才不会忘记输入内容:

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

单个清单的模板会用到目前 home.html 中的很多代码,所以可以先把其中的内容复制过来:

  1. $ cp lists/templates/home.html lists/templates/list.html

这会让测试再次通过(变绿)。现在要做一些清理工作(重构)。我们说过,首页不用显示待办事项,只放一个新建清单的输入框就行。因此,可以删除 lists/templates/home.html 中的一些代码,或许还可以把 h1 改成“Start a new To-Do list”:

lists/templates/home.html

  1. <body>
  2. <h1>Start a new To-Do list</h1>
  3. <form method="POST">
  4. <input name="item_text" id="id_new_item" placeholder="Enter a to-do item" >
  5. {% csrf_token %}
  6. <form>
  7. </body>

再次运行测试,确认这次改动没有破坏任何功能。很好,继续清理。

其实也不用在 home_page 视图中把全部待办事项都传入 home.html 模板,因此可以把 home_page 视图简化成:

lists/views.py

  1. def home_page(request):
  2. if request.method == 'POST':
  3. Item.objects.create(text=request.POST['item_text'])
  4. return redirect('liststhe-only-list-in-the-world/')
  5. return render(request, 'home.html')

再次运行单元测试,它们仍然能通过。然后运行功能测试:

  1. AssertionError: '1: Buy milk' not found in ['1: Buy peacock feathers', '2: Buy
  2. milk']

不错,回归测试(第一个功能测试)通过了,而且新增的测试稍微向前进展了一点——可以指出弗朗西斯没有得到自己的清单页面(因为他仍能看到伊迪丝的部分待办事项)。

你可能觉得并没有取得太多进展,因为网站的功能和本章开始时几乎一模一样。其实有进展,我们正在实现新设计,在前进的道路上铺好了几块垫脚石,而且网站的功能几乎没变。提交目前取得的进展:

  1. $ git status # 会看到4个改动的文件和1个新文件list.html
  2. $ git add lists/templates/list.html
  3. $ git diff # 会看到我们简化了home.html
  4. # 把一个测试移到了lists/tests.py中的新类里
  5. # 在views.py中添加了一个新视图
  6. # 还简化了home_page视图,并在urls.py中增加了一个映射
  7. $ git commit -a # 编写一个消息概述以上操作,或许可以写成
  8. #“new URL, view and template to display lists”

7.8 第三小步:用于添加待办事项的URL

看一下待办事项清单,现在到哪一步了呢?

02 - 图32

第二个问题已经取得了一定进展,不过网站中还是只有一个清单。第一个问题有点吓人。我们能对第三或第四个问题做些什么呢?

下面添加一个新 URL,用于新建待办事项。这么做至少能简化首页视图。

7.8.1 用来测试新建清单的测试类

打开文件 lists/tests.py,把 test_can_save_a_POST_requesttest_redirects_after_POST 两个方法到一个新类中,然后再修改 POST 请求的目标 URL:

lists/tests.py (ch07l021-1)

  1. class NewListTest(TestCase):
  2. def test_can_save_a_POST_request(self):
  3. self.client.post('listsnew', data={'item_text': 'A new list item'})
  4. self.assertEqual(Item.objects.count(), 1)
  5. new_item = Item.objects.first()
  6. self.assertEqual(new_item.text, 'A new list item')
  7. def test_redirects_after_POST(self):
  8. response = self.client.post('listsnew', data={'item_text': 'A new list item'})
  9. self.assertEqual(response.status_code, 302)
  10. self.assertEqual(response['location'], 'liststhe-only-list-in-the-world/')

02 - 图33 顺便说一句,这里也要注意末尾的斜线——/new 后面不加斜线。我的习惯是,不在修改数据库的“操作”后加斜线。

顺便学习一个新的 Django 测试客户端方法 assertRedirects

lists/tests.py (ch07l021-2)

  1. def test_redirects_after_POST(self):
  2. response = self.client.post('listsnew', data={'item_text': 'A new list item'})
  3. self.assertRedirects(response, 'liststhe-only-list-in-the-world/')

这个方法没什么大用,不过能把两个断言精简成一个。

运行这个测试试试:

  1. self.assertEqual(Item.objects.count(), 1)
  2. AssertionError: 0 != 1
  3. [...]
  4. self.assertRedirects(response, 'liststhe-only-list-in-the-world/')
  5. [...]
  6. AssertionError: 404 != 302 : Response didn't redirect as expected: Response
  7. code was 404 (expected 302)

第一个失败消息告诉我们,新建的待办事项没有存入数据库。第二个失败消息指出视图返回的状态码是 404,而不是表示重定向的 302。这是因为还没把 listsnew 添加到 URL 映射中,所以 client.post 得到的是“not found”(未找到)响应。

02 - 图34 还记得之前我们是如何把这种测试分成两个测试方法的吗?如果在一个测试方法中同时测试保存数据和重定向,看到的失败消息就是 0 != 1,调试起来更难。如果你好奇我是怎么知道要这么做的,不要犹豫,问我吧。