掌握 xUnit 單元測試中的 Mock 與 Stub 實戰

董瑞鹏發表於2024-04-12

引言

上一章節介紹了 TDD 的三大法則,今天我們講一下在單元測試中模擬物件的使用。

Fake

Fake - Fake 是一個通用術語,可用於描述 stubmock 物件。 它是 stub 還是 mock 取決於使用它的上下文。 也就是說,Fake 可以是 stubmock

Mock - Mock 物件是系統中的 fake 物件,用於確定單元測試是否透過。 Mock 起初為 Fake,直到對其斷言。

Stub - Stub 是系統中現有依賴項的可控制替代項。 透過使用 Stub,可以在無需使用依賴項的情況下直接測試程式碼。

參考 單元測試最佳做法 讓我們使用相同的術語

區別點:

  1. Stub
    • 用於提供可控制的替代行為,通常是在測試中模擬依賴項的簡單行為。
    • 主要用於提供固定的返回值或行為,以便測試程式碼的特定路徑。
    • 不涉及對方法呼叫的驗證,只是提供一個虛擬的實現。
  2. Mock
    • 用於驗證方法的呼叫和行為,以確保程式碼按預期工作。
    • 主要用於確認特定方法是否被呼叫,以及被呼叫時的引數和次數。
    • 可以設定期望的呼叫順序、引數和返回值,並在測試結束時驗證這些呼叫。

總結:

  • Stub 更側重於提供一個簡單的替代品,幫助測試程式碼路徑,而不涉及行為驗證。
  • Mock 則更側重於驗證程式碼的行為和呼叫,以確保程式碼按預期執行。

在某些情況下兩者可能看起來相似,但在測試的目的和用途上還是存在一些區別。在編寫單元測試時,根據測試場景和需求選擇合適的 stubmock物件可以幫助提高測試的準確性和可靠性。

建立實戰專案

建立一個 WebApi Controller 專案,和一個EFCore倉儲類庫作為我們後續章節的演示專案

dotNetParadise-Xunit
│
├── src
│   ├── Sample.Api
│   └── Sample.Repository

Sample.Repository 是一個簡單 EFCore 的倉儲模式實現,Sample.Api 對外提供 RestFulApi 介面

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 專案跑一下

image

到這兒我們的專案已經建立完成了本系列後面的章節基本上都會以這個專案為基礎展開擴充

控制器的單元測試

[單元測試涉及透過基礎結構和依賴項單獨測試應用的一部分。 單元測試控制器邏輯時,僅測試單個操作的內容,不測試其依賴項或框架自身的行為。

本章節主要以控制器的單元測試來帶大家瞭解一下StupMoq的核心區別。

建立一個新的測試專案,然後新增Sample.Api的專案引用

image

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

      //先暫時省略後面測試方法....

}

image

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

    }

    //先暫時省略後面測試方法....
}

看一下執行測試

image

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有三種方式去設定非同步方法的返回值分別是:

  1. 使用 .Result 屬性(Moq 4.16 及以上版本):

    • 在 Moq 4.16 及以上版本中,您可以直接透過 mock.Setup 返回任務的 .Result 屬性來設定非同步方法的返回值。這種方法幾乎適用於所有設定和驗證表示式。
    • 示例:
      mock.Setup(foo => foo.DoSomethingAsync().Result).Returns(true);
  2. 使用 ReturnsAsync(較早版本):

    • 在較早版本的 Moq 中,您可以使用類似 ReturnsAsyncThrowsAsync 等輔助方法來設定非同步方法的返回值。
    • 示例:
      mock.Setup(foo => foo.DoSomethingAsync()).ReturnsAsync(true);
  3. 使用 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 物件的屬性設定行為,包括 getset 的行為。
 {
    // 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 方法來一次性存根(StubMock 物件的所有屬性。這意味著所有屬性都會開始跟蹤其值,並可以提供預設值。以下是一個示例演示如何使用 SetupAllProperties 方法:

// 存根(Stub)Mock 物件的所有屬性
mock.SetupAllProperties();

透過使用 SetupProperty 方法,可以更靈活地設定 Mock 物件的屬性行為和預設值,以滿足單元測試中的需求

處理事件(Events

Moq 4.13 及以後的版本中,你可以透過配置事件的 addremove 訪問器來模擬事件的行為。這允許你指定當事件處理器被新增或移除時應該發生的邏輯。這通常用於驗證事件是否被正確新增或移除,或者模擬事件觸發時的行為。

  • 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 來設定事件的訂閱和移除行為,並驗證事件處理程式的新增和移除是否按預期工作。讓我簡單解釋一下這段程式碼的流程:

  1. 建立一個 Mock 物件 mock,模擬 HasEvent 類。
  2. 使用 SetupAdd 方法設定事件的訂閱行為,並使用 CallBase 方法呼叫基類的實現。
  3. 訂閱事件並設定事件處理邏輯,將事件處理程式 eventHandler 新增到事件中。
  4. 呼叫 RaiseEvent 方法觸發事件,並透過斷言驗證事件處理程式是否被正確處理。
  5. handled 標誌重置為 false
  6. 使用 SetupRemove 方法設定事件的移除行為,並使用 CallBase 方法呼叫基類的實現。
  7. 移除事件處理程式 eventHandler
  8. 再次觸發事件,並透過斷言驗證事件處理程式是否被正確移除。

透過這個測試示例,可以驗證事件處理程式的新增和移除操作是否正常工作

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

使用 MoqCallback 方法可以捕獲方法呼叫時的引數,允許我們在測試中訪問和處理這些引數。透過在 Setup 方法中指定 Callback 操作,我們可以捕獲方法呼叫時傳入的引數,並在回撥中執行自定義邏輯,例如將引數新增到列表中。這種方法可以幫助我們驗證方法在不同引數下的行為,以及檢查方法是否被正確呼叫和傳遞引數。總的來說,Callback 方法為我們提供了一種靈活的方式來處理方法呼叫時的引數,幫助我們編寫更全面的單元測試。


  • SetupProperty
    SetupProperty 方法可用於設定 Mock 物件的屬性,併為其提供 gettersetter
        {
            //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 方法的作用包括:

  1. 設定屬性的初始值:透過 SetupProperty 方法,我們可以設定 Mock 物件屬性的初始值,使其在測試中具有特定的初始狀態。

  2. 模擬屬性的 getter 和 setterSetupProperty 方法允許我們為屬性設定 gettersetter,使我們能夠訪問和修改屬性的值。

  3. 捕獲屬性的設定操作:在設定 Mock 物件的屬性時,可以使用 Callback 方法捕獲設定操作,以執行自定義邏輯或記錄屬性的設定情況。

  4. 驗證屬性的行為:透過設定屬性和相應的行為,可以驗證屬性的行為是否符合預期,以確保程式碼的正確性和可靠性

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

VerifyAddVerifyRemove 方法來驗證事件的訂閱和移除

// 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 介面具有一個虛擬屬性 BarBar 類有一個虛擬方法 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.StrictDefaultValue.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 MocksMoq 提供的一種宣告性規範查詢方式,使得您可以透過 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")));

最後

這篇總結詳細介紹了在單元測試中模擬物件的使用,包括 FakeMockStub 的概念及區別。針對 Moq 的核心功能進行了深入講解,包括引數匹配、事件處理、回撥操作、屬性值設定、驗證方法呼叫等內容。此外,還介紹了一些高階功能如自定義模擬物件行為、重置模擬物件、設定序列返回值、以及 LINQ to Mocks 的使用方式,後續章節開始我們的單元測試實戰啦。

  • Moq GitHub
  • Moq wiki
  • 本文完整原始碼

相關文章