谈谈我的首个开源项目WeiboSpider(1)——单元测试

我眼中的单元测试

        实际上我接触单元测试的时间并不算短,大三的时候就有一门课是讲测试的,我很早就知道什么是单元测试,要怎么设计测试用例。当然我不打算在博客中炫耀自己的理论知识,所以不要期待我在博客中介绍边界值法、逻辑覆盖、路径覆盖什么的。但把单元测试真正用在实际项目中,还是去年的事情,就算做毕业设计,我也没有写单元测试。

        去年我工作的团队开始推广单元测试,当然我不认为单元测试是银弹,它不可能解决软件质量上的所有问题,也不可能让软件质量有一个质的飞跃,但它的确对提高软件质量有相当大的促进作用。首先它能保证各个独立的程序单元是符合预期的,这是保证整个系统能符合预期地运行的基础;其次,一个测试友好的程序必定也是一个低耦合的程序,低耦合对于调试和后期维护的好处,写过程序的人应该都懂。实际上去年令我觉得最有成就感的事情就是把单元测试应用到实践中了,可是说这是少数能直接应用我所学的理论的场景之一(PS:我不是说学校的理论没用哈,实际上支撑我工作的大部分还是学校学到的理论,只是很少理论能直接应用于实践)。

        单元测试由于是一个测试过程,所以很少程序员会重视,甚至一些理解不深的人还会把单元测试看作是测试环节的一部分,我就曾经听到过测试的负责人问开发的负责人,推行单元测试测试方面需要给予什么支持。单元测试和测试人员有什么关系?可以说一点关系也没有,实际上单元测试不是测试环节应该做的事情,而是开发环节就应该做的事情,这是程序员的义务,程序员是有义务证明你写的程序是正确的。但现在能真正把单元测试好好执行的团队凤毛麟角。

WeiboSpider中的单元测试

        在WeiboSpider刚启动的时候,我就决定把单元测试作为项目的一部分,把它和主程序放在同等重要的地位。作为开源项目,我觉得单元测试比非开源的项目来得更重要。一旦项目开源,谁修改你的代码就变得不可控了,如果对方是一个牛人,不仅把你的代码重构得相当优雅,还帮你修复了几个十分隐蔽的bug,你会觉得这是上帝派人下来打救你了;但要是对方是一个水平一般,而且又粗心大意的程序员呢?他在你良好运行的程序中改出bug了怎么办?你怎么迅速地定位错误?如果他曲解了你意图或程序设计的初衷,私自改变了程序运行的逻辑,你能发现吗?所以Oracle当初拿掉MySQL的单元测试,在开源界反响还是很大的。对于MySQL这种规模这么大,而且成熟的开源软件,拿掉单元测试对于修改和重构程序都是无比困难的。
        单元测试提供了一个很好的检验标准和检验方法,程序哪怕一点点偏离了预想的运行方向,单元测试都能发现(当然前提是测试用例足够严谨),所以良好的单元测试不仅是发现程序bug的工具,更是程序设计者设计思想的表达,所有修改程序的人,要么严格地按照程序最初设计者的思路修改程序;要么先理解好程序最初设计者的设计,再改进设计。
        尽管在开发WeiboSpider的过程中,我没有使用TDD,但我依然坚持每增加一个模块,完成编码后都会为它写上单元测试。这样能保证我每个完成的模块,局部都是正确的。因为WeiboSpider开发的周期可能很长,而初期开发的大部分代码都是相对隔离的,无法集成起来测试。如果等到集成的时候才开始测试代码,调试代码就会变得很困难,特别是调试几个月前写的代码,当时的思路都未必记得了。因此尽管到目前为止写单元测试的工作量大概占了我整个开发工作量的1/3,但我依然会坚持下去,为了程序更好地维护,还有后续开发效率的提升。

Python中的单元测试

        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

