13.4.1 模板标签{% url %}

可以把 home.html 中硬编码的 URL 换成一个 Django 模板标签,再引用 URL 的“名字”:

lists/templates/home.html (ch11l036-1)

  1. {% block form_action %}{% url 'new_list' %}{% endblock %}

然后确认改动之后不会导致单元测试失败:

  1. $ python manage.py test lists
  2. OK

继续修改其他模板。传入了一个参数,所以这一个更有趣:

lists/templates/list.html (ch11l036-2)

  1. {% block form_action %}{% url 'view_list' list.id %}{% endblock %}

详情请阅读 Django 文档中对 URL 反向解析的介绍。

再次运行测试,确保都能通过:

  1. $ python manage.py test lists
  2. OK
  3. $ python manage.py test functional_tests
  4. OK

太棒了,做次提交:

  1. $ git commit -am "Refactor hard-coded URLs out of templates"

04 - 图1

13.4.2 重定向时使用get_absolute_url

下面来处理 views.py。在这个文件中去除硬编码的 URL,可以使用和模板一样的方法——写入 URL 的名字和一个位置参数:

lists/views.py (ch11l036-3)

  1. def new_list(request):
  2. [...]
  3. return redirect('view_list', list_.id)

修改之后单元测试和功能测试仍能通过,但是 redirect 函数的作用远比这强大。在 Django 中,每个模型对象都对应一个特定的 URL,因此可以定义一个特殊的函数,命名为 get_absolute_url,其作用是获取显示单个模型对象的页面 URL。这个函数在这里很有用,在 Django 管理后台(本书不会介绍管理后台,但稍后你可以自己学习)也很有用:

在后台查看一个对象时可以直接跳到前台显示该对象的页面。如果有必要,我总是建议在模型中定义 get_absolute_url 函数,这花不了多少时间。

先在 test_models.py 中编写一个超级简单的单元测试:

lists/tests/test_models.py (ch11l036-4)

  1. def testgetabsolute_url(self):
  2. list_ = List.objects.create()
  3. self.assertEqual(list_.get_absolute_url(), f'lists{list_.id}/')

得到的测试结果是:

  1. AttributeError: 'List' object has no attribute 'get_absolute_url'

实现这个函数时要使用 Django 中的 reverse 函数。reverse 函数的功能和 Django 对 urls.py 所做的操作相反。

lists/models.py (ch11l036-5)

  1. from django.core.urlresolvers import reverse
  2. class List(models.Model):
  3. def get_absolute_url(self):
  4. return reverse('view_list', args=[self.id])

现在可以在视图中使用 get_absolute_url 函数了——只需把重定向的目标对象传给 redirect 函数即可,redirect 函数会自动调用 get_absolute_url 函数。

lists/views.py (ch11l036-6)

  1. def new_list(request):
  2. [...]
  3. return redirect(list_)

更多信息参见 Django 文档。快速确认一下单元测试是否仍能通过:

  1. OK

然后使用同样的方法修改 view_list 视图:

lists/views.py (ch11l036-7)

  1. def view_list(request, list_id):
  2. [...]
  3. item.save()
  4. return redirect(list_)
  5. except ValidationError:
  6. error = "You can't have an empty list item"

分别运行全部单元测试和功能测试,确保一切仍能正常运行:

  1. $ python3 manage.py test lists
  2. OK
  3. $ python3 manage.py test functional_tests
  4. OK

把已解决问题从便签上划掉:

04 - 图2

提交一次:

  1. $ git commit -am "Use get_absolute_url on List model to DRY urls in views"

这一阶段结束!我们添加了模型层验证,而且在此过程中借机重构了几个地方。

最后一个待办事项是下一章的话题。

关于数据库层验证

我喜欢尽量把验证逻辑放在低层。

  • 数据库层验证是数据完整性的最终保障

    不管数据库层之上的各层代码有多么复杂,在最低层验证能保证数据是有效的,而且是一致的。

  • 但是数据库层验证有失灵活性

    优点往往都伴随着缺点。添加数据库层验证之后,就无法得到不一致的数据了,即便想暂时这么做也不可能。但我们有时就需要存储暂时破坏这些规则的数据(例如在很多阶段可能想从外部源导入数据),毕竟有数据总比没数据强。

  • 对用户不太友好

    尝试存储无效数据会导致数据库返回不友善的IntegrityError,这可能会让用户看到令人困惑的 500 错误页面。后面的章节会讲到,表单层验证考虑到了用户,不会直接报错,而是显示友好的错误消息。

第 14 章 简单的表单

前一章结尾提到,视图中处理验证的代码有太多重复。Django 鼓励使用表单类验证用户的输入,以及选择显示错误消息。本章介绍如何使用这种功能。

除此之外本章还会花点时间整理单元测试,确保一个单元测试一次只测试一件事。

14.1 把验证逻辑移到表单中

04 - 图3 在 Django 中,视图很复杂就说明有代码异味。你要想,能否把逻辑移到表单或模型类的方法中,或者把业务逻辑移到 Django 之外的模型中?

Django 中的表单功能很多很强大。

  • 可以处理用户输入,并验证输入值是否有错误。
  • 可以在模板中使用,用来渲染 HTML input 元素和错误消息。
  • 稍后会见识到,某些表单甚至还可以把数据存入数据库。

没必要在每个表单中都使用这三种功能。你可以自己编写表单的 HTML,或者自己处理数据存储,但表单是放置验证逻辑的绝佳位置。

14.1.1 使用单元测试探索表单API

我们要在一个单元测试中实验表单的用法。我的计划是逐步迭代,最终得到一个完整的解决方案。希望在这个过程中能由浅入深地介绍表单,即便你以前从未用过也能理解。

首先,新建一个文件,用于编写表单的单元测试。先编写一个测试方法,检查表单的 HTML:

lists/tests/test_forms.py

  1. from django.test import TestCase
  2. from lists.forms import ItemForm
  3. class ItemFormTest(TestCase):
  4. def test_form_renders_item_text_input(self):
  5. form = ItemForm()
  6. self.fail(form.as_p())

form.as_p() 的作用是把表单渲染成 HTML。这个单元测试使用 self.fail 探索性编程。在 manage.py shell 会话中探索编程也很容易,不过每次修改代码之后都要重新加载。

下面编写一个极简的表单,继承自基类 Form,只有一个字段 item_text

lists/forms.py

  1. from django import forms
  2. class ItemForm(forms.Form):
  3. item_text = forms.CharField()

运行测试后会看到一个失败消息,告诉我们自动生成的表单 HTML 是什么样:

  1. self.fail(form.as_p())
  2. AssertionError: <p><label for="id_item_text">Item text:</label> <input
  3. type="text" name="item_text" required id="id_item_text" ><p>

自动生成的 HTML 已经和 base.html 中的表单 HTML 很接近了,只不过没有 placeholder 属性和 Bootstrap 的 CSS 类。再编写一个单元测试方法,检查 placeholder 属性和 CSS 类:

lists/tests/test_forms.py

  1. class ItemFormTest(TestCase):
  2. def test_form_item_input_has_placeholder_and_css_classes(self):
  3. form = ItemForm()
  4. self.assertIn('placeholder="Enter a to-do item"', form.as_p())
  5. self.assertIn('class="form-control input-lg"', form.as_p())

这个测试会失败,表明我们需要真正地编写一些代码了。应该怎么定制表单字段的内容呢?答案是使用 widget 参数。加入 placeholder 属性的方法如下:

lists/forms.py

  1. class ItemForm(forms.Form):
  2. item_text = forms.CharField(
  3. widget=forms.fields.TextInput(attrs={
  4. 'placeholder': 'Enter a to-do item',
  5. }),
  6. )

修改之后测试的结果为:

  1. AssertionError: 'class="form-control input-lg"' not found in '<p><label
  2. for="id_item_text">Item text:</label> <input type="text" name="item_text"
  3. placeholder="Enter a to-do item" required id="id_item_text" ><p>'

继续修改:

lists/forms.py

  1. widget=forms.fields.TextInput(attrs={
  2. 'placeholder': 'Enter a to-do item',
  3. 'class': 'form-control input-lg',
  4. }),

04 - 图4 如果表单中的内容很多或者很复杂,使用 widget 参数定制很麻烦,此时可以借助 django-crispy-forms 和 django-floppyforms。

开发驱动测试:使用单元测试探索性编程

上述过程是不是有点像开发驱动测试?偶尔这么做其实没问题。

探索新 API 时,完全可以先抛开规则的束缚,然后再回到严格的 TDD 流程中。你可以使用交互式终端,或者编写一些探索性代码(不过你要答应测试山羊,稍后会删掉这些代码,然后使用合理的方式重写)。

其实,现在我们只是使用单元测试试验表单API,这是学习如何使用 API 的好方法。

14.1.2 换用Django中的ModelForm

接下来呢?我们希望表单重用已经在模型中定义好的验证规则。Django 提供了一个特殊的类,用来自动生成模型的表单,这个类是 ModelForm。从下面的代码能看出,我们要使用一个特殊的属性 Meta 配置表单:

lists/forms.py

  1. from django import forms
  2. from lists.models import Item
  3. class ItemForm(forms.models.ModelForm):
  4. class Meta:
  5. model = Item
  6. fields = ('text',)

我们在 Meta 中指定这个表单用于哪个模型,以及要使用哪些字段。

