【译】单元测试最佳实践

简介: 原文地址:Unit testing best practicesPS:本文未翻译原文的全部内容,以下为译文。 编写单元测试有如下好处: 利于回归测试 提供文档 改进代码设计 但是,难以阅读和维护的测试代码则会适得其反。

原文地址:Unit testing best practices
PS:本文未翻译原文的全部内容,以下为译文。


编写单元测试有如下好处:

  • 利于回归测试
  • 提供文档
  • 改进代码设计

但是,难以阅读和维护的测试代码则会适得其反。本文会提供一些编写单元测试的最佳实践以使得你的测试代码易于维护和理解。


为什么要写单元测试?

1. 花更少的时间进行功能测试

功能测试成本相对较高,因为经常需要打开应用并执行一系列操作以验证结果是否符合预期。测试步骤所涉及领域未必是测试人员所熟知,导致需要其他人协助进行测试。对于细微变化,测试可能需几秒钟,亦或几分钟来测试较大的变更。最后,对于系统中的每处修改都需要进行重复测试。

反观单元测试,仅需毫秒级别且无需对系统自身了解过多。单元测试通过与否取决于测试运行器(test runner),而不是某个人。

2. 避免回归测试

回归缺陷是在对应用程序进行更改时引入的缺陷。测试人员不仅要测试他们的新特性,还要测试以前存在的特性,以验证之前实现的特性是否仍然像预期的那样运行。

通过单元测试,可以在每次构建之后,即便是只修改了一行代码,重新运行整个测试流程,以确保新代码不会破坏已有功能。

3. 可执行的文档

有时对于特定的参数,方法的预期输出难以确定。你或许会问,如果向方法中传入空字符串或者null会发生什么?

当编写具有良好命名的测试用例时,每个用例可以清晰的说明对于给定的输入会有怎样的输出。此外,测试用例还应可以验证方法是否能够正常工作。

4. 低耦合代码

编写单元测试可以降低代码耦合度,因为高耦合的代码将会使得单元测试变得困难重重。


良好的单元测试应具备以下特征

  • 快速
    对于大型成熟项目可能会有数千个测试用例。每个测试用例应尽可能快的运行,最好在毫秒级别。
  • 隔离
    单元测试是独立的,可以单独运行而不依赖外部元素,如文件系统或数据库。
  • 可重复
    在不改变输入的情况下,单元测试的输出结果应保持不变。
  • 自检查
    单元测试应自动检测测试是否通过而无需人工干预。
  • 耗时少
    如果测试代码所花费的时间远超编写代码的时间,应当考虑重构代码以便于更好测试。即,确保编写测试所花费的

最佳实践

命名

测试用例命名应包含以下几部分:

  • 待测试方法的名称
  • 测试场景
  • 预期结果

为什么这么做

良好的命名可以表达测试意图 。测试不仅仅是用来检测代码是否可以正常工作,还可以提供方法的文档说明。仅仅看一组测试用例,你应该可以推断出代码的行为而无需查看代码。此外,当测试失败时,应该可以清楚的知道哪些场景不符合预期。

Bad:

[Fact]
public void Test_Single()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

Better

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

编排你的测试代码(Arranging your tests)

整理(Arrange)、执行、断言是单元测试的通用模式,主要包含以下三个步骤:

  • 创建符合测试条件的对象
  • 在对象上执行操作(行为)
  • 断言行为结果是否符合预期

为什么这么做

  • 测试步骤清晰
  • 避免断言与行为代码耦合在一起

可读性是编写测试代码时的一个重要指标。清晰明了的测试步骤可以清楚标明被测代码的依赖项,及如何调用被测代码,和行为预期结果。与其合并测试步骤以减少代码量,不如保持测试代码具有良好的可读性。

Bad

[Fact]
public void Add_EmptyString_ReturnsZero()
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Assert
    Assert.Equal(0, stringCalculator.Add(""));
}

Better:

[Fact]
public void Add_EmptyString_ReturnsZero()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("");

    Assert.Equal(0, actual);
}

单元测试粒度尽可能细(Write minimally passing tests)

单元测试的输入应尽可能简单以便验证当前测试行为。

为什么这么做

  • 测试用例可以灵活的应对被测代码的变更
  • 更接近于测试代码行为而非实现细节

测试用例中包含过多信息会增加测试出错的概率以及使得测试用例的意图不那么明显。测试代码的关注点是行为,给模型设置额外的属性或者使用非零值是非必需的。

Bad

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("42");

    Assert.Equal(42, actual);
}

Better

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

避免使用魔法字符串(magic strings)

