Unit test cho controller trong ASP.NET Core với xUnit và Moq

Trong phần trước của loạt bài, chúng ta đã 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 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.

Hãy tiếp tục bằng cách viết một bộ kiểm tra unit test cho tầng Controller của ứng dụng ASP.NET Core MVC.

Viết unit test cho lớp LeagueController

Đầu tiên, chúng ta hãy xem mã của lớp LeagueController như sau:

public class LeagueController : Controller
{
    private readonly ILeagueService _leagueService;

    public LeagueController(ILeagueService leagueService)
    {
        _leagueService = leagueService;
    }

    public IActionResult Index()
    {
        var leagues = _leagueService.GetAll();
        return View(leagues);
    }
}

Chỉ có một phương thức hành động là Index(). Vì vậy, chúng tôi chỉ cần xem xét các trường hợp thử nghiệm cho phương thức hành động này. Do không có đầu vào, tôi chỉ thấy hai tình huống thử nghiệm:

  1. KHÔNG CÓ giải đấu nào được trả về.
  2. CÓ các giải đấu được trả về.

Do đó, các thử nghiệm của chúng ta phải phù hợp với các tình huống này. Đầu tiên chúng ta cần tạo lớp fluent mock cho lớp LeagueService như sau:

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

        return this;
    }

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

        return this;
    }

    public MockLeagueService MockGetAll(List<League> results)
    {
        Setup(x => x.GetAll())
            .Returns(results);

        return this;
    }

    public MockLeagueService VerifyGetAll(Times times)
    {
        Verify(x => x.GetAll(), times);

        return this;
    }
}

Unit test #1: KHÔNG CÓ giải đấu nào được trả về

[Fact]
public void LeagueController_Index_NoLeagues()
{
    //Arrange
    var mockLeagueService = new MockLeagueService().MockGetAll(new List<League>());

    var controller = new LeagueController(mockLeagueService.Object);

    //Act
    var result = controller.Index();

    //Assert
    Assert.IsAssignableFrom<ViewResult>(result);
    mockLeagueService.VerifyGetAll(Times.Once());
}

Unit test #2: CÓ các giải đấu được trả về

[Fact]
public void LeagueController_Index_LeaguesExist()
{
    //Arrange
    var mockLeagues = new List<League>()
    {
        new League()
        {
            ID = 1,
            FoundingDate = new DateTime(1933, 5, 3)
        }
    };

    var mockLeagueService = new MockLeagueService().MockGetAll(mockLeagues);

    var controller = new LeagueController(mockLeagueService.Object);

    //Act
    var result = controller.Index();

    //Assert
    Assert.IsAssignableFrom<ViewResult>(result);
    mockLeagueService.VerifyGetAll(Times.Once());
}

Cho đến lúc này, các bài kiểm tra unit test cho tầng controller của chúng ta không có sự khác biệt đáng kể so tầng nghiệp vụ của ứng dụng. Tuy nhiên sẽ có một chút khác biệt khi chúng ta viết các bài kiểm tra unit test cho lớp TeamController.

Viết unit test cho lớp TeamController

Đây là mã của lớp TeamController:

public class TeamController : Controller
{
    private readonly ITeamService _teamService;

    public TeamController(ITeamService teamService)
    {
        _teamService = teamService;
    }

    [HttpGet]
    public IActionResult Search()
    {
        var teamSearch = new TeamSearch();
        return View(teamSearch);
    }

    [HttpPost]
    public IActionResult Search(TeamSearch search)
    {
        if(!ModelState.IsValid)
        {
            return RedirectToAction("Search"); //POST-REDIRECT-GET pattern assumed, but not implemented.
        }

        var results = _teamService.Search(search);

        if(!results.Any())
        {
            return RedirectToAction("Search");
        }
        
        search.Results = results;
        return View(search);
    }
}

Bây giờ chúng ta có hai phương thức hành động và một trong chúng dựa vào thuộc tính ModelState để đưa ra các quyết định hợp lý.

