E2E ผ่าน API จริง
บทที่แล้วเราขยับขึ้นมาถึงชั้น integration — พิสูจน์ว่า EfOrderRepository คุยกับ Postgres จริงถูกต้องผ่าน Testcontainers แต่นั่นคือการทดสอบ “ชิ้นเดียว” ของระบบ (repository ↔ ฐานข้อมูล) บทนี้ขึ้นไปถึงยอดพีระมิดสุดท้าย — ทดสอบทั้งระบบพร้อมกันในคราวเดียว ผ่านช่องทางเดียวกับที่ผู้ใช้จริงเรียกใช้: HTTP ไม่มีการเรียก handler ตรงๆ ไม่มีการ new Order ขึ้นมาเอง มีแต่การยิง request เข้าไปเหมือน app มือถือหรือเบราว์เซอร์จริง แล้วดู response ที่ได้กลับมา
code เต็มของบทนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — path: FoodOrdering.Api.Tests/
End-to-End Test — ขับทั้งระบบเหมือนเป็นผู้ใช้จริง
หัวข้อที่มีชื่อว่า “End-to-End Test — ขับทั้งระบบเหมือนเป็นผู้ใช้จริง”End-to-End TestEnd-to-End Test (E2E)test ที่ขับทั้งระบบผ่านช่องทางเดียวกับผู้ใช้จริง เช่น ยิง HTTP request เข้า API แล้วตรวจ response — ให้ความมั่นใจสูงสุดแต่ช้าและเปราะที่สุด จึงมีน้อยที่สุดในพีระมิดProcess (e2e) คือ test ที่ไม่แตะ code ภายในเลยแม้แต่บรรทัดเดียว มันเข้าระบบผ่าน “ประตูหน้า” เดียวกับผู้ใช้จริงเท่านั้น — ในกรณีของ kaen-food-ordering คือ HTTP endpoint ที่ FoodOrdering.Web เปิดไว้ ต่างจาก unit test (บทที่ 2) ที่เรียก Order.Place(...) ตรงๆ หรือ sociable test (บทที่ 5) ที่เรียก PlaceOrderHandler ตรงๆ แต่ fake port วงนอก — e2e test (บทนี้) ไม่รู้จัก PlaceOrderHandler เลยด้วยซ้ำ มันรู้แค่ว่ายิง POST /orders เข้าไปแล้วต้องได้ response กลับมาแบบไหน ทุกชั้นระหว่างทาง — routing, model binding, MediatR pipeline, FluentValidation, domain, EF Core — ทำงานจริงหมด ยกเว้นจุดเดียวที่เราตั้งใจสลับออก (ดูหัวข้อถัดไป)
ข้อดีคือความมั่นใจสูงสุด: ถ้า e2e test ผ่าน แปลว่าทุกชิ้นส่วนประกอบกันทำงานได้จริง ไม่ใช่แค่แต่ละชิ้นถูกแยกทดสอบเฉยๆ แต่ข้อเสียก็ชัดเจนไม่แพ้กัน — มันช้าที่สุด (ต้องบูตทั้ง app) และเปราะที่สุด (route เปลี่ยนชื่อ, response shape เปลี่ยน ก็พังได้ทั้งที่ business logic ยังถูกอยู่) นี่คือเหตุผลที่มันอยู่บนยอดพีระมิดของ Fowler เสมอ ไม่ใช่ฐาน
System Under Test ของ test ระดับนี้คือทั้ง app
หัวข้อที่มีชื่อว่า “System Under Test ของ test ระดับนี้คือทั้ง app”System Under TestSystem Under Test (SUT)ส่วนของระบบที่กำลังถูกทดสอบใน test หนึ่ง ๆ ขอบเขตกว้าง-แคบต่างกันตามระดับ: unit test มี SUT แคบแค่ class เดียว ส่วน e2e test มี SUT กว้างทั้ง appArchitecture (SUT) คือขอบเขตของ “สิ่งที่กำลังถูกทดสอบ” ใน test หนึ่งๆ — และขอบเขตนี้ขยายกว้างขึ้นเรื่อยๆ ตามที่เราไล่ขึ้นพีระมิดมาตลอดคอร์ส: unit test (บทที่ 2) มี SUT แคบสุดแค่ class เดียวอย่าง Order; sociable test (บทที่ 5) ขยาย SUT ไปถึง PlaceOrderHandler บวก domain จริง โดย fake เฉพาะ port วงนอก; integration test (บทที่ 6) มี SUT เป็น adapter หนึ่งตัวคู่กับฐานข้อมูลจริง ส่วนในบทนี้ SUT กว้างที่สุดเท่าที่เป็นไปได้ — ทั้งกระบวนการ: FoodOrdering.Web → FoodOrdering.Application → FoodOrdering.Domain → FoodOrdering.Infrastructure ต่อกันจริงทุกเส้นผ่าน composition root เดียวกับที่ระบบใช้งานจริง
ข้อยกเว้นเดียวที่เราแทรกเข้าไปคือ IPaymentGateway — สลับ adapter จริงที่ยิงเครือข่ายออกไปหาผู้ให้บริการชำระเงินภายนอก เป็น FakeFaketest double ที่ implement ใช้งานได้จริงแต่เบากว่าของจริง เช่น FakePaymentGateway ที่บันทึก Charges/Refunds ไว้ใน list แทนการยิงเครือข่ายจริง — ตรวจผลลัพธ์ผ่านสถานะได้โดยไม่ต้อง verify interactionProcess ที่รันในหน่วยความจำแทน เหตุผลไม่ใช่เพราะขี้เกียจ test ของจริง แต่เพราะผู้ให้บริการชำระเงินอยู่นอกขอบเขตของระบบเราเอง — มันไม่ใช่ส่วนหนึ่งของ kaen-food-ordering ที่เรารับผิดชอบ การยิงเครือข่ายจริงไปหามันทุกครั้งที่รัน test ทั้งช้า ทั้งไม่แน่นอน (ขึ้นกับเครือข่าย/บริการภายนอกที่เราคุมไม่ได้) และอาจสร้างธุรกรรมจริงโดยไม่ตั้งใจ ส่วนที่เหลือทั้งหมด — routing, DI, MediatR, FluentValidation, Order จริง, EF Core, ฐานข้อมูลจริง — ยังคงเป็นของจริงทั้งหมด SUT จึงยังกว้างเกือบเต็มระบบอยู่ดี
WebApplicationFactory<Program> — บูต app ทั้งตัวขึ้นมาในหน่วยความจำ
หัวข้อที่มีชื่อว่า “WebApplicationFactory<Program> — บูต app ทั้งตัวขึ้นมาในหน่วยความจำ”เครื่องมือที่ทำให้ทำแบบนี้ได้คือ WebApplicationFactoryWebApplicationFactoryhelper ของ ASP.NET Core ที่บูตทั้ง app ขึ้นมาในหน่วยความจำสำหรับ test e2e แล้วให้ HttpClient ยิง request จริงเข้าไป พร้อมสลับ dependency ใน DI container ได้ เช่น เปลี่ยน payment gateway จริงเป็น fakeProcess ของ ASP.NET Core — มันบูตทั้ง app (composition root, DI container, middleware pipeline) ขึ้นมาในหน่วยความจำโดยไม่ต้องเปิด port เครือข่ายจริงหรือรัน Kestrel แยก process แล้วให้ HttpClient ที่คุยกับ app นั้นตรงๆ ผ่าน TestServer — client เขียน code เหมือนยิง HTTP ปกติทุกอย่าง (PostAsJsonAsync, GetAsync) แต่ request/response เดินทางในหน่วยความจำล้วนๆ ไม่ผ่านการ์ดเครือข่ายจริง เร็วกว่าการรัน app แยก process มาก แต่ยังคงความจริงของ pipeline ทั้งหมดไว้ครบ
endpoint ที่ test นี้ยิงเข้าไปคือ endpoint จริงจากชั้น Web ของคอร์ส Clean Architecture .NET บทที่ 5 — ในที่นี้แม็ป request เข้าเป็น PlaceOrderCommand รูป canonical (Items อย่างเดียว ส่วนโปรโมชันคิดผ่าน PromotionEngine ไม่ต้องแนบมากับ command) — รูปนี้ตรงกับที่บทที่ 5 แม็ปไว้พอดี ทวนโครงมาก่อนเริ่มเขียน test:
// ทวนจาก FoodOrdering.Domain/ValueObjects/Money.cs (Course A บทที่ 2) — parameter ที่ IPaymentGateway ด้านล่างใช้public sealed record Money(decimal Amount, string Currency);
// ทวนจาก FoodOrdering.Application/Orders/IPaymentGateway.cs (Course A บทที่ 3, เพิ่ม RefundAsync บทที่ 7)public interface IPaymentGateway{ Task ChargeAsync(Money amount, CancellationToken ct); Task RefundAsync(Money amount, CancellationToken ct);}
// ทวนจาก FoodOrdering.Domain.Tests/Fakes/FakePaymentGateway.cs (Course A บทที่ 7 — first-tests)public sealed class FakePaymentGateway : IPaymentGateway{ public List<Money> Charges { get; } = new(); public List<Money> Refunds { get; } = new();
public Task ChargeAsync(Money amount, CancellationToken ct) { Charges.Add(amount); return Task.CompletedTask; }
public Task RefundAsync(Money amount, CancellationToken ct) { Refunds.Add(amount); return Task.CompletedTask; }}
// ทวนจาก FoodOrdering.Web/Contracts/PlaceOrderRequest.cs (Course A บทที่ 5) — รูปร่าง JSON ที่ client ส่งเข้ามาpublic sealed record PlaceOrderRequest( IReadOnlyList<PlaceOrderRequestItem> Items, string? PromoCode, string? CustomerTier);
public sealed record PlaceOrderRequestItem(Guid ProductId, int Quantity);
// ทวนจาก FoodOrdering.Domain/Orders/OrderId.cs และ ProductId.cs (Course A บทที่ 2)public sealed record OrderId(Guid Value);public sealed record ProductId(Guid Value);
// ทวนจาก FoodOrdering.Application/Orders/GetOrderQuery.cs (Course A บทที่ 3 — application-layer)public sealed record GetOrderQuery(OrderId OrderId) : IRequest<OrderDto>;public sealed record OrderDto(OrderId Id, decimal Total, string Currency, int LineCount);
// ทวนจาก FoodOrdering.Application/Orders/PlaceOrderCommand.cs (Course A บทที่ 3 — application-layer)public sealed record PlaceOrderCommand(IReadOnlyList<PlaceOrderItem> Items) : IRequest<OrderId>;public sealed record PlaceOrderItem(ProductId ProductId, int Quantity);
// ทวนจาก FoodOrdering.Web/Program.cs (Course A บทที่ 5) — endpoint จริงที่ test นี้ยิงเข้าไป (แม็ป request → PlaceOrderCommand แบบ canonical)app.MapPost("/orders", async (PlaceOrderRequest request, ISender sender, CancellationToken ct) =>{ var command = new PlaceOrderCommand( request.Items.Select(i => new PlaceOrderItem(new ProductId(i.ProductId), i.Quantity)).ToList()); var orderId = await sender.Send(command, ct); return Results.Created($"/orders/{orderId.Value}", new { orderId = orderId.Value });});
app.MapGet("/orders/{id:guid}", async (Guid id, ISender sender, CancellationToken ct) =>{ var dto = await sender.Send(new GetOrderQuery(new OrderId(id)), ct); return Results.Ok(dto);});จุดเดียวที่ test นี้ต้องแทรกคือสลับ IPaymentGateway จริงเป็น FakePaymentGateway — ทำผ่าน ConfigureWebHost ที่ WebApplicationFactory<Program> เปิดให้ override:
public sealed class OrderApiFactory : WebApplicationFactory<Program>{ public FakePaymentGateway PaymentGateway { get; } = new();
protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureServices(services => { services.RemoveAll<IPaymentGateway>(); services.AddSingleton<IPaymentGateway>(PaymentGateway); }); }}ConfigureServices รันหลัง registration ปกติของ Program.cs เสร็จ ทำให้ RemoveAll<IPaymentGateway>() ถอด adapter จริงออกก่อน แล้วค่อยลงทะเบียน FakePaymentGateway instance เดียวกับที่แฟกทอรีเปิดเผยผ่าน property PaymentGateway — test จึงตรวจย้อนหลังได้ว่ามันถูกเรียกจริงหรือไม่ ทุกอย่างอื่นในระบบ ไม่ว่าจะเป็น IMenuCatalog, IOrderRepository, EF Core, ฐานข้อมูล ยังคงเป็นของจริงเหมือนเดิมทุกประการ
WebApplicationFactory<Program> ต้องอ้างอิงถึง type ชื่อ Program ได้จาก project test — แต่ minimal API แบบ top-level statement จะ generate class Program เป็น internal โดยปริยาย project FoodOrdering.Web จึงต้องเติมบรรทัด public partial class Program; ไว้ท้าย Program.cs เพื่อเปิดให้ FoodOrdering.Api.Tests มองเห็น type นี้ได้ — รายละเอียดเล็กๆ ที่พลาดบ่อยเวลาตั้ง project test e2e ครั้งแรก
test ครบ flow: POST แล้วตามไป GET
หัวข้อที่มีชื่อว่า “test ครบ flow: POST แล้วตามไป GET”sequenceDiagram
participant Test as xUnit
participant Client as HttpClient
participant Web as "FoodOrdering.Web"
participant Handler as PlaceOrderHandler
participant Gateway as FakePaymentGateway
participant DB as Postgres
Test->>Client: PostAsJsonAsync กับ /orders
Client->>Web: POST /orders ผ่าน HTTP จริง
Web->>Handler: PlaceOrderCommand ผ่าน ISender
Handler->>Gateway: ChargeAsync แทน gateway จริง
Handler->>DB: SaveAsync บันทึกออเดอร์จริง
Web-->>Client: 201 Created พร้อม Location
Test->>Client: GetAsync ตาม Location
Client->>Web: GET /orders/id ผ่าน HTTP จริง
Web->>DB: อ่านออเดอร์กลับผ่าน GetOrderQuery
Web-->>Client: 200 OK พร้อม OrderDto
คำบรรยายภาพ: test ไม่รู้จัก PlaceOrderHandler เลยด้วยซ้ำ — มันคุยกับ HttpClient เท่านั้น ทุกลูกศรจาก Web ลงไปคือของจริงทั้งหมด (MediatR, Order, EF Core, Postgres) ยกเว้นเส้นเดียวที่ไปหา FakePaymentGateway ซึ่งเป็นจุดเดียวที่ถูกสลับไว้ตั้งแต่ตอนสร้างแฟกทอรี ทั้ง POST และ GET เดินผ่าน stack เดียวกันครบทุกชั้นสองรอบ นี่คือความหมายของคำว่า “end-to-end” ตามตัวอักษร
test เต็มรูปที่ประกอบทุกอย่างข้างบนเข้าด้วยกัน:
public sealed class PlaceOrderEndpointTests : IClassFixture<OrderApiFactory>{ // seed ไว้ในฐานข้อมูล test ล่วงหน้าผ่าน migration/seed script ของสภาพแวดล้อม test private static readonly Guid SeededProductId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private readonly HttpClient _client; private readonly OrderApiFactory _factory;
public PlaceOrderEndpointTests(OrderApiFactory factory) { _factory = factory; _client = factory.CreateClient(); }
[Fact] public async Task PlaceThenGetOrder_ReturnsCreated() { // Arrange var request = new PlaceOrderRequest( Items: new[] { new PlaceOrderRequestItem(SeededProductId, Quantity: 2) }, PromoCode: null, CustomerTier: null);
// Act — POST จริงผ่าน HttpClient ไม่ใช่เรียก handler ตรง ๆ var postResponse = await _client.PostAsJsonAsync("/orders", request);
// Assert — ฝั่ง POST Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); Assert.NotNull(postResponse.Headers.Location);
// Act ต่อ — ตาม Location ที่ endpoint คืนมาไป GET จริงอีกครั้ง var getResponse = await _client.GetAsync(postResponse.Headers.Location); var order = await getResponse.Content.ReadFromJsonAsync<OrderDto>();
// Assert — ฝั่ง GET Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); Assert.NotNull(order); Assert.True(order!.Total > 0);
// Assert — payment gateway ถูกเรียกจริงผ่าน handler แต่เป็น fake ไม่ยิงเครือข่ายออกไปจริง Assert.Single(_factory.PaymentGateway.Charges); }}สังเกตว่า test นี้ assert แค่สิ่งที่สังเกตได้จากภายนอกเท่านั้น — status code, header, และ body ที่ deserialize กลับมาเป็น OrderDto — ไม่มีบรรทัดไหนแตะ Order หรือ PlaceOrderHandler ตรงๆ เลย ตรงตามนิยามของ SUT ที่กว้างเท่าทั้ง app: มันไม่สนใจว่าข้างในทำงานยังไง สนใจแค่ว่า input ที่ถูกต้องผ่าน HTTP ต้องได้ output ที่ถูกต้องกลับมาผ่าน HTTP เหมือนกัน
e2e ควรมีกี่ตัว — น้อยที่สุดของพีระมิด
หัวข้อที่มีชื่อว่า “e2e ควรมีกี่ตัว — น้อยที่สุดของพีระมิด”ตามที่บทที่ 1 ประมาณสัดส่วนไว้แล้ว — FoodOrdering.Api.Tests ควรมี test แค่ราวๆ ไม่กี่ตัว ต่างจาก Domain.Tests/Application.Tests ที่มีเป็นร้อย เหตุผลไม่ใช่เพราะ e2e “สำคัญน้อยกว่า” แต่เพราะมันแพงที่สุดในทุกมิติ: ช้าสุด (บูตทั้ง app ทุกครั้ง), เปราะสุด (route/JSON shape เปลี่ยนก็พังได้ทั้งที่กฎธุรกิจยังถูก), และดูแลรักษายากสุด (fixture ซับซ้อน ต้อง seed ข้อมูล ต้องจัดการ container) กลยุทธ์ที่ถูกต้องคือเลือกเฉพาะเส้นทางวิกฤต (critical path) — เช่น PlaceThenGetOrder_ReturnsCreated ที่พิสูจน์ happy path หลักของทั้งระบบ บวกอาจมีอีกหนึ่งหรือสองตัวสำหรับเส้นทาง error สำคัญ (เช่นยิง POST /orders ด้วยตะกร้าว่างแล้วคาด 400) ส่วนกรณีปลีกย่อยของกฎธุรกิจ (invariant ต่างๆ, การคำนวณส่วนลด) ปล่อยให้ unit test บทที่ 2 กับ sociable test บทที่ 5 รับผิดชอบไปแล้ว — e2e ไม่มีหน้าที่ทวนกฎธุรกิจซ้ำ มันมีหน้าที่พิสูจน์แค่ว่า “สายไฟทุกเส้นต่อกันถูก”
สรุป + สิ่งที่จะทำต่อ
หัวข้อที่มีชื่อว่า “สรุป + สิ่งที่จะทำต่อ”บทนี้เราขึ้นถึงยอดพีระมิดแล้ว: WebApplicationFactory<Program> บูต FoodOrdering.Web ทั้งตัวในหน่วยความจำ ให้ HttpClient ยิง HTTP จริงเข้าไป โดยสลับแค่ IPaymentGateway เป็น FakePaymentGateway ผ่าน ConfigureWebHost ส่วนที่เหลือทั้งหมด — routing, MediatR, Order จริง, EF Core, ฐานข้อมูล — เป็นของจริงครบ ทำให้ SUT ของ test ระดับนี้กว้างที่สุดเท่าที่เป็นไปได้: ทั้ง app ตั้งแต่ Web ถึง Infrastructure และเพราะมันแพงที่สุด เราจึงมีมันน้อยที่สุด — เฉพาะเส้นทางวิกฤตเท่านั้น
บทสุดท้ายของคอร์สนี้จะพลิกคำถาม — ไม่ใช่ “test อะไร” แต่คือ “อะไรที่ไม่ควร test” เมื่อไรที่ test เยอะไม่ได้แปลว่าดี และทำไม coverage 100% ถึงไม่ใช่เป้าหมายที่ถูกต้อง
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Behavior-Driven Development — e2e test ที่ดีมักอ่านเหมือน scenario ของ BDD: Given ตะกร้าสินค้า, When ยิง POST /orders, Then ได้ 201 กลับมา โฟกัสที่พฤติกรรมภายนอก ไม่ใช่รายละเอียดภายใน
- ชั้น Web — API & error handling (Course A บทที่ 5) — จุดกำเนิดของ endpoint
POST /ordersและGET /orders/{id}ที่ test ในบทนี้ยิงเข้าไปจริง
เช็กความเข้าใจ — บทที่ 7
ข้อ 1 / 3ใน test แบบ end-to-end ของบทนี้ System Under Test (SUT) ครอบคลุมส่วนไหนของระบบ?