Skip to content

0.4 - Solution, thư viện và unit test ​

Tóm tắt: Ứng dụng thật hiếm khi chỉ có một project. Thường có một project chứa logic nghiệp vụ, một project giao diện hoặc API dùng logic đó, và một project test kiểm tra logic. Solution gom các project đó lại để build và test bằng một lệnh. Bài này dựng solution Crm gồm thư viện Crm.Core và project test Crm.Core.Tests dùng xUnit, viết một hàm tính giảm giá có lỗi, rồi để test bắt lỗi đó trước khi nó tới tay khách hàng. Từ Module 1, nhiều bài tập sẽ yêu cầu bạn viết test theo đúng cách này.

Mục tiêu bài học ​

Sau bài này bạn có thể:

  • Tạo solution, thư viện (class library) và project test bằng dòng lệnh, nối chúng với nhau.
  • Viết test với [Fact] và [Theory], theo cấu trúc Arrange, Act, Assert.
  • Chạy dotnet test, đọc kết quả khi test fail và sửa code cho test pass.
  • Giải thích vì sao logic nghiệp vụ nên nằm trong thư viện riêng, tách khỏi phần nhập xuất.

Cần biết trước: đã làm xong 0.3 — Debug và đọc thông báo lỗi.

Nội dung bài học ​

0.4.1 — Dựng solution ​

Chạy lần lượt các lệnh sau trong một thư mục trống:

bash
mkdir Crm
cd Crm

dotnet new sln -n Crm
dotnet new classlib -n Crm.Core -o src/Crm.Core
dotnet new xunit -n Crm.Core.Tests -o tests/Crm.Core.Tests

dotnet sln add src/Crm.Core tests/Crm.Core.Tests
dotnet reference add src/Crm.Core/Crm.Core.csproj --project tests/Crm.Core.Tests

Kết quả:

text
Crm/
├── Crm.slnx                       ← solution: danh sách các project
├── src/
│   └── Crm.Core/                  ← thư viện chứa logic nghiệp vụ
│       ├── Crm.Core.csproj
│       └── Class1.cs              ← file mẫu, xóa đi
└── tests/
    └── Crm.Core.Tests/            ← project test
        ├── Crm.Core.Tests.csproj
        └── UnitTest1.cs           ← file mẫu, xóa đi

Từng lệnh làm gì:

LệnhTác dụng
dotnet new slnTạo file solution. Từ .NET 10, file có đuôi .slnx, định dạng XML dễ đọc. Tài liệu cũ hay project cũ dùng .sln, cả hai đều chạy được.
dotnet new classlibTạo thư viện: code dùng chung, không tự chạy được.
dotnet new xunitTạo project test dùng xUnit, framework test phổ biến nhất trong hệ sinh thái .NET.
dotnet sln addĐưa project vào solution, để dotnet build hay dotnet test ở thư mục gốc xử lý tất cả.
dotnet reference addCho project test tham chiếu tới thư viện, để code test gọi được class trong Crm.Core.

Mở file Crm.slnx để thấy nó đơn giản thế nào:

xml
<Solution>
  <Folder Name="/src/">
    <Project Path="src/Crm.Core/Crm.Core.csproj" />
  </Folder>
  <Folder Name="/tests/">
    <Project Path="tests/Crm.Core.Tests/Crm.Core.Tests.csproj" />
  </Folder>
</Solution>

Trong VS Code, panel Solution Explorer của C# Dev Kit (dưới cùng panel Explorer) hiện cấu trúc này dưới dạng cây.

0.4.2 — Vì sao tách thư viện riêng? ​

Ở bài 0.2 và 0.3, mọi thứ nằm chung một file Program.cs: đọc dữ liệu, tính toán, in kết quả. Viết test cho code kiểu đó rất khó, vì muốn kiểm tra phép tính thì phải giả lập người gõ phím.

Tách ra thì mỗi phần làm một việc:

  • Crm.Core chỉ tính toán. Nhận số vào, trả số ra, không đọc bàn phím, không in màn hình.
  • Project console hay Web API (bài 0.5) lo nhập xuất, rồi gọi Crm.Core.
  • Crm.Core.Tests gọi thẳng Crm.Core với dữ liệu tự chọn và so sánh kết quả.

