目錄
1. 前言
2. 為什麼需要單元測試
2.1 防止回歸
2.2 減少代碼耦合
3. 基本原則和規範
3.1 3A原則
3.2 盡量避免直接測試私有方法
3.3 重構原則
3.4 避免多個斷言
3.5 檔案和方法命名規範
4. 常用類庫介紹
4.1 xUnit/MsTest/NUnit
4.2 Moq
4.3 AutoFixture
5. 實踐中結合Visual Studio的使用
5.1 如何在Visual Studio中運作單元測試
5.2 如何在Visual Studio中檢視單元測試覆寫率
6. 實踐中常見場景的Mock
6.1 DbSet
6.2 HttpClient
6.3 ILogger
7. 拓展
7.1 TDD介紹
1. 前言
單元測試一直都是"好處大家都知道很多,但是因為種種原因沒有實施起來"的一個老大難問題。具體是否應該落地單元測試,以及落地的程度, 每個項目都有自己的情況。
本篇為個人認為"如何更好地寫單元測試", 即更加偏向實踐向中夾雜一些理論的分享。
下列示例的單元測試架構為
xUnit
, Mock庫為
Moq
2. 為什麼需要單元測試
優點有很多, 這裡提兩點我個人認為的很明顯的好處
2.1 防止回歸
通常在進行新功能/子產品的開發或者是重構的時候,測試會進行回歸測試原有的已存在的功能,以驗證以前實作的功能是否仍能按預期運作。
使用單元測試,可在每次生成後,甚至在更改一行代碼後重新運作整套測試, 進而可以很大程度減少回歸缺陷。
2.2 減少代碼耦合
當代碼緊密耦合或者一個方法過長的時候,編寫單元測試會變得很困難。當不去做單元測試的時候,可能代碼的耦合不會給人感覺那麼明顯。為代碼編寫測試會自然地解耦代碼,變相提高代碼品質和可維護性。
3. 基本原則和規範
3.1 3A原則
3A分别是"arrange、act、assert", 分别代表一個合格的單元測試方法的三個階段
- 事先的準備
- 測試方法的實際調用
- 針對傳回值的斷言
一個單元測試方法可讀性是編寫測試時最重要的方面之一。在測試中分離這些操作會明确地突出顯示調用代碼所需的依賴項、調用代碼的方式以及嘗試斷言的内容.
是以在進行單元測試的編寫的時候, 請使用注釋标記出3A的各個階段的, 如下示例
[Fact]
public async Task VisitDataCompressExport_ShouldReturnEmptyResult_WhenFileTokenDoesNotExist()
{
// arrange
var mockFiletokenStore = new Mock<IFileTokenStore>();
mockFiletokenStore
.Setup(it => it.Get(It.IsAny<string>()))
.Returns(string.Empty);
var controller = new StatController(
mockFiletokenStore.Object,
null);
// act
var actual = await controller.VisitDataCompressExport("faketoken");
// assert
Assert.IsType<EmptyResult>(actual);
}
3.2 盡量避免直接測試私有方法
盡管私有方法可以通過反射進行直接測試,但是在大多數情況下,不需要直接測試私有的private方法, 而是通過測試公共public方法來驗證私有的private方法。
可以這樣認為:private方法永遠不會孤立存在。更應該關心的是調用private方法的public方法的最終結果。
3.3 重構原則
如果一個類/方法,有很多的外部依賴,造成單元測試的編寫困難。那麼應該考慮目前的設計和依賴項是否合理。是否有部分可以存在解耦的可能性。選擇性重構原有的方法,而不是硬着頭皮寫下去.
3.4 避免多個斷言
如果一個測試方法存在多個斷言,可能會出現某一個或幾個斷言失敗導緻整個方法失敗。這樣不能從根本上知道是了解測試失敗的原因。
是以一般有兩種解決方案
- 拆分成多個測試方法
- 使用參數化測試, 如下示例
[Theory]
[InlineData(null)]
[InlineData("a")]
public void Add_InputNullOrAlphabetic_ThrowsArgumentException(string input)
{
// arrange
var stringCalculator = new StringCalculator();
// act
Action actual = () => stringCalculator.Add(input);
// assert
Assert.Throws<ArgumentException>(actual);
}
當然如果是對對象進行斷言, 可能會對對象的多個屬性都有斷言。此為例外。
3.5 檔案和方法命名規範
檔案名規範
一般有兩種。比如針對
UserController
下方法的單元測試應該統一放在
UserControllerTest
或者
UserController_Test
下
單元測試方法名
單元測試的方法名應該具有可讀性,讓整個測試方法在不需要注釋說明的情況下可以被讀懂。格式應該類似遵守如下
<被測試方法全名>_<期望的結果>_<給予的條件>
// 例子
[Fact]
public void Add_InputNullOrAlphabetic_ThrowsArgumentException()
{
...
}
4. 常用類庫介紹
4.1 xUnit/MsTest/NUnit
編寫.Net Core的單元測試繞不過要選擇一個單元測試的架構, 三大單元測試架構中
- MsTest是微軟官方出品的一個測試架構
- NUnit沒用過
- xUnit是.Net Foundation下的一個開源項目,并且被dotnet github上很多倉庫(包括runtime)使用的單元測試架構
三大測試架構發展至今已是大差不差, 很多時候選擇隻是靠個人的喜好。
個人偏好
xUnit
簡潔的斷言
// xUnit
Assert.True()
Assert.Equal()
// MsTest
Assert.IsTrue()
Assert.AreEqual()
客觀地功能性地分析三大架構地差異可以參考如下
https://anarsolutions.com/automated-unit-testing-tools-comparison
4.2 Moq
官方倉庫
- https://github.com/moq/moq4
Moq是一個非常流行的模拟庫, 隻要有一個接口它就可以動态生成一個對象, 底層使用的是Castle的動态代理功能.
基本用法
在實際使用中可能會有如下場景
public class UserController
{
private readonly IUserService _userService;
public UserController(IUserService userService)
{
_userService = userService;
}
[HttpGet("{id}")]
public IActionResult GetUser(int id)
{
var user = _userService.GetUser(id);
if (user == null)
{
return NotFound();
}
else
{
...
}
}
}
在進行單元測試的時候, 可以使用
Moq
對
_userService.GetUser
進行模拟傳回值
[Fact]
public void GetUser_ShouldReturnNotFound_WhenCannotFoundUser()
{
// arrange
// 建立一個IUserService的mock對象
var mockUserService = new Mock<IUserService>();
// 使用moq對IUserService的GetUs方法進行mock: 當入參為233時傳回null
mockUserService
.Setup(it => it.GetUser(233))
.Return((User)null);
var controller = new UserController(mockUserService.Object);
// act
var actual = controller.GetUser(233) as NotFoundResult;
// assert
// 驗證調用過userService的GetUser方法一次,且入參為233
mockUserService.Verify(it => it.GetUser(233), Times.AtMostOnce());
}
4.3 AutoFixture
官方倉庫
- https://github.com/AutoFixture/AutoFixture
AutoFixture是一個假資料填充庫,旨在最小化3A中的
arrange
階段,使開發人員更容易建立包含測試資料的對象,進而可以更專注與測試用例的設計本身。
基本用法
直接使用如下的方式建立強類型的假資料
[Fact]
public void IntroductoryTest()
{
// arrange
Fixture fixture = new Fixture();
int expectedNumber = fixture.Create<int>();
MyClass sut = fixture.Create<MyClass>();
// act
int result = sut.Echo(expectedNumber);
// assert
Assert.Equal(expectedNumber, result);
}
上述示例也可以和測試架構本身結合,比如xUnit
[Theory, AutoData]
public void IntroductoryTest(
int expectedNumber, MyClass sut)
{
// act
int result = sut.Echo(expectedNumber);
// assert
Assert.Equal(expectedNumber, result);
}
5. 實踐中結合Visual Studio的使用
Visual Studio提供了完備的單元測試的支援,包括運作. 編寫. 調試單元測試。以及檢視單元測試覆寫率等。
5.1 如何在Visual Studio中運作單元測試
5.2 如何在Visual Studio中檢視單元測試覆寫率
如下功能需要Visual Studio 2019 Enterprise版本,社群版不帶這個功能。
如何檢視覆寫率
- 在測試視窗下,右鍵相應的測試組
- 點選如下的"分析代碼覆寫率"

