Tìm hiểu Unit Testing trong ASP.NET Core

 

Tiếp theo bài viết trước với tiêu đề Tìm hiểu về Dependency Injection trong ASP.NET Core. Hôm nay mình sẽ tập trung vào unit test. Bởi vì DI và unit test luôn đi kèm với nhau nên chúng ta có hai bài viết đi đồng hành. Nếu bạn chưa đọc có thể đọc lại ở link trên nhé.

Unit test là gì?

Unit test đơn giản giúp chúng ta kiểm tra tính đúng đắn của một đơn vị code (hầu hết là các method) có làm việc như mong muốn không? Nó cho phép bạn kiểm tra các phương thức độc lập và kiểm tra các logic với các điều kiện dữ liệu khác nhau.

Tại sao phải dùng Unit test?

Một lý do lớn là unit test giúp dễ dàng thay đổi code hơn nhiều khi chúng ta chắc rằng chúng ta không thay đổi tính năng (refactoring) mà chỉ là tối ưu code thôi. Điều này có nghĩa là unit test giúp bạn có thể dễ dàng phát hiện ra những chỗ bị lỗi khi thay đổi code nhờ các test case báo fail ở bất kỳ function nào. Điều này rất tuyệt vời bởi vì các bug bị giảm đi rất nhiều trong ứng dụng của chúng ta ngay từ các đơn vị code nhỏ nhất. Bug càng phát hiện sớm càng giảm chi phí cho việc fix.

 

 

Unit test làm cho thiết kế tốt hơn

Unit test giúp ảnh hưởng đến thiết kế của chúng ta bằng cách luôn theo xu hướng giảm sự phụ thuộc giữa các module đến mức cao nhất. Nếu một số chỗ không thể test được thì tức là có sự phụ thuộc không hề nhẹ ở đây tức là thiết kế chưa được tối ưu cho việc test cũng như bảo trì (tightly coupled). Khi chúng ta thiết kế các class với unit test trong tư duy bạn sẽ tập trung vào việc tách ra các thành phần độc lập và có thể test được.

Trong một số trường hợp, bạn thường không phải nghĩ về thiết kế. Các unit test sẽ hướng dẫn bạn chắc chắn rằng các logic là đúng và các thành phần được thiết kế tách biệt và giảm sự phụ thuộc thông qua việc sử dụng các abstraction và dependency injection. Điều này có được nếu như chúng ta thiết kế tuân theo Single Reponsibility Principle (nguyên tắc S trong SOLID).

Tìm hiểu về test-driven development

Mình thường viết code ngắn và sau đó viết lên dần dần song song với việc viết các unit test và thực hành như vậy tôi gọi là tư duy hướng test (test driven thinking). Test-driven development (TDD) là một quy trình khác khi mà các test được viết trước cả khi viết code. Các test như là các đặc tả (spec) mà bạn phải viết test khi function chưa được hình thành. Ban đầu test sẽ fail vì chưa có code tồn tại. Sau đó bạn chỉ cần viết code để cho các test case đó pass.

Trong khi TDD thường hướng tới unit testing, unit testing thì lại không yêu cầu phải làm TDD. Tôi muốn các bạn rõ điều này vì nó hơi bị dễ nhầm lẫn giữa các term này.

Thêm unit test vào project

Một cách tiếp cận tốt để unit test trong .NET là thêm các test vào solution của chúng ta dưới dạng class library hoàn toàn tách biệt với các project code chính. Giờ chúng ta sẽ thêm mới một .NET Core Class library vào solution như hình.

 

 

Tiếp đến là thêm một unit test framework, mình sử dụng xUnit được hỗ trợ cho .NET core và làm việc với tính năng có sẵn trong Visual Studio là Test Runner. Nunit cũng là một framework phổ biến khác, có một số khác nhau giữa 2 thằng này nhưng tựu chung là nó gần như nhau.

Thêm các thư viện bổ sung để mock tức là giả lập các đối tượng với các logic khác nhau và dữ liệu khác nhau trong unit test. Điều này có thể tham khảo tại bài viết này về mock, stubbing và faking đối tượng ở đây. Chúng ta sẽ dành 1 phút để sử dụng thư viện Moq để giả lập các hành vi của một interface trong test case, đây là tính năng rất mạnh mẽ của unit test.

Cuối cùng chúng ta cần Add reference từ project test đến project code chính để truy cập các class cho việc test.

