Chuyển đến nội dung chính

Test Coverage in .NET: Run All Tests không có nghĩa là Code đã được Test - Day 1

Chạy tất cả Unit Test và nhận kết quả Passed có đồng nghĩa với việc toàn bộ code đã được kiểm tra?

Câu trả lời là không.

Hãy tưởng tượng bạn vừa hoàn thành một chức năng mới trong ứng dụng .NET. Bạn chạy toàn bộ Unit Test và nhận được kết quả:

Passed: 120
Failed: 0

Mọi test đều thành công. Tuy nhiên, có thể một method mới chưa được bất kỳ test nào gọi đến, hoặc một nhánh xử lý quan trọng chưa bao giờ được thực thi.

Nếu không có thêm thông tin, chúng ta khó biết được những phần code nào thực sự đã được kiểm tra.

Trong bài viết này, chúng ta sẽ tìm hiểu Code Coverage, tạo Coverage Report cho một project .NET và sử dụng báo cáo để tìm những phần code chưa được test.

1. Code Coverage là gì?

Code Coverage là một phép đo cho biết phần code có thể thực thi nào đã được chạy trong quá trình thực hiện test.

Ví dụ, một ứng dụng có 100 dòng code có thể thực thi. Trong quá trình chạy test, 70 dòng được thực thi. Line Coverage có thể đạt 70%.

Con số này giúp chúng ta nhận biết những khu vực chưa được test, nhưng không có nghĩa 70 dòng đó đã được kiểm tra đúng về mặt nghiệp vụ.

Hãy phân biệt hai khái niệm:

  • Test Result: Các test có thành công hay thất bại?
  • Code Coverage: Những phần code nào đã được thực thi khi chạy test?

Hai thông tin này bổ sung cho nhau. Test Passed cho biết các kiểm tra trong test đã thành công; Coverage giúp phát hiện những phần code mà test suite chưa chạm tới.

2. Tạo project demo

Chúng ta sẽ xây dựng một ví dụ nhỏ về hệ thống quản lý thư viện. Khi thành viên trả sách muộn, thư viện tính phí 5.000 VNĐ cho mỗi ngày trễ.

Các quy tắc nghiệp vụ gồm:

  • Trả sách đúng hạn: không mất phí.
  • Trả sách muộn: phí bằng số ngày trễ nhân với 5.000 VNĐ.
  • Số ngày trễ âm là dữ liệu không hợp lệ và phải phát sinh exception.

Đầu tiên, tạo solution và hai project bằng .NET CLI. Các lệnh sau có thể chạy trong PowerShell:

dotnet new sln -n LibraryManagement

dotnet new classlib -n LibraryManagement

dotnet new xunit -n LibraryManagement.Tests

dotnet sln LibraryManagement.slnx add LibraryManagement/LibraryManagement.csproj

dotnet sln LibraryManagement.slnx add LibraryManagement.Tests/LibraryManagement.Tests.csproj

dotnet add LibraryManagement.Tests/LibraryManagement.Tests.csproj reference LibraryManagement/LibraryManagement.csproj

Nếu SDK của bạn tạo solution với tên hoặc cấu trúc khác, hãy sử dụng đúng tên file solution được tạo trên máy.

Cấu trúc project lúc này:

LibraryManagement/
├── LibraryManagement.slnx
├── LibraryManagement/
│   ├── LibraryManagement.csproj
│   └── Class1.cs
└── LibraryManagement.Tests/
    ├── LibraryManagement.Tests.csproj
    └── UnitTest1.cs

Xóa hai file mẫu Class1.cs và UnitTest1.cs. Sau đó tạo các file dưới đây.

3. Viết code nghiệp vụ

Tạo file LibraryService.cs trong project LibraryManagement:

namespace LibraryManagement;

public class LibraryService
{
    private const decimal DailyFine = 5000m;

    public decimal CalculateLateFee(int overdueDays)
    {
        if (overdueDays < 0)
        {
            throw new ArgumentException(
                "Overdue days cannot be negative.");
        }

        if (overdueDays == 0)
        {
            return 0;
        }

        return overdueDays * DailyFine;
    }
}

