如何在 Vim 中实践 Python TDD

-- xyb [2004-09-22 03:59:57]

如何在 Vim 中实践 Python TDD

作者:Xie Yanbo,版权:创作共用/cc 1.0

TDD,Test Driver Development,中文叫做测试驱动开发。这是最近几年比较受推崇的开发模式,也是 XP(敏捷开发方法) 中的重要组成部分。

关于 TDD 的书籍现在中文版的也有了,相关概念可以去看书或在网上查找。

在 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:

vim test_filelib.py

看到了吗,在 TDD 中,必须先写测试、再去编写它的实现。这个文件最开始只是一个空空的框架文件:

   1 #!/usr/bin/env python
   2 # -*- coding: GB2312 -*-
   3 
   4 from unittest import TestCase
   5 
   6 class simpleTest(TestCase):
   7     def setUp(self):
   8         pass
   9 
  10     def tearDown(self):
  11         pass
  12 
  13     def testExample(self):
  14         self.assertEqual(1, 1)
  15 
  16 if '__main__' == __name__:
  17     import unittest
  18     unittest.main()

添加我们的入库测试代码,把做为例子的 testExample 删掉,我想这个入库的函数应该是这样使用和测试的:

   1     def testAddProduct(self):
   2         result = addProduct('myproduct', '0.1', 'myproduct-0.1.tgz')
   3         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 是语法无误的,它可以编译运行,那我们就开始编写代码的实现。先在测试文件中写下导入的命令:

   1 from filelib import *

然后在 Vim 的命令模式键入命令:

:new filelib.py

这时 Vim 中出现了一个新的窗口,其中编辑的是我们的新文件 filelib.py,我们键入如下代码:

   1 # -*- coding: GB2312 -*-
   2 
   3 """管理不同产品、不同版本的文件库
   4 """
   5 
   6 def addProduct(ProductName, Version, Filename):
   7     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 文件:

   1 #!/usr/bin/env python
   2 
   3 import unittest
   4 import sys
   5 import os
   6 
   7 sys.path.append(os.curdir)
   8 sys.path.append(os.pardir)
   9 sys.path.append(os.path.join(os.curdir, 'tests'))
  10 
  11 tests = os.listdir(os.curdir)
  12 tests = [n[:-3] for n in tests if n.startswith('test') and n.endswith('.py')]
  13 
  14 teststests = os.path.join(os.curdir, 'tests')
  15 if os.path.isdir(teststests):
  16     teststests = os.listdir(teststests)
  17     teststests = [n[:-3] for n in teststests if n.startswith('test') \
  18             and n.endswith('.py')]
  19     modules_to_test = tests + teststests
  20 else:
  21     modules_to_test = tests
  22 
  23 def suite():
  24     alltests = unittest.TestSuite()
  25     for module in map(__import__, modules_to_test):
  26         alltests.addTest(unittest.findTestCases(module))
  27     return alltests
  28 
  29 if __name__ == '__main__':
  30     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 的另一个原则一起使用,才能保证你的代码质量:重构。要记得随时随地审视代码,考虑它的更简洁、更合理的实现。现在我们就来审视一下这个函数。其实它是一个“伪实现”,可它还是骗过了我们的测试用例,这说明测试写的还不太严格。为了让测试更准确,得修改一下它。现在我想,应该检查是不是真的有指定的产品文件被保存起来了:

   1     def testAddProduct(self):
   2         result = existsProduct('myproduct', '0.1', 'myproduct-0.1.tgz')
   3         self.assertEqual(result, False)
   4         result = addProduct('myproduct', '0.1', 'myproduct-0.1.tgz')
   5         self.assertEqual(result, True)
   6         result = existsProduct('myproduct', '0.1', 'myproduct-0.1.tgz')
   7         self.assertEqual(result, True)

哦,原来应该有这么一个专门检查产品版本的东西,早先我怎么没想起来呢?!呵呵,不要苛求自己,在一小会儿时间里把一个项目里所有的函数都提前想到,这是任何人都办不到的事情。现在在 TDD 的帮助下,我们把隐藏的函数找出来了。运行 :make 测试,我们看到了预期中的错误,接下来要实现 existsProduct,并完善 addProduct,让它真的把文件存起来(existsProduct 在 testAddProducts 中被充分测试,我们就用不着专门给它写一个测试用例了)。

   1 import os
   2 import shutil
   3 
   4 def existsProduct(ProductName, Version, Filename):
   5     return os.path.exists(os.path.join(ProductName, Version, Filename))
   6 
   7 def addProduct(ProductName, Version, Filename):
   8     pathname = os.path.join(ProductName, Version)
   9     if not os.path.exists(pathname):
  10         os.makedirs(pathname)
  11     return shutil.copyfile(Filename, os.path.join(pathname, Filename))