Viết unit test

Ok vậy chúng ta đã hiểu một số lý thuyết rồi, bao gồm có việc hiểu khái niệm unit test, tại sao phải dùng chúng. Chúng ta sẽ test ứng dụng với xUnit framework.

Test đầu tiên chúng ta sẽ sử dụng class TeamStatCalculator. Cụ thể là method GetTotalGoalsForSeason()

public class TeamStatCalculator
{
  private readonly ITeamStatRepository _teamStatRepository;

  public TeamStatCalculator(ITeamStatRepository teamStatRepository)
  {
     _teamStatRepository = teamStatRepository;
  }

  public int GetTotalGoalsForSeason(int seasonId)
  {
    // get all the team stats for the given season
    var teamStatsBySeason = _teamStatRepository
    .FindAll(ts => ts.SeasonId == seasonId);

    // sum and return the total goals
    return teamStatsBySeason.Sum(ts => ts.GoalsFor);
  }
}

Cả nhà thấy nó giống với cách viết của project TEDU-17 không? Đi vào chi tiết chúng ta có thể thấy phương thức này đơn giản là query từ _teamStatRepository và lấy ra danh sách các đội bóng theo mùa sau đó gọi hàm Sum() trong Linq để trả ra tổng bàn thắng cho toàn bộ. Quá dễ phải không nào?

Một điều ghi nhớ là chúng ta có thể thấy _teamStatRepository là một thành phần phụ thuộc được inject vào thông qua constructor. Trong thực tế, repository này có thể lấy dữ liệu từ database, một file hoặc một Rest API nào đó nhưng chúng ta không cần quan tâm vì chúng ta ủy thác việc lấy dữ liệu từ nguồn nào cho phần triển khai của IteamStatRepository interface. Chúng ta chỉ quan tâm là với một interface trừu tượng như thế nó có các phương thức trừu tượng và không dính gì đến phần triển khai. Chúng ta sẽ thấy vì sao điều này rất quan trọng ngay sau đây. Thêm nữa, nếu code test của bạn viết vào 1 file, mở database lên thông qua connection hoặc làm gì đó thông qua network thì nó sẽ thành test tích hợp mất (integration test) rồi. Cái này là phạm trù khác của test.

Hãy xem phương thức test dưới đây:

[Fact]
public void GetTotalGoalsForSeason_returns_expected_goal_count()
{
  // ARRANGE 
  var mockTeamStatRepo = new Mock();

  // setup a mock stat repo to return some fake data in our target method
  mockTeamStatRepo
 .Setup(mtsr => mtsr.FindAll(It.IsAny<Func<TeamStatSummary, bool>>()))
 .Returns(new List<TeamStatSummary>
     {
        new TeamStatSummary {SeasonId = 1,Team = "team 1",GoalsFor=1},
        new TeamStatSummary {SeasonId=1,Team = "team 2",GoalsFor=2},
        new TeamStatSummary {SeasonId = 1,Team = "team 3",GoalsFor=3}
     });

  // create our TeamStatCalculator by injecting our mock repository
  var teamStatCalculator = new TeamStatCalculator(mockTeamStatRepo.Object);

  // ACT - call our method under test
  var result = teamStatCalculator.GetTotalGoalsForSeason(1);
 
  // ASSERT - we got the result we expected - our fake data has 6 goals
  we should get this back from the method
  Assert.True(result==6);
}

Cấu trúc unit test

Unit test của chúng ta nên follow theo cách tiếp cận “AAA”:

  • Arrange làm các công việc cài đặt hay chuẩn bị dữ liệu cần thiết;
  • Act là thực thi function hay method cần được test và lấy kết quả.
  • Assert là verify kết quả trả (actual result) ra có đúng như mong muốn không (expected result)

Chúng ta có thể thấy một mớ test code xuất hiện trong phần Arrange. Đó là chúng ta tạo một mock cho IteamStatRepository và định nghĩa vài hành vi của nó cũng như dữ liệu sẽ trả về nếu gọi hàm FindAll(). Chúng ta sẽ inject đối tượng vừa mock vào TeamStatCalculator khi tạo ra nó.

Nếu chúng ta không cho phép inject Repository từ ngoài vào thì không thể giả lập được nó và setup được các hành vi như FindAll() trả về cái gì được mà nó sẽ bị đóng gói cứng ở trong triển khai của TeamStatRepository rồi, vậy là DI rất quan trọng cho unit test.