单元测试中的变量命名和生成代码中的变量命名同等重要,它们不应包含魔法字符串。

为什么这么做

  • 不要让阅读测试代码的人对某个特殊值产生疑惑而不得不去阅读生产代码
  • 显式的表明你要证明的东西

魔法字符串会让阅读测试代码的人产生疑问,某个特定值到底表示什么意思。这会导致他们去阅读代码的具体实现细节而非关注测试本身。尽可能使用常量或枚举来代替字面量。

Bad

[Fact]
public void Add_BigNumber_ThrowsException()
{
    var stringCalculator = new StringCalculator();

    Action actual = () => stringCalculator.Add("1001");

    Assert.Throws<OverflowException>(actual);
}

Better

[Fact]
void Add_MaximumSumResult_ThrowsOverflowException()
{
    var stringCalculator = new StringCalculator();
    const string MAXIMUM_RESULT = "1001";

    Action actual = () => stringCalculator.Add(MAXIMUM_RESULT);

    Assert.Throws<OverflowException>(actual);
}

测试用例中不要包含逻辑判断

避免在测试代码中进行手动字符串拼接和使用逻辑条件,如:if,while,for,switch等等。

为什么这么做

  • 避免在测试用例中引入BUG
  • 关注测试结果而不是实现细节

在测试用引入逻辑判断会增加测试出错的概率。你应当充分信任自己的测试用例,当测试失败时就应该判定被测试代码有错误,这是不容忽视的(不应因为有逻辑分支到而至某些方面未测试到)。

如果一个测试用例中无法避免使用逻辑分支,那么可以考虑将用例拆分为多个。

Bad

[Fact]
public void Add_MultipleNumbers_ReturnsCorrectResults()
{
    var stringCalculator = new StringCalculator();
    var expected = 0;
    var testCases = new[]
    {
        "0,0,0",
        "0,1,2",
        "1,2,3"
    };

    foreach (var test in testCases)
    {
        Assert.Equal(expected, stringCalculator.Add(test));
        expected += 3;
    }

}

Better

[Theory]
[InlineData("0,0,0", 0)]
[InlineData("0,1,2", 3)]
[InlineData("1,2,3", 6)]
public void Add_MultipleNumbers_ReturnsSumOfNumbers(string input, int expected)
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add(input);

    Assert.Equal(expected, actual);
}

使用帮助方法来构建和销毁测试依赖项

如果你的多个测试用例需要相似的对象或者状态,请使用帮助方法而不是SetupTeardown特性来获取它们。

为什么这么做

  • 是测试代码清晰易读
  • 避免在测试用例中创建不必要(或少创建)对象或状态
  • 避免在不同的测试用例中共享状态以降低测试用例间的相互依赖

在单元测试框架中,Setup方法在所有测试用例运行前被调用。这让Setup方法看起来很有用(如初始化一些测试依赖项),但很有可能导致测试代码难以阅读。不同的测试用例需要不同的测试条件,但Setup强制不同的测试用例使用相同的测试条件。

xUnit框架在2.0+版本已经移出了SetUpTearDown方法。

Bad

private readonly StringCalculator stringCalculator;
public StringCalculatorTests()
{
    stringCalculator = new StringCalculator();
}
// more tests...
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var result = stringCalculator.Add("0,1");

    Assert.Equal(1, result);
}

Better

[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var stringCalculator = CreateDefaultStringCalcualtor();

    var actual = stringCalculator.Add("0,1");

    Assert.Equal(1, actual);
}
// more tests...
private StringCalculator CreateDefaultStringCalcualtor()
{
    return new StringCalculator();
}

避免在同一个测试用例中使用多个断言

一个测试中应只使用一个断言。通用的只使用一个断言的方法包括:

  • 为每个断言编写一个测试
  • 使用参数化的测试

为什么这么做

  • 如果有多个断言,一个断言失败,剩余的断言也不会被计算
  • 确保在一个测试不对多种场景做断言
  • 可以清晰明了的知道测试失败的原因

一种例外情况是,对一个对象进行断言。在这种场景下可以使用多个断言来判断对象的不同属性值是否符合预期。

Bad

[Fact]
public void Add_EdgeCases_ThrowsArgumentExceptions()
{
    Assert.Throws<ArgumentException>(() => stringCalculator.Add(null));
    Assert.Throws<ArgumentException>(() => stringCalculator.Add("a"));
}

Better

[Theory]
[InlineData(null)]
[InlineData("a")]
public void Add_InputNullOrAlphabetic_ThrowsArgumentException(string input)
{
    var stringCalculator = new StringCalculator();

    Action actual = () => stringCalculator.Add(input);

    Assert.Throws<ArgumentException>(actual);
}

