Integration test ด้วย Testcontainers
บทที่แล้วเรา test PlaceOrderHandler ด้วย FakePaymentGateway และ InMemoryOrderRepository — ของปลอมที่ใช้งานได้จริงแต่ยังอยู่ในหน่วยความจำล้วนๆ ไม่มีอะไรวิ่งข้ามกระบวนการ (process) ออกไปเลยสักครั้ง fake หยุดอยู่ตรงนั้น เพราะมันตอบคำถามได้แค่ว่า “handler เรียก port ถูกไหม” แต่ตอบไม่ได้ว่า “ถ้าเป็น adapter ตัวจริงที่คุยกับฐานข้อมูลจริง มันยังทำงานถูกอยู่ไหม” บทนี้คือจุดที่ fake หยุดและของจริงเริ่มทำงาน — เราจะ test EfOrderRepository (Course A บทที่ 4) กับ Postgres ตัวจริง ไม่ใช่ของปลอมอีกต่อไป
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — บทนี้เกี่ยวข้องกับ path FoodOrdering.Integration.Tests/
Integration Test — จุดที่ fake หยุดและของจริงเริ่มทำงาน
หัวข้อที่มีชื่อว่า “Integration Test — จุดที่ fake หยุดและของจริงเริ่มทำงาน”Vladimir Khorikov แบ่ง dependency ที่ code พึ่งพาออกเป็นสองแบบ: managed dependency คือของที่เราควบคุมสถานะภายในได้เต็มที่และไม่มีใครมองเห็นจากนอกกระบวนการ (เช่น List<Order> ใน InMemoryOrderRepository ของบทที่แล้ว) — พวกนี้ fake แทนได้อย่างปลอดภัยโดยไม่เสียความน่าเชื่อถือของ test ส่วน unmanaged dependency คือของที่มีสถานะจริงอยู่นอกกระบวนการและมีกฎการทำงานของตัวเอง (ฐานข้อมูล, ระบบ file, บริการภายนอก) — พวกนี้ fake แทนไม่ได้เต็มร้อย เพราะสิ่งที่เรากลัวไม่ใช่ “handler เรียก repository ถูกไหม” (test ไปแล้วบทที่ 5) แต่คือ “SQL ที่ยิงจริงกับ mapping ที่ config ไว้มันทำงานถูกจริงไหม” — คำถามหลังนี้ตอบได้ด้วย Integration TestIntegration Testtest ที่ตรวจว่า code ของเราคุยกับของภายนอกจริงได้ถูกต้อง เช่น repository กับฐานข้อมูลจริง — ช้ากว่า unit test แต่จับ bug mapping/SQL ที่ unit test มองไม่เห็นProcess เท่านั้น: test ที่ยอมความช้าลง แลกกับการพิสูจน์ว่า code ของเราคุยกับของภายนอกจริงได้ถูกต้อง
ก่อนเขียน test ต้อง recall โครงของ Order เต็มๆ จาก Domain Reference ให้ครบก่อน (Course A บทที่ 1–2, Course B บทที่ 3 ที่ยกระดับ OrderLine เป็น entity, บทที่ 5 ที่เพิ่ม state machine, บทที่ 6 ที่เพิ่ม domain event):
public sealed record OrderId(Guid Value);public sealed record OrderLineId(Guid Value);public sealed record ProductId(Guid Value);public sealed record Quantity(int Value); // guard: Value<1 → "จำนวนต้องมีอย่างน้อย 1"
public sealed record Money(decimal Amount, string Currency) // guard: Amount<0 → "จำนวนเงินต้องไม่ติดลบ"; Currency!="THB" → "ตอนนี้รองรับเฉพาะสกุลเงิน THB"{ public static Money Thb(decimal amount) => new(amount, "THB"); public static Money operator +(Money a, Money b); // ต่างสกุลเงิน → InvalidOperationException "บวกเงินต่างสกุลกันไม่ได้" public static Money operator *(Money unitPrice, int quantity);}
public abstract class Entity<TId>{ public TId Id { get; } protected Entity(TId id) => Id = id; // Equals/GetHashCode เทียบด้วย Id เท่านั้น}
public sealed class OrderLine : Entity<OrderLineId>{ public ProductId ProductId { get; } public Quantity Quantity { get; } public Money UnitPrice { get; }
public OrderLine(OrderLineId id, ProductId productId, Quantity quantity, Money unitPrice) : base(id) { ProductId = productId; Quantity = quantity; UnitPrice = unitPrice; }}
public interface IDomainEvent{ DateTimeOffset OccurredOn { get; }}
public sealed record OrderPlaced(OrderId OrderId, Money Total, DateTimeOffset OccurredOn) : IDomainEvent;
public enum OrderStatus { Placed, Confirmed, Preparing, PickedUp, Delivered, Rejected, Cancelled }
public sealed class Order{ private readonly List<OrderLine> _lines = new(); private readonly List<IDomainEvent> _domainEvents = new();
public OrderId Id { get; } public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly(); public Money Total { get; private set; } public OrderStatus Status { get; private set; } = OrderStatus.Placed; public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
private Order(OrderId id, IEnumerable<OrderLine> lines) { Id = id; _lines.AddRange(lines); Total = _lines.Aggregate(Money.Thb(0), (sum, line) => sum + line.UnitPrice * line.Quantity.Value); }
public static Order Place(OrderId id, IReadOnlyList<OrderLine> items) { if (items.Count == 0) throw new InvalidOperationException("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้");
var order = new Order(id, items); order._domainEvents.Add(new OrderPlaced(order.Id, order.Total, DateTimeOffset.UtcNow)); return order; }}ครบชุดของ Order แล้ว ต่อไป recall ชิ้นส่วนที่ Course A บทที่ 4 สร้างไว้ให้ครบด้วย — เริ่มจาก contract ที่ Application ประกาศ:
// ทวนจาก FoodOrdering.Application/Orders/IOrderRepository.cs (Course A บทที่ 4) — method ที่บทนี้ใช้public interface IOrderRepository{ Task SaveAsync(Order order, CancellationToken ct); Task<Order?> FindAsync(OrderId id, CancellationToken ct);
// มีอีก1 overload ที่รับ ISpecification<Order> เพิ่มเข้ามาใน Course B บทที่ 8 (spec-driven query) — ไม่ใช้ในบทนี้}แล้ว recall adapter ที่ implement มันจริงด้วย EF Core และ DbContext ที่มันพึ่ง:
// ทวนจาก FoodOrdering.Infrastructure/Persistence/FoodOrderingDbContext.cs (Course A บทที่ 4) — เฉพาะส่วนที่เกี่ยวกับ Order/Totalpublic sealed class FoodOrderingDbContext : DbContext{ public FoodOrderingDbContext(DbContextOptions<FoodOrderingDbContext> options) : base(options) { }
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Order>(order => { order.ToTable("Orders"); order.HasKey(o => o.Id); order.Property(o => o.Id) .HasConversion(id => id.Value, value => new OrderId(value));
order.OwnsOne(o => o.Total, total => { total.Property(m => m.Amount).HasColumnName("TotalAmount"); total.Property(m => m.Currency).HasColumnName("TotalCurrency"); });
order.HasMany(o => o.Lines) .WithOne() .HasForeignKey("OrderId"); }); // mapping ของ OrderLine เหมือนกับ Course A บทที่ 4 ทุกประการ — ไม่ทวนซ้ำที่นี่ }}
// ทวนจาก FoodOrdering.Infrastructure/Persistence/EfOrderRepository.cs (Course A บทที่ 4)public sealed class EfOrderRepository : IOrderRepository{ private readonly FoodOrderingDbContext _db;
public EfOrderRepository(FoodOrderingDbContext db) => _db = db;
public async Task SaveAsync(Order order, CancellationToken ct) { _db.Orders.Add(order); await _db.SaveChangesAsync(ct); }
public async Task<Order?> FindAsync(OrderId id, CancellationToken ct) => await _db.Orders .Include(o => o.Lines) .FirstOrDefaultAsync(o => o.Id == id, ct);}Testcontainers — Postgres จริงใน container ชั่วคราว
หัวข้อที่มีชื่อว่า “Testcontainers — Postgres จริงใน container ชั่วคราว”TestcontainersTestcontainerslibrary ที่สปินอินฟราจริง (เช่น Postgres) ขึ้นมาใน Docker container ชั่วคราวสำหรับรัน test แต่ละครั้ง แล้วทิ้งทันทีหลังจบ — ได้ฐานข้อมูลจริงโดยไม่ต้องดูแล instance ถาวรProcess คือ library ที่สปิน Docker container ของอินฟราจริง (Postgres, Redis, Kafka ฯลฯ) ขึ้นมาเฉพาะช่วงที่ test รัน แล้วทิ้งทันทีหลังจบ — ไม่ต้องมี instance ถาวรให้ดูแล ไม่ต้อง reset schema มือระหว่างรัน และทุกเครื่อง (เครื่อง dev, CI) ได้ Postgres รุ่นเดียวกันเป๊ะทุกครั้ง
คำถามที่ตามมาคือ: ทำไมไม่ใช้ EF Core InMemory provider หรือ SQLite in-memory แทน มันเร็วกว่าและไม่ต้องมี Docker ด้วยซ้ำ? เพราะ provider พวกนั้น ไม่ใช่ Postgres — มันแปล LINQ เป็นกลไกของตัวเอง ไม่ใช่ SQL ของ Postgres จริง มันจึงมองไม่เห็น bug ที่เกิดเฉพาะกับ mapping จริง ดูตัวอย่างที่จับต้องได้จาก OnModelCreating ที่ recall ไว้ข้างบน: order.OwnsOne(o => o.Total, ...) สั่งให้ Money (ซึ่งมี2 property คือ Amount กับ Currency) ถูกฝังเป็น2 column TotalAmount/TotalCurrency ในตาราง Orders เดียวกัน — ถ้า config นี้เขียนผิด (เช่น ลืม map Currency, หรือ column type ปัดทศนิยม Amount ผิดตำแหน่ง) EF Core InMemory provider จะไม่มีวันจับได้เลย เพราะมันไม่เคยแปลง OwnsOne เป็น column SQL จริงตั้งแต่แรก มันแค่เก็บ object ทั้งก้อนไว้ในหน่วยความจำ ส่วน Postgres จริงผ่าน Testcontainers จะสร้างตารางจริง เขียนแถวจริง แล้วอ่านกลับมาผ่าน SQL จริง — ถ้า mapping ผิด test จะพังทันที นี่คือช่องว่างที่ integration test เท่านั้นที่ปิดได้
// FoodOrdering.Integration.Tests/PostgresFixture.cs — ใหม่ในบทนี้public sealed class PostgresFixture : IAsyncLifetime{ private readonly PostgreSqlContainer _container = new PostgreSqlBuilder() .WithImage("postgres:16-alpine") .WithDatabase("fooddb_test") .WithUsername("test") .WithPassword("test") .Build();
public FoodOrderingDbContext Db { get; private set; } = null!;
public async Task InitializeAsync() { await _container.StartAsync();
var options = new DbContextOptionsBuilder<FoodOrderingDbContext>() .UseNpgsql(_container.GetConnectionString()) .Options;
Db = new FoodOrderingDbContext(options); await Db.Database.EnsureCreatedAsync(); // สร้าง schema จริงจาก OnModelCreating ที่ recall ไว้ข้างบน }
public async Task DisposeAsync() { await Db.DisposeAsync(); await _container.DisposeAsync(); }}PostgreSqlBuilder/PostgreSqlContainer มาจาก package Testcontainers.PostgreSql — .WithImage(...) เลือก image เดียวกับที่ production ใช้จริง (ไม่ใช่แค่ “Postgres version ไหนก็ได้”) .StartAsync() ดึง image (ถ้ายังไม่มี) แล้วสตาร์ต container ผ่าน Docker daemon ของเครื่อง .GetConnectionString() คืน connection string ที่ชี้ไปยัง port ที่ Docker map ให้แบบสุ่ม เพื่อไม่ให้ชนกับ Postgres ตัวอื่นที่อาจรันอยู่บนเครื่องเดียวกัน
Test Fixture แบบ IAsyncLifetime — 1 container ต่อ collection ไม่ใช่ต่อ test
หัวข้อที่มีชื่อว่า “Test Fixture แบบ IAsyncLifetime — 1 container ต่อ collection ไม่ใช่ต่อ test”xUnit ให้ Test FixtureTest Fixtureสภาพแวดล้อม/ข้อมูลที่เตรียมไว้ให้ test ทำงานบนพื้นฐานเดียวกัน เช่น การ start/stop Testcontainers container ผ่าน IAsyncLifetime — ใช้ซ้ำได้ข้ามหลาย testProcess สำหรับเตรียมสภาพแวดล้อมที่ test หลายตัวใช้ร่วมกัน ปกติ constructor/IDisposable ก็พอสำหรับงาน setup/teardown ทั่วไป แต่การสตาร์ต Docker container เป็นงาน async (ต้องรอ docker pull/docker run จริง) — IAsyncLifetime จึงเป็นทางเลือกที่ถูกต้อง มันมี2 method: InitializeAsync() รันก่อน test ตัวแรกในกลุ่ม และ DisposeAsync() รันหลัง test ตัวสุดท้ายจบ ตามที่ PostgresFixture ข้างบน implement ไว้แล้ว
สิ่งที่สำคัญไม่แพ้กันคือ อย่าสตาร์ต container ใหม่ทุก test — สตาร์ต Postgres ใช้เวลาหลักวินาที ถ้าทำแบบนั้นกับ test เป็นร้อยตัว integration test ทั้งชุดจะช้าจนไม่มีใครอยากรัน xUnit แก้ปัญหานี้ด้วย ICollectionFixture<T> — ให้ test หลาย class ใน [Collection] เดียวกันใช้ instance ของ fixture ร่วมกันตัวเดียว container จึงสตาร์ตแค่ครั้งเดียวต่อการรัน test ทั้งชุด ไม่ใช่ครั้งละ test:
// FoodOrdering.Integration.Tests/PostgresCollection.cs — ใหม่ในบทนี้[CollectionDefinition("Postgres collection")]public sealed class PostgresCollection : ICollectionFixture<PostgresFixture> { }Round-trip test — SaveAsync แล้ว FindAsync ต้องได้ Order เดิมกลับมา
หัวข้อที่มีชื่อว่า “Round-trip test — SaveAsync แล้ว FindAsync ต้องได้ Order เดิมกลับมา”ตอนนี้มีครบทุกชิ้นแล้ว — Order/OrderLine/Money จาก domain, IOrderRepository/EfOrderRepository/FoodOrderingDbContext จาก infrastructure, PostgresFixture/PostgresCollection จากบทนี้ test ที่เป็นหัวใจของบทนี้คือround-trip: SaveAsync ออเดอร์หนึ่งตัวลง Postgres จริง แล้ว FindAsync มันกลับมา แล้วตรวจว่าค่าที่ได้กลับมาตรงกับที่ส่งเข้าไปทุกประการ:
// FoodOrdering.Integration.Tests/EfOrderRepositoryTests.cs — ใหม่ในบทนี้[Collection("Postgres collection")]public sealed class EfOrderRepositoryTests{ private readonly PostgresFixture _fixture;
public EfOrderRepositoryTests(PostgresFixture fixture) => _fixture = fixture;
[Fact] public async Task SaveThenFind_RoundTripsOrder() { // Arrange var line = new OrderLine( new OrderLineId(Guid.NewGuid()), new ProductId(Guid.NewGuid()), new Quantity(2), Money.Thb(60m)); var orderId = new OrderId(Guid.NewGuid()); var order = Order.Place(orderId, new List<OrderLine> { line }); var repo = new EfOrderRepository(_fixture.Db);
// Act await repo.SaveAsync(order, CancellationToken.None); var found = await repo.FindAsync(orderId, CancellationToken.None);
// Assert Assert.NotNull(found); Assert.Equal(order.Total, found!.Total); Assert.Equal(order.Status, found.Status); Assert.Equal( order.Lines.Select(l => (l.ProductId, l.Quantity, l.UnitPrice)), found.Lines.Select(l => (l.ProductId, l.Quantity, l.UnitPrice))); }}สังเกตว่า test นี้ไม่ได้เรียก Assert.Throws หรือเช็ก invariant อะไรเลย — เพราะ invariant ①(ตะกร้าว่างสร้างไม่ได้) พิสูจน์ไปแล้วเต็มๆ ที่บทที่ 2 ด้วยยูนิต test ที่เร็วกว่านี้มาก สิ่งที่ test นี้พิสูจน์คือคำถามคนละข้อ: Money แปลงเป็น2 column แล้วอ่านกลับมาประกอบเป็น Money เดิมได้ไหม, OrderStatus enum แปลงเป็นค่าใน column แล้วอ่านกลับถูกไหม, ความสัมพันธ์ Order↔OrderLine ผ่าน foreign key แล้วโหลดกลับมาครบไหม — คำถามที่ตอบได้ก็ต่อเมื่อมี Postgres จริงอยู่ปลายสาย
อะไรควรอยู่ระดับนี้ กับอะไรไม่ควร
หัวข้อที่มีชื่อว่า “อะไรควรอยู่ระดับนี้ กับอะไรไม่ควร”flowchart TB
subgraph unit["ระดับ Unit (บทที่ 2)"]
direction LR
U1["Test"] -->|"เรียกตรง ไม่มี I/O"| U2["Order (pure)"]
end
subgraph integ["ระดับ Integration (บทนี้)"]
direction LR
T["Test"] --> R["EfOrderRepository"]
R -->|"SQL จริงผ่าน Npgsql"| PG[("Testcontainers<br/>Postgres ชั่วคราวใน Docker")]
PG -->|"แถวที่บันทึกจริง"| R
R -->|"Order ที่ประกอบร่างคืน"| T
end
unit -.->|"เร็วกว่ามาก เยอะกว่ามาก"| integ
คำบรรยายภาพ: ระดับ unit (บนสุด) test เรียก Order ตรงๆ ไม่มีอะไรวิ่งข้ามกระบวนการเลย เร็วระดับมิลลิวินาที ระดับ integration (ล่าง) test เรียก EfOrderRepository ซึ่งยิง SQL จริงผ่าน Npgsql ไปหา Postgres ที่ Testcontainers สตาร์ตขึ้นมาเฉพาะกิจ แล้ววนกลับมาเป็น Order ที่ประกอบร่างคืน (reconstituted) — ช้ากว่าระดับ unit หลักสิบถึงหลักร้อยเท่า จึงมีจำนวนน้อยกว่ามากตาม Test Pyramid ที่บทที่ 1 วางไว้
เส้นแบ่งที่ต้องจำไว้เสมอ: integration test มีไว้ทดสอบเฉพาะสิ่งที่ข้าม process boundary เท่านั้น — mapping ถูกไหม, SQL ที่แปลออกมาทำงานถูกไหม, เชื่อมต่อฐานข้อมูลได้จริงไหม มันไม่ใช่ที่สำหรับ test กฎ domain ซ้ำ อย่างจะ test ว่า Order.Place(...) โยน exception เมื่อตะกร้าว่าง หรือ Confirm() โยนเมื่อสถานะผิดกฎ — เรื่องพวกนี้พิสูจน์ด้วยยูนิต test ที่บทที่ 2 ไปแล้วเต็มร้อย เขียนซ้ำที่นี่ด้วย Postgres จริงมีแต่ทำให้ชุด test ช้าลงโดยไม่ได้อะไรเพิ่ม เพราะ EfOrderRepository ไม่เคยแก้ไข logic ของ Order เลยสักบรรทัด มันแค่บันทึก/โหลดสิ่งที่ Order.Place(...) สร้างไว้แล้วเท่านั้น — นี่คือเหตุผลที่ integration test อยู่ ตรงกลาง พีระมิด ไม่ใช่ฐาน: จำนวนน้อยกว่ายูนิตมาก เพราะขอบเขตของมันแคบกว่าที่คิด แค่ “ชั้นแปลภาษา” ระหว่าง domain กับฐานข้อมูลเท่านั้น
สิ่งที่จะทำต่อ
หัวข้อที่มีชื่อว่า “สิ่งที่จะทำต่อ”บทนี้ปิดจุดที่ fake หยุด — จากนี้เราขยับขึ้นไปอีกชั้นบนสุดของพีระมิด บทที่ 7 จะขับทั้งระบบผ่าน HTTP endpoint จริงด้วย WebApplicationFactory เหมือนผู้ใช้จริงคลิกใช้งาน ซึ่งกิน EfOrderRepository ที่เพิ่ง integration-tested ในบทนี้เข้าไปเป็นส่วนหนึ่งของ stack ทั้งก้อนด้วย
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ และคอร์ส Clean Architecture .NET:
- Continuous Integration — integration test แบบนี้คือสิ่งที่ pipeline ของ CI ควรรันอัตโนมัติทุกครั้งที่ code เปลี่ยน เพื่อจับ bug mapping/SQL ให้เร็วที่สุด ไม่ใช่รอไปเจอตอน deploy จริง
- ชั้น Infrastructure — EF Core & Repository (Course A บทที่ 4) — จุดกำเนิดของ
EfOrderRepository/FoodOrderingDbContextที่บทนี้ทดสอบ
เช็กความเข้าใจ — บทที่ 6
ข้อ 1 / 3integration test ในบทนี้พิสูจน์อะไรเป็นหลัก?