PythonWeb
  • Introduction
  • Python Web 开发: 测试驱动方法
    • 准备工作和应具备的知识
    • 前言
    • 第一部分 TDD 和 Django 基础
      • 第 1 章 使用功能测试协助安装 Django
      • 第 2 章 使用 unittest 模块扩展功能测试
      • 第 3 章 使用单元测试测试简单的首页
      • 第 4 章 编写这些测试有什么用
      • 第 5 章 保存用户输入
      • 第 6 章 完成最简可用的网站
    • 第二部分 Web 开发要素
      • 第 7 章 美化网站: 布局, 样式, 及其测试方法
      • 第 8 章 使用过渡网站测试部署
      • 第 9 章 使用 Fabric 自动部署
      • 第 10 章 输入验证和测试的组织方式
      • 第 11 章 简单的表单
      • 第 12 章 高级表单
      • 第 13 章 试探 JavaScript
      • 第 14 章 部署新代码
    • 第三部分 高级话题
      • 第 15 章 用户认证_集成第三方插件以及 JavaScript 模拟技术的使用
      • 第 16 章 服务器端认证,在 Python 中使用模拟技术
      • 第 17 章 测试固件,日志和服务器端调试
      • 第 18 章 完成My Lists页面:由外而内的TDD
      • 第 19 章 测试隔离和倾听测试的心声
      • 第 20 章 持续集成
      • 第 21 章 简单的社会化功能,页面模式,以及练习
      • 第 22 章 测试运行速度的快慢和炽热的岩浆
    • 附录
      • 附录 A PythonAnywhere
      • 附录 B 基于类的 Django 视图
      • 附录 C 使用 Ansible 配置服务器
      • 附录 D 测试数据库迁移
Powered by GitBook
On this page
  • 第 18 章 完成 "My Lists" 页面:由外而内的 TDD
  • 18.1 对立技术:“由内而外”
  • 18.2 为什么选择使用 “由外而内”
  • 18.3 "My Lists" 页面的功能测试
  • 18.4 外层:表现层和模板
  • 18.5 下移一层到视图函数(控制器)
  • 18.6 使用由外而内技术,再让一个测试通过
  • 18.7 视图层的下一个需求:新建清单时应该记录属主
  • 18.8 下移到模型层

Was this helpful?

  1. Python Web 开发: 测试驱动方法
  2. 第三部分 高级话题

第 18 章 完成My Lists页面:由外而内的TDD

第 18 章 完成 "My Lists" 页面:由外而内的 TDD

18.1 对立技术:“由内而外”

大多数人都凭直觉选择后者。提出一个设计想法之后,有时会自然而然地从最内部、最低层的组件开始实现。

这么做感觉更自然,因为所用的代码从来不会依赖尚未实现的功能。内层的一切都是构建外层的坚实基础。

18.2 为什么选择使用 “由外而内”

由内而外的技术最明显的问题是它迫使我们抛开 TDD 流程。

我们可能已经在脑海中构思好了内层的模样,而且这些想法往往都很好,不过这些都是对真实需求的推测,因为还未构造内层组件的外层组件。

这么做可能会导致内层组件太笼统,或者比真实需求功能更强——不仅浪费了时间,还把项目变得更为复杂。另一种常见的问题是,创建内层组件使用的 API 乍看起来对内部设计而言很合适,但之后会发现并不适用于外层组件。更糟的是,最后你可能会发现内层组件完全无法解决外层组件需要解决的问题。

由此相反,使用由外而内的工作方式,可以在外层组件的基础上构思想从内层组件获取的最佳 API。

18.3 "My Lists" 页面的功能测试

编写下面这个功能测试时,我们从能接触到的最外层开始(表现层),然后是视图函数(或叫“控制器”),最后是内层,比如模型代码。

既然 create_pre_authenticated_session 函数可以正常使用,那么久可以直接用来编写针对 "My Lists" 页面的功能测试:

# functional_tests/test_my_lists.py
    def test_logged_in_users_lists_are_saved_as_my_lists(self):
        email = "edith@mockmyid.com"  # 这个邮箱成功了, 我自己的邮箱好像还要密码所以就失败了?

        self.browser.get(self.server_url)
        self.wait_to_be_logged_out(email)

        # Y 是已登录用户
        self.create_pre_authenticated_session(email)

        # self.browser.get(self.server_url)
        # self.wait_to_be_logged_in(email)

        # 她访问首页,新建一个清单
        self.browser.get(self.server_url)
        self.get_item_input_box().send_keys("Reticulate splines\n")
        self.get_item_input_box().send_keys("Immanentize eschaton\n")
        first_list_url = self.browser.current_url

        # 她第一次看到 My Lists 链接
        self.browser.find_element_by_link_text("My Lists").click()

        # 她看到这个页面中有她创建的清单
        # 而且清单根据第一个待办事项命名
        self.browser.find_element_by_link_text("Reticulate splines").click()
        self.assertEqual(self.browser.current_url, first_list_url)

        # 她决定再建一个清单试试
        self.browser.get(self.server_url)
        self.get_item_input_box().send_keys("Click cows\n")
        second_list_url = self.browser.current_url

        # 在 My Lists 页面,这个新建的清单也显示出来了
        self.browser.find_element_by_link_text("My Lists").click()
        self.browser.find_element_by_link_text("Click cows").click()
        self.assertEqual(self.browser.current_url, second_list_url)

        # 她退出后, My Lists 链接不见了
        self.browser.find_element_by_id("id_logout").click()
        self.assertEqual(self.browser.find_elements_by_link_text("My Lists"), [])

运行这个测试,可以看到预期的错误。

python3.4 manage.py test functional_tests.test_my_lists.MyListsTest.test_logged_in_users_lists_are_saved_as_my_lists

18.4 外层:表现层和模板

目前,这个测试失败,报错无法找到 "My Lists" 链接。这个问题可以在表现层,即 base.html 模板里的导航条中解决。最少量的代码改动:

<!-- lists/templates/base.html -->
{% if user.email %}
    <ul class="nav navbar-nav">
      <li><a href="#">My Lists</a></li>
    </ul>
    <a class="btn navbar-btn navbar-right" id="id_logout" [...]

显然,这个链接没指向任何页面,不过却能解决问题,得到下一个失败消息。

失败消息指出要构建一个页面,用标题列出一个用户的所有清单。

可以再次使用由外而内技术,先从表现层开始,只写上地址,其他什么都不做:

<!-- lists/templates/base.html -->
<ul class="nav navbar-nav">
  <li><a href="{% url 'my_lists' user.email %}">My Lists</a></li>
</ul>

18.5 下移一层到视图函数(控制器)

这样改还是会得到模板错误,所以要从表现层和 URL 层下移,进入控制器层,即 Django 中的视图函数。

先写测试:

# lists/tests/test_views.py
class MyListsTest(TestCase):
    def test_my_lists_url_renders_my_lists_template(self):
        response = self.client.get("/lists/users/a@b.com")
        self.assertTemplateUsed(response, "my_lists.html")

得到预期的失败测试结果。

然后修正这个问题,不过还在表现层,更准确地说是 urls.py:

# lists/urls.py
urlpatterns = [
    url(r"^(\d+)/$", lists.views.view_list, name="view_list"),
    url(r"^new$", lists.views.new_list, name="new_list"),
    url(r"^users/(.+)/$", "lists.views.my_lists", name="my_lists")
]

修改之后会得到另一个测试失败消息。

从表现层移到视图层,再定义一个最简单的占位视图:

# lists/views.py
def my_lists(request, email):
    return render(request, "my_lists.html")

以及一个最简单的模板:

<!-- lists/templates/my_lists.html -->
{% extends "base.html" %}
{% block header_text %}My Lists{% endblock %}

现在单元测试通过了,但功能测试毫无进展。

18.6 使用由外而内技术,再让一个测试通过

再次从外层开始,编写模板代码,让" My Lists"页面实现设想的功能。现在,要指定希望从低层获取的 API。

18.6.1 快速重组模板的继承层级

基模板目前没有地方放置新内容了,而且" My Lists"页面不需要新建待办事项清单,所以把表单放到一个块中,需要时才显示:

<!-- lists/templates/base.html -->
<div class="text-center">
  <h1>
    {% block header_text %}{% endblock %}
  </h1>

  {% block list_form %}
  <form method="POST" action="{% block form_action %}{% endblock %}">
    {{ form. text }}
    {% csrf_token %}
    {% if form.erros %}
    <div class="form-group has-error">
      <div class="help-block">
        {{ form.text.errors }}
      </div>
    </div>
    {% endif %}
  </form>
  {% endblock %}