通过测试公共方法来验证私有方法

在多数情况下,无需对私有方法进行测试。私有方法属于实现细节,它从来都不是孤立存在的(要不也没存在的必要)。通常,公共方法会调用私有方法,因此我们可以通过对共有方法的测试来验证私有方法是否符合我们的预期。

public string ParseLogLine(string input)
{
    var sanitizedInput = TrimInput(input);
    return sanitizedInput;
}

private string TrimInput(string input)
{
    return input.Trim();
}

对于上述代码,或许会有人想直接对TrimInput方法进行测试以确保该方法可以正常工作。然而,ParseLogLine方法可能会以某种意料之外的方式调用TrimInput方法而导致整个运行结果有误。

正确的测试方式是面向公共方法ParseLogLine,确保该方法能够正常工作才是我们最终要关心的。一个私有方法返回了正确的结果并不能保证调用者能够正确的使用这个结果。

public void ParseLogLine_ByDefault_ReturnsTrimmedResult()
{
    var parser = new Parser();

    var result = parser.ParseLogLine(" a ");

    Assert.Equals("a", result);
}

存根静态引用

测试的原则之一是要完全控制测试所依赖的外部条件。这对于含有静态引用的生产代码而言会有些困难。

public int GetDiscountedPrice(int price)
{
    if(DateTime.Now == DayOfWeek.Tuesday) 
    {
        return price / 2;
    }
    else 
    {
        return price;
    }
}

对于上述代码你可能会编写如下测试代码:

public void GetDiscountedPrice_ByDefault_ReturnsFullPrice()
{
    var priceCalculator = new PriceCalculator();

    var actual = priceCalculator.GetDiscountedPrice(2);

    Assert.Equals(2, actual)
}

public void GetDiscountedPrice_OnTuesday_ReturnsHalfPrice()
{
    var priceCalculator = new PriceCalculator();

    var actual = priceCalculator.GetDiscountedPrice(2);

    Assert.Equals(1, actual);
}

但,你很快会意识到这里有两个问题:

  • 如果是在周二(Tuesday)运行测试代码,第二个测试会通过而第一个会失败
  • 如果测试是在其它日期运行,那么第一个测试会通过而第二个则会失败

为了解决上述问题,需要在生产代码中开一个口子。一种方法是使用接口,让生产代码依赖于接口。

public interface IDateTimeProvider
{
    DayOfWeek DayOfWeek();
}

public bool GetDiscountedPrice(int price, IDateTimeProvider dateTimeProvider)
{
    if(dateTimeProvider.DayOfWeek() == DayOfWeek.Tuesday) 
    {
        return price / 2;
    }
    else 
    {
        return price;
    }
}

现在测试场景变成了:

public void GetDiscountedPrice_ByDefault_ReturnsFullPrice()
{
    var priceCalculator = new PriceCalculator();
    var dateTimeProviderStub = new Mock<IDateTimeProvider>();
    dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Monday);

    var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub);

    Assert.Equals(2, actual);
}

public void GetDiscountedPrice_OnTuesday_ReturnsHalfPrice()
{
    var priceCalculator = new PriceCalculator();
    var dateTimeProviderStub = new Mock<IDateTimeProvider>();
    dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Tuesday);

    var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub);

    Assert.Equals(1, actual);
}

现在,我们可以在测试中模拟任意的日期值了(完全控制)。


小结

本文根据自己的理解进行翻译,部分内容与原文会有出入。

单元测试关注行为是否符合预期而不是具体实现细节,这也是面向对象的特征体现。

上述一些最佳实践不仅仅可以用于测试代码,也可以用于其他方面代码的编写,如:确保代码具有良好的可读性、方法或变量要有良好的命名、方法要职责单一(高内聚)等等。

推荐阅读

“函数是一等公民”背后的含义

书籍推荐

《Clean C#》这本书讲述了一些C#编码的良好规范,但这些规范也可用于其它语言。