Từ những phương thức hành động này, tôi thấy bốn trường hợp thử nghiệm sau:

  1. Trong phiên bản HTTP GET của phương thức hành động Search(), hãy kiểm tra xem ViewResult có được trả về không. Không cần kiểm tra thêm, vì đó là kết quả duy nhất có thể có của phương thức hành động này.
  2. Trong phiên bản HTTP POST của phương thức hành động Search(), nếu ModelState.IsValidfalse, hãy kiểm tra xem RedirectToActionResult có được trả về không.
  3. Trong phiên bản HTTP POST của phương thức hành động Search(), nếu ModelState.IsValidtrue VÀ không có đội nào được trả về, hãy kiểm tra xem RedirectToActionResult có được trả về không.
  4. Trong phiên bản HTTP POST của phương thức hành động Search(), nếu ModelState.IsValidtrue và có đội được trả về, hãy kiểm tra xem ViewResult có được trả về không.

Bạn có thể tự hỏi tại sao kịch bản 2 và 3 được liệt kê riêng biệt, vì chúng được mong đợi sẽ trả về cùng một kết quả trong các điều kiện tương tự.

Lý do chính là trong kịch bản 3, phương thức _teamService.Search() sẽ được gọi, trong khi trong kịch bản 2, nó sẽ không được gọi.

Bởi vì unit test cũng là kiểm thử hồi quy, chúng ta muốn biết khi nào hành động thay đổi các yếu tố phụ thuộc của nó và kiểm tra unit test các tình huống đó một cách riêng biệt là một cách tốt để làm điều đó.

Với những tình huống này, chúng ta hãy tạo lớp fluent mock và viết các bài kiểm tra:

public class MockTeamService : Mock<ITeamService>
{
    public MockTeamService MockSearch(List<Team> results)
    {
        Setup(x => x.Search(It.IsAny<TeamSearch>()))
            .Returns(results);

        return this;
    }

    public MockTeamService VerifySearch(Times times)
    {
        Verify(x => x.Search(It.IsAny<TeamSearch>()), times);

        return this;
    }
}

Unit test #1: Search GET

[Fact]
public void TeamController_Search_Get_Valid()
{
    //Arrange
    var teamResults = new List<Team>()
    {
        new Team() { ID = 1 }
    };

    var mockTeamService = new MockTeamService().MockSearch(teamResults);

    var controller = new TeamController(mockTeamService.Object);

    //Act
    var result = controller.Search();

    //Assert
    Assert.IsAssignableFrom<ViewResult>(result);
}
Lưu ý việc sử dụng phương thức Assert.IsAssignableFrom<>(). Đây là cách tuyệt vời để kiểm tra kiểu dữ liệu IActionResult thường được trả về từ các lớp controller trong ứng dụng ASP.NET Core MVC.

Unit test #2: Search POST, ModelState không hợp lệ

[Fact]
public void TeamController_Search_Post_ModelStateInvalid()
{
    //Arrange
    var teamResults = new List<Team>()
    {
        new Team() { ID = 1 }
    };

    var mockTeamService = new MockTeamService().MockSearch(teamResults);

    var controller = new TeamController(mockTeamService.Object);
    controller.ModelState.AddModelError("Test", "Test");

    //Act
    var result = controller.Search(new TeamSearch());

    //Assert
    Assert.IsAssignableFrom<RedirectToActionResult>(result);
    mockTeamService.VerifySearch(Times.Never());

    var redirectToAction = (RedirectToActionResult)result; //See note below.
    Assert.Equal("Search", redirectToAction.ActionName);
}

Tôi muốn bạn đặc biệt chú ý đến hai dòng cuối cùng trong bài kiểm tra unit test này. Nó kiểm tra xem phương thức hành động được chuyển hướng có phải là hành động mà chúng ta mong đợi hay không và đây là cách thực hiện.

