高质量的程序有自动测试。我们需要使用一切可以使用的东西来确保我们的软件正常工作。黄金法则是:要可交付,特性必须具有自动单元测试。如果没有自动化的单元测试,就不能信任某个特性可以工作,也不应该使用它。根据 Kent Beck 的说法,在极限编程中解释了:
"Any program feature without an automated test simply doesn't exist."
关于程序功能的自动测试,有以下两个要点:
- 自动:这意味着不需要人工判断。测试涉及一个脚本,该脚本将实际响应与预期响应进行比较。
- 功能:对软件的元素进行单独测试,以确保它们单独工作。在某些上下文中,功能是相当广泛的概念,涉及用户观察到的功能。当进行单元测试时,特性通常要小得多,并且是指单个软件组件的行为。每个单元都有足够的软件来实现给定的功能。理想情况下,它是一个 Python 类。但是,它也可以是更大的单元,例如模块或包。
Python 有两个内置的测试框架,使得编写自动单元测试变得容易。我们将考虑使用doctest
和unittest
进行自动测试。doctest
提供了一种非常简单的测试写作的方法。unittest
套餐要复杂得多。
我们还将介绍流行的pytest
框架,它比unittest
更易于使用。在某些情况下,我们将使用unittest.TestCase
类编写测试,但使用pytest
复杂的测试发现执行测试。在其他情况下,我们将使用pytest
完成几乎所有操作。
我们将研究一些使测试实用所需的设计考虑因素。为了可测试性,通常需要分解复杂的类。正如我们在第 15 章、设计原则和模式中提到的,我们希望公开依赖项具有灵活的设计;依赖倒置原则也将有助于创建可测试类。
更多想法,请阅读Ottinger 和 Langr 的FIRST单元测试属性—Fast、Isolated、Repeatable、自我验证和Timed。在大多数情况下,可重复和自验证需要一个自动化测试框架。及时意味着测试是在测试代码之前编写的。更多信息,请参考http://pragprog.com/magazines/2012-01/unit-tests-are-first 。
在本章中,我们将介绍以下主题:
- 定义和隔离测试单元
- 使用
doctest
定义测试用例 - 使用安装和拆卸
TestCase
类层次结构- 使用外部定义的预期结果
- 使用
pytest
和fixtures
- 自动集成测试或自动性能测试
本章的代码文件可在上找到 https://git.io/fj2UM 。
当我们认为测试是必不可少的,可测试性是面向对象编程的一个重要设计考虑。我们的设计还必须支持测试和调试,因为只有出现工作的类是没有价值的。有证据表明它有效的类更有价值。
不同类型的测试形成一种层次结构。层次结构的基础是单元测试。在这里,我们单独测试每个类或函数,以确保它满足 API 的合同义务。每个类或函数都是一个独立的测试单元。在这一层之上是集成测试。一旦我们知道每个类和函数单独工作,我们就可以测试类的组和集群。我们也可以测试整个模块和整个软件包。一旦集成测试开始工作,下一层就是整个应用程序的自动化测试。这不是测试类型的详尽列表。我们还可以进行性能测试和安全漏洞测试。
在本章中,我们将重点讨论自动化单元测试,因为它是所有应用程序的核心。测试的层次结构揭示了重要的复杂性。单个类或一组类的测试用例可以定义得非常狭窄。随着我们在集成测试中引入更多的单元,输入领域也在增长。当我们尝试测试整个应用程序时,整个人类行为光谱都会成为潜在的输入。当我们更广泛地观察时,我们发现这可能包括关闭设备、拔出插头,甚至将设备从桌子上推下来,看看它们在被扔到硬木地板上三英尺后是否还能工作。人类行为领域的巨大性使得完全自动化所有可能的应用程序测试变得非常困难。我们将关注最容易自动测试的东西。一旦单元测试起作用,较大的聚合系统就更有可能起作用。
当我们设计一个类时,我们还必须考虑该类周围的依赖网络:依赖于它的类和依赖于它的类。为了简化类定义的测试,我们需要将它与周围的类隔离开来。关于这方面的更多想法,请参考第 15 章、设计原则和模式。
一个例子是Deck
类,它依赖于Card
类。我们可以很容易地单独测试Card
,但是当我们想要测试Deck
类时,我们需要将其从Card
的定义中剔除。
以下是我们已经研究过的Card
的一个(众多)先前定义:
import enum
class Suit(enum.Enum):
CLUB = "♣"
DIAMOND = "♦"
HEART = "♥"
SPADE = "♠"
class Card:
def __init__ (
self , rank: int , suit: Suit, hard: int = None , soft: int = None
) -> None :
self .rank = rank
self .suit = suit
self .hard = hard or int (rank)
self .soft = soft or int (rank)
def __str__ ( self ) -> str :
return f"{self.rank!s}{self.suit.value!s}"
class AceCard(Card):
def __init__ ( self , rank: int , suit: Suit) -> None :
super (). __init__ (rank, suit, 1 , 11 )
class FaceCard(Card):
def __init__ ( self , rank: int , suit: Suit) -> None :
super (). __init__ (rank, suit, 10 , 10 )
我们可以看到,这些类中的每一个都有一个简单的继承依赖关系。每个类都可以单独测试,因为只有两个方法和四个属性。
我们可以(错误地)设计Deck
类。以下是一个糟糕的示例,并且具有一些有问题的依赖关系:
import random
class Deck1( list ):
def __init__ ( self , size: int = 1 ) -> None :
super (). __init__ ()
self .rng = random.Random()
for d in range (size):
for s in iter (Suit):
cards: List[Card] = (
[cast(Card, 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
类层次结构中的三个类密切相关。对于独立的单元测试,我们无法将Deck
与Card
分离。其次,它依赖于随机数生成器,因此很难创建可重复的测试。当我们回顾第 15 章、设计原则和模式时,我们可以将这些问题视为没有遵循依赖倒置原则。
一方面,Card
是一个非常简单的类。我们可以在保留Card
的情况下测试Deck
的这个版本。另一方面,我们可能希望将Deck
与扑克卡或皮诺切勒卡一起使用,这两种卡的行为与 21 点卡不同。
理想的情况是使Deck
独立于任何特定的Card
实现。如果我们做得好,那么我们不仅可以独立于任何Card
实现来测试Deck
,还可以使用Card
和Deck
定义的任意组合。
这里是我们分离其中一个依赖项的首选方法。我们可以将这些依赖项放入工厂函数中,如下例所示:
class LogicError( Exception ):
pass
def card(rank: int , suit: Suit) -> Card:
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 LogicError( f"Rank { rank } invalid" )
card()
函数将根据请求的秩建立Card
的适当子类。这允许Deck
类使用此函数,而不是直接构建Card
类的实例。我们通过插入一个中间函数来分隔这两个类定义。
还有其他技术可以将Card
类与Deck
类分开。我们可以将工厂函数重构为一种Deck
方法。我们还可以通过类级属性甚至初始化方法参数使类名成为单独的绑定。以下示例通过在初始化方法中使用更复杂的绑定来避免工厂函数:
class Deck2( list ):
def __init__ (
self ,
size: int = 1 ,
random: random.Random=random.Random(),
ace_class: Type[Card]=AceCard,
card_class: Type[Card]=Card,
face_class: Type[Card]=FaceCard,
) -> None :
super (). __init__ ()
self .rng = random
for d in range (size):
for s in iter (Suit):
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
类定义,以简化我们的测试。
请注意为此类提供的默认值的类型提示。为random
参数提供的对象应该是random.Random
类型的实例,默认值是该类型的对象。类似地,对于三个卡类,每个卡类都必须是一个Type
和Card
类的子类。Type[Card]
提示允许从Card
派生的任何类。这允许mypy
确认超控可能有效。在测试模块中检查类型提示可能很困难,因为从版本 3.8.2 开始,pytest 没有完整的类型提示存根。
在下一节中,我们将关注Deck
类的另一个变体。这将使用card()
工厂功能。该工厂函数将Card
层次结构绑定和按等级划分卡类的规则封装到一个可测试的位置。
我们将为Card
类层次结构和card()
工厂函数创建一些简单的单元测试。
由于Card
类非常简单,因此没有理由进行过于复杂的测试。总是有可能在不必要的复杂情况下出错。在一个测试驱动的开发过程中进行不加思考的可能会让我们觉得需要为一个只有少量属性和方法的类编写大量不太有趣的单元测试。
理解测试驱动开发是建议很重要,而不是像质量守恒那样的自然法则。这也不是一种必须不假思索地遵循的仪式。
关于命名测试方法,有几个学派。我们将重点介绍一种命名方式,其中包括描述测试条件和预期结果。以下是有关此主题的三个变体:
StateUnderTest_should_ExpectedBehavior
when_StateUnderTest_should_ExpectedBehavior
UnitOfWork_StateUnderTest_ExpectedBehavior
有关更多信息,请参阅:
http://osherove.com/blog/2005/4/3/naming-standards-for-unit-tests.html
名称的StateUnderTest
部分通常在包含测试的类中很明显,并且可以从方法名称中省略。这意味着单个测试用例可以强调名称的should_ExpectedBehavior
部分。为了符合unittest.TestCase
类的工作方式,每个测试行为必须从test_
开始。这导致我们建议在对unittest.TestCase
子类使用单独的测试方法时,将test_should_*ExpectedBehavior*
作为测试用例名称的模式。对于pytest
函数,我们将使用不同的命名模式。
可以将unittest
模块配置为使用不同的模式来命名测试方法。我们可以将其更改为查找when_
而不是test_
。名称的改进似乎不值得付出所需的努力。
例如,这是对Card
类的测试:
class TestCard(unittest.TestCase):
def setUp( self ) -> None :
self .three_clubs = Card( 3 , Suit.CLUB)
def test_should_returnStr( self ) -> None :
self .assertEqual( "3♣" , str ( self .three_clubs))
def test_should_getAttrValues( self ) -> None :
self .assertEqual( 3 , self .three_clubs.rank)
self .assertEqual(Suit.CLUB, self .three_clubs.suit)
self .assertEqual( 3 , self .three_clubs.hard)
self .assertEqual( 3 , self .three_clubs.soft)
我们定义了一个 testsetUp()
方法,该方法创建被测试类的对象。我们还为此对象定义了两个测试。因为这里没有真正的交互,所以测试名称中没有被测试的状态;它们是简单的通用行为,应该始终有效。
这是一个非常小的类定义的大量测试代码。这就提出了一个问题,即测试代码的数量是否在某种程度上过多。答案是否;这不是过多的测试代码。没有法律规定应用程序代码应该多于测试代码。实际上,将测试代码量与应用程序代码量进行比较是没有意义的。最重要的是,即使是一个很小的类定义也可能存在 bug,并且可能需要复杂的测试来确保 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 ) -> None :
self .ace_spades = AceCard( 1 , Suit.SPADE)
@unittest.expectedFailure
def test_should_returnStr( self ) -> None :
self .assertEqual( "A♠" , str ( self .ace_spades))
def test_should_getAttrValues( self ) -> None :
self .assertEqual( 1 , self .ace_spades.rank)
self .assertEqual(Suit.SPADE, self .ace_spades.suit)
self .assertEqual( 1 , self .ace_spades.hard)
self .assertEqual( 11 , self .ace_spades.soft)
这个测试用例还设置了一个特定的Card
实例,以便我们可以测试字符串输出。它检查此固定卡的各种属性。
注意,test_should_returnStr()
测试将失败。AceCard
类的定义不显示此测试定义中所示的值。测试不正确或类定义不正确。单元测试在类设计中发现了这个错误。
FaceCard
类需要进行类似的测试。它将类似于AceCard
类的测试。我们不会在这里介绍它,但会留给您作为练习。
当我们有许多测试时,将它们组合成一套测试可能会有所帮助。我们下一步将讨论这个问题。
正式定义测试套件通常很有帮助。unittest
包默认能够发现测试。当聚合来自多个测试模块的测试时,有时在每个测试模块中创建一个测试套件是有帮助的。如果每个模块都定义了一个suite()
函数,我们可以将测试发现替换为从每个模块导入suite()
函数。另外,如果我们定制TestRunner
,我们必须使用套件。我们可以执行以下测试:
def suite2() -> unittest.TestSuite:
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
我们根据三个TestCase
类定义构建一个套件,然后将该套件提供给unittest.TextTestRunner()
实例。我们在unittest
中使用默认的TestLoader
。此TestLoader
检查TestCase
类以定位所有测试方法。TestLoader.testMethodPrefix
的值为test
,这是在类中识别测试方法的方式。加载程序使用每个方法名称创建一个单独的测试对象。
使用TestLoader
从TestCase
的适当命名方法构建测试实例是使用TestCase
的两种方法之一。在后面的部分中,我们将看到手动创建TestCase
的实例;这些例子我们不会依赖TestLoader
。我们可以使用以下代码运行此套件:
if __name__ == "__main__" :
t = unittest.TextTestRunner()
t.run(suite2())
我们将看到如下代码所示的输出:
...F.F
======================================================================
FAIL: test_should_returnStr (__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
类创建了两个测试。这给了我们总共六个测试。测试名称是方法名称,以test
开头。
显然,我们有一个问题。我们的测试提供了我们的类定义不符合的预期结果。为了通过这套简单的单元测试,我们需要为Card
类做更多的开发工作。我们将把修复作为练习留给您。
开始设计测试一开始似乎令人望而生畏。有一些指导方针可能会有所帮助。在下一节中,我们将讨论*边-*经常限制,*角-*经常是组件之间的接口,这可以帮助我们设计更好的测试。
当我们将Deck
类作为一个整体进行测试时,我们需要确认一些事情:它生成了所有必需的Cards
类,并且它确实正确地洗牌。我们真的不需要测试它是否正确处理,因为我们依赖于list
和list.pop()
方法;由于它们是 Python 的一流部分,因此不需要额外的测试。
我们想独立于任何特定的Card
类层次结构来测试Deck
类构造和洗牌。如前所述,我们可以使用工厂函数使两个Deck
和Card
定义相互独立。引入工厂功能将引入更多测试。考虑到之前在Card
类层次结构中暴露的 bug,这不是一件坏事。
下面是对 factory 功能的测试:
class TestCardFactory(unittest.TestCase):
def test_rank1_should_createAceCard( self ) -> None :
c = card( 1 , Suit.CLUB)
self .assertIsInstance(c, AceCard)
def test_rank2_should_createCard( self ) -> None :
c = card( 2 , Suit.DIAMOND)
self .assertIsInstance(c, Card)
def test_rank10_should_createCard( self ) -> None :
c = card( 10 , Suit.HEART)
self .assertIsInstance(c, Card)
def test_rank10_should_createFaceCard( self ) -> None :
c = card( 11 , Suit.SPADE)
self .assertIsInstance(c, Card)
def test_rank13_should_createFaceCard( self ) -> None :
c = card( 13 , Suit.CLUB)
self .assertIsInstance(c, Card)
def test_otherRank_should_exception( self ) -> None :
with self .assertRaises(LogicError):
c = card( 14 , Suit.DIAMOND)
with self .assertRaises(LogicError):
c = card( 0 , Suit.DIAMOND)
我们没有测试所有 13 个等级,因为 2 到 10 个等级应该都是相同的。相反,我们遵循了 Boris Beizer 在软件测试技术一书中的建议:
"Bugs lurk in corners and congregate at boundaries."
测试用例涉及每个卡范围的边缘值。因此,我们有值 1、2、10、11 和 13 以及非法值 0 和 14 的测试用例。我们用最小值、最大值、一个低于最小值、一个高于最大值将每个范围括起来。
我们已经修改了测试命名的模式。在本例中,我们测试了几个不同的状态。我们已经修改了这些更简单的名称,以遵循模式test_*StateUnderTest*_should_*ExpectedBehavior*
。似乎没有令人信服的理由将这些测试分成单独的类来分解测试状态
在下一节中,我们将研究处理依赖对象的方法。这将允许我们单独测试每个单元。
为了测试Deck
,我们有以下两种选择来处理Card
类层次结构中的依赖关系:
- Mocking:我们可以为
Card
类创建一个 mock(或替身)类,并创建一个 mockcard()
工厂函数来生成 mock 类的实例。使用模拟对象的优点是,我们可以真正确信被测试的单元在一个类中没有解决方法,这弥补了另一个类中的错误。一个罕见的潜在缺点是,我们可能必须调试超级复杂模拟类的行为,以确保它是真实类的有效替代。复杂的模拟对象表明真实对象太复杂,需要重构。 - 集成:如果我们对
Card
类层次结构有效,card()
工厂功能有效有一定程度的信任,我们可以利用它们来测试Deck
。这偏离了纯单元测试的主要道路,在纯单元测试中,出于测试目的省略了所有依赖项。这样做的缺点是,一个损坏的基础类将导致依赖它的所有类中的大量测试失败。此外,很难对 API 与非模拟类的一致性进行详细测试。模拟类可以跟踪调用历史,从而可以跟踪对模拟对象的调用的详细信息。
unittest
包包括unittest.mock
模块,该模块可用于为测试目的对现有类进行修补。它还可以用于提供完整的模拟类定义。稍后,当我们看pytest
时,我们将结合unittest.mock
对象和pytest
测试框架。
本节中的示例未在测试中使用广泛的类型暗示。在大多数情况下,测试应该顺利通过mypy
。正如我们前面提到的,pytest
版本 3.8.2。没有完整的类型存根集,因此在运行mypy
时必须使用--ignore-missing-imports
选项。大多数情况下,模拟对象提供类型提示,允许mypy
确认它们被正确使用。
当我们设计一个类时,我们必须考虑对单元测试必须被嘲弄的依赖关系。在Deck
的情况下,我们需要模拟以下三个依赖项:
Card
类:这个类非常简单,我们可以为这个类创建一个 mock,而无需基于现有的实现。由于Deck
类行为不依赖于Card
的任何特定特性,因此我们的模拟对象可以很简单。 *** 在card()
工厂:这个函数需要替换为一个 mock,我们可以用它来确定Deck
是否正确调用了这个函数。* 方法random.Random.shuffle()
方法:为了确定是否使用正确的参数值调用了该方法,我们可以提供一个模拟来跟踪使用情况,而不是实际执行任何洗牌。**
**以下是使用card()
工厂功能的Deck
版本:
class DeckEmpty( Exception ):
pass
class Deck3( list ):
def __init__ (
self ,
size: int = 1 ,
random: random.Random=random.Random(),
card_factory: Callable[[ int , Suit], Card]=card
) -> None :
super (). __init__ ()
self .rng = random
for d in range (size):
super ().extend(
[card_factory(r, s)
for r in range ( 1 , 14 )
for s in iter (Suit)])
self .rng.shuffle( self )
def deal( self ) -> Card:
try :
return self .pop( 0 )
except IndexError :
raise DeckEmpty()
这个定义有两个依赖项,它们被专门称为__init__()
方法的参数。它需要一个随机数生成器random
和一个卡片工厂card_factory
。它具有合适的默认值,因此可以非常简单地在应用程序中使用。还可以通过提供模拟对象而不是默认对象来测试它。
我们包含了一个deal()
方法,该方法通过使用pop()
从集合中删除Card
的实例来更改对象。如果甲板为空,deal()
方法将引发DeckEmpty
异常。
下面是一个测试用例,它向您展示了甲板是正确构建的:
import unittest
import unittest.mock
class TestDeckBuild(unittest.TestCase):
def setUp( self ) -> None :
self .mock_card = unittest.mock.Mock( return_value =unittest.mock.sentinel.card)
self .mock_rng = unittest.mock.Mock( wraps =random.Random())
self .mock_rng.shuffle = unittest.mock.Mock()
def test_Deck3_should_build( self ) -> None :
d = Deck3( size = 1 , random = self .mock_rng, card_factory = self .mock_card)
self .assertEqual( 52 * [unittest.mock.sentinel.card], d)
self .mock_rng.shuffle.assert_called_with(d)
self .assertEqual( 52 , len ( self .mock_card.mock_calls))
expected = [
unittest.mock.call(r, s)
for r in range ( 1 , 14 )
for s in (Suit.CLUB, Suit.DIAMOND, Suit.HEART, Suit.SPADE)
]
self .assertEqual(expected, self .mock_card.mock_calls)
我们在这个测试用例的setUp()
方法中创建了两个模拟。模拟卡工厂功能mock_card
是一个Mock
功能。定义的返回值是单个mock.sentinel,card
对象,而不是一个不同的Card
实例。当我们使用类似于mock.sentinel.card
的表达式引用mock.sentinel
对象的属性时,该表达式会在必要时创建一个新对象,或者检索现有对象。实现了单例设计模式;只有一个sentinel
对象具有给定的名称。这将是一个独特的对象,允许我们确认创建了正确数量的实例。因为sentinel
不同于所有其他 Python 对象,我们可以在没有返回None
的正确return
语句的情况下区分函数。
我们创建了一个模拟对象mock_rng
,用于包装random.Random()
生成器的一个实例。这个Mock
对象将作为一个适当的随机对象,有一个区别。我们将shuffle()
方法替换为一个Mock
函数,该函数返回None
。这为我们提供了一个适当的方法返回值,并允许我们确定使用适当的参数值调用了shuffle()
方法。
我们的测试使用两个模拟对象创建一个Deck3
实例。然后我们可以对Deck3
实例d
做出以下断言:
- 创建了 52 个对象。预计将有 52 份
mock.sentinel
,向我们展示了只有 factory 函数用于创建对象;所有对象都是由模拟创建的哨兵。 - 以
Deck
实例作为参数调用了shuffle()
方法。这向我们展示了模拟对象如何跟踪其调用。我们可以使用assert_called_with()
来确认在调用shuffle()
时参数值是否符合要求。 - 工厂函数被调用了 52 次。模拟对象的
mock_calls
属性是该对象的整个使用历史。从技术上讲,这个断言是多余的,因为下一次测试将暗示这种情况。 - 使用预期等级和适合值的特定列表调用 factory 函数。
模拟对象将记录调用的方法序列。在下一节中,我们将研究检查模拟对象的方法,以确保其他单元正确使用模拟对象。
前面的模拟对象用于测试Deck
类是如何构建的。有 52 名相同的哨兵,很难确认 aDeck
交易是否正确。我们将定义一个不同的 mock 来测试 deal 特性。
下面是第二个测试用例,以确保Deck
类正确处理:
class TestDeckDeal(unittest.TestCase):
def setUp( self ) -> None :
self .mock_deck = [
getattr (unittest.mock.sentinel, str (x)) for x in range ( 52 )
]
self .mock_card = unittest.mock.Mock(
side_effect = self .mock_deck)
self .mock_rng = unittest.mock.Mock(
wraps =random.Random())
self .mock_rng.shuffle = unittest.mock.Mock()
def test_Deck3_should_deal( self ) -> None :
d = Deck3( size = 1 , random = self .mock_rng, card_factory = self .mock_card)
dealt = []
for i in range ( 52 ):
card = d.deal()
dealt.append(card)
self .assertEqual(dealt, self .mock_deck)
def test_empty_deck_should_exception( self ) -> None :
d = Deck3( size = 1 , random = self .mock_rng, card_factory = self .mock_card)
for i in range ( 52 ):
card = d.deal()
self .assertRaises(DeckEmpty, d.deal)
此卡片工厂函数的模拟将side_effect
参数用于Mock()
。如果提供了 iterable,side_effect
特性每次调用时都返回 iterable 的另一个值。
在本例中,我们使用sentinel
对象构建了 52 个不同的sentinel
对象;我们将使用这些对象而不是Card
对象来将Deck3
类与Card
类层次结构隔离开来。getattr(unittest.mock.sentinel, str(x))
表达式将使用数字的字符串版本x
,并创建 52 个唯一的sentinel
对象。
我们模仿了shuffle()
方法,以确保这些卡片实际上没有重新排列。包装器意味着Random
类的大部分功能都可以访问。然而,shuffle
方法被取代。我们希望sentinel
对象保持其原始顺序,以便我们的测试具有可预测的预期值。
第一个测试test_Deck3_should_deal
将 52 张卡的交易结果累加到一个变量dealt
。然后,它断言该变量具有来自原始模拟卡工厂的 52 个预期值。因为卡片工厂是一个模拟对象,所以它通过Mock
的side_effect
特性返回各种sentinel
对象。
第二个测试test_empty_deck_should_exception
处理Deck
实例中的所有卡片。然而,它又发出了一个 API 请求。断言是Deck.deal()
方法将在处理所有卡后引发适当的异常。
因为Deck
类相对简单,所以可以将TestDeckBuild
和TestDeckDeal
组合成一个更复杂的模拟。虽然在这个例子中这是可能的,但重构测试用例以使其更简单既不是必要的,也不是必要的。事实上,过度简化测试可能无法正确测试 API 特性。
doctest
模块为我们提供了验证文档字符串的方法。除了代码中的 docstring 之外,任何带有 Python REPL 风格响应的文档都可以通过doctest
进行测试。这将把模块、类、函数和测试用例的文档合并到一个整洁的包中。
doctest
案例被写入 docstring。一个doctest
案例向我们展示了交互式 Python 提示符>>>
;声明;以及预期的反应。doctest
模块包含一个在 docstring 中查找这些示例的应用程序。它运行给定的示例,并将 docstring 中显示的预期结果与实际输出进行比较。
通过仔细设计 API,我们可以创建一个可以交互使用的类。如果可以交互使用,那么可以构建一个doctest
示例来显示交互的预期结果。
事实上,设计良好的类有两个属性,即它可以交互使用,并且文档字符串中有doctest
示例。许多内置模块包含 API 的doctest
示例。我们可能选择下载的许多其他软件包也包括doctest
示例。
通过一个简单的功能,我们可以提供以下文档:
def ackermann(m: int , n: int ) -> int :
"""Ackermann's Function
ackermann(m, n) = $2 \\uparrow^{m-2} (n+3)-3$
See http://en.wikipedia.org/wiki/Ackermann_function and
http://en.wikipedia.org/wiki/Knuth%27s_up-arrow_notation.
>>> from Chapter_17.ch17_ex1 import ackermann
>>> ackermann(2,4)
11
>>> ackermann(0,4)
5
>>> ackermann(1,0)
2
>>> ackermann(1,1)
3
"""
if m == 0 :
return n + 1
elif m > 0 and n == 0 :
return ackermann(m - 1 , 1 )
elif m > 0 and n > 0 :
return ackermann(m - 1 , ackermann(m, n - 1 ))
else :
raise LogicError()
我们已经定义了 Ackermann 函数的一个版本。这个函数相当复杂,定义中包含了一些奇怪的数学符号。形式定义以以下两种方式显示:
该定义包括 docstring 注释,其中包括来自交互式 Python 的五个示例响应。第一个示例输出是import
语句,它不应该产生任何输出。其他四个示例输出向我们显示函数的不同值。
我们可以使用doctest
模块运行这些测试。当doctest
模块作为程序运行时,命令行参数是应该测试的文件。doctest
程序定位所有 docstring,并在这些字符串中查找交互式 Python 示例。需要注意的是,doctest
文档提供了用于定位字符串的正则表达式的详细信息。在我们的示例中,我们在最后一个doctest
示例之后添加了一个难以看到的空行,以帮助doctest
解析器。
我们可以从命令行运行doctest
,如下所示:
$ python3 -m doctest Chapter_17/ch17_ex1.py
如果一切正常,这就是沉默。我们可以通过添加-v
选项使其显示一些细节,如下所示:
$ python3 -m doctest -v Chapter_17/ch17_ex1.py
这将为我们提供解析的每个 docstring 以及从 docstring 收集的每个测试用例的详细信息。
输出将包括以下内容:
Trying:
from Chapter_17.ch17_ex1 import ackermann
Expecting nothing
ok
Trying:
ackermann(2,4)
Expecting:
11
ok
Trying:
ackermann(0,4)
Expecting:
5
ok
Trying:
ackermann(1,0)
Expecting:
2
ok
Trying:
ackermann(1,1)
Expecting:
3
ok
Trying:
子句显示了>>>
示例中的语句。Expecting:
显示以下结果行。最后一个ok
告诉我们这个例子按照预期工作。详细的输出将向我们显示所有未经任何测试的类、函数和方法,以及具有测试的组件。这就证实了我们的测试在 docstring 中的格式是正确的。
在某些情况下,我们的输出将不容易与交互式 Python 匹配。在这些情况下,我们可能需要用一些注释来补充 docstring,这些注释修改测试用例和预期结果的解析方式。
有一个特殊的注释字符串,我们可以用于更复杂的输出。我们可以附加以下两个命令中的任意一个来启用(或禁用)各种可用的指令。以下是第一个命令:
# doctest: +DIRECTIVE
以下是第二个命令:
# doctest: -DIRECTIVE
我们可以对预期结果的处理方式进行十几次修改。其中大多数都是关于间距以及如何比较实际值和预期值的罕见情况。
doctest
文件强调精确匹配原则:
"doctest is serious about requiring exact matches in expected output." If even a single character doesn't match, the test fails. You'll need to build flexibility into some of the expected outputs. If building in flexibility gets too complex, it suggests that unitest
might be a better choice.
以下是doctest
的预期值和实际值不容易匹配的一些具体情况:
- 对象的
id()
和默认repr()
涉及物理内存地址;Python 不能保证它们是一致的。如果显示id()
或repr()
,请使用#doctest: +ELLIPSIS
指令,并在样本输出中将 ID 或地址替换为...
。 - 浮点结果在不同平台之间可能不一致。始终显示带有格式或舍入的浮点数,以将位数减少为有意义的位数。使用
"{:.4f}".format(value)
或round(value,4)
确保忽略不重要的数字。 - 虽然字典键现在已排序,但 Python 不保证集合排序。使用诸如
sorted(some_set)
之类的构造,而不是some_set
。 - 当然,不能使用当前日期或时间,因为这将不一致。涉及日期或时间的测试通常需要通过模拟
time
或datetime
强制特定日期或时间。 - 操作系统详细信息(如文件大小或时间戳)可能会有所不同,不应在没有省略号的情况下使用。有时,可以在
doctest
脚本中包含有用的设置或拆卸,以管理操作系统资源。在其他情况下,模拟os
模块是有帮助的。
这些考虑意味着我们的doctest
模块可能包含一些额外的处理,而不仅仅是 API 的一部分。我们可能在交互式 Python 提示符下做了如下操作:
>>> sum(values)/len(values)
3.142857142857143
这向我们展示了特定实现的完整输出。我们不能简单地复制并粘贴到 docstring 中。浮点结果可能不同。我们需要执行类似以下代码的操作:
>>> round(sum(values)/len(values), 4)
3.1429
这四舍五入到一个值,该值在不同的实现之间不应该有所不同。
有时,将doctest
和单元测试结合在一个综合测试包中是有帮助的。稍后我们将研究如何将doctest
案例与unittest
测试案例结合起来。
doctest
模块中有一个钩子,它将根据 docstring 注释创建一个适当的unittest.TestSuite
。这允许我们在大型应用中同时使用doctest
和unittest
。
我们要做的是创建一个doctest.DocTestSuite()
的实例。这将从模块的 docstring 构建一个套件。如果我们没有指定模块,那么当前正在运行的模块将用于构建套件。我们可以使用如下模块:
import doctest
suite5 = doctest.DocTestSuite()
t = unittest.TextTestRunner(verbosity=2)
t.run(suite5)
我们根据当前模块中的doctest
字符串构建了一个套件suite5
。我们在这个套房里用了unittest``TextTestRunner
。作为替代方案,我们可以将doctest
套件与其他TestCase
套件结合起来,创建一个更大、更完整的套件。
随着我们的测试变得越来越复杂,我们需要组织我们的测试模块。在下一节中,我们将了解在 Python 项目中创建tests
文件夹的总体情况。
对于较大的应用程序,每个应用程序模块可以有一个并行模块,其中包括该模块的TestCase
。这可以形成两个并行封装结构:一个带有应用模块的src
结构和一个带有测试模块的test
结构。以下是两个并行目录树,它们向我们展示了模块集合:
src
__init__.py
__main__.py
module1.py
module2.py
setup.py
tests
__init__.py
module1.py
module2.py
all.py
显然,平行性并不精确。我们通常不会对setup.py
进行自动单元测试。一个设计良好的__main__.py
可能不需要单独的单元测试,因为它不应该包含太多的代码。我们将在第 18 章中介绍一些设计__main__.py
的方法,处理命令行。
我们可以创建一个顶层test/all.py
模块,其主体将所有测试构建到单个套件中,如下所示:
import module1
import module2
import unittest
import doctest
all_tests = unittest.TestSuite()
for mod in module1, module2:
all_tests.addTests(mod.suite())
all_tests.addTests(doctest.DocTestSuite(mod))
t = unittest.TextTestRunner()
t.run(all_tests)
我们从其他测试模块中的套件构建了一个套件all_tests
。这为我们提供了一个方便的脚本,它将运行作为发行版一部分提供的所有测试。
也可以使用unittest
模块的测试发现功能来实现这一点。我们从命令行执行包范围的测试,代码如下:
python3 -m unittest tests/*.py
这将使用unittest
的默认测试发现功能在给定文件中定位TestCase
。这样做的缺点是依赖 shell 脚本特性而不是纯 Python 特性。通配符文件规范有时会使开发更加复杂,因为不完整的模块可能会被测试。
使用pytest
比使用unittest
发现和运行整个测试套件有一些优势。我们将在后面看到,pytest
稍微简单一些。更重要的是,它可以定位更广泛的测试用例,包括函数以及unittest.TestCase
的子类。这使得新软件功能的开发更加灵活和快速。
unittest
模块有三个设置和拆卸级别。这些定义了不同类型的测试范围:方法、类和模块,如下所示:
- 测试用例
setUp()
和tearDown()
方法:这些方法确保TestCase
类中的每个单独测试方法都有适当的上下文。通常,我们会使用setUp()
方法来创建被测试的单元和所需的任何模拟对象 - 测试用例
setUpClass()
和tearDownClass()
方法:这些方法对TestCase
类中的所有测试执行一次性设置(和拆卸)。这些方法包括每种方法的setUp()-testMethod()-tearDown()
顺序。这是在数据库中插入和删除测试所需数据的好地方。 - 模块
setUpModule()
和tearDownModule()
功能:这两个独立的功能为我们提供了在模块中所有TestCase
类之前的一次性设置。这是一个在运行一系列TestCase
类之前创建和销毁整个测试数据库的好地方。
我们很少需要定义所有这些setUp()
和tearDown()
方法。有几个测试场景将成为我们可测试性设计的一部分。这些场景之间的本质区别在于所涉及的集成程度。如前所述,我们的测试层次结构中有三层:独立单元测试、集成测试和总体应用程序测试。这些测试层通过多种方式使用各种设置和拆卸功能,包括:
- **独立单元,无集成,无依赖。**某些类或函数没有外部依赖关系;它们不依赖于文件、设备、其他进程或其他主机。其他类有一些可以模拟的外部资源。当
TestCase.setUp()
方法的成本和复杂性很小时,我们可以在那里创建必要的对象。如果模拟对象特别复杂,则类级别TestCase.setUpClass()
可能更适合将重新创建模拟对象的成本分摊到多个测试方法中。 - **内部集成,一些依赖性:**类或模块之间的自动集成测试通常涉及更复杂的设置情况。我们可能有一个复杂的
setUpClass()
甚至模块级setUpModule()
来为集成测试准备上下文。在使用第 11 章、通过 Shelve存储和检索对象、第 12 章中的数据库访问层时,通过 SQLite存储和检索对象,我们经常执行集成测试,包括我们的类定义和访问层。这可能涉及在测试数据库或架子上植入适当的测试数据。 - 外部集成:我们可以对更大、更复杂的应用程序进行自动集成测试。在这些情况下,我们可能需要生成外部进程或创建数据库,并为它们添加数据种子。在这种情况下,我们可能需要
setUpModule()
准备一个空数据库,供模块中的所有TestCase
类使用。当使用第 13 章中的 RESTful web 服务、传输和共享对象时,或者在第 19 章、模块和包设计中的(PITL)中测试编程时,这种方法可能会有所帮助。
请注意,单元测试的概念并没有定义被测试的单元是什么。单元可以是类、模块、包,甚至是软件组件的集成集合。该单元需要与其环境隔离,以创建一个集中的测试。
在设计自动化集成测试时,选择要测试的组件非常重要。例如,我们不需要测试 Python 库;他们有自己的测试。类似地,我们不需要测试操作系统。例如,如果我们的软件删除了一个文件,我们不需要在删除文件后检查文件系统的完整性。我们不需要确定空间是否被回收了。我们通常需要相信库和操作系统能够正常工作。如果我们对基础设施有疑问,我们可以为操作系统或库运行测试套件;我们不需要重新发明它。集成测试必须重点测试我们编写的代码,而不是我们下载和安装的代码
在许多情况下,测试用例可能需要特定的操作系统环境。当使用外部资源(如文件、目录或进程)时,我们可能需要在测试之前创建或初始化它们。我们可能还需要在测试之前删除资源,或者可能需要在测试结束时删除资源。
假设我们有一个函数rounds_final()
,它应该处理给定的文件。我们需要在文件不存在的罕见情况下测试函数的行为。通常可以看到TestCase
具有如下结构:
import os
class Test_Missing(unittest.TestCase):
def setUp( self ) -> None :
try :
(Path.cwd() / "data" / "ch17_sample.csv" ).unlink()
except OSError as e :
pass # OK if file didn't exist
def test_missingFile_should_returnDefault( self ) -> None :
self .assertRaises(
FileNotFoundError , rounds_final, (Path.cwd() / "data" / "ch17_sample.csv" )
)
我们必须处理一个可能的异常,即试图删除一个首先不存在的文件。这个测试用例有一个setUp()
方法来确保所需的文件丢失。一旦setuUp()
确保文件确实消失,我们就可以使用丢失文件的参数p3_c15_sample.csv
执行rounds_final()
函数。我们预计这会引起一个FileNotFoundError
错误。
注意,提升FileNotFoundError
是 Pythonopen()
方法的默认行为。这可能根本不需要测试。这引出了一个重要的问题:为什么要测试内置功能?如果我们正在执行黑盒测试,我们需要练习外部接口的所有功能,包括预期的默认行为。如果我们正在执行白盒测试,我们可能需要测试rounds_final()
函数体中的异常处理try:
语句。
ch17_sample.csv
文件名在测试主体内重复。有些人认为枯燥的规则甚至应该适用于测试代码。在编写测试时,这种优化的价值是有限的:
It's okay for test code to be brittle. If a small change to the application leads to test failures, this really is a good thing. Tests should value simplicity and clarity, not robustness and reliability.
在下一节中,我们将看一些其他使用setUp()
和tearDown()
作为数据库的示例。
当使用数据库和 ORM 层时,我们通常必须创建测试数据库、文件、目录或服务器进程。我们可能需要在测试通过后拆除测试数据库,以确保其他测试可以运行。我们可能不想在测试失败后破坏数据库;我们可能需要让数据库单独运行,以便检查结果行以诊断测试失败。
在复杂的多层体系结构中管理测试范围是很重要的。回顾第 12 章通过 SQLite存储和检索对象,我们不需要专门测试 SQLAlchemy ORM 层或 SQLite 数据库。这些组件在我们的应用程序测试之外有自己的测试程序。然而,由于 ORM 层从我们的代码中创建数据库定义、SQL 语句和 Python 对象的方式,我们不能轻易地模仿 SQLAlchemy,希望我们已经正确地使用了它。我们需要测试我们的应用程序使用 ORM 层的方式,而不必脱离测试 ORM 层本身。
一个更复杂的测试用例设置情况将涉及创建一个数据库,然后用给定测试的适当样本数据填充它。在使用 SQL 时,可能需要运行相当复杂的 SQL DDL 脚本来创建必要的表,然后运行另一个 SQL DML 脚本来填充这些表。关联的拆卸将是另一个复杂的 SQL DDL 脚本。
这种测试用例可能会变得冗长,因此我们将其分为三个部分:创建数据库和模式的有用函数、setUpClass()
方法和单元测试的其余部分。
以下是创建数据库功能:
from Chapter_12.ch12_ex4 import Base, Blog, Post, Tag, assoc_post_tag
import datetime
import sqlalchemy.exc
from sqlalchemy import create_engine
def build_test_db(name= "sqlite:///./data/ch17_blog.db" ):
"""
Create Test Database and Schema
"""
engine = create_engine(name, echo = True )
Base.metadata.drop_all(engine)
Base.metadata.create_all(engine)
return engine
这将通过删除与 ORM 类关联的所有表并重新创建表来构建一个新的数据库。这样做的目的是确保一个新的、空的数据库符合当前的设计,无论自上次运行单元测试以来该设计发生了多大的变化。
在本例中,我们使用一个文件构建了一个 SQLite 数据库。我们可以使用内存中SQLite 数据库特性使测试运行更快一些。使用内存中数据库的缺点是我们没有可以用来调试失败测试的数据库。
下面是我们在TestCase
子类中如何使用它:
from sqlalchemy.orm import sessionmaker
class Test_Blog_Queries(unittest.TestCase):
@staticmethod
def setUpClass():
engine = build_test_db()
Test_Blog_Queries.Session = sessionmaker( bind =engine)
session = Test_Blog_Queries.Session()
tag_rr = Tag( phrase = "#RedRanger" )
session.add(tag_rr)
tag_w42 = Tag( phrase = "#Whitby42" )
session.add(tag_w42)
tag_icw = Tag( phrase = "#ICW" )
session.add(tag_icw)
tag_mis = Tag( phrase = "#Mistakes" )
session.add(tag_mis)
blog1 = Blog( title = "Travel 2013" )
session.add(blog1)
b1p1 = Post(
date =datetime.datetime( 2013 , 11 , 14 , 17 , 25 ),
title = "Hard Aground" ,
rst_text = """Some embarrassing revelation. Including ☹ and ⚓ """ ,
blog =blog1,
tags =[tag_rr, tag_w42, tag_icw],
)
session.add(b1p1)
b1p2 = Post(
date =datetime.datetime( 2013 , 11 , 18 , 15 , 30 ),
title = "Anchor Follies" ,
rst_text = """Some witty epigram. Including ☺ and ☀ """ ,
blog =blog1,
tags =[tag_rr, tag_w42, tag_mis],
)
session.add(b1p2)
blog2 = Blog( title = "Travel 2014" )
session.add(blog2)
session.commit()
我们定义了setUpClass()
以便在运行此类中的测试之前创建一个数据库。这允许我们定义一些共享公共数据库配置的测试方法。一旦建立了数据库,我们就可以创建一个会话并添加数据。
我们已经将 session maker 对象作为类级属性Test_Blog_Queries.Session = sessionmaker(bind=engine)
放入类中。这个类级对象可以用于setUp()
和单独的测试方法。
以下是setUp()
和两种单独的试验方法:
def setUp( self ):
self .session = Test_Blog_Queries.Session()
def test_query_eqTitle_should_return1Blog( self ):
results = self .session.query(Blog).filter(Blog.title == "Travel 2013" ).all()
self .assertEqual( 1 , len (results))
self .assertEqual( 2 , len (results[ 0 ].entries))
def test_query_likeTitle_should_return2Blog( self ):
results = self .session.query(Blog).filter(Blog.title.like( "Travel %" )).all()
self .assertEqual( 2 , len (results))
setUp()
方法从类级sessionmaker
实例创建一个新的空会话对象。这将确保每个查询都能够正确地生成 SQL 并使用 SQLAlchemy 会话从数据库中获取数据。
query_eqTitle_should_return1Blog()
测试将找到请求的Blog
实例,并通过entries
关系导航到Post
实例。请求的filter()
部分并没有真正测试我们的应用程序定义;它练习炼金术和 SQLite。最后一个断言中的results[0].entries
测试是对我们的类定义的有意义的测试。
query_likeTitle_should_return2Blog()
测试几乎完全是对 SQLAlchemy 和 SQLite 的测试。除了在Blog
中存在一个名为title
的属性之外,它在我们的应用程序中并没有真正意义上的使用。这些类型的测试通常是创建初始技术峰值时留下的。它们可以帮助澄清应用程序 API,即使它们不能作为测试用例提供太多的价值。
以下是另外两种测试方法:
def test_query_eqW42_tag_should_return2Post( self ):
results = self .session.query(Post).join(assoc_post_tag).join(Tag).filter(
Tag.phrase == "#Whitby42"
).all()
self .assertEqual( 2 , len (results))
def test_query_eqICW_tag_should_return1Post( self ):
results = self .session.query(Post).join(assoc_post_tag).join(Tag).filter(
Tag.phrase == "#ICW"
).all()
self .assertEqual( 1 , len (results))
self .assertEqual( "Hard Aground" , results[ 0 ].title)
self .assertEqual( "Travel 2013" , results[ 0 ].blog.title)
self .assertEqual(
set ([ "#RedRanger" , "#Whitby42" , "#ICW" ]),
set (t.phrase for t in results[ 0 ].tags),
)
query_eqW42_tag_should_return2Post()
测试执行更复杂的查询来定位具有给定标记的帖子。这将练习类中定义的许多关系。当找到两个相关的博客条目时,此测试已通过。
类似地,query_eqICW_tag_should_return1Post()
测试执行一个复杂的查询。通过results[0].blog.title
测试从Post
到包含Post
的Blog
实例的导航。它还通过set(t.phrase for t in results[0].tags)
测试从Post
到Tags
相关集合的导航。我们必须使用显式的set()
,因为 SQL 中的结果顺序不能保证。
关于TestCase
的Test_Blog_Queries
子类,重要的是它通过setUpClass()
方法创建了一个数据库模式和一组特定的定义行。这种测试设置对数据库应用程序很有帮助。它可能变得相当复杂,通常通过从文件或 JSON 文档加载示例行来补充,而不是用 Python 编码行。
继承在TestCase
类中起作用。理想情况下,每个TestCase
都是唯一的。从实用角度讲,案例之间可能有共同的特点。TestCase
类有以下三种常见的重叠方式:
- 常用
setUp()
:我们可能有一些数据在多个TestCase
中使用。没有理由重复这些数据。一个只定义了setUp()
或tearDown()
而没有测试方法的TestCase
类是合法的,但它可能会导致一个混乱的日志,因为其中涉及零个测试。 - 通用
tearDown()
:对涉及操作系统资源的测试进行通用清理是很常见的。我们可能需要删除文件和目录或终止子进程。 - 常见结果检查:对于算法复杂的测试,我们可能会有一种结果检查方法来验证结果的某些属性。
回首于 Tyl T1 第 4 章,To2 T2,AuthT3 属性访问,属性和描述符 To.T4^,例如,考虑 AutoT0p 类。此类基于其他两个值在字典中填充缺少的值,如下所示:
@dataclass
class RateTimeDistance:
rate: Optional[ float ] = None
time: Optional[ float ] = None
distance: Optional[ float ] = None
def __post_init__( self ) -> None :
if self .rate is not None and self .time is not None :
self .distance = self .rate * self .time
elif self .rate is not None and self .distance is not None :
self .time = self .distance / self .rate
elif self .time is not None and self .distance is not None :
self .rate = self .distance / self .time
RateTimeDistance
类的每个单元测试方法可包括以下代码:
self .assertAlmostEqual(
self .rtd.distance, self .rtd.rate * self .rtd.time, places = 2
)
如果我们使用许多TestCase
子类,我们可以将此有效性检查作为单独的方法继承,如下所示:
def validate(self, object):
self.assertAlmostEqual(
self.rtd.distance, self.rtd.rate * self.rtd.time, places=2
)
这样,每个测试只需要包含self.validate(object)
,以确保所有测试都提供了一致的正确性定义。
unittest
模块定义的一个重要特征是,测试用例是具有适当继承的适当类。我们可以设计TestCase
类层次结构,就像我们应用于应用程序类一样,对细节非常关注。
对于某些应用程序,用户可以明确描述软件行为的处理规则。在其他情况下,分析师或设计师的工作会将用户的需求转化为软件的过程描述。
通常最容易提供预期结果的具体示例。最终用户或中介分析师可能会发现,创建一个显示样本输入和预期结果的电子表格很有帮助。使用用户提供的具体样本数据可以简化正在开发的软件。
只要有可能,让真实用户提供正确结果的具体示例。创建过程描述或软件规范非常困难。创建具体的示例,并将示例概括为软件规范,这样就不那么复杂和混乱了。此外,它还融入了一种开发风格,在这种风格中,测试用例驱动开发工作。一套测试用例为开发人员提供了done的具体定义。跟踪软件开发项目状态会询问我们现在有多少个测试用例,其中有多少通过了测试。
给出一个包含具体示例的电子表格,我们需要将每一行转换为一个TestCase
实例。然后,我们可以从这些对象构建一个套件。
对于本章前面的示例,我们从基于TestCase
的类加载测试用例。我们使用unittest.defaultTestLoader.loadTestsFromTestCase
来定位名称以test
开头的所有方法。加载程序从每个方法中创建一个带有正确名称前缀的测试对象,并将它们组合到一个测试套件中。
然而,还有另一种方法。对于这个例子,我们将分别构建测试用例实例。这是通过使用一个runTest()
方法定义一个类来实现的。我们可以将这个类的多个实例加载到一个套件中。要使其工作,TestCase
类必须只定义一个名为runTest()
的测试。我们不会使用加载程序来创建测试对象;我们将直接从外部提供的数据行创建它们。
让我们来看看我们需要测试的具体功能。这来自第 4 章、属性访问、属性和描述符:
from Chapter_4.ch04_ex3 import RateTimeDistance
这是一个在初始化时急切地计算许多属性的类。这个简单函数的用户向我们提供了一些作为电子表格的测试用例,我们从中提取了 CSV 文件。有关 CSV 文件的更多信息,请参见第 10 章、序列化和保存-JSON、YAML、Pickle、CSV 和 XML。我们需要将每一行转换为一个TestCase
。以下是 CSV 文件中的数据:
rate_in,time_in,distance_in,rate_out,time_out,distance_out
2,3,,2,3,6
5,,7,5,1.4,7
,11,13,1.18,11,13
我们不打算使用以test
开头的名称定义一个类,因为该类不会被加载程序简单地发现。相反,该类用于将实例构建到一套更大的测试中。下面是我们可以用来从 CSV 文件的每一行创建测试实例的测试用例模板:
class Test_RTD(unittest.TestCase):
def runTest( self ) -> None :
with (Path.cwd() / "data" / "ch17_data.csv" ).open() as source:
rdr = csv.DictReader(source)
for row in rdr:
self .example(**row)
def example(
self ,
rate_in: str ,
time_in: str ,
distance_in: str ,
rate_out: str ,
time_out: str ,
distance_out: str ,
) -> None :
args = dict (
rate =float_or_none(rate_in),
time =float_or_none(time_in),
distance =float_or_none(distance_in),
)
expected = dict (
rate = float (rate_out), time = float (time_out), distance = float (distance_out)
)
rtd = RateTimeDistance(**args)
assert rtd.distance and rtd.rate and rtd.time
self .assertAlmostEqual(rtd.distance, rtd.rate * rtd.time, places = 2 )
self .assertAlmostEqual(rtd.rate, expected[ "rate" ], places = 2 )
self .assertAlmostEqual(rtd.time, expected[ "time" ], places = 2 )
self .assertAlmostEqual(rtd.distance, expected[ "distance" ], places = 2 )
测试体现在该类的runTest()
方法中。在前面的示例中,我们使用以test_
开头的方法名来提供测试用例行为。可以提供一个runTest()
方法,而不是多个test_
方法名称。这也将改变测试套件的构建方式,我们将在下面看到。
此方法将电子表格的一行解析为字典。要使其正常工作,示例数据列标题必须与example()
方法所需的参数名称相匹配。输入值放在名为args
的字典中;类似地,预期结果值被放入名为expected
的字典中。
float_or_none()
函数有助于处理 CSV 源数据,None
值将由空字符串表示。它将单元格的文本转换为float
值或None
。该函数定义如下:
def float_or_none(text: str ) -> Optional[ float ]:
if len (text) == 0 :
return None
return float (text)
电子表格的每一行都通过example()
方法进行处理。这为我们提供了一种相对灵活的测试方法。我们可以允许用户或业务分析师创建澄清正确操作所需的所有示例。
我们可以从该测试对象构建一个套件,如下所示:
def suite9():
suite = unittest.TestSuite()
suite.addTest(Test_RTD())
return suite
注意,我们不使用loadTestsFromTestCase
方法来发现具有test_
名称的方法。相反,我们创建一个测试用例的实例,该实例可以简单地添加到测试套件中。
该套件是使用我们前面看到的那种脚本执行的。下面是一个例子:
if __name__ == "__main__":
t = unittest.TextTestRunner()
t.run(suite9())
输出如下所示:
..F
======================================================================
FAIL: runTest (__main__.Test_RTD)
{'rate': None, 'distance': 13.0, 'time': 11.0} -> {'rate': 1.18, 'distance': 13.0, 'time': 11.0}
----------------------------------------------------------------------
Traceback (most recent call last):
File "p3_c15.py", line 504, in runTest
self.assertAlmostEqual( self.rtd.rate, self.result['rate'] )
AssertionError: 1.1818181818181819 != 1.18 within 7 places
----------------------------------------------------------------------
Ran 3 tests in 0.000s
FAILED (failures=1)
用户提供的数据有一个小问题。用户提供的值仅四舍五入到两位。要么样本数据需要提供更多的数字,要么我们的测试断言需要处理舍入。
这也可以使用unittest
测试发现从命令行运行。我们可以运行以下命令来使用unittest
模块的内置测试发现功能:
python3 -m unittest Chapter_17/ch17_ex1.py
这将生成一个简短的输出,其中包含本章中的许多测试示例。看起来是这样的:
.x............x..Run time 0.931446542
..
----------------------------------------------------------------------
Ran 19 tests in 0.939s
每个.
都是通过的测试。x
标记是预期会失败的测试。正如我们前面提到的,一些测试揭示了定义类的问题,这些测试将失败,直到类被修复。
Run time 0.931446542
输出来自测试中的print()
。这不是输出的标准部分。由于输出的结构方式,在测试用例中打印其他调试或性能数据输出可能会很困难。这个例子展示了在显示测试执行进度的简单周期的中间有额外的输出是如何混淆的。
unittest
转轮的替代品是pytest
试验转轮。pytest
框架有一个出色的测试发现功能,它超越了unittest
工具所能发现的功能。
unittest
转轮可通过以下两种方式使用。
- 使用测试套件对象。前面的例子都集中在这一点上。
- 要搜索属于
unittest.TestCase
扩展的类,请从这些类构建一个测试套件,然后运行该套件。这提供了相当大的灵活性。我们可以添加测试用例,而无需更新代码来构建测试套件。
pytest
工具还可以定位unittest.TestCase
类定义,构建一套测试,并执行测试。除此之外,它还可以在名称以test_
开头的模块中定位名称以test_
开头的函数。与更复杂的unittest.TestCase
类定义相比,使用简单函数具有一些优势。
使用单独功能的主要优点是简化了测试模块。特别是,当我们回顾TestCardFactory
示例时,我们发现类内的测试不需要setUp()
。因为所有的方法都是独立的,所以不需要将这些方法绑定到单个类定义中。尽管这是一本关于面向对象 Python 的书,但如果类定义不能改进代码,就没有理由使用它们。在许多情况下,面向类的测试用例定义没有帮助,pytest
执行的单独函数具有优势。
pytest
方法会导致以下两种其他后果:
self.assert...()
方法不可用。使用pytest
时,Pythonassert
语句用于比较预期结果和实际结果。setUp()
和tearDown()
使用的有状态类变量不可用。为了设置和分解测试上下文,我们将使用pytest @fixture
函数。
作为可能的简化的具体示例,我们将回顾前面的一些示例,从Card
类的测试开始。pytest
版本如下:
def test_card():
three_clubs = Card( 3 , Suit.CLUB)
assert "3♣" == str (three_clubs)
assert 3 == three_clubs.rank
assert Suit.CLUB == three_clubs.suit
assert 3 == three_clubs.hard
assert 3 == three_clubs.soft
函数名必须以test_
开头,以确保pytest
可以发现它。测试设置创建了一个card
实例和许多assert
语句,以确认它具有预期的行为。在使用带有assert
语句的函数时,我们没有使用unitest.TestCase
子类时那么多的开销。
为了确认测试引发的异常,unittest.TestCase
类有类似assertRaises()
的方法。在使用pytest
时,我们有一种独特的方法来测试此功能。pytest
包提供了一个名为raises
的上下文管理器来帮助检测引发的异常。raises
上下文如本例所示:
from pytest import raises
def test_card_factory():
c1 = card( 1 , Suit.CLUB)
assert isinstance (c1, AceCard)
c2 = card( 2 , Suit.DIAMOND)
assert isinstance (c1, Card)
c10 = card( 10 , Suit.HEART)
assert isinstance (c10, Card)
cj = card( 11 , Suit.SPADE)
assert isinstance (cj, FaceCard)
ck = card( 13 , Suit.CLUB)
assert isinstance (ck, FaceCard)
with raises(LogicError):
c14 = card( 14 , Suit.DIAMOND)
with raises(LogicError):
c0 = card( 0 , Suit.DIAMOND)
我们使用pytest.raises
作为上下文管理器。当随类定义一起提供时,上下文中的语句将引发命名异常。如果引发异常,则测试通过;如果未引发异常,则这是测试失败。
pytest
的测试设置和拆卸功能通常由@fixture
功能处理。这些功能构成了一个装置连接到其中进行测试的夹具。在硬件测试领域,它也可以称为测试线束或测试台。
夹具可用于执行与测试相关的任何类型的设置或拆卸。由于pytest
调用测试的方式,它隐式调用 fixture 函数,大大简化了测试代码。这些装置可以引用其他装置,使我们能够创建复合对象,从而有助于隔离正在测试的单元。
之前,我们研究了两个复杂的测试用例子类:TestDeckBuild
和TestDeckDeal
。这两个测试用例涵盖了Deck3
类定义的不同特性。我们可以使用公共夹具构建类似的测试用例。以下是设备定义:
import unittest.mock
from types import SimpleNamespace
from pytest import fixture
@fixture
def deck_context():
mock_deck = [
getattr (unittest.mock.sentinel, str (x))
for x in range ( 52 )
]
mock_card = unittest.mock.Mock( side_effect =mock_deck)
mock_rng = unittest.mock.Mock(
wraps =random.Random,
shuffle =unittest.mock.Mock( return_value = None )
)
return SimpleNamespace(** locals ())
deck_context()
函数创建以下三个模拟对象:
mock_deck
对象是 52 个独立mock.sentinel
对象的列表。每个sentinel
对象通过获取sentinel
的唯一属性进行定制。属性名称是根据range(52)
中的整数值构建的字符串。将有名称为mock.sentinel.0
的对象。对于源代码中的简单 Python 属性引用,这不是有效的语法,但我们只需要确保sentinel
是唯一的。mock_card
对象是带有side_effect
的模拟对象。这将表现为一个函数。每次调用时,它都会从提供给side_effect
参数的列表中返回另一个值。这可用于模拟从文件或网络连接读取值的函数。mock_rng
对象是random.Random
类的包装版本。除了两个特征外,它的行为类似于随机对象。首先,shuffle()
方法没有任何作用。第二,Mock
包装器将跟踪对该对象的方法的单个调用,以便我们可以确定被测试单元是否正确使用了它。
return
步骤将所有局部变量打包到一个SimpleNamespace
对象中。此对象允许我们使用诸如deck_context.mock_card
之类的语法来引用Mock
函数。我们可以在test
函数中使用此夹具。以下是一个例子:
def test_deck_build(deck_context):
d = Deck3(
size = 1 ,
random =deck_context.mock_rng,
card_factory =deck_context.mock_card
)
deck_context.mock_rng.shuffle.assert_called_once_with(d)
assert 52 == len (deck_context.mock_card.mock_calls)
expected = [
unittest.mock.call(r, s) for r in range ( 1 , 14 ) for s in iter (Suit)
]
assert expected == deck_context.mock_card.mock_calls
本试验参考deck_context
夹具。代码中没有做任何特殊的操作;pytest
将隐式计算函数,生成的SimpleNamespace
对象将是deck_context
参数的值。参数和夹具之间的映射非常简单:所有参数名称必须是夹具函数的名称,并且这些函数会自动计算。
测试使用random
参数和card_factory
参数的模拟对象构建Deck3
实例。一旦构建了Deck3
实例,我们就可以检查 mock 对象,看看它们是否有适当数量的调用,以及预期的参数值。
夹具功能还可以提供拆卸功能和设置功能。这依赖于生成器函数的惰性工作方式。进行设置和拆卸的夹具的一些示例代码如下:
@fixture
def damaged_file_path():
file_path = Path.cwd() / "data" / "ch17_sample.csv"
with file_path.open( "w" , newline = "" ) as target:
print ( "not_player,bet,rounds,final" , file =target)
print ( "data,1,1,1" , file =target)
yield file_path
file_path.unlink()
damaged_file_path()
函数创建一个相对路径为data/ch17_sample.csv
的文件。将几行数据写入该文件。
yield
语句提供初始结果值。这是由测试函数使用的。测试完成后,将从夹具中检索第二个值。当请求第二个结果时,fixture 函数可以执行所需的任何拆卸工作。在本例中,拆卸工作将删除已创建的测试文件。
测试运行时将隐式调用 fixture 函数。以下是使用此夹具的示例测试:
def test_damaged( damaged_file_path ):
with raises( AssertionError ):
stats = rounds_final(Path.cwd()/ "data" / "ch17_sample.csv" )
此测试确认rounds_final()
函数在给出示例文件时将引发AssertionError
。因为damaged_file_path
fixture 使用yield
,所以它可以删除测试上下文,删除文件。
对于一个测试用例,通常会有大量类似的示例。在前面的部分中,我们讨论了让用户或分析师创建带有输入和输出示例的电子表格。这有助于允许直接输入软件开发过程。我们需要我们的测试工具来尽可能直接地处理 CSV 示例文件。
使用pytest
,我们可以将参数应用于夹具。pytest
运行程序将使用参数集合中的每个对象重复运行测试函数。要构建参数化夹具,我们可以使用如下示例中的代码:
import csv
with (Path.cwd() / "Chapter_17" / "ch17_data.csv" ).open() as source:
rdr = csv.DictReader(source)
rtd_cases = list (rdr)
@fixture ( params =rtd_cases)
def rtd_example(request):
yield request.param
我们已经在上下文管理器中打开了 CSV 文件。该文件用于构建读取器,将每行数据转换为字典。键是列标题,值是给定行中每个单元格的字符串。最后一个rtd_cases
变量是字典列表;一个类型提示List[Dict[str, str]]
将捕获该结构。
rtd_example
夹具是使用params=
参数构建的。params
集合中的每一项都将作为测试功能的上下文提供。这意味着测试将运行几次,每次都将使用与params
序列不同的值。要使用此夹具,我们将有一个类似以下示例的测试用例:
from pytest import approx
def test_rtd(rtd_example):
args = dict (
rate =float_or_none(rtd_example[ 'rate_in' ]),
time =float_or_none(rtd_example[ 'time_in' ]),
distance =float_or_none(rtd_example[ 'distance_in' ]),
)
result = dict (
rate =float_or_none(rtd_example[ 'rate_out' ]),
time =float_or_none(rtd_example[ 'time_out' ]),
distance =float_or_none(rtd_example[ 'distance_out' ]),
)
rtd = RateTimeDistance(**args)
assert rtd.distance == approx(rtd.rate * rtd.time)
assert rtd.rate == approx(result[ "rate" ], abs = 1E-2 )
assert rtd.time == approx(result[ "time" ])
assert rtd.distance == approx(result[ "distance" ])
这种情况取决于rtd_example
夹具。由于 fixture 有一个参数值列表,因此将多次调用此案例;每次,rtd_example
的值都将与值序列不同。这使得为各种输入值编写通用测试变得非常方便
本测试还使用了pytest.approx
对象。此对象用于包装浮点值,因此__eq__()
方法是近似相等的算法,而不是简单的精确相等测试。这是一种非常方便的方法,可以忽略由于截断数字的二进制表示形式而产生的微小浮点差异。
我们可以使用unittest
包来执行测试,而不是专注于单个孤立的类定义。如前所述,我们可以使用unittest
自动化测试多个组件集成的单元。这种测试只能在通过了独立组件单元测试的软件上执行。当组件的单元测试无法正常工作时,尝试调试失败的集成测试是没有意义的。
性能测试可以在多个集成级别上进行。对于大型应用程序,对整个构建进行性能测试可能不会完全有帮助。一种传统的观点是,程序 90%的时间只执行可用代码的 10%。因此,我们通常不需要优化整个应用程序;我们只需要找到代表真正性能瓶颈的一小部分程序。
在某些情况下,很明显我们有一个涉及搜索的数据结构。我们知道,删除搜索将大大提高性能。正如我们在第 6 章中所看到的,使用可调用对象和上下文,通过避免重新计算,实现记忆可以显著提高性能。
为了执行适当的性能测试,我们需要遵循以下三步工作循环:
- 结合使用设计审查和代码分析来定位应用程序中可能存在性能问题的部分。Python 在标准库中有两个分析模块。除非有更复杂的需求,
cProfile
将定位应用程序中需要焦点的部分。 - 使用
unittest
创建自动测试场景,以演示任何实际性能问题。使用timeit
或time.perf_counter()
采集性能数据。 - 优化所选测试用例的代码,直到性能可接受为止。
关键是尽可能地自动化,避免为了提高性能而对事情进行模糊的调整。大多数情况下,必须替换中心数据结构或算法(或两者兼而有之),从而导致大规模重构。拥有自动化的单元测试使大规模重构变得切实可行。
当性能测试缺乏特定的通过-失败标准时,可能会出现尴尬的情况。有一种驱动力可以使软件更快,而无需具体定义足够快。当有可测量的绩效目标时,它总是更简单。给定一个具体的目标,然后可以使用正式的自动化测试来断言结果是正确的,并且获得这些结果所花费的时间是可以接受的。
对于性能测试,我们可以使用如下代码:
import unittest
import timeit
class Test_Performance(unittest.TestCase):
def test_simpleCalc_shouldbe_fastEnough( self ):
t = timeit.timeit(
stmt = """RateTimeDistance(rate=1, time=2)""" ,
setup = """from Chapter_4.ch04_ex3 import RateTimeDistance""" ,
)
print ( "Run time" , t)
self .assertLess(t, 10 , f"run time { t } >= 10" )
使用unittest
可以创建自动性能测试。当timeit
模块执行给定语句 1000000 次时,这将使执行测试的计算机上的后台工作产生的测量变化最小化。
在前面的示例中,RTD 构造函数的每次执行所需时间少于 1/100000 秒。100 万次执行应该不到 10 秒。
我们研究了使用unittest
和doctest
来创建自动化单元测试。我们还研究了如何创建一个测试套件,以便在不依赖自动测试发现过程的情况下,将测试集合打包以供重用和聚合到具有更大范围的套件中。
我们研究了如何创建模拟对象,以便能够单独测试软件单元。我们还研究了各种设置和拆卸功能。这些允许我们编写具有复杂初始状态或持久结果的测试。
第一个单元测试属性与doctest
和unittest
都非常吻合。第一个属性如下所示:
- 快:除非我们编写了非常糟糕的测试,
doctest
和unitest
的性能应该非常快。 - 隔离:
unittest
包为我们提供了一个模拟模块,我们可以使用它隔离我们的类定义。此外,我们可以在设计中谨慎一些,以确保我们的组件彼此隔离。 - 可重复:使用
doctest
和unittest
进行自动测试,确保重复性。 - 自验证:
doctest
和unittest
都将测试结果与测试用例条件绑定,确保测试不涉及主观判断。 - 及时:一旦我们有了类、函数或模块的框架,我们就可以编写和运行测试用例。一个类的主体只有
pass
就足以运行测试脚本。
出于项目管理的目的,书面测试和通过测试的计数有时是非常有用的状态报告。
在创建软件时,测试用例必须是可交付的。没有自动测试的任何特性也可能不存在。如果没有测试,就不能相信特性是正确的。如果它不可信,就不应该使用它。
唯一真正的权衡问题是是否使用doctest
或unittest
或两者兼用。对于简单的编程,doctest
可能非常适合。对于更复杂的情况,unittest
将是必要的。对于 API 文档需要包含示例的框架,组合可以很好地工作。
在某些情况下,只需创建一个充满TestCase
类定义的模块就足够了。TestLoader
类和测试发现功能可能完全足以定位所有测试。
更一般而言,unittest
涉及使用TestLoader
从每个TestCase
子类中提取多个测试方法。我们将测试方法打包到一个单独的类中,基于他们可以与谁共享类级别setUp()
和setUpClass()
的方法。
我们也可以创建没有TestLoader
的TestCase
实例。在这种情况下,runTest()
的默认方法被定义为具有测试用例断言。我们可以从此类类的实例创建一个套件。
最困难的部分可能是设计可测试性。删除依赖项以便可以独立测试单元有时会感觉增加了软件设计的复杂性。在大多数情况下,暴露依赖关系所花费的时间就是在创建更具可维护性和灵活性的软件上投入的时间。
一般规则是:类之间的隐式依赖是糟糕的设计。
可测试设计具有明确的依赖性;可以很容易地用模拟对象替换这些对象。pytest
框架提供monkeypatch
夹具。这允许我们编写测试,通过修补依赖项来隔离正在测试的单元。虽然这很方便,但提供简单、可见的依赖项注入通常更简单、更可靠。
在第 18 章处理命令行中,我们将研究如何编写从命令行启动的完整应用程序。我们将研究在 Python 应用程序中处理启动选项、环境变量和配置文件的方法。
在第 19 章、模块和封装设计中,我们将对应用程序设计进行扩展。我们将添加在更大的应用程序中组合应用程序的能力,以及将应用程序分解为更小的模块或包的能力。**