Skip to content

บทที่ 4 — Testing (xUnit + WebApplicationFactory + Testcontainers)

← บทที่ 3 | สารบัญ | บทที่ 5: Production Readiness →

🟡 หมายเหตุระดับ: บทนี้เริ่มจาก unit test พื้นฐาน แต่ครึ่งหลัง (Testcontainers, integration กับ JWT/AuthHandler, CI) เป็นระดับกลาง-สูง — มือใหม่อ่านหัวข้อ 1-6 ก่อนแล้วค่อยกลับมาอ่านส่วน Testcontainers/CI ตอนเจอปัญหาจริงได้ ส่วนหัวข้อ 8 (Authentication ใน integration test) สมมุติว่าคุณคุ้นกับเรื่อง DI/auth จากบทที่ 3 มาแล้ว

ASP.NET Core ออกแบบมาให้ testing เป็นเรื่องหลัก (first-class) — รองรับมาในตัวอย่างดี ไม่ใช่ของเสริม

หัวใจคือ WebApplicationFactory<T> ซึ่งรันแอปแบบ in-process (รันแอปในโปรเซสเดียวกับ test ไม่ต้องเปิด server จริง) ใช้สำหรับ e2e test (end-to-end — ทดสอบทั้งระบบตั้งแต่ต้นจนจบเหมือน user ใช้จริง) และ integration test ที่ใช้ DI ตัวจริงทั้งระบบ


1. Test Framework

ASP.NET ใช้ได้ทั้ง:

  • xUnit (popular, modern)
  • NUnit (เก่ากว่า, .NET-native)
  • MSTest (Microsoft, ใช้กับ Visual Studio)

หนังสือนี้ใช้ xUnit


2. Setup project

เริ่มเขียน test ด้วยการสร้าง test project แยก (xUnit) ที่ reference (อ้างอิงถึง) โปรเจกต์หลัก พร้อมติดตั้งเครื่องมือที่ใช้บ่อย:

  • Mvc.Testing — สำหรับ integration test (รัน WebApplicationFactory)
  • FluentAssertions — assertion (คำสั่งยืนยันผลลัพธ์) แบบอ่านง่าย
  • Testcontainers — รัน DB จริงใน test ผ่าน Docker
  • NSubstitute (แนะนำสำหรับ project ใหม่) — ตัวช่วย mock dependency (สร้างของปลอม = ของเลียนแบบ dependency จริง เช่น database หรือ email sender เพื่อทดสอบ logic โดยไม่ต้องต่อของจริง)
    • ทำไมแนะนำ NSubstitute — syntax สะอาดอ่านง่ายกว่า และไม่มีปัญหาด้าน license
    • เกร็ดเบื้องหลัง (ข้ามได้) — ชุมชน .NET ส่วนใหญ่ย้ายจาก Moq มาใช้ NSubstitute หลังประเด็น SponsorLink ของ Moq ปี 2024 (SponsorLink = ส่วนเสริมใน package ที่เก็บข้อมูลอีเมลนักพัฒนาโดยไม่แจ้งก่อน ทำให้ชุมชนไม่ไว้วางใจ) — Moq ยังใช้ได้อยู่ แต่หนังสือเล่มนี้ยึด NSubstitute ตลอดทั้งบท
bash
dotnet new xunit -n UsersApi.Tests
dotnet add UsersApi.Tests reference UsersApi
cd UsersApi.Tests
dotnet add package Microsoft.AspNetCore.Mvc.Testing
dotnet add package FluentAssertions      # readable assertion
dotnet add package Testcontainers.PostgreSql
dotnet add package NSubstitute             # mocking — หนังสือนี้ใช้ NSubstitute ตลอดทั้งบท

3. Unit Test — Service

unit test ทดสอบ logic ของ service โดยแยกจาก dependency จริง (DB, hasher) เพื่อให้ test เร็วและ deterministic (ให้ผลเหมือนเดิมทุกครั้ง ไม่สุ่ม)