Bạn có thể muốn thực hiện điều này khi một hành động có thể chuyển hướng đến nhiều nơi khác nhau, tùy thuộc vào đầu vào và logic của phương thức.

Unit test #3: Search POST, ModelState hợp lệ, không có kết quả tìm kiếm

[Fact]
public void TeamController_Search_Post_NoResults()
{
    //Arrange
    var mockTeamService = new MockTeamService().MockSearch(new List<Team>());

    var controller = new TeamController(mockTeamService.Object);

    //Act
    var result = controller.Search(new TeamSearch());

    //Assert
    Assert.IsAssignableFrom<RedirectToActionResult>(result);
    mockTeamService.VerifySearch(Times.Once());
}

Unit test #4: Search POST, ModelState hợp lệ, có kết quả tìm kiếm

[Fact]
public void TeamController_Search_Post_Valid()
{
    //Arrange
    var teamResults = new List<Team>()
    {
        new Team() { ID = 1 }
    };
    var mockTeamService = new MockTeamService().MockSearch(teamResults);

    var controller = new TeamController(mockTeamService.Object);

    //Act
    var result = controller.Search(new TeamSearch());

    //Assert
    Assert.IsAssignableFrom<ViewResult>(result);
    mockTeamService.VerifySearch(Times.Once());
}

Viết unit test cho lớp PlayerController

Cuối cùng, hãy xem mã của lớp PlayerController như sau:

public class PlayerController : Controller
{
    private readonly ILeagueService _leagueService;
    private readonly IPlayerService _playerService;

    public PlayerController(ILeagueService leagueService,
                            IPlayerService playerService)
    {
        _leagueService = leagueService;
        _playerService = playerService;
    }

    public IActionResult Index(int id)
    {
        try
        {
            var player = _playerService.GetByID(id);
            return View(player);
        }
        catch (Exception)
        {
            return RedirectToAction("Error", "Home");
        }
    }

    public IActionResult League(int id)
    {
        if (!_leagueService.IsValid(id))
        {
            return RedirectToAction("Error", "Home");
        }

        var players = _playerService.GetForLeague(id);
        return View(players);
    }
}

Có hai phương thức hành động trong controller này, mỗi phương thức hành động có hai kịch bản cần được kiểm tra. Chúng được liệt kê ở dưới đây:

  1. Trong phương thức hành động Index(), Id cầu thủ không hợp lệ trả về RedirectToActionResult.
  2. Trong phương thức hành động Index(), Id cầu thủ hợp lệ trả về ViewResult.
  3. Trong phương thức hành động League(), Id giải đấu không hợp lệ trả về RedirectToActionResult.
  4. Trong phương thức hành động League(), Id giải đấu hợp lệ trả về ViewResult.

Bây giờ, chúng ta có thể viết lớp fluent mock và các bài kiểm tra unit test cho các kịch bản này.

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

        return this;
    }

    public MockPlayerService MockGetByIDInvalid()
    {
        Setup(x => x.GetByID(It.IsAny<int>()))
            .Throws(new Exception("Player with that ID was not found!"));

        return this;
    }

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

        return this;
    }

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

        return this;
    }

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

        return this;
    }
}

Unit test #1: Phương thức hành động Index, Id cầu thủ không hợp lệ

[Fact]
public void PlayerController_Index_Invalid()
{
    //Arrange
    var mockPlayerService = new MockPlayerService().MockGetByIDInvalid();

    var controller = new PlayerController(new MockLeagueService().Object,
                                          mockPlayerService.Object);

    //Act
    var result = controller.Index(1); //ID doesn't matter

    //Assert
    Assert.IsAssignableFrom<RedirectToActionResult>(result);
    mockPlayerService.VerifyGetByID(Times.Once());
}

Unit test #2: Phương thức hành động Index, Id cầu thủ hợp lệ

