15.1 为测试定义并隔离单元
因为测试是基本的,可测试性在设计的考虑过程中是一个重要的环节。设计也必须要支持测试和调试,因为不使用的类是没有价值的。一个类需要被证明是可以工作的,这一点是很重要的。
理想情况下,会希望有一个测试的层次结构。最底层是单元测试。在这里,我们对每个类或函数进行隔离测试是为了确保它符合API的标准。每个类或函数在测试中都是一个单元。在单元测试上面是集成测试。一旦可以确定每个类或函数是可以独立工作的,就可以对一组类进行测试。也可以测试整个模块和整个包。完成集成测试之后,就可以对整个应用进行自动化测试。
这并不是有关测试的所有种类,还可以做性能测试以及安全漏洞测试。然而,我们会重点关注自动化测试,因为它是整个应用的重心。测试的层次结构揭示了很重要的复杂性。对于类或一组类的测试用例的定义可以是非常狭义的。随着向集成测试中添加更多的单元,输入的值域也增加了。当尝试对整个应用进行测试时,手动操作成为了一个可选的输入,包括在测试过程中关闭设备、拔出电源,或是将桌子上的东西推下去,看一下当设备掉在距离桌面3英尺的硬地板上时一切是否仍然正常。由于可能的场景非常多,因此使得应用完全做到自动化测试是非常困难的。
我们会重点关注最容易实现自动化测试的方式。只有完成了单元测试,在更大的持续集成的系统才有可能正常运行。
15.1.1 最小化依赖
在设计类时,必须要考虑到类的依赖关系:它所依赖的类和依赖于它的类。为了简化类定义的测试,需要将它从依赖的类中隔离出来。
以Deck类为例,它依赖于Card类。我们可以很容易地将Card类隔离出来进行测试,但是当需要对Deck类进行测试时,需要将它对Card类的依赖拿掉。
以下是一个Card类的定义,在之前介绍过了。
class Card:
def init( self, rank, suit, hard=None, soft=None ):
self.rank= rank
self.suit= suit
self.hard= hard or int(rank)
self.soft= soft or int(rank)
def str( self ):
return "{0.rank!s}{0.suit!s}".format(self)
class AceCard( Card ):
def init( self, rank, suit ):
super().init( rank, suit, 1, 11 )
class FaceCard( Card ):
def init( self, rank, suit ):
super().init( rank, suit, 10, 10 )
可以看到这些类中的每一个都有一个直接的继承层次结构。每个类都可以被隔离出来进行测试,因为它们只包含了两个方法和4个属性。
可以(错误)定义一个Deck类,包含一些相关依赖。
Suits = '♣', '◆', '♥', '♠'
class Deck1( list ):
def init( self, size=1 ):
super().init()
self.rng= random.Random()
for d in range(size):
for s in Suits:
cards = ([AceCard(1, s)]
+ [Card(r, s) for r in range(2, 12)]
+ [FaceCard(r, s) for r in range(12, 14)])
super().extend( cards )
self.rng.shuffle( self )
以上设计有两点缺陷。首先,它与Card类层次结构中的3个类是紧耦合的。无法将Deck从Card中分离出来做单元测试。其次,它依赖于随机数生成器,不是一个可重复的测试。
一方面,Card是一个非常简单的类,可以很容易将Deck以及所包含的Card一起测试。另一方面,可能会想重用扑克牌或者皮纳克尔牌,它们在21点游戏中有不同的行为。
理想情况下,可以使Deck独立于任何Card的实现。如果做到了这一点,就能够在不依赖Card实现的前提下,对Deck进行测试,也可以对任何Card与Deck的组合情况进行测试。
以下实现使用了工厂函数,是一种比较好的解耦方式。
def card( rank, suit ):
if rank == 1: return AceCard( rank, suit )
elif 2 <= rank < 11: return Card( rank, suit )
elif 11 <= rank < 14: return FaceCard( rank, suit )
else: raise Exception( "LogicError" )
card()函数会基于传入的rank值完成Card子类的创建。这样一来,Deck类就可以使用这个函数来完成创建Card实例的过程。我们通过插入一个中间函数将两个类的定义分离。
还有其他用于将Card类从Deck类解耦的方式。可以对工厂函数进行重构,将它变成Deck类的方法。也可以通过使用类级别的属性,或初始化方法参数的方式将类名独立出来进行绑定。
在以下这个例子中,在初始化方法中使用了复杂的绑定来代替工厂函数。
class Deck2( list ):
def init( self, size=1,
random=random.Random(),
aceclass=AceCard, cardclass=Card, face_class=FaceCard ):
super().__init()
self.rng= random
for d in range(size):
for s in Suits:
cards = ([ace_class(1, s)]
+ [ card_class(r, s) for r in range(2, 12) ]
+ [ face_class(r, s) for r in range(12, 14) ] )
super().extend( cards )
self.rng.shuffle( self )
然而,这个初始化过程有些多余,Deck类并没有与Card类的层次结构或是特殊的随机数生成器存在耦合。为了可测试性,可以提供一个包含了已知种子的随机数生成器。也可以使用其他用于简化测试的类(例如tuple)来替代Card类的相关定义。
在下一节中,我们会重点关注另一种Deck类的实现。它将使用card()工厂函数。在工厂函数中,封装了Card层次结构的绑定并使用了一些规则,根据rank将card类分离出来,可以单独测试。
15.1.2 创建简单的单元测试
我们将对Card类层次结构和card()工厂函数创建一些简单的单元测试。
由于Card的相关类都很简单,因此没有必要对它们过度地进行测试。总存在一种不会太复杂的方式。如果盲目地进行测试驱动开发,在开发过程中就需要为一个只包含了几个简单的属性和方法的类写很多没必要的单元测试。
测试驱动开发只是一个建议,而并不是质量守恒定律,理解这一点很重要。它并不是一种不思考就必须要遵从的一种规则。
有几种关于测试方法命名的方式。我们会重点关注一种包含了测试条件和期望结果的命名风格。关于这种命名方式有以下3种写法。
- 可以使用 should 将命名分为两个部分,例如StateUnderTest should ExpectedBehavior,包含了状态和结果。我们将重点关注这种方式。
- 可以使用由 when 和 should 组成的包含两个部分的名称,例如 when State UnderTest should ExpectedBehavior,也是包含了状态和结果,但是使用了更多的命名语法。
- 可以使用一个包含了3个部分的名称,UnitOfWork StateUnderTest Expected Behavior。它结合了要测试的工作单元进行说明,在读测试日志时会很有用。
更多信息,可以参考http://osherove.com/blog/2005/4/3/namingstandards-for-unit-tests.html。
可以通过对unittest模块进行配置来使用不同的模式对测试方法进行查找。可以将它配置为查找when _。可以简单地使用内置的查找模式,将所有的测试方法名以test开头。
以下是Card类的一个测试方法示例。
class TestCard( unittest.TestCase ):
def setUp( self ):
self.three_clubs= Card( 3, '♣' )
def test_should_returnStr( self ):
self.assertEqual( "3♣", str(self.three_clubs) )
def test_should_getAttrValues( self ):
self.assertEqual( 3, self.three_clubs.rank )
self.assertEqual( "♣", self.three_clubs.suit )
self.assertEqual( 3, self.three_clubs.hard )
self.assertEqual( 3, self.three_clubs.soft )
我们定义了一个测试方法setUp(),创建了一个被测试类的对象,也为这个对象定义了两个测试。因为这里没有实际的交互,在测试名称中也没有包含测试的状态。它们的行为非常简单,应该总是工作的。
有些人会质疑这样做有些过度测试了,因为测试代码多于应用程序代码。答案是不会,因为它没有过度。实际上并没有一种规定,要求应用程序代码要比测试代码多。而事实上,这样的对比是没有意义的。重要的是,即使很小的类可能都会有bug。
只是对属性值进行测试并没有测试到类的逻辑。正如在之前的例子中所看到的,关于测试属性值有两种观点。
- 从黑盒的角度来看,我们应该忽视实现。这样的话,需要对所有的属性进行测试。而在属性中,有些有可能是特性,也应该被测试。
- 从白盒的角度来看,可以对实现细节进行验证。如果打算这样测试,可能需要稍微琢磨一下哪些属性该被测试。例如,suit 属性就不需要太多的测试。而对于hard和soft属性,需要多一些的测试。
有关更多信息,可以参见http://en.wikipedia.org/wiki/White-box_testing和http://en.wikipedia.org/ wiki/Black-box_testing。
当然,还需要对Card类层次结构中的其余部分进行测试,这里只演示AceCard这个测试用例。介绍完这个例子,FaceCard这个测试用例也应该清楚了。
class TestAceCard( unittest.TestCase ):
def setUp( self ):
self.ace_spades= AceCard( 1, '♠' )
def test_should_returnStr( self ):
self.assertEqual( "A♠", str(self.ace_spades) )
def test_should_getAttrValues( self ):
self.assertEqual( 1, self.ace_spades.rank )
self.assertEqual( "♠", self.ace_spades.suit )
self.assertEqual( 1, self.ace_spades.hard )
self.assertEqual( 11, self.ace_spades.soft )
在这个测试用例中,也先创建了一个Card实例,这样就可以测试字符串输出。它对这张牌中每个属性都进行了检查。
15.1.3 创建一个测试组件
定义一个测试组件通常是有用的。默认情况下,会使用unittest包来对测试进行搜索。当在多个测试模块中对测试进行收集时,最好在每个测试模块中都定义一个测试组件。如果每个模块都定义了一个suite()函数,就可以使用各个模块中定义的suite()函数来进行测试的查找操作。而且,如果要自定义一个TestRunner,也需要使用一个测试组件,可以使用如下代码来执行测试。
def suite2():
s= unittest.TestSuite()
load_from= unittest.defaultTestLoader.loadTestsFromTestCase
s.addTests( load_from(TestCard) )
s.addTests( load_from(TestAceCard) )
s.addTests( load_from(TestFaceCard) )
return s
我们先基于3个TestCases类的定义创建了一个测试组件,然后将它传入unittest. TextTestRunner()实例。在unittest中我们使用了默认的TestLoader。TestLoader先对TestCase类进行扫描,找出所有的测试方法。TestLoader.testMethodPrefix的值为test,这是查找类中测试方法的匹配方式,加载器使用方法名来创建测试对象。
有两种方式来使用TestCases,其中基于TestCase的命名方法使用TestLoader来创建测试用例是其中一种。在接下来的节中,会介绍手动创建TestCase实例。在这里的例子中,我们不会依赖TestLoader。可使用以下代码来运行测试组件。
if name == "main":
t= unittest.TextTestRunner()
t.run( suite2() )
可以看到如下输出:
…F.F
======================================================================
FAIL: testshouldreturnStr (main.TestAceCard)
———————————————————————————————————
Traceback (most recent call last):
File "p3_c15.py", line 80, in test_should_returnStr
self.assertEqual( "A♠", str(self.ace_spades) )
AssertionError: 'A♠' != '1♠'
- A♠
+ 1♠
======================================================================
FAIL: test_should_returnStr (__main.TestFaceCard)
———————————————————————————————————
Traceback (most recent call last):
File "p3_c15.py", line 91, in test_should_returnStr
self.assertEqual( "Q♥", str(self.queen_hearts) )
AssertionError: 'Q♥' != '12♥'
- Q♥
+ 12♥
———————————————————————————————————
Ran 6 tests in 0.001s
FAILED (failures=2)
TestLoader类分别为每一个TestCase类创建了两个测试,一共6个测试。测试名称即方法名称,以test开头。
这里有个很明显的问题。测试所提供的期望结果与类定义并没有匹配。需要为Card的相关类做一些开发工作来通过这个组件的测试。关于这个bug的修改,留给读者来完成。
15.1.4 包含边界值测试
当需要对Deck类整体进行测试时,需要保证几点:覆盖到了所有需要用到的Cards类,并且被正确洗牌了。这里并不需要对出牌进行测试,因为会依赖于 list和list.pop()方法。因为它们是Python内置的,所以不需要额外的测试。
我们希望测试Deck类的创建和洗牌过程,独立于任何Card类层次结构。如之前提到的,可以使用一个工厂方法来对Deck和Card的定义进行解耦。工厂方法的添加会需要更多的测试。如果考虑一下之前在 Card 类层次结构中找出的 bug,就可以看出这并不是坏事。
以下是工厂函数的一个测试。
class TestCardFactory( unittest.TestCase ):
def test_rank1_should_createAceCard( self ):
c = card( 1, '♣' )
self.assertIsInstance( c, AceCard )
def test_rank2_should_createCard( self ):
c = card( 2, '◆' )
self.assertIsInstance( c, Card )
def test_rank10_should_createCard( self ):
c = card( 10, '♥' )
self.assertIsInstance( c, Card )
def test_rank10_should_createFaceCard( self ):
c = card( 11, '♠' )
self.assertIsInstance( c, Card )
def test_rank13_should_createFaceCard( self ):
c = card( 13, '♣' )
self.assertIsInstance( c, Card )
def test_otherRank_should_exception( self ):
with self.assertRaises( LogicError ):
c = card(14, '◆')
with self.assertRaises( LogicError ):
c = card(0, '◆')
我们没有对所有13种牌面值进行测试,因为从2到10可以看作是同样的情况。而我们所做的,正是结合了Boris Beizer的建议。
Bug隐藏在角落里,并聚集在边界。
测试用例中应该包含纸牌的所有边界值。因此,需要对牌面值1、2、10、11和13,以及不合法的0和14进行测试。其中包括了牌面值的最大值和最小值的情况,并为小于最小值和大于最大值分别设计了一个测试用例。
当运行时,这个测试用例会出现一些错误。其中一个错误是使用了未定义的异常LogicError。它的定义是Exception的一个子类,可定义了这个异常后还有一些其他错误,这部分留给读者进行修正。
15.1.5 为测试模仿依赖
为了对Deck进行测试,有两种方式来解决依赖问题。
- 模仿:可以为Card类创建一个模仿类和一个模仿的card()工厂函数,用于创建模仿类的对象。使用模仿对象的优势是我们有足够的信心可以将被测试单元从整个解决方案中独立出来,它弥补了其他类产生的bug。潜在的缺陷是,我们可能会需要对一个行为超复杂的模仿类进行调试,来确保它正确地替代了实际类。
- 集成:如果我们非常确定Card类层次结构和card()工厂函数是工作的,我们可以使用它们来测试Deck。这种做法偏离了纯单元测试的轨道,单元测试提倡移除所有的依赖。在实践中它可以很好地工作,然而,对于一个可以通过所有单元测试的类来说,它与模仿类同样是可以信赖的。对于在非常复杂、有状态的API的环境中,应用类可能比模仿类更值得信赖。这种做法的缺陷是,底层的一个类一旦不工作,会导致所有依赖它的测试失败。而且,使用非模仿类来对API的一致性做详细测试也是很困难的。模仿类可以追踪调用记录,可以统计模仿类被调用的次数以及参数的使用次数。
可以使用unittest包中的unittest.mock模块来对已有类打补丁用于测试,也可以用于定义模仿类。
在设计一个类时,必须要考虑到在单元测试中需要被模仿的依赖部分。对于 Deck 例子来说,有3处依赖需要模仿。
- Card类:这个类很简单,只需要为它创建一个不基于任何实现的模仿类。由于Deck类的行为并不依赖Card中的任何功能,因此Card的模仿对象很简单。
- card()工厂函数:需要使用一个模仿函数来替代这个函数,用于判断Deck对这个函数的调用是否是正确的。
- random.Random.shuffle()方法:为了判断是否使用了正确的参数值在调用这个方法,可以使用一个模仿类来追踪它的使用而不再包含洗牌行为。
以下是使用了card()工厂函数的Deck的实现。
class Deck3( list ):
def init( self, size=1,
random=random.Random(),
cardfactory=card ):
super()._init()
self.rng= random
for d in range(size):
super().extend(
card_factory(r,s) for r in range(1,13) for s in Suits
)
self.rng.shuffle( self )
def deal( self ):
try:
return self.pop(0)
except IndexError:
raise DeckEmpty()
以上定义有两点依赖,通过参数传入 init ()方法中。它需要一个随机数生成器random和一个纸牌工厂card _ factory,并已经为它们设置了默认值。在测试时,也可以使用模仿对象来代替默认值对象。
引入了一个deal()方法,打牌的行为改变了对象。如果deck是空的,deal()方法会抛出DeckEmpty异常。
在以下这个测试用例中可以看出,deck被正确地创建了。
import unittest
import unittest.mock
class TestDeckBuid( unittest.TestCase ):
def setUp( self ):
self.test_card= unittest.mock.Mock( return_value=unittest.
mock.sentinel )
self.test_rng= random.Random()
self.test_rng.shuffle= unittest.mock.Mock( return_value=None )
def test_deck_1_should_build(self):
d= Deck3( size=1, random=self.test_rng, card_factory= self.
test_card )
self.assertEqual( 52*[unittest.mock.sentinel], d )
self.test_rng.shuffle.assert_called_with( d )
self.assertEqual( 52, len(self.test_card.call_args_list) )
expected = [
unittest.mock.call(r,s)
for r in range(1,14)
for s in ('♣', '◆', '♥', '♠') ]
self.assertEqual( expected, self.test_card.call_args_list )
在以上用例的setUp()方法中,我们创建了两个模仿对象。其中纸牌工厂函数test _ card是一个模仿函数。返回值也只是模拟的,是一个 sentinel 对象,而不是 Card实例。由于sentinel是唯一的对象,因此可以用于确定所创建的实例数量是正确的。它和所有其他的 Python 对象都是不同的,这样就可以找出那些没有使用正确返回语句而返回了 None的函数。
我们创建了一个random.Random()生成器的实例,但是我们使用了模仿函数,在其中直接返回None来代替shuffle()方法的返回值。这样,我们就可以为方法返回一个定值从而判断出shuffle()方法在被调用时是否使用了正确的参数。
在我们的测试中,使用两个模仿对象创建了一个Deck类,然后为这个Deck的实例d做了一些断言。
- 52张牌都被创建了。它们是52个mock.sentinel对象的备份,可以看出,只有工厂函数参与了对象的创建。
- shuffle()方法被Deck的实例所调用。从这里可以看出,一个模仿对象是如何追踪调用记录的,可以使用assert called with()来确定,调用shuffle()的时候,需要指定参数值。
- 工厂函数被调用了52次。
- 在调用工厂函数时,使用了所期望的牌面值和花色的值序列作为参数。
在Deck类定义中有一个小bug,因此这个测试没有通过。这个bug的修复作为练习留给读者完成。
15.1.6 为更多的行为使用更多的模仿对象
在之前所示的例子中,使用了模仿对象来测试Deck类的创建。使用52个相同的模仿对象导致很难确定Deck出牌行为的正确性,我们会定义一个不同的模仿对象来测试出牌功能。
这里是第2个测试用例,用来确保Deck类的出牌行为是正确的。
class TestDeckDeal( unittest.TestCase ):
def setUp( self ):
self.test_card= unittest.mock.Mock( side_effect=range(52) )
self.test_rng= random.Random()
self.test_rng.shuffle= unittest.mock.Mock( return_value=None )
def test_deck_1_should_deal( self ):
d= Deck3( size=1, random=self.test_rng, card_factory= self.
test_card )
dealt = []
for c in range(52):
c= d.deal()
dealt.append(c)
self.assertEqual( dealt, list(range(52)) )
def test_empty_deck_should_exception( self ):
d= Deck3( size=1, random=self.test_rng, card_factory= self.
test_card )
for c in range(52):
c= d.deal()
self.assertRaises( DeckEmpty, d.deal )
纸牌工厂函数的模仿对象使用了side _ effect从参数来调用Mock()。当传入一个可迭代对象时,会返回每次迭代时的值。
我们对shuffle()方法进行了模仿,来确定纸牌实际上没有被洗。希望它们保持原来的顺序,这样测试就可以有一个可预测的期望值。
在第1个测试(test deck 1 should deal)中,将52张牌的结果存入变量dealt中。然后断言在这个变量中,包含的52个牌面值与模仿纸牌工厂相匹配。
在第2个测试(test empty deck should exception)中,打出Deck实例中所有的牌。然而,多了一次API请求。断言是,在打完所有的牌后,Deck.deal()方法会抛出正确的异常。
由于Deck类相对简单,可以将TestDeckBuild和TestDeckDeal合并为一个类,需要更复杂的模仿对象。而在这个例子中,可以对测试用例进行重构使它们变得简单,这一点不是必需的。然而,对测试过度的简化可能会导致一些API功能的测试遗漏。