Cách tách này đi theo bạn suốt khóa học, tới tận Clean Architecture ở Module 16.

0.4.3 — Một hàm nghiệp vụ có lỗi ​

Xóa Class1.cs, tạo src/Crm.Core/DiscountCalculator.cs:

csharp
namespace Crm.Core;

public static class DiscountCalculator
{
    // Khách VIP giảm 10%, đơn từ 1.000.000đ giảm thêm 5%
    public static decimal FinalPrice(decimal total, bool isVip)
    {
        var rate = 0m;
        if (isVip) rate += 0.10m;
        if (total > 1_000_000m) rate += 0.05m;
        return total * (1 - rate);
    }
}

0m, 0.10m: hậu tố m nghĩa là kiểu decimal, kiểu dùng cho tiền. Bài 1.1 giải thích vì sao không dùng double cho tiền. 1_000_000m có dấu gạch dưới chỉ để dễ đọc, giá trị vẫn là một triệu.

Đoạn code trông đúng. Nhưng chú thích ghi "từ 1.000.000đ", còn code viết >. Ta để test phát hiện điều đó.

0.4.4 — Viết test ​

Xóa UnitTest1.cs, tạo tests/Crm.Core.Tests/DiscountCalculatorTests.cs:

csharp
namespace Crm.Core.Tests;

public class DiscountCalculatorTests
{
    [Fact]
    public void RegularCustomer_SmallOrder_NoDiscount()
    {
        var result = DiscountCalculator.FinalPrice(200_000m, isVip: false);
        Assert.Equal(200_000m, result);
    }

    [Fact]
    public void VipCustomer_Gets10PercentOff()
    {
        var result = DiscountCalculator.FinalPrice(500_000m, isVip: true);
        Assert.Equal(450_000m, result);
    }

    [Fact]
    public void OrderOfExactly1Million_Gets5PercentOff()
    {
        var result = DiscountCalculator.FinalPrice(1_000_000m, isVip: false);
        Assert.Equal(950_000m, result);
    }
}
  • [Fact] đánh dấu một phương thức là một test.
  • Tên test mô tả tình huống và kết quả mong đợi. Khi test fail, chỉ đọc tên là biết quy tắc nghiệp vụ nào bị vi phạm.
  • Mỗi test có ba phần, gọi là Arrange, Act, Assert: chuẩn bị dữ liệu, gọi hàm cần test, so sánh kết quả. Ở đây phần chuẩn bị ngắn nên gộp chung với phần gọi hàm.
  • Assert.Equal(mongDoi, thucTe): giá trị mong đợi đứng trước. Viết ngược thì thông báo lỗi sẽ in ngược và gây khó hiểu.
  • isVip: false là named argument: ghi rõ tên tham số để người đọc không phải đoán false nghĩa là gì.

Test thứ ba được chọn có chủ đích: giá trị nằm đúng trên đường biên của quy tắc. Lỗi hay nằm ở biên nhất, vì đó là chỗ > và >= cho kết quả khác nhau.

0.4.5 — Chạy test và đọc kết quả ​

Ở thư mục Crm (thư mục chứa Crm.slnx):

bash
dotnet test
text
[xUnit.net 00:00:00.50]     Crm.Core.Tests.DiscountCalculatorTests.OrderOfExactly1Million_Gets5PercentOff [FAIL]
  Failed Crm.Core.Tests.DiscountCalculatorTests.OrderOfExactly1Million_Gets5PercentOff [2 ms]
  Error Message:
   Assert.Equal() Failure: Values differ
Expected: 950000
Actual:   1000000
  Stack Trace:
     at Crm.Core.Tests.DiscountCalculatorTests.OrderOfExactly1Million_Gets5PercentOff() in D:\code\Crm\tests\Crm.Core.Tests\DiscountCalculatorTests.cs:line 23
Failed!  - Failed:     1, Passed:     2, Skipped:     0, Total:     3, Duration: 145 ms - Crm.Core.Tests.dll (net10.0)

