.NetCore 单元测试之最佳实践

原文转自: https://docs.microsoft.com/en-us/dotnet/core/testing/unit-testing-best-practices?view=aspnetcore-2.1

使用单元测试有太多的好处, 它可以帮助我们回归测试,文档结构化,促进更好的设计等。然而,那些不合适或者不易于阅读的单元测试会对代码本身造成巨大的破坏。
本片中,会介绍一些单元测试的最佳实践,特别是让你的代码富有弹性并且易于阅读。

目录

为什么需要单元测试

好的单元测试的特点

让我们说同一种语言

最佳实践

为测试起一个合适的名字

单元测试构建

最低限度通过测试

避免无显示引用的字符串常量

在测试中避免使用逻辑语句

正确的使用helper方法

避免出现多次断言

通过对public方法实现对private方法的验证

Stub 静态引用


 

为什么需要单元测试

 

高性能方法测试
方法测试有时候十分昂贵,典型的情况:我们经常需要引用一系列对外开放的方法,然后使用让这些方法按照一定的顺序来呈现程序的合法性。测试者往往不会完全了解这些方法的真切意义,这时候为了展开测试,测试者不得不向更有经验的人请教。有时候测试一些小改动花费几秒钟,有时候测试巨大改动时得花费好几分钟。最终这些测试步骤会在系统中被重复的进行。


单元测试,另一方面,当测试这个动作实行的时候,仅仅是测试者点击“测试”按钮,仅仅耗时几毫秒,这个动作不需要了解太多系统相关的东西。 测试通过或者失败,不是孤立的情况,而是不管是谁每次执行特定的测试的时候,只会得出统一的结果。


回归测试与已有功能保护
回归测试的弊端体现在, 当在程序中做出了修改,正常情况下,测试人员不仅仅需要测试新的功能,并且需要测试在这个功能之前的已经存在的功能,如此确保新的改动没有影响之前已有的功能。


当引入单元测试,每当程序做出改动,即使仅仅改动了一行代码,只要可以通过进行整组的单元测试,就可以给予你极大的信心确保你的更改没有破坏之前已存在的功能。

可执行的“文档“
一些情况可能我们觉得并不是很明显:某个特定的方法是做什么?某个特定的输入会产生怎样的行为? 你也可能会问自己,当这个方法传入一个空字符串或者Null值的时候会怎样?


当一组单元测试中所有的case 的名称并且都能很清晰的表达出预期的输入输出,并且能够能够测试通过,那么这一组单元测试的命名就很成功


低耦合的代码
当代码的耦合度很高时,便很难进行单元测试。 甚至当你不为你的代码创建单元测试时,耦合看起来不是那么明显
创建单元测试会自然而然地为你写的代码解耦,否则单元测试会难以进行。
 

好的单元测试的特点


快速
一个成熟的项目拥有上千个单元测试并非奇事,重要的是每一个测试都能最快速的运行,最好以毫秒计时。
分离
每个单元测试都应可以独立运行,并且每个单元测试都不应该有外部的依赖因素,如文件系统,数据库等
可重复
当没有更改测试或者代码,每一次执行测试的结果都应该是一致的。
自我检查
测试应该在没有任何人为交互的情况下自动的检查成功与失败,
时限性
一个单元测试执行的时间不应该长于修改代码所耗的时间,如果这种不对程太过明显,就需要考虑重构代码使其更加具有测试性

 

让我们说同一种语言


当我们说测试的时候,很不幸’Mock’是一个经常被勿用甚至滥用的词汇. 下面的这段定义是通常在单元测试时’fake’类型的表述:


Fake - A fake is a generic term which can be used to describe either a stub or a mock object. Whether it is a stub or a mock depends on the context in which it's used. So in other words, a fake can be a stub or a mock.
Fake 通常是用来描述的stub或者mock对象,具体是那种需取决于上下文中的实际用意。 Fake可以是stub也可以是mock


Mock - A mock object is a fake object in the system that decides whether or not a unit test has passed or failed. A mock starts out as a Fake until it is asserted against.
Mock对象是系统用来决定测试是否通过的一个Fake对象,只有当Fake对象作为断言的指示时才显示成为Mock对象


Stub - A stub is a controllable replacement for an existing dependency (or collaborator) in the system. By using a stub, you can test your code without dealing with the dependency directly. By default, a fake starts out as a stub.
Stub是测试中的参与者,协作者,或者测试中已存在的依赖对象的可控替代。使用stub可以避免使测试代码和外部依赖产生直接联系,默认情况下(在没有确定是否为Mock对象时),一个fake对象可以认为是Stub对象。

请考虑一下测试代码片段

var mockOrder = new MockOrder();
var purchase = new Purchase(mockOrder);
purchase.ValidateOrders();
Assert.True(purchase.CanBeShipped);

这就是stub对象被当成mock对象使用的一个例子,Order只是作为purchase初始化而被创建的,而真正测试的对象是purchase。所以Order被命名为’MockOrder’ 是不确切的,容易产生歧义。

相应的,上面的代码可以修改为:

var stubOrder = new FakeOrder();
var purchase = new Purchase(stubOrder);
purchase.ValidateOrders();
Assert.True(purchase.CanBeShipped);

 

将Order的类重命名为’FakeOrder’可以使得Order变得更加普适。这样FakeOrder的对象可以作为Stub也可以作为Mock. 上面的例子中FakeOrder是以stub含义存在的,并非是用来指示断言的。它的目的仅仅是创建purchase对象。
如果需要测试Order,代码相应的可以修改为:

var mockOrder = new FakeOrder();
var purchase = new Purchase(mockOrder);
purchase.ValidateOrders();
Assert.True(mockOrder.Validated);


和之前不同的是,上面的Order的属性被用作断言指示。所以它是一个Mock对象
将上文所述的专业概念搞明白是非常重要的,要不然其他测试开发人员免不了去猜测你的真实意图,

一句话概括,Mock就像是Stub, 然而只有Mock可以作为断言指示,而Stub不可以

最佳实践

 

为测试起一个合适的名字


测试的命名可以分三部分
•    测试方法的命名
•    测试用例的真实场景
•    测试用例执行后预期的结果
为什么?
在你想要确切地表达测试意图时,规范的命名就十分重要。


测试的目的不仅仅是为了保证你的代码可以工作,更重要的是使你的代码文档化。你只需要看看单元测试便能够清晰把握代码的内在构建,甚至看单元测试对代码的了解更胜过于看源代码。当测试失败时,你能够精确地知道那种场景没有达到你的预期。
 

不合适的单元测试

[Fact]
public void Test_Single()
{
    var stringCalculator = new StringCalculator();
    var actual = stringCalculator.Add("0");
    Assert.Equal(0, actual);
}

改进的测试

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();
    var actual = stringCalculator.Add("0");
    Assert.Equal(0, actual);
}

单元测试构建

构建测试元素,测试动作,断言。这是一个常见的单元测试模型。从测试的名称就可以引申出测试的三个元素:
•    构建相关必要的对象
•    测试对象的行为
•    断言行为的预期是否合法


为什么将测试分为三部
•    清晰地将测试目标(行为)与对象构建,断言区分开来
•    使得测试断言与测试目标(行为)不容易相互混淆

当你写一个单元测试时,首先要考虑的是易读性。将测试的三个部分清晰地区分开可以更加醒目的标注代码的内在结构和依赖,如:如何调用代码,断言的指示是什么。当然不这么做有可能让你的代码更加简短,但是我们写单元测试的主要目标是易于阅读。
 

不合适的测试

[Fact]
public void Add_EmptyString_ReturnsZero()
{
    // Arrange
    var stringCalculator = new StringCalculator();
    // Assert
    Assert.Equal(0, stringCalculator.Add(""));
}

改进的测试

public void Add_EmptyString_ReturnsZero()
{
    var stringCalculator = new StringCalculator();
    var actual = stringCalculator.Add("");
    Assert.Equal(0, actual);
}

最低限度通过测试

当编写单元测试时,输入应该尽可能的保持最低限度
为什么?
•    最低限度的输入能使测试在将来的改动中快速恢复
•    同具体实现保持更加紧密的联系

测试中使用过多的信息会增加测试本身出错的概率,并且会是的测试目的变得模糊。编写测试时你需要更多的关注其行为。当没有特定要求时,可以设置额外的属性或者使用非零值,总之尽可能最小化你的测试点。

改进前的测试

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();
    var actual = stringCalculator.Add("42");
    Assert.Equal(42, actual);
}

改进后的测试

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();
    var actual = stringCalculator.Add("0");
    Assert.Equal(0, actual);
}

避免无显示引用的字符串常量

对一个变量进行命同样重要,即使不重要,和生产代码不同的是,在测试代码中不应该出现这种没有命名的字符串常量。

为什么?
•    避免测试阅读者对这个特殊的常量在生产代码中的实际意义进行更深入的探究
•    更清晰地补充说明这个测试的目的而并非仅仅为了通过测试。


无显示引用的字符串常量会导致测试阅读者的迷惑,如果这个常量看起来不寻常,就更加容易让人遐想为什么会选择这个常量,它有什么内在含义,从而不得不更加深入的去了解代码的实现细节而非测试本身。


在写测试的时候,你用改尽可能更多的表述出你的测试意图。在下面的实例中就展示了,如何恰当的使用字符串常量。

改进前的测试

[Fact]
public void Add_BigNumber_ThrowsException()
{
    var stringCalculator = new StringCalculator();
    Action actual = () => stringCalculator.Add("1001");
    Assert.Throws(actual);
}

改进后的测试

[Fact]
void Add_MaximumSumResult_ThrowsOverflowException()
{
    var stringCalculator = new StringCalculator();
    const string MAXIMUM_RESULT = "1001";
    Action actual = () => stringCalculator.Add(MAXIMUM_RESULT);
    Assert.Throws(actual);
}

在测试中避免使用逻辑语句

当写单元测试的时候应避免使用显示创建字符串数组或者逻辑条件语句,如, if, while, for, switch 等等
为什么