วิธีแยกคือ mock dependency เหล่านั้น (สร้างของปลอมที่เราคุมพฤติกรรมได้) ด้วย NSubstitute ตามที่แนะนำในหัวข้อ 2

โครงสร้าง test ตามแบบ AAA (Arrange-Act-Assert — จัดเตรียมข้อมูล → เรียกสิ่งที่ทดสอบ → ตรวจผลลัพธ์) ทำให้อ่านง่าย ส่วนนี้แสดงตัวอย่างทดสอบว่า Create เรียก hasher จริงไหม

📌 หมายเหตุ cross-chapter — ตัวอย่างนี้ต่อยอดจากบทก่อน อ่านทีละข้อ:

  • signature ที่สมมุติ — บทนี้สมมุติว่า UsersService รับ (AppDbContext, IPasswordHasher<User>) และ CreateUserDto มี field Password เพิ่มเข้ามา (จาก ch.1 ที่มีแค่ Name, Email)
  • มาจากบทไหนIPasswordHasher<User> เริ่มใช้ในบทที่ 3 ส่วน Password เป็นการขยาย CreateUserDto ต่อจากนั้น
  • การ validate — เทสต์ Create_ShouldRejectShortName ด้านล่างสมมุติว่า UsersService.Create มีโค้ด validate ชื่อสั้นแล้วโยน ValidationException ทิ้ง — โค้ด validate ตัวนี้หนังสือยังไม่ได้แสดงให้ดู คุณต้องเพิ่มเองใน service (เช่น if (dto.Name.Length < 2) throw new ValidationException(...)) ไม่งั้นเทสต์นี้จะไม่ผ่านเพราะไม่มีอะไรโยน exception
  • ถ้าโปรเจกต์คุณต่างออกไป — ยังใช้ signature ของ ch.1/ch.2 อยู่ ให้ปรับ argument และ assertion ให้ตรงกับโปรเจกต์จริง
csharp
using FluentAssertions;
using NSubstitute;
using Xunit;

public class UsersServiceTests
{
    [Fact]
    public async Task Create_ShouldHashPassword()
    {
        // Arrange — จัดเตรียม: สร้าง hasher ปลอม (sub) ที่บอกให้คืนค่า "hashed"
        var hasher = Substitute.For<IPasswordHasher<User>>();
        hasher.HashPassword(Arg.Any<User>(), "secret").Returns("hashed");
        var svc = new UsersService(MakeDb(), hasher); // MakeDb() เขียนไว้ใน §4 ด้านล่าง

        // Act — เรียกสิ่งที่ต้องการทดสอบ
        // CreateUserDto ในบทนี้สมมุติว่ามี Password เพิ่มจาก ch.1 (Name, Email)
        var u = await svc.Create(new CreateUserDto("Alice", "a@b.c", "secret"));

        // Assert — ตรวจว่าผลลัพธ์เป็นไปตามคาด
        u.PasswordHash.Should().Be("hashed");
        // Received(1) = ตรวจว่าเมธอดนี้ถูกเรียกพอดี 1 ครั้ง (เทียบเท่า Times.Once ของ Moq)
        hasher.Received(1).HashPassword(Arg.Any<User>(), "secret");
    }

    [Theory]
    [InlineData("")]
    [InlineData("a")]
    public async Task Create_ShouldRejectShortName(string name)
    {
        // service นี้สมมุติว่ามีโค้ด validate ชื่อสั้นแล้วโยน ValidationException (ดูหมายเหตุ cross-chapter ด้านบน)
        var svc = new UsersService(MakeDb(), Substitute.For<IPasswordHasher<User>>()); // MakeDb() อยู่ใน §4 ด้านล่าง
        var act = () => svc.Create(new CreateUserDto(name, "a@b.c", "secret"));
        await act.Should().ThrowAsync<ValidationException>();
    }
}

