Skip to main content

16.10 — 9. Chiến lược test

Summary

Câu hỏi đúng không phải "test bao nhiêu phần trăm" mà "test cái gì". Nguyên tắc chi phối: test hành vi, đừng test cấu trúc. Một test gắn với cách code được viết sẽ đỏ mỗi lần refactor — dù hành vi không đổi — và sau vài tháng cả đội sẽ ngừng tin vào bộ test. Với kiến trúc phân tầng, phân bổ hợp lý là hình thang chứ không phải kim tự tháp cổ điển: nhiều unit test cho Domain, nhiều integration test cho Application và API, rất ít test end-to-end. Và một điều phải nói thẳng: coverage nói dối — 90% coverage với toàn assert NotNull tệ hơn 50% coverage kiểm tra đúng những quy tắc quan trọng.

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

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

  • Quyết định test cái gì và bỏ qua cái gì.
  • Chọn loại test phù hợp cho từng tầng.
  • Biết mock cái gì và không mock cái gì.
  • Viết test không gãy khi refactor.
  • Đọc coverage đúng cách.

Nội dung bài học​

16.10.1 — Test hành vi, không test cấu trúc​

// TEST CẤU TRÚC — gãy khi refactor dù hành vi không đổi
[Fact]
public async Task Should_Call_Repository_And_UnitOfWork()
{
await _handler.Handle(command, default);

_repo.Verify(r => r.Add(It.IsAny<Lead>()), Times.Once);
_uow.Verify(u => u.SaveChangesAsync(default), Times.Once);
}

Test này khẳng định cách code được viết. Đổi từ repo.Add + uow.SaveChanges sang một phương thức duy nhất — hành vi y hệt — và test đỏ.

Sau ba lần như vậy, người ta bắt đầu "sửa test cho xanh" thay vì đọc nó. Bộ test mất giá trị.

// TEST HÀNH VI — chỉ quan tâm KẾT QUẢ
[Fact]
public async Task CreateLead_WithValidData_ShouldPersistLead()
{
var command = new CreateLeadCommand("Nguyen Van A", "a@example.com", 1000);

var result = await _handler.Handle(command, default);

Assert.True(result.IsSuccess);

var saved = await _db.Leads.FindAsync(result.Value);
Assert.NotNull(saved);
Assert.Equal("a@example.com", saved.Email.Value);
}

Test này đỏ chỉ khi hành vi thật sự sai. Refactor thoải mái.

Quy tắc phân biệt: nếu test dùng Verify để khẳng định một phương thức đã được gọi, nó có thể đang test cấu trúc. Hỏi lại: "nếu tôi viết lại phần bên trong nhưng giữ nguyên kết quả, test này còn đúng không?"

Ngoại lệ hợp lệ: khi lời gọi chính là hành vi — "gửi email cho khách hàng" thì Verify việc gửi email là đúng, vì đó là thứ cần quan sát.

16.10.2 — Hình thang, không phải kim tự tháp​

Kim tự tháp cổ điển nói: rất nhiều unit test, ít integration test, rất ít E2E.

Với kiến trúc phân tầng và công cụ hiện đại (Testcontainers, WebApplicationFactory), phân bổ hợp lý hơn là hình thang:

TầngLoại testSố lượngVì sao
DomainUnit, không phụ thuộcNhiềuNhanh, và đây là nơi quy tắc sống
ApplicationIntegration với database thậtNhiềuBắt được lỗi thật; mock repository ít giá trị
APIIntegration qua WebApplicationFactoryVừaBắt lỗi route, binding, middleware
E2EToàn hệ thống đã triển khaiRất ítChậm, giòn, khó chẩn đoán

Hàng thứ hai là khác biệt lớn nhất so với lời khuyên cổ điển.

// Unit test Domain — nhanh nhất, giá trị cao nhất
[Fact]
public void Convert_WhenLeadIsLost_ShouldThrow()
{
var lead = Lead.Create("A", new EmailAddress("a@b.com"), Money.Vnd(1000));
lead.MarkAsLost("Khách từ chối");

var ex = Assert.Throws<DomainException>(() => lead.Convert(DateTime.UtcNow));
Assert.Contains("đã mất", ex.Message);
}

Test này chạy trong micro giây, không cần gì cả, và nó bảo vệ một quy tắc nghiệp vụ thật. Đây là loại test đáng viết nhiều nhất — và nó chỉ viết được nếu Domain tách khỏi hạ tầng (bài 16.4).

// Integration test Application — voi database THAT
public sealed class ConvertLeadTests(DatabaseFixture fixture) : IClassFixture<DatabaseFixture>
{
[Fact]
public async Task ConvertLead_ShouldCreateCustomer_AndMarkLeadAsWon()
{
var lead = await SeedLeadAsync();

var result = await _sender.Send(new ConvertLeadCommand(lead.Id), default);

Assert.True(result.IsSuccess);

var updated = await _db.Leads.FindAsync(lead.Id);
Assert.Equal(LeadStatus.Won, updated!.Status);

var customer = await _db.Customers.FirstOrDefaultAsync(c => c.SourceLeadId == lead.Id);
Assert.NotNull(customer);
}
}

