NUnit 进行单元测试的一般方法

参考

0x00

这里是参考文献中的单元测试的规则,后面我们会使用可执行代码来替换这些示例代码,来做更进一步的说明。

为什么?

与功能测试相比,进行单元测试成本更低,耗费时间更短,完全由程序进行。

可以防止回归缺陷,修改缺陷引入新的缺陷。

验证给定输入下方法的预期输出(需要一套命名正确易懂的单元测试)。

减少代码耦合,紧密耦合的代码很难进行单元测试。测试驱动开发会自然而然地解耦代码。

什么是好的单元测试?

快速,大量单元测试,毫秒级。

独立,单元测试可以单独运行,不依赖文件系统或数据库等外部因素。

可重复,单元测试的结果应该保持一致,总是返回相同的结果。

自检查,在没有人工交互的情况下,自动检查是否通过测试。

及时,与需要测试的代码相比,不应该在测试代码上花费过多不必要的时间(是否代码难以测试)。

术语?

Fake:是一个通用术语,可以用来描述 Mock 或 Stub 对象,具体是描述哪一种对象取决于上下文。

Mock:是系统中的 fake 对象,用来确定单元测试是否通过。

Stub:是系统中现有依赖项的可控制代替项。通过使用 Stub,可以在无需使用依赖项的情况下直接测试代码。

为了进行单元测试,我们将定义了一个 FakeOrder 类,此类只实现 Order 必须的接口即可。简单来说,Mock 是会用来进行断言的 Fake,Stub 是不进行断言的 Fake。

1
2
3
4
5
6
var stubOrder = new FakeOrder();
var purchase = new Purchase(stubOrder);

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

在此示例中,FakeOrder 类是 Purchase 类的依赖项,需要满足 Purchase 类的构造函数的要求,作为参数传递并创建 purchase 对象,最终,通过 purchase 来确定是否通过单元测试,所以这里是将 FakeOrder 用作 Stub。

1
2
3
4
5
6
var mockOrder = new FakeOrder()
var purchase = new Purchase(mockOrder);

purchase.ValidateOrders();

Assert.True(mockOrder.Validated);

此例中需要检查 FakeOrder 上的成员属性(对其进行断言),因此 FakeOrder 是 Mock。

命名?

良好的测试名称应该包含以下三个部分:

  • 要测试的方法名称
  • 测试的方案
  • 调用方案时的预期行为

这样的命名方式可以清楚的表达测试的意图,不仅确保代码有效,还可以提供文档。这样的命名可以在不查看代码的情况下就推断出代码的行为。在测试失败时,也可以马上知道哪些方案不符合预期。

例如:

1
2
3
4
5
6
7
8
9
/*
*public void Test_Single() // bad name
*/
public void Add_SingleNumber_ReturnsSameNumber() // functionName_TestMethod_ExpectedBehaviour
{
var stringCalculator = new StringCalculator();
var actual = stringCalculator.Add("0");
Assert.Equal(0, actual);
}

测试用例的写法?

三步走:Arrange、Act、Assert

Arrange:准备对象,根据需要进行创建和必要的设置
Act:作用于对象,产生一些结果
Assert:断言某些结果是否符合预期

这样做可以提高可读性,这些彼此分离的操作,可以明确突出调用代码所需的依赖项,调用方式,断言的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void Add_EmptyString_ReturnsZero()
{
//Arrange
var stringCalculator = new StringCalculator();

//Act
var actual = stringCalculator.Add("");

//Assert
Assert.Equal(0, actual)

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

最精简的输入进行测试?

使用最简单的输入进行测试,使测试在未来修改代码时更具弹性,测试的行为也表现得更像“测试”。同时,包含信息过多容易导致测试意图不明确。

比如上面的例子,将“0”值修改为更加复杂的输入,便不是最精简的输入。

避免魔幻字符串?

避免使用意义不明的常量值,将其赋值给意图明确的常量。

1
2
3
4
5
6
7
8
9
10
public void Add_BigNumber_ThrowsException()
{
var stringCalculator = new StringCalculator();

const string MAXIMUM_RESULT = "1001";
Action actual = () => stringCalculator.Add(MAXIMUM_RESULT);
//Action actual = () => stringCalculator.Add("1001");

Assert.Throws<OverflowException>(actual);
}

测试中避免逻辑代码?

编写单元测试时,应该避免逻辑条件。降低测试代码中出现缺陷的可能,如果一定要在测试中使用逻辑,更好的方法是将测试拆分成几个。

无法被信任的测试不会带来任何价值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[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;
}

}
1
2
3
4
5
6
7
8
9
10
11
12
[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);
}

