Sử dụng Moq để viết unit test trong ASP.NET Core

Ở bài viết trước trong loạt bài hướng dẫn về viết unit test cho ứng dụng ASP.NET Core sử dụng xUnit và Moq, chúng ta đã thảo luận về lý do tại sao, khi nào và cách chúng ta muốn viết các bài kiểm tra unit test.

Tổng quan về unit test với ASP.NET Core, xUnit và Moq
Trong loạt bài này, bạn sẽ tìm hiểu tổng quan về unit test và cách viết unit test cho ứng dụng ASP.NET Core sử dụng xUnit và Moq.

Bây giờ đã đến lúc thiết lập dự án của chúng ta để viết các bài kiểm tra unit test. Trong bài viết này, chúng ta sẽ tạo các lớp fluent mock cho phép chúng ta dễ dàng viết các bài kiểm tra unit test bằng cách sử dụng Moq, xUnit và ASP.NET Core.

Trong các bài viết tiếp theo, chúng ta sẽ sử dụng các lớp mô phỏng (mock) này để viết các bài kiểm tra unit test thực tế.

Kiến trúc ứng dụng và mô hình dữ liệu

Đây là kiến ​​trúc cơ bản của ứng dụng:

Kiến trúc ứng dụng và mô hình dữ liệu

Ở tầng thấp nhất, chúng ta có Repositories. Các lớp này giao tiếp trực tiếp với cơ sở dữ liệu.

Ở tầng tiếp theo là Services. Các lớp này xử lý các nghiệp vụ (business logic) của ứng dụng. Nó gọi các Repositories để lấy dữ liệu từ database hoặc lưu trữ dữ liệu vào database.

Ở tầng cao nhất là Controller. Đây là các lớp Controller của MVC, nó gọi các Services để lấy dữ liệu, xử lý nghiệp vụ, ..., cũng như xử lý view và những thứ khác mà controller thường làm.

Mô hình dữ liệu của dự án như sau:

  • Leagues là giải đấu thể thao, bao gồm nhiều team.
  • Teams là các đội chơi bao gồm nhiều player.
  • Players là những người chơi cho một team cụ thể.

Kiến thức cơ bản về Mocking

Một trong những mục tiêu mà bộ thử nghiệm nên có để kiểm tra một ứng dụng như ứng dụng này là kiểm tra từng lớp độc lập với những lớp khác. Điều đó có nghĩa là để kiểm tra lớp Service, chúng ta cần chạy các bài kiểm tra độc lập với các chức năng trong lớp Repository, nơi mà lớp Service phụ thuộc vào.

Để làm điều này, chúng ta sẽ "mocking" lớp Repository, có nghĩa là chúng ta thiết lập một lớp "giả" trả về một giá trị đã biết cho các lệnh gọi nhất định. Chúng ta thực hiện điều này đối với mỗi lệnh gọi mà lớp Service sẽ thực hiện đối với lớp Repository, hoặc tổng quát hơn là đối với từng phụ thuộc mà lớp đang được kiểm tra có.

Thư viện phổ biến nhất để thực hiện mocking trong ASP.NET là Moq. Do đó, chúng tôi sẽ sử dụng thư viện này trong dự án.

Sử dụng Moq

Moq là một gói NuGet, vì vậy trước khi có thể sử dụng nó, chúng ta cần thêm nó vào dự án của mình thông qua NuGet.

Cách đầu tiên chúng tôi sử dụng Moq là thiết lập một thể hiện "giả" của lớp, như sau:

var mockTeamRepository = new Mock<ITeamRepository>();

Đối tượng mockTeamRepository được tạo sau đó có thể được đưa vào các lớp cần nó, như sau:

var teamService = new TeamService(mockTeamRepository.Object);

Tuy nhiên, cho đến lúc này, lớp mock của chúng ta không thực sự làm bất cứ điều gì; chúng ta cần thiết lập cho phương thức được gọi hoặc trả về giá trị. Để làm được điều đó, chúng ta có thể làm như sau:

mockTeamRepository.Setup(x => x.GetByID(It.IsAny<int>()))
                  .Returns(new Team());