</div>
[...]
    <div class="row">
        <div class="col-md-6 col-md-offset-3">
            {% block table %}
            {% endblock %}
        </div>
    </div>

    <div class="row">
        <div class="col-md-6 col-md-offset-3">
            {% block extra_content %}
            {% endblock %}
        </div>
    </div>
</div>

18.6.2 使用模板设计 API

同时,在 my_lists.html 中覆盖 list_form 块,把块中的内容清空:

<!-- lists/templates/my_lists.html -->
{% extends "base.html" %}
{% block header_text %}My Lists{% endblock %}
{% block list_form %}{% endblock %}

然后只在 extra_content 块中编写代码:

<!-- lists/templates/my_lists.html -->
[...]
{% block list_form %}{% endblock %}

{% block extra_content %}
    <h2>
        <!-- 需要一个名为 owner 的变量,在模板中表示用户 -->
        {{ owner.email }}'s lists
    </h2>
    <ul>
        <!-- 想使用 owner.list_set.all 遍历用户创建的清单(ORM 提供了这个属性) -->
        {% for list in owner.list_set.all %}
            <!-- 想使用 list.name 获取清单的名字,目前清单以其中的第一个待办事项命名 -->
            <li><a href="{{ list.get_absolute_url }}">{{ list.name }}</a></li>
        {% endfor %}
    </ul>
{% endblock %}

再次运行功能测试,确认没有造成任何破坏,或者是有所进展:

python3 manage.py test functional_tests

该提交了:

git add lists
git diff --staged
git commit -m "url, placehodler view, and first-cut templates for my_lists"

18.6.3 移到下一层:视图向模板中传入什么

# lists/tests/test_views.py
from django.contrib.auth import get_user_model
User = get_user_model()
[...]
class MyListsTest(TestCase):
    def test_my_lists_url_renders_my_lists_template(self):
        [...]

    def test_passes_correct_owner_to_template(self):
        User.objects.create(email="wrong@owner.com")
        correct_user = User.objects.create(email="a@b.com")
        response = self.client.get("/lists/users/a@b.com/")
        self.assertEqual(response.context["owner"], correct_user)

视图没有传入 owner,于是:

# lists/views.py
from django.contrib.auth import get_user_model
User = get_user_model()
[...]

def my_lists(request, email):
    owner = User.objects.get(email=email)
    return render(request, "my_lists.html", {"owner": owner})

这样修改之后,新测试通过了,但还是能看到前一个测试导致的错误。只需要在这个测试中添加一个用户即可:

# lists/tests/test_views.py
def test_my_lists_url_renders_my_lists_template(self):
    User.objects.create(email="a@b.com")
    [...]

18.7 视图层的下一个需求:新建清单时应该记录属主

下移到模型层之前,视图层还有一部分代码要用到模型:如果当前用户已经登录网站,需要一种方式把新建的清单指派给一个属主。

初期编写的测试如下所示:

# lists/tests/test_views.py
from django.http import HttpRequest
[...]
from lists.views import new_list
[...]

class NewListTest(TestCase):
    [...]

    def test_list_owner_is_saved_if_user_is_authenticated(self):
        request = HttpRequest()
        request.user = User.objects.create(email="a@b.com")
        request.POST["text"] = "new list item"
        new_list(request)
        list_ = List.objects.first()
        self.assertEqual(list_.owner, request.user)

这个测试直接调用视图函数,而且手动构造了一个 HttpRequest 对象,因为这么写测试稍微简单些。虽然 Django 测试客户端提供了辅助函数 login,但在外部认证系统中用起来并不顺手。此外,还可以手动创建会话对象,或者使用驭件。不过这两种方式写出来的代码并不好看。

按照测试失败的消息来改进代码,首先尝试如下编写:

# lists/views.py
def new_list(request):
    form = ItemForm(data=request.POST)
    if form.is_valid():
        list_ = List()
        list_.owner = request.user
        list_.save()
        form.save(for_list=list_)
        return redirect(list_)
    else:
        return render(request, "home.html", {"form": form})

但这个视图解决不了问题,因为还不知道怎么保存清单的属主。

抉择时刻:测试失败时是否要移入下一层

为了让这个测试通过,要下移到模型层。

可以采用另一种策略,使用驭件把测试和下层组件更明显地隔离开。

