โหมดมืด
บทที่ 4 — Testing (xUnit + WebApplicationFactory + Testcontainers)
🟡 หมายเหตุระดับ: บทนี้เริ่มจาก 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มี fieldPasswordเพิ่มเข้ามา (จาก ch.1 ที่มีแค่Name,- มาจากบทไหน —
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 ก่อน:
bashdotnet add UsersApi.Tests package Microsoft.EntityFrameworkCore.Sqlitecsharpusing 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) คอมไพเลอร์จะสร้าง classProgramให้แบบinternalซึ่ง test project มองไม่เห็นเราจึงประกาศ
partial class Programเปล่า ๆ ไว้เอง (partial = แบ่งนิยาม class เดียวออกเป็นหลายที่) แล้วใส่publicให้ ทำให้WebApplicationFactory<Program>ในเทสต์อ้างถึงได้ทางเลือก: ใช้
[assembly: InternalsVisibleTo("UsersApi.Tests")]ใน main project เพื่อให้ test project มองเห็น internal classProgramได้โดยไม่ต้องทำให้เป็น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 ระดับ minor17.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 classAuthenticationHandler<...>คือการส่ง 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.Xunitcsharp
[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 BenchmarkDotNetcsharp
[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:coveragereport13. 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
- AAA pattern — Arrange / Act / Assert (จัดเตรียม/เรียก/ตรวจ — อ่านง่าย)
- 1 test = 1 behavior — 1 เทสต์ตรวจพฤติกรรมเดียว ห้าม assert หลายอย่างที่ไม่เกี่ยวกัน
- Fluent Assertions —
result.Should().Be(expected)อ่านง่ายกว่าAssert.Equal - Setup ผ่าน builder pattern (สร้าง object ทดสอบแบบต่อ method) —
var user = UserBuilder.Default().WithEmail("...").Build() - ห้ามแชร์ state (สถานะ) ระหว่าง test → ใช้
Fixtureต่อ class หรือCollection - Test trait (ป้ายกำกับ test) —
[Trait("Category", "Integration")]ใช้ filter ตอนสั่งรัน - Test naming — ตั้งชื่อแบบ
Method_Condition_ExpectedResult(เมธอด_เงื่อนไข_ผลที่คาด)
csharp
[Fact]
public async Task Create_WhenEmailExists_ShouldThrowConflict() { /*...*/ }15. Common Pitfalls
- ใช้
InMemoryDatabaseแทน DB จริง — ผ่านใน test แต่ระเบิดใน production (เพราะ feature ต่างกัน) - แชร์ HttpClient ระหว่าง test — state ค้าง → สร้างใหม่ต่อ test หรือ reset
- ลืม
awaitใน async test — test ผ่านทั้งที่ความจริงล้มเหลว - WebApplicationFactory + DbContext แต่ไม่ migrate → ได้ schema ว่าง (ไม่มีตาราง)
- mock ทุกอย่าง → test ผ่านแต่ production พัง (เพราะของจริงไม่เหมือน mock)
- Test ที่ขึ้นกับเวลาจริง — ใช้
IClockที่ inject ได้ (ตรึงเวลาใน test)
🛠️ Checkpoint 4 — ลงมือทำ
- unit test
UsersService.Createที่ verify password hash + return user - integration test endpoint
POST /auth/loginด้วย WebApplicationFactory + TestAuthHandler - ใช้ Testcontainers PG → run full CRUD test
- snapshot test ของ response shape
- coverage → ดู % แต่ละ class — เพิ่ม test ที่ class ที่ต่ำ
- 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 + ตั้งชื่อดี
🔤 Glossary · 📋 Style guide · 📅 last_verified: 2026-06-12