Helper,而不是 Setup,Teardown?

测试时如果需要类似的对象或者状态,最好使用 helper 方法而不是使用 Setup 和 Teardown 属性。(在一套单元测试中使用相同的 Setup 看似有用,但会强制使每个测试使用相同的条件。)

首先是可读性,在每个测试中看到全部代码,更有利于理解代码。同时,也可以降低在测试之间共享状态的可能性。

共享相同的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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);
}

使用 helper 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
var stringCalculator = CreateDefaultStringCalculator();

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

Assert.Equal(1, actual);
}

// more tests...

private StringCalculator CreateDefaultStringCalculator()
{
return new StringCalculator();
}

避免在测试中使用多个断言?

在一个断言失败后,将不会进行其他断言,因此每个测试只包含一个断言。依然是可读性,当测试失败,从多个断言中寻找失败原因不是进行单元测试的初衷。

此规则的一个常见例外是对对象进行断言。 在这种情况下,通常可以对每个属性进行多次断言,以确保对象处于所预期的状态。

1
2
3
4
5
6
[Fact]
public void Add_EdgeCases_ThrowsArgumentExceptions()
{
Assert.Throws<ArgumentException>(() => stringCalculator.Add(null));
Assert.Throws<ArgumentException>(() => stringCalculator.Add("a"));
}

更好的实现:

1
2
3
4
5
6
7
8
9
10
11
[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);
}

通过单元测试公共方法验证专有方法?

在大多数情况下,我们不需要测试专用方法。

专用方法是实现细节。可以认为:专用方法永远不会孤立存在。

在某些时候,存在调用专用方法作为其实现的一部分的面向公共的方法。你应关心的是调用到专用方法的公共方法的最终结果。

请考虑下列情形

1
2
3
4
5
6
7
8
9
10
public string ParseLogLine(string input)
{
var sanitizedInput = TrimInput(input);
return sanitizedInput;
}

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

你的第一反应可能是为 TrimInput 编写测试,因为想要确保该方法按预期工作。但是,ParseLogLine 完全有可能以一种你所不期望的方式操纵 sanitizedInput,使得对 TrimInput 的测试变得毫无用处。

真正的测试应该针对面向公共的方法 ParseLogLine 进行,因为这是你最终应该关心的。

1
2
3
4
5
6
7
8
public void ParseLogLine_ByDefault_ReturnsTrimmedResult()
{
var parser = new Parser();

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

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

由此,如果看到一个专用方法,可以找到公共方法并针对该方法编写测试。 不能仅仅因为专用方法返回预期结果就认为最终调用专用方法的系统正确地使用结果。

Stub 静态引用?

单元测试的原则之一是必须完全控制待测试系统。当生产代码中包含对静态引用的调用时,就可能存在一些问题。

1
2
3
4
5
6
7
8
9
10
11
public int GetDiscountedPrice(int price)
{
if (DateTime.Now.DayOfWeek == DayOfWeek.Tuesday)
{
return price / 2;
}
else
{
return price;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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);
}

如果直接对其进行测试,很容易写出测试结果不可重复的代码,周二运行的结果,和其他时候运行的结果不同。

我们可以在接口中包装需要控制的代码,让生产代码依赖这个接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public interface IDateTimeProvider
{
DayOfWeek DayOfWeek();
}

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

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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);
}

0xff

理解规则!