Mocking Dependencies với Moq và NSubstitute: Cô lập Unit Test Hiệu Quả trong Lộ Trình .NET

Chào mừng bạn quay trở lại với series Lộ trình .NET! Sau khi đã làm quen với việc lựa chọn các framework kiểm thử Unit như xUnit, NUnit hay MSTest, chúng ta biết rằng Unit Test là một phần không thể thiếu trong quy trình phát triển phần mềm hiện đại. Unit Test giúp chúng ta kiểm tra từng đơn vị code (unit) một cách độc lập, đảm bảo chúng hoạt động đúng như mong đợi và cung cấp “tấm lưới an toàn” khi thực hiện các thay đổi hay refactor code.

Tuy nhiên, trong các ứng dụng thực tế, các “unit” của chúng ta hiếm khi tồn tại một mình. Chúng thường phụ thuộc (dependency) vào các service, database, hệ thống file, API bên ngoài, hoặc các component khác. Việc test một unit khi nó có nhiều phụ thuộc “thật” sẽ gặp phải nhiều vấn đề:

* **Tốc độ:** Việc gọi database, API, hoặc hệ thống file trong mỗi unit test sẽ làm chậm quá trình chạy test.
* **Sự ổn định:** Test có thể thất bại vì các yếu tố bên ngoài không liên quan đến unit đang test (database offline, API trả về lỗi mạng, file không tồn tại…).
* **Tính độc lập:** Unit test sẽ không còn “unit” nữa, mà trở thành integration test hoặc thậm chí là end-to-end test, kiểm tra cả logic của các phụ thuộc. Điều này làm khó xác định nguyên nhân lỗi.
* **Khả năng kiểm tra các kịch bản khó:** Rất khó để mô phỏng các trường hợp ngoại lệ, lỗi mạng, hoặc các dữ liệu trả về cụ thể từ các phụ thuộc thật.

Đây chính là lúc **Mocking** phát huy sức mạnh. Mocking cho phép chúng ta tạo ra các đối tượng “giả” (fake objects) thay thế cho các phụ thuộc thật trong quá trình test. Những đối tượng giả này (thường gọi là mocks hoặc substitutes) được cấu hình để trả về các giá trị cụ thể hoặc thực hiện các hành động nhất định khi được gọi, cho phép chúng ta tập trung kiểm tra logic của unit code mà không bị ảnh hưởng bởi các phụ thuộc bên ngoài.

Trong bài viết này, chúng ta sẽ đi sâu vào hai thư viện mocking phổ biến nhất trong hệ sinh thái .NET: **Moq** và **NSubstitute**. Chúng ta sẽ tìm hiểu cách sử dụng chúng, so sánh ưu nhược điểm, và nắm vững các best practices để viết unit test hiệu quả hơn.

Hãy bắt đầu hành trình làm chủ kỹ thuật mocking này nhé!

Mocking là gì và Tại sao Cần Mock?

Hiểu một cách đơn giản, **Mocking** là kỹ thuật tạo ra các đối tượng thay thế (thường là implementations của interfaces hoặc derived classes của abstract classes) cho các phụ thuộc của đối tượng đang được test (System Under Test – SUT). Những đối tượng giả này không chứa logic thật của phụ thuộc, mà chỉ được cấu hình để phản hồi theo kịch bản test mong muốn.

Tại sao chúng ta cần mocking?