一方面,使用驭件要做的工作更多,而且驭件会让测试代码更难读懂。另一方面,如果应用更复杂,外部和内部之间的分层更多,测试就会涉及多层。

这里先走捷径,放任测试失败不管。

下面做次提交,并且为这次提交打上标签。

git commit -am "new_list view tries to assign owner bat cant"
git tag revisit_this_point_with_isolated_tests

18.8 下移到模型层

使用由外而内技术得出了两个需求,需要在模型层实现:其一,想使用 .owner 属性为清单指派一个属主;其二,想使用 API owner.list_set.all 获取清单的属主。

针对这两个需求,先编写一个测试:

# lists/tests/test_models.py
from django.contrib.auth import get_user_model
User = get_user_model()
[...]

class ListModelTest(TestCase):
    def test_get_absolute_url(self):
        [...]

    def test_lists_can_have_owners(self):
        user = User.objects.create(email="a@b.com")
        list_ = List.objects.create(owner=user)
        self.assertIn(list_, user.list_set.all())

得到了一个失败的单元测试,接着把模型写成下面这样:

from django.conf import settings
[...]

class List(models.Model):
    owner = models.ForeignKey(settings.AUTH_USER_MODEL)

可是我们希望属主可有可无,所以再编写一个测试来明确表示需求:

# lists/tests/test_models.py
def test_list_owner_is_optional(self):
    List.objects.create() # 不该抛出异常

于是可以实现正确的模型了:

# lists/models.py
from django.conf import settings
[...]

class List(models.Model):
    owner = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True)

    def get_absolute_url(self):
        return reverse("view_list", args=[self.id])

现在运行测试,会看到数据库错误,因此需要做一次迁移。

现在回到视图层,做些清理工作。注意,这些错误发生在针对 new_list 视图的测试中,而且用户没有登录。仅当用户登录后才应该保存清单的属主。(用户未登录时,Django 使用 AnonymousUser 类表示用户,此时 is_authenticated() 函数的返回值始终是 False):

# lists/views.py
if form.is_valid():
    list_ = List()
    if request.user.is_authenticated():
        list_.owner = request.user
    list_.save()
    form.save(for_list=list_)
    [...]

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

现在是提交的好时机了:

git add lists
git commit -m "lists can have owners, which are saved on creation."

最后一步:实现模板需要的 .name 属性

使用由外而内设计方式还有最后一个需求,即清单根据其中第一个待办事项命名:

# lists/tests/test_models.py
def test_list_name_is_first_item_text(self):
    list_ = List.objects.create()
    Item.objects.create(list_attr=list_, text="first item")
    Item.objects.create(list_attr=list_, text="second item")
    self.assertEqual(list_.name, "first item")
# lists/models.py
@property
def name(self):
    return self.item_set.first().text

这样测试就能通过了,而且 "My Lists" 页面也能使用了。

Python 中的 @property 修饰器

该修饰器的作用是把类中的方法转变成与属性一样,可以在外部访问。

这是 Python 语言一个强大的特性,因为很容易用它实现“鸭子类型”(duck typing),无需修改类的接口就能改变属性的实现方式。也就是说,如果想把 .name 改成模型真正的属性,在数据库中存储文本型数据,整个过程是完全透明的,只要兼顾其他代码,就能继续使用 .name 获取清单名,完全不用知道这个属性的具体实现方式。

不过,就算没使用 @property 修饰器,在 Django 的模板语言中还是会调用 .name 方法。不过这是 Django 专有的特性,不适用于一般的 Python 程序。

由外而内的 TDD

  • 由外而内的 TDD

    • 一种编写代码的方法,由测试驱动,从外层开始(表现层,GUI),然后逐步向内层移动,通过视图层或控制层,最终达到模型层。这种方法的理念是由实际需要使用的功能驱动代码的编写,而不是在低层猜测需求。

  • 一厢情愿式编程

    • 由外而内的过程有时也叫“一厢情愿式编程“。其实,任何 TDD 形式都涉及一厢情愿。我们总是为还未实现的功能编写测试。

  • 由外而内技术的缺点

    • 由外而内技术鼓励我们关注用户立即就能看到的功能,但不会自动提醒我们为不是那么明显的功能编写关键测试,例如安全相关的功能。

Previous第 17 章 测试固件,日志和服务器端调试Next第 19 章 测试隔离和倾听测试的心声

Last updated 5 years ago

Was this helpful?