TL;DR: GitHub Copilot Testing for .NET (GA trong Visual Studio 2026) có thể tự động sinh, build và chạy unit test cho toàn bộ project C# chỉ bằng một lệnh @Test. Kết hợp với Coverlet threshold enforcement, team có thể đi từ 12% lên 80%+ coverage trong vài giờ — thay vì vài sprint.
Hook: cái giá của "để sau viết test"
Sprint review tháng trước, một team ở BKGlobal báo cáo xong feature mới. Tôi hỏi coverage hiện tại là bao nhiêu. Câu trả lời: 12%.
Không phải team lười. Họ bận. Deadline dồn. Test "để sau viết" — và cái "sau" đó không bao giờ đến. Đến khi refactor service lớn nhất trong codebase, không ai dám chạm vào vì không có safety net. Một PR nhỏ fix bug auth lại làm hỏng module thanh toán ở phía dưới — và không ai phát hiện cho đến khi lên staging.
Đây là bài toán quen thuộc của mọi .NET developer: viết test đúng là việc đúng nhưng tốn thời gian. AI test generation không giải quyết được bài toán kiến trúc — nhưng nó hạ thấp đáng kể chi phí để bắt đầu.
Concept: AI test generation hoạt động thế nào?
Không phải "AI đọc code, đoán test". Công cụ hiện đại như GitHub Copilot Testing for .NET (GA trong Visual Studio 2026 v18.3) hoạt động theo pipeline:
Phân tích cấu trúc solution
→ Xác định test framework (MSTest / NUnit / xUnit)
→ Sinh test code dựa trên signature + implementation
→ Build → Run → Phát hiện lỗi
→ Tự fix và re-run cho đến khi stable
→ Báo cáo before/after coverage
Điểm khác biệt so với "Copilot inline suggestion" thông thường: đây là một agentic loop — Copilot không chỉ gợi ý mà còn build, chạy test, debug lỗi compile, và thử lại. Kết quả bạn nhận được là test đã pass, không phải test draft cần sửa thêm.
Với JetBrains Rider, AI Assistant cũng có tính năng tương tự: right-click vào class → Generate → Unit Tests → Generate test content with AI. Rider 2025.1 bổ sung hỗ trợ Claude 3.7 Sonnet và Gemini 2.0 làm backend AI.
Before: codebase điển hình không có test
Hãy lấy ví dụ thực tế — một OrderService trong hệ thống e-commerce mà tôi gặp ở BKGlobal:
// OrderService.cs — không có test nào
public class OrderService
{
private readonly IOrderRepository _orderRepository;
private readonly IInventoryService _inventoryService;
private readonly IPaymentGateway _paymentGateway;
public OrderService(
IOrderRepository orderRepository,
IInventoryService inventoryService,
IPaymentGateway paymentGateway)
{
_orderRepository = orderRepository;
_inventoryService = inventoryService;
_paymentGateway = paymentGateway;
}
/// <summary>
/// Xử lý đặt hàng: kiểm tra tồn kho, tạo đơn, charge payment.
/// </summary>
public async Task<OrderResult> PlaceOrderAsync(OrderRequest request)
{
// Validate input cơ bản
if (request == null) throw new ArgumentNullException(nameof(request));
if (request.Items == null || !request.Items.Any())
throw new ArgumentException("Order must have at least one item.");
// Kiểm tra tồn kho từng item
foreach (var item in request.Items)
{
var available = await _inventoryService.CheckStockAsync(item.ProductId, item.Quantity);
if (!available)
return OrderResult.Failure($"Product {item.ProductId} is out of stock.");
}
// Tính tổng tiền
var totalAmount = request.Items.Sum(i => i.Price * i.Quantity);
// Charge payment
var paymentResult = await _paymentGateway.ChargeAsync(request.CustomerId, totalAmount);
if (!paymentResult.Success)
return OrderResult.Failure($"Payment failed: {paymentResult.ErrorMessage}");
// Lưu order vào DB
var order = new Order
{
CustomerId = request.CustomerId,
Items = request.Items,
TotalAmount = totalAmount,
Status = OrderStatus.Confirmed,
CreatedAt = DateTime.UtcNow
};
await _orderRepository.SaveAsync(order);
return OrderResult.Success(order.Id);
}
}
Coverage hiện tại: 0%. Không có file test nào.
Examples: dùng GitHub Copilot @Test agent
Bước 1 — Mở Copilot Chat trong Visual Studio 2026
Gõ vào chat:
@Test class OrderService, targeting 80% code coverage, using xUnit and Moq
Hoặc right-click vào class → Copilot Actions → Generate Tests.
Copilot sẽ tự động:
- Tạo project
OrderService.Testsnếu chưa có - Thêm package
xunit,xunit.runner.visualstudio,Moq,coverlet.collector - Sinh file
OrderServiceTests.cs - Build và chạy — nếu fail, tự debug và retry
Bước 2 — Kết quả AI sinh ra (đã review và tinh chỉnh)
// OrderServiceTests.cs — AI-generated, reviewed bởi developer
using Moq;
using Xunit;
namespace OrderService.Tests;
public class OrderServiceTests
{
// Mocks cho các dependency
private readonly Mock<IOrderRepository> _mockRepo;
private readonly Mock<IInventoryService> _mockInventory;
private readonly Mock<IPaymentGateway> _mockPayment;
private readonly OrderService _sut; // System Under Test
public OrderServiceTests()
{
_mockRepo = new Mock<IOrderRepository>();
_mockInventory = new Mock<IInventoryService>();
_mockPayment = new Mock<IPaymentGateway>();
_sut = new OrderService(
_mockRepo.Object,
_mockInventory.Object,
_mockPayment.Object);
}
[Fact]
public async Task PlaceOrderAsync_WhenRequestIsNull_ThrowsArgumentNullException()
{
// Arrange - không cần setup gì
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(
() => _sut.PlaceOrderAsync(null!));
}
[Fact]
public async Task PlaceOrderAsync_WhenItemsListIsEmpty_ThrowsArgumentException()
{
// Arrange
var request = new OrderRequest { CustomerId = "cust-1", Items = new List<OrderItem>() };
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(
() => _sut.PlaceOrderAsync(request));
}
[Fact]
public async Task PlaceOrderAsync_WhenProductOutOfStock_ReturnsFailureResult()
{
// Arrange
var request = CreateValidOrderRequest();
// Inventory báo hết hàng cho product đầu tiên
_mockInventory
.Setup(x => x.CheckStockAsync(request.Items[0].ProductId, request.Items[0].Quantity))
.ReturnsAsync(false);
// Act
var result = await _sut.PlaceOrderAsync(request);
// Assert
Assert.False(result.IsSuccess);
Assert.Contains("out of stock", result.ErrorMessage);
}
[Fact]
public async Task PlaceOrderAsync_WhenPaymentFails_ReturnsFailureResult()
{
// Arrange
var request = CreateValidOrderRequest();
// Tất cả items đều có hàng
_mockInventory
.Setup(x => x.CheckStockAsync(It.IsAny<string>(), It.IsAny<int>()))
.ReturnsAsync(true);
// Payment gateway trả về lỗi
_mockPayment
.Setup(x => x.ChargeAsync(It.IsAny<string>(), It.IsAny<decimal>()))
.ReturnsAsync(new PaymentResult { Success = false, ErrorMessage = "Card declined" });
// Act
var result = await _sut.PlaceOrderAsync(request);
// Assert
Assert.False(result.IsSuccess);
Assert.Contains("Payment failed", result.ErrorMessage);
}
[Fact]
public async Task PlaceOrderAsync_WhenAllValid_SavesOrderAndReturnsSuccess()
{
// Arrange
var request = CreateValidOrderRequest();
_mockInventory
.Setup(x => x.CheckStockAsync(It.IsAny<string>(), It.IsAny<int>()))
.ReturnsAsync(true);
_mockPayment
.Setup(x => x.ChargeAsync(It.IsAny<string>(), It.IsAny<decimal>()))
.ReturnsAsync(new PaymentResult { Success = true });
// Act
var result = await _sut.PlaceOrderAsync(request);
// Assert
Assert.True(result.IsSuccess);
// Verify repository.SaveAsync được gọi đúng 1 lần
_mockRepo.Verify(x => x.SaveAsync(It.IsAny<Order>()), Times.Once);
}
[Fact]
public async Task PlaceOrderAsync_CalculatesTotalAmountCorrectly()
{
// Arrange — order 2 items, verify total = price * quantity
var request = new OrderRequest
{
CustomerId = "cust-1",
Items = new List<OrderItem>
{
new() { ProductId = "p1", Quantity = 2, Price = 100m }, // 200
new() { ProductId = "p2", Quantity = 3, Price = 50m } // 150
}
};
_mockInventory
.Setup(x => x.CheckStockAsync(It.IsAny<string>(), It.IsAny<int>()))
.ReturnsAsync(true);
_mockPayment
.Setup(x => x.ChargeAsync(request.CustomerId, 350m)) // Expect exactly 350
.ReturnsAsync(new PaymentResult { Success = true });
// Act
await _sut.PlaceOrderAsync(request);
// Assert — nếu total sai thì mock sẽ không match và test fail
_mockPayment.Verify(x => x.ChargeAsync(request.CustomerId, 350m), Times.Once);
}
// Helper tạo request hợp lệ để tái sử dụng
private static OrderRequest CreateValidOrderRequest() => new()
{
CustomerId = "cust-42",
Items = new List<OrderItem>
{
new() { ProductId = "prod-1", Quantity = 1, Price = 500m }
}
};
}
6 test cases. Coverage nhảy từ 0% lên 84% — 5 nhánh code, 1 edge case tính toán, và happy path đầy đủ.
Bước 3 — Enforce coverage threshold với Coverlet
Sinh test xong mà không enforce thì coverage sẽ lại drift theo thời gian. Team tôi enforce threshold ngay trong .csproj của test project:
<!-- OrderService.Tests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<!-- Test framework -->
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<!-- Coverage collector -->
<PackageReference Include="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<!-- Moq cho mocking -->
<PackageReference Include="Moq" Version="4.20.72" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OrderService\OrderService.csproj" />
</ItemGroup>
</Project>
Chạy với threshold enforcement:
# Fail build nếu line coverage < 80%
dotnet test /p:CollectCoverage=true /p:Threshold=80 /p:ThresholdType=line
# Hoặc enforce cả line + branch + method với ngưỡng khác nhau
dotnet test /p:CollectCoverage=true \
/p:Threshold="80,70,75" \
/p:ThresholdType="line,branch,method" \
/p:CoverletOutputFormat=cobertura
Tích hợp vào GitHub Actions:
# .github/workflows/test.yml
- name: Run tests with coverage
run: |
dotnet test \
/p:CollectCoverage=true \
/p:Threshold=80 \
/p:ThresholdType=line \
/p:CoverletOutputFormat=cobertura \
/p:CoverletOutput=./TestResults/coverage.xml
- name: Generate coverage report
run: |
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator \
-reports:"./TestResults/coverage.xml" \
-targetdir:"./TestResults/CoverageReport" \
-reporttypes:Html
Giờ thì mỗi PR đều bị chặn nếu coverage drop xuống dưới 80%.
Comparison: Copilot vs Rider AI vs tự viết
| Tiêu chí | GitHub Copilot @Test | Rider AI Assistant | Tự viết tay |
|---|---|---|---|
| Tốc độ khởi động | Rất nhanh (1 lệnh) | Nhanh (right-click) | Chậm |
| Chất lượng edge cases | Tốt, miss một số | Trung bình | Tốt nhất (nếu dev kỹ) |
| Tự fix compile error | Có (agentic loop) | Không | N/A |
| Coverage đạt được | 70–85% tự động | 50–70% cần review nhiều | 90%+ nhưng tốn thời gian |
| Hiểu business logic | Giới hạn | Giới hạn | Tốt nhất |
| Phù hợp | Codebase cũ thiếu test | IDE-first workflow | Feature mới phức tạp |
Nhận xét thực tế từ tôi sau khi dùng với vài project internal: AI test generation tốt nhất cho "coverage debt" — những class đã hoạt động ổn, business logic không quá phức tạp, chỉ thiếu safety net. Với business logic phức tạp (pricing rules, tax calculation, workflow state machine), tôi vẫn prefer viết tay để test đúng intent chứ không chỉ test implementation.
Best practices: để AI test generation không trở thành "test wash"
Qua vài lần áp dụng thực tế, team tôi đúc ra một số quy tắc:
1. Review trước khi commit — không bao giờ bỏ qua
AI có thể tạo test pass nhưng test sai logic. Ví dụ điển hình:
// ⚠️ AI sinh ra — test pass nhưng assert sai
[Fact]
public async Task PlaceOrder_PaymentFails_ReturnsResult()
{
// ... setup ...
var result = await _sut.PlaceOrderAsync(request);
Assert.NotNull(result); // ← quá yếu, không verify IsSuccess = false
}
// ✅ Sau khi review và fix
[Fact]
public async Task PlaceOrder_WhenPaymentFails_ReturnsFailureWithMessage()
{
// ... setup ...
var result = await _sut.PlaceOrderAsync(request);
Assert.False(result.IsSuccess);
Assert.Contains("Payment failed", result.ErrorMessage);
}
2. Cho AI context đủ — đừng để nó đoán
// Prompt tốt:
@Test class OrderService, using xUnit and Moq,
focus on edge cases for PlaceOrderAsync:
- null/empty inputs
- out of stock scenarios
- payment gateway failures
- concurrent order scenarios
// Prompt kém:
@Test OrderService
3. Đừng enforce 100% ngay từ đầu
Team tôi dùng chiến lược tăng dần:
- Sprint 1: enforce 60% (nhanh đạt, tạo momentum)
- Sprint 2–3: tăng lên 75%
- Từ sprint 4 trở đi: giữ ổn định 80%
4. Exclude những gì không cần test
<!-- Trong csproj, exclude generated code và DTOs -->
<PropertyGroup>
<ExcludeFromCodeCoverage>
**/*Dto.cs,
**/*Migrations/*.cs,
**/Program.cs
</ExcludeFromCodeCoverage>
</PropertyGroup>
Hoặc dùng attribute trực tiếp:
[ExcludeFromCodeCoverage]
public class UserDto
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
// ... chỉ là data container, không cần test
}
5. Chạy coverage locally trước khi push
# Alias hữu ích thêm vào bash/zsh profile
alias dotnet-cov='dotnet test /p:CollectCoverage=true /p:Threshold=80 /p:CoverletOutputFormat=lcov /p:CoverletOutput=./coverage.info && genhtml coverage.info -o coverage-report'
Conclusion: coverage không phải mục tiêu — nhưng AI giúp bạn đạt nó dễ hơn
80% coverage không đảm bảo code không có bug. Nhưng nó đảm bảo rằng khi bạn refactor, bạn có một tấm lưới an toàn. Và khi cái tấm lưới đó tốn ít công để dệt hơn — nhờ AI — thì không có lý do gì để không có nó.
Tôi đã thấy team đi từ 12% lên 78% trong một ngày làm việc, dùng GitHub Copilot @Test kết hợp với review kỹ. Không phải vì AI làm tất cả — mà vì AI xử lý phần nhàm chán (viết test boilerplate, mock setup, happy path cơ bản), để developer tập trung vào phần quan trọng hơn: verify đúng behavior, không phải verify implementation.
Nếu bạn đang có codebase .NET với coverage thấp và ngại bắt đầu — thử ngay hôm nay với @Test #solution trong Visual Studio 2026. Kết quả đầu tiên có thể sẽ làm bạn ngạc nhiên.
Bài liên quan:
- Áp dụng AI để dự án fail fast — tránh sai lầm tốn kém
- AI-powered sprint planning: kiểm soát scope creep từ đầu sprint
Son Do — BKGlobal Tech Team
#BKGlobal #dotnet #architecture #1percentbetter