•    更小的出现bug的概率
•    更加关注最终结果,而非细节的实现

当测试代码中引入逻辑分支时,你的测试代码中出现bug的风险也会大大增加。 测试是最后一个你能够发现具体实现代码中bug的地方。你必须对你写的测试代码具有极高的信心,不然,一个不被信任的测试不能对你产生任何价值。当测试失败时,你最需要的是一个确定的感觉,它使你一眼定位到那些在生产代码中不可忽视的bug。
 

小贴士:如果你的测试不得不需要引入逻辑语句,那么就考虑将这个测试分成两个甚至更多个不同的测试。

改进前的测试

[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;
    }
}

改进后的测试

[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方法

如果你的多个测试中使用了相同的对象或者状态,这时候可以编写一个helper方法来提供该对象,而不是使用其他便利的方式。
为什么?
•    使得在多个测试中相应的代码产生更小的混淆
•    减小初始化冗余或者避免初始化元素不完全
•    减小因隐藏依赖导致多个单元测试测试共享同一个状态的可能性


在单元测试框架中,Setup(构造方法)是在所有单元测试被执行前调用的,所以被一些人认为是可以加以充分利用的工具,这样往往最终导致整个测试变得很臃肿而且难以阅读。一般地,每一个测试都有不一样的(初始化)要求来确保一组(多个)测试可以正常工作。不幸的是,Setup(构造方法)使得这些测试具有了相同的(初始化)要求.


注: xUnit 从2.x版本后已经移除了对于Setup 和 TearDown 的功能

改进前的测试

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);
}

改进后的测试

[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();
}

避免出现多次断言

当你在写单元测试的时候,尽力使每一个测试只拥有一次断言,常见的做法有:
•    为其他的断言创建单独的单元测试
•    使用参数化测试
为什么?
•    如果第一次断言失败,那么剩下的断言将不会被检查
•    确保一个单元测试的目的单一
•    可以确保你更全面地发现测试中的错误


当引入多重断言的时候,当程序无发保证可以覆盖到所有的断言。在大多数测试框架中,当一个测试中有一个断言失败,那么整个测试就被认为是失败的。所以框架会将其他可能正常的断言误认为是失败的。


下面的例子中,为确保对象的状态是在与其状态,测试自动的接受了两个来自不同输入的断言
改进前的测试:

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

改进后的测试

[Theory]
[InlineData(null)]
[InlineData("a")]
public void Add_InputNullOrAlphabetic_ThrowsArgumentException(string input)
{
    var stringCalculator = new StringCalculator();
    Action actual = () => stringCalculator.Add(input);
    Assert.Throws(actual);
}

通过对public方法实现对private方法的验证

在大多数情况中,不应该为private方法单独去写单元测试,因为private方法是实现的细节。你可以认为private方法从来都不是独立的。在这之上,总会有一个public方法去调用这个private方法。而这个public方法是我们所要关心的。

考虑一下情况
 

public string ParseLogLine(string input)
{
    var sanitizedInput = trimInput(input);
    return sanitizedInput;
}
private string trimInput(string input)
{
    return input.Trim();
}

看到上面的实现,你的第一个想法可能是写一个针对trimInput单元测试 来首先保证这个方法是可以工作的。 然而很有可能ParseLogLine会对sanitizedInput进行不可预料的操作,所以编写一个针对trimInput的测试的作用很有限。


真正的单元测试应该是针对ParseLogLine进行的,这是我们最终要关心的一个方法。
 

public void ParseLogLine_ByDefault_ReturnsTrimmedResult()
{
    var parser = new Parser();
    var result = parser.ParseLogLine(" a ");
    Assert.Equals("a", result);
}

根据这个观点,如果找见了一个private方法的public引用,然后写了一个private方法的单元测试,仅仅保证这个private方法返回了一个预期的值并不能说明这个方法的返回值最终被正确的使用了。

Stub 静态引用
 

单元测试中一个重要的原则就是一定要对真实系统新型全面覆盖, 有一种令人头疼的情况就是当生产代码中引入了某些静态引用,如 DateTime.Now.
考虑下面的情况
 

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);
}

不幸的的是,你会很快的意识到这会有很多问题
•    如果测试在礼拜二run,第二条测试会失败
•    如果在其他时间run,第一条测试会失败
•    测试一个礼拜的某一天如何可能…?


为了解决这个问题,你需要重构代码。一种实现是将你想要控制的代码提取冰封装成一个Interface, 然后使你的生产代码引入该接口。
 

public interface IDateTimeProvider
{
    DayOfWeek DayOfWeek();
}

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

相应的单元测试可以变成

public void GetDiscountedPrice_ByDefault_ReturnsFullPrice()
{
    var priceCalculator = new PriceCalculator();
    var dateTimeProviderStub = new Mock();
    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();
    dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Tuesday);
    var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub);
    Assert.Equals(1, actual);
}

现在你的单元测试完全控制了DateTime.Now, 而且可指定任何替代的日期。

你可能感兴趣的:(UnitTest,C#,.NetCore)