有效的测试

文摘   2024-08-04 07:49   四川  

当我们想到自动化测试时,许多积极的方面可能会浮现在脑海中,比如缺陷检测,但实际上,良好的测试与良好设计同等重要,即通过可变代码来降低成本。可变性需要三个相关能力:

  • 重构

  • 灵活的设计

  • 有效的测试

但是,自动化测试是如何实现可变性的呢?尤其是,我只能看到两个主要理由,这两个理由又衍生出许多其他好处:

改变的信心

没有测试,任何更改都可能是一个潜在的缺陷。(Martin, 2008)。

自动化测试提供了一张安全网,使得开发者在修改或重构代码时更有信心。知道有一个坚实的测试套件可以迅速捕获因更改导致的 regression(回归错误),开发者就可以大胆地优化和调整代码,而不必担心不经意间引入新的错误。这种信心加速了开发过程,因为团队成员不再需要手动验证每一个小改动的效果,从而减少了因害怕破坏现有功能而产生的心理障碍。

无论基础设施是否令人满意,如果不在设计中处理好领域复杂性(Eric, 2003),无论架构多么灵活,设计多么良好地划分,如果没有测试,对变化的抵制始终存在(Martin, 2008)。一个令人恐惧的环境会扼杀重构的能力。

重构

重构是指通过重新设计内部结构来改善现有代码,以消除技术债务、提高可读性并适应新功能……但要保持当前的行为。

用测试来验证预期的行为,可以在开发过程中更早地发现和修复bug,这就是重构的信心来源。测试覆盖率越高,信心越强,信心越强,压力越小。可以放心地对被测试包围的不断改进的代码设计进行更改;实际上,重构是使代码灵活的重要实践。

灵活的设计

一个好的设计会尽可能在最低成本的情况下推迟每一个决策机会,以最大限度地保持灵活性(梅茨,2018年)。

除了与重构紧密相关外,良好的设计还依赖于测试。如果设计不佳,测试就很困难。

  • 痛苦的设置意味着代码需要太多的上下文,这意味着被测试的对象需要了解太多关于其依赖项的信息。

  • 对许多对象的需要意味着过度依赖。依赖越多,代码的稳定性就越低,变化的放大效应也就越大。

  • 如果编写测试很困难,那么该代码可能也很难重用。

这些“指标”有助于跟踪代码的灵活性、可维护性和可重用性。通过重构和测试实践可以提供良好的设计,同时,良好的设计也与重构相辅相成,使测试更容易编写,它们相辅相成。

测试带来的好处不仅限于代码库。如果没有测试作为安全网,新的需求可能会引起担忧、压力甚至额外的工作时间。此外,测试作为系统的活文档,有助于降低学习曲线,减少认知负荷。

文档

认知负荷指的是开发者为了完成一项任务需要掌握的知识量。当认知负荷较高时,学习需要改变的内容所需的时间会更长,并且由于遗漏了某个关键点而导致出现错误的风险也会更高(Ousterhout, 2021)。

过高的认知负荷来自于不完善的文档所造成的模糊性,而这种模糊性又源于无法表达意图的糟糕代码。静态文档,如注释,在我们无法在代码中表达自己时就成为了一种辅助手段。

静态文档

撰写评论的常见动机之一是糟糕的代码(马丁,2008年)。

就如同网站一样,注释和文档文件也是一种静态文档的形式,这意味着即使行为发生变化,它们仍会保持不变。它们非常适合用于澄清某些代码,也是传播错误信息的最佳方式。

这并不意味着我们永远不应该编写静态文档。它们对于指导外部人员如何使用SDK非常重要,可以提供用例和代码示例。然而,对于其他目的,请优先考虑动态文档。

动态文档

与注释和文档文件相比,代码本身应该被用来动态地记录系统的设计和行为,以适应每一个需求。但是,正如在“如何测试”一节中所述,这并非免费的,代码必须具有表达力、简单且结构良好。

要记住,测试也是代码,是代码库中的第一类公民。它们对于系统文档来说至关重要,提供了关于生产代码应该如何使用的实用视角;突出了设计决策、假设和约束。即使在纸质文档过时、人类记忆衰退之后,它们讲述的故事仍然真实可靠(Metz, 2018)。

仅仅专注于静态文档可能会掩盖良好的设计和经过充分测试的重要性。设计越明显,团队(开发人员和利益相关者)之间的协作就越多,对注释和文档的需求就越少。

