测试驱动开发(TDD)是一个迭代的开发周期,强调编写实际代码之前编写自动化测试。
这个过程很简单:
- 先编写测试。
- 查看测试失败的地方
- 编写足够的代码以使测试通过。
- 再次测试。
- 代码重构 。
- 重复以上操作。
为什么要用TDD?
使用TDD,你将学会把你的代码拆分成符合逻辑的,简单易懂的片段,这有助于确保代码的正确性。
这一点非常重要,因为做到下面这些事情是非常困难的:
- 在我们的脑中一次性处理所有复杂的问题。
- 了解何时从哪里开始着手解决问题。
- 在代码库的复杂度不断增长的同时不引入错误和bug;并且
- 辨别出代码在什么时候发生了问题。
TDD帮助我们定位问题。它不能保证你的代码完全没有错误;然而,你可以写出更好的代码,从而能更好地理解理解代码。这本身有助于消除错误,并且至少,你可以更容易的定位错误。
TTD实际上也是一种行业标准。
说的够多了。让我们来看看代码吧。
在这个教程里,我们将创建一个存储用户联系人的app。
请注意: 这篇教程假设你运行在一个基于Unix的环境里 - 例如, Mac OSX, Linux, 或者在Windows下的Linux VM。 我将使用Sublime 2作为文本编辑器。并且,确保你已经完成了官方的Django教程并且基本了解Python语言. 此外,在这个第一篇post里,我们不会涉及到Django1.6提供的新工具。这篇文章将为之后的post打好基础来处理不同形式的测试。
第一个测试
在开始做一些事情之前,我们需要首先创建一个测试。为了这个测试,我们需要让Django正确安装。为此我们将使用一个函数测试——这在下面会详细解释。
创建一个新目录存放你的项目:
1
2
|
$ mkdir django-tdd $ cd django-tdd |
再建立一个目录存放函数测试
1
2
|
$ mkdir ft $ cd ft |
创建一个新文件 "tests.py"并加入以下代码:
1
2
3
4
5
|
from selenium import webdriver browser = webdriver.Firefox()browser.get( 'http://localhost:8000/' )body = browser.find_element_by_tag_name( 'body' ) assert 'Django' in body.text browser.quit() |
现在运行测试:
1
|
$ python tests.py |
确认安装selenium(译注:自动化测试软件)时是使用 installed -pip安装的
你将看到 FireFox弹出来试图打开 http://localhost:8000/。在你的终端上面你会看到:
1
|
Traceback (most recent call last): File "tests.py" , line 7 , in <module> assert 'Django' in body.textAssertionError |
祝贺!你完成了第一个失效测试。
现在我们写足够的代码来让它通过,这些代码量约相当于设置一个 Django 开发环境。
设置Django
1. 激活一个virtualenv:
1
2
3
|
$ cd .. $ virtualenv --no-site-packages env $ source env /bin/activate |
2. 安装Django并且建立一个项目
1
|
$ pip install django==1.6.1$ django-admin.py startproject contacts |
你当前的项目结构应该是下面这个样子:
├── contacts
│ ├── contacts
│ │ ├── __init__.py
│ │ ├── settings.py
│ │ ├── urls.py
│ │ └── wsgi.py
│ └── manage.py
└── ft
└── tests.py
3. 安装 Selenium:
1
|
pip install selenium==2.39.0 |
4. 运行server
1
2
|
$ cd contacts $ python manage.py runserver |
5. 接着,打开一个新终端窗口,定位到"ft"文件夹下,再运行一次测试:
$ python tests.py
你将看到FireFox又一次窗口导航到了http://localhost:8000/。这次应该没有错误了。你刚刚已经通过了你的第一个测试!现在,让我们完成环境设置。
6. 版本控制,首先添加一个".gitignore"并且在里面添加下面的代码:
.Pythonenv
bin
lib
include.DS_Store.pyc
现在来创建一个Git仓库然后提交吧
1
2
|
$ git init $ git add .$ git commit -am "initial" |
7. 项目建完了,现在我们回头讨论一下功能测试吧。
功能测试
我们通过用 Selenium 来进行第一次测试。这样的测试会使我们使用web浏览器就像我们是最终用户一样,来看看应用程序实际上是怎么运行的。因为这些测试是遵循最终用户的行为习惯——也可以说是用户用例——这个包含了对一系列产品特点进行测试,而不仅仅对单一功能进行测试——这种更适合单元测试。有一点非常需要注意的是,当这部分测试代码你还没开始写,那么你必须先从功能测试开始。由于我们基本上是测试Django的代码,所以功能测试是一个正确的方法去做的。
另一种方式去思考功能测试和单元测试的区别,就是功能测试主要关注在应用程序的外部,从用户的角度来进行测试,而单元测试主要是关注在应用程序的内部,从开发的角度进行测试。
在实践中会更多地体现这个概念。
在继续下个话题之前,我们先来重构我们的测试环境,使得测试起来更加简单。
首先,我们要重写在“tests.py”文件内的第一个测试:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
from selenium import webdriverfrom selenium.webdriver.common.keys import Keysfrom django.test import LiveServerTestCaseclass AdminTest(LiveServerTestCase): def setUp( self ): self .browser = webdriver.Firefox() def tearDown( self ): self .browser.quit() def test_admin_site( self ): # user opens web browser, navigates to admin page self .browser.get( self .live_server_url + '/admin/' ) body = self .browser.find_element_by_tag_name( 'body' ) self .assertIn( 'Django administration' , body.text) |
然后运行它:
1
|
$ python manage.py test ft |
它会通过:
1
|
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Ran 1 test in 3.304sOK |
恭喜你!
在继续之前,我们先看看这里是怎么回事。如果所有都通过了,你也会看到FireFox浏览器被打开,然后按照我们在测试里所用的setUp()和tearDown()方法设置的功能进行整个过程。这个测试本身只是简单的测试这个"/admin" (self.browser.get(self.live_server_url + '/admin/')页面是否被找到,"Django administration"这个单词是否出现在body标签内。
让我们确认一下。
运行服务:
1
|
$ python manage.py runserver |
在地址栏里敲上地址 http://localhost:8000/admin/ 你会看到:
我们可以只需对错误的东西进行简单地测试便能确认测试是否正确运作。更新测试里的最后一行:
1
|
self .assertIn( 'administration Django' , body.text) |
重新再运行一次。你会发现有以下的错误(当然是我们所期望的):
1
|
AssertionError: 'administration Django' not found in u 'Django administration\nUsername:\nPassword:\n ' |
修正测试,再测试一遍,就可以提交代码了。
最后,你有没有注意到,我们用来进行实际测试的功能名称均以test_开头。这是为了让Django测试运行器能找到这些测试。换句话来说,任何一个以test_开头命名的功能都会被测试运行器视为一个测试。
管理员登陆
接下来,让我们来测试,以确保用户可以登录到管理网站。
更新“tests.py”文件中的test_admin_site功能:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
def test_admin_site( self ): # user opens web browser, navigates to admin page self .browser.get( self .live_server_url + '/admin/' ) body = self .browser.find_element_by_tag_name( 'body' ) self .assertIn( 'Django administration' , body.text) # users types in username and passwords and presses enter username_field = self .browser.find_element_by_name( 'username' ) username_field.send_keys( 'admin' ) password_field = self .browser.find_element_by_name( 'password' ) password_field.send_keys( 'admin' ) password_field.send_keys(Keys.RETURN) # login credentials are correct, and the user is redirected to the main admin page body = self .browser.find_element_by_tag_name( 'body' ) self .assertIn( 'Site administration' , body.text) |
所以 -
find_element_by_name- 是用于定位输入框。
send_keys- 发送键盘按键信息。
运行测试,你会发现这个错误:
1
|
AssertionError: 'Site administration' not found in u 'Django administration\nPlease enter the correct username and password for a staff account. Note that both fields may be case-sensitive.\nUsername:\nPassword:\n ' |
这个之所以会失败,是因为我们没有管理员用户设置。这是一个预期中的失败,所以出现这种情况是对的。换句话来说,我们知道它会失败的,这使得我们更容易去解决它。
同步数据库:
1
|
$ python manage.py syncdb |
设置一个管理员用户。
再重新测试一遍。它依旧会失败。为什么呢?因为Django在运行的时候会给我们数据库创建一份副本,这样的测试方式不会影响生产数据库。
我们需要设置一个Fixture,是一个包含了我们想加载到测试数据库的数据文件:登录凭据。为了要实现这一点,当运行以下命令时,能够将数据库管理员用户信息从数据库转存到Fixture中去:
1
2
|
$ mkdir ft /fixtures $ python manage.py dumpdata auth.User --indent=2 > ft /fixtures/admin .json |
现在更新AdminTest类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
class AdminTest(LiveServerTestCase): # load fixtures fixtures = [ 'admin.json' ] def setUp( self ): self .browser = webdriver.Firefox() def tearDown( self ): self .browser.quit() def test_admin_site( self ): # user opens web browser, navigates to admin page self .browser.get( self .live_server_url + '/admin/' ) body = self .browser.find_element_by_tag_name( 'body' ) self .assertIn( 'Django administration' , body.text) # users types in username and passwords and presses enter username_field = self .browser.find_element_by_name( 'username' ) username_field.send_keys( 'admin' ) password_field = self .browser.find_element_by_name( 'password' ) password_field.send_keys( 'admin' ) password_field.send_keys(Keys.RETURN) # login credentials are correct, and the user is redirected to the main admin page body = self .browser.find_element_by_tag_name( 'body' ) self .assertIn( 'Site administration' , body.text) |
运行这个测试,它会通过。
每次运行测试的时候,Django都会转存测试数据库。而这所有的Fixture都会在“test.py”文件中被指定加载到数据库中去。
让我们加一个或多个断言。再次更新测试:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
def test_admin_site( self ): # user opens web browser, navigates to admin page self .browser.get( self .live_server_url + '/admin/' ) body = self .browser.find_element_by_tag_name( 'body' ) self .assertIn( 'Django administration' , body.text) # users types in username and passwords and presses enter username_field = self .browser.find_element_by_name( 'username' ) username_field.send_keys( 'admin' ) password_field = self .browser.find_element_by_name( 'password' ) password_field.send_keys( 'admin' ) password_field.send_keys(Keys.RETURN) # login credentials are correct, and the user is redirected to the main admin page body = self .browser.find_element_by_tag_name( 'body' ) self .assertIn( 'Site administration' , body.text) # user clicks on the Users link user_link = self .browser.find_elements_by_link_text( 'Users' ) user_link[ 0 ].click() # user verifies that user live@forever.com is present body = self .browser.find_element_by_tag_name( 'body' ) self .assertIn( 'live@forever.com' , body.text) |
运行它,它会失败,因为我们需要添加另一个用户到fixture文件中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
[{"pk": 1, "model": "auth.user", "fields": { "username": "admin", "first_name": "", "last_name": "", "is_active": true, "is_superuser": true, "is_staff": true, "last_login": "2013-12-29T03:49:13.545Z", "groups": [], "user_permissions": [], "password": "pbkdf2_sha256$12000$VtsgwjQ1BZ6u$zwnG+5E5cl8zOnghahArLHiMC6wGk06HXrlAijFFpSA=", "email": "ad@min.com", "date_joined": "2013-12-29T03:49:13.545Z"}},{"pk": 2, "model": "auth.user", "fields": { "username": "live", "first_name": "", "last_name": "", "is_active": true, "is_superuser": false, "is_staff": false, "last_login": "2013-12-29T03:49:13.545Z", "groups": [], "user_permissions": [], "password": "pbkdf2_sha256$12000$VtsgwjQ1BZ6u$zwnG+5E5cl8zOnghahArLHiMC6wGk06HXrlAijFFpSA=", "email": "live@forever.com", "date_joined": "2013-12-29T03:49:13.545Z"}}] |
再次运行,它是会通过的。如果需要可以重构一下这个测试。现在想想还有什么可以测试。或许你可以测试管理员用户可以添加一个用户到管理面板中,或者可以测试没有管理员权限的人是不能进入管理面板中。写几个测试,更新你的代码,再次测试,根据需要重构代码。
接下来,我们会添加增加联系人应用,不要忘了提交代码哦!
设置联系人应用
开始一个测试,添加以下功能:
1
2
3
4
5
6
7
8
9
10
|
def test_create_contact_admin( self ): self .browser.get( self .live_server_url + '/admin/' ) username_field = self .browser.find_element_by_name( 'username' ) username_field.send_keys( 'admin' ) password_field = self .browser.find_element_by_name( 'password' ) password_field.send_keys( 'admin' ) password_field.send_keys(Keys.RETURN) # user verifies that user_contacts is present body = self .browser.find_element_by_tag_name( 'body' ) self .assertIn( 'User_Contacts' , body.text) |
再次运行测试,你会看到以下错误:
1
|
AssertionError: 'User_Contacts' not found in u 'Django administration\nWelcome, admin. Change password / Log out\nSite administration\nAuth\nGroups\nAdd\nChange\nUsers\nAdd\nChange\nRecent Actions\nMy Actions\nNone available' |
这是预料之中的。
现在,我们要写足够的代码让它通过。
新建一个应用:
1
|
$ python manage.py startapp user_contacts |
添加到“settings.py”文件:
1
2
3
4
5
6
7
8
9
|
INSTALLED_APPS = ( 'django.contrib.admin' , 'django.contrib.auth' , 'django.contrib.contenttypes' , 'django.contrib.sessions' , 'django.contrib.messages' , 'django.contrib.staticfiles' , 'ft' , 'user_contacts' ,) |
在user_contacts目录下的“admin.py”文件中添加以下代码:
1
2
3
|
from user_contacts.models import Person, Phonefrom django.contrib import admin admin.site.register(Person)admin.site.register(Phone) |
你的工程架构会跟如下类似:
.├── user_contacts
│ ├── __init__.py
│ ├── admin.py
│ ├── models.py
│ ├── tests.py
│ └── views.py
├── contacts
│ ├── __init__.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── ft
│ ├── __init__.py
│ ├── fixtures
│ │ └── admin.json
│ └── tests.py
└── manage.py
更新“models.py”:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
from django.db import modelsclass Person(models.Model): first_name = models.CharField(max_length = 30 ) last_name = models.CharField(max_length = 30 ) email = models.EmailField(null = True , blank = True ) address = models.TextField(null = True , blank = True ) city = models.CharField(max_length = 15 , null = True ,blank = True ) state = models.CharField(max_length = 15 , null = True , blank = True ) country = models.CharField(max_length = 15 , null = True , blank = True ) def __unicode__( self ): return self .last_name + ", " + self .first_nameclass Phone(models.Model): person = models.ForeignKey( 'Person' ) number = models.CharField(max_length = 10 ) def __unicode__( self ): return self .number |
再次运行测试,你会看到:
1
|
Ran 2 tests in 11.730sOK |
我们继续下一步骤,添加测试进去以保证管理员可以添加数据:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
# user clicks on the Persons link persons_links = self .browser.find_elements_by_link_text( 'Persons' ) persons_links[ 0 ].click() # user clicks on the Add person link add_person_link = self .browser.find_element_by_link_text( 'Add person' ) add_person_link.click() # user fills out the form self .browser.find_element_by_name( 'first_name' ).send_keys( "Michael" ) self .browser.find_element_by_name( 'last_name' ).send_keys( "Herman" ) self .browser.find_element_by_name( 'email' ).send_keys( "michael@realpython.com" ) self .browser.find_element_by_name( 'address' ).send_keys( "2227 Lexington Ave" ) self .browser.find_element_by_name( 'city' ).send_keys( "San Francisco" ) self .browser.find_element_by_name( 'state' ).send_keys( "CA" ) self .browser.find_element_by_name( 'country' ).send_keys( "United States" ) # user clicks the save button self .browser.find_element_by_css_selector( "input[value='Save']" ).click() # the Person has been added body = self .browser.find_element_by_tag_name( 'body' ) self .assertIn( 'Herman, Michael' , body.text) # user returns to the main admin screen home_link = self .browser.find_element_by_link_text( 'Home' ) home_link.click() # user clicks on the Phones link persons_links = self .browser.find_elements_by_link_text( 'Phones' ) persons_links[ 0 ].click() # user clicks on the Add phone link add_person_link = self .browser.find_element_by_link_text( 'Add phone' ) add_person_link.click() # user finds the person in the drop downel = self .browser.find_element_by_name( "person" ) for option in el.find_elements_by_tag_name( 'option' ): if option.text = = 'Herman, Michael' : option.click() # user adds the phone numbers self .browser.find_element_by_name( 'number' ).send_keys( "4158888888" ) # user clicks the save button self .browser.find_element_by_css_selector( "input[value='Save']" ).click() # the Phone has been added body = self .browser.find_element_by_tag_name( 'body' ) self .assertIn( '4158888888' , body.text) # user logs out self .browser.find_element_by_link_text( 'Log out' ).click() body = self .browser.find_element_by_tag_name( 'body' ) self .assertIn( 'Thanks for spending some quality time with the Web site today.' , body.text) |
这就是管理员的功能。让我们转过头来专注于user_contacts本身。你之前的代码还记得提交吗?如果没有,赶紧提交吧!
单元测试
考虑下我们现在已经写的特性。我们已经定义了我们的模型,允许管理员更改模型。根据这个情况和我们项目的整体目标,着重关注剩下的用户功能。
用户应该可以——
- 浏览所有的联系人。
- 添加新的联系人。
根据这些需求,尝试把剩下的功能测试公式化。尽管,在我们写功能测试之前,我们应该通过单元测试定义代码的行为——这有助于你写出良好、干净的代码,编写功能测试更加简单。
记住:功能测试最终将表示你的项目是否工作,而单元测试有助于你达到这样的目的。这很快就会变的有意义。
让我们暂停片刻,谈论一些常规惯例。
尽管TDD(或者终端)的基础——测试、代码、重构——是通用的,很多开发者使用的方法是不同的。例如,我喜欢先写单元测试,保证我们的代码在细粒度级别有效,然后写功能测试。其他开发者先写功能测试,查看它们失败,然后写单元测试,查看它们失败,然后再写代码,首先满足单元测试,最终也应该满足功能测试。这里没有正确和错误的答案。哪种方法舒服用哪种——但继续先测试、然后写代码,最后重构。
视图
首先,检查所有视图都设置准确。
主视图
跟往常一样,先开始一个测试:
1
2
3
4
|
from django.template.loader import render_to_stringfrom django.test import TestCase, Clientfrom user_contacts.models import Person, Phonefrom user_contacts.views import * class ViewTest(TestCase): def setUp( self ): self .client_stub = Client() def test_view_home_route( self ): response = self .client_stub.get( '/' ) self .assertEquals(response.status_code, 200 ) |
给这个测试文件取名为test_views.py,并保存到user_contacts/tests目录下。同时要添加__init__.py文件到目录中去,在user_contacts主目录下删除"tests.py"文件。
运行它:
1
|
$ python manage.py test user_contacts |
它会失败的 -AssertionError: 404 != 200- 因为URL、视图和模板都还没存在。如果你不熟悉Django如何处理MVC架构,请点击这里阅览这篇简短的文章。我们首先获取用客户端获取url的“/”地址,这事Django的TestCase的一部分。这个响应被存储起来,然后我们去检查返回的状态码是否等于200。
添加如下路径到“contacts/urls.py”:
(r'^', include('user_contacts.urls')),
更新“contacts/urls.py”:
1
2
|
from django.conf.urls import patterns, urlfrom user_contacts.views import * urlpatterns = patterns('', url(r '^$' , home),) |
更新“views.py”:
1
2
|
from django.http import HttpResponse, HttpResponseRedirectfrom django.shortcuts import render_to_response, renderfrom django.template import RequestContextfrom user_contacts.models import Phone, Person # from user_contacts.new_contact_form import ContactFormdef home(request): return render_to_response( 'index.html' ) |
添加“index.html”模板到模板目录中去:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
<!DOCTYPE html>< html > < head > < title >Welcome.</ title > < meta name = "viewport" content = "width=device-width, initial-scale=1.0" > < link href = "http://netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap.min.css" rel = "stylesheet" media = "screen" > < style > .container { padding: 50px; } </ style > </ head > < body > < div class = "container" > < h1 >What would you like to do?</ h1 > < ul > < li >< a href = "/all" >View Contacts</ a ></ li > < li >< a href = "/add" >Add Contact</ a ></ li > </ ul > < div > </ body ></ html > |
再次运行测试,它就会顺利通过。
所有联系人视图
对这个视图的测试几乎和我们上一个测试相同。在看我的答案之前先自己试试吧。
1.通过在ViewTest类里添加下面的方法来开始这个测试。
1
2
3
|
def test_view_contacts_route( self ): response = self .client_stub.get( '/all/' ) self .assertEquals(response.status_code, 200 ) |
2. 在运行时,你将看到同样的错误:AssertionError: 404 != 200 。
3. 用下面的路由策略更新"user_contacts/urls.py":
1
|
url(r '^all/$' , all_contacts), |
4. 更新"view.py":
1
2
3
|
def all_contacts(request): contacts = Phone.objects. all () return render_to_response( 'all.html' , { 'contacts' :contacts}) |
5. 在templates文件夹里加入一个叫"all.html"的模板:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
<!DOCTYPE html>< html >< head >< title >All Contacts.</ title >< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >< link href = "http://netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap.min.css" rel = "stylesheet" media = "screen" >< style > .container { padding: 50px; }</ style ></ head >< body >< div class = "container" > < h1 >All Contacts</ h1 > < table border = "1" cellpadding = "5" > < tr > < th >First Name</ th > < th >Last Name</ th > < th >Address</ th > < th >City</ th > < th >State</ th > < th >Country</ th > < th >Phone Number</ th > < th >Email</ th > </ tr > {% for contact in contacts %} < tr > < td ></ td > < td ></ td > < td ></ td > < td ></ td > < td ></ td > < td ></ td > < td ></ td > < td ></ td > </ tr > {% endfor %} </ table > < br > < a href = "/" >Return Home</ a ></ div >< script src = "http://code.jquery.com/jquery-1.10.2.min.js" ></ script >< script src = "http://netdna.bootstrapcdn.com/bootstrap/3.0.0/js/bootstrap.min.js" ></ script ></ body ></ html > |
6. 然后测试应该能通过了。
增加联系人视图
这个测试与前面两个稍有不同,所以一定要仔细的跟着下列步骤走。
1. 在test suite里加入测试:
1
2
3
|
def test_add_contact_route( self ): response = self .client_stub.get( '/add/' ) self .assertEqual(response.status_code, 200 ) |
2. 你将在运行时看到这样的错误:AssertionError: 404 != 200
3. 更新"urls.py":
1
|
url(r '^add/$' , add), |
4. 更新"views.py"
1
|
def add(request):person_form = ContactForm() return render(request, 'add.html' , { 'person_form' : person_form}, context_instance = RequestContext(request)) |
确保加入了如下的引用:
from user_contacts.new_contact_form import ContactForm
5. 创建一个叫 new_contact_form.py的新文件然后加入如下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
import refrom django import formsfrom django.core.exceptions import ValidationErrorfrom user_contacts.models import Person, Phoneclass ContactForm(forms.Form): first_name = forms.CharField(max_length = 30 ) last_name = forms.CharField(max_length = 30 ) email = forms.EmailField(required = False ) address = forms.CharField(widget = forms.Textarea, required = False ) city = forms.CharField(required = False ) state = forms.CharField(required = False ) country = forms.CharField(required = False ) number = forms.CharField(max_length = 10 ) def save( self ): if self .is_valid(): data = self .cleaned_data person = Person.objects.create(first_name = data.get( 'first_name' ), last_name = data.get( 'last_name' ), email = data.get( 'email' ), address = data.get( 'address' ), city = data.get( 'city' ), state = data.get( 'state' ), country = data.get( 'country' )) phone = Phone.objects.create(person = person, number = data.get( 'number' )) return phone |
6. 加入"add.html"到模板文件夹里:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
import refrom django import formsfrom django.core.exceptions import ValidationErrorfrom user_contacts.models import Person, Phoneclass ContactForm(forms.Form): first_name = forms.CharField(max_length = 30 ) last_name = forms.CharField(max_length = 30 ) email = forms.EmailField(required = False ) address = forms.CharField(widget = forms.Textarea, required = False ) city = forms.CharField(required = False ) state = forms.CharField(required = False ) country = forms.CharField(required = False ) number = forms.CharField(max_length = 10 ) def save( self ): if self .is_valid(): data = self .cleaned_data person = Person.objects.create(first_name = data.get( 'first_name' ), last_name = data.get( 'last_name' ), email = data.get( 'email' ), address = data.get( 'address' ), city = data.get( 'city' ), state = data.get( 'state' ), country = data.get( 'country' )) phone = Phone.objects.create(person = person, number = data.get( 'number' )) return phone |
7. 是不是通过了?应该是的。如果没有,再检查一下。
验证
现在我们已经完成了视图的测试,让我们添加对表单的验证。但首先我们要写一个测试,惊喜吧!
在“tests”目录下新增一个叫“test_validator.py”的文件并增加以下代码:
1
2
3
4
5
6
7
8
9
10
11
|
from django.core.exceptions import ValidationError from django.test import TestCase from user_contacts.validators import validate_number, validate_string class ValidatorTest(TestCase): def test_string_is_invalid_if_contains_numbers_or_special_characters( self ): with self .assertRaises(ValidationError): validate_string( '@test' ) validate_string( 'tester#' ) def test_number_is_invalid_if_contains_any_character_except_digits( self ): with self .assertRaises(ValidationError): validate_number( '123ABC' ) validate_number( '75431#' ) |
在运行测试之前,你猜猜会有什么情况发生?提示:请密切注意代码上面导入进来的包。你会有以下错误信息,因为我们没有“validators.py”文件:
1
|
ImportError: cannot import name validate_string |
换言之,我们测试所需的逻辑验证文件还不存在。
在“user_contacts”目录下新增一个叫“validators.py”的文件:
1
2
3
4
5
|
import refrom django.core.exceptions import ValidationErrordef validate_string(string): if re.search( '^[A-Za-z]+$' , string) is None : raise ValidationError( 'Invalid' ) def validate_number(value): if re.search( '^[0-9]+$' , value) is None : raise ValidationError( 'Invalid' ) |
再次运行测试。5个测试会通过的:
1
|
Ran 5 tests in 0.019sOK |
新增联系人
由于我们增加了验证,我们想测试一下在管理员区域这个验证功能是可以工作的,所以更新“test_views.py”:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
from django.template.loader import render_to_stringfrom django.test import TestCase, Clientfrom user_contacts.models import Person, Phonefrom user_contacts.views import * class ViewTest(TestCase): def setUp( self ): self .client_stub = Client() self .person = Person(first_name = 'TestFirst' ,last_name = 'TestLast' ) self .person.save() self .phone = Phone(person = self .person,number = '7778889999' ) self .phone.save() def test_view_home_route( self ): response = self .client_stub.get( '/' ) self .assertEquals(response.status_code, 200 ) def test_view_contacts_route( self ): response = self .client_stub.get( '/all/' ) self .assertEquals(response.status_code, 200 ) def test_add_contact_route( self ): response = self .client_stub.get( '/add/' ) self .assertEqual(response.status_code, 200 ) def test_create_contact_successful_route( self ): response = self .client_stub.post( '/create' ,data = { 'first_name' : 'testFirst' , 'last_name' : 'tester' , 'email' : 'test@tester.com' , 'address' : '1234 nowhere' , 'city' : 'far away' , 'state' : 'CO' , 'country' : 'USA' , 'number' : '987654321' }) self .assertEqual(response.status_code, 302 ) def test_create_contact_unsuccessful_route( self ): response = self .client_stub.post( '/create' ,data = { 'first_name' : 'tester_first_n@me' , 'last_name' : 'test' , 'email' : 'tester@test.com' , 'address' : '5678 everywhere' , 'city' : 'far from here' , 'state' : 'CA' , 'country' : 'USA' , 'number' : '987654321' }) self .assertEqual(response.status_code, 200 ) def tearDown( self ): self .phone.delete() self .person.delete() |
两个测试会失败。
我们要怎么做才能让测试通过呢?首先我们要为添加数据到数据库增加一个视图功能来查看。
添加路径:
1
|
url(r '^create$' , create), |
更新“views.py”:
1
2
3
4
|
def create(request): form = ContactForm(request.POST) if form.is_valid(): form.save() return HttpResponseRedirect( 'all/' ) return render(request, 'add.html' , { 'person_form' : form}, context_instance = RequestContext(request)) |
再次测试:
1
|
$ python manage.py test user_contacts |
这次只有一个测试会失败 - AssertionError: 302 != 200 - 因为我们尝试添加一些不通过验证的数据但添加成功了。换言之,我们需要更新“models.py”文件中的表单都要把验证考虑进去。
更新“models.py”:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
from django.db import modelsfrom user_contacts.validators import validate_string, validate_numberclass Person(models.Model): first_name = models.CharField(max_length = 30 , validators = [validate_string]) last_name = models.CharField(max_length = 30 , validators = [validate_string]) email = models.EmailField(null = True , blank = True ) address = models.TextField(null = True , blank = True ) city = models.CharField(max_length = 15 , null = True ,blank = True ) state = models.CharField(max_length = 15 , null = True , blank = True , validators = [validate_string]) country = models.CharField(max_length = 15 , null = True , blank = True ) def __unicode__( self ): return self .last_name + ", " + self .first_nameclass Phone(models.Model): person = models.ForeignKey( 'Person' ) number = models.CharField(max_length = 10 , validators = [validate_number]) def __unicode__( self ): return self .number |
删除当前的数据库,“db.sqlite3”,重新同步数据库:
1
|
$ python manage.py syncdb |
再次设置一个管理员账户。
新增验证,更新new_contact_form.py:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
import refrom django import formsfrom django.core.exceptions import ValidationErrorfrom user_contacts.models import Person, Phonefrom user_contacts.validators import validate_string, validate_numberclass ContactForm(forms.Form): first_name = forms.CharField(max_length = 30 , validators = [validate_string]) last_name = forms.CharField(max_length = 30 , validators = [validate_string]) email = forms.EmailField(required = False ) address = forms.CharField(widget = forms.Textarea, required = False ) city = forms.CharField(required = False ) state = forms.CharField(required = False , validators = [validate_string]) country = forms.CharField(required = False ) number = forms.CharField(max_length = 10 , validators = [validate_number]) def save( self ): if self .is_valid(): data = self .cleaned_data person = Person.objects.create(first_name = data.get( 'first_name' ), last_name = data.get( 'last_name' ), email = data.get( 'email' ), address = data.get( 'address' ), city = data.get( 'city' ), state = data.get( 'state' ), country = data.get( 'country' )) phone = Phone.objects.create(person = person, number = data.get( 'number' )) return phone |
再次运行测试,7个测试会通过的。
现在,先脱离开TDD一会儿。我想在客户端添加一个额外的测试验证。所以添加test_contact_form.py:
1
2
3
4
5
6
7
8
9
|
from django.test import TestCasefrom user_contacts.models import Personfrom user_contacts.new_contact_form import ContactFormclass TestContactForm(TestCase): def test_if_valid_contact_is_saved( self ): form = ContactForm({ 'first_name' : 'test' , 'last_name' : 'test' , 'number' : '9999900000' }) contact = form.save() self .assertEqual(contact.person.first_name, 'test' ) def test_if_invalid_contact_is_not_saved( self ): form = ContactForm({ 'first_name' : 'tes&t' , 'last_name' : 'test' , 'number' : '9999900000' }) contact = form.save() self .assertEqual(contact, None ) |
运行测试,所有9个测试都通过了。耶!现在可以提交代码了。
功能测试的终极版
当单元测试已经完成了,我们现在添加功能测试去保证应用程序可以顺利运行。但愿由于我们的单元测试已经通过了,功能测试也不会有什么问题。
添加一个新类到“tests.py”文件中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
class UserContactTest(LiveServerTestCase): def setUp( self ): self .browser = webdriver.Firefox() self .browser.implicitly_wait( 3 ) def tearDown( self ): self .browser.quit() def test_create_contact( self ): # user opens web browser, navigates to home page self .browser.get( self .live_server_url + '/' ) # user clicks on the Persons link add_link = self .browser.find_elements_by_link_text( 'Add Contact' ) add_link[ 0 ].click() # user fills out the form self .browser.find_element_by_name( 'first_name' ).send_keys( "Michael" ) self .browser.find_element_by_name( 'last_name' ).send_keys( "Herman" ) self .browser.find_element_by_name( 'email' ).send_keys( "michael@realpython.com" ) self .browser.find_element_by_name( 'address' ).send_keys( "2227 Lexington Ave" ) self .browser.find_element_by_name( 'city' ).send_keys( "San Francisco" ) self .browser.find_element_by_name( 'state' ).send_keys( "CA" ) self .browser.find_element_by_name( 'country' ).send_keys( "United States" ) self .browser.find_element_by_name( 'number' ).send_keys( "4158888888" ) # user clicks the save button self .browser.find_element_by_css_selector( "input[value='Add']" ).click() # the Person has been added body = self .browser.find_element_by_tag_name( 'body' ) self .assertIn( 'michael@realpython.com' , body.text) def test_create_contact_error( self ): # user opens web browser, navigates to home page self .browser.get( self .live_server_url + '/' ) # user clicks on the Persons link add_link = self .browser.find_elements_by_link_text( 'Add Contact' ) add_link[ 0 ].click() # user fills out the form self .browser.find_element_by_name( 'first_name' ).send_keys( "test@" ) self .browser.find_element_by_name( 'last_name' ).send_keys( "tester" ) self .browser.find_element_by_name( 'email' ).send_keys( "test@tester.com" ) self .browser.find_element_by_name( 'address' ).send_keys( "2227 Tester Ave" ) self .browser.find_element_by_name( 'city' ).send_keys( "Tester City" ) self .browser.find_element_by_name( 'state' ).send_keys( "TC" ) self .browser.find_element_by_name( 'country' ).send_keys( "TCA" ) self .browser.find_element_by_name( 'number' ).send_keys( "4158888888" ) # user clicks the save button self .browser.find_element_by_css_selector( "input[value='Add']" ).click() body = self .browser.find_element_by_tag_name( 'body' ) self .assertIn( 'Invalid' , body.text) |
运行功能测试:
1
|
$ python manage.py test ft |
这里我们只测试我们写过的,以及从最终用户角度来看已经被单元测试过的代码。4个测试都将会通过。
最后,我们通过添加以下功能到AdminTest类来保证我们添加进去的验证会应用到管理员面板中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
def test_create_contact_admin_raise_error( self ): # # user opens web browser, navigates to admin page, and logs in self .browser.get( self .live_server_url + '/admin/' ) username_field = self .browser.find_element_by_name( 'username' ) username_field.send_keys( 'admin' ) password_field = self .browser.find_element_by_name( 'password' ) password_field.send_keys( 'admin' ) password_field.send_keys(Keys.RETURN) # user clicks on the Persons link persons_links = self .browser.find_elements_by_link_text( 'Persons' ) persons_links[ 0 ].click() # user clicks on the Add person link add_person_link = self .browser.find_element_by_link_text( 'Add person' ) add_person_link.click() # user fills out the form self .browser.find_element_by_name( 'first_name' ).send_keys( "test@" ) self .browser.find_element_by_name( 'last_name' ).send_keys( "tester" ) self .browser.find_element_by_name( 'email' ).send_keys( "test@tester.com" ) self .browser.find_element_by_name( 'address' ).send_keys( "2227 Tester Ave" ) self .browser.find_element_by_name( 'city' ).send_keys( "Tester City" ) self .browser.find_element_by_name( 'state' ).send_keys( "TC" ) self .browser.find_element_by_name( 'country' ).send_keys( "TCA" ) # user clicks the save button self .browser.find_element_by_css_selector( "input[value='Save']" ).click() body = self .browser.find_element_by_tag_name( 'body' ) self .assertIn( 'Invalid' , body.text) |
运行它。会有5个测试通过。提交之后就可以收工啦。
测试结构
TDD是一个强大的工具以及是开发周期的一部分,帮助开发人员将程序拆分成小的、可读性强的部分。这样的组成部分可以更容易编写和修改。另外,有一套全面完整的测试组件,覆盖了你代码的所有功能,有助于确保新功能在实现的时候不会破坏现有的功能。
在这过程中,功能测试是一个高层次的测试,重点放在了最终用户的交互功能上。
同时,单元测试支持功能测试来测试代码的每个功能。请记住,因为单元测试一次仅需测一个产品特征,所以它们更容易编写,一般覆盖性会更好些,也更容易调试。它们会运行非常快,所以你进行单元测试的次数往往会多于功能测试。
让我们来看看我们的测试结构,看看我们的单元测试是如何支持功能测试的:
总结
恭喜你,你完成了!接下来做什么呢?
首先,我没有100%地遵循TDD过程,这是没有关系的。大部分用TDD进行开发的开发人员并不会始终坚持在每一个情况下都使用它。有时候,你为了把事情做好而偏离它这个过程——这是完全没有问题的。如果你想重构代码、过程使得它更好地遵循TDD过程,你也可以这么去做。事实上,这是一个很好的做法。
其次,思考一下我错过的测试。确定什么地方以及什么时候去测试是困难的。这一般需要时间和大量的练习去把测试做好。我打算在我的下一篇文章中多留一些空白,来看看你们能否找到那些空白并添加测试。
最后,还记得TDD过程的最后一步吗?这一步是至关重要的,因为它可以帮助创建可读性强的、可维护的代码,你不仅仅要现在理解这件事,在将来也要如此。当你重新看回你的代码,思考下你结合起来的测试。此外,你应该添加哪些测试来确保所有写过的代码都被测试?例如你可以测试空值或者服务端的验证。你也可以在准备写新代码前去重构之前没时间去整理的代码。或许这是另外一篇博文?思考下糟糕的代码如何污染整个过程?
感谢阅读。点击这里获取最终的代码。有任何的问题请在下面评论。