测试驱动开发(TDD)是一个迭代的开发周期,强调编写实际代码之前编写自动化测试。
这个过程很简单:
- 先编写测试。
- 查看测试失败的地方
- 编写足够的代码以使测试通过。
- 再次测试。
- 代码重构 。
- 重复以上操作。
为什么要用TDD?
使用TDD,你将学会把你的代码拆分成符合逻辑的,简单易懂的片段,这有助于确保代码的正确性。
这一点非常重要,因为做到下面这些事情是非常困难的:
- 在我们的脑中一次性处理所有复杂的问题。
- 了解何时从哪里开始着手解决问题。
- 在代码库的复杂度不断增长的同时不引入错误和bug;并且
- 辨别出代码在什么时候发生了问题。
TDD帮助我们定位问题。它不能保证你的代码完全没有错误;然而,你可以写出更好的代码,从而能更好地理解理解代码。这本身有助于消除错误,并且至少,你可以更容易的定位错误。
TTD实际上也是一种行业标准。
说的够多了。让我们来看看代码吧。
在这个教程里,我们将创建一个存储用户联系人的app。
请注意: 这篇教程假设你运行在一个基于Unix的环境里 – 例如, Mac OSX, Linux, 或者在Windows下的Linux VM。 我将使用Sublime 2作为文本编辑器。并且,确保你已经完成了官方的Django教程并且基本了解Python语言. 此外,在这个第一篇post里,我们不会涉及到Django1.6提供的新工具。这篇文章将为之后的post打好基础来处理不同形式的测试。
第一个测试
在开始做一些事情之前,我们需要首先创建一个测试。为了这个测试,我们需要让Django正确安装。为此我们将使用一个函数测试——这在下面会详细解释。
创建一个新目录存放你的项目:
$ mkdir django-tdd $ cd django-tdd
再建立一个目录存放函数测试
$ mkdir ft $ cd ft
创建一个新文件 “tests.py”并加入以下代码:
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()
现在运行测试:
$ python tests.py
确认安装selenium(译注:自动化测试软件)时是使用 installed -pip安装的
你将看到 FireFox弹出来试图打开 http://localhost:8000/。在你的终端上面你会看到:
Traceback (most recent call last):File "tests.py", line 7, in assert 'Django' in body.textAssertionError
祝贺!你完成了第一个失效测试。
现在我们写足够的代码来让它通过,这些代码量约相当于设置一个 Django 开发环境。
设置Django
1. 激活一个virtualenv:
$ cd ..$ virtualenv --no-site-packages env$ source env/bin/activate
2. 安装Django并且建立一个项目
$ pip install django==1.6.1$ django-admin.py startproject contacts
你当前的项目结构应该是下面这个样子:
<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>
6. 然后测试应该能通过了。
增加联系人视图
这个测试与前面两个稍有不同,所以一定要仔细的跟着下列步骤走。
1. 在test suite里加入测试:
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”:
url(r'^add/$', add),
4. 更新”views.py”
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的新文件然后加入如下代码:
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”到模板文件夹里:
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”的文件并增加以下代码:
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”文件:
ImportError: cannot import name validate_string
换言之,我们测试所需的逻辑验证文件还不存在。
在“user_contacts”目录下新增一个叫“validators.py”的文件:
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个测试会通过的:
Ran 5 tests in 0.019sOK
新增联系人
由于我们增加了验证,我们想测试一下在管理员区域这个验证功能是可以工作的,所以更新“test_views.py”:
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':'[email protected]', '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':'[email protected]', '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()
两个测试会失败。
我们要怎么做才能让测试通过呢?首先我们要为添加数据到数据库增加一个视图功能来查看。
添加路径:
url(r'^create$', create),
更新“views.py”:
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))
再次测试:
$ python manage.py test user_contacts
这次只有一个测试会失败 – AssertionError: 302 != 200 – 因为我们尝试添加一些不通过验证的数据但添加成功了。换言之,我们需要更新“models.py”文件中的表单都要把验证考虑进去。
更新“models.py”:
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”,重新同步数据库:
$ python manage.py syncdb
再次设置一个管理员账户。
新增验证,更新new_contact_form.py:
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:
from django.test import TestCasefrom user_contacts.models import Personfrom user_contacts.new_contact_form import ContactFormclass TestContactForm(TestCase):<strong style="color:transparent">本文来源gao@daima#com搞(%代@#码网@</strong> 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”文件中:
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("[email protected]") 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('[email protected]', 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("[email protected]") 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)
运行功能测试:
$ python manage.py test ft
这里我们只测试我们写过的,以及从最终用户角度来看已经被单元测试过的代码。4个测试都将会通过。
最后,我们通过添加以下功能到AdminTest类来保证我们添加进去的验证会应用到管理员面板中:
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("[email protected]") 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过程的最后一步吗?这一步是至关重要的,因为它可以帮助创建可读性强的、可维护的代码,你不仅仅要现在理解这件事,在将来也要如此。当你重新看回你的代码,思考下你结合起来的测试。此外,你应该添加哪些测试来确保所有写过的代码都被测试?例如你可以测试空值或者服务端的验证。你也可以在准备写新代码前去重构之前没时间去整理的代码。或许这是另外一篇博文?思考下糟糕的代码如何污染整个过程?
感谢阅读。点击这里获取最终的代码。有任何的问题请在下面评论。