自动化测试与敏捷开发密不可分,是持续交付的关键组成部分。它们的好处和预防的问题可以写成整本书。但是,必须精心编写测试,明确测试的内容和方式。如果没有明确的目的和策略,测试可能会成为负担,而不是好处。

测试策略

自动化测试可以采用不同的策略,这些策略可以提供有价值的见解,帮助我们权衡成本和收益。在讨论测试策略时,几乎无法绕开“测试金字塔”的概念。Mike Cohn在《Succeeding With Agile book》一书中引入了这个概念,帮助我们可视化自动化测试套件的理想结构。它强调在底部要有坚实的单元测试基础,然后逐渐过渡到顶部的较少但更广泛的集成测试。

金字塔模型仍然是一个很有价值的工具,但如果我们仔细观察,它还是有一些不足之处。它可能过于简单化,在命名和某些概念方面存在误导性;例如,“服务测试”是针对面向服务架构的特定内容,而“用户界面测试”并不一定意味着速度慢。但其核心概念仍然存在。

  • 编写不同粒度的测试。

  • 你的级别越高,你应该参加的考试就越少。

除了这些核心概念外,可以考虑更改测试层的命名,使用与代码库一致的术语。以下是一种基于集成级别且符合行业标准的一致命名约定:单元测试、集成测试(也称为组件测试)和端到端测试。

不幸的是,不同作者在描述测试策略时所使用的术语可能会有显著差异。尽管如此,不要拘泥于测试命名规范,而是要专注于核心概念,并在团队中达成共识,使用共同的术语。

端到端

从金字塔的顶端开始,我们有端到端(E2E)测试。正如其名称所暗示的,这里的想法是测试应用程序的某些部分,模拟真实世界的场景,完全集成,涉及与数据库、文件系统、网络调用等的连接。用户界面通常在E2E测试中涉及,但无需使用客户端来创建或执行它们。

由于其固有的权衡取舍,端到端测试位于测试金字塔的顶端。这种测试需要进行大量的设置和配置,因此开发和维护起来更加耗时。此外,端到端测试容易受到不稳定性的影响,这意味着它们有时会因为外部因素或环境变化而失败,导致结果不可靠。

尽管存在一些缺点,但端到端测试仍能提供宝贵的信心,确保在更改后完整的用户旅程能够按预期运行。然而,重要的是要有策略性地使用它们,并且要谨慎使用。烟雾测试(一种端到端测试类型)是这里的绝佳选择。它们专注于测试系统的核心功能,并使用少量的测试用例,这可以最小化这种测试策略的缺点。

集成测试

它也被称为组合测试。与端到端测试相比,集成测试提供了更大的灵活性,可以在测试范围内进行调整。它依赖于测试替身来替换一些外部组件,例如数据库和网络调用,以隔离和验证应用程序组件之间的交互,避免了真实世界的依赖关系。这种受控环境使我们能够一次专注于特定的集成,从而促进高效且有针对性的测试。

集成测试可以在更窄的范围内进行。可以使用模拟响应来替换所有网络调用,并创建合同测试来验证与服务器的协议保持完整。例如,在分层架构中,可以使用测试替身替换任何层组件,以实现不同级别的集成。

通过专注于系统中的一小部分,集成测试运行速度更快,降低了配置成本,使得它们更容易创建和维护。这使得可以在测试套件中包含大量测试用例,同时保持其健壮性。

单元测试

单元测试是任何有效测试套件的基础。它们专注于每次测试一小段代码,同时隔离外部参与者。虽然在单元测试的上下文中,“单元”范围可以从一个函数到整个类。单元测试的隔离程度也可以根据社交程度而变化,可以是孤立的或社交的。

  • 社交性 - 在与真实协作者集成时,单元测试可以获得更现实的行为。但是,这种方法也有其代价。首先,由于需要实例化所有依赖于被测试对象的对象,因此设置测试变得更加复杂。其次,这些测试容易受到副作用的影响。协作者的任何变化都可能导致多个测试失败,即使被测试对象本身功能正常。

  • 孤立式 - 为最大限度地实现隔离,单元测试可以完全依赖测试替身来模拟依赖关系,从而消除社交测试所带来的问题,但会失去真实行为。