Dùng Testcontainers để có database thật (bài 9.9). Test này chậm hơn (trăm mili giây) nhưng bắt được: ràng buộc database, truy vấn không dịch được, cấu hình EF Core sai, và lỗi transaction.

Đừng dùng InMemory provider — nó không có ràng buộc, không có ROWVERSION, và không dịch SQL thật.

16.10.3 — Mock cái gì​

Mock?Cái gì
CóHệ thống ngoài: email, cổng thanh toán, API bên thứ ba
CóThời gian (TimeProvider), ngẫu nhiên
KhôngDatabase — dùng Testcontainers
KhôngLớp trong cùng assembly
KhôngValue object, entity

Hai hàng "không" cuối quan trọng: mock một lớp trong cùng assembly nghĩa là bạn đang test lời gọi giữa hai lớp của mình, tức test cấu trúc.

// Mock hệ thống ngoài — ĐÚNG
var email = Substitute.For<IEmailSender>();
var clock = new FakeTimeProvider(new DateTimeOffset(2026, 3, 15, 10, 0, 0, TimeSpan.Zero));

// Mock database — SAI, dung Testcontainers
var repo = Substitute.For<ILeadRepository>();
repo.GetByIdAsync(Arg.Any<LeadId>(), default).Returns(someLead);

Mock repository làm test luôn xanh kể cả khi truy vấn thật sai — bạn chỉ đang test rằng code gọi đúng phương thức bạn vừa bảo nó gọi.

Fake tốt hơn mock khi bạn cần một implementation đơn giản:

public sealed class FakeEmailSender : IEmailSender
{
public List<EmailMessage> Sent { get; } = [];

public Task SendAsync(EmailMessage message, CancellationToken ct)
{
Sent.Add(message);
return Task.CompletedTask;
}
}

// Trong test — đọc được hơn Verify
Assert.Single(fakeEmail.Sent);
Assert.Equal("a@example.com", fakeEmail.Sent[0].To);

Fake cho phép assert về kết quả (email nào đã gửi) thay vì về lời gọi (phương thức nào được gọi).

FakeTimeProvider (gói Microsoft.Extensions.TimeProvider.Testing) cho phép kiểm soát thời gian — điều kiện để test logic hết hạn, TTL, và lịch chạy.

16.10.4 — Coverage nói dối​

// 100% coverage, ZERO giá trị
[Fact]
public async Task Handle_ShouldNotThrow()
{
var result = await _handler.Handle(command, default);
Assert.NotNull(result);
}

Test này chạy qua toàn bộ handler nên coverage cao, nhưng nó không khẳng định gì. Handler có thể lưu sai dữ liệu, trả sai id, bỏ qua quy tắc — test vẫn xanh.

Coverage đo dòng code được chạy, không đo hành vi được kiểm tra. Hai thứ khác nhau.

Cách dùng coverage đúng là đọc ngược: tìm code không được chạy bởi test nào. Nếu đó là quy tắc nghiệp vụ quan trọng, bạn có lỗ hổng thật.

Chỉ số hữu ích hơn: mutation testing. Công cụ như Stryker.NET sửa code của bạn (đổi > thành >=, xoá một dòng) và kiểm tra test có đỏ không. Test không phát hiện được thay đổi đó là test không bảo vệ gì.

dotnet tool install -g dotnet-stryker
dotnet stryker --project Crm.Domain.csproj

Chạy nó trên Domain là đáng nhất: đó là nơi quy tắc quan trọng sống, và nó chạy nhanh vì không có hạ tầng.

Đặt mục tiêu coverage theo tầng, không theo toàn dự án:

TầngMục tiêu hợp lý
DomainCao — đây là nơi quy tắc sống
ApplicationVừa — tập trung use case quan trọng
InfrastructureThấp — phần lớn là code kết nối
APIThấp — controller nên mỏng

Một mục tiêu duy nhất cho cả dự án khuyến khích viết test vô nghĩa cho Infrastructure để nâng con số.

16.10.5 — Test nào viết trước​

Với thời gian có hạn, thứ tự ưu tiên:

1. Quy tắc nghiệp vụ trong Domain. Rẻ nhất, giá trị cao nhất, và bảo vệ thứ quan trọng nhất.

2. Use case có tác dụng phụ không đảo ngược được. Thanh toán, gửi email hàng loạt, xoá dữ liệu.

3. Những chỗ đã từng có bug. Mỗi bug được sửa nên kèm một test — nó ngăn hồi quy, và nó chứng minh bug đã thật sự được sửa.