xUnit attributes:

  • [Fact] — test เคสเดียว ค่าตายตัว
  • [Theory] + [InlineData] — ทดสอบด้วยข้อมูลหลายชุด (parameterized) — เขียน test ครั้งเดียว ใส่ค่าทดสอบหลายชุดได้
  • [MemberData], [ClassData] — แหล่งข้อมูลที่ซับซ้อนกว่า InlineData เหมาะเมื่อข้อมูลทดสอบเยอะ หรือต้องการ object ที่ซับซ้อน เช่น:
csharp
public static IEnumerable<object[]> InvalidNames() =>
    new[] { new object[] { "" }, new object[] { "a" } };

[Theory]
[MemberData(nameof(InvalidNames))]
public async Task Create_ShouldRejectInvalidName(string name) { /* ... */ }

4. In-Memory DbContext (สำหรับ unit test ของ service ที่ใช้ EF)

csharp
public static AppDbContext MakeDb()
{
    var opts = new DbContextOptionsBuilder<AppDbContext>()
        .UseInMemoryDatabase(Guid.NewGuid().ToString())
        .Options;
    return new AppDbContext(opts);
}

⚠️ Pitfall: UseInMemoryDatabase (database จำลองในหน่วยความจำ) ไม่รู้จัก feature เฉพาะของ provider จริง (PG JSONB, full-text search, transactions) → ใช้สำหรับ test พื้นฐานเท่านั้น ถ้าต้องการ integration test จริง → ใช้ Testcontainers (หัวข้อ 7)

💡 ทางเลือกที่ดีกว่า UseInMemoryDatabase — Microsoft แนะนำให้ใช้ SQLite in-memory mode (Data Source=:memory:) สำหรับ unit test ที่อยากใกล้เคียง SQL จริงมากขึ้น เพราะรองรับ transaction, FK constraint, และ SQL syntax ที่แท้จริง:

ติดตั้ง package ก่อน:

bash
dotnet add UsersApi.Tests package Microsoft.EntityFrameworkCore.Sqlite
csharp
using Microsoft.Data.Sqlite; // ต้องมี using นี้

var conn = new SqliteConnection("Data Source=:memory:");
conn.Open(); // ต้องเปิดค้างไว้ ไม่งั้น in-memory DB จะถูกลบ
var opts = new DbContextOptionsBuilder<AppDbContext>().UseSqlite(conn).Options;
var db = new AppDbContext(opts);
await db.Database.EnsureCreatedAsync();

5. WebApplicationFactory — Smoke Test ด้วย InMemoryDatabase

⚠️ ตัวอย่างนี้เป็น smoke test ไม่ใช่ integration test จริง

  • UseInMemoryDatabase ใช้สำหรับทดสอบ wiring (การต่อสายระหว่างชิ้นส่วน: routing, DI, serialization) เร็ว ๆ เท่านั้น
  • อย่าเอาไปใช้ตรวจ logic ที่ขึ้นกับ DB จริง (transaction, constraint, SQL feature, EF Core query translation) เพราะ provider ตัวนี้ทำตัวต่างจาก PostgreSQL/SQL Server (ดู Pitfall #1 ท้ายบท)
  • integration test ที่ถูกต้องใช้ Testcontainers — ดูตัวอย่างในหัวข้อ 7
csharp
public class UsersEndpointsSmokeTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public UsersEndpointsSmokeTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.WithWebHostBuilder(b =>
        {
            b.UseEnvironment("Test");
            b.ConfigureServices(services =>
            {
                // smoke test: สลับ DB จริงออก ใช้ in-memory แทน
                // ⚠️ ใช้ตรวจแค่ wiring/serialization — ไม่ใช่ logic ที่ขึ้นกับ DB จริง
                services.RemoveAll<DbContextOptions<AppDbContext>>();
                // EF Core 8+: ถ้า RemoveAll ข้างบนไม่ครบ ให้เพิ่ม:
                // services.RemoveAll<IDbContextOptionsConfiguration<AppDbContext>>();
                // หมายเหตุ: type IDbContextOptionsConfiguration<T> อาจเป็น API ภายในที่มองไม่เห็นในบาง minor version
                // ตรวจกับ EF Core เวอร์ชันที่ติดตั้งจริงก่อนใช้ — ทางที่ portable กว่าคือลบ registration ของ DbContext
                // ทั้งตัว: services.RemoveAll(typeof(DbContextOptions<AppDbContext>)); แล้วค่อย AddDbContext ใหม่
                services.AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase("test"));
            });
        }).CreateClient();
    }

    [Fact]
    public async Task GetUsers_ShouldReturnEmpty()
    {
        var resp = await _client.GetAsync("/users");
        resp.StatusCode.Should().Be(HttpStatusCode.OK);
        var users = await resp.Content.ReadFromJsonAsync<List<UserResponse>>();
        users.Should().BeEmpty();
    }

    [Fact]
    public async Task PostThenGet_ShouldRoundTrip()
    {
        var create = await _client.PostAsJsonAsync("/users", new { name = "Alice", email = "a@b.c" });
        create.EnsureSuccessStatusCode();
        var created = await create.Content.ReadFromJsonAsync<UserResponse>();

        var get = await _client.GetAsync($"/users/{created!.Id}");
        get.StatusCode.Should().Be(HttpStatusCode.OK);
    }
}