6. 實踐中常見場景的Mock
主要
6.1 DbSet
使用EF Core過程中,如何mock DbSet是一個繞不過的坎。
方法一
參考如下連結的回答進行自行封裝
https://stackoverflow.com/questions/31349351/how-to-add-an-item-to-a-mock-dbset-using-moq
方法二(推薦)
使用現成的庫(也是基于上面的方式封裝好的)
倉庫位址:
- https://github.com/romantitov/MockQueryable
使用範例
// 1. 測試時建立一個模拟的List<T>
var users = new List<UserEntity>()
{
new UserEntity{LastName = "ExistLastName", DateOfBirth = DateTime.Parse("01/20/2012")},
...
};
// 2. 通過擴充方法轉換成DbSet<UserEntity>
var mockUsers = users.AsQueryable().BuildMock();
// 3. 指派給給mock的DbContext中的Users屬性
var mockDbContext = new Mock<DbContext>();
mockDbContext
.Setup(it => it.Users)
.Return(mockUsers);
6.2 HttpClient
使用RestEase/Refit的場景
如果使用的是
RestEase
或者
Refit
等第三方庫,具體接口的定義本質上就是一個interface,是以直接使用moq進行方法mock即可。
并且建議使用這種方式。
IHttpClientFactory
如果使用的是.Net Core自帶的
IHttpClientFactory
方式來請求外部接口的話,可以參考如下的方式對
IHttpClientFactory
進行mock
https://www.thecodebuzz.com/unit-test-mock-httpclientfactory-moq-net-core/
6.3 ILogger
由于ILogger的LogError等方法都是屬于擴充方法,是以不需要特别的進行方法級别的mock。
針對平時的一些使用場景封裝了一個幫助類, 可以使用如下的幫助類進行Mock和Verify
public static class LoggerHelper
{
public static Mock<ILogger<T>> LoggerMock<T>() where T : class
{
return new Mock<ILogger<T>>();
}
public static void VerifyLog<T>(this Mock<ILogger<T>> loggerMock, LogLevel level, string containMessage, Times times)
{
loggerMock.Verify(
x => x.Log(
level,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((o, t) => o.ToString().Contains(containMessage)),
It.IsAny<Exception>(),
(Func<It.IsAnyType, Exception, string>)It.IsAny<object>()),
times);
}
public static void VerifyLog<T>(this Mock<ILogger<T>> loggerMock, LogLevel level, Times times)
{
loggerMock.Verify(
x => x.Log(
level,
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.IsAny<Exception>(),
(Func<It.IsAnyType, Exception, string>)It.IsAny<object>()),
times);
}
}
使用方法
[Fact]
public void Echo_ShouldLogInformation()
{
// arrange
var mockLogger = LoggerHelpe.LoggerMock<UserController>();
var controller = new UserController(mockLogger.Object);
// act
controller.Echo();
// assert
mockLogger.VerifyLog(LogLevel.Information, "hello", Times.Once());
}
7. 拓展
7.1 TDD介紹
TDD是測試驅動開發(Test-Driven Development)的英文簡稱. 一般是先提前設計好單元測試的各種場景再進行真實業務代碼的編寫,編織安全網以便将Bug扼殺在在搖籃狀态。
此種開發模式以測試先行,對開發團隊的要求較高, 落地可能會存在很多實際困難。詳細說明可以參考如下
https://www.guru99.com/test-driven-development.html
參考連結
- https://docs.microsoft.com/en-us/dotnet/core/testing/unit-testing-best-practices
- https://www.kiltandcode.com/2019/06/16/best-practices-for-writing-unit-tests-in-csharp-for-bulletproof-code/
- https://github.com/AutoFixture/AutoFixture