实际上我接触单元测试的时间并不算短,大三的时候就有一门课是讲测试的,我很早就知道什么是单元测试,要怎么设计测试用例。当然我不打算在博客中炫耀自己的理论知识,所以不要期待我在博客中介绍边界值法、逻辑覆盖、路径覆盖什么的。但把单元测试真正用在实际项目中,还是去年的事情,就算做毕业设计,我也没有写单元测试。
去年我工作的团队开始推广单元测试,当然我不认为单元测试是银弹,它不可能解决软件质量上的所有问题,也不可能让软件质量有一个质的飞跃,但它的确对提高软件质量有相当大的促进作用。首先它能保证各个独立的程序单元是符合预期的,这是保证整个系统能符合预期地运行的基础;其次,一个测试友好的程序必定也是一个低耦合的程序,低耦合对于调试和后期维护的好处,写过程序的人应该都懂。实际上去年令我觉得最有成就感的事情就是把单元测试应用到实践中了,可是说这是少数能直接应用我所学的理论的场景之一(PS:我不是说学校的理论没用哈,实际上支撑我工作的大部分还是学校学到的理论,只是很少理论能直接应用于实践)。
单元测试由于是一个测试过程,所以很少程序员会重视,甚至一些理解不深的人还会把单元测试看作是测试环节的一部分,我就曾经听到过测试的负责人问开发的负责人,推行单元测试测试方面需要给予什么支持。单元测试和测试人员有什么关系?可以说一点关系也没有,实际上单元测试不是测试环节应该做的事情,而是开发环节就应该做的事情,这是程序员的义务,程序员是有义务证明你写的程序是正确的。但现在能真正把单元测试好好执行的团队凤毛麟角。
Python在其标准库中,就已经有一套较为完整的单元测试框架了,这对于降低这个语言单元测试的学习成本和版本管理成本是相当有帮助的。比如Java,标准库中不提供单元测试的框架,尽管现在主流是用JUnit,学会JUnit应该能应付大部分Java项目的单元测试了,但总有非主流的存在。另外不同版本的JUnit必定会有些微小的差别,这样一个项目除了对Java的版本进行限定,对单元测试框架的版本也需要进行限定。对于第三方库的版本管理问题,一直是一个比较头疼的问题。而对于已经包含进标准库中的Python单元测试框架,则不存在第三方库的问题了,对于一个特定版本的Python环境,单元测试框架也是固定的。
Python的单元测试框架也是独立于IDE的。这个对于大部分编程语言来说应该都是理所当然的吧,但有一个例外,它就是.NET。微软提供的官方.NET单元测试框架是耦合在Visual Studio中的。对于大部分场景这样的确没啥问题,因为没有几个.NET程序员不是用VS作为开发工具的,但如果要在服务器上自动跑单元测试呢,比如持续集成环境,难道我还要在服务器上装一个VS?所以开源社区也为.NET提供了第三方的单元测试框架,比如比较流行的NUnit——JUnit的兄弟版本。但其实这样的话又回到了上一段提到的问题了。
市面上大部分单元测试框架都大同小异,Python的单元测试框架也一样。下面是WeiboSpider中一段比较简单的单元测试代码:
from unittest import TestCase
class CreateInfoLoggerTest(TestCase):
def setUp(self):
configMock = Mock()
configMock.INFO_LOG_FILE_PATH = '/tmp/unittest.log'
self.logger = filelogging.createInfoLogger(configMock)
def test_withoutInfoLogFilePath(self):
config = None
self.assertRaises(RuntimeError, filelogging.createInfoLogger, (config))
def test_withInfoLogFilePath(self):
self.assertIsInstance(self.logger, Logger)
让自己编写的测试类继承unittest模块中的TestCase类,这个类变成为一个最小的测试套件了,然后在测试类中定义一“test_”开头的方法,单元测试框架会识别出这些方法,并运行这些方法测试程序。
既然Python的单元测试框架是独立于IDE的,也就说可以通过框架本身或命令行来运行单元测试了。在WeiboSpider每个单元测试模块最后,都会有这样一段代码:
if __name__ == '__main__':
unittest.main(verbosity=2)
这段代码的目的就是让每个独立的单元测试模块都变得可运行的,只要执行这个python脚本,这个模块下的所有测试用例都会自动被执行。
当然这也是相当麻烦的事情,毕竟我不会把所有单元测试都放在同一个模块中,但起码我会把所有单元测试都放在同一个目录下,所以我又写了如下的一段程序:
if __name__ == '__main__':
test_modules = []
dir_name, cur_file_name = os.path.split(os.path.realpath(__file__))
for file_name in os.listdir(dir_name):
if re.search('.py$', file_name) and re.search(cur_file_name, file_name) is None:
test_modules.append(file_name[0:-3])
if len(test_modules) > 0:
suit = unittest.TestLoader().loadTestsFromNames(test_modules)
unittest.TextTestRunner(verbosity=2).run(suit)
读取当前目录下的所有python脚本,并执行测试。这样我就可以轻松地把所有的单元测试都运行一遍了,哈哈。
另外,python支持命令行模式下运行单元测试,命令如下:
$python -m unittest test_module1 test_module2
$python -m unittest test_module.TestClass
$python -m unittest test_module.TestClass.test_method
可以从不同粒度去运行单元测试,除了以包的粒度。这就是为什么我要自己写程序读取目录下的单元测试脚本了。
Python的单元测试框架提供的功能还是挺全的,在一般成熟的单元测试框架下有的功能,在Python的单元测试框架都能看到,具体可以看看Python的官方文档:http://docs.python.org/2.7/library/unittest.html
redisMock = Mock()
redisMock.lpop = Mock(return_value='test_user')
当调用redisMock变量的lpop方法时,无论传入参数是什么,返回值都是“test_user”。
redisMock = Mock()
redisMock.sismember = Mock(side_effect=lambda k, v:True if v == 'test_user' else False)
或
self._weiboList = []
def getWeibo(*args, **kwargs):
startIndex = (kwargs['page'] - 1) * 10
endIndex = kwargs['page'] * 10
result = Mock()
result.statuses = self._weiboList[startIndex:endIndex]
return result
self._defaultweiboapiMock.statuses.user_timeline.get = Mock(side_effect=getWeibo)
甚至是让Mock对象抛出一个异常:
mock = Mock(side_effect=KeyError('foo'))
因此,学会灵活使用Mock框架,是写好单元测试必不可少的一课。这一点学校的理论给了你很重要的提示(单元测试中被测模块要与其他模块隔离,为了能让被测模块正常运行,需要编写桩程序),但学校却没有教你如何快速编写桩程序(使用Mock框架)。一个好的工程师就是应该学会理论与实践结合,既不小看理论的作用,也不把理论束之高阁。
Class1 c = new Class1();
c.doSomething();
根据单元测试的要求,除了环境自带的标准库,所有与被测代码关联的其他模块、第三方模块都应该被替换成桩程序。请问上述代码在不改源代码的情况,怎么去把c变量替换成桩程序?难道我们每次单元测试之前都先修改一下源代码,测试完成后再改回来?那还怎么做自动化测试?
class SinaWeiboAPI(object):
def __init__(self, weiboAPIModule, virtualBrowser, appKey, appSecret, RedirectUri, userName, password):
self._apiClient = weiboAPIModule.APIClient(app_key=appKey, app_secret=appSecret, redirect_uri=RedirectUri)
sinaWeiboAutoAuth(self._apiClient, userName, password, virtualBrowser)
weiboAPIModule参数是访问微博开放平台的SDK的模块,virtualBrowser变量是模拟浏览器对象。可以看到,对其他模块的引用,都是以变量的形式存在于被测对象的内部,只要实现了特定的接口,关联模块是可以任意替换的。做单元测试的时候,只要把mock对象作为参数传入对象内部,就可以在不改被测对象一行代码的情况下,实现了把被测对象关联的模块替换成桩程序的工作了。
class Class1(object):
def doSomething(self):
pass
然后在被测代码里直接都引用了这个类:
c = Class1()
c.doSomething()
这样的代码在静态语言中是理所当然无法在不修改源代码的情况下注入桩程序的,但Python可以。只要在编写单元测试的时候不使用import语句导入Class1,而是在正式运行单元测试之前先执行如下语句:
Class1 = Mock(return_value=Mock())
这样Python解析器就会把Class1当作是一个全局变量,Class1()返回的就是一个Mock对象了。但这样的设计并不具有很好的扩展性,毕竟我们可能因为种种原因,或者是性能、或者是数据库从RDB换成了NoSQL,需要用Class2替换Class1。上述的代码就可能需要修改很多地方的代码了,毕竟你引用Class1的地方可能不止一两个。
cla = Class1
c = cla()
c.doSomething()
定义一个全局变量引用表示将要被创建的类,所有对于类的创建都是基于该全局变量之上的。这样修改全局变量的值,就等于把整个类都替换了,既适用于单元测试时注入桩程序,又适用于后续程序修改时模块的替换。