在制定测试策略时,社交能力水平并不重要。抓住机会,利用代码库尝试不同的方法,了解哪些方法最适合团队。既社交又独处的态度也是可行的,例如,用测试替身替换复杂的协作者,或者只让数据对象成为真实的协作者。

一个健壮的测试套件需要一个有针对性的测试策略。以编写一组集中的端到端用例为核心,辅以大量的集成测试和作为测试金字塔基础的大量单元测试。这种平衡的方法可以避免“冰淇淋锥”反模式,即过多的端到端测试会减缓开发速度并降低测试套件的可维护性。

在探讨了测试策略及其权衡之后,让我们深入探讨要测试什么内容以及如何有效地创建测试用例。

有效的测试

在一本书中很难详尽地阐述每种测试策略的细节,而在一篇文章中更是不可能做到。下面的几个部分将探讨一些适用于各种测试的最佳实践,但重点是从面向对象编程的角度强调建立一个坚实的单元测试套件基础。

要测试什么?

你不是因为拥有事物才发送信息,而是因为你发送了信息才拥有事物。(梅茨,2018年)

传统的基于类的设计侧重于定义对象及其功能。通过将消息置于设计的中心,应用程序围绕对象之间的通信而不是对象本身旋转。这可以理解为从“对象是什么”到“对象需要什么”(消息)的转变。

想象一下,说“我需要把这件事完成”而不是直接指示如何去做。这种“盲目信任”能够促进对象之间的协作。消息作为请求,允许对象履行各自的责任,而不必紧密耦合或了解彼此的内部工作机制。这有助于促进整个系统设计的松散耦合和模块化。

回答最初的问题:要测试什么?我们需要测试的是消息。

对话中的对象会处理两种主要类型的消息,即“入站消息”和“出站消息”。测试这些消息可以确保对话的双方都能按预期工作。

入站消息

入站消息定义了接收对象的公共接口,确立了其通信协议。接收对象负责测试自己的接口,这是通过状态测试来完成的,即在接收到消息时断言预期结果。

发出消息

发出消息是指一个对象发送给另一个对象的消息。它们自然是其他对象的接收消息。在从对象A到对象B的对话中,对象A发出的消息成为对象B的接收消息。

有两种类型的出口消息:

  • 查询:这些消息用于获取信息,不会造成持久性更改。由于只有发送者关心响应,查询通常不需要进行测试。

  • 命令:这些消息会触发系统内的操作,可能影响其他组件。命令对于系统的功能至关重要,应该彻底测试。

值得注意的是,发送方不应该对接收方的公共接口进行任何断言。相反,它应该专注于确保命令是正确的:以正确的数据发送,在适当的频率发送。这些测试专注于验证消息的行为,而不是接收方的内部工作原理。

咖啡机

想象一下拥有一台全自动咖啡机。你不需要担心加热水、研磨咖啡豆或其他复杂的细节。你所关心的只是享受你的完美咖啡。通过简单的选择,你将整个咖啡制作过程交给机器,相信它能提供你想要的结果。

咖啡机接收一个配方作为入站消息,其中指定了水温、研磨粒度和其他规格。

为了完成配方,它会与磨豆机等组件协同工作,向它们发送一些外出消息。然而,最初的设计将咖啡机与磨豆机的操作捆绑得太紧,要求它直接与料斗交互。

为了提高模块化程度并减少依赖性,咖啡机应该将咖啡豆获取过程委托给研磨机。通过向研磨机提供所需的粉末轮廓,咖啡机只需发送一个简单的查询消息,就可以让研磨机自主工作,从而抽象化了咖啡豆分发的复杂性。

这款咖啡机还可以缓存上一次选择的配方。当选择一种咖啡饮品时,咖啡机会通过向缓存发送命令消息来存储所选配方,这样就可以快速选择下一次冲泡的相同咖啡。


消息驱动的方法具有显著的优势。通过关注消息,系统之间的耦合变得更松散,从而使系统更加灵活,维护和扩展变得更加容易。这种视角还有助于发现新的对象,因为消息本身需要一个相应的对象来处理它,这可以导致更模块化和可重用的设计。

值得注意的是,对象向自身发送的消息通常不会被直接测试,因为这些方法是私有方法,不属于公共通信接口。如果您认为某些内部消息需要进行测试,请确保它们被发送到正确的位置。这可能意味着需要创建一个新的对象来处理这些消息,以便进行适当的测试。