Giải thích đoạn mã trên:

  1. Phương thức .Setup() được sử dụng để chỉ định phương thức cần mocking (trong trường hợp này là GetByID()).
  2. Phương thức GetByID() có một tham số kiểu int. Đoạn mã It.IsAny<int>() chỉ định rằng phương thức GetByID() sẽ được gọi với bất kỳ tham số kiểu int nào. Các ví dụ khác có trên trang Moq GitHub.
  3. Phương thức .Returns() được sử dụng để chỉ định dữ liệu trả về cho phương thức GetByID(). Trong ví dụ này sẽ trả về new Team().

Vì vậy, với bất kỳ số nguyên nào, lớp được mock sẽ trả về new Team().

Đôi khi, bạn có thể muốn có một phương thức ném một ngoại lệ thay vì trả về một đối tượng. Bạn có thể thực hiện việc này bằng phương thức Throws() như sau:

mockTeamRepository.Setup(x => x.GetByID(It.IsAny<int>()))
                  .Throws(new Exception());

Xây dựng một Fluent Mock

Tất cả những điều này đều ổn và tuyệt vời, bởi vì nó cho phép chúng ta dễ dàng mô phỏng các lớp của mình để chúng ta có thể kiểm tra chúng một cách độc lập.

Nhưng trong các bài kiểm tra unit test thực tế, chúng ta thường phải sử dụng các mô phỏng đó trong nhiều thử nghiệm.

Một trong những cách mà nhóm của tôi và tôi đã khám phá ra để hạn chế số lượng mã lặp lại trong các bài kiểm tra của chúng tôi là xây dựng một lớp "fluent mock".

Hãy bắt đầu với một lớp kế thừa từ Mock<>:

public class MockTeamRepository : Mock<ITeamRepository> 
{ 

}

Đối với mỗi phương thức chúng ta muốn giả lập, chúng ta cần gọi phương thức .Setup(), nhưng chúng ta muốn làm như vậy theo cách cho phép chúng ta gọi "chuỗi" các phương thức khi tạo các thể hiện của các lớp mock này. Điều này cho phép một tập hợp các lệnh gọi "fluent".

public class MockTeamRepository : Mock<ITeamRepository> 
{
    public MockTeamRepository MockGetByID(Team result)
    {
        Setup(x => x.GetByID(It.IsAny<int>()))
        .Returns(result);

        return this;
    }

    public MockTeamRepository MockGetForLeague(List<Team> results)
    {
        Setup(x => x.GetForLeague(It.IsAny<int>()))
        .Returns(results);

        return this;
    }
}

Sau đó, chúng ta có thể sử dụng lớp "fluent mock" mới này để tạo một đối tượng giả lập như sau:

var mockTeamRepo = new MockTeamRepository()
                       .MockGetByID(new Team())
                       .MockGetForLeague(new List<Team>());

Xác minh phương thức được gọi

Moq cũng bao gồm một tính năng "Verify" cho phép chúng ta đảm bảo rằng một phương thức mock được gọi với số lần xác định.

Ví dụ: bạn có thể muốn sử dụng tính năng này khi bạn cần thực hiện một số loại xác minh trước khi thực hiện cuộc gọi đến một lớp mock. Hãy xem một ví dụ về phương thức Search() của lớp TeamRepository.

public List<Team> Search(TeamSearch search)
{
    //If we are searching for an invalid or unknown League...
    var isValidLeague = _leagueRepo.IsValid(search.LeagueID);
    if (!isValidLeague)
    {
        return new List<Team>(); //Return an empty list.
    }

    //Otherwise get all teams in the specified league...
    var allTeams = _teamRepo.GetForLeague(search.LeagueID);

    //... and filter them by the specified Founding Date and Direction.
    if(search.Direction == Enums.SearchDateDirection.OlderThan)
    {
        return allTeams.Where(x => x.FoundingDate <= search.FoundingDate).ToList();
    }
    
    return allTeams.Where(x => x.FoundingDate >= search.FoundingDate).ToList();
    
}

Trong phương pháp này, có thể phương thức GetForLeague() của lớp TeamRepository hoàn toàn không được gọi; điều này xảy ra khi Id giải đấu không hợp lệ.

Phương thức Verify sẽ cho phép chúng ta đảm bảo trong quá trình thử nghiệm của mình rằng, khi Id giải đấu không hợp lệ, phương thức GetForLeague() sẽ không bao giờ được gọi.

Sau đó, chúng ta có thể thêm một hàm mới vào lớp MockTeamRepository, như sau:

public class MockTeamRepository : Mock<ITeamRepository> 
{ 
    //...
    
    public MockTeamRepository VerifyGetForLeague(Times times)
    {
        Verify(x => x.GetForLeague(It.IsAny<int>()), times);
        
        return this;
    }
}

Lớp Times được định nghĩa bởi Moq để sử dụng trong những tình huống này; nó cho phép chúng ta chỉ định số lần chúng ta mong đợi phương thức được gọi.

Ví dụ: nếu chúng ta mong đợi nó được gọi chính xác một lần, chúng ta có thể sử dụng phương thức này như sau:

//Arrange
var mockTeamRepo = new MockTeamRepository()
                       .MockGetByID(new Team())
                       .MockGetForLeague(new List<Team>());
                       
var teamSearch = new TeamSearch()
{
    LeagueID = 1
}
                       
var teamService = new TeamService(mockTeamRepo.Object);

//Act
var result = teamService.Search(teamSearch);

//Assert
mockTeamRepo.VerifyGetForLeague(Times.Once());//Expect this to be called exactly once.

Có nhiều giá trị cho lớp Times mà chúng ta có thể sử dụng:

mockTeamRepo.VerifyGetForLeague(Times.Once());
mockTeamRepo.VerifyGetForLeague(Times.AtLeastOnce());
mockTeamRepo.VerifyGetForLeague(Times.AtMostOnce());
mockTeamRepo.VerifyGetForLeague(Times.Never());
mockTeamRepo.VerifyGetForLeague(Times.AtMost(3)); //At most three calls
mockTeamRepo.VerifyGetForLeague(Times.Exactly(5)); //Exactly five calls

Xây dựng lớp Mock

Chúng ta đã tìm hiểu cách xây dựng một lớp fluent mock, bây giờ hãy thực hành xây dựng chúng.

Hãy bắt đầu với lớp TeamRepository. Đây là mã của nó:

public class TeamRepository : ITeamRepository
{
    public Team GetByID(int id)
    {
        throw new NotImplementedException();
    }

    public List<Team> GetForLeague(int leagueID)
    {
        throw new NotImplementedException();
    }
}

Chờ một chút, tại sao không có gì được triển khai trong lớp này?! Để chứng minh một luận điểm: Các chi tiết triển khai thực tế không quan trọng khi mocking một lớp. Vì vậy, hãy viết một số quy tắc cho các phương thức của lớp này.

  • Phương thức GetByID() nên trả về một đối tượng Team nếu một đối tượng được tìm thấy và ném một ngoại lệ nếu không tìm thấy.
  • Phương thức GetForLeague() sẽ trả về một danh sách nếu Id giải đấu hợp lệ và được tìm thấy, và một danh sách trống nếu không tìm thấy.

Với các quy tắc này, chúng ta có thể tạo ra lớp giả lập. Nó như sau:

public class MockTeamRepository : Mock<ITeamRepository>
{
    public MockTeamRepository MockGetByID(Team result)
    {
        Setup(x => x.GetByID(It.IsAny<int>()))
            .Returns(result);

        return this;
    }

    public MockTeamRepository MockGetByIDInvalid()
    {
        Setup(x => x.GetByID(It.IsAny<int>()))
            .Throws(new Exception());

        return this;
    }

    public MockTeamRepository VerifyGetByID(Times times)
    {
        Verify(x => x.GetByID(It.IsAny<int>()), times);

        return this;
    }

    public MockTeamRepository MockGetForLeague(List<Team> results)
    {
        Setup(x => x.GetForLeague(It.IsAny<int>()))
            .Returns(results);

        return this;
    }

    public MockTeamRepository VerifyGetForLeague(Times times)
    {
        Verify(x => x.GetForLeague(It.IsAny<int>()), times);

        return this;
    }
}

Lớp fluent mock này và những lớp khác giống như nó có thể được sử dụng trong các bài kiểm tra của chúng ta như sau:

[Fact]
public void TeamService_Search_OlderThan_Valid()
{
    //Arrange
    var mockTeams = GetMockTeams();

    var mockTeamRepo = new MockTeamRepository().MockGetForLeague(mockTeams);
    var mockLeagueRepo = new MockLeagueRepository().MockIsValid(true);

    var teamService = new TeamService(mockTeamRepo.Object, mockLeagueRepo.Object);

    var searchParams = new TeamSearch()
    {
        LeagueID = 1,
        FoundingDate = new DateTime(2013, 1, 1),
        Direction = SearchDateDirection.OlderThan
    };

    //Act
    var results = teamService.Search(searchParams);

    //Assert
    Assert.NotEmpty(results);
    mockLeagueRepo.VerifyIsValid(Times.Once());
    mockTeamRepo.VerifyGetForLeague(Times.Once());
}

Tương tự là lớp fluent mock cho lớp PlayerRepositoryLeagueRepository:

public class MockPlayerRepository : Mock<IPlayerRepository>
{
    public MockPlayerRepository MockGetByID(Player result)
    {
        Setup(x => x.GetByID(It.IsAny<int>()))
            .Returns(result);

        return this;
    }

    public MockPlayerRepository MockGetForTeam(List<Player> results)
    {
        Setup(x => x.GetForTeam(It.IsAny<int>()))
            .Returns(results);

        return this;
    }

    public MockPlayerRepository VerifyGetForTeam(Times times)
    {
        Verify(x => x.GetForTeam(It.IsAny<int>()), times);

        return this;
    }
}

public class MockLeagueRepository : Mock<ILeagueRepository>
{
    public MockLeagueRepository MockIsValid(bool result)
    {
        Setup(x => x.IsValid(It.IsAny<int>()))
            .Returns(result);

        return this;
    }

    public MockLeagueRepository VerifyIsValid(Times times)
    {
        Verify(x => x.IsValid(It.IsAny<int>()), times);

        return this;
    }
}

Phần kết luận

Sử dụng Moq, chúng ta có thể tạo các lớp fluent mock cho phép chúng ta viết các bài kiểm tra unit test của mình một cách dễ dàng và ngắn gọn hơn.

Các lớp giả lập này có thể được sử dụng lại nhiều lần trong các thử nghiệm của chúng ta và cho phép thiết lập các phương thức giả lập, trả về giá trị và xác minh rằng các phương thức được gọi.

Trong phần tiếp theo của loạt bài này, chúng ta sẽ sử dụng các lớp fluent mock được tạo ở bài viết này, cũng như xUnit và Moq, để thực sự viết một số bài kiểm tra unit test cho lớp nghiệp vụ của ứng dụng.

Unit test cho tầng nghiệp vụ trong ASP.NET Core với xUnit và Moq
Trong bài viết này, chúng ta sẽ viết một số bài kiểm tra unit test cho tầng Service của ứng dụng ASP.NET sử dụng xUnit và Moq.
Unit TestXUnit.NET CoreASP.NET CoreLập Trình C#Mock
Bài Viết Liên Quan:
E2E Test với ASP.NET Core, XUnit và Playwright
Trung Nguyen 15/10/2021
E2E Test với ASP.NET Core, XUnit và Playwright

Trong bài viết này, chúng ta sẽ tìm hiểu cách sử dụng thư viện Playwright kết hợp với XUnit để kiểm tra các ứng dụng web ASP.NET Core như người dùng có thể.

Tạo DataAttribute tùy chỉnh cho Theory của xUnit để tải dữ liệu từ file JSON
Trung Nguyen 22/04/2021
Tạo DataAttribute tùy chỉnh cho Theory của xUnit để tải dữ liệu từ file JSON

Trong bài viết này hướng dẫn bạn cách tạo lớp DataAttribute tùy chỉnh để tải dữ liệu cho bài kiểm tra unit test viết bằng xUnit của bạn.

Tạo các bài kiểm tra unit test được tham số hóa trong xUnit với Theory, InlineData, ClassData và MemberData
Trung Nguyen 18/04/2021
Tạo các bài kiểm tra unit test được tham số hóa trong xUnit với Theory, InlineData, ClassData và MemberData

Trong bài viết này, tôi sẽ hướng dẫn bạn tạo các bài kiểm tra unit test được tham số hóa trong xUnit với Theory, InlineData, ClassData và MemberData

Sử dụng Theory và InlineData của xUnit để viết unit test
Trung Nguyen 18/04/2021
Sử dụng Theory và InlineData của xUnit để viết unit test

Trong bài này, chúng ta sẽ sử dụng thuộc tính [Theory] và [InlineData] của xUnit để nhanh chóng viết một loạt các bài kiểm tra unit test.