#chapter3

本章涵盖单元测试的结构单元测试命名最佳实践使用参数化测试处理流利的断言

在第1部分的其余章节中,我将向您介绍一些基本主题。 我将介绍典型的单元测试的结构,该结构通常由安排,行动和声明(AAA)模式表示。 我还将展示我选择的单元测试框架xUnit,并解释为什么我使用它而不是其竞争对手之一。

在此过程中,我们将讨论命名单元测试。 关于此主题,有很多相互竞争的建议,但是不幸的是,大多数建议在改善单元测试方面做得不够好。 在本章中,我将介绍那些不太有用的命名方式,并说明为什么它们通常不是最佳选择。 除了这些做法之外,我还提供了一种替代方法-一种简单,易于遵循的准则,以一种使测试不仅对编写它们的程序员而且对熟悉问题域的其他人可读的方式来命名测试 。

最后,我将讨论框架的一些功能,这些功能有助于简化单元测试的过程。 不必担心这些信息对于C#和.NET来说太具体了; 无论哪种编程语言,大多数单元测试框架都具有相似的功能。 如果您学习其中一种,则与另一种工作不会有问题。

3.1 如何构建单元测试

本节说明如何使用“排列”,“动作”和“断言”模式来构造单元测试,应避免的陷阱以及如何使测试尽可能可读。

3.1.1 使用AAA模式

AAA模式提倡将每个测试分为三个部分:安排,执行和声明。 (此模式有时也称为3A模式。)让我们使用一个计算器类,该计算器类使用一种方法来计算两个数字的和:

public class Calculator
{public double Sum(double first, double second){return first + second;}
}

以下清单显示了一个验证班级行为的测试。 此测试遵循AAA模式。

public class CalculatorTests
{[Fact]public void Sum_of_two_numbers(){// Arrangedouble first = 10;double second = 20;var calculator = new Calculator();// Actdouble result = calculator.Sum(first, second);// AssertAssert.Equal(30, result);}
}

是该模式的最大优点之一:一旦习惯了,您就可以轻松阅读和理解任何测试。 从而减少了整个测试套件的维护成本。 结构如下:

  • 在“安排”部分中,将被测系统(SUT)及其依赖项置于所需状态。

  • 在行为部分中,您可以在SUT上调用方法,传递准备好的依赖关系,并捕获输出值(如果有)。

  • 在断言部分中,验证结果。 结果可以由返回值,SUT及其协作者的最终状态或SUT在这些协作者上调用的方法表示。

给予时间模式您可能已经听说过“准时赠送”模式,类似于AAA。 此模式还主张将测试分为三个部分:1.给定-对应于“安排”部分2.什么时候—对应于行为部分3.然后—对应于断言部分在测试构图方面,两种模式没有区别。 唯一的区别是,非程序员可以更容易地读取“时给定”结构。 因此,“按时给出”更适合与非技术人员共享的测试。

自然的倾向是开始用range部分编写测试。 毕竟,它先于其他两个。 这种方法在绝大多数情况下都行之有效,但从assert部分开始也是一种可行的选择。 当您练习测试驱动开发(TDD)时,即在开发功能之前创建失败的测试时,您对功能的行为还不了解。 因此,首先概述您对行为的期望,然后弄清楚如何开发系统来满足这一期望将变得非常有利。

这种技术可能看起来违反直觉,但这是我们解决问题的方式。 我们首先考虑目标:一种特定的行为应该为我们做什么。 之后才真正解决问题。 在其他所有内容之前写下断言只是这种思考过程的形式化。 但是同样,该指南仅在遵循TDD时适用-在生产代码之前编写测试时。 如果您在测试之前编写生产代码,那么在进行测试时,您已经知道从行为中可以得到什么,因此从“安排”部分开始是一个更好的选择。

3.1.2 避免多个安排,行动和断言部分

有时,您可能会遇到包含多个安排,操作或断言部分的测试。 它通常如图3.1所示工作。

当您看到多个行为部分被断言和可能的排列部分分开时,这意味着测试将验证行为的多个单位。 而且,正如我们在第2章中讨论的那样,这样的测试不再是单元测试,而是集成测试。 最好避免这样的测试结构。 一项操作可确保您的测试保持在单元测试范围之内,这意味着它们简单,快速且易于理解。 如果看到包含一系列动作和断言的测试,请对其进行重构。 将每个行为提取到自己的测试中。

有时最好在集成测试中包含多个行为部分。 您可能还记得上一章,集成测试可能很慢。 一种加快速度的方法是将多个集成测试组合在一起,成为具有多个操作和断言的单个测试。 当系统状态自然地彼此流动时,即当一个行为同时充当后续行为的安排时,这特别有用。

但同样,这种优化技术仅适用于集成测试-并非适用于所有测试,而是适用于已经很慢并且您不想变得更慢的测试。 在足够快的单元测试或集成测试中,无需进行此类优化。 最好将多步单元测试分为多个测试。

3.1.3 避免在测试中使用if语句

与多次出现的ranging,act和assert部分相似,有时您可能会通过if语句遇到单元测试。 这也是一种反模式。 测试(无论是单元测试还是集成测试)应该是没有分支的简单步骤序列。

if语句表示测试一次验证了太多东西。 因此,应将此类测试分为几个测试。 但是,与具有多个AAA部分的情况不同,集成测试也不例外。 在测试中进行分支没有任何好处。 您仅会获得额外的维护成本:if语句使测试难以阅读和理解。

3.1.4 每个部分应该多大?

人们开始使用AAA模式时通常会问的一个问题是,每个部分应为多大? 那么拆解部分(测试后要清理的部分)呢? 关于每个测试部分的大小,有不同的准则。

安排部分是最大的

安排部分通常是三个中最大的一个。 它可以和act和assert部分组合在一起一样大。 但是如果它变得更大,最好将安排提取到相同测试类中的私有方法中,或者提取到单独的工厂类中。 两种流行的模式可以帮助您重用“安排”部分中的代码:“对象母体”和“测试数据生成器”。

注意大于单个行的行为部分

动作部分通常只是一行代码。 如果该行为包含两行或更多行,则可能表明SUT的公共API有问题。

最好用一个例子来表达这一点,因此,让我们从第2章中选一个,在下面的清单中重复。 在此示例中,客户从商店进行购买。

[Fact]
public void Purchase_succeeds_when_enough_inventory()
{// Arrangevar store = new Store();store.AddInventory(Product.Shampoo, 10);var customer = new Customer();// Actbool success = customer.Purchase(store, Product.Shampoo, 5);// AssertAssert.True(success);Assert.Equal(5, store.GetInventory(Product.Shampoo));
}

请注意,此测试中的act部分是单个方法调用,这是设计良好的类API的标志。 现在将其与清单3.3中的版本进行比较:此行为部分包含两行。 这表明SUT存在问题:它要求客户记住要进行第二种方法调用才能完成购买,因此缺乏封装。

[Fact]
public void Purchase_succeeds_when_enough_inventory()
{// Arrangevar store = new Store();store.AddInventory(Product.Shampoo, 10);var customer = new Customer();// Actbool success = customer.Purchase(store, Product.Shampoo, 5);store.RemoveInventory(success, Product.Shampoo, 5);// AssertAssert.True(success);Assert.Equal(5, store.GetInventory(Product.Shampoo));
}

您可以从清单3.3的行为部分中阅读以下内容:

  • 在第一行中,客户尝试从商店购买五种洗发水。

  • 在第二行中,将库存从商店中删除。 仅当先前对Purchase()的调用返回成功时,才进行删除。

新版本的问题在于,它需要两个方法调用才能执行单个操作。 请注意,这不是测试本身的问题。 该测试仍然验证行为的相同单位:购买过程。 问题出在Customer类的API表面。 它不应该要求客户端进行其他方法调用。

从业务角度来看,成功购买有两个结果:客户购买产品和减少商店中的库存。 这两个结果必须同时实现,这意味着应该有一个同时做两件事的公共方法。 否则,如果客户代码调用第一种方法而不调用第二种方法,则可能存在不一致的地方,在这种情况下,客户将购买产品,但商店中不会减少其可用数量。

这种不一致称为不变违反。 保护您的代码免于潜在的不一致的行为称为封装。 当不一致的情况渗透到数据库中时,这将成为一个大问题:现在,无法通过简单地重新启动来重置应用程序的状态。 您必须处理数据库中的损坏数据,并有可能联系客户并根据具体情况处理情况。 试想一下,如果应用程序生成确认收据而不实际保留库存,将会发生什么情况。 它可能会提出索赔,甚至向您收取更多的库存,而不是在不久的将来可能获得的库存。

补救措施是始终保持代码封装。 在前面的示例中,客户应将购买的库存从商店中删除,作为其购买方法的一部分,而不要依赖客户代码来这样做。 当涉及到保持不变性时,您应该消除任何可能导致不变性违规的潜在措施。

对于大多数包含业务逻辑的代码,将行为部分缩减为一行的指导原则是正确的,而对于实用程序或基础结构代码则不是如此。 因此,我不会说“从不做”。 不过,请务必检查每种情况,以防封装可能遭到破坏。

3.1.5 断言部分应包含多少个断言?

最后,是断言部分。 您可能已经听说过关于每个测试仅声明一个准则的指导。 它扎根于上一章讨论的前提:以尽可能小的代码为目标的前提。

如您所知,此前提是不正确的。 单元测试中的单元是行为的单元,而不是代码的单元。 一个行为单元可以表现出多种结果,最好在一个测试中对所有结果进行评估。

话虽如此,您需要注意断言节过大:这可能表示生产代码中缺少抽象。 例如,与其在SUT返回的对象内声明所有属性,不如在该对象的类中定义适当的相等成员。 然后,您可以使用单个断言将对象与期望值进行比较。

3.1.6 拆卸阶段又如何呢?

有些人还区分了第四部分,即拆解,这是在安排,行动和主张之后出现的。 例如,您可以使用本节来删除测试创建的所有文件,关闭数据库连接,等等。 通常,拆解由单独的方法表示,该方法可在类中的所有测试中重复使用。 因此,我不会将此阶段包括在AAA模式中。

请注意,大多数单元测试不需要拆卸。 单元测试不会涉及流程外的依赖性,因此不会留下需要处理的副作用。 这就是集成测试的领域。 在第3部分中,我们将详细讨论在集成测试后如何正确清理。

3.1.7 区分被测系统

SUT在测试中起着重要作用。 它为您要在应用程序中调用的行为提供了一个入口点。 正如我们在上一章中讨论的那样,这种行为可能涉及多达多个类或仅涉及一个方法。 但是只能有一个切入点:一个触发该行为的类。

因此,将SUT与依存关系区分开来非常重要,尤其是当它们之间有很多依存关系时,这样就不需要花费太多时间来确定谁在测试中是谁。 为此,请始终在测试sut中命名SUT。 以下清单显示了重命名Calculator实例后CalculatorTests的外观。

public class CalculatorTests
{[Fact]public void Sum_of_two_numbers(){// Arrangedouble first = 10;double second = 20;var sut = new Calculator();// Actdouble result = sut.Sum(first, second);// AssertAssert.Equal(30, result);}
}

在大多数单元测试中,用空行分隔各个部分非常有用。 它使您可以在简洁性和可读性之间保持平衡。 不过,在大型测试中,它的效果不佳,您可能需要在ranging部分中放置其他空行以区分配置阶段。 在集成测试中通常是这种情况-它们经常包含复杂的设置逻辑。 因此,