ModelForms 很智能,能完成各种操作,例如为不同类型的字段生成合适的 input 类型,以及应用默认的验证。详情参见文档(https://docs.djangoproject.com/en/1.11/topics/forms/modelforms/)。

现在表单的 HTML 不一样了:

  1. AssertionError: 'placeholder="Enter a to-do item"' not found in '<p><label
  2. for="id_text">Text:</label> <textarea name="text" cols="40" rows="10" required
  3. id="id_text">\n</textarea></p>'

placeholder 属性和 CSS 类都不见了,而且 name="item_text" 变成了 name="text"。这些变化能接受,但普通的输入框变成了 textarea,这可不是应用 UI 想要的效果。幸好,和普通的表单类似,ModelForm 的字段也能使用 widget 参数定制:

lists/forms.py

  1. class ItemForm(forms.models.ModelForm):
  2. class Meta:
  3. model = Item
  4. fields = ('text',)
  5. widgets = {
  6. 'text': forms.fields.TextInput(attrs={
  7. 'placeholder': 'Enter a to-do item',
  8. 'class': 'form-control input-lg',
  9. }),
  10. }

定制后测试通过了。

14.1.3 测试和定制表单验证

现在我们看一下 ModelForm 是否应用了模型中定义的验证规则。我们还会学习如何把数据传入表单,就像用户输入的一样:

lists/tests/test_forms.py (ch11l008)

  1. def test_form_validation_for_blank_items(self):
  2. form = ItemForm(data={'text': ''})
  3. form.save()

测试的结果为:

  1. ValueError: The Item could not be created because the data didn't validate.

很好,如果提交空待办事项,表单不会保存数据。

现在看一下表单能否显示指定的错误消息。在尝试保存数据之前检查验证是否通过的 API 是 is_valid 函数:

lists/tests/test_forms.py (ch11l009)

  1. def test_form_validation_for_blank_items(self):
  2. form = ItemForm(data={'text': ''})
  3. self.assertFalse(form.is_valid())
  4. self.assertEqual(
  5. form.errors['text'],
  6. ["You can't have an empty list item"]
  7. )

调用 form.is_valid() 得到的返回值是 TrueFalse,不过还有个附带效果,即验证输入的数据,生成 errors 属性。errors 是个字典,把字段的名字映射到该字段的错误列表上(一个字段可以有多个错误)。

测试的结果为:

  1. AssertionError: ['This field is required.'] != ["You can't have an empty list
  2. item"]

Django 已经为显示给用户查看的错误消息提供了默认值。急着开发 Web 应用的话,可以直接使用默认值。不过我们比较在意,想让错误消息特殊一些。定制错误消息可以修改 Meta 的另一个变量,error_messages

lists/forms.py (ch11l010)

  1. class Meta:
  2. model = Item
  3. fields = ('text',)
  4. widgets = {
  5. 'text': forms.fields.TextInput(attrs={
  6. 'placeholder': 'Enter a to-do item',
  7. 'class': 'form-control input-lg',
  8. }),
  9. }
  10. error_messages = {
  11. 'text': {'required': "You can't have an empty list item"}
  12. }

然后测试即可通过:

  1. OK

知道如何避免让这些错误消息搅乱代码吗?使用常量:

lists/forms.py (ch11l011)

  1. EMPTY_ITEM_ERROR = "You can't have an empty list item"
  2. [...]
  3. error_messages = {
  4. 'text': {'required': EMPTY_ITEM_ERROR}
  5. }

再次运行测试,确认能通过。好的,然后修改测试:

lists/tests/test_forms.py (ch11l012)

  1. from lists.forms import EMPTY_ITEM_ERROR, ItemForm
  2. [...]
  3. def test_form_validation_for_blank_items(self):
  4. form = ItemForm(data={'text': ''})
  5. self.assertFalse(form.is_valid())
  6. self.assertEqual(form.errors['text'], [EMPTY_ITEM_ERROR])

修改之后测试仍能通过:

  1. OK

很好。提交:

  1. $ git status # 会看到lists/forms.py和tests/test_forms.py
  2. $ git add lists
  3. $ git commit -m "new form for list items"

14.2 在视图中使用这个表单

一开始我想继续编写这个表单,除了空值验证之外再捕获唯一性验证错误。不过,精益理论中的“尽早部署”有个推论,即“尽早合并代码”。也就是说,编写表单可能要花很多时间,不断添加各种功能——我知道这一点是因为我在撰写本章草稿时就是这么做的,做了各种工作,得到一个功能完善的表单类,但发布应用后才发现大多数功能实际并不需要。

因此,要尽早试用新编写的代码。这么做能避免编写用不到的代码,还能尽早在现实的环境中检验代码。

我们编写了一个表单类,它可以渲染一些 HTML,而且至少能验证一种错误——下面就来使用这个表单吧!既然可以在 base.html 模板中使用这个表单,那么在所有视图中都可以使用。

14.2.1 在处理GET请求的视图中使用这个表单

先修改首页视图的单元测试。我们要编写一个新测试,检查使用的表单类型是否正确:

lists/tests/test_views.py (ch11l013)

  1. from lists.forms import ItemForm
  2. class HomePageTest(TestCase):
  3. def test_uses_home_template(self):
  4. [...]
  5. def test_home_page_uses_item_form(self):
  6. response = self.client.get('/')
  7. self.assertIsInstance(response.context['form'], ItemForm)

assertIsInstance 检查表单是否属于正确的类。

测试的结果为:

  1. KeyError: 'form'

因此,要在首页视图中使用这个表单:

lists/views.py (ch11l014)

  1. [...]
  2. from lists.forms import ItemForm
  3. from lists.models import Item, List
  4. def home_page(request):
  5. return render(request, 'home.html', {'form': ItemForm()})

好了,下面尝试在模板中使用这个表单——把原来的 替换成 {{ form.text }}

lists/templates/base.html (ch11l015)

  1. <form method="POST" action="{% block form_action %}{% endblock %}">
  2. {{ form.text }}
  3. {% csrf_token %}
  4. {% if error %}
  5. <div class="form-group has-error">

{{ form.text }} 只会渲染这个表单中的 text 字段,生成 HTML input 元素。

14.2.2 大量查找和替换

前文我们修改了表单,idname 属性的值变了。运行功能测试时你会看到,首次尝试查找输入框时测试失败了:

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

我们得修正这个问题,为此要进行大量查找和替换。在此之前先提交,把重命名和逻辑变动区分开:

  1. $ git diff # 审查base.html、views.py及其测试中的改动
  2. $ git commit -am "use new form in home_page, simplify tests. NB breaks stuff"

下面来修正功能测试。通过 grep 命令,我们得知有很多地方都使用了 id_new_item

  1. $ grep id_new_item functional_tests/test*
  2. functional_tests/test_layout_and_styling.py: inputbox =
  3. self.browser.find_element_by_id('id_new_item')
  4. functional_tests/test_layout_and_styling.py: inputbox =
  5. self.browser.find_element_by_id('id_new_item')
  6. functional_tests/test_list_item_validation.py:
  7. self.browser.find_element_by_id('id_new_item').send_keys(Keys.ENTER)
  8. [...]

这表明我们要重构。在 base.py 中定义一个新辅助方法:

functional_tests/base.py (ch11l018)

  1. class FunctionalTest(StaticLiveServerTestCase):
  2. [...]
  3. def get_item_input_box(self):
  4. return self.browser.find_element_by_id('id_text')

然后所有需要替换的地方都使用这个辅助方法——test_simple_list_creation.py 修改四处, test_layout_and_styling.py 修改两处,test_list_item_validation.py 修改四处。例如:

functional_tests/test_simple_list_creation.py

  1. # 应用邀请她输入一个待办事项
  2. inputbox = self.get_item_input_box()

以及:

functional_tests/test_list_item_validation.py

  1. # 输入框中没输入内容,她就按下了回车键
  2. self.browser.get(self.live_server_url)
  3. self.get_item_input_box().send_keys(Keys.ENTER)

我不会列出每一处,相信你自己能搞定!你可以再执行一遍 grep,看是不是全都改了。

第一步完成了,接下来还要修改应用代码。我们要找到所有旧的 idid_new_item)和 nameitem_text),分别替换成 id_texttext

  1. $ grep -r id_new_item lists/
  2. listsstaticbase.css:#id_new_item {

只要改动一处。使用类似的方法查看 name 出现的位置:

  1. $ grep -Ir item_text lists
  2. [...]
  3. lists/views.py: item = Item(text=request.POST['item_text'], list=list_)
  4. lists/views.py: item = Item(text=request.POST['item_text'],
  5. list=list_)
  6. lists/tests/test_views.py: self.client.post('listsnew',
  7. data={'item_text': 'A new list item'})
  8. lists/tests/test_views.py: response = self.client.post('listsnew',
  9. data={'item_text': 'A new list item'})
  10. [...]
  11. lists/tests/test_views.py: data={'item_text': ''}
  12. [...]

改完之后再运行单元测试,确保一切仍能正常运行:

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

然后还要运行功能测试:

  1. $ python manage.py test functional_tests
  2. [...]
  3. File "...superlists/functional_tests/test_simple_list_creation.py", line
  4. 37, in test_can_start_a_list_for_one_user
  5. return self.browser.find_element_by_id('id_text')
  6. File "...superlists/functional_tests/base.py", line 51, in
  7. get_item_input_box
  8. return self.browser.find_element_by_id('id_text')
  9. [...]
  10. selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
  11. element: [id="id_text"]
  12. [...]
  13. FAILED (errors=3)

不能全部通过。确认一下发生错误的位置——查看其中一个失败所在的行号,你会发现,每次提交第一个待办事项后,清单页面都不会显示输入框。

查看 views.py 和 new_list 视图后我们找到了原因——如果检测到有验证错误,根本就不会把表单传入 home.html 模板:

lists/views.py

  1. except ValidationError:
  2. list_.delete()
  3. error = "You can't have an empty list item"
  4. return render(request, 'home.html', {"error": error})

我们也想在这个视图中使用 ItemForm 表单。继续修改之前,先提交:

  1. $ git status
  2. $ git commit -am "rename all item input ids and names. still broken"

14.3 在处理POST请求的视图中使用这个表单

现在要调整 new_list 视图的单元测试,更确切地说,要修改针对验证的那个测试方法。先看一下这个测试方法:

lists/tests/test_views.py

  1. class NewListTest(TestCase):
  2. [...]
  3. def test_validation_errors_are_sent_back_to_home_page_template(self):
  4. response = self.client.post('listsnew', data={'text': ''})
  5. self.assertEqual(response.status_code, 200)
  6. self.assertTemplateUsed(response, 'home.html')
  7. expected_error = escape("You can't have an empty list item")
  8. self.assertContains(response, expected_error)

14.3.1 修改new_list视图的单元测试

首先,这个测试方法测试的内容太多了,所以借此机会可以清理一下。我们应该把这个测试方法分成两个不同的断言。

  • 如果有验证错误,应该渲染首页模板,并且返回 200 响应。
  • 如果有验证错误,响应中应该包含错误消息。

此外,还可以添加一个新断言。

  • 如果有验证错误,应该把表单对象传入模板。

不用硬编码错误消息字符串,而要使用一个常量:

lists/tests/test_views.py (ch11l023)

  1. from lists.forms import ItemForm, EMPTY_ITEM_ERROR
  2. [...]
  3. class NewListTest(TestCase):
  4. [...]
  5. def test_for_invalid_input_renders_home_template(self):
  6. response = self.client.post('listsnew', data={'text': ''})
  7. self.assertEqual(response.status_code, 200)
  8. self.assertTemplateUsed(response, 'home.html')
  9. def test_validation_errors_are_shown_on_home_page(self):
  10. response = self.client.post('listsnew', data={'text': ''})
  11. self.assertContains(response, escape(EMPTY_ITEM_ERROR))
  12. def test_for_invalid_input_passes_form_to_template(self):
  13. response = self.client.post('listsnew', data={'text': ''})
  14. self.assertIsInstance(response.context['form'], ItemForm)

