AI test generation trong .NET — từ zero đến 80% coverage tự động

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:

  1. Tạo project OrderService.Tests nếu chưa có
  2. Thêm package xunit, xunit.runner.visualstudio, Moq, coverlet.collector
  3. Sinh file OrderServiceTests.cs
  4. 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:


Son Do — BKGlobal Tech Team

#BKGlobal #dotnet #architecture #1percentbetter

Ứng dụng & Xu hướng AI

Xem tất cả