[Fact]
public void PlayerController_Index_Valid()
{
    //Arrange
    var mockPlayer = new Player()
    {
        ID = 1
    };

    var mockPlayerService = new MockPlayerService().MockGetByID(mockPlayer);

    var controller = new PlayerController(new MockLeagueService().Object,
                                          mockPlayerService.Object);

    //Act
    var result = controller.Index(1); //ID doesn't matter

    //Assert
    Assert.IsAssignableFrom<ViewResult>(result);
    mockPlayerService.VerifyGetByID(Times.Once());
}

Unit test #3: Phương thức hành động League, Id giải đấu không hợp lệ

[Fact]
public void PlayerController_League_Invalid()
{
    //Arrange
    var mockLeagueService = new MockLeagueService().MockIsValid(false);
    var mockPlayerService = new MockPlayerService().MockGetForLeague(new List<Player>());

    var controller = new PlayerController(mockLeagueService.Object,
                                          mockPlayerService.Object);

    //Act
    var result = controller.League(1); //ID doesn't matter

    //Assert
    Assert.IsAssignableFrom<RedirectToActionResult>(result);
    mockPlayerService.VerifyGetForLeague(Times.Never());
    mockLeagueService.VerifyIsValid(Times.Once());
}
Lưu ý rằng trong trường hợp này, chúng tôi muốn xác nhận rằng _playerService.GetForLeague() chưa bao giờ được gọi.

Unit test #4: Phương thức hành động League, Id giải đấu hợp lệ

[Fact]
public void PlayerController_League_Valid()
{
    //Arrange
    var mockPlayers = new List<Player>()
    {
        new Player()
        {
            ID = 1
        }
    };

    var mockLeagueService = new MockLeagueService().MockIsValid(true);
    var mockPlayerService = new MockPlayerService().MockGetForLeague(mockPlayers);

    var controller = new PlayerController(mockLeagueService.Object,
                                          mockPlayerService.Object);

    //Act
    var result = controller.League(1); //ID doesn't matter

    //Assert
    Assert.IsAssignableFrom<ViewResult>(result);
    mockPlayerService.VerifyGetForLeague(Times.Once());
    mockLeagueService.VerifyIsValid(Times.Once());
}

Nhận xét

Sự khác biệt chính mà bạn thấy khi viết các bài kiểm tra unit test cho tầng Controller của ứng dụng ASP.NET MVC là:

  • Bạn phải thiết lập các thuộc tính của lớp Controller theo cách thủ công mà bạn mong đợi (ví dụ: đoạn mã controller.ModelState.AddModelError("Test", "Test"); sẽ làm cho thuộc tính ModelState.IsValid có giá trị là false). Điều này cũng có nghĩa là nếu bạn cần các giá trị trong các thuộc tính khác của đối tượng Controller, chẳng hạn như Request, được thiết lập để chức năng của bạn được kiểm tra, bạn phải thiết lập chúng trước khi chạy thử nghiệm.
  • Tốt hơn là sử dụng Assert.IsAssignableFrom<>() để kiểm tra xem kiểu dữ liệu IActionResult trả về có đúng như những gì bạn mong đợi hay không.

Tóm lược

Việc viết các bài kiểm tra unit test cho Controller của ASP.NET Core MVC không quá khác biệt so với các bài kiểm tra unit test cho các lớp khác, chỉ khác nhau ở thiết lập lớp controller và sử dụng Assert.IsAssignableFrom<>() để kiểm tra kiểu dữ liệu kết quả trả về của các phương thức hành động.

Trong phần tiếp theo và cũng là bài viết cuối cùng của loạt bài này, chúng ta sẽ sử dụng các thuộc tính [Theory][InlineData] của XUnit để viết unit test. Nó cho phép bạn có thể chạy nhiều thử nghiệm với cùng một kết quả mong đợi chỉ trong một vài dòng mã.

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.
Unit TestXUnitMockMoqASP.NET CoreASP.NET Core MVCASP.NET Core Web API
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.