Python中的Mock

        在我刚开始接触单元测试的时候,我就想到了一个挺麻烦的问题,就是要写桩程序和mock程序。尽管这些程序的逻辑可以很简单,但有些代码无论如何都是省不了的,比如定义类的代码、定义方法的代码等等。对于桩程序,有时候这些代码比逻辑代码本身还要多。这样很打击人的积极性啊!因此Mock框架便应运而生了,不同语言都能在开源社区中找到自己的Mock框架。尽管各个Mock框架用法差别可能很大,但都秉承者一个理念:开发者只要把接口的定义和模拟的逻辑告诉Mock对象就可以了,具体的桩对象由Mock对象帮忙创建,尽可能地减少和测试无关的代码。
        由于Python2.7的标准库中没有Mock框架,所以我使用了一个在官网中能找到的Mock框架(https://pypi.python.org/pypi/mock),这个框架在Python3.3版本被加入到了unittest包中,成为了Python标准库的一部分。
        得益于Python众生皆对象的特性,Python的Mock框架很简单,核心只有一个Mock类。Mock类型的对象可以模拟包、模块、对象、方法、函数等等一切Python对象。当Mock对象是模拟一个可执行实体(函数或方法)时,我们可以向Mock对象传入一些简单的逻辑,比如向return_value属性赋值,让函数(或方法)直接返回某些值,比如如下代码:
redisMock = Mock()
redisMock.lpop = Mock(return_value='test_user')
        当调用redisMock变量的lpop方法时,无论传入参数是什么,返回值都是“test_user”。
        甚至可以让Mock对象执行一些简单的逻辑,只要在side_effect属性中传入lambda表达式或可执行对象就可以了,比如:
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框架)。一个好的工程师就是应该学会理论与实践结合,既不小看理论的作用,也不把理论束之高阁。

测试友好的设计

        是的,并不是什么样的代码都是能方便地进行单元测试的,比如如下的Java代码:
Class1 c = new Class1();
c.doSomething();
        根据单元测试的要求,除了环境自带的标准库,所有与被测代码关联的其他模块、第三方模块都应该被替换成桩程序。请问上述代码在不改源代码的情况,怎么去把c变量替换成桩程序?难道我们每次单元测试之前都先修改一下源代码,测试完成后再改回来?那还怎么做自动化测试?
        所以WeiboSpider的所有类的定义都会像以下代码一样定义(函数也类似,如果有需要的话):
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对象作为参数传入对象内部,就可以在不改被测对象一行代码的情况下,实现了把被测对象关联的模块替换成桩程序的工作了。
        当然这种设计方式的最大缺点就是很长的参数列表,特别是在被测程序传出耦合度很高的时候,这简直是个灾难。Python众生皆对象的灵活性给予了我们另一条注入桩程序的路。
        例如定义了这样一个类:
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()
        定义一个全局变量引用表示将要被创建的类,所有对于类的创建都是基于该全局变量之上的。这样修改全局变量的值,就等于把整个类都替换了,既适用于单元测试时注入桩程序,又适用于后续程序修改时模块的替换。
        实际上这两种方法各有优劣:参数传入的方法会增加参数列表维护成本、创建对象时的复杂度和维护类私有成员的成本;全局变量的方法则是增加维护和规划全局变量的成本。尽管只要建立一个全局变量的管理规范,第二种方法能写出相对简洁和易于维护的代码,但在多人协助的项目,特别是开源项目这种无法控制项目参与成员的情况下,只要有一两个人定义的全局变量难于管理,其恶果可能会蔓延到项目的其他模块,但第一种方法受影响的只会是当前模块。出于谨慎使用全局变量的原则,我最终选择了使用第一种方法。
        对于Python这种具有极高灵活性,而且语言本事支持多种编程模式的语言,你需要思考的东西就变得很多。当使用Java或C#时,上述第一种方法几乎是唯一选择了(当然使用反射或对象克隆的方式能实现第二种方法,但是这要么影响性能,要么白白增加程序的复杂度,何必呢?),但Python却会令你在两条路之间徘徊。设计就是一个取舍的过程,好的设计师都会知道自己需要什么,自己可以抛弃什么。

总  结

        单元测试被我列为WeibSpider系列文章的第一个细节点来讲,是因为我希望参与我这个开源项目的朋友都能认认真真地写好单元测试,当你完成了一段代码后,先别急着写下一段代码,而是为这段代码加上单元测试,在写单元测试的过程中重新审视自己的代码,她的逻辑完整吗?她有“臭味”吗?当然TDD也是相当欢迎的。
        就算你不大算参与WeiboSpider项目,如果你也是一个程序员,也欢迎你加入单元测试的行列。当你习惯写单元测试,你写出来的代码会变得很不一样,真的!

WeiboSpider项目地址:https://github.com/phospher/WeiboSpider

你可能感兴趣的:(软件开发,WeiboSpider,单元测试,Python,开源项目,单元测试)