Hãy đọc method CalculateLateFee() theo thứ tự thực thi:

  1. Nếu số ngày trễ nhỏ hơn 0, method phát sinh ArgumentException.
  2. Nếu số ngày trễ bằng 0, method trả về 0.
  3. Trong các trường hợp còn lại, method tính phí bằng số ngày trễ nhân với 5.000.

Method này có nhiều đường xử lý. Nếu test chỉ kiểm tra trường hợp trả sách đúng hạn, những phần code còn lại có thể chưa được thực thi.

4. Viết Unit Test đầu tiên

Trong project LibraryManagement.Tests, tạo file LibraryServiceTests.cs:

using LibraryManagement;
using Xunit;

namespace LibraryManagement.Tests;

public class LibraryServiceTests
{
    [Fact]
    public void CalculateLateFee_WhenReturnedOnTime_ReturnsZero()
    {
        var service = new LibraryService();

        var result = service.CalculateLateFee(0);

        Assert.Equal(0m, result);
    }
}

Test này kiểm tra một trường hợp: thành viên trả sách đúng hạn thì phí trễ hạn phải bằng 0.

Hãy chú ý ba bước trong test:

  • Arrange: Tạo đối tượng LibraryService.
  • Act: Gọi CalculateLateFee(0).
  • Assert: Kiểm tra kết quả bằng 0m.

Đây là cấu trúc Arrange – Act – Assert thường được sử dụng để viết Unit Test dễ đọc.

Chạy test:

dotnet test

Nếu project được tạo và tham chiếu đúng, test sẽ Passed. Tuy nhiên, chúng ta mới chỉ xác nhận một trường hợp nghiệp vụ. Bước tiếp theo là kiểm tra phạm vi code được thực thi.

Kết quả:
> dotnet test
Restore complete (0.4s)
  LibraryManagement net10.0 succeeded (0.2s) → D:\Practices\Test\LibraryManagement\LibraryManagement\bin\Debug\net10.0\LibraryManagement.dll
  LibraryManagement.Tests net10.0 succeeded (0.2s) → bin\Debug\net10.0\LibraryManagement.Tests.dll
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.1.4+50e68bbb8b (64-bit .NET 10.0.13)
[xUnit.net 00:00:00.40]   Discovering: LibraryManagement.Tests
[xUnit.net 00:00:00.45]   Discovered:  LibraryManagement.Tests
[xUnit.net 00:00:00.47]   Starting:    LibraryManagement.Tests
[xUnit.net 00:00:00.63]   Finished:    LibraryManagement.Tests
  LibraryManagement.Tests test net10.0 succeeded (3.2s)

Test summary: total: 1, failed: 0, succeeded: 1, skipped: 0, duration: 3.2s
Build succeeded in 4.6s

5. Thu thập Code Coverage bằng .NET CLI

Để thu thập Coverage, trước tiên cần cài package coverlet.collector vào project test:

dotnet add LibraryManagement.Tests/LibraryManagement.Tests.csproj package coverlet.collector

Package này cung cấp collector có tên XPlat Code Coverage, cho phép thu thập dữ liệu Coverage khi chạy test.

Bây giờ, chạy lệnh:

dotnet test LibraryManagement.Tests/LibraryManagement.Tests.csproj --collect:"XPlat Code Coverage"

Nếu test thành công, kết quả sẽ bao gồm thông tin về test và thư mục chứa dữ liệu Coverage. Đường dẫn có thể tương tự như sau:

LibraryManagement.Tests/
└── TestResults/
    └── <test-run-id>/
        └── coverage.cobertura.xml

Phần <test-run-id> đại diện cho thư mục được tạo cho lần chạy test. Tên thư mục thực tế sẽ khác nhau giữa các lần chạy.

File coverage.cobertura.xml chứa dữ liệu Coverage ở định dạng Cobertura. Đây là dữ liệu đầu vào cho công cụ tạo báo cáo; nó không phải báo cáo HTML dễ đọc dành cho người dùng.

6. Tạo báo cáo HTML bằng ReportGenerator

Để xem Coverage thuận tiện hơn, chúng ta sẽ dùng ReportGenerator để chuyển file XML thành báo cáo HTML.

Cài công cụ bằng lệnh:

dotnet tool install --global dotnet-reportgenerator-globaltool

