第7章 步步为营
7.1 必要时做少量的设计
7.1.1 不要预先做大量设计
7.1.2 YAGNI
7.1.3 REST
本章便签 :
- 调整模型,让待办事项和不同的清单关联起来
- 为每个清单添加唯一的URL
- 添加通过POST请求新建清单所需URL
- 添加通过POST请求在现有的清单中增加新待办事项所需的URL
7.2 使用TDD实现新设计
7.3 确保出现回归测试
引入第二个用户,确认他的待办事项清单与第一个用户是分开的。
self.wait_for_row_in_list_table('2: Use peacock feathers to make a fly')
self.wait_for_row_in_list_table('1: Buy peacock feathers')
def test_multiple_users_can_start_lists_at_different_urls(self):
# 乔伊新建一个待办事项清单
self.browser.get(self.live_server_url)
inputbox = self.browser.find_element_by_id('id_new_item')
inputbox.send_keys('Buy peacock feathers')
inputbox.send_keys(Keys.ENTER)
self.wait_for_row_in_list_table('1: Buy peacock feathers')
# 她注意到清单有个唯一的URL
edith_list_url = self.browser.current_url
self.assertRegex(edith_list_url, '/lists/.+')
# 现在一名焦作弗朗西斯的新用户访问了网站
## 我们使用了一个新浏览器会话
## 确保乔伊的信息不会从cookie中泄露出去
self.browser.quit()
self.browser = webdriver.Firefox()
# 弗朗西斯访问首页
# 页面中看不到乔伊的清单
self.browser.get(self.live_server_url)
page_text = self.browser.find_element_by_tag_name('body').text
self.assertNotIn('Buy peacock feathers', page_text)
self.assertNotIn('make a fly', page_text)
# 弗朗西斯输入一个新待办事项,新建一个清单
# 他不像乔伊那样兴趣盎然
inputbox = self.browser.find_element_by_id('id_new_item')
inputbox.send_keys('Buy milk')
inputbox.send_keys(Keys.ENTER)
self.wait_for_row_in_list_table('1: Buy milk')
# 弗朗西斯获得了他的唯一URL
francis_list_url = self.browser.current_url
self.assertRegex(francis_list_url, '/lists/.+')
self.assertNotEqual(francis_list_url, edith_list_url)
# 这个页面还是没有乔伊的清单
page_text = self.browser.find_element_by_tag_name('body').text
self.assertNotIn('Buy peacock feathers', page_text)
self.assertIn('Buy milk', page_text)
#两个人都很满意,然后去休息了
self.assertRegex(edith_list_url, '/lists/.+')
AssertionError: Regex didn't match: '/lists/.+' not found in 'http://localhost:49669/'
提交:git commit -m 'second user functional_test'
7.4 逐步迭代,实现新设计
lists/tests.py
def test_redirects_after_POST(self):
response = self.client.post('/', data={'item_text': 'A new list item'})
self.assertEqual(response.status_code, 302)
self.assertEqual(response['location'], '/lists/the-only-list-in-the-world/')
功能测试:AssertionError: '/' != '/lists/the-only-list-in-the-world/'
修改视图
def home_page(request):
if request.method == 'POST':
Item.objects.create(text=request.POST['item_text'])
return redirect('/lists/the-only-list-in-the-world/')
功能测试:selenium.common.exceptions.NoSuchElementException: Message: Unable to locate element: [id="id_list_table"]
7.5 自成一体的第一步:新的URL
lists/tests.py 添加新的测试类
class ListViewTest(TestCase):
def test_display_all_items(self):
Item.objects.create(text='itemey 1')
Item.objects.create(text='itemey 2')
respinse = self.client.get('/lists/the-only-list-in-the-world/')
self.assertContains(respinse, 'itemey 1')
self.assertContains(respinse, 'itemey 2')
7.5.1 一个新URL
urlpatterns = [
url(r'^$', views.home_page, name='home'),
url(r'^lists/the-only-list-in-the-world/$', views.view_list, name='view_list'),
]
单元测试:AttributeError: module 'lists.views' has no attribute 'view_list'
7.5.2 一个新的视图函数
def view_list(request):
pass
单元测试:ValueError: The view lists.views.view_list didn't return an HttpResponse object. It returned None instead.
def view_list(request):
items = Item.objects.all()
return render(request, 'home.html', {'items': items})
通过单元测试
功能测试:AssertionError: 'Buy peacock feathers' unexpectedly found in 'Your To-Do list\n1: Buy peacock feathers'
根据经验,当所有单元测试都能通过而功能测试不能通过时,问题通常是由单元测试没有覆盖的事物引起的--这往往是模板的问题。
<form method="POST" action="/">
AssertionError: 'Buy peacock feathers' unexpectedly found in 'Your To-Do list\n1: Buy peacock feathers'
7.6 变绿了吗?该重构了
删除lists/tests.py
def test_display_all_list(self):
Item.objects.create(text='itemey 1')
Item.objects.create(text='itemey 2')
response = self.client.get('/')
self.assertIn('itemey 1', response.content.decode())
self.assertIn('itemey 2', response.content.decode())
首页不需要显示所有待办事项,只显示一个输入框
7.7 再迈一小步:一个新模板,用于查看清单
首页和清单视图是不同页面,就应该使用不同模板。
检查是否使用不同模板:
lists/tests.py
class ListViewTest(TestCase):
def test_uses_list_template(self):
response = self.client.get('/lists/the-only-list-in-the-world/')
self.assertTemplateUsed(response, 'list.html')
def test_display_all_items(self):
修改视图
def view_list(request):
items = Item.objects.all()
return render(request, 'list.html', {'items': items})
单元测试:django.template.exceptions.TemplateDoesNotExist: list.html
创建list.html (pycharm创建会提示是否git追加)
单元测试AssertionError: False is not true : Couldn't find 'itemey 1' in response
复制home.html到list.html
通过单元测试
进行重构
home.html
<body>
<h1>Start a new To-Do list</h1>
<form method="POST">
{% csrf_token %}
<input type="text" id="id_new_item" placeholder="Enter a to-do item" name="item_text">
</form>
</table>
</body>
视图:
def home_page(request):
if request.method == 'POST':
Item.objects.create(text=request.POST['item_text'])
return redirect('/lists/the-only-list-in-the-world/')
return render(request, 'home.html')
单元测试没有问题,运行功能测试:
AssertionError: '1: Buy milk' not found in ['1: Buy peacock feathers', '2: Buy milk']
提交:git commit -m 'new URL ,view and template to display lists'
7.8 第三小步:用于添加待办事项的URL
7.8.1 用来测试新建清单的测试类
lists/tests.py 把test_can_seve_a_POST_request 和test_redirects_after_POST移动到一个新类中,修改请求URL
class NewListTest(TestCase):
def test_can_save_a_POST_request(self):
response = self.client.post('/lists/new', data={'item_text': 'A new list item'})
self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.first()
self.assertEqual(new_item.text, 'A new list item')
def test_redirects_after_POST(self):
response = self.client.post('/lists/new', data={'item_text': 'A new list item'})
self.assertEqual(response.status_code, 302)
self.assertEqual(response['location'], '/lists/the-only-list-in-the-world/')
改进:
def test_redirects_after_POST(self):
response = self.client.post('/lists/new', data={'item_text': 'A new list item'})
self.assertRedirects(response, '/lists/the-only-list-in-the-world/')
单元测试:
AssertionError: 404 != 302 : Response didn't redirect as expected: Response code was 404 (expected 302)
7.8.2 用于新建清单的URL和视图
urlpatterns = [
url(r'^$', views.home_page, name='home'),
url(r'^lists/new$', views.new_list, name='new_list'),
url(r'^lists/the-only-list-in-the-world/$', views.view_list, name='view_list'),
]
单元测试:
AttributeError: module 'lists.views' has no attribute 'new_list'
视图:
def new_list(request):
pass
ValueError: The view lists.views.new_list didn't return an HttpResponse object. It returned None instead.
def new_list(request):
return redirect('/lists/the-only-list-in-the-world/')
AssertionError: 0 != 1
def new_list(request):
Item.objects.create(text=request.POST['item_text'])
return redirect('/lists/the-only-list-in-the-world/')
Ran 7 tests in 0.024s
OK
7.8.3 删除当前多余的代码和测试
def home_page(request):
return render(request, 'home.html')
删掉
def test_only_saves_items_when_necessary(self):
self.client.get('/')
self.assertEqual(Item.objects.count(), 0)
Ran 6 tests in 0.020s
OK
7.8.4 出现回归!让表单指向刚添加的新URL
功能测试:
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate element: [id="id_list_table"]
home.html,lists.html共同修改:
<form method="POST" action="/lists/new">
AssertionError: '1: Buy milk' not found in ['1: Buy peacock feathers', '2: Buy milk']\
提交:git commit -am 'commit 7.8'
待办清单:
- 调整模型,让待办事项和不同的清单关联起来
- 为每个清单添加唯一的URL
添加通过POST请求新建清单所需URL- 添加通过POST请求在现有的清单中增加新待办事项所需的URL
7.9 下定决心,调整模型
from django.test import TestCase
from lists.models import Item, List
class HomePageTest(TestCase):
def test_use_home_template(self):
response = self.client.get('/')
self.assertTemplateUsed(response, 'home.html')
class ListAndItemModelTest(TestCase):
def test_saving_and_retrieving_items(self):
list_ = List()
list_.save()
first_item = Item()
first_item.text = 'The first (ever) list item'
first_item.list = list_
first_item.save()
second_item = Item()
second_item.text = 'Item the second'
second_item.list = list_
second_item.save()
saved_list = List.objects.first()
self.assertEqual(saved_list, list_)
saved_items = Item.objects.all()
self.assertEqual(saved_items.count(), 2)
first_saved_item = saved_items[0]
second_saved_item = saved_items[1]
self.assertEqual(first_saved_item.text, 'The first (ever) list item')
self.assertEqual(first_saved_item.list, list_)
self.assertEqual(second_saved_item.text, 'Item the second')
self.assertEqual(second_saved_item.list, list_)
单元测试:ImportError: cannot import name 'List'
7.9.1 外键关系
from django.db import models
class Item(models.Model):
text = models.TextField(default='')
list = models.TextField(default='')
class List(models.Model):
pass
python manage.py makemigrations
AssertionError: 'List object' != <List: List object>
from django.db import models
class List(models.Model):
pass
class Item(models.Model):
text = models.TextField(default='')
list = models.ForeignKey(List, default=None)
删除之前的rm lists/migrations/0003_auto_20190209_1304.py
重新迁移python manage.py makemigrations
7.9.2 根据新模型定义调整其他代码
django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id
lists/tests.py
def test_display_all_items(self):
list_ = List.objects.create()
Item.objects.create(text='itemey 1', list=list_)
Item.objects.create(text='itemey 2', list=list_)
视图:
def new_list(request):
list_ = List.objects.create()
Item.objects.create(text=request.POST['item_text'], list=list_)
return redirect('/lists/the-only-list-in-the-world/')
通过单元测试
功能测试:AssertionError: '1: Buy milk' not found in ['1: Buy peacock feathers', '2: Buy milk']
git commit -m 'commit 7.9.2'
调整模型,让待办事项和不同的清单关联起来- 为每个清单添加唯一的URL
添加通过POST请求新建清单所需URL- 添加通过POST请求在现有的清单中增加新待办事项所需的URL
7.10 每个列表都应该有自己的URL
修改ListViewTest,让两个测试指向新URL。
test_display_all_items改为test_displays_only_items_for_that_list
class ListViewTest(TestCase):
def test_uses_list_template(self):
list_ = List.objects.create()
response = self.client.get(f'/lists/{list_.id}/') # 3.6新功能
self.assertTemplateUsed(response, 'list.html')
def test_displays_only_items_for_that_list(self):
correct_list = List.objects.create()
Item.objects.create(text='itemey 1', list=correct_list)
Item.objects.create(text='itemey 2', list=correct_list)
other_list = List.objects.create()
Item.objects.create(text='other list item 1', list=other_list)
Item.objects.create(text='other list item 2', list=other_list)
respinse = self.client.get(f'/lists/{correct_list.id}/')
self.assertContains(respinse, 'itemey 1')
self.assertContains(respinse, 'itemey 2')
self.assertNotContains(respinse, 'other list item 1')
self.assertNotContains(respinse, 'other list item 2')
单元测试:
AssertionError: 404 != 200 : Couldn't retrieve content: Response code was 404 (expected 200)
AssertionError: No templates used to render the response
7.10.1 捕获URL中的参数
urlpatterns = [
url(r'^$', views.home_page, name='home'),
url(r'^lists/new$', views.new_list, name='new_list'),
url(r'^lists/(.+)/$', views.view_list, name='view_list'),
]
TypeError: view_list() takes 1 positional argument but 2 were given
视图:
def view_list(request,list_id):
AssertionError: 1 != 0 : Response should not contain 'other list item 1'
视图:
def view_list(request,list_id):
list_ = List.objects.get(id=list_id)
items = Item.objects.filter(list=list_)
return render(request, 'list.html', {'items': items})
ValueError: invalid literal for int() with base 10: 'the-only-list-in-the-world'
7.10.2 按照新设计调整new_list视图
修改lists/tests.py
def test_redirects_after_POST(self):
response = self.client.post('/lists/new', data={'item_text': 'A new list item'})
new_list = List.objects.first()
self.assertRedirects(response, f'/lists/{new_list.id}/')
视图
def new_list(request):
list_ = List.objects.create()
Item.objects.create(text=request.POST['item_text'], list=list_)
return redirect(f'/lists/{list_.id}/')
通过单元测试
7.11 功能测试又检测到回归
功能测试:AssertionError: '2: Use peacock feathers to make a fly' not found in ['1: Use peacock feathers to make a fly']
调整模型,让待办事项和不同的清单关联起来为每个清单添加唯一的URL添加通过POST请求新建清单所需URL- 添加通过POST请求在现有的清单中增加新待办事项所需的URL
7.12 还需要一个视图,把待办事项加入现有清单
def test_can_save_a_POSE_request_to_an_existing_list(self):
other_list = List.objects.create()
correct_list = List.objects.create()
self.client.post(
f'/lists/{correct_list.id}/add_item',
data={'item_text': 'A new item for an existing list'}
)
self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.first()
self.assertEqual(new_item.text, 'A new item for an existing list')
self.assertEqual(new_item.list, correct_list)
def test_redirects_to_list_view(self):
other_list = List.objects.create()
correct_list = List.objects.create()
response = self.client.post(
f'/lists/{correct_list.id}/add_item',
data={'item_text': 'A new item for an existing list'}
)
self.assertRedirects(response, f'/lists/{correct_list.id}/')
单元测试:AssertionError: 301 != 302 : Response didn't redirect as expected: Response code was 301 (expected 302)
7.12.1 小心霸道的正则表达式
url(r'^lists/(\d+)/$', views.view_list, name='view_list')
AssertionError: 404 != 302 : Response didn't redirect as expected: Response code was 404 (expected 302)
7.12.2 z最后一个新URL
urlpatterns = [
url(r'^$', views.home_page, name='home'),
url(r'^lists/new$', views.new_list, name='new_list'),
url(r'^lists/(\d+)/$', views.view_list, name='view_list'),
url(r'^lists/(\d+)/add_item$', views.add_item, name='add_item'),
]
调整模型,让待办事项和不同的清单关联起来为每个清单添加唯一的URL添加通过POST请求新建清单所需URL添加通过POST请求在现有的清单中增加新待办事项所需的URL- 重构urls.py,去除重复
AttributeError: module 'lists.views' has no attribute 'add_item'
7.12.3 最后一个新视图
def add_item(request):
pass
TypeError: add_item() takes 1 positional argument but 2 were given
def add_item(request, list_id):
pass
ValueError: The view lists.views.add_item didn't return an HttpResponse object. It returned None instead.
def add_item(request, list_id):
list_ = List.objects.get(id=list_id)
return redirect(f'/lists/{list_.id}/')
self.assertEqual(Item.objects.count(), 1)
AssertionError: 0 != 1
def add_item(request, list_id):
list_ = List.objects.get(id=list_id)
Item.objects.create(text=request.POST['item_text'], list=list_)
return redirect(f'/lists/{list_.id}/')
通过单元测试
7.12.4 直接测试响应上下文对象
lists/tests.py
def test_passes_correct_list_to_tmplate(self):
other_list = List.objects.create()
correct_list = List.objects.create()
response = self.client.get(f'/lists/{correct_list.id}/')
self.assertEqual(response.context['list'], correct_list)
list.html
<form method="POST" action="/lists/{{ list.id }}/add_item">
KeyError: 'list'
视图:
def view_list(request, list_id):
list_ = List.objects.get(id=list_id)
return render(request, 'list.html', {'list': list_})
AssertionError: False is not true : Couldn't find 'itemey 1' in response
修改list.html
<table id="id_list_table">
{% for item in list.item_set.all %}
<tr><td>{{ forloop.counter }}: {{ item.text }}</td></tr>
{% endfor %}
</table>
通过单元测试
通过功能测试
7.13 使用URL引入做最后一次重构
如果某些URL只在lists应用中使用,Django建议使用单独的lists/urls.py.
TDDweb/urls.py
from django.conf.urls import url,include
from django.contrib import admin
from lists import views
from lists import urls as list_urls
urlpatterns = [
url(r'^admin/', admin.site.urls),
url(r'^$', views.home_page, name='home'),
url(r'^lists/', include(list_urls)),
]
TDDweb/urls.py
urlpatterns = [
url(r'^new$', views.new_list, name='new_list'),
url(r'^(\d+)/$', views.view_list, name='view_list'),
url(r'^(\d+)/add_item$', views.add_item, name='add_item'),
]
通过所有测试