Ở Day 1, chúng ta đã xây dựng LibraryService, viết unit test và sử dụng Code Coverage để quan sát những dòng code đã được thực thi.
Tuy nhiên, một câu hỏi quan trọng vẫn còn: Nếu một dòng code đã chạy, điều đó có nghĩa là chúng ta đã kiểm tra hết logic của dòng code đó chưa?
Trong bài này, chúng ta sẽ tìm hiểu ba khái niệm:
- Line Coverage: Dòng code có được thực thi không?
- Branch Coverage: Các nhánh của điều kiện có được thực thi không?
- Assertions: Test có thực sự kiểm tra kết quả mong đợi không?
Chúng ta tiếp tục sử dụng ví dụ tính phí trả sách trễ hạn trong hệ thống quản lý thư viện.
1. Nhắc lại logic của LibraryService
Ở Day 1, chúng ta đã xây dựng phương thức CalculateLateFee với quy tắc nghiệp vụ:
- Trả sách đúng hạn: không tính phí.
- Trễ hạn: tính 5.000 VNĐ cho mỗi ngày trễ.
- Số ngày trễ âm: dữ liệu không hợp lệ, cần ném
ArgumentException.
Mã nguồn của LibraryService.cs như sau:
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 chú ý phương thức có hai câu lệnh if. Mỗi câu lệnh đều có thể dẫn đến những hướng thực thi khác nhau tùy theo giá trị đầu vào.
2. Bổ sung các unit test
Trước tiên, chúng ta cần kiểm tra những trường hợp nghiệp vụ quan trọng. Bảng dưới đây mô tả input và kết quả mong đợi.
| Trường hợp | Input | Kết quả mong đợi |
|---|---|---|
| Trả đúng hạn | 0 |
0 VNĐ |
| Trễ 1 ngày | 1 |
5.000 VNĐ |
| Trễ 3 ngày | 3 |
15.000 VNĐ |
| Số ngày trễ âm | -1 |
Ném ArgumentException |
Giả sử test project đã tham chiếu đến project LibraryManagement như ở Day 1. Chúng ta tạo hoặc cập nhật file LibraryServiceTests.cs.
2.1. Kiểm tra trường hợp trả đúng hạn
[Fact]
public void CalculateLateFee_WhenNotOverdue_ReturnsZero()
{
var service = new LibraryService();
var result = service.CalculateLateFee(0);
Assert.Equal(0m, result);
}
[Fact] đánh dấu một test case độc lập trong xUnit. Lệnh Assert.Equal so sánh kết quả thực tế với kết quả mong đợi.
Nếu phương thức trả về 0m, test sẽ thành công. Nếu trả về một giá trị khác, test sẽ thất bại.
2.2. Kiểm tra nhiều trường hợp trễ hạn
Chúng ta có thể sử dụng [Theory] kết hợp với [InlineData] để chạy cùng một phương thức test với nhiều bộ dữ liệu.
[Theory]
[InlineData(1, 5000)]
[InlineData(3, 15000)]
public void CalculateLateFee_WhenOverdue_ReturnsExpectedFee(
int overdueDays,
decimal expectedFee)
{
var service = new LibraryService();
var result = service.CalculateLateFee(overdueDays);
Assert.Equal(expectedFee, result);
}
Trong ví dụ trên, xUnit chạy phương thức test hai lần:
- Lần 1:
overdueDays = 1, kết quả mong đợi là5000. - Lần 2:
overdueDays = 3, kết quả mong đợi là15000.
Lưu ý: Hai bộ dữ liệu kiểm tra hai kết quả khác nhau, nhưng chưa chắc đã đi qua hai nhánh điều kiện khác nhau. Chúng ta sẽ phân tích điểm này ở phần Branch Coverage.
2.3. Kiểm tra dữ liệu không hợp lệ
Tiếp theo, chúng ta kiểm tra số ngày trễ âm:
[Fact]
public void CalculateLateFee_WhenDaysAreNegative_ThrowsException()
{
var service = new LibraryService();
Assert.Throws<ArgumentException>(
() => service.CalculateLateFee(-1));
}
Ở đây, chúng ta không mong phương thức trả về một số tiền. Kết quả đúng là phương thức phải ném ra ArgumentException.
Assert.Throws xác nhận loại exception mong đợi được ném ra. Nếu phương thức không ném exception, hoặc ném một loại exception không phù hợp, test sẽ thất bại.
3. Chạy test và kiểm tra kết quả
Tại thư mục chứa solution, chạy lệnh:
dotnet test
Nếu mọi test đều thành công, kết quả sẽ cho biết số lượng test đã chạy và số test thất bại. Tuy nhiên, test thành công không tự động có nghĩa là mọi logic đã được kiểm tra đầy đủ.
Để kiểm tra Code Coverage bằng công cụ đã cài ở Day 1, chạy:
dotnet test LibraryManagement.Tests/LibraryManagement.Tests.csproj --collect:"XPlat Code Coverage"
Lệnh này yêu cầu test runner thu thập dữ liệu Coverage. Nếu đã cài coverlet.collector, kết quả Coverage thường được lưu trong thư mục TestResults của test project.
Nếu đã cài ReportGenerator ở Day 1, bạn có thể tạo lại báo cáo HTML theo cách đã thực hiện ở bài trước để xem chi tiết kết quả.
Điều quan trọng trong bài này không phải là đạt một tỷ lệ phần trăm cụ thể. Chúng ta cần hiểu ý nghĩa của từng loại Coverage.
4. Line Coverage là gì?
Line Coverage cho biết những dòng code có thể được đo lường và đã được thực thi trong quá trình chạy test.
Ví dụ, khi gọi:
service.CalculateLateFee(1);
Phương thức sẽ kiểm tra điều kiện overdueDays < 0. Vì giá trị là 1, điều kiện sai và chương trình tiếp tục đến điều kiện overdueDays == 0. Điều kiện này cũng sai, vì vậy chương trình tính phí và trả về 5000.
Những dòng code trên đường đi này được thực thi. Nhưng điều đó chưa chứng minh rằng tất cả hướng xử lý của hai câu lệnh if đều đã được kiểm tra.
Hãy tưởng tượng chúng ta chỉ có test cho trường hợp trễ 1 ngày. Chúng ta có thể đã thực thi dòng tính phí, nhưng chưa kiểm tra trường hợp trả đúng hạn hoặc trường hợp dữ liệu âm.
Vì vậy, Line Coverage trả lời câu hỏi:
Những dòng code nào đã được thực thi khi chạy test?
5. Branch Coverage là gì?
Branch Coverage quan tâm đến các nhánh thực thi được tạo ra bởi những điểm quyết định trong code, chẳng hạn như câu lệnh if.
Với một câu lệnh if thông thường, chúng ta cần quan tâm đến hai kết quả:
- Điều kiện đúng: chương trình đi vào nhánh
if. - Điều kiện sai: chương trình không đi vào nhánh
ifvà tiếp tục theo luồng còn lại.
Xét điều kiện đầu tiên trong CalculateLateFee:
if (overdueDays < 0)
{
throw new ArgumentException(
"Overdue days cannot be negative.");
}
Để kiểm tra cả hai nhánh, chúng ta cần ít nhất các trường hợp:
| Input | Điều kiện overdueDays < 0 |
Hướng thực thi |
|---|---|---|
-1 |
Đúng | Ném ArgumentException |
1 |
Sai | Tiếp tục kiểm tra điều kiện kế tiếp |
Test với input -1 kiểm tra nhánh đúng. Test với input 1 kiểm tra nhánh sai của điều kiện đầu tiên.
Chúng ta cũng cần kiểm tra hai hướng của điều kiện thứ hai:
if (overdueDays == 0)
{
return 0;
}
return overdueDays * DailyFine;
- Input
0: điều kiện đúng, phương thức trả về0. - Input
1: điều kiện sai, phương thức tiếp tục tính phí.
Như vậy, các test cho input -1, 0 và 1 giúp chúng ta kiểm tra các hướng xử lý quan trọng của hai điều kiện.
5.1. Vì sao test với 1 và 3 ngày trễ không tạo thêm nhánh?
Đây là điểm quan trọng nhất của bài.
Với input 1:
overdueDays < 0là sai.overdueDays == 0là sai.- Chương trình tính phí và trả về
5000.
Với input 3:
overdueDays < 0cũng là sai.overdueDays == 0cũng là sai.- Chương trình tính phí và trả về
15000.
Hai input khác nhau, hai kết quả khác nhau, nhưng đều đi qua cùng các nhánh điều kiện.
Chúng ta có thể hình dung luồng thực thi như sau:
flowchart TD
A["CalculateLateFee(overdueDays)"] --> B{"overdueDays < 0?"}
B -->|Đúng| C["Ném ArgumentException"]
B -->|Sai| D{"overdueDays == 0?"}
D -->|Đúng| E["Trả về 0"]
D -->|Sai| F["Tính overdueDays * 5000"]
F --> G["Trả về phí trễ hạn"]
Input 1 và 3 đều đi theo đường:
Điều kiện đầu tiên: Sai
|
v
Điều kiện thứ hai: Sai
|
v
Tính phí trễ hạn
Do đó, thêm nhiều giá trị kiểm thử trong cùng một đường thực thi không nhất thiết làm tăng Branch Coverage.
Tuy nhiên, test với cả 1 và 3 vẫn có giá trị. Nó kiểm tra công thức tính phí với hai giá trị đầu vào khác nhau. Nếu code vô tình tính phí cố định là 5000 cho mọi trường hợp trễ hạn, test với 3 sẽ phát hiện lỗi.
Kết luận: Branch Coverage và kiểm tra nhiều giá trị đầu vào phục vụ những mục đích khác nhau. Chúng ta thường cần cả hai.
6. So sánh Line Coverage, Branch Coverage và Assertions
| Khái niệm | Câu hỏi chính | Ví dụ |
|---|---|---|
| Line Coverage | Dòng code có được thực thi không? | Dòng tính phí đã chạy chưa? |
| Branch Coverage | Các nhánh điều kiện đã được thực thi chưa? | Điều kiện đúng và sai đã được kiểm tra chưa? |
| Assertions | Kết quả thực tế có đúng như mong đợi không? | Trễ 3 ngày có trả về 15.000 VNĐ không? |
Hãy xem một test không có assertion:
[Fact]
public void CalculateLateFee_WhenOverdue_RunsSuccessfully()
{
var service = new LibraryService();
service.CalculateLateFee(3);
}
Test này có thể chạy thành công và thực thi các dòng tính phí. Nhưng nó không kiểm tra kết quả có bằng 15000 hay không.
Nếu phương thức trả về 10000 thay vì 15000, test vẫn có thể thành công vì không có assertion xác nhận kết quả.
Đây là lý do chúng ta cần viết assertion phù hợp:
Assert.Equal(15000m, service.CalculateLateFee(3));
Line Coverage và Branch Coverage giúp chúng ta quan sát mức độ thực thi code. Assertions giúp xác nhận kết quả có đúng với yêu cầu nghiệp vụ hay không.
Coverage cao không đảm bảo chương trình không có bug. Một test có thể chạy qua tất cả các nhánh nhưng vẫn thiếu assertion hoặc chỉ kiểm tra những kết quả không đủ để phát hiện lỗi.
7. Giới thiệu TDD: Test trước, code sau
TDD (Test-Driven Development) là phương pháp phát triển phần mềm trong đó chúng ta viết test trước, sau đó triển khai code để làm test thành công, rồi refactor khi cần.
Một chu trình TDD phổ biến gồm ba bước:
flowchart TD
A["Red: Viết test trước"] --> B["Chạy test và thấy thất bại"]
B --> C["Green: Viết code tối thiểu để test thành công"]
C --> D["Refactor: Cải thiện code"]
D --> A
7.1. Ví dụ với phí trả sách trễ hạn
Giả sử chúng ta chưa viết phương thức tính phí. Trước tiên, hãy xác định quy tắc nghiệp vụ bằng test:
[Fact]
public void CalculateLateFee_WhenOverdueThreeDays_Returns15000()
{
var service = new LibraryService();
var result = service.CalculateLateFee(3);
Assert.Equal(15000m, result);
}
Bước 1 - Red: Nếu phương thức chưa tồn tại, project sẽ không biên dịch. Nếu phương thức đã tồn tại nhưng chưa xử lý đúng, test có thể thất bại. Đó là tín hiệu cho biết chúng ta cần triển khai hoặc sửa logic.
Bước 2 - Green: Viết logic tối thiểu để phí trễ 3 ngày được tính đúng.
Bước 3 - Refactor: Khi test đã thành công, cải thiện cấu trúc code mà không thay đổi hành vi nghiệp vụ đã được kiểm tra.
Trong thực tế, TDD không chỉ có một test. Chúng ta tiếp tục bổ sung các test cho trả đúng hạn, trễ nhiều ngày và dữ liệu không hợp lệ để làm rõ quy tắc nghiệp vụ.
Lưu ý: chạy test trước khi viết code không có nghĩa là bắt buộc phải nhìn thấy lỗi assertion. Nếu phương thức hoặc project chưa tồn tại, bước Red có thể là lỗi biên dịch. Điều quan trọng là test chưa thể xác nhận hành vi mong muốn cho đến khi phần triển khai phù hợp.
8. Tổng kết
Sau bài này, chúng ta cần nhớ bốn ý chính:
- Line Coverage cho biết dòng code nào đã được thực thi.
- Branch Coverage cho biết các nhánh điều kiện nào đã được thực thi.
- Assertions kiểm tra kết quả thực tế có khớp với kết quả mong đợi hay không.
- Nhiều test với các input khác nhau có thể đi qua cùng một nhánh. Tăng số lượng test không đồng nghĩa với tăng Branch Coverage.
Không nên xem Coverage như một điểm số để chạy theo. Mục tiêu là xác định những logic chưa được kiểm tra và bổ sung test có giá trị, dựa trên yêu cầu nghiệp vụ và rủi ro của hệ thống.
9. Bài tập thực hành
Hãy thử thực hiện các bài tập sau:
- Chỉ giữ test cho input
1. Quan sát những trường hợp nghiệp vụ nào chưa được kiểm tra. - Thêm test cho input
0và-1. Giải thích nhánh nào được kiểm tra bởi mỗi test. - Giữ test cho input
1, sau đó thêm test cho input3. Giải thích tại sao test mới có thể phát hiện lỗi tính toán nhưng không tạo ra nhánh điều kiện mới. - Tạm bỏ
Assert.Equaltrong test tính phí. Giải thích vì sao test có thể thành công dù kết quả tính phí không đúng.
Ở Day 3, chúng ta sẽ mở rộng ví dụ sang solution .NET có nhiều test project, tìm hiểu cách thu thập và tổng hợp báo cáo Coverage để có cái nhìn đầy đủ hơn về toàn bộ solution.
Nhận xét