* **Cô lập Unit Test:** Đây là lợi ích chính. Mocking giúp chúng ta kiểm tra *chỉ* logic của unit đang test, loại bỏ ảnh hưởng của các phụ thuộc. Nếu test thất bại, chúng ta biết chắc chắn vấn đề nằm ở unit đó, không phải ở các service bên ngoài hay database.
* **Tăng tốc độ chạy test:** Các mock objects phản hồi ngay lập tức theo cấu hình, không tốn thời gian gọi ra các tài nguyên bên ngoài (database, network…). Điều này giúp bộ test suite chạy nhanh hơn đáng kể.
* **Kiểm tra các kịch bản phức tạp/ngoại lệ:** Bạn có thể dễ dàng cấu hình mock để giả lập các trường hợp như service trả về lỗi, database rỗng, timeout… Các kịch bản này rất khó (hoặc tốn kém) để tái tạo với phụ thuộc thật.
* **Giảm chi phí và sự phức tạp:** Không cần thiết lập môi trường test phức tạp với database, service khác… chỉ để chạy unit test.
* **Thúc đẩy thiết kế tốt hơn:** Việc sử dụng mocking thường khuyến khích chúng ta thiết kế code theo hướng phụ thuộc vào abstraction (interfaces) thay vì concrete classes. Điều này là một practice tốt, đặc biệt khi kết hợp với Dependency Injection (DI), giúp code dễ test và dễ bảo trì hơn. Chúng ta đã thảo luận về DI trong các bài trước như Hiểu Rõ Vòng Đời Dịch Vụ: Scoped, Transient, Singleton.

Trong .NET, Moq và NSubstitute là hai trong số các thư viện mocking mạnh mẽ và phổ biến nhất. Chúng ta hãy cùng tìm hiểu cách sử dụng từng loại.

Giới thiệu về Moq

Moq (phát âm là “Mock-you”) là một framework mocking mã nguồn mở rất phổ biến cho .NET, được xây dựng dựa trên cú pháp LINQ expressions, mang lại sự linh hoạt và dễ đọc.

Để bắt đầu với Moq, bạn cần thêm package NuGet vào project test của mình:

dotnet add package Moq

Giả sử chúng ta có một interface `IEmailService` và một class `UserService` phụ thuộc vào nó:

// Dependency Interface
public interface IEmailService
{
    bool SendEmail(string recipient, string subject, string body);
}

// Class to be tested (System Under Test - SUT)
public class UserService
{
    private readonly IEmailService _emailService;

    public UserService(IEmailService emailService)
    {
        _emailService = emailService;
    }

    public bool RegisterUser(string username, string email)
    {
        // Some registration logic...
        bool registrationSuccessful = true; // Simplified

        if (registrationSuccessful)
        {
            // Send confirmation email - This is the dependency call we want to mock
            return _emailService.SendEmail(email, "Welcome!", $"Hi {username}, welcome aboard!");
        }

        return false;
    }
}

Bây giờ, chúng ta muốn test phương thức `RegisterUser` của `UserService` mà không thực sự gửi email.

Tạo Mock

Với Moq, bạn tạo một instance của `Mock` nơi `T` là interface hoặc abstract class bạn muốn mock:

using Moq;
using Xunit; // hoặc NUnit/MSTest

public class UserServiceTests
{
    [Fact]
    public void RegisterUser_SuccessfulRegistration_SendsEmail()
    {
        // Arrange
        var mockEmailService = new Mock<IEmailService>();

        // SUT instance, injecting the mock
        var userService = new UserService(mockEmailService.Object);

        string username = "testuser";
        string email = "test@example.com";

        // ... continued below
    }
}

Lưu ý: Chúng ta tiêm `mockEmailService.Object` vào `UserService`. `.Object` là thuộc tính trả về instance giả của `IEmailService` mà Moq đã tạo.

Thiết lập Hành vi (Setup)