ต้องเพิ่มบรรทัดนี้ใน Program.cs ของ project หลัก (UsersApi/Program.cs) ไม่ใช่ไฟล์ test: public partial class Program { } (วางไว้ ท้ายไฟล์ Program.cs หลังโค้ดทั้งหมด)

ทำไมต้องเพิ่ม: เมื่อใช้ Minimal API แบบไม่มี class Program ชัดเจน (top-level statements) คอมไพเลอร์จะสร้าง class Program ให้แบบ internal ซึ่ง test project มองไม่เห็น

เราจึงประกาศ partial class Program เปล่า ๆ ไว้เอง (partial = แบ่งนิยาม class เดียวออกเป็นหลายที่) แล้วใส่ public ให้ ทำให้ WebApplicationFactory<Program> ในเทสต์อ้างถึงได้

ทางเลือก: ใช้ [assembly: InternalsVisibleTo("UsersApi.Tests")] ใน main project เพื่อให้ test project มองเห็น internal class Program ได้โดยไม่ต้องทำให้เป็น public

WebApplicationFactory:

  • รันแอปขึ้นมาใน process เดียวกับ test (ไม่ได้เปิด http server จริง)
  • เร็วมาก
  • ใช้ DI ของจริง — แค่ override (เขียนทับ) บางตัวที่ต้องการ

6. Override Service ใน Test

ใน integration test เราอยากใช้ของจริงเกือบทั้งหมด แต่บาง dependency ต้องสลับเป็นของปลอม เช่น:

  • email sender — ไม่อยากส่งอีเมลจริงตอน test
  • clock — นาฬิกาที่ตรึงเวลาไว้ ให้ test ได้ผลเดิมทุกครั้ง

WithWebHostBuilder ให้ override (เขียนทับ) service ใน DI ได้เฉพาะตัวที่ต้องการ โดยส่วนอื่นยังเป็นของจริง:

csharp
factory.WithWebHostBuilder(b =>
{
    b.ConfigureServices(services =>
    {
        // สลับ email sender จริงเป็นตัวปลอม (จะได้ไม่ส่งอีเมลจริงตอน test)
        services.RemoveAll<IEmailSender>();
        services.AddSingleton<IEmailSender, FakeEmailSender>();

        // หรือตรึงเวลาให้เป็นค่าคงที่ด้วย TimeProvider ของ .NET 8+ (แนะนำแทน IClock)
        // ต้องติดตั้ง: dotnet add package Microsoft.Extensions.TimeProvider.Testing
        services.AddSingleton<TimeProvider>(
            new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)));
    });
});

7. Testcontainers — DB จริง

Testcontainers = library ที่สั่งเปิด Docker container (เช่น PostgreSQL จริง) ขึ้นมาชั่วคราวตอน test แล้วลบทิ้งเมื่อจบ ทำให้ test กับ DB engine จริงได้โดยไม่ต้องติดตั้งเอง