Nếu công cụ đã được cài đặt trước đó, bạn không cần cài lại. Có thể kiểm tra bằng:

reportgenerator -version

Tiếp theo, chạy test và tạo report từ thư mục gốc của solution:

dotnet test LibraryManagement.Tests/LibraryManagement.Tests.csproj --collect:"XPlat Code Coverage"

reportgenerator "-reports:LibraryManagement.Tests/TestResults/**/coverage.cobertura.xml" "-targetdir:CoverageReport" "-reporttypes:Html"

Trong lệnh trên:

  • -reports: chỉ định file Coverage XML đầu vào. Ký tự ** giúp tìm file trong các thư mục con.
  • -targetdir: chỉ định thư mục chứa báo cáo được tạo ra.
  • -reporttypes:Html: yêu cầu tạo báo cáo HTML.

Nếu đường dẫn project hoặc thư mục kết quả trên máy bạn khác ví dụ, hãy điều chỉnh đường dẫn tương ứng.

Sau khi lệnh hoàn tất, mở file:

CoverageReport/index.html

Báo cáo sẽ hiển thị các thông tin như tỷ lệ Coverage, danh sách class và chi tiết code được thực thi. Tùy phiên bản và cấu hình công cụ, giao diện có thể khác nhau.

7. Đọc báo cáo và tìm code chưa được test

Quay lại LibraryService.CalculateLateFee(). Trong test hiện tại, chúng ta chỉ truyền overdueDays = 0.

Điều gì xảy ra khi test chạy?

flowchart TD
    A["CalculateLateFee(0)"] --> B{"overdueDays < 0?"}
    B -->|False| C{"overdueDays == 0?"}
    C -->|True| D["return 0"]
    D --> E["Test Passed"]
    B -->|True| F["Throw ArgumentException"]
    C -->|False| G["Calculate late fee"]

Luồng thực thi cho thấy test hiện tại đi qua nhánh trả về 0. Hai đường xử lý còn lại chưa được kiểm tra bằng test này.

Trong Coverage Report, hãy tìm class LibraryService và xem các dòng được đánh dấu là covered hoặc uncovered. ReportGenerator thường sử dụng màu sắc để phân biệt các dòng đã và chưa được thực thi.

Với test hiện tại, chúng ta kỳ vọng:

  • Điều kiện kiểm tra số ngày âm đã được đánh giá, nhưng nhánh phát sinh exception chưa được chạy.
  • Nhánh overdueDays == 0 được thực hiện.
  • Dòng tính phí trễ hạn chưa được thực hiện.

Lưu ý: Tỷ lệ Line Coverage và Branch Coverage cụ thể phải lấy từ báo cáo thực tế. Không nên mặc định rằng một test sẽ tạo ra một tỷ lệ phần trăm cố định, vì kết quả phụ thuộc vào các dòng được công cụ tính là executable và cấu hình đo lường.

8. Bổ sung test cho phần code còn thiếu

Sau khi xác định các khoảng trống, chúng ta có thể bổ sung test cho những trường hợp nghiệp vụ quan trọng.

Thêm hai test sau vào LibraryServiceTests.cs:

[Fact]
public void CalculateLateFee_WhenOverdue_ReturnsExpectedFee()
{
    var service = new LibraryService();

    var result = service.CalculateLateFee(3);

    Assert.Equal(15000m, result);
}

[Fact]
public void CalculateLateFee_WhenDaysAreNegative_ThrowsException()
{
    var service = new LibraryService();

    Assert.Throws<ArgumentException>(
        () => service.CalculateLateFee(-1));
}

Hai test này kiểm tra hai hành vi khác nhau:

  • Trả sách muộn 3 ngày phải chịu phí 15.000 VNĐ.
  • Số ngày trễ âm phải phát sinh ArgumentException.

Chạy lại test và tạo báo cáo:

dotnet test LibraryManagement.Tests/LibraryManagement.Tests.csproj --collect:"XPlat Code Coverage"

reportgenerator "-reports:LibraryManagement.Tests/TestResults/**/coverage.cobertura.xml" "-targetdir:CoverageReport" "-reporttypes:Html"

Mở lại CoverageReport/index.html để so sánh kết quả với lần trước. Sau khi bổ sung test, các nhánh xử lý quan trọng của method sẽ được thực thi nhiều hơn.