Bước tiếp theo là nói cho mock biết phải làm gì khi một phương thức cụ thể được gọi. Moq sử dụng phương thức `Setup`:

    // ... inside RegisterUser_SuccessfulRegistration_SendsEmail method

    // Arrange (continued)
    // Setup: When SendEmail is called with ANY string arguments, return true.
    mockEmailService
        .Setup(service => service.SendEmail(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
        .Returns(true); // Configure it to return true

    // Act
    bool result = userService.RegisterUser(username, email);

    // Assert
    // ... continued below
}

* `Setup(service => service.SendEmail(…))`: Chỉ định phương thức nào trên mock mà chúng ta muốn thiết lập.
* `It.IsAny()`: Đây là Moq argument matcher. Nó cho phép chúng ta nói rằng chúng ta không quan tâm đến giá trị cụ thể của đối số, miễn là nó là kiểu `string`. Moq cung cấp nhiều loại matcher khác như `It.Is(predicate)`, `It.Ref()`, v.v.
* `.Returns(true)`: Cấu hình mock để trả về giá trị `true` khi phương thức `SendEmail` được gọi với các đối số phù hợp.

Bạn cũng có thể thiết lập dựa trên các đối số cụ thể:

// Setup: When SendEmail is called exactly with "test@example.com", "Welcome!", "Hi testuser, welcome aboard!", return true.
mockEmailService
    .Setup(service => service.SendEmail("test@example.com", "Welcome!", "Hi testuser, welcome aboard!"))
    .Returns(true);

Kiểm tra Lượt gọi (Verification)

Một phần quan trọng của mocking không chỉ là thiết lập hành vi mà còn là kiểm tra xem các phương thức trên mock có được gọi *đúng số lần* với *đúng đối số* hay không. Moq sử dụng phương thức `Verify`:

    // ... inside RegisterUser_SuccessfulRegistration_SendsEmail method

    // Assert (continued)
    Assert.True(result);

    // Verify: Check if SendEmail was called exactly once with specific arguments
    mockEmailService.Verify(
        service => service.SendEmail("test@example.com", "Welcome!", "Hi testuser, welcome aboard!"),
        Times.Once); // Ensure it was called exactly one time

    // You could also verify with It.IsAny if you only care it was called
    // mockEmailService.Verify(
    //     service => service.SendEmail(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()),
    //     Times.Once);
}

`Times.Once` là một trong nhiều tùy chọn mà Moq cung cấp (ví dụ: `Times.Never`, `Times.AtLeastOnce`, `Times.Exactly(n)`, v.v.).

Thiết lập Properties

Bạn có thể thiết lập giá trị cho properties trên mock:

mockEmailService.SetupGet(service => service.ServiceName).Returns("FakeEmailService");
// Sau đó trong code test SUT của bạn, khi SUT truy cập service.ServiceName, nó sẽ nhận được "FakeEmailService"

Xử lý Exceptions

Bạn có thể cấu hình mock để ném ra exception khi một phương thức được gọi:

mockEmailService
    .Setup(service => service.SendEmail(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
    .Throws(new Exception("Failed to send email"));

// Sau đó, trong test, bạn có thể kiểm tra xem SUT có xử lý exception này đúng cách không.

Với cú pháp dựa trên LINQ expression, Moq rất mạnh mẽ và linh hoạt.

Giới thiệu về NSubstitute

NSubstitute là một thư viện mocking thân thiện và dễ sử dụng khác cho .NET. Triết lý thiết kế của NSubstitute tập trung vào sự rõ ràng và ngắn gọn, sử dụng cú pháp gần gũi với cách chúng ta viết code C# thông thường.

Để bắt đầu với NSubstitute, thêm package NuGet:

dotnet add package NSubstitute

Chúng ta sẽ sử dụng lại interface `IEmailService` và class `UserService` ở ví dụ trên để so sánh.

Tạo Substitute

Với NSubstitute, bạn sử dụng phương thức `Substitute.For`:

using NSubstitute;
using Xunit; // hoặc NUnit/MSTest

public class UserServiceTests
{
    [Fact]
    public void RegisterUser_SuccessfulRegistration_SendsEmail_NSubstitute()
    {
        // Arrange
        var mockEmailService = Substitute.For<IEmailService>(); // Using Substitute.For

        // SUT instance, injecting the substitute
        var userService = new UserService(mockEmailService); // Pass the substitute directly

        string username = "testuser";
        string email = "test@example.com";

        // ... continued below
    }
}

Lưu ý: Với NSubstitute, bạn chỉ cần truyền instance được tạo bởi `Substitute.For()` trực tiếp vào SUT, không cần thuộc tính `.Object`.

Thiết lập Hành vi (Setup/Arrange)

NSubstitute sử dụng cú pháp khác, thiên về việc “ghi lại” (record) hành vi mong muốn:

    // ... inside RegisterUser_SuccessfulRegistration_SendsEmail_NSubstitute method

    // Arrange (continued)
    // Setup: When SendEmail is called with ANY string arguments, return true.
    mockEmailService
        .SendEmail(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>()) // Call the method on the substitute
        .Returns(true); // Chain the Returns method

    // Act
    bool result = userService.RegisterUser(username, email);

    // Assert
    // ... continued below
}

* `mockEmailService.SendEmail(…)`: Bạn gọi trực tiếp phương thức trên substitute.
* `Arg.Any()`: Đây là NSubstitute argument matcher, tương tự như `It.IsAny()` trong Moq. NSubstitute cũng có các matcher khác như `Arg.Is(predicate)`, `Arg.Do(action)`, v.v.
* `.Returns(true)`: Thiết lập giá trị trả về.

Thiết lập với đối số cụ thể:

// Setup: When SendEmail is called exactly with "test@example.com", "Welcome!", "Hi testuser, welcome aboard!", return true.
mockEmailService
    .SendEmail("test@example.com", "Welcome!", "Hi testuser, welcome aboard!")
    .Returns(true);

Kiểm tra Lượt gọi (Verification)

NSubstitute sử dụng phương thức `Received` để kiểm tra lượt gọi:

    // ... inside RegisterUser_SuccessfulRegistration_SendsEmail_NSubstitute method

    // Assert (continued)
    Assert.True(result);

    // Verify: Check if SendEmail was called exactly once with specific arguments
    mockEmailService
        .Received(1) // Check for 1 call
        .SendEmail("test@example.com", "Welcome!", "Hi testuser, welcome aboard!"); // Specify the expected call

    // You could also verify with Arg.Any
    // mockEmailService
    //     .Received(1)
    //     .SendEmail(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
}

`Received(1)` tương đương với `Times.Once` trong Moq. NSubstitute cũng hỗ trợ `Received(n)`, `DidNotReceive()`, v.v.

Thiết lập Properties

Thiết lập properties trong NSubstitute rất trực quan:

mockEmailService.ServiceName.Returns("FakeEmailService");
// Sau đó trong code test SUT của bạn, khi SUT truy cập service.ServiceName, nó sẽ nhận được "FakeEmailService"

Xử lý Exceptions

Sử dụng cú pháp tương tự như thiết lập giá trị trả về, nhưng với `Returns(x => throw new Exception(…))`:

mockEmailService
    .SendEmail(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
    .Returns(x => throw new Exception("Failed to send email"));

// Sau đó, trong test, bạn có thể kiểm tra xem SUT có xử lý exception này đúng cách không.

NSubstitute có cú pháp rất “fluent” và gần gũi với code C# thông thường, giúp người mới bắt đầu dễ tiếp cận.

Moq và NSubstitute: So Sánh Trực Quan

Moq và NSubstitute đều là những thư viện mocking tuyệt vời, cung cấp đầy đủ các tính năng cần thiết để viết unit test hiệu quả. Sự khác biệt chủ yếu nằm ở cú pháp và “triết lý” sử dụng. Moq sử dụng LINQ expressions và cú pháp `Setup/Verify`, trong khi NSubstitute sử dụng cú pháp “record/replay” và `Returns/Received`.

Đây là bảng so sánh các khía cạnh chính:

Tính năng/Khía cạnh Moq NSubstitute
Cách tạo Mock/Substitute new Mock<T>(), sử dụng .Object để lấy instance Substitute.For<T>(), sử dụng instance trực tiếp
Cú pháp thiết lập hành vi (Setup) Sử dụng .Setup(...) với LINQ expression Gọi trực tiếp phương thức/property trên substitute, sau đó gọi .Returns(...)
Cú pháp kiểm tra lượt gọi (Verify) Sử dụng .Verify(...) với LINQ expression và Times enum Gọi .Received(...) trên substitute, sau đó gọi lại phương thức/property
Đối số bất kỳ (Any argument) It.IsAny<T>(), It.Is<T>(...) Arg.Any<T>(), Arg.Is<T>(...)
Thiết lập Property .SetupGet(...), .SetupSet(...) Gọi trực tiếp property và dùng .Returns(...)
Ném Exception Sử dụng .Throws(...) Sử dụng .Returns(x => throw ...)
Khả năng Mock Concrete Classes/Abstract Classes Có, với các ràng buộc (phương thức/property phải là virtual hoặc abstract) Có, với các ràng buộc tương tự (sử dụng Substitute.For<ConcreteClass>())

Lựa chọn giữa Moq và NSubstitute thường mang tính cá nhân và phụ thuộc vào sở thích về cú pháp. Moq đã tồn tại lâu hơn và rất phổ biến, trong khi NSubstitute được đánh giá là có cú pháp đơn giản, dễ đọc hơn cho nhiều người. Cả hai đều được cộng đồng hỗ trợ tốt và liên tục được cập nhật. Điều quan trọng là hiểu được nguyên lý mocking và cách áp dụng nó vào test của bạn.

Best Practices Khi Mocking

Sử dụng mocking một cách không hợp lý có thể dẫn đến “mocking hell” – tình trạng test quá phức tạp, khó hiểu và dễ vỡ. Dưới đây là một số best practices để tránh điều đó:

1. **Chỉ Mock Dependencies, Không Mock SUT:** Mục đích của unit test là kiểm tra *logic* của unit code của bạn (SUT). Bạn không bao giờ nên mock chính đối tượng đang được test. Hãy tạo instance thật của SUT và tiêm (inject) các mock/substitute dependencies vào nó.
2. **Mock Interfaces hoặc Abstract Classes:** Hạn chế tối đa việc mock concrete classes. Mocking interfaces giúp bạn tuân thủ nguyên tắc Dependency Inversion Principle và Interface Segregation Principle, làm code của bạn linh hoạt và dễ test hơn. Khi mock concrete classes, bạn bị ràng buộc phải mock các phương thức/properties `virtual` hoặc `abstract`, điều này hạn chế khả năng test và có thể là dấu hiệu của thiết kế cần cải thiện.
3. **Chỉ Mock những gì cần thiết:** Đừng mock mọi thứ! Chỉ mock những phụ thuộc bên ngoài unit bạn đang test, những thứ gây khó khăn cho việc test (gọi API, database, file system…). Nếu một phụ thuộc là một pure function hoặc một object giá trị đơn giản, có thể không cần mock nó.
4. **Tuân thủ nguyên tắc AAA (Arrange-Act-Assert):** Cấu trúc test rõ ràng theo 3 bước:
* **Arrange:** Khởi tạo SUT, tạo và cấu hình các mock dependencies, chuẩn bị dữ liệu test.
* **Act:** Thực thi phương thức hoặc hành động trên SUT mà bạn muốn test.
* **Assert:** Kiểm tra kết quả trả về của SUT, trạng thái nội bộ của SUT, và (quan trọng!) kiểm tra xem các phương thức trên mock dependencies có được gọi như mong đợi hay không (verification).
5. **Tránh Over-Mocking:** Nếu một test cần mock quá nhiều đối tượng hoặc có quá nhiều dòng code chỉ để setup các mock, đó có thể là dấu hiệu:
* Unit code của bạn quá lớn hoặc có quá nhiều trách nhiệm (High Coupling). Hãy thử chia nhỏ nó.
* Bạn đang mock những thứ không cần thiết.
Over-mocking làm test trở nên “giòn” (brittle) – dễ vỡ khi có những thay đổi nhỏ trong implement details của SUT hoặc dependencies, ngay cả khi logic kinh doanh vẫn đúng.
6. **Sử dụng Argument Matchers hợp lý:** Dùng `It.IsAny` (Moq) hoặc `Arg.Any` (NSubstitute) khi bạn *thực sự không quan tâm* đến giá trị cụ thể của đối số. Ngược lại, hãy kiểm tra (verify) với các giá trị cụ thể để đảm bảo SUT đã truyền đúng dữ liệu cho dependency.

Tích hợp với Các Framework Kiểm Thử và DI

Các thư viện mocking như Moq và NSubstitute hoạt động trơn tru với tất cả các framework kiểm thử unit phổ biến trong .NET, bao gồm xUnit, NUnit và MSTest, mà chúng ta đã tìm hiểu trong bài viết về Lựa Chọn Framework Kiểm Thử Unit cho .NET.

Đặc biệt, mocking kết hợp rất tốt với mô hình Dependency Injection. Khi sử dụng DI container (như built-in DI trong ASP.NET Core hoặc các container khác), bạn có thể dễ dàng thay thế các implement dependency thật bằng các mock instance trong môi trường test. Thay vì đăng ký service thật, bạn đăng ký instance mock/substitute, và container sẽ tự động tiêm nó vào SUT của bạn.

// Ví dụ cấu hình trong test setup (sử dụng HostBuilder cho integration test hoặc tương tự)
// Giả sử bạn đang test một Controller hoặc Service cần IEmailService
var mockEmailService = new Mock<IEmailService>();
mockEmailService.Setup(...); // Cấu hình mock

var host = new HostBuilder()
    .ConfigureWebHostDefaults(webBuilder =>
    {
        webBuilder.UseStartup<Startup>(); // Sử dụng Startup class thật của ứng dụng
        // Override service registration với mock instance
        webBuilder.ConfigureServices(services =>
        {
            // Tìm và gỡ bỏ đăng ký IEmailService thật (nếu có)
            var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IEmailService));
            if (descriptor != null)
            {
                services.Remove(descriptor);
            }

            // Đăng ký mock instance
            services.AddSingleton<IEmailService>(mockEmailService.Object);

            // Đăng ký SUT (ví dụ: Controller hoặc Service) nếu nó chưa được đăng ký trong Startup
            // services.AddTransient<MyServiceBeingTested>();
        });
    })
    .Build();