IAsyncLifetime คือ interface ของ xUnit ที่ให้เรารัน setup/teardown (การจัดเตรียมก่อน test และเก็บกวาดหลัง test) แบบ async — เปิด container ก่อน test, ปิดหลัง test

💡 pin image version ให้ตรงกับ production (ระบุเวอร์ชัน image แทนการใช้ latest)

  • ตัวอย่าง postgres:16-alpine ระบุ major version + ใช้ alpine variant (image เล็ก pull เร็ว) เพื่อ reproducibility (ผลลัพธ์ test เหมือนเดิมทุกครั้ง)
  • ถ้า production ของคุณใช้ PostgreSQL 17 ก็เปลี่ยนเป็น postgres:17-alpine (หรือ pin ระดับ minor 17.x-alpine ถ้าทีมเน้น strict reproducibility — ผูกแน่นทุกระดับ)
  • อย่าใช้ postgres:latest ใน test เพราะ test จะแตกตอน image เลื่อนเวอร์ชัน
  • alpine vs debian: alpine variant เคยมีปัญหา collation (การเรียงตัวอักษรตาม locale) ในบาง use case ถ้าเจอ ลอง debian-based image (postgres:16) แทน (last reviewed: 2026-06)
csharp
public class IntegrationFixture : IAsyncLifetime
{
    public PostgreSqlContainer Db { get; } = new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")   // pin minor + alpine variant — เปลี่ยนเป็นเวอร์ชันที่ production ใช้
        .Build();

    public WebApplicationFactory<Program> Factory { get; private set; } = null!;

    public async Task InitializeAsync()
    {
        await Db.StartAsync();
        Factory = new WebApplicationFactory<Program>()
            .WithWebHostBuilder(b =>
            {
                b.ConfigureAppConfiguration((_, cfg) =>
                {
                    cfg.AddInMemoryCollection(new Dictionary<string, string?>
                    {
                        ["ConnectionStrings:Default"] = Db.GetConnectionString()
                    });
                });
            });

        using var scope = Factory.Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        await db.Database.MigrateAsync();
    }

    public async Task DisposeAsync()
    {
        await Factory.DisposeAsync();
        await Db.DisposeAsync();
    }
}

[CollectionDefinition("Integration")]
public class IntegrationCollection : ICollectionFixture<IntegrationFixture> { }

[Collection("Integration")]
public class UsersIntegrationTests
{
    private readonly IntegrationFixture _fx;
    public UsersIntegrationTests(IntegrationFixture fx) => _fx = fx;

    [Fact]
    public async Task FullFlow()
    {
        var client = _fx.Factory.CreateClient();
        // ... ทดสอบจริงกับ DB จริง
    }
}

ข้อดี:

  • ใช้ DB engine จริง (feature เฉพาะของ PostgreSQL ทำงานได้จริง)
  • isolation (แยกขาดจากกัน) ระหว่าง test suite (ได้ container ใหม่ ไม่ปนกัน)
  • ทำงานบน CI (Continuous Integration — ระบบ build+test อัตโนมัติทุกครั้งที่ push โค้ด) ได้ ถ้า CI นั้นมี docker

⚠️ State leak (ข้อมูลตกค้างข้าม test) ระหว่าง test ใน collection fixture — fixture ตัวอย่างข้างบนแชร์ container ระหว่าง test ทั้งกลุ่ม (เพื่อความเร็ว) แต่ data ที่ test ก่อนหน้าใส่ไว้จะค้างถึง test ถัดไป วิธีจัดการ:

  • ใช้ Respawn library reset ตารางก่อนทุก test
  • หรือห่อทุก test ด้วย transaction แล้ว rollback ตอนจบ (transaction-per-test pattern)

8. Authentication ใน Integration Test

csharp
factory.WithWebHostBuilder(b =>
{
    b.ConfigureServices(services =>
    {
        services.AddAuthentication("Test")
            .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", _ => { });
    });
});