4. Ranh giới tích hợp. Endpoint danh sách có phân trang, quyền, và lọc — nơi dễ sai nhất (bài 10.7).

Không đáng viết: test cho getter/setter, test cho mapping thuần, test cho code framework (EF Core đã được Microsoft test).

Bốn loại test đáng có dù không theo tầng nào, vì chúng bắt lớp lỗi mà test thường bỏ qua:

// 1. Kiến trúc — ranh giới không bị phá
[Fact] public void Domain_ShouldNotDependOn_Infrastructure() { ... }

// 2. Dem truy van — chong N+1 ([bai 13.7])
[Fact] public async Task GetList_ShouldNotCauseNPlusOne() { ... }

// 3. Cách ly tenant — chống rò rỉ dữ liệu ([bài 13.11])
[Fact] public async Task TenantB_ShouldNotSee_TenantAData() { ... }

// 4. Hợp đồng API — chống breaking change ([bài 9.3])
[Fact] public void OpenApiDocument_ShouldNotHaveBreakingChanges() { ... }

Bốn test này rẻ để viết và bắt được những lỗi tốn kém nhất.

16.10.6 — Rà lại code của bạn​

Danh sách rà soát chiến lược test

  • •Test khẳng định kết quả, không khẳng định phương thức nào được gọi.
  • •Refactor giữ nguyên hành vi không làm test đỏ.
  • •Quy tắc nghiệp vụ có unit test chạy không cần hạ tầng.
  • •Test Application dùng database thật qua Testcontainers, không dùng InMemory.
  • •Không mock repository hay DbContext.
  • •Hệ thống ngoài dùng fake đọc được, không chỉ mock với Verify.
  • •Thời gian lấy qua TimeProvider và được kiểm soát trong test.
  • •Mục tiêu coverage đặt theo tầng, không phải một con số cho cả dự án.
  • •Đã cân nhắc mutation testing cho Domain.
  • •Mỗi bug đã sửa đều kèm một test.
  • •Có kiến trúc test, test đếm truy vấn, test cách ly tenant.

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

Bài 1 — Test gãy khi refactor​

Viết một test dùng Verify cho hai lời gọi, rồi gộp hai lời gọi đó thành một phương thức mà không đổi hành vi. Xác nhận test đỏ.

Tiêu chí hoàn thành: bạn giải thích được khác biệt giữa test hành vi và test cài đặt, và có một quy tắc để nhận ra mình đang viết loại nào.

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

Gợi ý. Nếu bạn đổi cách làm mà không đổi kết quả, test nên đỏ hay xanh?

Lời giải — test cài đặt:

[Fact]
public async Task Chot_lead_phai_gui_email_va_ghi_audit()
{
var email = Substitute.For<IEmailSender>();
var audit = Substitute.For<IAuditLogger>();
var svc = new LeadService(_db, email, audit, _clock);

await svc.ChotLeadAsync(leadId, ct);

await email.Received(1).GuiAsync(
Arg.Any<string>(), "Lead đã chốt", Arg.Any<string>());
await audit.Received(1).GhiAsync("LeadChot", leadId.ToString());
}

Refactor — gộp hai lời gọi, không đổi hành vi:

// Trước
await _email.GuiAsync(lead.Email, "Lead đã chốt", NoiDung(lead));
await _audit.GhiAsync("LeadChot", lead.Id.ToString());

// Sau — một phương thức làm cả hai, kết quả cuối GIỐNG HỆT
await _thongBao.ThongBaoChotLeadAsync(lead, ct);
NSubstitute.Exceptions.ReceivedCallsException:
Expected to receive exactly 1 call matching:
GuiAsync(any String, "Lead đã chốt", any String)
Actually received no matching calls.

Test đỏ dù hành vi không đổi. Email vẫn được gửi, audit vẫn được ghi, người dùng không thấy khác biệt nào — nhưng test báo hỏng.

Khác biệt giữa test hành vi và test cài đặt:

Test hành viTest cài đặt
Khẳng định vềKết quả quan sát đượcCách làm
Đỏ khiHành vi đổiCách làm đổi, kể cả khi hành vi không đổi
Cho phép refactorCóKhông
Công cụAssert trên trạng thái, kết quả trả vềVerify, Received, AssertWasCalled
Giá trịBảo vệ đúng đắnGhi lại cấu trúc code tại một thời điểm

Test hành vi cho cùng use case:

[Fact]
public async Task Chot_lead_chuyen_trang_thai_va_ghi_thoi_diem()
{
var lead = Lead.Tao("ABC", Email.Tao("an@abc.com").Value, Money.VND(1_000_000)).Value;
var truongPhong = NguoiDung.TruongPhong("tp01");
var bayGio = new DateTime(2026, 9, 25, 8, 0, 0, DateTimeKind.Utc);

var kq = lead.ChuyenSangWon(truongPhong, bayGio);

kq.ThanhCong.Should().BeTrue();
lead.Status.Should().Be(LeadStatus.Won);
lead.ClosedUtc.Should().Be(bayGio);
lead.DomainEvents.Should().ContainSingle().Which.Should().BeOfType<LeadDaChot>();
}