// Lấy SUT từ service provider của host và tiến hành test
// var serviceBeingTested = host.Services.GetRequiredService<MyServiceBeingTested>();
// ... test logic ...
// mockEmailService.Verify(...);

Việc này cho phép bạn kiểm tra các component lớn hơn (như Controller) trong môi trường gần với thực tế nhưng vẫn kiểm soát được hành vi của các phụ thuộc bên ngoài, như chúng ta đã thấy trong bài viết về Kiểm thử Tích hợp với WebApplicationFactory.

Kết Luận

Mocking là một kỹ năng không thể thiếu đối với bất kỳ lập trình viên .NET chuyên nghiệp nào, đặc biệt khi xây dựng các ứng dụng phức tạp và có nhiều phụ thuộc. Việc làm chủ Moq hoặc NSubstitute sẽ giúp bạn viết unit test tốt hơn, đảm bảo chất lượng code, tăng tốc độ phát triển và mang lại sự tự tin khi thay đổi code.

Trong lộ trình phát triển .NET của bạn, việc học cách test hiệu quả, bao gồm cả mocking, là một bước tiến quan trọng sau khi đã nắm vững các kiến thức nền tảng về C#, hệ sinh thái .NET, database, và Dependency Injection.

Hãy dành thời gian thực hành với Moq hoặc NSubstitute trong các dự án của bạn. Bắt đầu với những unit test đơn giản, từng bước làm quen với cú pháp và các tính năng nâng cao. Bạn sẽ sớm nhận ra giá trị to lớn mà mocking mang lại cho quy trình phát triển của mình.

Bài viết tiếp theo trong series Lộ trình .NET sẽ đi sâu hơn vào các khía cạnh khác của testing hoặc các chủ đề quan trọng khác. Hãy đón đọc nhé!

Chúc bạn thành công trên hành trình chinh phục .NET!

Chỉ mục