// TestAuthHandler — constructor ต้องรับ parameter เหล่านี้ตามที่ AuthenticationHandler<T> กำหนด (.NET 8+)
public class TestAuthHandler(
    IOptionsMonitor<AuthenticationSchemeOptions> options,
    ILoggerFactory logger,
    UrlEncoder encoder)
    : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, "test-user-id"),
            new Claim(ClaimTypes.Role, "Admin"),
        };
        var identity = new ClaimsIdentity(claims, "Test");
        var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), "Test");
        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

// caller
client.DefaultRequestHeaders.Authorization = new("Test");

💡 อ่าน syntax ของ TestAuthHandler — ส่วน (options, logger, encoder) ที่อยู่หลังชื่อ base class AuthenticationHandler<...> คือการส่ง parameter ของ primary constructor (constructor ที่เขียนไว้ในวงเล็บติดชื่อ class) ต่อเข้า constructor ของ base class โดยตรง — เทียบเท่ากับการเขียน constructor เต็ม ๆ ที่เรียก base(options, logger, encoder) เอง แค่เขียนสั้นกว่า

ข้อดี: test endpoint ที่ต้องผ่าน auth ได้ โดยไม่ต้อง login จริงทุก test (handler ปลอมนี้ปั๊ม user+role ให้เลย)


9. Snapshot Testing

bash
dotnet add package Verify.Xunit
csharp
[Fact]
public async Task MatchesSnapshot()
{
    var resp = await _client.GetAsync("/users/1");
    var json = await resp.Content.ReadAsStringAsync();
    await Verify(json);
}

snapshot testing = บันทึก "ภาพ" ของผลลัพธ์ไว้ครั้งแรก แล้วครั้งต่อไปเทียบกับภาพเดิม ถ้าต่างถือว่า fail — ครั้งแรกสร้างไฟล์ .verified.txt เก็บผลไว้ ครั้งถัดไปเทียบ ถ้า diff (ต่างกัน) = test fail (เหมาะกับเช็คว่า response shape ไม่เปลี่ยนโดยไม่ตั้งใจ)


10. Performance Test — BenchmarkDotNet

🚀 โซนขั้นสูง — มือใหม่ข้ามได้ หัวข้อ 10-11 (performance/load test) เป็นเครื่องมือวัดความเร็ว ค่อยกลับมาตอนต้องจูน performance จริง ตอนเรียนพื้นฐานโฟกัส unit/integration test (หัวข้อ 1-9) ก่อนได้

เมื่อต้องวัดว่าโค้ดเร็วแค่ไหนหรือเทียบสองวิธี อย่าเดาด้วย Stopwatch เอง

BenchmarkDotNet เป็นเครื่องมือมาตรฐานของ .NET — จัดการ warmup (รันอุ่นเครื่องก่อนวัดจริง), วัดหลายรอบ, และรายงาน memory allocation (การจองหน่วยความจำ ยิ่งน้อยยิ่งดี) ให้แม่นยำ

เหมาะกับ micro-benchmark (การวัดเทียบโค้ดชิ้นเล็ก ๆ) ตัวอย่างนี้เทียบสองวิธีของ JSON serializer (ตัวแปลง object เป็น JSON):

bash
dotnet add package BenchmarkDotNet
csharp
[MemoryDiagnoser]
public class JsonBench
{
    private readonly User _user = new() { Id = Guid.NewGuid(), Name = "Alice", Email = "a@b" };

    // เทียบ System.Text.Json แบบ reflection vs source-generation (เร็วกว่า จอง memory น้อยกว่า)
    [Benchmark(Baseline = true)]
    public string Reflection() => JsonSerializer.Serialize(_user);

    [Benchmark]
    public string SourceGen() => JsonSerializer.Serialize(_user, AppJsonContext.Default.User);
}

// source-generated context (โค้ดใหม่ควรใช้ System.Text.Json — Newtonsoft.Json เป็น library เก่า)
[JsonSerializable(typeof(User))]
public partial class AppJsonContext : JsonSerializerContext { }

