單元測試的核心就是:只測試眼前的邏輯。這就要求所有的依賴項都要使用仿類來代替,也就是所謂的 Mock Object。在測試 和 的時候,我遇到了需要對 和 進行 Mock 的需求。因為這兩個組件相互依賴,還依賴別的組件,我折騰了好一陣才搞定這個問題。具體的方法分兩種:直接使用 Moq 進行 Mock ...
單元測試的核心就是:只測試眼前的邏輯。這就要求所有的依賴項都要使用仿類來代替,也就是所謂的 Mock Object。在測試 ProfileRepository
和 AccountController
的時候,我遇到了需要對 UserManager
和 SignInManager
進行 Mock 的需求。因為這兩個組件相互依賴,還依賴別的組件,我折騰了好一陣才搞定這個問題。具體的方法分兩種:直接使用 Moq 進行 Mock 和使用 InMemory Database 進行 Mock。下麵分別來說明一下。
一、 使用 InMemory Database 進行 Mock
在 ProfileRepository
的測試中,我使用了 InMemory 這個方案。因為之前對單元測試的一些誤解(使用 PHPUnit 而遺留下來的想法),我最直接想到的就是在資料庫中添加數據,然後讓各個組件去直接讀資料庫。當然,為了讓測試能夠飛速運行,我需要使用一個在記憶體里運行的資料庫。但嚴格來說,這樣就不算是單元測試了,而有一些集成測試的味道。只是使用記憶體資料庫,速度上並沒有那麼慢,所以權且當成是一種擴展版的單元測試吧。這裡有兩個記憶體資料庫可以選:一個是 SQLite 的 :memory:
模式,這個是一個接近完整的資料庫,只是在外鍵的約束上可能還有點問題;另一個是 EF Core 的 InMenory 資料庫。這個只是一個記憶體里保存數據的容器,其實並不是一個資料庫,沒有 SQLite 那樣的數據一致性檢查。這裡,我使用的是 InMemory Database,這樣可以讓這個測試更“單元”一點:
public ProfileRepositoryTests()
{
var services = new ServiceCollection();
services.AddEntityFramework()
.AddEntityFrameworkInMemoryDatabase()
.AddDbContext<ApplicationDbContext>(options => {
options.UseInMemoryDatabase();
});
services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>();
// Taken from https://github.com/aspnet/MusicStore/blob/dev/test/MusicStore.Test/ManageControllerTest.cs (and modified)
// IHttpContextAccessor is required for SignInManager, and UserManager
var context = new DefaultHttpContext();
context.Features.Set<IHttpAuthenticationFeature>(
new HttpAuthenticationFeature());
services.AddSingleton<IHttpContextAccessor>(h =>
new HttpContextAccessor { HttpContext = context });
var serviceProvider = services.BuildServiceProvider();
_dbContext = serviceProvider.GetRequiredService<ApplicationDbContext>();
_userManager = serviceProvider.GetRequiredService<UserManager<ApplicationUser>>();
Task.Run(async () => {
await _userManager.CreateAsync(new ApplicationUser {
UserName = "test1" }, "11aaAA_");
await _userManager.CreateAsync(new ApplicationUser {
UserName = "test2" }, "11aaAA_");
var user = await _userManager.FindByNameAsync("test2");
var profile = new Profile()
{
AccountID = user.Id,
Avatar = "avatar-file"
};
_dbContext.Add(profile);
_dbContext.SaveChanges();
user.ProfileID = profile.Id;
_dbContext.Update(user);
_dbContext.SaveChanges();
}).Wait();
}
可以看到,其實就是把 Startup 里的一些內容複製過來而已。這裡 _userManager
和 _dbContext
都是做為 TestClass 的成員而存在的。可以使用 Property,也可以使用成員變數。這樣做的意義主要是一些測試可能需要再增加一些數據,歌者直接去 Assert 資料庫里有對應的數據。當然,如果為了更靈活的測試,這裡的添加數據的問題也可以提出去,做了一個單獨的函數,然後在 Arrange 階段來調用。怎麼安排測試的結構,取決於測試的複雜度和個人的風格,沒有太多的標準。
二、直接使用 Moq 來 Mock
在 Mock AccountController
里的 UserManager
時,我發現了另一個解決方案,相比上面的方案,這個更加直接一些:
public static Mock<SignInManager<TUser>>
MockSignInManager<TUser>(Mock<UserManager<TUser>> manager)
where TUser : class
{
var context = new Mock<HttpContext>();
// var manager = MockUserManager<TUser>();
return new Mock<SignInManager<TUser>>(manager.Object,
new HttpContextAccessor { HttpContext = context.Object },
new Mock<IUserClaimsPrincipalFactory<TUser>>().Object,
null, null)
{ CallBase = true };
}
public static Mock<UserManager<TUser>> MockUserManager<TUser>()
where TUser : class
{
IList<IUserValidator<TUser>> UserValidators =
new List<IUserValidator<TUser>>();
IList<IPasswordValidator<TUser>> PasswordValidators =
new List<IPasswordValidator<TUser>>();
var store = new Mock<IUserStore<TUser>>();
UserValidators.Add(new UserValidator<TUser>());
PasswordValidators.Add(new PasswordValidator<TUser>());
var mgr = new Mock<UserManager<TUser>>(store.Object, null, null,
UserValidators, PasswordValidators, null, null, null, null);
return mgr;
}
使用這兩個函數,就可以直接創建 UserManager
和 SignInManager
的 Mock 了。不過,在使用 SignInManager
模擬登錄的時候還要註意:
_mockSignInManager.Setup(m =>
m.PasswordSignInAsync(It.IsAny<ApplicationUser>(),
It.IsAny<string>(),
It.IsAny<bool>(),
It.IsAny<bool>()))
.Returns(Task.FromResult(SignInResult.Success));
也就是說,創建“登錄成功”,不能直接 new
一個 SignInResult
,因為不能修改 SignInResult
的狀態,而是要使用它已經寫好的帶狀態的結果。
這兩種方式各有用處。比如 InMemory Database 的方案,不但可以對 UserManager
和 SignInManager
的結果進行控制,還提供了一個可以寫入和檢查的資料庫。而直接 Mock 的方案,則干擾更少,更專註於邏輯。我個人感覺,在對 Repository 的測試中,使用 InMemory Database 可能更合適一點,然後在其它地方,因為 Repository 隔離了數據訪問,所以可以直接對 Repository 進行 Mock,這時候就可以使用直接 Mock 的方案。
三、Logger 的 Mock
在測試 AccountController
的時候,還需要對 ILogger
和 ILoggerFactory
進行 Mock,這當然也不是什麼難事:
_mockLogger.Setup(m => m.Log(It.IsAny<LogLevel>(),
It.IsAny<EventId>(),
It.IsAny<FormattedLogValues>(),
It.IsAny<Exception>(),
It.IsAny<Func<object, Exception, string>>()));
_mockLoggerFactory.Setup(m =>
m.CreateLogger(It.IsAny<string>())).Returns(_mockLogger.Object);
也就是,得 Mock 兩個東西。這當然是因為 Controller
里都是依賴於 ILoggerFactory
,然後再使用 factory
創建 ILogger
。
四、UrlHelper 的 Mock
最後一個坑是 UrlHelper
。通常一個 Controller
都會有 RedirectTo
一個 Action
或者一個 URL 的需求,那就不可避免要用到 UrlHelper
。而 Controller
需要單獨進行 Mock:
var mockUrlHelper = new Mock<IUrlHelper>();
mockUrlHelper.Setup(m => m.IsLocalUrl(It.IsAny<string>())).Returns(true);
controller.Url = mockUrlHelper.Object;
下麵參考資料里的代碼要複雜的多,應該是因為 ASP.NET Core 的版本問題造成的。我這個“簡單的版本”,是針對 1.1.0 版本的。如果以後有變化,可能會在別的地方再說明吧。
完整的代碼請到下麵兩個 repo 中的一個去看:
GitHub: http://github.com/holmescn/CoreCRM
Codint.NET: https://coding.net/u/holmescn/p/CoreCRM/git
參考鏈接:
直接 Mock 的代碼是請看這裡
使用 InMemory Database 的請看這裡
UrlHelper 的原始想法來自這裡