  • 将节注释放在遵循AAA模式的测试中,并且可以避免在ranging和assert部分中出现其他空行。

  • 否则,请保留本节的注释。

3.2 探索xUnit测试框架

在这一节中,我将简要概述. net中可用的单元测试工具及其特性。我使用xUnit (https://github.com/xunit/xunit)作为单元测试框架(注意,为了从Visual Studio运行xUnit测试,需要安装xUnit .runner.visualstudio NuGet包)。尽管这个框架只能在。net中工作,但是每一种面向对象的语言(Java、c++、JavaScript等等)都有单元测试框架,而且所有这些框架看起来都非常相似。如果你和其中一个合作过,那么你和另一个合作就不会有任何问题。

仅在。net中,就有几个替代选项可供选择,比如NUnit (https://github.com/nunit/nunit)和内置的Microsoft MSTest。我个人更喜欢xUnit的原因我将在稍后描述,但是您也可以使用NUnit;这两个框架在功能方面相当相似。不过,我不推荐MSTest;它没有提供与xUnit和NUnit相同级别的灵活性。不要相信我的话,即使是微软内部的人也不愿意使用MSTest。例如,ASP。NET核心团队使用xUnit。

我更喜欢xUnit,因为它是NUnit的一个更简洁的版本。例如,您可能已经注意到,在我目前提出的测试中,除了[Fact]之外,没有与框架相关的属性,它将方法标记为单元测试,以便单元测试框架知道要运行它。没有[TestFixture]属性;任何公共类都可以包含单元测试。也没有[设置]或[拆除]。如果您需要在测试之间共享配置逻辑,您可以将其放在构造函数中。如果需要清理某些内容,可以实现IDisposable接口,如清单所示。

public class CalculatorTests : IDisposable
{private readonly Calculator _sut;public CalculatorTests(){_sut = new Calculator();}[Fact]public void Sum_of_two_numbers(){/* ... */}public void Dispose(){_sut.CleanUp();}
}

如您所见,xUnit作者采取了重要步骤来简化框架。 许多以前需要附加配置的概念(例如[TestFixture]或[SetUp]属性)现在都依赖于约定或内置语言构造。

我特别喜欢[Fact]属性,特别是因为它称为Fact而不是Test。 它强调了我在上一章中提到的经验法则:每个测试都应该讲一个故事。 这个故事是关于问题域的单个原子场景或事实,通过测试是证明此场景或事实成立的证据。 如果测试失败,则说明该故事不再有效,您需要重写它,或者必须修复系统本身。

我鼓励您在编写单元测试时采用这种思维方式。 您的测试不应该只是对生产代码所做的事情的简单列举。 相反,它们应该提供有关应用程序行为的更高级描述。 理想情况下,此描述不仅对程序员有意义,而且对商人也有意义。

3.3 在测试之间重复使用测试夹具

重要的是要知道如何以及何时在测试之间重用代码。 在安排部分之间重用代码是缩短和简化测试的好方法,本节说明了如何正确地进行测试。

我之前提到过,固定装置通常会占用太多空间。 将这些安排提取到单独的方法或类中,然后在测试之间重用是有意义的。 您可以通过两种方式执行这种重用,但是只有一种是有益的。 另一个导致维护成本增加。


```markup
测试装置测试夹具一词有两个常见含义:1.测试夹具是测试所针对的对象。 该对象可以是常规依赖项,即传递给SUT的参数。 它也可以是数据库中的数据或硬盘上的文件。 在每次测试运行之前,此类对象都需要保持已知的固定状态,因此它会产生相同的结果。 因此,夹具一词。2.另一个定义来自NUnit测试框架。 在NUnit中,TestFixture是标记包含测试的类的属性。我在本书中使用第一个定义。

第一种(不正确的)重用测试装置的方法是在测试的构造函数(或使用NUnit的方法中,使用[SetUp]属性标记的方法)中对其进行初始化,如下所示。```java
public class CustomerTests
{private readonly Store _store;private readonly Customer _sut;public CustomerTests(){_store = new Store();_store.AddInventory(Product.Shampoo, 10);_sut = new Customer();}[Fact]public void Purchase_succeeds_when_enough_inventory(){bool success = _sut.Purchase(_store, Product.Shampoo, 5);Assert.True(success);Assert.Equal(5, _store.GetInventory(Product.Shampoo));}[Fact]public void Purchase_fails_when_not_enough_inventory(){bool success = _sut.Purchase(_store, Product.Shampoo, 15);Assert.False(success);Assert.Equal(10, _store.GetInventory(Product.Shampoo));}
}

清单3.7中的两个测试具有通用的配置逻辑。 实际上,它们的安排部分是相同的,因此可以完全提取到CustomerTests的构造函数中,这正是我在这里所做的。 测试本身不再包含安排。

使用这种方法,您可以大大减少测试代码的数量,您可以摆脱测试中大多数甚至所有测试夹具的配置。 但是此技术有两个明显的缺点:

  • 它引入了测试之间的高耦合。

  • 它降低了测试的可读性。

让我们更详细地讨论这些缺点。

3.3.1。测试之间的高耦合是一个反模式。

在新版本中,如清单3.7所示,所有的测试都是相互耦合的:修改一个测试的安排逻辑将影响类中的所有测试。例如,改变这条线

_store.AddInventory(Product.Shampoo, 10);

to this

_store.AddInventory(Product.Shampoo, 15);

会使测试对商店的初始状态所做的假设无效,从而导致不必要的测试失败。

这违反了一项重要准则:修改一项测试不会影响其他测试。 该准则类似于我们在第2章中讨论的准则,即测试应彼此隔离运行。 不过不一样。 在这里,我们谈论的是测试的独立修改,而不是独立执行。 两者都是精心设计的测试的重要属性。

要遵循此准则,您需要避免在测试类中引入共享状态。 这两个私有字段是这种共享状态的示例:

private readonly Store _store;
private readonly Customer _sut;

3.3.2 在测试中使用构造函数会降低测试的可读性

将安排代码提取到构造函数中的另一个缺点是降低了测试的可读性。 您不再只是通过查看测试本身就可以看到整个画面。 您必须检查类中的不同位置以了解测试方法的作用。

即使没有太多的排列逻辑(例如,仅对夹具进行实例化),您还是最好直接将其移至测试方法。 否则,您会想知道它是否只是实例化或在此处进行了其他配置。 独立的测试不会给您带来不确定性。

3.3.3 重用测试夹具的更好方法

在重用测试夹具时,使用构造器不是最佳方法。 第二种方法(有益的方法)是在测试类中引入私有工厂方法,如下面的清单所示。

public class CustomerTests
{[Fact]public void Purchase_succeeds_when_enough_inventory(){Store store = CreateStoreWithInventory(Product.Shampoo, 10);Customer sut = CreateCustomer();bool success = sut.Purchase(store, Product.Shampoo, 5);Assert.True(success);Assert.Equal(5, store.GetInventory(Product.Shampoo));}[Fact]public void Purchase_fails_when_not_enough_inventory(){Store store = CreateStoreWithInventory(Product.Shampoo, 10);Customer sut = CreateCustomer();bool success = sut.Purchase(store, Product.Shampoo, 15);Assert.False(success);Assert.Equal(10, store.GetInventory(Product.Shampoo));}private Store CreateStoreWithInventory(Product product, int quantity){Store store = new Store();store.AddInventory(product, quantity);return store;}private static Customer CreateCustomer(){return new Customer();}
}

通过将通用的初始化代码提取到私有工厂方法中,您还可以缩短测试代码,但同时保留测试过程的全部内容。 此外,只要您使测试足够通用,私有方法就不会将测试彼此耦合。 也就是说,允许测试指定如何创建固定装置。

看这行,例如:

Store store = CreateStoreWithInventory(Product.Shampoo, 10);

该测试明确表明它希望工厂方法向商店添加10单位的洗发水。 这是高度可读和可重用的。 可读性强,因为您无需检查factory方法的内部结构即可了解所创建商店的属性。 可重复使用,因为您也可以在其他测试中使用此方法。

请注意,在此特定示例中,由于布置逻辑非常简单,因此无需介绍工厂方法。 仅将其视为演示。

重复使用测试治具的规则有一个例外。 如果所有或几乎所有测试都使用固定装置,则可以在构造器中实例化固定装置。 与数据库一起使用的集成测试通常是这种情况。 所有这些测试都需要数据库连接,您可以初始化一次,然后在任何地方重用。 但是即使那样,引入基类并在该类的构造函数中而不是在单个测试类中初始化数据库连接还是有意义的。 请参见以下清单,以获取基类中常见初始化代码的示例。

public class CustomerTests : IntegrationTests
{[Fact]public void Purchase_succeeds_when_enough_inventory(){/* use _database here */}
}public abstract class IntegrationTests : IDisposable
{protected readonly Database _database;protected IntegrationTests(){_database = new Database();}public void Dispose(){_database.Dispose();}
}

请注意CustomerTests是如何保持无构造函数的。它通过继承IntegrationTests基类来访问_database实例。

3.4 命名单元测试

给你的测试取个有表现力的名字是很重要的。正确的命名可以帮助您理解测试所验证的内容以及底层系统的行为方式。

那么,应该如何命名单元测试呢?在过去的十年里,我看到并尝试了很多命名约定。其中最突出的,可能也是最没有帮助的是下面的惯例:

[MethodUnderTest]_[Scenario]_[ExpectedResult]

哪里

  • MethodUnderTest是您要测试的方法的名称。

  • 方案是测试该方法的条件。

  • ExpectedResult是您期望被测试方法在当前场景下所执行的操作。

这样做无济于事,因为它鼓励您将注意力集中在实现细节上,而不是行为上。

用简单的英语表达的简单短语效果更好:它们更具表达力,不会使您陷入僵硬的命名结构。 您可以使用简单的短语以对客户或领域专家有意义的方式描述系统行为。 为了给您提供以简单的英语标题进行测试的示例,以下是再次列出3.5中的测试:

public class CalculatorTests
{[Fact]public void Sum_of_two_numbers(){double first = 10;double second = 20;var sut = new Calculator();double result = sut.Sum(first, second);Assert.Equal(30, result);}
}

如何使用[MethodUnderTest] _ [Scenario] _ [ExpectedResult]约定重写测试的名称(Sum_of_two_numbers)? 大概是这样的:

public void Sum_TwoNumbers_ReturnsSum()

被测试的方法是Sum,方案包括两个数字,预期结果是这两个数字的总和。 这个新名称在程序员看来似乎很合逻辑,但是它真的有助于提高测试的可读性吗? 一点也不。 这是希腊人的消息。 考虑一下:为什么Sum在测试名称中出现两次? 这是什么回报短语? 款项返还到哪里? 你不知道

有人可能会争辩说,非程序员对这个名称的想法并不重要。 毕竟,单元测试是由程序员为程序员而不是领域专家编写的。 程序员擅长破译加密名称,这是他们的工作!

这是正确的,但仅在一定程度上。 隐名对所有人(无论是否为程序员)都征收认知税。 他们需要额外的大脑能力,才能弄清楚该测试可以验证的内容以及它与业务需求的关系。 这看起来似乎不多,但是随着时间的流逝,精神负担加重了。 它缓慢但肯定会增加整个测试套件的维护成本。 如果您忘记了功能的详细信息,或者尝试了解同事编写的测试,然后又返回测试,这一点尤其值得注意。 读别人的代码已经很困难了,任何帮助理解它的用途都很大。

这又是两个版本:

public void Sum_of_two_numbers()
public void Sum_TwoNumbers_ReturnsSum()

用简单的英语写的名字更容易阅读。 这是对被测行为的详尽描述。

3.4.1 单元测试命名准则

遵循以下准则来编写表达性强,易于阅读的测试名称:

  • 不要遵循严格的命名政策。 您根本无法在此类政策的狭义框中加入对复杂行为的高级描述。 允许言论自由。

  • 将测试命名为好像是向熟悉问题域的非程序员描述场景一样。 领域专家或业务分析师就是一个很好的例子。

  • 带下划线的单词分开。 这样做有助于提高可读性,尤其是在长名称中。

请注意,在命名测试类CalculatorTests时,我没有使用下划线。 通常,类的名称不是那么长,所以它们很好读,没有下划线。

另请注意,尽管在命名测试类时使用[ClassName] Tests模式,但这并不意味着测试仅限于仅验证该类。 请记住,单元测试中的单元是行为的单元,而不是类。 这个单元可以跨越一个或几个类别。 实际大小无关紧要。 不过,您必须从某个地方开始。 在[ClassName] Tests中查看该类就是这样:一个入口点,一个API,使用它可以验证行为单位。

3.4.2 示例:将测试重命名为指南

让我们以测试为例,尝试按照我刚才概述的指南逐步提高其名称。 在下面的清单中,您可以看到一个验证过去日期的交货无效的测试。 测试名称是使用严格的命名策略写的,这对测试的可读性没有帮助。

[Fact]
public void IsDeliveryValid_InvalidDate_ReturnsFalse()
{DeliveryService sut = new DeliveryService();DateTime pastDate = DateTime.Now.AddDays(-1);Delivery delivery = new Delivery{Date = pastDate};bool isValid = sut.IsDeliveryValid(delivery);Assert.False(isValid);
}

此测试检查DeliveryService是否正确地将日期不正确的交付标识为无效交付。你将如何用通俗的英语改写试题的名称?下面将是一个很好的第一次尝试

public void Delivery_with_invalid_date_should_be_considered_invalid()

请注意新版本中的两件事:

  • 现在,该名称对于非程序员来说很有意义,这意味着程序员也将更容易理解它。

  • SUT方法的名称IsDeliveryValid不再是测试名称的一部分。

第二点是用简单的英语重写测试名称的自然结果,因此很容易被忽略。 但是,这一结果很重要,可以提升为自己的准则。

以测试名称命名的测试方法在测试名称中不要包含SUT方法的名称。请记住,您不测试代码,而是测试应用程序的行为。 因此,被测方法的名称是什么都没有关系。 如前所述,SUT只是一个入口点:一种调用行为的方式。 您可以决定将被测方法重命名为IsDeliveryCorrect,这不会影响SUT的行为。 另一方面,如果您遵循原始的命名约定,则必须重命名测试。 这再次表明,定位代码而不是行为将测试与该代码的实现详细信息耦合在一起,这会对测试套件的可维护性产生负面影响。 有关此问题的更多信息,请参见第5章。该准则的唯一例外是使用实用程序代码时。 这样的代码不包含业务逻辑-它的行为只限于简单的辅助功能,因此对业务人员没有任何意义。 可以在此处使用SUT的方法名称。

但是,让我们回到示例。 新版本的测试名称是一个很好的开始,但可以进一步改进。 交货日期无效意味着什么? 从清单3.10中的测试中,我们可以看到无效日期是过去的任何日期。 这是有道理的-应该只允许您选择将来的交货日期。

因此,请具体说明一下,并以测试名称反映此知识:

public void Delivery_with_past_date_should_be_considered_invalid()

这更好,但仍然不理想。 太冗长了。 我们可以摆脱所考虑的单词而不会失去任何含义:

public void Delivery_with_past_date_should_be_invalid()

该措辞应该是另一种常见的反模式。 在本章的前面,我提到测试是关于行为单位的单个原子事实。 陈述事实时,没有希望或欲望的地方。 相应地命名测试-替换为:

public void Delivery_with_past_date_is_invalid()

最后,没有必要避免基本的英语语法。文章帮助测试阅读完美。将文章a添加到测试的名称中:

public void Delivery_with_a_past_date_is_invalid()

那就这样吧。这个最终版本是对事实的直截了当的陈述,它本身描述了被测试应用程序行为的一个方面:在这个特定的情况下,确定是否可以完成交付的方面。

3.5 重构到参数化测试

一个测试通常不足以完全描述一个行为单元。这样的单元通常由多个组件组成,每个组件都应该用它自己的测试来捕获。如果行为足够复杂,描述它的测试数量会急剧增长,并且可能变得难以管理。幸运的是,大多数单元测试框架都提供了允许您使用参数化测试对类似测试进行分组的功能(参见图3.2)。在本节中,我将首先展示由单独测试描述的每个此类行为组件,然后演示如何将这些测试分组在一起。

假设我们的投放功能以这样一种方式工作,即允许的最快投放日期是从现在开始的两天。 显然,我们仅有的一项测试还不够。 除了检查过去交货日期的测试外,我们还需要检查今天日期,明天日期和之后日期的测试。

现有测试称为Delivery_with_a_past_date_is_invalid。 我们可以再添加三个:

public void Delivery_for_today_is_invalid()
public void Delivery_for_tomorrow_is_invalid()
public void The_soonest_delivery_date_is_two_days_from_now()

但这将导致四种测试方法,它们之间唯一的区别是交付日期。

更好的方法是将这些测试分组,以减少测试代码的数量。xUnit(像大多数其他测试框架一样)有一个称为参数化测试的特性,允许您这样做。下一个清单显示了这种分组的样子。每个InlineData属性代表一个单独的关于系统的事实;它本身就是一个测试用例。

public class DeliveryServiceTests
{[InlineData(-1, false)][InlineData(0, false)][InlineData(1, false)][InlineData(2, true)][Theory]public void Can_detect_an_invalid_delivery_date(int daysFromNow,bool expected){DeliveryService sut = new DeliveryService();DateTime deliveryDate = DateTime.Now.AddDays(daysFromNow);Delivery delivery = new Delivery{Date = deliveryDate};bool isValid = sut.IsDeliveryValid(delivery);Assert.Equal(expected, isValid);}
}
提示请注意使用[Theory]属性而不是[Fact]。理论是一堆关于行为的事实。

现在,每个事实都由[InlineData]行而不是单独的测试表示。 我还对测试方法进行了更通用的重命名:它不再提及构成有效或无效日期的内容。

使用参数化测试,可以显着减少测试代码的数量,但这是有代价的。 现在很难弄清楚测试方法代表什么事实。 而且参数越多,难度就越大。 作为一种折衷,您可以将肯定的测试用例提取到其自己的测试中,并从最重要的描述性命名中受益-确定哪些区分有效和无效的交付日期,如下表所示。

public class DeliveryServiceTests
{[InlineData(-1)][InlineData(0)][InlineData(1)][Theory]public void Detects_an_invalid_delivery_date(int daysFromNow){/* ... */}[Fact]public void The_soonest_delivery_date_is_two_days_from_now(){/* ... */}
}

这种方法还简化了负测试用例,因为您可以从测试方法中删除预期的布尔参数。当然,您也可以将正测试方法转换为参数化测试,以测试多个日期。

正如您所看到的,在测试代码的数量和代码的可读性之间存在一种权衡。经验法则是,只有当输入参数表明用例代表什么时,才能将阳性和阴性测试用例放在一个方法中。否则,提取阳性测试用例。如果行为过于复杂,就完全不要使用参数化测试。用它自己的测试方法来表示每个否定的和肯定的测试用例。

3.5.1为参数化测试生成数据

在使用参数化测试(至少在。net中)时,需要注意一些注意事项。注意,在清单3.11中,我使用daysFromNow参数作为测试方法的输入。你可能会问,为什么不是实际的日期和时间呢?不幸的是,下面的代码不能工作:

[InlineData(DateTime.Now.AddDays(-1), false)]
[InlineData(DateTime.Now, false)]
[InlineData(DateTime.Now.AddDays(1), false)]
[InlineData(DateTime.Now.AddDays(2), true)]
[Theory]
public void Can_detect_an_invalid_delivery_date(DateTime deliveryDate,bool expected)
{DeliveryService sut = new DeliveryService();Delivery delivery = new Delivery{Date = deliveryDate};bool isValid = sut.IsDeliveryValid(delivery);Assert.Equal(expected, isValid);
}

在c#中,所有属性的内容都是在编译时计算的。你必须只使用编译器可以理解的值,如下:

  • 常量

  • 文字

  • typeof()表达式

调用DateTime。现在依赖于。net运行时,因此是不允许的。

有一个办法可以解决这个问题。xUnit还有另一个特性,您可以使用它来生成定制数据,并将这些数据提供给测试方法:[MemberData]。下一个清单展示了我们如何使用这个特性重写前面的测试。

[Theory]
[MemberData(nameof(Data))]
public void Can_detect_an_invalid_delivery_date(DateTime deliveryDate,bool expected)
{/* ... */
}public static List<object[]> Data()
{return new List<object[]>{new object[] { DateTime.Now.AddDays(-1), false },new object[] { DateTime.Now, false },new object[] { DateTime.Now.AddDays(1), false },new object[] { DateTime.Now.AddDays(2), true }};
}

MemberData接受生成输入数据集合的静态方法的名称(编译器将(data)的名称转换为“data”字面量)。集合的每个元素本身都是一个集合,它被映射到两个输入参数:deliveryDate和expected。有了这个特性,您可以克服编译器的限制,并在参数化测试中使用任何类型的参数。

3.6 使用断言库进一步提高测试的可读性

为了提高测试的可读性,还可以使用断言库。我个人更喜欢流畅断言(https://fluentassertions.com),但是。net在这个领域有几个相互竞争的库。

使用断言库的主要好处是如何重构断言,使它们更易于阅读。这是我们早期的一个测试:

[Fact]
public void Sum_of_two_numbers()
{var sut = new Calculator();double result = sut.Sum(10, 20);Assert.Equal(30, result);
}

现在将其与下面使用流畅断言的方法进行比较:

[Fact]
public void Sum_of_two_numbers()
{var sut = new Calculator();double result = sut.Sum(10, 20);result.Should().Be(30);
}

第二个测试中的断言使用简单的英语,这正是您希望所有代码都读取的方式。 我们作为人类更喜欢以故事的形式吸收信息。 所有故事都遵循以下特定模式:

[Subject] [action] [object].

比如:

Bob opened the door.

在这里,鲍勃是一个主体,打开是一个动作,门是一个物体。 相同的规则适用于代码。 result.Should()。Be(30)的阅读要比Assert.Equal(30,result)更好,因为它遵循故事模式。 这是一个简单的故事,其中结果是一个主题,应该是一个动作,而30是一个对象。

注意面向对象编程(OOP)的范例已成为成功,部分原因是这种可读性。 借助OOP,您也可以以类似于故事的方式来构造代码。

Fluent Assertions库还提供了许多帮助器方法来针对数字,字符串,集合,日期和时间等等进行断言。 唯一的缺点是,此类库是您可能不想引入到项目中的其他依赖项(尽管它仅用于开发并且不会交付生产)。

概要

  • 所有单元测试都应遵循AAA模式:安排,执行,声明。 如果一个测试包含多个安排,操作或断言部分,则表明该测试可以一次验证多个行为单位。 如果此测试打算作为单元测试,则将其分为多个测试-每个动作一个。

  • 行为部分中的一行以上表明SUT的API有问题。 它要求客户记住始终一起执行这些操作,这有可能导致不一致。 这种不一致称为不变违规。 保护您的代码免遭潜在不变性侵犯的行为称为封装。

  • 通过在测试中命名SUT来区分SUT。 通过将“排列”,“行为”和“断言”注释放在前面或在这两个部分之间插入空白行,可以区分这三个测试部分。

  • 通过引入工厂方法来重用测试夹具的初始化代码,而不是通过将该初始化代码放入构造函数中。 这种重用有助于维持测试之间的高度去耦,并提供更好的可读性。

  • 不要使用严格的测试命名策略。 给每个测试起一个命名,就好像您是在向熟悉问题域的非程序员描述该场景一样。 测试名称中的单词用下划线隔开,并且测试名称中不要包含被测试方法的名称。

  • 参数化测试有助于减少类似测试所需的代码量。 缺点是随着您使测试名称更通用,测试名称的可读性降低。

  • 断言库通过重新构造断言中的单词顺序,使它们读起来像普通的英语,可以帮助您进一步提高测试的可读性

单元测试 chapter3相关推荐

  1. springboot项目使用junit4进行单元测试,maven项目使用junit4进行单元测试

    首先,maven项目中引入依赖 <dependency><groupId>junit</groupId><artifactId>junit</ar ...

  2. 写算子单元测试Writing Unit Tests

    写算子单元测试Writing Unit Tests! 一些单元测试示例,可在tests/python/relay/test_op_level3.py中找到,用于累积总和与乘积算子. 梯度算子 梯度算子 ...

  3. 写单元测试应该注意什么

    写单元测试应该注意什么 转载于:https://www.cnblogs.com/yishenweilv/p/10899695.html

  4. Atitti mybatis的单元测试attilax总结

    Atitti mybatis的单元测试attilax总结 版本mybatis 3.2.4 /palmWin/src/main/java/com/attilax/dao/mybatisTest.java ...

  5. java 中的单元测试_浅谈Java 中的单元测试

    单元测试编写 Junit 单元测试框架 对于Java语言而言,其单元测试框架,有Junit和TestNG这两种, 下面是一个典型的JUnit测试类的结构 package com.example.dem ...

  6. android 找不到类文件,Android Studio单元测试找不到类文件!

    就是一个方法里面逻辑比较多,查数据库,循环等等.比较复杂,我想测试一下他.是没有返回值的,我想看运行完成之后看看最后里面的变量是不是对的 如果跑整个程序的话就太慢了, 编译,运行, 登陆 等等.太长了 ...

  7. java单元测试启动类配置_Springboot 单元测试简单介绍和启动所有测试类的方法

    最近一段时间都是在补之前的技术债,一直忙着写业务代码没有注重代码的质量,leader也在强求,所有要把单元测试搞起来了 我把单元测试分为两种 一个是service的单元测试,一个是controller ...

  8. JUnit单元测试依赖包构建路径错误解决办法

    JUnit单元测试依赖包构建路径错误解决办法: 选中报错的项目文件夹→右击选择属性(ALT+Enter)→java构建路径→库→添加库→JUnit→选择合适的Junit库版本.

  9. kotlin + springboot 整合redis,Redis工具类编写及单元测试

    参考自:  https://www.cnblogs.com/zeng1994/p/03303c805731afc9aa9c60dbbd32a323.html 1.maven依赖 <?xml ve ...

最新文章

  1. 【 Vivado 】输出延迟约束(Constraining Ouput Delay)
  2. Java取当前时间,深夜思考
  3. 【收藏】13个CSS3快速必备开发工具
  4. 03 Oracle分区表
  5. c语言如何创建虚拟串口,模拟串口的C语言源程序代码
  6. mysql 第二天数据_MySQL入门第二天------数据库操作
  7. java实现n选m组合数_求组合数m_n
  8. [技術]如何合併 GridView 中的多個標題
  9. iOS开发之抽屉效果
  10. asp.net Page事件处理管道
  11. 结构化和面向对象语言的区别
  12. 自动驾驶的Pipline -- 如何打造自动驾驶的数据闭环?(中)
  13. 软件体系结构风格整理
  14. 基于Javaweb的图书馆管理系统设计与实现(开题报告+论文).doc
  15. 下载代码去 pudn.com每个编程人员都需要的网站
  16. CentOS 开启端口
  17. 今天考了关于java认证的OCJP,特此谈谈个人java学习过程及心得
  18. d435i 深度相机运行踩坑大合集
  19. matlab 纵向的虚线,纵向减速标记符号中间是虚线可以变道吗
  20. c语言见习报告,专业见习报告(汉语言文学)

热门文章

  1. python2打开文件_Python 基础 -2.2 文件操作
  2. 关于我发表了TalentOrg的面试文章而被官方的人找上门
  3. 获取MAC端当前系统语言
  4. Python【爬虫实战】爬取美女壁纸资源
  5. android7源码结构分析
  6. tomcat9下载与安装(免安装版)
  7. 每日一思(2022.5.6)——非理性行为
  8. nodejs单个暴露,批量暴露
  9. 小世界网络中的SIRS传染病模型实现
  10. 被关注的独山县:400亿数字背后是什么? | Alfred数据室