Khi setup hoàn thành, act sẽ đơn giản là gọi method cần test với GetTotalGoalsForSeason(1) tham số là 1 và nhận kết quả. Chỉ có một dòng nhỏ trong test nhưng nếu bạn hạy test khi debug bạn sẽ thấy nó thực sự kỳ diệu. Đây là sự kỳ diệu của IoC/DI. Chúng ta hoàn toàn nghịch đảo việc tạo repository và có thể khiến nó làm bất cứ điều gì.

Cuối cùng, trong phần assert của chúng ta, bạn biết sẽ có 6 bàn thắng trong test data và vì thế phương thức của bạn làm việc đúng.

 

 

Chạy unit test trong Visual Studio với Test Explorer và nhận kết quả pass màu xanh.

Thay vào đó, bạn có thể run test với command line trong công cụ CLI mới. Trong thư mục project đơn giản là run dotnet test để chạy test runner từ command line.

Unit test controller

Controller là một thành phần quan trọng trong ứng dụng ASP.NET Core, chúng ta cũng phải test nó để đảm bảo toàn bộ luồng hoạt động trơn chu. Chúng ta thường muốn controller của mình nhỏ và không thực hiện bất cứ logic nào hoặc là truy cập dữ liệu. Chúng ta chỉ cần verify là controller action nhận đầu vào sau trả ra kết quả là được.

Hãy viết unit test cho method Index() trong HomeController

public IActionResult Index([FromServices] IGameRepository gameRepository)
{
  var model = new IndexViewModel
  {
    Players = _playerRepository.GetAll().ToList(), // constructor injected
    Games = gameRepository.GetTodaysGames().ToList() // parameter injected
  };

  return View(model);
}

Sau đó chúng ta có hàm test:

[Fact]
public void Index_returns_viewresult_with_list_of_players_and_games()
{
  // ARRANGE 
  var mockPlayerRepo = new Mock();
          
  mockPlayerRepo.Setup(mpr => mpr.GetAll()).Returns(new List
  {
     new Player {Name = "Sidney Crosby"},
     new Player {Name="Patrick Kane"}
  });

 var mockGameRepo = new Mock();

 mockGameRepo.Setup(mpr => mpr.GetTodaysGames()).Returns(new List
 {
   new Game {
            HomeTeam = "Montreal Canadiens",
            AwayTeam = "Toronto Maple Leafs",
            Date = DateTime.Today},
   new Game {
            HomeTeam = "Calgary Flames",
            AwayTeam = "Vancouver Canucks",
            Date = DateTime.Today},
   new Game {
            HomeTeam = "Los Angeles Kings",
            AwayTeam = "Anaheim Ducks",
            Date = DateTime.Today},
 });

 // player repository is injected through constructor
 var controller = new HomeController(mockPlayerRepo.Object);

// ACT 
// game repository is injected through action parameter
var result = controller.Index(mockGameRepo.Object); 

// ASSERT our action result and model
var viewResult = Assert.IsType(result);
var model = Assert.IsAssignableFrom(viewResult.ViewData.Model);
Assert.Equal(2, model.Players.Count);
Assert.Equal(3, model.Games.Count);
}

Không có gì khác biệt hơn khi chúng ta test TeamStatCalculator. Chúng ta cũng vẫn dùng “AAA” và mock vài thành phần phụ thuộc cần có cho controller này như PlayerRepository hay GameRepository, inject chúng vào controller sau đó gọi hàm Index(). Sự khác nhau lớn nhất là phần assert nơi chúng ta kiểm tra ViewResult và model để chắc chắn là nó trả về kết quả đúng. Hơn nữa, đảm bảo rằng controller cũng được test.

Tóm lại

Ok vậy là chúng ta đã tìm hiểu về Unit test trong .NET Core thông qua ví dụ, chúng ta thấy rằng việc viết unit test rất quan trọng và quan trọng hơn là code của chúng ta phải có IoC và DI để hỗ trợ việc test. Nó giúp ứng dụng của chúng ta tăng chất lượng code giảm rủi ro khi thay đổi và giảm effort test lại khi có thay đổi.

Những lợi ích này là không thể phủ nhận, để các bạn nắm rõ hơn về unit test trong dự án thực tế các bạn có thể tham gia khóa học Kỹ thuật Unit test cho .NET Developer tại TEDU

Lên trên