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

E2E ผ่าน API จริง

บท​ที่​แล้ว​เรา​ขยับ​ขึ้น​มา​ถึง​ชั้น integration — พิสูจน์​ว่า EfOrderRepository คุย​กับ Postgres จริง​ถูกต้อง​ผ่าน Testcontainers แต่​นั่น​คือ​การ​ทดสอบ “ชิ้น​เดียว” ของ​ระบบ (repository ↔ ฐาน​ข้อมูล) บท​นี้​ขึ้น​ไป​ถึง​ยอด​พีระมิด​สุดท้าย — ทดสอบ​ทั้ง​ระบบ​พร้อม​กัน​ใน​คราว​เดียว ผ่าน​ช่อง​ทาง​เดียว​กับ​ที่​ผู้​ใช้​จริง​เรียก​ใช้: HTTP ไม่มี​การ​เรียก handler ตรงๆ ไม่มี​การ new Order ขึ้น​มา​เอง มี​แต่​การ​ยิง request เข้าไป​เหมือน app มือถือ​หรือ​เบราว์เซอร์​จริง แล้ว​ดู response ที่​ได้​กลับ​มา

📦 code ตัวอย่าง

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

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 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.WebFoodOrdering.ApplicationFoodOrdering.DomainFoodOrdering.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:

FoodOrdering.Api.Tests/OrderApiFactory.cs
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, ฐาน​ข้อมูล ยัง​คง​เป็น​ของ​จริง​เหมือน​เดิม​ทุก​ประการ

ทำไม Program.cs ต้อง​มี public partial class Program

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 ครั้ง​แรก

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 เต็ม​รูป​ที่​ประกอบ​ทุก​อย่าง​ข้าง​บน​เข้า​ด้วย​กัน:

FoodOrdering.Api.Tests/PlaceOrderEndpointTests.cs
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 เหมือน​กัน

ตาม​ที่​บท​ที่ 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

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง 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) ครอบคลุมส่วนไหนของระบบ?