Đọc như đọc stack trace ở bài 0.3:

  • Test nào fail: OrderOfExactly1Million_Gets5PercentOff. Tên test đã nói quy tắc bị vi phạm.
  • Mong đợi và thực tế: mong 950000, nhận 1000000, tức là không được giảm gì.
  • Hai test còn lại pass, nên lỗi chỉ nằm ở quy tắc ngưỡng 1 triệu.

Sửa > thành >= trong DiscountCalculator.cs, chạy lại:

text
Passed!  - Failed:     0, Passed:     3, Skipped:     0, Total:     3, Duration: 54 ms - Crm.Core.Tests.dll (net10.0)

Trong VS Code, bạn cũng có thể chạy test bằng giao diện. Bấm biểu tượng bình thí nghiệm (Testing) ở thanh bên trái, hoặc bấm nút ▷ nhỏ hiện cạnh mỗi [Fact]. Muốn debug một test, chuột phải vào nút đó và chọn Debug Test: mọi kỹ năng ở bài 0.3 dùng được luôn.

0.4.6 — Nhiều bộ dữ liệu cho một quy tắc: [Theory] ​

Ba test trên giống nhau, chỉ khác dữ liệu. [Theory] cùng [InlineData] gom chúng lại:

csharp
namespace Crm.Core.Tests;

public class DiscountCalculatorTheoryTests
{
    [Theory]
    [InlineData(200_000, false, 200_000)]
    [InlineData(500_000, true, 450_000)]
    [InlineData(1_000_000, false, 950_000)]
    [InlineData(2_000_000, true, 1_700_000)]
    public void FinalPrice_MatchesPriceTable(decimal total, bool isVip, decimal expected)
    {
        Assert.Equal(expected, DiscountCalculator.FinalPrice(total, isVip));
    }
}

Mỗi dòng [InlineData] là một test riêng, fail riêng. Thêm một trường hợp mới chỉ tốn một dòng. Dòng cuối kiểm tra khách VIP với đơn 2 triệu được giảm cộng dồn 15%.

Attribute không nhận giá trị decimal trực tiếp, nên ở đây viết số nguyên. xUnit tự chuyển sang decimal theo kiểu tham số của phương thức.

0.4.7 — Lỗi thường gặp ​

error CS0103: The name 'DiscountCalculator' does not exist in the current context. Project test chưa tham chiếu tới thư viện. Chạy lại lệnh dotnet reference add ở mục 0.4.1.

error xUnit1000: Test classes must be public. Class test thiếu public. Analyzer của xUnit bắt lỗi này ngay lúc build.

dotnet test báo không tìm thấy project. Bạn đang đứng sai thư mục. Chạy ở thư mục chứa file .slnx, hoặc chỉ rõ đường dẫn: dotnet test tests/Crm.Core.Tests.

Test pass nhưng code vẫn sai. Test chỉ kiểm tra những gì bạn viết. Nếu không có test cho đúng mốc 1 triệu, lỗi > ở trên đã lọt qua với 2 test pass. Hãy luôn tự hỏi: đường biên của quy tắc này ở đâu?

Danh sách rà soát khi viết test

  • Tên test nói rõ tình huống và kết quả mong đợi.
  • Có ít nhất một test ngay trên đường biên của mỗi điều kiện >, >=, <, <=.
  • Assert.Equal đặt giá trị mong đợi trước, giá trị thực tế sau.
  • Code nghiệp vụ không đọc bàn phím, không in màn hình, nên test gọi thẳng được.

Bài tập áp dụng ​

Bài 1 — Xếp hạng thành viên ​

CLB chấm điểm hoạt động mỗi học kỳ: dưới 50 điểm là Thành viên, từ 50 tới dưới 100 là Tích cực, từ 100 trở lên là Nòng cốt. Điểm âm là dữ liệu sai và phải báo lỗi.

Viết MemberRank.FromPoints(int points) trong Crm.Core và các test trong Crm.Core.Tests.

Tiêu chí hoàn thành: test phủ mọi đường biên (49, 50, 99, 100), có trường hợp 0 và trường hợp điểm âm, tất cả đều pass.

Gợi ý và lời giải — Bài 1

Gợi ý. Xét điều kiện từ mức cao xuống mức thấp để mỗi if chỉ cần một phép so sánh. Kiểm tra một hàm có ném lỗi không bằng Assert.Throws<KieuLoi>(() => ...).