现在好多了,每个测试方法只测试一件事。如果幸运的话,只有一个测试会失败,而且会告诉我们接下来做什么:

  1. $ python manage.py test lists
  2. [...]
  3. ======================================================================
  4. ERROR: test_for_invalid_input_passes_form_to_template
  5. (lists.tests.test_views.NewListTest)
  6. ---------------------------------------------------------------------
  7. Traceback (most recent call last):
  8. File "...superlistsliststests/test_views.py", line 49, in
  9. test_for_invalid_input_passes_form_to_template
  10. self.assertIsInstance(response.context['form'], ItemForm)
  11. [...]
  12. KeyError: 'form'
  13.  
  14. ---------------------------------------------------------------------
  15. Ran 19 tests in 0.041s
  16.  
  17. FAILED (errors=1)

14.3.2 在视图中使用这个表单

在视图中使用这个表单的方法如下:

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. Item.objects.create(text=request.POST['text'], list=list_)
  6. return redirect(list_)
  7. else:
  8. return render(request, 'home.html', {"form": form})

❶ 把 request.POST 中的数据传给表单的构造方法。

❷ 使用 form.is_valid() 判断提交是否成功。

❸ 如果提交失败,把表单对象传入模板,而不显示一个硬编码的错误消息字符串。

视图现在看起来更完美了。而且除了一个测试之外,其他测试都能通过:

  1. self.assertContains(response, escape(EMPTY_ITEM_ERROR))
  2. [...]
  3. AssertionError: False is not true : Couldn't find 'You can&#39;t have an empty
  4. list item' in response

14.3.3 使用这个表单在模板中显示错误消息

测试失败的原因是模板还没使用这个表单显示错误消息:

lists/templates/base.html (ch11l026)

  1. <form method="POST" action="{% block form_action %}{% endblock %}">
  2. {{ form.text }}
  3. {% csrf_token %}
  4. {% if form.errors %} ➊
  5. <div class="form-group has-error">
  6. <div class="help-block">{{ form.text.errors }}</div>
  7. </div>
  8. {% endif %}
  9. </form>

form.errors 是一个列表,包含这个表单中的所有错误。

form.text.errors 也是一个列表,但只包含 text 字段的错误。

这样修改之后对测试有什么作用呢?

  1. FAIL: test_validation_errors_end_up_on_lists_page
  2. (lists.tests.test_views.ListViewTest)
  3. [...]
  4. AssertionError: False is not true : Couldn't find 'You can&#39;t have an empty
  5. list item' in response

得到了一个意料之外的失败,这次失败发生在针对最后一个试图 view_list 的测试中。因为我们修改了错误在所有模板中显示的方式,不再显示手动传入模板的错误。

因此,还要修改 view_list 视图才能重新回到可运行状态。

14.4 在其他视图中使用这个表单

view_list 视图既可以处理 GET 请求也可以处理 POST 请求。先测试 GET 请求,为此,可以编写一个新测试方法:

lists/tests/test_views.py

  1. class ListViewTest(TestCase):
  2. [...]
  3. def test_displays_item_form(self):
  4. list_ = List.objects.create()
  5. response = self.client.get(f'lists{list_.id}/')
  6. self.assertIsInstance(response.context['form'], ItemForm)
  7. self.assertContains(response, 'name="text"')

测试的结果为:

  1. KeyError: 'form'

解决这个问题最简单的方法如下:

lists/views.py (ch11l028)

  1. def view_list(request, list_id):
  2. [...]
  3. form = ItemForm()
  4. return render(request, 'list.html', {
  5. 'list': list_, "form": form, "error": error
  6. })

14.4.1 定义辅助方法,简化测试

接下来要在另一个视图中使用这个表单的错误消息,把当前针对表单提交失败的测试(test_validation_errors_end_up_on_lists_page)分成多个测试方法:

lists/tests/test_views.py (ch11l030)

  1. class ListViewTest(TestCase):
  2. [...]
  3. def post_invalid_input(self):
  4. list_ = List.objects.create()
  5. return self.client.post(
  6. f'lists{list_.id}/',
  7. data={'text': ''}
  8. )
  9. def test_for_invalid_input_nothing_saved_to_db(self):
  10. self.post_invalid_input()
  11. self.assertEqual(Item.objects.count(), 0)
  12. def test_for_invalid_input_renders_list_template(self):
  13. response = self.post_invalid_input()
  14. self.assertEqual(response.status_code, 200)
  15. self.assertTemplateUsed(response, 'list.html')
  16. def test_for_invalid_input_passes_form_to_template(self):
  17. response = self.post_invalid_input()
  18. self.assertIsInstance(response.context['form'], ItemForm)
  19. def test_for_invalid_input_shows_error_on_page(self):
  20. response = self.post_invalid_input()
  21. self.assertContains(response, escape(EMPTY_ITEM_ERROR))

我们定义了一个辅助方法 post_invalid_input,这样就不用在分拆的四个测试中重复编写代码了。

这种做法我们见过几次了。把视图测试写在一个测试方法中,编写一连串的断言检测视图应该做这个、这个和这个,然后应该返回那个——我们经常觉得这么做更合理,但把单个测试方法分解为多个方法也绝对有好处。从前面几章我们已经得知,如果以后修改代码时不小心引入了一个问题,分拆的测试能帮助定位真正的问题所在。辅助方法则是降低心理障碍的方式之一。

例如,现在测试结果只有一个失败,而且我们知道是由哪个测试方法导致的:

  1. FAIL: test_for_invalid_input_shows_error_on_page
  2. (lists.tests.test_views.ListViewTest)
  3. AssertionError: False is not true : Couldn't find 'You can&#39;t have an empty
  4. list item' in response

现在,试试能否使用 ItemForm 表单重写视图。第一次尝试:

lists/views.py

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

重写后,单元测试通过了:

  1. Ran 23 tests in 0.086s
  2. OK

再看功能测试的结果如何:

  1. ERROR: test_cannot_add_empty_list_items
  2. (functional_tests.test_list_item_validation.ItemValidationTest)
  3. ---------------------------------------------------------------------
  4. Traceback (most recent call last):
  5. File "...superlists/functional_tests/test_list_item_validation.py", line
  6. 15, in test_cannot_add_empty_list_items
  7. [...]
  8. selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
  9. element: .has-error

失败。

14.4.2 意想不到的好处:HTML5自带的客户端验证

这是怎么回事呢?我们在错误所处位置之前加上 time.sleep,看看会发生什么(如果愿意,也可以执行 manage.py runserver 命令,自己动手访问网站,如图 14-1 所示)。

04 - 图5

图 14-1:HTML5 验证失败

看起来输入框为空时,浏览器禁止用户提交表单。

这是因为 Django 为那个 HTML 输入框添加了 required 属性 1。(不相信?再看一下前面的 as_p() 输出。)这是 HTML5 的新特性,浏览器会在客户端做些验证,输入无效时禁止用户提交表单。

1这是 Django 1.11 的新特性。

下面据此修改功能测试:

functional_tests/test_list_item_validation.py (ch11l032)

  1. def test_cannot_add_empty_list_items(self):
  2. # 伊迪丝访问首页,不小心提交了一个空待办事项
  3. # 输入框中没输入内容,她就按下了回车键
  4. self.browser.get(self.live_server_url)
  5. self.get_item_input_box().send_keys(Keys.ENTER)
  6. # 浏览器截获了请求
  7. # 清单页面不会加载
  8. self.wait_for(lambda: self.browser.find_elements_by_css_selector(
  9. '#id_text:invalid'
  10. ))
  11. # 她在待办事项中输入了一些文字
  12. # 错误消失了
  13. self.get_item_input_box().send_keys('Buy milk')
  14. self.wait_for(lambda: self.browser.find_elements_by_css_selector(
  15. '#id_text:valid'
  16. ))
  17. # 现在能提交了
  18. self.get_item_input_box().send_keys(Keys.ENTER)
  19. self.wait_for_row_in_list_table('1: Buy milk')
  20. # 她有点儿调皮,打算再提交一个空待办事项
  21. self.get_item_input_box().send_keys(Keys.ENTER)
  22. # 浏览器这次也不会放行
  23. self.wait_for_row_in_list_table('1: Buy milk')
  24. self.wait_for(lambda: self.browser.find_elements_by_css_selector(
  25. '#id_text:invalid'
  26. ))
  27. # 输入一些文字后就能纠正这个错误
  28. self.get_item_input_box().send_keys('Make tea')
  29. self.wait_for(lambda: self.browser.find_elements_by_css_selector(
  30. '#id_text:valid'
  31. ))
  32. self.get_item_input_box().send_keys(Keys.ENTER)
  33. self.wait_for_row_in_list_table('1: Buy milk')
  34. self.wait_for_row_in_list_table('2: Make tea')

➊ 不再检查我们自定义的错误消息,而是通过 CSS 伪选择符 :invalid 检查。这个伪选择符是浏览器为输入无效内容的 HTML5 输入框添加的。

➋ 输入有效的内容时,伪选择符逆转。

看到 self.wait_for 函数多么有用、多么灵活了吗?

现在的功能测试与刚开始时区别很大,我相信此时此刻你有很多疑问。先别急,我会说明的。先来看看测试是否又能通过了:

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

14.5 值得鼓励

首先,给自己一个大大的肯定:我们刚刚完成了这个小型应用中的一项重要修改——那个输入框,以及它的 nameid 属性,对应用正常运行至关重要。我们修改了七八个文件,完成了一次工作量很大的重构。如果没有测试,做这么复杂的重构我一定会担心,甚至有可能觉得没必要再去改动可以使用的代码。可是,我们有一套完整的测试组件,所以可以深入研究、整理代码,这些操作都很安全,因为我们知道如果有错误,测试能发现。所以我们会不断重构、整理和维护代码,确保整个应用的代码干净整洁,运行起来毫无障碍、准确无误,而且功能完善。

04 - 图6

现在是提交的绝佳时刻:

  1. $ git diff
  2. $ git commit -am "use form in all views, back to working state"

14.6 这难道不是浪费时间吗

如果这样的话,我们自定义的错误消息还有什么用呢?我们在 HTML 模板中费这么大力气渲染表单都是无用功吗?如果在产生错误之前,浏览器就截获了请求,Django 根本无法把错误呈现到用户面前,功能测试也就无从测试。

好吧,你说得对。但是我们的时间并没有浪费,原因有三个。首先,客户端验证不能百分百阻止无效输入。如果你真的在意数据完整性,就必须使用服务器端验证,而这部分逻辑很适合封装在表单中。

其次,不是所有浏览器(咳,Safari)都完全支持 HTML5,有些用户还是能看到我们自定义的消息的。而且,如果我们打算让用户通过 API 访问数据(参见附录 F),验证消息也会回送给用户。