Nếu muốn so sánh chính xác hai lần chạy, hãy lưu báo cáo của lần đầu vào một thư mục riêng trước khi tạo báo cáo mới.

9. Coverage cao có đồng nghĩa với code không có bug?

Không. Code Coverage chỉ cho biết phần code nào đã được thực thi, chứ không chứng minh rằng test đã kiểm tra đầy đủ mọi hành vi.

Ví dụ, nếu một test gọi method tính phí nhưng không kiểm tra kết quả bằng assertion phù hợp, method vẫn có thể được tính là covered. Tuy nhiên, test đó có thể không phát hiện được lỗi tính toán.

Vì vậy, khi đọc Coverage Report, hãy đặt ba câu hỏi:

  1. Những dòng code nào chưa được thực thi?
  2. Những nhánh nghiệp vụ nào chưa được kiểm tra?
  3. Các test hiện tại có thực sự xác nhận kết quả mong đợi không?

Không nhất thiết phải đạt 100% Coverage cho mọi project. Điều quan trọng là ưu tiên kiểm tra các business rule quan trọng, những trường hợp biên và các đường xử lý lỗi có ảnh hưởng lớn.

10. Tổng kết

Trong bài viết này, chúng ta đã:

  • Phân biệt Test Result và Code Coverage.
  • Tạo một project .NET nhỏ để tính phí trả sách trễ hạn.
  • Thu thập Coverage bằng dotnet test --collect:"XPlat Code Coverage".
  • Sử dụng ReportGenerator để tạo báo cáo HTML.
  • Đọc báo cáo để tìm code chưa được test và bổ sung test tương ứng.

Thông điệp quan trọng nhất là: Test Passed không có nghĩa là toàn bộ code đã được kiểm tra. Coverage giúp chúng ta nhận biết những khoảng trống, còn các test với assertion phù hợp mới giúp xác nhận hành vi của chương trình.

Ở bài tiếp theo, chúng ta sẽ tìm hiểu sâu hơn về Line Coverage và Branch Coverage, đồng thời khám phá vì sao một method có Line Coverage cao vẫn có thể bỏ sót những nhánh logic quan trọng.

Nhận xét

Bài đăng phổ biến từ blog này

[ASP.NET MVC] Authentication và Authorize

Một trong những vấn đề bảo mật cơ bản nhất là đảm bảo những người dùng hợp lệ truy cập vào hệ thống. ASP.NET đưa ra 2 khái niệm: Authentication và Authorize Authentication xác nhận bạn là ai. Ví dụ: Bạn có thể đăng nhập vào hệ thống bằng username và password hoặc bằng ssh. Authorization xác nhận những gì bạn có thể làm. Ví dụ: Bạn được phép truy cập vào website, đăng thông tin lên diễn đàn nhưng bạn không được phép truy cập vào trang mod và admin.

Tổng hợp một số kiến thức lập trình về Amibroker

Giới thiệu về Amibroker Amibroker theo developer Tomasz Janeczko được xây dựng dựa trên ngôn ngữ C. Vì vậy bộ code Amibroker Formula Language sử dụng có syntax khá tương đồng với C, ví dụ như câu lệnh #include để import hay cách gói các object, hàm trong các block {} và kết thúc câu lệnh bằng dấu “;”. AFL trong Amibroker là ngôn ngữ xử lý mảng (an array processing language). Nó hoạt động dựa trên các mảng (các dòng/vector) số liệu, khá giống với cách hoạt động của spreadsheet trên excel.

ASP.NET MVC: Cơ bản về Validation

Validation (chứng thực) là một tính năng quan trọng trong ASP.NET MVC và được phát triển trong một thời gian dài. Validation vắng mặt trong phiên bản đầu tiên của asp.net mvc và thật khó để tích hợp 1 framework validation của một bên thứ 3 vì không có khả năng mở rộng. ASP.NET MVC2 đã hỗ trợ framework validation do Microsoft phát triển, tên là Data Annotations. Và trong phiên bản 3, framework validation đã hỗ trợ tốt hơn việc xác thực phía máy khách, và đây là một xu hướng của việc phát triển ứng dụng web ngày nay.