// Program.cs (benchmark project)
BenchmarkRunner.Run<JsonBench>();

11. Load Test — k6 / NBomber

micro-benchmark วัดโค้ดเล็ก ๆ ส่วน load test (การทดสอบรับโหลด) วัดว่าทั้งระบบรับ traffic (ปริมาณ request) จริงไหวแค่ไหน

วิธีคือยิง request จำนวนมากพร้อมกันแล้วดู:

  • latency — เวลาที่ใช้ตอบ 1 request
  • error rate — สัดส่วน request ที่ error

เครื่องมือยอดนิยม: k6 (สคริปต์ JS) หรือ NBomber (library .NET native — เขียนด้วย .NET โดยตรง) ที่เขียน scenario (สถานการณ์จำลอง) เป็น C# ได้เลย:

NBomber = library .NET native:

csharp
var scenario = Scenario.Create("load", async ctx =>
{
    var resp = await client.GetAsync("/users");
    return resp.IsSuccessStatusCode ? Response.Ok() : Response.Fail();
})
.WithLoadSimulations(Simulation.Inject(100, TimeSpan.FromSeconds(1), TimeSpan.FromMinutes(2)));

NBomberRunner.RegisterScenarios(scenario).Run();

12. Coverage

code coverage (สัดส่วนโค้ดที่ถูก test ครอบคลุม) บอกว่า test ครอบคลุมโค้ดกี่ % — ช่วยหาส่วนที่ยังไม่ได้ทดสอบ (แต่ coverage สูงไม่ได้แปลว่า test ดีเสมอ) ใช้ coverlet เก็บข้อมูลแล้ว reportgenerator แปลงเป็นรายงานอ่านง่าย ตั้งใน CI ให้ fail ถ้าต่ำกว่าเกณฑ์ได้:

bash
dotnet add UsersApi.Tests package coverlet.collector
dotnet test --collect:"XPlat Code Coverage"
# → coverage report

แสดงด้วย reportgenerator:

bash
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:"**/coverage.cobertura.xml" -targetdir:coveragereport

13. CI Setup (GitHub Actions)

test มีค่าที่สุดเมื่อรันอัตโนมัติทุก push/PR (Pull Request — คำขอรวมโค้ดเข้า branch หลัก) ไม่ใช่รอให้คนจำได้

workflow (ลำดับขั้นงานอัตโนมัติ) ของ GitHub Actions ด้านล่าง restore (ดึง package) → build → test ให้ทุกครั้ง

สังเกตว่า GitHub-hosted runner (เครื่องที่ GitHub เตรียมไว้รัน workflow) มี Docker อยู่แล้ว จึงรัน Testcontainers ได้:

yaml
name: test
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with: { dotnet-version: '8.0.x' }    # .NET 8 LTS (baseline ของหนังสือ — support ถึง พ.ย. 2026)
        # เปลี่ยนเป็น '10.0.x' หลัง .NET 10 GA (ตุลาคม 2026) และอัป TargetFramework ใน .csproj ด้วย
      - run: dotnet restore
      - run: dotnet build --no-restore
      - run: dotnet test --no-build --logger trx --collect:"XPlat Code Coverage"

Testcontainers ต้องการ docker ใน CI — GitHub-hosted runner มีอยู่แล้ว

📌 เรื่องเวอร์ชัน .NET ใน CI — ตัวอย่างนี้ใช้ 8.0.x ตาม baseline ของหนังสือ (.NET 8 LTS, support ถึง พ.ย. 2026) หลัง .NET 10 GA (ตุลาคม 2026) เมื่อต้องการอัป path ที่ Microsoft แนะนำคือ: อัป dotnet-version เป็น '10.0.x' → อัป TargetFramework ใน csproj → รัน test → แก้ deprecation ทีละจุด (last reviewed: 2026-06 — ตรวจ release notes ทางการก่อนใช้)