Test này không quan tâm cách ChuyenSangWon được cài đặt. Gộp phương thức, tách phương thức, đổi tên biến — nó vẫn xanh chừng nào hành vi đúng.

Quy tắc để nhận ra mình đang viết loại nào:

"Nếu tôi viết lại phần cài đặt từ đầu, giữ nguyên hành vi, test này có còn xanh không?"

Có    -> test hành vi -> giữ
Không -> test cài đặt -> viết lại hoặc xoá

Ba dấu hiệu cụ thể của test cài đặt:

// 1. Verify trên lời gọi nội bộ
await _repo.Received(1).LayAsync(Arg.Any<LeadId>(), Arg.Any<CancellationToken>());

// 2. Kiểm tra THỨ TỰ gọi
Received.InOrder(() => { _repo.LayAsync(...); _email.GuiAsync(...); });

// 3. Mock một lớp CỦA CHÍNH BẠN mà không phải ranh giới
var svc = Substitute.For<ILeadCalculator>();

Vậy khi nào Verify là đúng? Khi lời gọi đó chính là hành vi — tức là tác dụng phụ ra ngoài hệ thống mà không quan sát được bằng cách nào khác:

[Fact]
public async Task Lead_tren_nguong_phai_gui_thong_bao_cho_truong_phong()
{
var email = Substitute.For<IEmailSender>();
// ...
await _handler.Handle(new ChotLeadCommand(id), ct);

// Gửi email LÀ yêu cầu nghiệp vụ, không phải chi tiết cài đặt
await email.Received(1).GuiAsync(
"truongphong@cty.com",
Arg.Is<string>(s => s.Contains("cần duyệt")),
Arg.Any<string>());
}

Phân biệt:

"Gọi _repo.LayAsync"              -> cách làm -> đừng verify
"Gửi email tới trưởng phòng" -> yêu cầu nghiệp vụ -> verify được
"Publish message lên queue" -> hợp đồng với hệ thống khác -> verify được
"Gọi API thanh toán" -> tác dụng phụ ra ngoài -> verify được

Quy tắc mock:

MOCK:      ranh giới ra ngoài hệ thống — email, SMS, API bên thứ ba,
message broker, đồng hồ, bộ sinh số ngẫu nhiên

ĐỪNG MOCK: lớp của chính bạn — entity, value object, service nội bộ
database (dùng SQLite in-memory hoặc Testcontainers)

Lý do không mock database: nó là chi tiết cài đặt của việc lưu trữ, không phải ranh giới hệ thống. Mock nó nghĩa là test không kiểm tra được rằng truy vấn đúng, ánh xạ đúng, và ràng buộc được tôn trọng — tức là bỏ qua đúng những chỗ hay sai nhất.

Viết lại test ở đầu bài theo hướng hành vi:

[Fact]
public async Task Chot_lead_phai_thong_bao_ra_ngoai()
{
var email = Substitute.For<IEmailSender>();
await using var db = TaoSqliteInMemory();
await db.Leads.AddAsync(Lead.Tao(...).Value);
await db.SaveChangesAsync();

var handler = new ChotLeadHandler(db, email, _user, _clock);
var kq = await handler.Handle(new ChotLeadCommand(leadId), ct);

// Hành vi quan sát được
kq.ThanhCong.Should().BeTrue();

var lead = await db.Leads.AsNoTracking().FirstAsync(l => l.Id == leadId);
lead.Status.Should().Be(LeadStatus.Won);

// Tác dụng phụ ra ngoài — đây LÀ hành vi
await email.Received(1).GuiAsync(
"an@abc.com", Arg.Is<string>(s => s.Contains("chốt")), Arg.Any<string>());
}

Test này sống sót qua mọi refactor nội bộ, và nó kiểm tra đúng ba thứ quan trọng: kết quả trả về, trạng thái được lưu, và tác dụng phụ ra ngoài.

Rà soát test hiện có:

grep -rn "Received(\|Verify(\|AssertWasCalled" --include="*Tests.cs" tests/ | wc -l
284

284 lời gọi Verify. Với mỗi cái, áp câu hỏi ở trên. Trong một codebase điển hình, khoảng 70–80% trong số đó là test cài đặt — và chúng là lý do khiến "refactor thì 40 test đỏ" trở thành trải nghiệm quen thuộc.

Và một chỉ số đáng theo dõi:

# Trong 6 tháng qua, bao nhiêu commit sửa test mà KHÔNG sửa code production?
git log --since='6 months ago' --name-only --format='%H' | awk '
/^[0-9a-f]{40}$/ { if (n > 0 && chiTest) print sha; sha = $0; n = 0; chiTest = 1; next }
/\.cs$/ { n++; if ($0 !~ /[Tt]ests?\//) chiTest = 0 }
END { if (n > 0 && chiTest) print sha }' | wc -l

Con số cao nghĩa là test đang bám vào cài đặt: người ta refactor code, test đỏ, rồi sửa test cho khớp. Mỗi lần như vậy, test mất một phần giá trị — vì nó được sửa để khớp với code mới, không phải để kiểm tra rằng code mới đúng.


Bài 2 — Mutation testing​

Chạy Stryker.NET trên assembly Domain và xem báo cáo. Tìm một mutant "sống sót" và viết test bắt được nó.

Tiêu chí hoàn thành: bạn giải thích được mutation testing đo gì mà coverage không đo được, và biết đọc báo cáo để tìm test yếu.

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

Gợi ý. Coverage đo dòng code được chạy. Nó có đo dòng code được kiểm tra không?

Lời giải:

dotnet tool install -g dotnet-stryker
cd tests/Crm.Domain.Tests
dotnet stryker --project Crm.Domain.csproj --mutation-level Standard
Killed:   142
Survived: 38
Timeout: 2
No coverage: 14

The final mutation score is 71.36%
Crm.Domain/Entities/Lead.cs

[Survived] Equality mutation on line 47
- if (Value >= NguongCanDuyet && !nguoiThucHien.LaTruongPhong)
+ if (Value > NguongCanDuyet && !nguoiThucHien.LaTruongPhong)

[Survived] Logical mutation on line 52
- if (Status is LeadStatus.Won or LeadStatus.Lost)
+ if (Status is LeadStatus.Won)

[Survived] Boolean mutation on line 61
- return Result.ThanhCong();
+ return Result.Loi("");

Ba mutant sống sót trên một phương thức có coverage 100%.

Mutation testing đo gì mà coverage không đo được:

// Coverage 100% — dòng này được chạy
public Result ChuyenSangWon(NguoiDung nguoiThucHien, DateTime bayGio)
{
if (Value >= NguongCanDuyet && !nguoiThucHien.LaTruongPhong)
return Result.Loi("Cần trưởng phòng duyệt");
// ...
}
// Test duy nhất — chạy qua dòng đó, nhưng không kiểm tra BIÊN
[Fact]
public void Lead_gia_tri_lon_can_duyet()
{
var lead = TaoLead(giaTri: 600_000_000); // XA biên
var kq = lead.ChuyenSangWon(NhanVien(), _bayGio);
kq.ThanhCong.Should().BeFalse();
}

Đổi >= thành > không làm test đỏ, vì 600 triệu vẫn lớn hơn 500 triệu theo cả hai toán tử. Nghĩa là test không kiểm tra biên — và biên chính là chỗ lỗi hay nằm.

Coverage:          "dòng này ĐƯỢC CHẠY trong lúc test"
Mutation testing: "nếu tôi làm hỏng dòng này, có test nào PHÁT HIỆN không?"

Khác biệt là bản chất: coverage đo sự hiện diện của test; mutation testing đo sức mạnh của test.

Coverage 100%, mutation score 45%  -> test chạy hết code nhưng kiểm tra rất ít
Coverage 70%, mutation score 85% -> test ít hơn nhưng mỗi test có giá trị

Cặp thứ hai tốt hơn cặp thứ nhất.

Viết test bắt mutant thứ nhất — kiểm tra biên:

[Theory]
[InlineData(499_999_999, true)] // ngay DƯỚI ngưỡng -> qua
[InlineData(500_000_000, false)] // ĐÚNG ngưỡng -> chặn <- mutant chết ở đây
[InlineData(500_000_001, false)] // ngay TRÊN ngưỡng -> chặn
public void Nguong_duyet_kiem_tra_dung_bien(decimal giaTri, bool mongDoiThanhCong)
{
var lead = TaoLead(giaTri: giaTri);
var kq = lead.ChuyenSangWon(NhanVien(), _bayGio);
kq.ThanhCong.Should().Be(mongDoiThanhCong);
}

Với > thay cho >=, dòng thứ hai sẽ cho ThanhCong = true và test đỏ. Mutant bị giết.

Mutant thứ hai — thiếu nhánh:

[Theory]
[InlineData(LeadStatus.Won)]
[InlineData(LeadStatus.Lost)] // <- nhánh này trước đây không được test
public void Khong_chot_duoc_lead_da_o_trang_thai_cuoi(LeadStatus trangThai)
{
var lead = TaoLeadOTrangThai(trangThai);
var kq = lead.ChuyenSangWon(TruongPhong(), _bayGio);

kq.ThanhCong.Should().BeFalse();
kq.Loi.Should().Contain("trạng thái cuối");
}

Mutant thứ ba — khẳng định quá yếu:

// Trước — chỉ kiểm tra thành công, không kiểm tra TÁC DỤNG
kq.ThanhCong.Should().BeTrue();

// Sau — kiểm tra cả trạng thái đã đổi
kq.ThanhCong.Should().BeTrue();
lead.Status.Should().Be(LeadStatus.Won);
lead.ClosedUtc.Should().Be(_bayGio);
lead.DomainEvents.Should().ContainSingle().Which.Should().BeOfType<LeadDaChot>();

Mutant return Result.Loi("") sống sót được vì test chỉ kiểm tra ThanhCong ở nhánh lỗi, và không kiểm tra gì ở nhánh thành công.

Đọc báo cáo để tìm test yếu:

dotnet stryker --reporter html --reporter progress
open StrykerOutput/*/reports/mutation-report.html

Ba thứ cần nhìn, theo thứ tự ưu tiên:

1. Mutant [Survived] trong lớp Domain — đây là ưu tiên cao nhất, vì đó là nơi chứa quy tắc nghiệp vụ.

2. Mutant [No coverage] — code không có test nào chạm tới:

[No coverage] on line 89 in Lead.cs
- public Result Huy(string lyDo)

Một phương thức public không có test nào. Đây là thứ coverage cũng phát hiện được, nhưng báo cáo Stryker đặt nó cạnh các mutant khác nên dễ ưu tiên hơn.

3. Mutant [Timeout] — thường nghĩa là mutant tạo vòng lặp vô hạn, và đó là tín hiệu tốt: nó cho thấy có test chạy qua đoạn đó. Stryker tính timeout như "đã giết".

Mục tiêu mutation score thực tế:

Loại codeMục tiêu
Domain (entity, value object)85–95%
Application (handler)70–80%
Infrastructure (repository, adapter)40–60%
Api (controller, endpoint)không chạy mutation testing

Đừng đặt mục tiêu 100% ở bất cứ đâu. Luôn có mutant tương đương — thay đổi không làm đổi hành vi:

// Mutant này KHÔNG thể giết được, vì hai bản tương đương về hành vi
- for (var i = 0; i < items.Count; i++)
+ for (var i = 0; i != items.Count; i++)

Đánh dấu chúng để không phải xem lại:

// Stryker disable once all : hai biểu thức tương đương
for (var i = 0; i < items.Count; i++)

Cấu hình cho dự án:

// stryker-config.json
{
"stryker-config": {
"project": "Crm.Domain.csproj",
"test-projects": ["../../tests/Crm.Domain.Tests/Crm.Domain.Tests.csproj"],
"mutation-level": "Standard",
"thresholds": { "high": 85, "low": 70, "break": 65 },
"reporters": ["html", "progress", "cleartext"],
"ignore-mutations": ["string"],
"mutate": ["**/*.cs", "!**/Migrations/**", "!**/obj/**"]
}
}

"ignore-mutations": ["string"] bỏ qua mutation trên chuỗi — chúng thường sinh ra hàng trăm mutant vô nghĩa trên thông điệp lỗi.

Đưa vào CI, nhưng không chạy mỗi lần:

# Mutation testing mất nhiều phút -> chạy hằng đêm, không chạy mỗi PR
on:
schedule:
- cron: '0 2 * * *'
workflow_dispatch:

jobs:
mutation:
runs-on: ubuntu-latest
steps:
- run: dotnet stryker --break-at 65

--break-at 65 làm job đỏ nếu điểm tụt dưới 65 — một mẫu ratchet: nâng ngưỡng dần theo thời gian thay vì đặt mục tiêu cao ngay từ đầu.

Và giá trị lớn nhất của mutation testing không phải con số — mà là danh sách mutant sống sót. Mỗi mutant là một câu hỏi cụ thể: "nếu dòng này sai theo cách này, ai phát hiện?" Trả lời câu đó thường dẫn tới một test tốt mà bạn không nghĩ ra khi viết test lần đầu.


Bài 3 — Coverage nói dối​

Viết một test chỉ assert NotNull, đo coverage, rồi cố tình làm hỏng logic bên trong handler. Xác nhận test vẫn xanh.

Tiêu chí hoàn thành: bạn nêu được ba cách coverage đánh lừa, và biết dùng coverage đúng mục đích của nó.

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

Gợi ý. Coverage đếm dòng được chạy. Bạn làm gì với kết quả sau khi chạy?

Lời giải — test vô nghĩa:

[Fact]
public async Task ChotLead_tra_ve_ket_qua()
{
var handler = new ChotLeadHandler(_db, _email, _user, _clock);
var kq = await handler.Handle(new ChotLeadCommand(leadId), ct);
kq.Should().NotBeNull();
}
dotnet test --collect:"XPlat Code Coverage"
reportgenerator -reports:**/coverage.cobertura.xml -targetdir:coverage
ChotLeadHandler.cs     Line coverage: 100%    Branch coverage: 100%

Làm hỏng logic:

public async Task<Result> Handle(ChotLeadCommand c, CancellationToken ct)
{
var lead = await _db.Leads.FirstOrDefaultAsync(l => l.Id == c.Id, ct);
if (lead is null) return Result.KhongTimThay();

// Bỏ hẳn kiểm tra quyền
lead.DatTrangThai(LeadStatus.Won); // đi vòng qua ChuyenSangWon
await _db.SaveChangesAsync(ct);

return Result.ThanhCong(); // luôn thành công
}
dotnet test
Passed!  - Failed: 0, Passed: 1, Skipped: 0
Line coverage: 100%

Test vẫn xanh. Coverage vẫn 100%. Và quy tắc duyệt đã biến mất hoàn toàn.

Ba cách coverage đánh lừa:

Cách 1 — đếm dòng được CHẠY, không đếm dòng được KIỂM TRA.

var kq = await handler.Handle(command, ct);
kq.Should().NotBeNull(); // 100% coverage, 0% kiểm tra

Mọi dòng trong handler được chạy, nên coverage đầy đủ. Nhưng khẳng định duy nhất là "kết quả không null" — điều đúng với mọi phiên bản của handler, kể cả phiên bản sai.

Đây chính là thứ mutation testing ở bài 2 đo được mà coverage không đo được.

Cách 2 — branch coverage không phải path coverage.

public Result ChuyenSangWon(NguoiDung nguoi, DateTime bayGio)
{
if (Status is LeadStatus.Won) return Result.Loi("Đã chốt"); // nhánh A
if (AssignedTo is null) return Result.Loi("Chưa gán"); // nhánh B
if (CanDuyet() && !nguoi.LaTruongPhong) return Result.Loi("Cần duyệt"); // nhánh C
Status = LeadStatus.Won;
return Result.ThanhCong();
}

Bốn test — mỗi nhánh một test cộng một test đường thành công — cho branch coverage 100%. Nhưng số tổ hợp là 2³ = 8:

Chưa test:  lead đã Won VÀ chưa gán       -> trả lỗi nào? thứ tự có đúng không?
đã gán VÀ cần duyệt VÀ là nhân viên
...

Branch coverage 100% không có nghĩa là mọi tổ hợp điều kiện đã được kiểm tra — và lỗi thường nằm ở tổ hợp, không ở từng nhánh riêng.

Cách 3 — coverage cao vì test những thứ không cần test.

Coverage tổng: 87%

Phân rã:
DTO, record, property 98% <- không có logic, test vô nghĩa
Migration 95% <- code sinh tự động
Mapping profile 92%
Program.cs, extension đăng ký 88%
Entity, value object 42% <- CHỖ QUAN TRỌNG NHẤT
Handler 51%

Con số 87% nghe tốt, nhưng nó được kéo lên bởi những phần không chứa quy tắc nghiệp vụ. Chỗ thật sự cần test thì dưới 50%.

Đây là lý do coverage tổng gần như vô dụng — nó trộn những thứ có giá trị rất khác nhau vào một con số.

Dùng coverage đúng mục đích:

Coverage hữu ích khi TÌM code KHÔNG có test. Nó vô dụng khi làm MỤC TIÊU.

Dùng đúng:
"Lead.cs có 3 phương thức không có test nào chạm tới" -> hành động được
"Toàn bộ thư mục Handlers/Orders không có test" -> hành động được
"Coverage giảm từ 68% xuống 51% sau PR này" -> đáng hỏi vì sao

Dùng sai:
"Đạt 80% coverage" -> người ta viết test vô nghĩa để đạt
"Coverage tổng là 87%" -> không nói lên điều gì
"PR phải có coverage 100%" -> khuyến khích test rác

Lý do "coverage làm mục tiêu" luôn thất bại là định luật Goodhart: khi một thước đo trở thành mục tiêu, nó ngừng là thước đo tốt. Cách nhanh nhất để tăng coverage là viết test NotNull cho mọi thứ — và đó chính là điều sẽ xảy ra.

Cấu hình coverage có ích:

<!-- coverlet.runsettings -->
<Configuration>
<Exclude>[*]*.Migrations.*,[*]*Dto,[*]*Request,[*]*Response,[*]Program</Exclude>
<ExcludeByAttribute>GeneratedCodeAttribute,CompilerGeneratedAttribute</ExcludeByAttribute>
<SkipAutoProps>true</SkipAutoProps>
</Configuration>
dotnet test --settings coverlet.runsettings --collect:"XPlat Code Coverage"
Coverage sau khi loại trừ: 58%
Crm.Domain: 42% <- ưu tiên 1
Crm.Application: 51% <- ưu tiên 2
Crm.Infrastructure: 73%

Con số thấp hơn nhưng có ý nghĩa — nó phản ánh đúng phần code chứa quyết định.

Ngưỡng theo tầng, không dùng một ngưỡng chung:

- name: Kiểm tra coverage theo tầng
run: |
dotnet test --settings coverlet.runsettings \
/p:CollectCoverage=true \
/p:Threshold=85 /p:ThresholdType=line \
/p:Include="[Crm.Domain]*" \
tests/Crm.Domain.Tests
Crm.Domain:         ngưỡng 85%   (quy tắc nghiệp vụ — phải có test kỹ)
Crm.Application: ngưỡng 70%
Crm.Infrastructure: không đặt ngưỡng (test bằng integration test)
Crm.Api: không đặt ngưỡng

Và ba chỉ số tốt hơn coverage:

1. Mutation score trên Domain (bài 2 ở trên) — đo sức mạnh của test, không đo sự hiện diện.

2. Thời gian phát hiện lỗi:

| Lỗi | Phát hiện ở | Đáng ra nên phát hiện ở |
|---|---|---|
| Ngưỡng duyệt sai | Production | Unit test Domain |
| Rò rỉ tenant | Production | Integration test |
| N+1 | Production | Test đếm truy vấn |

Bảng này cho biết lớp test nào đang thiếu, và nó hữu ích hơn bất kỳ con số phần trăm nào.

3. Tỷ lệ commit chỉ sửa test:

git log --since='3 months ago' --name-only --format='%H' \
| awk '/^[0-9a-f]{40}$/{if(n&&t)c++;sha=$0;n=0;t=1;next} /\.cs$/{n++;if($0!~/[Tt]ests?\//)t=0} END{if(n&&t)c++; print c}'

Con số cao nghĩa là test đang bám vào cài đặt — vấn đề ở bài 1.

Kết luận đáng mang theo:

Coverage trả lời:     "phần nào của code CHƯA có test?"
Coverage KHÔNG trả lời: "test của tôi có tốt không?"

Dùng nó để tìm khoảng trống, đừng dùng nó để tự đánh giá.

Và nếu nhóm bạn đang có mục tiêu coverage theo phần trăm, hãy thử đề xuất thay bằng: mutation score trên Domain, và danh sách file không có test nào. Hai thứ đó dẫn tới hành động cụ thể, còn một con số phần trăm thì không.

Tự kiểm tra​

Frequently asked questions

Test hành vi khác test cấu trúc thế nào?

Test cấu trúc khẳng định cách code được viết, ví dụ phương thức nào được gọi bao nhiêu lần, nên nó đỏ mỗi lần refactor dù hành vi không đổi. Test hành vi chỉ khẳng định kết quả nên chỉ đỏ khi hành vi thật sự sai. Câu hỏi phân biệt là nếu viết lại phần bên trong nhưng giữ nguyên kết quả thì test còn đúng không.

Vì sao phân bổ test nên là hình thang thay vì kim tự tháp?

Vì với công cụ hiện đại như Testcontainers và WebApplicationFactory, integration test cho tầng Application rẻ và bắt được lỗi thật, trong khi mock repository thì test luôn xanh kể cả khi truy vấn sai. Nên nhiều unit test cho Domain, nhiều integration test cho Application và API, rất ít end-to-end.

Nên mock cái gì và không mock cái gì?

Mock hệ thống ngoài như email và cổng thanh toán, mock thời gian và ngẫu nhiên. Không mock database mà dùng Testcontainers, không mock lớp trong cùng assembly vì đó là test cấu trúc, và không mock entity hay value object.

Fake tốt hơn mock ở điểm nào?

Fake cho phép assert về kết quả, ví dụ email nào đã được gửi tới ai, thay vì assert về lời gọi. Test đọc dễ hơn và không gãy khi bạn đổi cách gọi nội bộ.

Vì sao coverage nói dối?

Nó đo số dòng code được chạy chứ không đo hành vi được kiểm tra. Một test chỉ assert NotNull vẫn chạy qua toàn bộ handler nên coverage cao, nhưng handler có thể lưu sai dữ liệu mà test vẫn xanh. Cách dùng đúng là đọc ngược để tìm code không được test nào chạy tới.

Bốn loại test nào đáng có dù không thuộc tầng nào?

Kiến trúc test kiểm tra ranh giới không bị phá, test đếm truy vấn chống N+1, test cách ly tenant chống rò rỉ dữ liệu, và test so sánh hợp đồng API chống breaking change. Bốn loại này rẻ để viết và bắt được những lỗi tốn kém nhất.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Test kết quả, đừng test lời gọi. Test gãy khi refactor là test sẽ bị bỏ.
  2. Đừng mock database. Testcontainers cho test thật mà vẫn nhanh đủ dùng.
  3. Coverage đo dòng chạy, không đo hành vi. Mutation testing nói thật hơn.

Tham khảo​

Điều hướng​