代码看起来很不错,我们来运行一下测试。可惜,出错了,原来是目前根本就没有这么一个文件“myproduct-0.1.tgz”。那么我们要准备一个这样的测试用的文件吗?经过思考,我想应该把 addProduct 的参数改变一下,增加一个 filehandle 参数,文件的数据流就从这个参数中传入。这可真是一个不错的主意,这样我们需要的数据就可以从任何一个类 file 的对象中读入了,比如内存中的 StringIO,或者是一个 urlopen 打开的网络连接。同时我们要准备的测试文件也解决了,用一个 StringIO 模拟就行了。这又是一个测试中想到的好主意。为什么我们会想到这么多以前没有想到的好主意呢?我看,这是因为我们在编写测试用例时,是做为这些函数、接口的使用者在工作。做为直接的实践者,这些接口使用是否方便,我们自然是心知肚明了。实际上,对于其它开发者来说,这些测试用例正是模块更新最及时、最易查询的使用手册!重新编码之后,测试代码变成这样:

   1 from unittest import TestCase
   2 from StringIO import StringIO
   3 from filelib import *
   4 
   5 class simpleTest(TestCase):
   6     def setUp(self):
   7         self.pname = 'myproduct'
   8         self.pver = '0.1'
   9         self.pfilename = 'myproduct-0.1.tgz'
  10         self.pcontent = 'testfile\nmyproduct\n'
  11         self.pfd = StringIO(self.pcontent)
  12 
  13     def tearDown(self):
  14         pass
  15 
  16     def testAddProduct(self):
  17         result = existsProduct(self.pname, self.pver, self.pfilename, self.pfd)
  18         self.assertEqual(result, False)
  19         result = addProduct(self.pname, self.pver, self.pfilename, self.pfd)
  20         self.assertEqual(result, True)
  21         result = existsProduct(self.pname, self.pver, self.pfilename, self.pfd)
  22         self.assertEqual(result, True)

这里,我把多次重复出现的产品名称、文件名之类放到变量中,并设置了模拟文件设备 self.pfd。这些准备工作都在 setUp 中完成,它会在每个测试用例之前被执行,构造全新的测试环境。这里用来模拟的 self.pfd 也被称为 Mock Object 或者 Stub(桩模块)。

再次运行测试,修改实现代码:

   1 def addProduct(ProductName, Version, Filename, FileHandle):
   2     pathname = os.path.join(ProductName, Version)
   3     if not os.path.exists(pathname):
   4         os.makedirs(pathname)
   5     newfd = open(os.path.join(pathname, Filename), 'w')
   6     return newfd.write(FileHandle.read())

这次,测试报告告诉我们:AssertionError: None != True。原来是 file.write 根本不返回任何值的。改一下代码:

   1 def addProduct(ProductName, Version, Filename, FileHandle):
   2     pathname = os.path.join(ProductName, Version)
   3     if not os.path.exists(pathname):
   4         os.makedirs(pathname)
   5     newfd = open(os.path.join(pathname, Filename), 'w')
   6     newfd.write(FileHandle.read())
   7     return True

再测。这次的提示是:AssertionError: True != False。怎么回事?我们还没有添加这个产品,它就已经存在了?!看看目录中的文件,原来我们上次测试中生成的文件还留在文件系统中。需要抛弃真实文件系统,使用自己模拟的吗?那代价太高了,还是用最快的方法吧,每次把上次遗留的测试目录删除。其实 Python 有 tempfile 模块,可以用它来创建我们自己的临时目录,我们应该好好利用一下。不过,现在的实现代码中,所有文件都保存在源代码所在的目录中,没有一个好的机制更改文件库的“根目录”,看来这也要改一下了。经过考虑,决定把 filelib 组织成一个类,在初始化时指定它的存储根目录。看,代码是不是更像那么回事了。测试用例:

   1 from unittest import TestCase
   2 from StringIO import StringIO
   3 import tempfile
   4 from shutil import rmtree
   5 from filelib import *
   6 
   7 class simpleTest(TestCase):
   8     def setUp(self):
   9         self.pname = 'myproduct'
  10         self.pver = '0.1'
  11         self.pfilename = 'myproduct-0.1.tgz'
  12         self.pcontent = 'testfile\nmyproduct\n'
  13         self.pfd = StringIO(self.pcontent)
  14         self.root = tempfile.mkdtemp()
  15         self.filelib = ProductLib(self.root)
  16 
  17     def tearDown(self):
  18         rmtree(self.root)
  19 
  20     def testAddProduct(self):
  21         result = self.filelib.existsProduct(
  22                 self.pname, self.pver, self.pfilename)
  23         self.assertEqual(result, False)
  24         result = self.filelib.addProduct(
  25                 self.pname, self.pver, self.pfilename, self.pfd)
  26         self.assertEqual(result, True)
  27         result = self.filelib.existsProduct(
  28                 self.pname, self.pver, self.pfilename)
  29         self.assertEqual(result, True)

每次测试用例执行完毕,tearDown 都会把临时目录删除。运行测试,出错,但不是语法的问题。然后考虑实现:

   1 class ProductLib:
   2     def __init__(self, rootpath):
   3         self.root = rootpath
   4 
   5     def existsProduct(self, ProductName, Version, Filename):
   6         return os.path.exists(os.path.join(
   7             self.root, ProductName, Version, Filename))
   8 
   9     def addProduct(self, ProductName, Version, Filename, FileHandle):
  10         pathname = os.path.join(self.root, ProductName, Version)
  11         if not os.path.exists(pathname):
  12             os.makedirs(pathname)
  13         newfd = open(os.path.join(pathname, Filename), 'w')
  14         newfd.write(FileHandle.read())
  15         return True

好,测试,再次通过了!我们又多运行了几遍,还是没有任何问题 :D

现在,显然测试代码和实现代码都有很多要继续修改的地方,比如:

我们把它记在纸片上,留到明天一个一个处理 :)

总结,显然 TDD 有以下优点:

VimPythonTDD (last edited 2009-12-25 07:11:03 by localhost)