14. Best Practices

  1. AAA pattern — Arrange / Act / Assert (จัดเตรียม/เรียก/ตรวจ — อ่านง่าย)
  2. 1 test = 1 behavior — 1 เทสต์ตรวจพฤติกรรมเดียว ห้าม assert หลายอย่างที่ไม่เกี่ยวกัน
  3. Fluent Assertionsresult.Should().Be(expected) อ่านง่ายกว่า Assert.Equal
  4. Setup ผ่าน builder pattern (สร้าง object ทดสอบแบบต่อ method) — var user = UserBuilder.Default().WithEmail("...").Build()
  5. ห้ามแชร์ state (สถานะ) ระหว่าง test → ใช้ Fixture ต่อ class หรือ Collection
  6. Test trait (ป้ายกำกับ test)[Trait("Category", "Integration")] ใช้ filter ตอนสั่งรัน
  7. Test naming — ตั้งชื่อแบบ Method_Condition_ExpectedResult (เมธอด_เงื่อนไข_ผลที่คาด)
csharp
[Fact]
public async Task Create_WhenEmailExists_ShouldThrowConflict() { /*...*/ }

15. Common Pitfalls

  1. ใช้ InMemoryDatabase แทน DB จริง — ผ่านใน test แต่ระเบิดใน production (เพราะ feature ต่างกัน)
  2. แชร์ HttpClient ระหว่าง test — state ค้าง → สร้างใหม่ต่อ test หรือ reset
  3. ลืม await ใน async test — test ผ่านทั้งที่ความจริงล้มเหลว
  4. WebApplicationFactory + DbContext แต่ไม่ migrate → ได้ schema ว่าง (ไม่มีตาราง)
  5. mock ทุกอย่าง → test ผ่านแต่ production พัง (เพราะของจริงไม่เหมือน mock)
  6. Test ที่ขึ้นกับเวลาจริง — ใช้ IClock ที่ inject ได้ (ตรึงเวลาใน test)

🛠️ Checkpoint 4 — ลงมือทำ

  1. unit test UsersService.Create ที่ verify password hash + return user
  2. integration test endpoint POST /auth/login ด้วย WebApplicationFactory + TestAuthHandler
  3. ใช้ Testcontainers PG → run full CRUD test
  4. snapshot test ของ response shape
  5. coverage → ดู % แต่ละ class — เพิ่ม test ที่ class ที่ต่ำ
  6. CI ใน GitHub Actions — ต้องผ่านก่อน merge

สรุปบทที่ 4

  • xUnit + FluentAssertions + NSubstitute = stack (ชุดเครื่องมือ) ที่หนังสือนี้แนะนำสำหรับ project ใหม่
  • WebApplicationFactory<Program> = e2e แบบรันใน process เดียว
  • Testcontainers = DB จริงที่แยกขาด (isolate) ต่อ test
  • TestAuthHandler = ข้าม (bypass) auth ใน test
  • Coverage:
    • Line coverage ระดับ ~70-80% เป็นแนวทางทั่วไปสำหรับ business logic ของโปรเจกต์ปกติ; 100% ไม่ใช่เป้าหมายเสมอ
    • อุตสาหกรรมที่เสี่ยงสูง (banking/fintech/medical) มัก ตั้ง gate สูงกว่า + บังคับ branch coverage ด้วย — coverage gate ขึ้นกับ risk profile (ตัวเลข exact ตามองค์กร — flagged 2026-06)
    • ⚠️ ระวัง: เขียน test แค่ให้ "รันผ่านบรรทัด" โดยไม่ assert อะไรเลยก็ดัน coverage % ขึ้นได้ — เลข coverage สูงจึงไม่การันตีว่า test ดี ต้องคู่กับ test ที่ assert พฤติกรรมจริงเสมอ
  • AAA pattern + 1 test 1 behavior + ตั้งชื่อดี

→ บทที่ 5: Production Readiness


🔤 Glossary · 📋 Style guide · 📅 last_verified: 2026-06-12