在
Python 中,也提供了 xUnit 的 Python 实现:PyUnit,在源代码中是一个叫做 unittest
的模块,它提供了对单元测试的支持。还有许多使用 Python 的项目,都利用 PyUnit 开发了自己更适用、更方便的测试框架,比如
Twisted 有 trail,Zope 有 ZopeTestCase,Plone 有 PloneTestCase。关于
PyUnit 的一些基本知识请参考 PyUnitTut。
这个文档着重于讨论如何在 Vim 编辑器中使用 unittest 来实践
TDD。
首先要配置我们的 Vim,使之更适合 Python TDD。详细资料请参考
VimPython。
我们开发的案例是一个产品库,用来保存不同产品、不同版本的源代码。我们知道,开源社区有大量优秀的项目供我们无偿使用(感谢那些开发者),那些使用广泛的项目大多更新迅速,这是开源的优点,但也给使用者带来一些更新上的麻烦。比如我开发的
Zope 系统,每天都有新版本的第三方代码出现,我在自己的程序中使用了很多,每次当我制造发布包都是一件麻烦的事情。所以我想到,应该有一个保存这些不同产品、不同版本源代码的产品库,可以在我需要时很快的找到它们。当然我们这里讨论的只是一个
TDD 方法的介绍,不可能把整个系统都写完,但如果有兴趣的朋友可以把这个程序继续开发下去,为大家造福。如果我有时间或空闲,说不定我也会继续它的开发的
首先我们要确定需求。需求当然越详细越好,但我们自己的程序往往刚开始只有一个想法,TDD 对这个情况照样也很适应。我想从最基本的功能来说,这个产品库肯定要有添加产品的某个版本文件的功能,也要有从库里把这个产品、这个版本取出来的能力──这是最核心的功能了。
我们先来做入库的功能。我们开启 vim,建立一个新文件 test_filelib.py:
看到了吗,在 TDD 中,必须先写测试、再去编写它的实现。这个文件最开始只是一个空空的框架文件:
#!/usr/bin/env python # -*- coding: GB2312 -*-
from unittest import TestCase
class simpleTest(TestCase):
def setUp(self):
pass
def tearDown(self):
pass
def testExample(self):
self.assertEqual(1, 1)
if '__main__' == __name__:
import unittest
unittest.main() |
添加我们的入库测试代码,把做为例子的 testExample 删掉,我想这个入库的函数应该是这样使用和测试的:
def testAddProduct(self): result = addProduct('myproduct', '0.1', 'myproduct-0.1.tgz') self.assertEqual(result <> None, True)
|
在还没有考虑到任何实现方法的情况下,我认为应该把产品名、版本号、要加入的文件的名字传给入库方法。不管它是否真的能这么做,反正我现在是这么想的。好,现在我们执行一下这个测试:在
Vim 的命令模式下键入 :make。如果你使用的是 Gvim,可以在工具栏上找到一个 make 的快捷按钮,点击它。在我的
Vim 里,出现了这样的提示:
:!python test_filelib.py 2>&1| tee /tmp/v874609/3 E ====================================================================== ERROR: testAddProduct (__main__.simpleTest) ---------------------------------------------------------------------- Traceback (most recent call last): File "test_filelib.py", line 20, in testAddProduct result = addProduct('myproduct', '0.1', 'myproduct-0.1.tgz') NameError: global name 'addProduct' is not defined
----------------------------------------------------------------------
Ran 1 test in 0.002s
FAILED (errors=1)
(6 / 11): NameError: global name 'addProduct'
is not defined
|
看来它在告诉我,我还没有定义这个 addProduct 这个方法呐!呵呵,不要担心,在
TDD 看来,这是正常的。TDD 的原则就是:测试先行!当我们准备好了测试,才会去编写它的编码。好了,我们现在知道
test_filelib.py 是语法无误的,它可以编译运行,那我们就开始编写代码的实现。先在测试文件中写下导入的命令:
然后在 Vim 的命令模式键入命令:
这时 Vim 中出现了一个新的窗口,其中编辑的是我们的新文件 filelib.py,我们键入如下代码:
# -*- coding: GB2312 -*-
"""管理不同产品、不同版本的文件库
"""
def addProduct(ProductName, Version, Filename):
return True |
执行 :make 操作,这时 Vim 提示:
:!python ./alltests.py 2>&1| tee /tmp/v878704/2 python: can't open file './alltests.py' (1 / 1): python: can't open file './alltests.py' |
原来我们忘了加上 alltests.py 文件,好的,新增一个 alltests.py 文件:
#!/usr/bin/env python
import unittest
import sys
import os
sys.path.append(os.curdir)
sys.path.append(os.pardir)
sys.path.append(os.path.join(os.curdir, 'tests'))
tests = os.listdir(os.curdir)
tests = [n[:-3] for n in tests if n.startswith('test')
and n.endswith('.py')]
teststests = os.path.join(os.curdir, 'tests')
if os.path.isdir(teststests):
teststests = os.listdir(teststests)
teststests = [n[:-3] for n in teststests if n.startswith('test')
\
and n.endswith('.py')]
modules_to_test = tests + teststests
else:
modules_to_test = tests
def suite():
alltests = unittest.TestSuite()
for module in map(__import__, modules_to_test):
alltests.addTest(unittest.findTestCases(module))
return alltests
if __name__ == '__main__':
unittest.main(defaultTest='suite') |
关闭 alltests.py,回到 filelib.py 中,重新执行 :make,这次的提示很完美:
:!python ./alltests.py 2>&1| tee /tmp/v878704/5 . ---------------------------------------------------------------------- Ran 1 test in 0.001s
OK
(1 / 5): |
测试报告告诉我们,我们的第一个测试已经通过!
也许有人要说了,这是在编程序吗?呵呵,TDD 的另一个原则:快速实现。快速实现允许你先不去考虑那么多,用一个最快的方法使测试通过,不管那个方法是多么可笑、多么简陋。但要记得,它是要与
TDD 的另一个原则一起使用,才能保证你的代码质量:重构。要记得随时随地审视代码,考虑它的更简洁、更合理的实现。现在我们就来审视一下这个函数。其实它是一个“伪实现”,可它还是骗过了我们的测试用例,这说明测试写的还不太严格。为了让测试更准确,得修改一下它。现在我想,应该检查是不是真的有指定的产品文件被保存起来了:
def testAddProduct(self): result = existsProduct('myproduct', '0.1', 'myproduct-0.1.tgz') self.assertEqual(result, False) result = addProduct('myproduct', '0.1', 'myproduct-0.1.tgz') self.assertEqual(result, True) result = existsProduct('myproduct', '0.1', 'myproduct-0.1.tgz') self.assertEqual(result, True) |
哦,原来应该有这么一个专门检查产品版本的东西,早先我怎么没想起来呢?!呵呵,不要苛求自己,在一小会儿时间里把一个项目里所有的函数都提前想到,这是任何人都办不到的事情。现在在
TDD 的帮助下,我们把隐藏的函数找出来了。运行 :make 测试,我们看到了预期中的错误,接下来要实现
existsProduct,并完善 addProduct,让它真的把文件存起来(existsProduct
在 testAddProducts 中被充分测试,我们就用不着专门给它写一个测试用例了)。
import os import shutil
def existsProduct(ProductName, Version, Filename):
return os.path.exists(os.path.join(ProductName,
Version, Filename))
def addProduct(ProductName, Version, Filename):
pathname = os.path.join(ProductName, Version)
if not os.path.exists(pathname):
os.makedirs(pathname)
return shutil.copyfile(Filename, os.path.join(pathname,
Filename)) |
代码看起来很不错,我们来运行一下测试。可惜,出错了,原来是目前根本就没有这么一个文件“myproduct-0.1.tgz”。那么我们要准备一个这样的测试用的文件吗?经过思考,我想应该把
addProduct 的参数改变一下,增加一个 filehandle 参数,文件的数据流就从这个参数中传入。这可真是一个不错的主意,这样我们需要的数据就可以从任何一个类
file 的对象中读入了,比如内存中的 StringIO,或者是一个 urlopen 打开的网络连接。同时我们要准备的测试文件也解决了,用一个
StringIO 模拟就行了。这又是一个测试中想到的好主意。为什么我们会想到这么多以前没有想到的好主意呢?我看,这是因为我们在编写测试用例时,是做为这些函数、接口的使用者在工作。做为直接的实践者,这些接口使用是否方便,我们自然是心知肚明了。实际上,对于其它开发者来说,这些测试用例正是模块更新最及时、最易查询的使用手册!重新编码之后,测试代码变成这样:
from unittest import TestCase from StringIO import StringIO from filelib import *
class simpleTest(TestCase):
def setUp(self):
self.pname = 'myproduct'
self.pver = '0.1'
self.pfilename = 'myproduct-0.1.tgz'
self.pcontent = 'testfile\nmyproduct\n'
self.pfd = StringIO(self.pcontent)
def tearDown(self):
pass
def testAddProduct(self):
result = existsProduct(self.pname, self.pver,
self.pfilename, self.pfd)
self.assertEqual(result, False)
result = addProduct(self.pname, self.pver, self.pfilename,
self.pfd)
self.assertEqual(result, True)
result = existsProduct(self.pname, self.pver,
self.pfilename, self.pfd)
self.assertEqual(result, True) |
这里,我把多次重复出现的产品名称、文件名之类放到变量中,并设置了模拟文件设备 self.pfd。这些准备工作都在
setUp 中完成,它会在每个测试用例之前被执行,构造全新的测试环境。这里用来模拟的 self.pfd 也被称为
Mock Object 或者 Stub(桩模块)。
再次运行测试,修改实现代码:
def addProduct(ProductName, Version, Filename, FileHandle): pathname = os.path.join(ProductName, Version) if not os.path.exists(pathname): os.makedirs(pathname) newfd = open(os.path.join(pathname, Filename), 'w') return newfd.write(FileHandle.read()) |
这次,测试报告告诉我们:AssertionError: None != True。原来是 file.write
根本不返回任何值的。改一下代码:
def addProduct(ProductName, Version, Filename, FileHandle): pathname = os.path.join(ProductName, Version) if not os.path.exists(pathname): os.makedirs(pathname) newfd = open(os.path.join(pathname, Filename), 'w') newfd.write(FileHandle.read()) return True |
再测。这次的提示是:AssertionError: True != False。怎么回事?我们还没有添加这个产品,它就已经存在了?!看看目录中的文件,原来我们上次测试中生成的文件还留在文件系统中。需要抛弃真实文件系统,使用自己模拟的吗?那代价太高了,还是用最快的方法吧,每次把上次遗留的测试目录删除。其实
Python 有 tempfile 模块,可以用它来创建我们自己的临时目录,我们应该好好利用一下。不过,现在的实现代码中,所有文件都保存在源代码所在的目录中,没有一个好的机制更改文件库的“根目录”,看来这也要改一下了。经过考虑,决定把
filelib 组织成一个类,在初始化时指定它的存储根目录。看,代码是不是更像那么回事了。测试用例:
from unittest import TestCase from StringIO import StringIO import tempfile from shutil import rmtree from filelib import *
class simpleTest(TestCase):
def setUp(self):
self.pname = 'myproduct'
self.pver = '0.1'
self.pfilename = 'myproduct-0.1.tgz'
self.pcontent = 'testfile\nmyproduct\n'
self.pfd = StringIO(self.pcontent)
self.root = tempfile.mkdtemp()
self.filelib = ProductLib(self.root)
def tearDown(self):
rmtree(self.root)
def testAddProduct(self):
result = self.filelib.existsProduct(
self.pname, self.pver, self.pfilename)
self.assertEqual(result, False)
result = self.filelib.addProduct(
self.pname, self.pver, self.pfilename, self.pfd)
self.assertEqual(result, True)
result = self.filelib.existsProduct(
self.pname, self.pver, self.pfilename)
self.assertEqual(result, True) |
每次测试用例执行完毕,tearDown 都会把临时目录删除。运行测试,出错,但不是语法的问题。然后考虑实现:
class ProductLib: def __init__(self, rootpath): self.root = rootpath
def existsProduct(self, ProductName, Version,
Filename):
return os.path.exists(os.path.join(
self.root, ProductName, Version, Filename))
def addProduct(self, ProductName, Version, Filename,
FileHandle):
pathname = os.path.join(self.root, ProductName,
Version)
if not os.path.exists(pathname):
os.makedirs(pathname)
newfd = open(os.path.join(pathname, Filename),
'w')
newfd.write(FileHandle.read())
return True |
好,测试,再次通过了!我们又多运行了几遍,还是没有任何问题
现在,显然测试代码和实现代码都有很多要继续修改的地方,比如:
测试新增的产品文件的内容就是我们提供给它的内容
重构 ProductLib,消除重复,把计算目录全名的代码放到一个独立函数中
显然我们开始随便起的 filelib.py 的名字不合时宜,应该把模块改名成
productlib 了
我们把它记在纸片上,留到明天一个一个处理
总结,显然 TDD 有以下优点:
可以让我们专著于小范围内的编码,有代码隔离的作用,减少编程时思考的复杂度
提前进入使用者的角度思考,使编出的接口稳定可靠
使代码随时保持在可运行、可发布状态! |