目录
相关文章
|
6月前
|
Java 测试技术 开发者
Java单元测试与集成测试:确保代码质量的最佳实践
【4月更文挑战第2天】在软件开发中,单元测试验证单个代码单元(如Java类或方法)的功能,确保其正确性;而集成测试则关注多个组件协作时的交互。JUnit是常见的Java单元测试框架,集成测试则检验组件间接口的兼容性。Spring框架提供了集成测试的支持。遵循良好编码习惯,编写可测试代码,设计全面的测试用例,是保证代码质量和稳定性的关键。
148 0
|
4月前
|
机器学习/深度学习 监控 算法
自动化测试框架的演进与最佳实践
随着软件行业的迅猛发展,自动化测试已成为确保软件质量的关键手段。本文将深入探讨自动化测试框架的历史演进、当前趋势以及面临的挑战,并结合实际案例分析,提出一系列的最佳实践策略,旨在帮助读者构建更加高效、稳定的自动化测试体系。
|
13天前
|
安全 Java 测试技术
最佳实践:通义灵码生成单元测试,让单测更简单
本文首先讲述了什么是单元测试、单元测试的价值、一个好的单元测试所具备的原则,进而引入如何去编写一个好的单元测试,通义灵码是如何快速生成单元测试的。
|
2月前
|
设计模式 SQL 安全
PHP中的设计模式:单例模式的深入探索与实践在PHP的编程实践中,设计模式是解决常见软件设计问题的最佳实践。单例模式作为设计模式中的一种,确保一个类只有一个实例,并提供全局访问点,广泛应用于配置管理、日志记录和测试框架等场景。本文将深入探讨单例模式的原理、实现方式及其在PHP中的应用,帮助开发者更好地理解和运用这一设计模式。
在PHP开发中,单例模式通过确保类仅有一个实例并提供一个全局访问点,有效管理和访问共享资源。本文详细介绍了单例模式的概念、PHP实现方式及应用场景,并通过具体代码示例展示如何在PHP中实现单例模式以及如何在实际项目中正确使用它来优化代码结构和性能。
37 2
|
2月前
|
监控 安全 测试技术
提升软件测试效率:探索持续集成的最佳实践
在现代软件开发过程中,持续集成(CI)已成为提高软件质量和测试效率的关键实践。通过将代码库的每次提交自动构建和测试,CI 帮助团队及时发现问题,减少手动测试的负担。本文探讨了如何有效实施持续集成来优化软件测试流程,并提供了一些实用的策略和工具建议,以帮助开发团队更高效地运作。
66 2
|
3月前
|
测试技术 持续交付 开发者
Xamarin 高效移动应用测试最佳实践大揭秘,从框架选择到持续集成,让你的应用质量无敌!
【8月更文挑战第31天】竞争激烈的移动应用市场,Xamarin 作为一款优秀的跨平台开发工具,提供了包括单元测试、集成测试及 UI 测试在内的全面测试方案。借助 Xamarin.UITest 框架,开发者能便捷地用 C# 编写测试案例,如登录功能测试;通过 Xamarin 模拟框架,则可在无需真实设备的情况下模拟各种环境测试应用表现;Xamarin.TestCloud 则支持在真实设备上执行自动化测试,确保应用兼容性。结合持续集成与部署策略,进一步提升测试效率与应用质量。掌握 Xamarin 的测试最佳实践,对确保应用稳定性和优化用户体验至关重要。
56 0
|
3月前
|
测试技术
软件测试的艺术与科学:探索自动化测试的最佳实践
【8月更文挑战第31天】在软件开发的海洋中,测试是确保航船稳健前行的罗盘。本文将带你揭开软件测试的神秘面纱,深入探讨自动化测试的魅力和挑战。我们将一起航行于代码的波浪之中,学习如何构建稳固的测试框架,以及如何利用这些框架来捕捉那些潜藏在深处的缺陷。加入我们,让我们一起提升软件质量,确保每一次航行都能抵达成功的彼岸。
|
4月前
|
测试技术 C#
.NET单元测试使用Bogus或AutoFixture按需填充的几种方式和最佳实践
【7月更文挑战第13天】AutoFixture 和 Bogus 都是流行的 C#库,用于在单元测试中按需填充测试数据。以下是它们的几种使用方式和最佳实践:一、AutoFixture:1.直接定制 2.使用匿名函数 3.实现ICustomization接口 4.使用Build方法。 二、最佳实践Bogus:1.安装2.使用。
|
5月前
|
Java 测试技术 数据库
Java单元测试与集成测试的最佳实践
Java单元测试与集成测试的最佳实践
|
5月前
|
前端开发 JavaScript 测试技术
Jest与React Testing Library:前端测试的最佳实践
Jest和React Testing Library是React应用测试的核心工具。安装相关依赖后,在`jest.config.js`中配置Jest。测试时,编写描述性测试用例,使用`render`、`fireEvent`和`screen`来检查组件行为。Jest提供模拟功能,如模拟API调用。测试组件交互性时,模拟用户行为并验证状态变化。确保覆盖边缘情况,使用代码覆盖率报告评估测试完整性,并将测试集成到CI流程中。
79 1