此外,下一章将重用这里的验证、表单代码以及前端 .has-error 类,实现一些 HTML5 没有的高级验证。

话说回来,就算没有这些理由,你也不用为编程时走错路了而责怪自己。没人能预见未来,我们的目标是找出正确的解决方案,不惜“浪费”时间在错误的方案上。

14.7 使用表单自带的save方法

我们还可以进一步简化视图。前面说过,表单可以把数据存入数据库。我们遇到的情况并不能直接保存数据,因为需要知道把待办事项保存到哪个清单中,不过解决起来也不难。

一如既往,先编写测试。为了查明遇到的问题,先看一下如果直接调用 form.save() 会发生什么:

lists/tests/test_forms.py (ch11l033)

  1. def test_form_save_handles_saving_to_a_list(self):
  2. form = ItemForm(data={'text': 'do me'})
  3. new_item = form.save()

Django 报错了,因为待办事项必须隶属于某个清单:

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

这个问题的解决办法是告诉表单的 save 方法,应该把待办事项保存到哪个清单中:

lists/tests/test_forms.py

  1. from lists.models import Item, List
  2. [...]
  3. def test_form_save_handles_saving_to_a_list(self):
  4. list_ = List.objects.create()
  5. form = ItemForm(data={'text': 'do me'})
  6. new_item = form.save(for_list=list_)
  7. self.assertEqual(new_item, Item.objects.first())
  8. self.assertEqual(new_item.text, 'do me')
  9. self.assertEqual(new_item.list, list_)

然后,要保证待办事项能顺利存入数据库,而且各个属性的值都正确:

  1. TypeError: save() got an unexpected keyword argument 'for_list'

可以定制 save 方法,实现方式如下:

lists/forms.py (ch11l035)

  1. def save(self, for_list):
  2. self.instance.list = for_list
  3. return super().save()

表单的 .instance 属性是将要修改或创建的数据库对象。我也是在撰写本章时才知道这种用法的。此外还有很多方法,例如自己手动创建数据库对象,或者调用 save() 方法时指定参数 commit=False,但我觉得使用 .instance 属性最简洁。下一章还会介绍一种方法,让表单知道它应用于哪个清单。

  1. Ran 24 tests in 0.086s
  2. OK

最后,要重构视图。先重构 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})

然后运行测试,确保都能通过:

  1. Ran 24 tests in 0.086s
  2. OK

接着重构 view_list

lists/views.py

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

修改之后,单元测试仍能全部通过:

  1. Ran 24 tests in 0.111s
  2. OK

功能测试也能通过:

  1. Ran 4 tests in 14.367s
  2. OK

太棒了!现在这两个视图更像是“正常的”Django 视图了:从用户的请求中读取数据,结合一些定制的逻辑或 URL 中的信息(list_id),然后把数据传入表单进行验证,如果通过验证就保存数据,最后重定向或者渲染模板。

表单和验证在 Django 以及常规的 Web 编程中都很重要,下一章要看一下能否编写稍微复杂的表单。

小贴士

  • 简化视图

    如果发现视图很复杂,要编写很多测试,这时候就应该考虑是否能把逻辑移到其他地方。可以移到表单中,就像本章中的做法一样;也可以移到模型类的自定义方法中。如果应用本身就很复杂,可以把核心业务逻辑移到 Django 专属的文件之外,编写单独的类和函数。

  • 一个测试只测试一件事

    如果一个测试中不止一个断言,你就要怀疑这么写是否合理。有时断言之间联系紧密,可以放在一起。不过第一次编写测试时往往都会测试很多表现,其实应该把它们分成多个测试。辅助函数有助于简化拆分后的测试。

第 15 章 高级表单

接下来,你将看到表单的一些高级用法。我们已经帮助用户避免输入空待办事项,接下来要避免用户输入重复的待办事项。

本章将进一步介绍 Django 表单验证的细节。如果你已经完全了解如何定制 Django 表单,或者你阅读本书的目的是学习 TDD 而不是 Django,那就可以跳过本章。

如果你还想接着学习 Django,本章有些值得学习的重要知识。如果你想跳过本章也可以,不过一定要快速阅读关于开发者犯错的框注和本章末尾对视图测试的总结。

15.1 针对重复待办事项的功能测试

ItemValidationTest 类中再添加一个测试方法:

functional_tests/test_list_item_validation.py (ch13l001)

  1. def test_cannot_add_duplicate_items(self):
  2. # 伊迪丝访问首页,新建一个清单
  3. self.browser.get(self.live_server_url)
  4. self.get_item_input_box().send_keys('Buy wellies')
  5. self.get_item_input_box().send_keys(Keys.ENTER)
  6. self.wait_for_row_in_list_table('1: Buy wellies')
  7. # 她不小心输入了一个重复的待办事项
  8. self.get_item_input_box().send_keys('Buy wellies')
  9. self.get_item_input_box().send_keys(Keys.ENTER)
  10. # 她看到一条有帮助的错误消息
  11. self.wait_for(lambda: self.assertEqual(
  12. self.browser.find_element_by_css_selector('.has-error').text,
  13. "You've already got this in your list"
  14. ))

为什么编写两个测试方法,而不直接在原来的基础上扩展,或者新建一个文件和类?要自己判断该怎么做。这两种方法看起来联系紧密,都和同一个输入字段的验证有关,所以放在同一个文件中没问题。另一方面,这两种方法在逻辑上互相独立,所以将它们设为不同的两种不同的方法是可行的:

  1. $ python manage.py test functional_tests.test_list_item_validation
  2. [...]
  3. selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
  4. element: .has-error
  5.  
  6. Ran 2 tests in 9.613s

好的,这两个测试中的第一个现在可以通过。你可能会问:“有没有办法只运行那个失败的测试?”确实有:

  1. $ python manage.py test functional_tests.\
  2. test_list_item_validation.ItemValidationTest.test_cannot_add_duplicate_items
  3. [...]
  4. selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
  5. element: .has-error

15.1.1 在模型层禁止重复

这是我们真正要做的事情。编写一个新测试,检查同一个清单中有重复的待办事项时是否抛出异常:

lists/tests/test_models.py (ch09l028)

  1. def test_duplicate_items_are_invalid(self):
  2. list_ = List.objects.create()
  3. Item.objects.create(list=list_, text='bla')
  4. with self.assertRaises(ValidationError):
  5. item = Item(list=list_, text='bla')
  6. item.full_clean()

此外,还要再添加一个测试,确保完整性约束不要做过头了:

lists/tests/test_models.py (ch09l029)

  1. def test_CAN_save_same_item_to_different_lists(self):
  2. list1 = List.objects.create()
  3. list2 = List.objects.create()
  4. Item.objects.create(list=list1, text='bla')
  5. item = Item(list=list2, text='bla')
  6. item.full_clean() # 不该抛出异常

我总喜欢在检查某项操作该抛出异常的测试中加入一些注释,要不然很难看出在测试什么。

  1. AssertionError: ValidationError not raised

如果想故意犯错,可以这么做:

lists/models.py (ch09l030)

  1. class Item(models.Model):
  2. text = models.TextField(default='', unique=True)
  3. list = models.ForeignKey(List, default=None)