如何测试?

正如我们之前讨论的,如果不能写得清晰明了,测试反而会增加公司的成本而不是节省成本。它们需要精心设计和编写,并且在编写清晰的测试时,可读性至关重要。但是,我们该如何使测试具有可读性呢?就像我们编写可读性强的代码一样,通过表达清晰、简洁明了和结构清晰的方式来实现。

表达力为变量、方法和类使用有意义的名称,避免编写晦涩难懂或难以理解的代码。简洁的代码通常是可取的,但绝不能以牺牲清晰性为代价。

// Throws an exception if the boiler is not ready for the recipefun prepare(recipe: Recipe){if(boiler.temperature != recipe.temperature) throw Exception()//continue with the process }}

最初的代码中,有一条模糊的注释表示如果锅炉未准备好,将会抛出异常。但是,由于异常类型是泛型的,而且锅炉的可用性检查是隐式的,因此代码不够清晰。

fun prepare(recipe: Recipe){
val boilerIsNotReady = boiler.temperature != recipe.temperature
if(boilerIsNotReady) throw BoilerNotReadyException()
//continue with the process
}

通过引入一个变量来明确检查锅炉是否准备就绪,并指定一个自定义异常,我们已将模糊的注释转化为清晰、可操作的代码。这种方法增强了代码的可读性和可维护性,同时提供了有关系统行为的宝贵信息。简化的代码应尽可能简单,同时仍能满足其需求。将复杂的逻辑分解成更小和更易管理的单元。

fun listComponentsForMaintenance() : List<Component>{val componentsForMaintenance = emptyList() components.forEach{ component ->val now = Clock.System.now()val days = components.lastMaintenance.daysUntil(now, TimeZone.UTC)val eligibleForMaintenanceList = emptyList<String>()if(days >= 30) { componentsForMaintenance.add(component) } }return componentForMaintenance}

在这个示例中,如果上次维护是在30天或更早之前,组件就会被添加到componentsForMaintenance列表中。但是,由于在foreach循环中使用了过多的嵌套逻辑,代码阅读起来较为困难,缺乏清晰的故事线。

fun listComponentsForMaintenance() : List<Component> {return components.filter{ component-> isEligibleForMaintenance(compnent) }}
private fun isEligibleForMaintenance(component: Component): Boolean {val now = Clock.System.now()val lastMaintenance = component.lastMaintenanceval daysSinceLastMaintenance = lastMaintenance.daysUntil(now, TimeZone.UTC)return daysSinceLastMaintenance >= 30}

不要使用冗长的逻辑链,而是识别出更小的步骤,并为每个步骤创建单独的函数。这会使代码更容易理解。还可以将 days since last maintenance逻辑提取到自己的方法中。此外, isEligibleForMaintenance方法可以是 Component类的Kotlin扩展函数。甚至更好的是, Component本身可以决定是否需要维护。

结构 将不同的关注点和责任分离开来。这使得代码更容易理解和导航。

