引言
上一章節介紹了 TDD 的三大法則,今天我們講一下在單元測試中模擬物件的使用。
Fake
Fake
-Fake
是一個通用術語,可用於描述stub
或mock
物件。 它是stub
還是mock
取決於使用它的上下文。 也就是說,Fake
可以是stub
或mock
Mock
-Mock
物件是系統中的fake
物件,用於確定單元測試是否透過。Mock
起初為Fake
,直到對其斷言。
Stub
-Stub
是系統中現有依賴項的可控制替代項。 透過使用Stub
,可以在無需使用依賴項的情況下直接測試程式碼。
參考 單元測試最佳做法 讓我們使用相同的術語
區別點:
- Stub:
- 用於提供可控制的替代行為,通常是在測試中模擬依賴項的簡單行為。
- 主要用於提供固定的返回值或行為,以便測試程式碼的特定路徑。
- 不涉及對方法呼叫的驗證,只是提供一個虛擬的實現。
- Mock:
- 用於驗證方法的呼叫和行為,以確保程式碼按預期工作。
- 主要用於確認特定方法是否被呼叫,以及被呼叫時的引數和次數。
- 可以設定期望的呼叫順序、引數和返回值,並在測試結束時驗證這些呼叫。
總結:
- Stub 更側重於提供一個簡單的替代品,幫助測試程式碼路徑,而不涉及行為驗證。
- Mock 則更側重於驗證程式碼的行為和呼叫,以確保程式碼按預期執行。
在某些情況下兩者可能看起來相似,但在測試的目的和用途上還是存在一些區別。在編寫單元測試時,根據測試場景和需求選擇合適的
stub
或mock
物件可以幫助提高測試的準確性和可靠性。
建立實戰專案
建立一個 WebApi
的 Controller
專案,和一個EFCore
倉儲類庫作為我們後續章節的演示專案
dotNetParadise-Xunit
│
├── src
│ ├── Sample.Api
│ └── Sample.Repository
Sample.Repository
是一個簡單 EFCore
的倉儲模式實現,Sample.Api
對外提供 RestFul
的 Api
介面
Sample.Repository 實現
- 第一步
Sample.Repository
類庫安裝Nuget
包
PM> NuGet\Install-Package Microsoft.EntityFrameworkCore.InMemory -Version 8.0.3
PM> Microsoft.EntityFrameworkCore.Relational -Version 8.0.3
- 建立實體
Staff
public class Staff
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public int? Age { get; set; }
public List<string>? Addresses { get; set; }
public DateTimeOffset? Created { get; set; }
}
- 建立
SampleDbContext
資料庫上下文
public class SampleDbContext(DbContextOptions<SampleDbContext> options) : DbContext(options)
{
public DbSet<Staff> Staff { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
}
}
- 定義倉儲介面和實現
public interface IStaffRepository
{
/// <summary>
/// 獲取 Staff 實體的 DbSet
/// </summary>
DbSet<Staff> dbSet { get; }
/// <summary>
/// 新增新的 Staff 實體
/// </summary>
/// <param name="staff"></param>
Task AddStaffAsync(Staff staff, CancellationToken cancellationToken = default);
/// <summary>
/// 根據 Id 刪除 Staff 實體
/// </summary>
/// <param name="id"></param>
Task DeleteStaffAsync(int id, CancellationToken cancellationToken = default);
/// <summary>
/// 更新 Staff 實體
/// </summary>
/// <param name="staff"></param>
Task UpdateStaffAsync(Staff staff, CancellationToken cancellationToken = default);
/// <summary>
/// 根據 Id 獲取單個 Staff 實體
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
Task<Staff?> GetStaffByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>
/// 獲取所有 Staff 實體
/// </summary>
/// <returns></returns>
Task<List<Staff>> GetAllStaffAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 批次更新 Staff 實體
/// </summary>
/// <param name="staffList"></param>
Task BatchAddStaffAsync(List<Staff> staffList, CancellationToken cancellationToken = default);
}
- 倉儲實現
public class StaffRepository : IStaffRepository
{
private readonly SampleDbContext _dbContext;
public DbSet<Staff> dbSet => _dbContext.Set<Staff>();
public StaffRepository(SampleDbContext dbContext)
{
dbContext.Database.EnsureCreated();
_dbContext = dbContext;
}
public async Task AddStaffAsync(Staff staff, CancellationToken cancellationToken = default)
{
await dbSet.AddAsync(staff, cancellationToken);
await _dbContext.SaveChangesAsync(cancellationToken);
}
public async Task DeleteStaffAsync(int id, CancellationToken cancellationToken = default)
{
//await dbSet.AsQueryable().Where(_ => _.Id == id).ExecuteDeleteAsync(cancellationToken);
var staff = await GetStaffByIdAsync(id, cancellationToken);
if (staff is not null)
{
dbSet.Remove(staff);
await _dbContext.SaveChangesAsync(cancellationToken);
}
}
public async Task UpdateStaffAsync(Staff staff, CancellationToken cancellationToken = default)
{
dbSet.Update(staff);
_dbContext.Entry(staff).State = EntityState.Modified;
await _dbContext.SaveChangesAsync(cancellationToken);
}
public async Task<Staff?> GetStaffByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await dbSet.AsQueryable().Where(_ => _.Id == id).FirstOrDefaultAsync(cancellationToken);
}
public async Task<List<Staff>> GetAllStaffAsync(CancellationToken cancellationToken = default)
{
return await dbSet.ToListAsync(cancellationToken);
}
public async Task BatchAddStaffAsync(List<Staff> staffList, CancellationToken cancellationToken = default)
{
await dbSet.AddRangeAsync(staffList, cancellationToken);
await _dbContext.SaveChangesAsync(cancellationToken);
}
}
- 依賴注入
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddEFCoreInMemoryAndRepository(this IServiceCollection services)
{
services.AddScoped<IStaffRepository, StaffRepository>();
services.AddDbContext<SampleDbContext>(options => options.UseInMemoryDatabase("sample").EnableSensitiveDataLogging(), ServiceLifetime.Scoped);
return services;
}
}
到目前為止 倉儲層的簡單實現已經完成了,接下來完成
WebApi
層
Sample.Api
將 Sample.Api
新增專案引用Sample.Repository
program
依賴注入
builder.Services.AddEFCoreInMemoryAndRepository();
- 定義
Controller
[Route("api/[controller]")]
[ApiController]
public class StaffController(IStaffRepository staffRepository) : ControllerBase
{
private readonly IStaffRepository _staffRepository = staffRepository;
[HttpPost]
public async Task<IResult> AddStaff([FromBody] Staff staff, CancellationToken cancellationToken = default)
{
await _staffRepository.AddStaffAsync(staff, cancellationToken);
return TypedResults.NoContent();
}
[HttpDelete("{id}")]
public async Task<IResult> DeleteStaff(int id, CancellationToken cancellationToken = default)
{
await _staffRepository.DeleteStaffAsync(id);
return TypedResults.NoContent();
}
[HttpPut("{id}")]
public async Task<Results<BadRequest<string>, NoContent, NotFound>> UpdateStaff(int id, [FromBody] Staff staff, CancellationToken cancellationToken = default)
{
if (id != staff.Id)
{
return TypedResults.BadRequest("Staff ID mismatch");
}
var originStaff = await _staffRepository.GetStaffByIdAsync(id, cancellationToken);
if (originStaff is null) return TypedResults.NotFound();
originStaff.Update(staff);
await _staffRepository.UpdateStaffAsync(originStaff, cancellationToken);
return TypedResults.NoContent();
}
[HttpGet("{id}")]
public async Task<Results<Ok<Staff>, NotFound>> GetStaffById(int id, CancellationToken cancellationToken = default)
{
var staff = await _staffRepository.GetStaffByIdAsync(id, cancellationToken);
if (staff == null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(staff);
}
[HttpGet]
public async Task<IResult> GetAllStaff(CancellationToken cancellationToken = default)
{
var staffList = await _staffRepository.GetAllStaffAsync(cancellationToken);
return TypedResults.Ok(staffList);
}
[HttpPost("BatchAdd")]
public async Task<IResult> BatchAddStaff([FromBody] List<Staff> staffList, CancellationToken cancellationToken = default)
{
await _staffRepository.BatchAddStaffAsync(staffList, cancellationToken);
return TypedResults.NoContent();
}
}
F5
專案跑一下
到這兒我們的專案已經建立完成了本系列後面的章節基本上都會以這個專案為基礎展開擴充
控制器的單元測試
[單元測試涉及透過基礎結構和依賴項單獨測試應用的一部分。 單元測試控制器邏輯時,僅測試單個操作的內容,不測試其依賴項或框架自身的行為。
本章節主要以控制器的單元測試來帶大家瞭解一下Stup
和Moq
的核心區別。
建立一個新的測試專案,然後新增Sample.Api
的專案引用
Stub
實戰
Stub
是系統中現有依賴項的可控制替代項。透過使用 Stub
,可以在測試程式碼時不需要使用真實依賴項。通常情況下,存根最初被視為 Fake
下面對 StaffController
利用 Stub
進行單元測試,
- 建立一個
Stub
實現IStaffRepository
介面,以模擬對資料庫或其他資料來源的訪問操作。 - 在單元測試中使用這個
Stub
替代IStaffRepository
的實際實現,以便在不依賴真實資料來源的情況下測試StaffController
中的方法。
我們在dotNetParadise.FakeTest
測試專案上新建一個IStaffRepository
的實現,名字可以叫StubStaffRepository
public class StubStaffRepository : IStaffRepository
{
public DbSet<Staff> dbSet => default!;
public async Task AddStaffAsync(Staff staff, CancellationToken cancellationToken)
{
// 模擬新增員工操作
await Task.CompletedTask;
}
public async Task DeleteStaffAsync(int id)
{
// 模擬刪除員工操作
await Task.CompletedTask;
}
public async Task UpdateStaffAsync(Staff staff, CancellationToken cancellationToken)
{
// 模擬更新員工操作
await Task.CompletedTask;
}
public async Task<Staff?> GetStaffByIdAsync(int id, CancellationToken cancellationToken)
{
// 模擬根據 ID 獲取員工操作
return await Task.FromResult(new Staff { Id = id, Name = "Mock Staff" });
}
public async Task<List<Staff>> GetAllStaffAsync(CancellationToken cancellationToken)
{
// 模擬獲取所有員工操作
return await Task.FromResult(new List<Staff> { new Staff { Id = 1, Name = "Mock Staff 1" }, new Staff { Id = 2, Name = "Mock Staff 2" } });
}
public async Task BatchAddStaffAsync(List<Staff> staffList, CancellationToken cancellationToken)
{
// 模擬批次新增員工操作
await Task.CompletedTask;
}
public async Task DeleteStaffAsync(int id, CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
}
}
我們新建立了一個倉儲的實現來替換StaffRepository
作為新的依賴
下一步在單元測試專案測試我們的Controller
方法
public class TestStubStaffController
{
[Fact]
public async Task AddStaff_WhenCalled_ReturnNoContent()
{
//Arrange
var staffController = new StaffController(new StubStaffRepository());
var staff = new Staff()
{
Age = 10,
Name = "Test",
Email = "Test@163.com",
Created = DateTimeOffset.Now,
};
//Act
var result = await staffController.AddStaff(staff);
//Assert
Assert.IsType<Results<NoContent, ProblemHttpResult>>(result);
}
[Fact]
public async Task GetStaffById_WhenCalled_ReturnOK()
{
//Arrange
var staffController = new StaffController(new StubStaffRepository());
var id = 1;
//Act
var result = await staffController.GetStaffById(id);
//Assert
Assert.IsType<Results<Ok<Staff>, NotFound>>(result);
var okResult = (Ok<Staff>)result.Result;
Assert.Equal(id, okResult.Value?.Id);
}
//先暫時省略後面測試方法....
}
用
Stub
來替代真實的依賴項,以便更好地控制測試環境和測試結果
Mock
在測試過程中,尤其是
TDD
的開發過程中,測試用例有限開發在這個時候,我們總是要去模擬物件的建立,這些物件可能是某個介面的實現也可能是具體的某個物件,這時候就必須去寫介面的實現,這時候模擬物件Mock
的用處就體現出來了,在社群中也有很多模擬物件的庫如Moq
,FakeItEasy
等。
Moq
是一個簡單、直觀且強大的.NET
模擬庫,用於在單元測試中模擬物件和行為。透過Moq
,您可以輕鬆地設定依賴項的行為,並驗證程式碼的呼叫。
我們用上面的例項來演示一下Moq
的核心用法
第一步 Nuget
包安裝Moq
PM> NuGet\Install-Package Moq -Version 4.20.70
您可以使用 Moq
中的 Setup
方法來設定模擬物件(Mock
物件)中可重寫方法的行為,結合 Returns
(用於返回一個值)或 Throws
(用於丟擲異常)等方法來定義其行為。這樣可以模擬對特定方法的呼叫,使其在測試中返回預期的值或丟擲特定的異常。
建立TestMockStaffController
測試類,接下來我們用Moq
實現一下上面的例子
public class TestMockStaffController
{
private readonly ITestOutputHelper _testOutputHelper;
public TestMockStaffController(ITestOutputHelper testOutputHelper)
{
_testOutputHelper = testOutputHelper;
}
[Fact]
public async Task AddStaff_WhenCalled_ReturnNoContent()
{
//Arrange
var mock = new Mock<IStaffRepository>();
mock.Setup(_ => _.AddStaffAsync(It.IsAny<Staff>(), default));
var staffController = new StaffController(mock.Object);
var staff = new Staff()
{
Age = 10,
Name = "Test",
Email = "Test@163.com",
Created = DateTimeOffset.Now,
};
//Act
var result = await staffController.AddStaff(staff);
//Assert
Assert.IsType<Results<NoContent, ProblemHttpResult>>(result);
}
[Fact]
public async Task GetStaffById_WhenCalled_ReturnOK()
{
//Arrange
var mock = new Mock<IStaffRepository>();
var id = 1;
mock.Setup(_ => _.GetStaffByIdAsync(It.IsAny<int>(), default)).ReturnsAsync(() => new Staff()
{
Id = id,
Name = "張三",
Age = 18,
Email = "zhangsan@163.com",
Created = DateTimeOffset.Now
});
var staffController = new StaffController(mock.Object);
//Act
var result = await staffController.GetStaffById(id);
//Assert
Assert.IsType<Results<Ok<Staff>, NotFound>>(result);
var okResult = (Ok<Staff>)result.Result;
Assert.Equal(id, okResult.Value?.Id);
_testOutputHelper.WriteLine(okResult.Value?.Name);
}
//先暫時省略後面測試方法....
}
看一下執行測試
Moq 核心功能講解
透過我們上面這個簡單的
Demo
簡單的瞭解了一下 Moq 的使用,接下來我們對Moq
和核心功能深入瞭解一下
透過安裝的Nuget
包可以看到, Moq
依賴了Castle.Core
這個包,Moq
正是利用了 Castle
來實現動態代理模擬物件的功能。
基本概念
-
Mock
物件:透過Moq
建立的模擬物件,用於模擬外部依賴項的行為。//建立Mock物件 var mock = new Mock<IStaffRepository>();
-
Setup
:用於設定Mock
物件的行為和返回值,以指定當呼叫特定方法時應該返回什麼結果。//指定呼叫AddStaffAsync方法的引數行為 mock.Setup(_ => _.AddStaffAsync(It.IsAny<Staff>(), default));
非同步方法
從我們上面的單元測試中看到我們使用了一個非同步方法,使用返回值ReturnsAsync
表示的
mock.Setup(_ => _.GetStaffByIdAsync(It.IsAny<int>(), default))
.ReturnsAsync(() => new Staff()
{
Id = id,
Name = "張三",
Age = 18,
Email = "zhangsan@163.com",
Created = DateTimeOffset.Now
});
Moq
有三種方式去設定非同步方法的返回值分別是:
-
使用 .Result 屬性(Moq 4.16 及以上版本):
- 在 Moq 4.16 及以上版本中,您可以直接透過
mock.Setup
返回任務的.Result
屬性來設定非同步方法的返回值。這種方法幾乎適用於所有設定和驗證表示式。 - 示例:
mock.Setup(foo => foo.DoSomethingAsync().Result).Returns(true);
- 在 Moq 4.16 及以上版本中,您可以直接透過
-
使用 ReturnsAsync(較早版本):
- 在較早版本的 Moq 中,您可以使用類似
ReturnsAsync
、ThrowsAsync
等輔助方法來設定非同步方法的返回值。 - 示例:
mock.Setup(foo => foo.DoSomethingAsync()).ReturnsAsync(true);
- 在較早版本的 Moq 中,您可以使用類似
-
使用 Lambda 表示式:
- 您還可以使用 Lambda 表示式來返回非同步方法的結果。不過這種方式會觸發有關非同步 Lambda 同步執行的編譯警告。
- 示例:
mock.Setup(foo => foo.DoSomethingAsync()).Returns(async () => 42);
引數匹配
在我們單元測試例項中用到了引數匹配,mock.Setup(_ => _.GetStaffByIdAsync(It.IsAny<int>(), default)).
,對就是這個It.IsAny<int>()
,此處的用意是匹配任意輸入的 int
型別的入參,接下來我們一起看下引數匹配的一些常用示例。
-
任意值匹配
It.IsAny<T>()
mock.Setup(_ => _.GetStaffByIdAsync(It.IsAny<int>(), default))
-
ref 引數的任意值匹配:
對於 ref 引數,可以使用 It.Ref.IsAny 進行匹配(需要 Moq 4.8 或更高版本)。 //Arrange var mock = new Mock<IFoo>(); // ref arguments var instance = new Bar(); // Only matches if the ref argument to the invocation is the same instance mock.Setup(foo => foo.Submit(ref instance)).Returns(true);
-
匹配滿足條件的值:
使用It.Is<T>(predicate)
可以匹配滿足條件的值,其中predicate
是一個函式。//匹配滿足條件的值 mock.Setup(foo => foo.Add(It.Is<int>(i => i % 2 == 0))).Returns(true); //It.Is 斷言 var result = mock.Object.Add(3); Assert.False(result);
-
匹配範圍:
使用It.IsInRange<T>
可以匹配指定範圍內的值mock.Setup(foo => foo.Add(It.IsInRange<int>(0, 10, Moq.Range.Inclusive))).Returns(true); var inRangeResult = mock.Object.Add(3); Assert.True(inRangeResult);
-
匹配正規表示式:
使用It.IsRegex
可以匹配符合指定正規表示式的值{ mock.Setup(x => x.DoSomethingStringy(It.IsRegex("[a-d]+", RegexOptions.IgnoreCase))).Returns("foo"); var result = mock.Object.DoSomethingStringy("a"); Assert.Equal("foo", result); }
屬性值
- 設定屬性的返回值
透過Setup
後的Returns
函式 設定Mock
的返回值{ mock.Setup(foo => foo.Name).Returns("bar"); Assert.Equal("bar",mock.Object.Name); }
-
SetupSet
設定屬性的設定行為,期望特定值被設定.
主要是透過設定預期行為,對屬性值做一些驗證或者回撥等操作//SetupUp mock = new Mock<IFoo>(); // Arrange mock.SetupSet(foo => foo.Name = "foo").Verifiable(); //Act mock.Object.Name = "foo"; mock.Verify();
如果值設定為mock.Object.Name = "foo1";
,
單元測試就會丟擲異常
OutPut:
dotNetParadise.FakeTest.TestControllers.TestMockStaffController.Test_Moq_Demo
源: TestMockStaffController.cs 行 70
持續時間: 8.7 秒
訊息:
Moq.MockException : Mock<IFoo:2>:
This mock failed verification due to the following:
IFoo foo => foo.Name = "foo":
This setup was not matched.
堆疊跟蹤:
Mock.Verify(Func`2 predicate, HashSet`1 verifiedMocks) 行 309
Mock.Verify() 行 251
TestMockStaffController.Test_Moq_Demo() 行 111
--- End of stack trace from previous location ---
VerifySet
直接驗證屬性的設定操作
//VerifySet直接驗證屬性的設定操作
{
// Arrange
mock = new Mock<IFoo>();
//Act
mock.Object.Name = "foo";
//Asset
mock.VerifySet(person => person.Name = "foo");
}
SetupProperty
使用SetupProperty
可以為Mock
物件的屬性設定行為,包括get
和set
的行為。
{
// Arrange
mock = new Mock<IFoo>();
// start "tracking" sets/gets to this property
mock.SetupProperty(f => f.Name);
// alternatively, provide a default value for the stubbed property
mock.SetupProperty(f => f.Name, "foo");
//Now you can do:
IFoo foo = mock.Object;
// Initial value was stored
//Asset
Assert.Equal("foo", foo.Name);
}
在Moq
中,您可以使用 SetupAllProperties
方法來一次性存根(Stub
)Mock
物件的所有屬性。這意味著所有屬性都會開始跟蹤其值,並可以提供預設值。以下是一個示例演示如何使用 SetupAllProperties
方法:
// 存根(Stub)Mock 物件的所有屬性
mock.SetupAllProperties();
透過使用 SetupProperty
方法,可以更靈活地設定 Mock 物件的屬性行為和預設值,以滿足單元測試中的需求
處理事件(Events
)
在 Moq
4.13 及以後的版本中,你可以透過配置事件的 add
和 remove
訪問器來模擬事件的行為。這允許你指定當事件處理器被新增或移除時應該發生的邏輯。這通常用於驗證事件是否被正確新增或移除,或者模擬事件觸發時的行為。
SetupAdd
用於設定Mock
物件的事件的add
訪問器,即用於模擬事件訂閱的行為
SetupRemove
用於設定Mock
物件的事件的remove
訪問器,以模擬事件處理程式的移除行為
建立要被測試的類:
public class HasEvent
{
public virtual event Action Event;
public void RaiseEvent() => this.Event?.Invoke();
}
{
var handled = false;
var mock = new Mock<HasEvent>();
//設定訂閱行為
mock.SetupAdd(m => m.Event += It.IsAny<Action>()).CallBase();
// 訂閱事件並設定事件處理邏輯
Action eventHandler = () => handled = true;
mock.Object.Event += eventHandler;
mock.Object.RaiseEvent();
Assert.True(handled);
// 重置標誌為 false
handled = false;
// 移除事件處理程式
mock.SetupRemove(h => h.Event -= It.IsAny<Action>()).CallBase();
// 移除事件處理程式
mock.Object.Event -= eventHandler;
// 再次觸發事件
mock.Object.RaiseEvent();
// Assert - 驗證事件是否被正確處理
Assert.False(handled); // 第一次應該為 true,第二次應該為 false
}
這段程式碼是一個針對 HasEvent
類的測試示例,使用 Moq 來設定事件的訂閱和移除行為,並驗證事件處理程式的新增和移除是否按預期工作。讓我簡單解釋一下這段程式碼的流程:
- 建立一個 Mock 物件
mock
,模擬HasEvent
類。 - 使用
SetupAdd
方法設定事件的訂閱行為,並使用CallBase
方法呼叫基類的實現。 - 訂閱事件並設定事件處理邏輯,將事件處理程式
eventHandler
新增到事件中。 - 呼叫
RaiseEvent
方法觸發事件,並透過斷言驗證事件處理程式是否被正確處理。 - 將
handled
標誌重置為false
。 - 使用
SetupRemove
方法設定事件的移除行為,並使用CallBase
方法呼叫基類的實現。 - 移除事件處理程式
eventHandler
。 - 再次觸發事件,並透過斷言驗證事件處理程式是否被正確移除。
透過這個測試示例,可以驗證事件處理程式的新增和移除操作是否正常工作
Raise
Raise
方法用於手動觸發 Mock 物件上的事件,模擬事件的觸發過程
{
// Arrange
var handled = false;
var mock = new Mock<HasEvent>();
//設定訂閱行為
mock.Object.Event += () => handled = true;
//act
mock.Raise(m => m.Event += null);
// Assert - 驗證事件是否被正確處理
Assert.True(handled);
}
這個示例使用Raise
方法手動觸發 Mock
物件上的事件 Event
,並驗證事件處理程式的執行情況。透過設定事件的訂閱行為,觸發事件,以及斷言驗證事件處理程式的執行結果,測試了事件處理程式的邏輯是否按預期執行。這個過程幫助我們確認事件處理程式在事件觸發時能夠正確執行.
Callbacks
Callback
方法用於在設定 Mock
物件的成員時指定回撥操作。當特定操作被呼叫時,可以在 Callback
方法中執行自定義的邏輯
//Arrange
var mock = new Mock<IFoo>();
var calls = 0;
var callArgs = new List<string>();
mock.Setup(foo => foo.DoSomething("ping"))
.Callback(() => calls++)
.Returns(true);
// Act
mock.Object.DoSomething("ping");
// Assert
Assert.Equal(1, calls); // 驗證 DoSomething 方法被呼叫一次
在呼叫 DoSomething 方法是,回撥操作自動被觸發引數++
CallBack
捕獲引數
//CallBack 捕獲引數
{
//Arrange
mock = new Mock<IFoo>();
mock.Setup(foo => foo.DoSomething(It.IsAny<string>()))
.Callback<string>(s => callArgs.Add(s))
.Returns(true);
//Act
mock.Object.DoSomething("a");
//Asset
// 驗證引數是否被新增到 callArgs 列表中
Assert.Contains("a", callArgs);
}
使用
Moq
的Callback
方法可以捕獲方法呼叫時的引數,允許我們在測試中訪問和處理這些引數。透過在Setup
方法中指定Callback
操作,我們可以捕獲方法呼叫時傳入的引數,並在回撥中執行自定義邏輯,例如將引數新增到列表中。這種方法可以幫助我們驗證方法在不同引數下的行為,以及檢查方法是否被正確呼叫和傳遞引數。總的來說,Callback
方法為我們提供了一種靈活的方式來處理方法呼叫時的引數,幫助我們編寫更全面的單元測試。
SetupProperty
SetupProperty
方法可用於設定Mock
物件的屬性,併為其提供getter
和setter
。
{
//Arrange
mock = new Mock<IFoo>();
mock.SetupProperty(foo => foo.Name);
mock.Setup(foo => foo.DoSomething(It.IsAny<string>()))
.Callback((string s) => mock.Object.Name = s)
.Returns(true);
//Act
mock.Object.DoSomething("a");
// Assert
Assert.Equal("a", mock.Object.Name);
}
SetupProperty
方法的作用包括:
-
設定屬性的初始值:透過
SetupProperty
方法,我們可以設定Mock
物件屬性的初始值,使其在測試中具有特定的初始狀態。 -
模擬屬性的 getter 和 setter:
SetupProperty
方法允許我們為屬性設定getter
和setter
,使我們能夠訪問和修改屬性的值。 -
捕獲屬性的設定操作:在設定
Mock
物件的屬性時,可以使用Callback
方法捕獲設定操作,以執行自定義邏輯或記錄屬性的設定情況。 -
驗證屬性的行為:透過設定屬性和相應的行為,可以驗證屬性的行為是否符合預期,以確保程式碼的正確性和可靠性
Verification
在 Moq
中,Verification
是指驗證 Mock
物件上的方法是否被正確呼叫,以及呼叫時是否傳入了預期的引數。透過 Verification
,我們可以確保 Mock
物件的方法按預期進行了呼叫,從而驗證程式碼的行為是否符合預期。
{
//Arrange
var mock = new Mock<IFoo>();
//Act
mock.Object.Add(1);
// Assert
mock.Verify(foo => foo.Add(1));
}
- 驗證方法被呼叫的行為
- 未被呼叫,或者呼叫至少一次
{
var mock = new Mock<IFoo>();
mock.Verify(foo => foo.DoSomething("ping"), Times.Never());
}
mock.Verify(foo => foo.DoSomething("ping"), Times.AtLeastOnce());
Verify
指定 Times.AtLeastOnce()
驗證方法至少被呼叫了一次。
- VerifySet
驗證是否是按續期設定,上面有講過。
- VerifyGet
用於驗證屬性的getter
方法至少被訪問指定次數,或者沒有被訪問.
{
var mock = new Mock<IFoo>();
mock.VerifyGet(foo => foo.Name);
}
- VerifyAdd,VerifyRemove
VerifyAdd
和 VerifyRemove
方法來驗證事件的訂閱和移除
// Verify event accessors (requires Moq 4.13 or later):
mock.VerifyAdd(foo => foo.FooEvent += It.IsAny<EventHandler>());
mock.VerifyRemove(foo => foo.FooEvent -= It.IsAny<EventHandler>());
- VerifyNoOtherCalls
VerifyNoOtherCalls
方法的作用是在使用 Moq
進行方法呼叫驗證時,確保除了已經透過 Verify
方法驗證過的方法呼叫外,沒有其他未驗證的方法被執行
mock.VerifyNoOtherCalls();
Customizing Mock Behavior
- MockBehavior.Strict
使用Strict
模式建立的Mock
物件時,如果發生了未設定期望的方法呼叫,包括未設定對方法的期望行為(如返回值、丟擲異常等),則在該未設定期望的方法呼叫時會丟擲MockException
異常。這意味著在Strict
模式下,Mock
物件會嚴格要求所有的方法呼叫都必須有對應的期望設定,否則會觸發異常。
[Fact]
public void TestStrictMockBehavior_WithUnsetExpectation()
{
// Arrange
var mock = new Mock<IFoo>(MockBehavior.Strict);
//mock.Setup(_ => _.Add(It.IsAny<int>())).Returns(true);
// Act & Assert
Assert.Throws<MockException>(() => mock.Object.Add(3));
}
如果mock.Setup
這一行註釋了,即未設定期望值,則會丟擲異常
- CallBase
在上面的示例中我們也能看到CallBase
的使用
在Moq
中,透過設定CallBase = true
,可以建立一個部分模擬物件(Partial Mock
),這樣在沒有設定期望的成員時,會呼叫基類的實現。這在需要模擬部分行為並保留基類實現的場景中很有用,特別適用於模擬System.Web
中的Web/Html
控制元件。
public interface IUser
{
string GetName();
}
public class UserBase : IUser
{
public virtual string GetName()
{
return "BaseName";
}
string IUser.GetName() => "Name";
}
測試
[Fact]
public void TestPartialMockWithCallBase()
{
// Arrange
var mock = new Mock<UserBase> { CallBase = true };
mock.As<IUser>().Setup(foo => foo.GetName()).Returns("MockName");
// Act
string result = mock.Object.GetName();//
// Assert
Assert.Equal("BaseName", result);
//Act
var valueOfSetupMethod = ((IUser)mock.Object).GetName();
//Assert
Assert.Equal("MockName", valueOfSetupMethod);
}
- 第一個
Act
:呼叫模擬物件的 GetName() 方法,此時基類的實現被呼叫,返回值為"BaseName"
。 - 第二個
Act
😕/透過強制型別轉換將模擬物件轉換為 IUser 介面型別,呼叫介面方法 GetName(),返回值為"MockName"
。
- DefaultValue.Mock
建立一個自動遞迴模擬物件,該模擬物件在沒有期望的成員上返回新的模擬物件
[Fact]
public void TestRecursiveMock()
{
// Arrange
var mock = new Mock<IFoo> { DefaultValue = DefaultValue.Mock };
// Act
Bar value = mock.Object.Bar;
var barMock = Mock.Get(value);
barMock.Setup(b => b.Submit()).Returns(true);
// Assert
Assert.True(mock.Object.Bar.Submit());
}
在這個示例中,IFoo
介面具有一個虛擬屬性 Bar
,Bar
類有一個虛擬方法 Submit
。透過設定 DefaultValue.Mock
,我們建立了一個自動遞迴模擬物件 mock
,在訪問 Bar
屬性時會返回一個新的模擬物件。然後,我們對返回的 Bar 模擬物件設定了期望行為,並驗證了其提交方法的返回值。這樣,您可以方便地管理和設定遞迴模擬物件的期望行為。
-
**MockRepository**
透過使用MockRepository
,可以更加方便地集中管理和驗證所有模擬物件,同時確保它們的設定和驗證是一致的[Fact] public void TestRepositoryMock() { // Create a MockRepository with MockBehavior.Strict and DefaultValue.Mock var repository = new MockRepository(MockBehavior.Strict) { DefaultValue = DefaultValue.Mock }; // Create a mock using the repository settings var fooMock = repository.Create<IFoo>(); // Create a mock overriding the repository settings with MockBehavior.Loose var barMock = repository.Create<Bar>(MockBehavior.Loose); // Verify all verifiable expectations on all mocks created through the repository repository.Verify(); // Additional setup and assertions can be done on fooMock and barMock as needed // For example: barMock.Setup(b => b.Submit()).Returns(true); Assert.True(barMock.Object.Submit()); }
我們首先建立了一個
MockRepository
,並設定了MockBehavior.Strict
和DefaultValue.Mock
。然後透過repository.Create<T>()
方法在MockRepository
設定下建立了一個IFoo
介面的模擬物件fooMock
,以及一個使用MockBehavior.Loose
的Bar
類的模擬物件barMock
。最後,我們呼叫repository.Verify()
來驗證透過MockRepository
建立的所有模擬物件上的所有可驗證期望。
Miscellaneous
Reset
可以使用 Reset()
方法來重置模擬物件,清除所有的設定、預設返回值、註冊的事件處理程式以及所有記錄的呼叫。這在測試場景中特別有用,可以確保每個測試用例在獨立的環境下執行,避免測試之間的相互影響
mock.Reset();
SetupSequence
可以使用 SetupSequence
方法來設定一個成員在連續呼叫時返回不同的值或丟擲異常。這在需要模擬一個成員在多次呼叫時具有不同行為的場景中非常有用
[Fact]
public void TestSetupSequence()
{
// Arrange
var mock = new Mock<IFoo>();
mock.SetupSequence(f => f.GetCount())
.Returns(3)
.Returns(2)
.Returns(1)
.Returns(0)
.Throws(new InvalidOperationException());
// Act & Assert
Assert.Equal(3, mock.Object.GetCount());
Assert.Equal(2, mock.Object.GetCount());
Assert.Equal(1, mock.Object.GetCount());
Assert.Equal(0, mock.Object.GetCount());
Assert.Throws<InvalidOperationException>(() => mock.Object.GetCount());
}
LINQ to Mocks
LINQ to Mocks
是 Moq
提供的一種宣告性規範查詢方式,使得您可以透過 LINQ
風格的語法來指定模擬物件的行為。透過 LINQ to Mocks
,您可以從模擬物件的宇宙中獲取符合特定規範的模擬物件,從而更加直觀地設定模擬物件的行為
var services = Mock.Of<IServiceProvider>(sp =>
sp.GetService(typeof(IRepository)) == Mock.Of<IRepository>(r => r.IsAuthenticated == true) &&
sp.GetService(typeof(IAuthentication)) == Mock.Of<IAuthentication>(a => a.AuthenticationType == "OAuth"));
// Multiple setups on a single mock and its recursive mocks
ControllerContext context = Mock.Of<ControllerContext>(ctx =>
ctx.HttpContext.User.Identity.Name == "kzu" &&
ctx.HttpContext.Request.IsAuthenticated == true &&
ctx.HttpContext.Request.Url == new Uri("http://moq.github.io/moq4/") &&
ctx.HttpContext.Response.ContentType == "application/xml");
// Setting up multiple chained mocks:
var context = Mock.Of<ControllerContext>(ctx =>
ctx.HttpContext.Request.Url == new Uri("http://moqthis.me") &&
ctx.HttpContext.Response.ContentType == "application/xml" &&
// Chained mock specification
ctx.HttpContext.GetSection("server") == Mock.Of<ServerSection>(config =>
config.Server.ServerUrl == new Uri("http://moqthis.com/api")));
最後
這篇總結詳細介紹了在單元測試中模擬物件的使用,包括 Fake
、Mock
和 Stub
的概念及區別。針對 Moq
的核心功能進行了深入講解,包括引數匹配、事件處理、回撥操作、屬性值設定、驗證方法呼叫等內容。此外,還介紹了一些高階功能如自定義模擬物件行為、重置模擬物件、設定序列返回值、以及 LINQ to Mocks
的使用方式,後續章節開始我們的單元測試實戰啦。
- Moq GitHub
- Moq wiki
- 本文完整原始碼