【译】单元测试最佳实践

时间:2021-11-28 23:34:59

原文地址: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#编码的良好规范,但这些规范也可用于其它语言。现在正在翻译这本书,点此查看译文