这么做可以确认第二个测试确实能检测到这个问题:

  1. Traceback (most recent call last):
  2. File "...superlistsliststests/test_models.py", line 62, in
  3. test_CAN_save_same_item_to_different_lists
  4. item.full_clean() # 不该抛出异常
  5. [...]
  6. django.core.exceptions.ValidationError: {'text': ['Item with this Text already
  7. exists.']}

何时测试开发者犯下的错误

测试时要判断何时应该编写测试确认我们没有犯错。一般而言,做决定时要谨慎。

这里,编写测试确认无法把重复的待办事项存入同一个清单。目前,让这个测试通过最简单的方法(即编写的代码量最少)是,让表单无法保存任何重复的待办事项。此时就要编写另一个测试,因为我们编写的代码可能有错。

但是,不可能编写测试检查所有可能出错的方式。如果有一个函数计算两数之和,可以编写一些测试:

  1. assert adder(1, 1) == 2
  2. assert adder(2, 1) == 3

但不应该认为实现这个函数时故意编写了有违常理的代码:

  1. def adder(a, b):
  2. # 不可能这么写!
  3. if a == 3:
  4. return 666
  5. else:
  6. return a + b

判断时你要相信自己不会故意犯错,只会不小心犯错。

模型和 ModelForm 一样,也能使用 class Meta。在 Meta 类中可以实现一个约束,要求清单中的待办事项必须是唯一的。也就是说,textlist 的组合必须是唯一的:

lists/models.py (ch09l031)

  1. class Item(models.Model):
  2. text = models.TextField(default='')
  3. list = models.ForeignKey(List, default=None)
  4. class Meta:
  5. unique_together = ('list', 'text')

此时,你可能想快速浏览一遍 Django 文档中对模型属性 Meta 的说明。

15.1.2 题外话:查询集合排序和字符串表示形式

运行测试,会看到一个意料之外的失败:

  1. ======================================================================
  2. FAIL: test_saving_and_retrieving_items
  3. (lists.tests.test_models.ListAndItemModelsTest)
  4. ---------------------------------------------------------------------
  5. Traceback (most recent call last):
  6. File "...superlistsliststests/test_models.py", line 31, in
  7. test_saving_and_retrieving_items
  8. self.assertEqual(first_saved_item.text, 'The first (ever) list item')
  9. AssertionError: 'Item the second' != 'The first (ever) list item'
  10. - Item the second
  11. [...]

04 - 图7 根据所用系统和 SQLite 版本的不同,你可能看不到这个错误。如果没看到就直接阅读下一节,代码和测试本身也很有趣。

失败消息有点儿晦涩。输出一些信息,以便调试:

lists/tests/test_models.py

  1. first_saved_item = saved_items[0]
  2. print(first_saved_item.text)
  3. second_saved_item = saved_items[1]
  4. print(second_saved_item.text)
  5. self.assertEqual(first_saved_item.text, 'The first (ever) list item')

然后,看到的测试结果如下:

  1. .....Item the second
  2. The first (ever) list item
  3. F.....

看样子唯一性约束干扰了查询(例如 Item.objects.all())的默认排序。虽然现在仍有测试失败,但最好添加一个新测试明确测试排序:

lists/tests/test_models.py (ch09l032)

  1. def test_list_ordering(self):
  2. list1 = List.objects.create()
  3. item1 = Item.objects.create(list=list1, text='i1')
  4. item2 = Item.objects.create(list=list1, text='item 2')
  5. item3 = Item.objects.create(list=list1, text='3')
  6. self.assertEqual(
  7. Item.objects.all(),
  8. [item1, item2, item3]
  9. )

测试的结果多了一个失败,而且也不易读:

  1. AssertionError: <QuerySet [<Item: Item object>, <Item: Item object>, <Item:
  2. Item object>]> != [<Item: Item object>, <Item: Item object>, <Item: Item
  3. object>]

我们的对象需要一个更好的字符串表示形式。下面再添加一个单元测试:

04 - 图8 如果已经有测试失败,还要再添加更多的失败测试,通常都要三思而后行,因为这么做会让测试的输出变得更复杂,而且往往你都会担心:“还能回到正常运行的状态吗?”这里,测试都很简单,所以我不担忧。

lists/tests/test_models.py (ch13l008)

  1. def test_string_representation(self):
  2. item = Item(text='some text')
  3. self.assertEqual(str(item), 'some text')

测试的结果为:

  1. AssertionError: 'Item object' != 'some text'

连同另外两个失败,现在开始一并解决:

lists/models.py (ch09l034)

  1. class Item(models.Model):
  2. [...]
  3. def __str__(self):
  4. return self.text

04 - 图9 在 Python 2.x 的 Django 版本中,字符串表示形式使用 unicode 方法定制。和很多字符串处理方式一样,Python 3 对此做了简化。参见文档(https://docs.djangoproject.com/en/1.11/topics/python3/#str-and-unicode-methods)。

现在只剩两个失败测试了,而且排序测试的失败消息更易读了:

  1. AssertionError: <QuerySet [<Item: i1>, <Item: item 2>, <Item: 3>]> != [<Item:
  2. i1>, <Item: item 2>, <Item: 3>]

可以在 class Meta 中解决这个问题:

lists/models.py (ch09l035)

  1. class Meta:
  2. ordering = ('id',)
  3. unique_together = ('list', 'text')

这么做有用吗?

  1. AssertionError: <QuerySet [<Item: i1>, <Item: item 2>, <Item: 3>]> != [<Item:
  2. i1>, <Item: item 2>, <Item: 3>]

呃,确实有用,从测试结果中可以看到,顺序是一样的,只不过测试没分清。其实我一直会遇到这个问题,因为 Django 中的查询集合不能和列表正确比较。可以在测试中把查询集合转换成列表 1 解决这个问题:

1也可以考虑使用 unittest 中的 assertSequenceEqual,以及 Django 测试工具中的 assertQuerysetEqual。不过我承认,之前我并没搞清楚怎么使用 assertQuerysetEqual

lists/tests/test_models.py (ch09l036)

  1. self.assertEqual(
  2. list(Item.objects.all()),
  3. [item1, item2, item3]
  4. )

这样就可以了,整个测试组件都能通过:

  1. OK

15.1.3 重写旧模型测试

虽然冗长的模型测试无意间帮我们发现了一个问题,但现在要重写模型测试。重写的过程中我会讲得很详细,因为借此机会要介绍 Django ORM。既然我们已经编写了专门测试排序的测试,现在就可以使用一些较短的测试达到相同的覆盖度。删除 test_saving_and_retrieving_items,换成:

lists/tests/test_models.py (ch13l010)

  1. class ListAndItemModelsTest(TestCase):
  2. def test_default_text(self):
  3. item = Item()
  4. self.assertEqual(item.text, '')
  5. def test_item_is_related_to_list(self):
  6. list_ = List.objects.create()
  7. item = Item()
  8. item.list = list_
  9. item.save()
  10. self.assertIn(item, list_.item_set.all())
  11. [...]

这么改绰绰有余。初始化一个全新的模型对象,检查属性的默认值,这么做足以确认 models.py 中是否正确设定了一些字段。test_item_is_related_to_list 其实是双重保险,确认外键关联是否正常。

借此机会,还要把这个文件中的内容分成专门针对 ItemList 的测试(后者只有一个测试方法,即 testgetabsolute_url):

lists/tests/test_models.py (ch13l011)

  1. class ItemModelTest(TestCase):
  2. def test_default_text(self):
  3. [...]
  4. class ListModelTest(TestCase):
  5. def testgetabsolute_url(self):
  6. [...]

修改之后代码更整洁。测试结果如下:

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

15.1.4 保存时确实会显示完整性错误

在继续之前还有一个题外话要说。我在第 13 章提到过,保存数据时会出现一些数据完整性错误,还记得吗?是否出现完整性错误完全取决于完整性约束是否由数据库执行。

执行 makemigrations 命令试试,你会看到,Django 除了把 unique_together 作为应用层约束之外,还想把它加到数据库中:

  1. $ python manage.py makemigrations
  2. Migrations for 'lists':
  3. lists/migrations/0005_auto_20140414_2038.py
  4. - Change Meta options on item
  5. - Alter unique_together for item (1 constraint(s))

现在,修改检查重复待办事项的测试,把 .full_clean 改成 .save

lists/tests/test_models.py

  1. def test_duplicate_items_are_invalid(self):
  2. list_ = List.objects.create()
  3. Item.objects.create(list=list_, text='bla')
  4. with self.assertRaises(ValidationError):
  5. item = Item(list=list_, text='bla')
  6. # item.full_clean()
  7. item.save()

测试的结果为:

  1. ERROR: test_duplicate_items_are_invalid (lists.tests.test_models.ItemModelTest)
  2. [...]
  3. return Database.Cursor.execute(self, query, params)
  4. sqlite3.IntegrityError: UNIQUE constraint failed: lists_item.list_id,
  5. lists_item.text
  6. [...]
  7. django.db.utils.IntegrityError: UNIQUE constraint failed: lists_item.list_id,
  8. lists_item.text

可以看出,错误是由 SQLite 导致的,而且错误类型也和我们期望的不一样,我们想得到的是 ValidationError,实际却是 IntegrityError

把改动改回去,让测试全部通过:

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

然后提交对模型层的修改:

  1. $ git status # 会看到改动了测试和模型,还有一个新迁移文件
  2. # 我们给新迁移文件起一个更好的名字
  3. $ mv lists/migrations/0005_auto* lists/migrations/0005_list_item_unique_together.py
  4. $ git add lists
  5. $ git diff --staged
  6. $ git commit -am "Implement duplicate item validation at model layer"

15.2 在视图层试验待办事项重复验证

运行功能测试,看看现在我们进展到哪里了:

  1. selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
  2. element: .has-error

运行功能测试时浏览器窗口一闪而过,你可能没看到网站现在处于 500 状态之中。2 简单的修改视图层单元测试应该能解决这个问题:

2显示一个服务器错误,响应码为 500。你要明白这些术语的意思。

lists/tests/test_views.py (ch13l014)

  1. class ListViewTest(TestCase):
  2. [...]
  3. def test_for_invalid_input_shows_error_on_page(self):
  4. [...]
  5. def test_duplicate_item_validation_errors_end_up_on_lists_page(self):
  6. list1 = List.objects.create()
  7. item1 = Item.objects.create(list=list1, text='textey')
  8. response = self.client.post(
  9. f'lists{list1.id}/',
  10. data={'text': 'textey'}
  11. )
  12. expected_error = escape("You've already got this in your list")
  13. self.assertContains(response, expected_error)
  14. self.assertTemplateUsed(response, 'list.html')
  15. self.assertEqual(Item.objects.all().count(), 1)

测试结果为:

  1. django.db.utils.IntegrityError: UNIQUE constraint failed: lists_item.list_id,
  2. lists_item.text

我们不想让测试出现完整性错误!理想情况下,我们希望在尝试保存数据之前调用 is_valid 时,已经注意到有重复。不过在此之前,表单必须知道待办事项属于哪个清单。

现在暂时为这个测试加上 @skip 修饰器:

lists/tests/test_views.py (ch13l015)

  1. from unittest import skip
  2. [...]
  3. @skip
  4. def test_duplicate_item_validation_errors_end_up_on_lists_page(self):

15.3 处理唯一性验证的复杂表单

新建清单的表单只需知道一件事,即新待办事项的文本。为了验证清单中的代办事项是否唯一,表单需要知道使用哪个清单以及待办事项的文本。就像前面我们在 ItemForm 类中定义 save 方法一样,这一次要重定义表单的构造方法,让它知道待办事项属于哪个清单。

复制前一个表单的测试,稍微做些修改:

lists/tests/test_forms.py (ch13l016)

  1. from lists.forms import (
  2. DUPLICATE_ITEM_ERROR, EMPTY_ITEM_ERROR,
  3. ExistingListItemForm, ItemForm
  4. )
  5. [...]
  6. class ExistingListItemFormTest(TestCase):
  7. def test_form_renders_item_text_input(self):
  8. list_ = List.objects.create()
  9. form = ExistingListItemForm(for_list=list_)
  10. self.assertIn('placeholder="Enter a to-do item"', form.as_p())
  11. def test_form_validation_for_blank_items(self):
  12. list_ = List.objects.create()
  13. form = ExistingListItemForm(for_list=list_, data={'text': ''})
  14. self.assertFalse(form.is_valid())
  15. self.assertEqual(form.errors['text'], [EMPTY_ITEM_ERROR])
  16. def test_form_validation_for_duplicate_items(self):
  17. list_ = List.objects.create()
  18. Item.objects.create(list=list_, text='no twins!')
  19. form = ExistingListItemForm(for_list=list_, data={'text': 'no twins!'})
  20. self.assertFalse(form.is_valid())
  21. self.assertEqual(form.errors['text'], [DUPLICATE_ITEM_ERROR])

要历经几次 TDD 循环,最后才能得到一个自定义的构造方法。这个构造方法会忽略 for_list 参数。(我不会写出全部过程,但我相信你会做完的,对吗?记住,测试山羊能看到一切。)

lists/forms.py (ch09l071)

  1. DUPLICATE_ITEM_ERROR = "You've already got this in your list"
  2. [...]
  3. class ExistingListItemForm(forms.models.ModelForm):
  4. def __init__(self, for_list, args, *kwargs):
  5. super().__init__(args, *kwargs)

现阶段的错误应该是:

  1. ValueError: ModelForm has no model class specified.

接下来,让这个表单继承现有的表单,看测试能不能通过:

lists/forms.py (ch09l072)

  1. class ExistingListItemForm(ItemForm):
  2. def __init__(self, for_list, args, *kwargs):
  3. super().__init__(args, *kwargs)

能通过,现在只剩一个失败测试了:

  1. FAIL: test_form_validation_for_duplicate_items
  2. (lists.tests.test_forms.ExistingListItemFormTest)
  3. self.assertFalse(form.is_valid())
  4. AssertionError: True is not false

下面这一步需要了解一点 Django 内部运作机制,你可以阅读 Django 文档中对模型验证和表单验证的介绍。

Django 在表单和模型中都会调用 validate_unique 方法,借助 instance 属性在表单的 validate_unique 方法中调用模型的 validate_unique 方法:

lists/forms.py

  1. from django.core.exceptions import ValidationError
  2. [...]
  3. class ExistingListItemForm(ItemForm):
  4. def __init__(self, for_list, args, *kwargs):
  5. super().__init__(args, *kwargs)
  6. self.instance.list = for_list
  7. def validate_unique(self):
  8. try:
  9. self.instance.validate_unique()
  10. except ValidationError as e:
  11. e.error_dict = {'text': [DUPLICATE_ITEM_ERROR]}
  12. self.updateerrors(e)

这段代码用到了一点 Django 魔法,先获取验证错误,修改错误消息之后再把错误传回表单。

任务完成,做个简单的提交:

  1. $ git diff
  2. $ git commit -a

15.4 在清单视图中使用ExistingListItemForm

现在看一下能否在视图中使用这个表单。

删掉测试方法的 @skip 修饰器,同时使用前一节创建的常量清理测试。

lists/tests/test_views.py (ch13l049)

  1. from lists.forms import (
  2. DUPLICATE_ITEM_ERROR, EMPTY_ITEM_ERROR,
  3. ExistingListItemForm, ItemForm,
  4. )
  5. [...]
  6. def test_duplicate_item_validation_errors_end_up_on_lists_page(self):
  7. [...]
  8. expected_error = escape(DUPLICATE_ITEM_ERROR)

修改之后完整性错误又出现了:

  1. django.db.utils.IntegrityError: UNIQUE constraint failed: lists_item.list_id,
  2. lists_item.text

解决方法是使用前一节定义的表单类。在此之前,先找到检查表单类的测试,然后按照下面的方式修改:

lists/tests/test_views.py (ch13l050)

  1. class ListViewTest(TestCase):
  2. [...]
  3. def test_displays_item_form(self):
  4. list_ = List.objects.create()
  5. response = self.client.get(f'lists{list_.id}/')
  6. self.assertIsInstance(response.context['form'], ExistingListItemForm)
  7. self.assertContains(response, 'name="text"')
  8. [...]
  9. def test_for_invalid_input_passes_form_to_template(self):
  10. response = self.post_invalid_input()
  11. self.assertIsInstance(response.context['form'], ExistingListItemForm)

修改之后测试的结果为:

  1. AssertionError: <ItemForm bound=False, valid=False, fields=(text)> is not an
  2. instance of <class 'lists.forms.ExistingListItemForm'>

那么就可以修改视图了:

lists/views.py (ch13l051)

  1. from lists.forms import ExistingListItemForm, ItemForm
  2. [...]
  3. def view_list(request, list_id):
  4. list_ = List.objects.get(id=list_id)
  5. form = ExistingListItemForm(for_list=list_)
  6. if request.method == 'POST':
  7. form = ExistingListItemForm(for_list=list_, data=request.POST)
  8. if form.is_valid():
  9. form.save()
  10. [...]

问题几乎都解决了,但又出现了一个意料之外的失败:

  1. TypeError: save() missing 1 required positional argument: 'for_list'

不再需要使用父类 ItemForm 中自定义的 save 方法。为此,先编写一个单元测试:

lists/tests/test_forms.py (ch13l053)

  1. def test_form_save(self):
  2. list_ = List.objects.create()
  3. form = ExistingListItemForm(for_list=list_, data={'text': 'hi'})
  4. new_item = form.save()
  5. self.assertEqual(new_item, Item.objects.all()[0])

可以让表单调用祖父类中的 save 方法:

lists/forms.py (ch13l054)

  1. def save(self):
  2. return forms.models.ModelForm.save(self)

04 - 图10 个人观点:这里可以使用 super,但是有参数时我选择不用,例如获取祖父类中的方法。我觉得使用 Python 3 的 super() 方法获取直接父类很棒,但其他用途太容易出错,而且写出的代码也不好看。你的观点可能与我不同。

搞定!所有单元测试都能通过:

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

针对验证的功能测试也能通过:

  1. $ python manage.py test functional_tests.test_list_item_validation
  2. [...]
  3. ..
  4. ---------------------------------------------------------------------
  5. Ran 2 tests in 12.048s
  6.  
  7. OK

检查的最后一步——运行所有功能测试:

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

太棒了,最后还要提交,再回顾一下之前所学的内容。

15.5 小结:目前所学的Django测试知识

我们的应用现在看起来更像是“标准的”Django 应用了,它实现了 Django 常见的三层:模型、表单和视图。测试不再是“试水式”的了,代码也更像是实际应用中应该有的样子了。

每个关键的源码文件都对应一个单元测试文件,下面回顾一下内容最多(层级也最高)的那个,即 test_views(下述代码清单只列出了关键测试和断言)。

如何测试视图

lists/tests/test_views.py

  1. class ListViewTest(TestCase):
  2. def test_uses_list_template(self):
  3. response = self.client.get(f'lists{list_.id}/')
  4. self.assertTemplateUsed(response, 'list.html')
  5. def test_passes_correct_list_to_template(self):
  6. self.assertEqual(response.context['list'], correct_list)
  7. def test_displays_item_form(self):
  8. self.assertIsInstance(response.context['form'], ExistingListItemForm)
  9. self.assertContains(response, 'name="text"')
  10. def test_displays_only_items_for_that_list(self):
  11. self.assertContains(response, 'itemey 1')
  12. self.assertContains(response, 'itemey 2')
  13. self.assertNotContains(response, 'other list item 1')
  14. def test_can_save_a_POST_request_to_an_existing_list(self):
  15. self.assertEqual(Item.objects.count(), 1)
  16. self.assertEqual(new_item.text, 'A new item for an existing list')
  17. def test_POST_redirects_to_list_view(self):
  18. self.assertRedirects(response, f'lists{correct_list.id}/')
  19. def test_for_invalid_input_nothing_saved_to_db(self):
  20. self.assertEqual(Item.objects.count(), 0)
  21. def test_for_invalid_input_renders_list_template(self):
  22. self.assertEqual(response.status_code, 200)
  23. self.assertTemplateUsed(response, 'list.html')
  24. def test_for_invalid_input_passes_form_to_template(self):
  25. self.assertIsInstance(response.context['form'], ExistingListItemForm)
  26. def test_for_invalid_input_shows_error_on_page(self):
  27. self.assertContains(response, escape(EMPTY_ITEM_ERROR))
  28. def test_duplicate_item_validation_errors_end_up_on_lists_page(self):
  29. self.assertContains(response, expected_error)
  30. self.assertTemplateUsed(response, 'list.html')
  31. self.assertEqual(Item.objects.all().count(), 1)

❶ 使用 Django 测试客户端。

❷ 检查使用的模板。然后在模板的上下文中检查各个待办事项。

❸ 检查每个对象都是希望得到的,或者查询集合中包含正确的待办事项。

❹ 检查表单使用正确的类。

❺ 检查测试模板逻辑:每个 forif 语句都要做最简单的测试。

❻ 对于处理 POST 请求的视图,确保有效和无效两种情况都要测试。

❼ 健全性检查(可选),检查是否渲染指定的表单,而且是否显示错误消息。

为什么要测试这么多?请阅读附录 B。使用基于类的视图重构时,这些测试能保证视图仍然可以正常运行。

接下来要尝试使用一些客户端代码让数据验证更友好。我想你知道这是什么意思。

第 16 章 试探JavaScript

“如果上帝想让我们享受生活,就不会把他无尽的痛苦当作珍贵的礼物赠与我们。”

——John Calvin1

1Calvin and the Chipmunk 中的角色。

虽然前面实现的验证逻辑很好,但当用户开始修正问题时,让待办事项重复的错误消息消失,就像 HTML5 验证错误那样,不是更好吗?为了达到这个效果,需要使用少量的 JavaScript。

每天使用 Python 这种充满乐趣的语言编程完全把我们宠坏了。JavaScript 是给我们的惩罚。对 Web 开发者而言,这是绕不开的话题。接下来要十分谨慎地试探如何使用 JavaScript。

04 - 图11 我假设你知道基本的 JavaScript 句法。如果你还没读过《JavaScript 语言精粹》,现在就买一本吧!这本书并不厚。

16.1 从功能测试开始

ItemValidationTest 类中添加一个新的功能测试:

functional_tests/test_list_item_validation.py (ch14l001)

  1. def test_error_messages_are_cleared_on_input(self):
  2. # 伊迪丝新建一个清单,但方法不当,所以出现了一个验证错误
  3. self.browser.get(self.live_server_url)
  4. self.get_item_input_box().send_keys('Banter too thick')
  5. self.get_item_input_box().send_keys(Keys.ENTER)
  6. self.wait_for_row_in_list_table('1: Banter too thick')
  7. self.get_item_input_box().send_keys('Banter too thick')
  8. self.get_item_input_box().send_keys(Keys.ENTER)
  9. self.wait_for(lambda: self.assertTrue(
  10. self.browser.find_element_by_css_selector('.has-error').is_displayed()
  11. ))
  12. # 为了消除错误,她开始在输入框中输入内容
  13. self.get_item_input_box().send_keys('a')
  14. # 看到错误消息消失了,她很高兴
  15. self.wait_for(lambda: self.assertFalse(
  16. self.browser.find_element_by_css_selector('.has-error').is_displayed()
  17. ))

❶ 又用到了 wait_for,又一次传入 assertTrue

is_displayed() 可检查元素是否可见。不能只靠检查元素是否存在于 DOM 中去判断,因为现在要开始隐藏元素了。

无疑,这个测试会失败。但在继续之前,要应用“事不过三,三则重构”原则,因为多次使用 CSS 查找错误消息元素。把这个操作移到一个辅助函数中:

functional_tests/test_list_item_validation.py (ch14l002)

  1. class ItemValidationTest(FunctionalTest):
  2. def get_error_element(self):
  3. return self.browser.find_element_by_css_selector('.has-error')
  4. [...]

04 - 图12 我喜欢把辅助函数放在使用它们的功能测试类中,仅当辅助函数需要在别处使用时才放在基类中,以防止基类太臃肿。这就是 YAGNI 原则。

然后,在 test_list_item_validation.py 中做三次替换,例如:

functional_tests/test_list_item_validation.py (ch14l003)

  1. self.wait_for(lambda: self.assertEqual(
  2. self.get_error_element().text,
  3. "You've already got this in your list"
  4. ))
  5. [...]
  6. self.wait_for(lambda: self.assertTrue(
  7. self.get_error_element().is_displayed()
  8. ))
  9. [...]
  10. self.wait_for(lambda: self.assertFalse(
  11. self.get_error_element().is_displayed()
  12. ))

得到了一个预期错误:

  1. $ python manage.py test functional_tests.test_list_item_validation
  2. [...]
  3. self.get_error_element().is_displayed()
  4. AssertionError: True is not false

可以提交这些代码,作为对功能测试的首次改动。

16.2 安装一个基本的JavaScript测试运行程序

在 Python 和 Django 领域中选择测试工具非常简单。标准库中的 unittest 模块完全够用了,而且 Django 测试运行程序也是一个不错的默认选择。除此之外,还有一些替代工具——nose 很受欢迎,Green 是新推出的,我个人对 pytest 的印象比较深刻。不过默认选项很不错,已能满足需求。2

2无可否认,一旦开始找 Python BDD 工具,情况会稍微复杂一些。

在 JavaScript 领域,情况就不一样了。我们在工作中使用 YUI,但我觉得我应该走出去看看有没有其他新推出的工具。我被如此多的选项淹没了——jsUnit、Qunit、Mocha、Chutzpah、Karma、Testacular、Jasmine 等。而且还不仅限于此:选中其中一个工具后(例如 Mocha3),我还得选择一个断言框架报告程序,或许还要选择一个模拟技术库——永远没有终点。

3纯粹是因为 Mocha 提供了 NyanCat 测试运行程序。

最终,我决定我们应该使用 QUnit,因为它简单,跟 Python 单元测试很像,而且能很好地和 jQuery 配合使用。

在 lists/static 中新建一个目录,将其命名为 tests,把 QUnit JavaScript 和 CSS 两个文件下载到该目录。我们还要在该目录中放入一个 tests.html 文件:

  1. $ tree listsstatictests/
  2. listsstatictests/
  3. ├── qunit-2.0.1.css
  4. ├── qunit-2.0.1.js
  5. └── tests.html

QUnit 的 HTML 样板文件内容如下,其中包含一个冒烟测试:

  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width">
  6. <title>Javascript tests</title>
  7. <link rel="stylesheet" href="qunit-2.0.1.css">
  8. </head>
  9. <body>
  10. <div id="qunit"></div>
  11. <div id="qunit-fixture"></div>
  12. <script src="qunit-2.0.1.js"></script>
  13. <script>
  14. QUnit.test("smoke test", function (assert) {
  15. assert.equal(1, 1, "Maths works!");
  16. });
  17. </script>
  18. </body>
  19. </html>

仔细分析这个文件时,要注意几个重要的问题:使用第一个 标签引入 qunit-2.0.1,然后在第二个 标签中编写测试的主体。

如果在 Web 浏览器中打开这个文件(不用运行开发服务器,在硬盘中找到这个文件即可),会看到类似图 16-1 所示的页面。

04 - 图13

图 16-1:QUnit 的基本界面

查看测试代码会发现,和我们目前编写的 Python 测试有很多相似之处:

  1. QUnit.test("smoke test", function (assert) {
  2. assert.equal(1, 1, "Maths works!");
  3. });

QUnit.test 函数定义一个测试用例,有点儿类似 Python 中的 def test_something(self)test 函数的第一个参数是测试名,第二个参数是一个函数,定义这个测试的主体。

assert.equal 函数是一个断言,和 assertEqual 非常像,比较两个参数的值。不过,和在 Python 中不同,不管失败还是通过都会显示消息,所以消息应该使用肯定式而不是否定式。

为什么不修改这些参数,故意让测试失败,看看效果如何呢?

16.3 使用jQuery和
固件元素

下面来摸索一下这个测试框架能做什么,并借此开始使用一些 jQuery(不可或缺的库,为操纵 DOM 提供跨浏览器兼容的 API)。

04 - 图14 如果你从未用过 jQuery,在行文的过程中我会尝试解说,以防你完全不懂。这不是 jQuery 教程,所以在阅读本章的过程中最好抽出一两个小时研究 jQuery。

从 jquery.com 下载新版 jQuery,保存到 lists/static 文件夹中。

下面在测试文件中使用 jQuery,并添加几个 HTML 元素。先试着显示和隐藏元素,并编写几个断言,检查元素的可见性:

listsstatictests/tests.html

  1. <div id="qunit-fixture"></div>
  2. <form>
  3. <input name="text" >
  4. <div class="has-error">Error text<div>
  5. </form>
  6. <script src="../jquery-3.1.1.min.js"></script>
  7. <script src="qunit-2.0.1.js"></script>
  8. <script>
  9. QUnit.test("smoke test", function (assert) {
  10. assert.equal($('.has-error').is(':visible'), true); ➌➍
  11. $('.has-error').hide();
  12. assert.equal($('.has-error').is(':visible'), false);
  13. });
  14. </script>

及其中的内容放在那儿是为了表示真实的清单页面中的内容。

❷ 加载 jQuery。

❸ jQuery 魔法从这里开始! $ 是 jQuery 的瑞士军刀,用来查找 DOM 中的内容。$ 的第一个参数是 CSS 选择符,要查找类为“has-error”的所有元素。查找得到的结果是一个对象,表示一个或多个 DOM 元素。然后,可以在这个对象上使用很多有用的方法处理或者查看这些元素。

❹ 其中一个方法是 .is,它的作用是指出某个元素的表现是否和指定的 CSS 属性匹配。这里使用 :visible 检查元素是否显示出来。

❺ 使用 jQuery 提供的 .hide() 方法隐藏这个

元素。其实,这个方法是在元素上动态设定 style="display: none" 属性。

❻ 最后,使用第二个 assert.equal 断言检查隐藏是否成功。

刷新浏览器后应该会看到所有测试都通过了。

在浏览器中期望 QUnit 得到的结果如下:

  1. 2 assertions of 2 passed, 0 failed.
  2. 1. smoke test (2)

下面要介绍如何使用固件(fixture)。直接复制测试:

listsstatictests/tests.html

  1. <script>
  2. QUnit.test("smoke test", function (assert) {
  3. assert.equal($('.has-error').is(':visible'), true);
  4. $('.has-error').hide();
  5. assert.equal($('.has-error').is(':visible'), false);
  6. });
  7. QUnit.test("smoke test 2", function (assert) {
  8. assert.equal($('.has-error').is(':visible'), true);
  9. $('.has-error').hide();
  10. assert.equal($('.has-error').is(':visible'), false);
  11. });
  12. </script>

其中一个测试失败了,有点儿出乎预料,如图 16-2 所示。

04 - 图15

图 16-2:两个测试中有一个失败了

测试失败的原因是,第一个测试把显示错误消息的 div 元素隐藏了,所以运行第二个测试时,一开始这个元素就是隐藏的。

04 - 图16 QUnit 中的测试不会按照既定的顺序运行,所以不要觉得第一个测试一定会在第二个测试之前运行。多刷新几次试试,你会发现失败的测试有变化。

我们需要一种方法在测试之间执行清理工作,有点儿类似于 setUptearDown,或者像 Django 测试运行程序一样,运行完每个测试后还原数据库。idqunit-fixture

元素就是我们正在寻找的方法。把表单移到这个元素中:

listsstatictests/tests.html

  1. <div id="qunit"></div>
  2. <div id="qunit-fixture">
  3. <form>
  4. <input name="text" >
  5. <div class="has-error">Error text<div>
  6. </form>
  7. </div>
  8. <script src="../jquery-3.1.1.min.js"></script>

你可能已经猜到了,每次运行测试前,jQuery 都会还原这个固件元素中的内容。因此,两个测试都能通过了:

  1. 4 assertions of 4 passed, 0 failed.
  2. 1. smoke test (2)
  3. 2. smoke test 2 (2)

16.4 为想要实现的功能编写JavaScript单元测试

现在我们已经熟悉这个 JavaScript 测试工具了,所以可以只留下一个测试,开始编写真正的测试代码了:

listsstatictests/tests.html

  1. <script>
  2. QUnit.test("errors should be hidden on keypress", function (assert) {
  3. $('input[name="text"]').trigger('keypress');
  4. assert.equal($('.has-error').is(':visible'), false);
  5. });
  6. </script>

➊ jQuery 提供的 .trigger 方法主要用于测试,作用是在指定的元素上触发一个 JavaScript DOM 事件。这里使用的是 keypress 事件,当用户在指定的输入框中输入内容时,浏览器就会触发这个事件。

04 - 图17 这里 jQuery 隐藏了很多复杂的细节。不同浏览器之间处理事件的方式大不一样,详情请访问 Quirksmode.org。jQuery 之所以这么受欢迎就是因为它消除了这些差异。

这个测试的结果为:

  1. 0 assertions of 1 passed, 1 failed.
  2. 1. errors should be hidden on keypress (1, 0, 1)
  3. 1. failed
  4. Expected: false
  5. Result: true

假设我们想把代码放在单独的 JavaScript 文件中,命名为 list.js:

listsstatictests/tests.html

  1. <script src="../jquery-3.1.1.min.js"></script>
  2. <script src="../list.js"></script>
  3. <script src="qunit-2.0.1.js"></script>
  4. <script>
  5. [...]

若想让这个测试通过,所需的最简代码如下所示:

listsstaticlist.js

  1. $('.has-error').hide();

确实通过了:

  1. 1 assertions of 1 passed, 0 failed.
  2. 1. errors should be hidden on keypress (1)

但显然还有个问题,最好再添加一个测试:

listsstatictests/tests.html

  1. QUnit.test("errors should be hidden on keypress", function (assert) {
  2. $('input[name="text"]').trigger('keypress');
  3. assert.equal($('.has-error').is(':visible'), false);
  4. });
  5. QUnit.test("errors aren't hidden if there is no keypress", function (assert) {
  6. assert.equal($('.has-error').is(':visible'), true);
  7. });

得到一个预期的失败:

  1. 1 assertions of 2 passed, 1 failed.
  2. 1. errors should be hidden on keypress (1)
  3. 2. errors aren't hidden if there is no keypress (1, 0, 1)
  4. 1. failed
  5. Expected: true
  6. Result: false
  7. [...]

然后,可以使用一种更真实的实现方式:

listsstaticlist.js

  1. $('input[name="text"]').on('keypress', function () {
  2. $('.has-error').hide();
  3. });

➊ 这行代码的意思是:查找所有 name 属性为“text”的 input 元素,然后在找到的每个元素上附属一个事件监听器,作用在 keypress 事件上。事件监听器是那个行间函数,其作用是隐藏类为 .has-error 的所有元素。

这样能让测试通过吗?不能。

  1. 1 assertions of 2 passed, 1 failed.
  2. 1. errors should be hidden on keypress (1, 0, 1)
  3. 1. failed
  4. Expected: false
  5. Result: true
  6. [...]
  7. 2. errors aren't hidden if there is no keypress (1)

可恶!这是为什么呢?

16.5 固件、执行顺序和全局状态:JavaScript 测试的重大挑战

一般来说,JavaScript 的一大难点,尤其对测试而言,就是理解代码的执行顺序(何时发生什么)。我们想知道 list.js 中的代码何时运行,每个测试何时运行;我们还想知道代码的运行对全局状态(网页的 DOM)有何影响,以及每次测试后是如何清理固件的。

16.5.1 使用console.log打印调试信息

下面在测试中添加几个 console.log,打印调试信息:

listsstatictests/tests.html

  1. <script>
  2. console.log('qunit tests start');
  3. QUnit.test("errors should be hidden on keypress", function (assert) {
  4. console.log('in test 1');
  5. $('input[name="text"]').trigger('keypress');
  6. assert.equal($('.has-error').is(':visible'), false);
  7. });
  8. QUnit.test("errors aren't hidden if there is no keypress", function (assert) {
  9. console.log('in test 2');
  10. assert.equal($('.has-error').is(':visible'), true);
  11. });
  12. </script>

然后在 JavaScript 代码中也这样做:

listsstaticlist.js (ch14l015)

  1. $('input[name="text"]').on('keypress', function () {
  2. console.log('in keypress handler');
  3. $('.has-error').hide();
  4. });
  5. console.log('list.js loaded');

运行测试,打开浏览器的调试控制台(通常可按 Ctrl-Shift-I 组合键),你应该会看到类似图 16-3 所示的输出。

04 - 图18

图 16-3:在 QUnit 测试中使用 console.log 输出的调试信息

我们看到了什么?

  • list.js 先被加载,因此事件监听器应该依附到输入元素上了。
  • 然后加载 QUnit 测试文件。
  • 最后运行各个测试。

但仔细一想,每个测试都会“还原”固件 div,也就是销毁输入元素后再重建。因此,每次运行测试时,list.js 看到并依附事件监听器的输入元素都是全新的。

16.5.2 使用初始化函数精确控制执行时间

我们需要进一步掌控 JavaScript 的执行顺序,而不是依赖 标签加载并运行 list.js 中的代码。为此,常见的做法是定义“初始化”函数,在测试(以及真实的场景)中需要的地方调用:

listsstaticlist.js

  1. var initialize = function () {
  2. console.log('initialize called');
  3. $('input[name="text"]').on('keypress', function () {
  4. console.log('in keypress handler');
  5. $('.has-error').hide();
  6. });
  7. };
  8. console.log('list.js loaded');

然后在测试文件中的每个测试中调用 initialize

listsstatictests/tests.html (ch14l017)

  1. QUnit.test("errors should be hidden on keypress", function (assert) {
  2. console.log('in test 1');
  3. initialize();
  4. $('input[name="text"]').trigger('keypress');
  5. assert.equal($('.has-error').is(':visible'), false);
  6. });
  7. QUnit.test("errors aren't hidden if there is no keypress", function (assert) {
  8. console.log('in test 2');
  9. initialize();
  10. assert.equal($('.has-error').is(':visible'), true);
  11. });

现在,测试能通过,而且调试输出更合理了:

  1. 2 assertions of 2 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. list.js loaded
  5. qunit tests start
  6. in test 1
  7. initialize called
  8. in keypress handler
  9. in test 2
  10. initialize called

太棒了!下面把 console.log 删掉:

listsstaticlist.js

  1. var initialize = function () {
  2. $('input[name="text"]').on('keypress', function () {
  3. $('.has-error').hide();
  4. });
  5. };

把测试中的也删掉:

listsstatictests/tests.html

  1. QUnit.test("errors should be hidden on keypress", function (assert) {
  2. initialize();
  3. $('input[name="text"]').trigger('keypress');
  4. assert.equal($('.has-error').is(':visible'), false);
  5. });
  6. QUnit.test("errors aren't hidden if there is no keypress", function (assert) {
  7. initialize();
  8. assert.equal($('.has-error').is(':visible'), true);
  9. });

关键时刻到了。现在引入 jQuery 和我们的脚本,在真实的页面中调用初始化函数:

lists/templates/base.html (ch14l020)

  1. </div>
  2. <script src="staticjquery-3.1.1.min.js"></script>
  3. <script src="staticlist.js"></script>
  4. <script>
  5. initialize();
  6. </script>
  7. </body>
  8. </html>

04 - 图19 习惯做法是在 HTML 的 body 元素末尾引入脚本;这样的话,用户无须等到所有 JavaScript 都加载完就能看到页面中的内容。此外,还能保证运行脚本前加载了大部分 DOM。

然后运行功能测试:

  1. $ python manage.py test functional_tests.test_list_item_validation.\
  2. ItemValidationTest.test_error_messages_are_cleared_on_input
  3.  
  4. [...]
  5. Ran 1 test in 3.023s
  6.  
  7. OK

太棒了!做次提交:

  1. $ git add lists/static
  2. $ git commit -m "add jquery, qunit tests, list.js with keypress listeners"

16.6 经验做法:onload样板代码和命名空间

哦,还有一件事。initialize 这个函数名称太普通了,如果引入的某个第三方 JavaScript 工具也有名为 initialize 的函数呢?下面为其添加别人不太可能使用的“命名空间”:

listsstaticlist.js

  1. window.Superlists = {};
  2. window.Superlists.initialize = function () {
  3. $('input[name="text"]').on('keypress', function () {
  4. $('.has-error').hide();
  5. });
  6. };

❶ 声明一个对象,作为“window”全局对象的属性,为其起一个别人不太可能使用的名称。

❷ 然后把 initialize 函数设为命名空间中那个对象的属性。

04 - 图20 若想在 JavaScript 中处理命名空间,还有很多更取巧的方式,不过都太复杂,而且我也不是专家,无法一一说明。如果想深入学习,请搜索 require.js——这似乎已经成为了标准做法,至少在当下是这样。

listsstatictests/tests.html

  1. <script>
  2. QUnit.test("errors should be hidden on keypress", function (assert) {
  3. window.Superlists.initialize();
  4. $('input[name="text"]').trigger('keypress');
  5. assert.equal($('.has-error').is(':visible'), false);
  6. });
  7. QUnit.test("errors aren't hidden if there is no keypress", function (assert) {
  8. window.Superlists.initialize();
  9. assert.equal($('.has-error').is(':visible'), true);
  10. });
  11. </script>

最后,如果 JavaScript 需要和 DOM 交互,最好把相应的代码包含在 onload 样板代码中,确保在执行脚本之前完全加载了页面。目前的做法也能正常运行,因为 标签在页面底部,但不能依赖这种方式。

jQuery 提供的 onload 样板代码非常简洁:

lists/templates/base.html

  1. <script>
  2. $(document).ready(function () {
  3. window.Superlists.initialize();
  4. });
  5. </script>

更多信息请阅读 jQuery .ready() 的文档。

16.7 JavaScript测试在TDD循环中的位置

你可能想知道 JavaScript 测试在双重 TDD 循环中处于什么位置。答案是,JavaScript 测试和 Python 单元测试扮演的角色完全相同。

(1) 编写一个功能测试,看着它失败。

(2) 判断接下来需要哪种代码,Python 还是 JavaScript ?

(3) 使用选中的语言编写单元测试,看着它失败。

(4) 使用选中的语言编写一些代码,让测试通过。

(5) 重复上述步骤。

04 - 图21 想多练习使用 JavaScript 吗?当用户在输入框内点击或者输入内容时,看看你能否隐藏错误消息。实现的过程中应该还要编写功能测试。

我们几乎可以进入第三部分了,但在此之前还有最后一步:把修改后的新代码部署到服务器中。别忘了最后再提交一次(包含 base.html)!

16.8 一些缺憾

本章的目的是介绍 JavaScript 测试基础知识,并说明 JavaScript 测试在 TDD 循环中的位置。下面几点可做深入研究。

  • 目前的测试只检查 JavaScript 能否在一个页面中使用。JavaScript 之所以能使用,是因为在 base.html 中引入了 JavaScript 文件。如果只在 home.html 中引入 JavaScript 文件,测试也能通过。你可以选择在哪个文件中引入,但也可以再编写一个测试。
  • 编写 JavaScript 时,应该尽量利用编辑器提供的协助,避免常见的问题。试一下句法 / 错误检查工具(linter),例如 jslint 和 jshint。
  • QUnit 希望你在真正的 Web 浏览器中“运行”测试,这样有利于创建与网站的真实内容匹配的 HTML 固件,供测试使用。但是,JavaScript 测试也能在命令行中运行。第 24 章有个例子。
  • 前端开发圈目前流行 angular.js 和 React 这样的 MVC 框架。这些框架的教程大都使用一个 RSpec 式断言库,名为 Jasmine。如果你想使用 MVC 框架,使用 Jasmine 比 QUnit 更方便。

本书后面还会涉及 JavaScript。如果你有兴趣,可以翻阅附录 F 的内容。

JavaScript 测试笔记

  • Selenium 最大的优势之一是可以测试 JavaScript 是否真的能使用,就像测试 Python 代码一样。
  • JavaScript 测试运行库有很多,QUnit 和 jQuery 联系紧密,这是我选择使用它的主要原因。
  • 不管使用哪个测试库,都要设法解决 JavaScript 测试面对的主要挑战:管理全局状态。这包括:
    • DOM/HTML固件;
    • 命名空间;
    • 理解并控制执行顺序。
  • 我说 JavaScript 很糟糕并不是出于真心。JavaScript 其实也可以很有趣。不过我还是要再说一次:一定要阅读《JavaScript 语言精粹》。

第 17 章 部署新代码

现在可以把全新的验证代码部署到线上服务器了。借此机会,我们也能再次在实践中使用自动化部署脚本。

04 - 图22 此刻,我由衷地感谢 Andrew Godwin 和整个 Django 团队。在 Django 1.7 之前,我写了很长一节内容,专门说明如何迁移。现在,因为迁移可以自动执行,所以那整节内容都不需要了。感谢你们的辛勤工作。

17.1 部署到过渡服务器

先部署到过渡服务器中:

  1. $ git push
  2. $ cd deploy_tools
  3. $ fab deploy:host=elspeth@superlists-staging.ottg.eu
  4. Disconnecting from superlists-staging.ottg.eu... done.

重启 Gunicorn:

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

然后在过渡服务器中运行测试:

  1. $ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests
  2. OK

17.2 部署到线上服务器

假设在过渡服务器上一切正常,那么就可以运行脚本,部署到线上服务器:

  1. $ fab deploy:host=elspeth@superlists.ottg.eu
  2.  
  3. elspeth@server:$ sudo service gunicorn-superlists.ottg.eu restart

17.3 如果看到数据库错误该怎么办

迁移中引入了一个完整性约束,你可能会发现迁移执行失败,因为某些现有的数据违背了约束规则。

此时有两个选择。

  • 删除服务器中的数据库,然后再部署试试。毕竟这只是一个小项目!
  • 或者,学习如何迁移数据(参见附录 D)。