Lời giải — src/Crm.Core/MemberRank.cs:

csharp
namespace Crm.Core;

public static class MemberRank
{
    // Điểm hoạt động trong học kỳ: dưới 50 là "Thành viên", 50 đến dưới 100 là "Tích cực", từ 100 là "Nòng cốt"
    public static string FromPoints(int points)
    {
        if (points < 0) throw new ArgumentOutOfRangeException(nameof(points), "Điểm không được âm.");
        if (points >= 100) return "Nòng cốt";
        if (points >= 50) return "Tích cực";
        return "Thành viên";
    }
}

tests/Crm.Core.Tests/MemberRankTests.cs:

csharp
namespace Crm.Core.Tests;

public class MemberRankTests
{
    [Theory]
    [InlineData(0, "Thành viên")]
    [InlineData(49, "Thành viên")]
    [InlineData(50, "Tích cực")]
    [InlineData(99, "Tích cực")]
    [InlineData(100, "Nòng cốt")]
    public void FromPoints_ReturnsCorrectRank_AtBoundaries(int points, string expected)
    {
        Assert.Equal(expected, MemberRank.FromPoints(points));
    }

    [Fact]
    public void FromPoints_NegativePoints_Throws()
    {
        Assert.Throws<ArgumentOutOfRangeException>(() => MemberRank.FromPoints(-1));
    }
}

Vì sao chọn đúng các số 49, 50, 99, 100? Mỗi mức có hai đầu, và lỗi gõ nhầm > thành >= chỉ lộ ra ở đúng giá trị biên. Test với 30 hay 70 thì lỗi đó lọt qua.

nameof(points) trả về chuỗi "points". Đổi tên tham số thì chuỗi này đổi theo, không bị lệch như khi gõ tay.

Bài 2 — Để test tìm lỗi cho bạn ​

Nhờ một bạn cùng nhóm cố ý cài một lỗi nhỏ vào MemberRank.FromPoints của bạn (đổi một dấu so sánh, đổi một con số), không nói lỗi ở đâu. Chạy dotnet test.

Tiêu chí hoàn thành: test của bạn bắt được lỗi, và bạn chỉ ra được lỗi chỉ bằng cách đọc tên test fail cùng dòng Expected/Actual, không cần mở code.

Gợi ý và lời giải — Bài 2

Nếu lỗi lọt qua, đó là thông tin quý: bộ test còn thiếu một trường hợp. Thêm test cho trường hợp đó, chạy để thấy nó fail, rồi mới sửa code.

Thứ tự "viết test bắt được lỗi trước, sửa code sau" là nền tảng của TDD (Test-Driven Development). Đọc thêm: Phát triển hướng kiểm thử (TDD).

Tự kiểm tra ​

Solution khác project thế nào?

Project (.csproj) là một đơn vị build ra một file .dll hoặc chương trình chạy được. Solution (.slnx) chỉ là danh sách các project để build, test và mở trong IDE cùng lúc. Solution không chứa code.

Vì sao project test cần dotnet reference add?

Để code test gọi được các class public trong thư viện. Không có tham chiếu, trình biên dịch không biết DiscountCalculator là gì và báo lỗi CS0103.

Fact và Theory khác nhau thế nào?

[Fact] là một test với một bộ dữ liệu cố định. [Theory] là một test chạy nhiều lần, mỗi lần với một bộ dữ liệu từ [InlineData]. Dùng [Theory] khi cùng một quy tắc cần kiểm tra với nhiều giá trị.

Nên chọn giá trị nào để test một điều kiện như total >= 1.000.000?

Chọn đúng giá trị biên (1.000.000) và một giá trị ngay dưới biên (999.999). Đó là hai điểm mà > và >= cho kết quả khác nhau.

Kết luận ​

  1. Tách logic vào thư viện thì test được. Code không đọc bàn phím, không in màn hình thì test gọi thẳng được.
  2. Test đường biên. Lỗi thường nằm ở > so với >=.
  3. Test fail là tin tốt. Lỗi bị bắt trên máy bạn thay vì trên máy người dùng.

Tham khảo ​

Điều hướng ​

Phát hành theo giấy phép MIT.