fun prepare(recipe: Recipe) : Coffee {// other steps hereval beans = grinder.silo.dispense(recipe.weight) grinder.grind(beans, recipe.granulometry)}

咖啡机直接与储豆箱交互以获取咖啡豆,这种设计不必要地耦合了两个组件。

fun prepare(recipe: Recipe) : Coffee {// other steps here grinder.grind(powderProfile)}

通过将豆子获取的责任转移给研磨器,我们增强了封装性并优化了代码结构,从而避免了违反迪米特法则(Law of Demeter)。

以上展示的这些小例子似乎不会对代码库造成任何伤害,但请记住,复杂性是逐渐累积的,它以小块的形式积累。

测试咖啡机

在开始之前,请注意,这里的咖啡机依赖项并未作为抽象处理。虽然罗伯特·马丁提倡使用健壮的抽象,并建议在接口只有一个实现时使用 Impl后缀,但我更喜欢桑迪·梅茨的观点。她认为代码可以先专注于功能,然后根据需要逐步演化为抽象。现代的IDE使得在必要时提取对象的公共接口变得十分容易。

记住,语言(本例中为Kotlin)只是一种工具。我们可以将这些想法转换为任何面向对象编程语言。MockK也被用作模拟框架,但在测试抽象依赖时可以考虑使用桩。与模拟不同,桩专注于依赖项的特定行为,同时保持其剩余功能完好无损。请查看这篇关于测试替身的文章,了解更多信息。

让我们编写一些测试程序吧…

class CoffeeMakerTest {@MockK private lateinit var grinder: Grinder@MockK private lateinit var cache: Cacheprivate lateinit var coffeeMaker: CoffeeMaker
@Beforefun setUp() { MockKAnnotations.init(this) coffeeMaker = CoffeeMaker(grinder, cache) }
@Testfun `should return coffee when call the prepare function`() {val espressoRecipe = EspressoRecipe() every { grinder.grind(PowderProfile(weight = 15f, granulometry= 1.5)) } returns Powder("espresso")val coffee = coffeeMaker.prepare(espressoRecipe) assertThat(coffee).isInstanceOf(Espresso::class.java) assertThat(coffee.ratio).isEqualTo("1:2") verify(exactly = 1) { cache.store(espressoRecipe) }//other assertions and verifications }}

在这个例子中,我们确保在准备浓缩咖啡配方时会返回一杯咖啡。我们还验证所选配方已存储在缓存中。然而,由于这个例子并不复杂,我们只测试了咖啡机的一个小交互,而且没有包含任何错误情况,因此这个测试的可读性并不高。代码缺乏表达力、简洁性和结构性。至少,它使用了现实生活中的例子来进行测试。

命名

我们先从测试命名开始:should return coffee when call the prepare function。这个名称没有传达任何业务规则的信息;它更注重函数调用,而且每段代码“应该”做一些事情或者被删除。

在编写测试用例时,要尝试回答以下问题:在特定操作下,期望代码中的哪些行为?例如:brew an espresso when an espresso recipe is prepared.

Given, When, Then

Given, When, Then、“构建、运行、检查”或“安排、行动、断言”是适用于所有类型测试的结构模式,将测试功能分为三个部分:

  • Given:本节设定了测试的初始状态,包括数据和前提条件。

  • When:这部分表示触发被测试行为的动作,通常是一个函数调用。

  • Then:本节检查这些操作是否产生了预期的结果。


class CoffeeMakerTest {// setup
@Testfun `brew an espresso when an espresso recipe is prepared`() {val espressoRecipe = EspressoRecipe() every { grinder.grind(PowderProfile(weight = 15f, granulometry= 1.5)) } returns Powder("espresso")
val coffee = coffeeMaker.prepare(espressoRecipe)
assertThat(coffee).isInstanceOf(Espresso::class.java) assertThat(coffee.ratio).isEqualTo("1:2") verify(exactly = 1) { cache.store(espressoRecipe) }//other assertions and verifications }}

每个测试只包含一个断言

一些测试人员建议每个测试用例只包含一个断言。这与其说是限制方法调用的数量,不如说是专注于验证每个测试用例中的单一行为。

在当前的例子中,测试方法正在检查咖啡机的入站消息和发送到缓存的出站消息。这些代表了两个不同的场景,应该分别进行测试。

相反,应该创建一个测试用例来断言当发送“准备(食谱)”消息时咖啡机的状态。另一个测试用例应该专门用于确保调用缓存来存储食谱。

class CoffeeMakerTest {// test setup
@Testfun `brew an espresso when an espresso recipe is prepared`() { stubEspressoGrinding()
val coffee = coffeeMaker.prepare(EspressoRecipe())
assertThat(coffee).isInstanceOf(Espresso::class.java) assertThat(coffee.ratio).isEqualTo("1:2") }
@Testfun `store espresso as the last chosen recipe on prepare an espresso`(){ stubEspressoGrinding()val espressoRecipe = EspressoRecipe()
val coffee = coffeeMaker.prepare(espressoRecipe)
verify(exactly = 1) { cache.store(espressoRecipe) } }
private fun stubEspressoGrinding(){val powderProfile = espressoPowderFixture() every { grinder.grind(powderProfile) } returns Powder("espresso") }}

领域特定语言

领域特定语言(DSL)是提升测试编写效率的有效途径。通过创建一套函数来抽象生产和测试API,可以提高可读性和可维护性。在测试场景中管理复杂数据集时,DSL尤其有用,可以实现动态的测试数据操作以满足复杂的需求和变化。

例如,在之前的测试中,我们可以通过将 verify块抽象为 verifyRecipeHasBeenCached(espressoRecipe)来增强缓存命令验证的表达能力,就像我们将espresso研磨桩抽象为 stubEspressoGrinding一样。

测试套件还可以用于创建具有明确定义数据的简单测试用例。此外,它们还可以与特定领域的语言一起使用,作为这些语言的构建块。

保持‘干净’

有缺陷的测试与没有测试(就其效果而言)同样糟糕,甚至更糟糕(马丁,2008年)。

实现干净易维护的测试是一个持续不断的努力过程。没有单一的神奇解决方案,而是多种技术的组合。清晰的命名约定、适当的测试结构以及根据场景选择合适的测试数据管理方法(fixtures或DSLs)都是关键因素。

有时候,改进生产代码本身可以显著提升测试质量。通过更好地分离关注点或创建辅助方法来重构代码,可以使测试更具表达力和可维护性。

在某些情况下,有控制地进行代码复制实际上可以提高测试的可读性和可维护性,即使它违反了DRY(不要重复自己)原则。在这两者之间找到平衡至关重要。

没有维护的测试会失去其价值,并可能产生一种虚假的安全感。定期审查你的测试。如果某个测试变得难以维护,可以考虑重构它、改进它所针对的代码,甚至完全删除它,如果它不再有任何用途的话。

避免使用多种架构和测试模式。虽然针对单一问题的特定解决方案可能看起来很完美,但在更广泛的背景下考虑时,它可能会增加认知负荷。“完美”是因人而异的,应该寻求改进。

TDD与BDD

正如前面所提到的,在一篇文章中几乎不可能涵盖与测试相关的所有内容,但在讨论干净的测试时,这两个实践值得特别提及。

  • TDD(Test-Driven Development)是一种开发方法,它要求在编写任何生产代码之前先编写自动化测试。这种做法通过迫使开发人员在编写代码之前考虑期望的功能和潜在问题,从而确保代码质量。

  • BDD(行为驱动开发)是一种专注于描述软件系统期望行为的开发方法。它通过使用自然语言(如Gherkin语法)编写测试用例来促进开发人员、测试人员和非技术利益相关者之间的协作。这种共享理解确保最终产品符合业务需求。

BDD(行为驱动开发)和TDD(测试驱动开发)都不是强制性的实践,但它们显著提高了软件质量保证过程。

F.I.R.S.T

最后,请首先创建测试。

  • 快速:测试应该执行迅速,以便提供快速的反馈并避免减缓开发周期。

  • 独立性:测试不应该依赖于其他测试的结果,允许它们以任何顺序或隔离方式运行。

  • 可重现性:无论环境或之前的测试运行如何,测试都应始终产生相同的结果。

  • 自我验证:测试应该能够明确地显示成功或失败,而不需要人工解释。

  • 及时:在编写代码之前或同时编写测试用例。这有助于推动测试驱动开发(TDD),即测试用例指导开发过程。

参考文献

  • Beck, K. (2002). Test-driven development by example. Addison-Wesley Professional.

  • Freeman, S., & Pryce, N. (2009). Growing object-oriented software, guided by tests. Addison-Wesley Professional.

  • Martin, R. C. (2008). Clean code: a handbook of agile software craftsmanship. Prentice Hall.

  • Martin, R. C. (2017). Clean architecture: a craftsman's guide to software structure and design. Prentice Hall.

  • Evans, E. (2003). Domain-driven design: tackling complexity in the heart of software. Addison-Wesley Professional.

  • Ousterhout, J. K. (2018). A philosophy of software design. Addison-Wesley Professional.

  • Metz, S. (2018). Practical object-oriented design in Ruby: an agile approach (2nd ed.). Addison-Wesley Professional.

  • Humble, J., & Farley, D. (2010). Continuous delivery: reliable software releases through build, test, and deployment automation. Addison-Wesley Professional.

  • Forsgren, N., Humble, J., & Kim, G. (2018). Accelerate: The science of lean software and DevOps: Building and scaling high-performing technology organizations. IT Revolution Press.

  • Vocke, H. (2011). Practical Test Pyramid. Martin Fowler

  • Fowler, M. (2014). Mocks Aren't Stubs. Martin Fowler

  • Fowler, M. (n.d.). Integration Test. Martin Fowler

  • Fowler, M. (n.d.). Contract Test. Martin Fowler

  • Fowler, M. (n.d.). Test Double. Martin Fowler


软件质量保障
所寫即所思|一个阿里质量人对测试技术的思考。
 最新文章