ข้าม​ไป​ยัง​เนื้อหา

Integration test ด้วย Testcontainers

บท​ที่​แล้ว​เรา test PlaceOrderHandler ด้วย FakePaymentGateway และ InMemoryOrderRepository — ของ​ปลอม​ที่​ใช้งาน​ได้​จริง​แต่​ยัง​อยู่​ใน​หน่วย​ความ​จำ​ล้วนๆ ไม่มี​อะไร​วิ่ง​ข้าม​กระบวนการ (process) ออก​ไป​เลย​สัก​ครั้ง fake หยุด​อยู่​ตรง​นั้น เพราะ​มัน​ตอบ​คำถาม​ได้​แค่​ว่า “handler เรียก port ถูก​ไหม” แต่​ตอบ​ไม่​ได้​ว่า “ถ้า​เป็น adapter ตัว​จริง​ที่​คุย​กับ​ฐาน​ข้อมูล​จริง มัน​ยัง​ทำงาน​ถูก​อยู่​ไหม” บท​นี้​คือ​จุด​ที่ fake หยุด​และ​ของ​จริง​เริ่ม​ทำงาน — เรา​จะ test EfOrderRepository (Course A บท​ที่ 4) กับ Postgres ตัว​จริง ไม่ใช่​ของ​ปลอม​อีก​ต่อ​ไป

📦 code ตัวอย่าง

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — บท​นี้​เกี่ยวข้อง​กับ path FoodOrdering.Integration.Tests/

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/Total
public 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);
}

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 ตัว​อื่น​ที่​อาจ​รัน​อยู่​บน​เครื่อง​เดียวกัน

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> { }

ตอน​นี้​มี​ครบ​ทุก​ชิ้น​แล้ว — 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 แล้ว​อ่าน​กลับ​ถูก​ไหม, ความ​สัมพันธ์ OrderOrderLine ผ่าน 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

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง 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 / 3

integration test ในบทนี้พิสูจน์อะไรเป็นหลัก?