Test ที่แตะ DB ต้อง snapshot/restore: กัน test ทำข้อมูลเน่าและ flaky
Integration test ที่เขียน DB จริงคือดาบสองคม — พังเงียบๆ ด้วยความ flaky, order-dependent, และเผลอยิงเข้า prod. มาดูกันว่า snapshot/restore, seeding strategy, safety guard, และ coverage gate ทำงานร่วมกันยังไงให้ test เชื่อถือได้
อ่าน ~8 นาที
ปัญหา: test ผ่านตอนรันเดี่ยว แต่แดงตอนรันรวม
ผมเคยวางระบบ integration test ให้ backend ตัวหนึ่งที่มี business logic เยอะ แล้วเจอปรากฏการณ์ที่หลอนที่สุดในสาย test คือ รัน test file เดียว เขียว รันทั้ง suite แดง แล้วพอรันซ้ำอีกที ผลกลับกัน ทั้งที่ไม่ได้แตะโค้ดสักบรรทัด นี่คืออาการคลาสสิกของ test ที่ mutate DB จริงแบบ in-place โดยไม่คืนสภาพ
Thesis ของบทความนี้ตรงไปตรงมา: test ที่เขียน DB ต้อง snapshot ก่อนและ restore หลังเสมอ เพื่อให้ deterministic + repeatable และต้องมี safety guard กันเผลอชี้ไป prod (รับเฉพาะ DB ที่ชื่อขึ้นต้นด้วย test) พร้อม coverage gate ที่บังคับจริง ไม่ใช่ตัวเลขประดับ README. ผมจะเล่าจากตอนที่วางระบบนี้ให้ตัวเอง และตอนที่ออกแบบ curriculum ให้เด็กฝึกงานเขียน test สาย backend ของจริง
ทำไม mutate-in-place ทำ test flaky และ order-dependent
Test แต่ละอันควรเป็นฟังก์ชันบริสุทธิ์ในเชิงสภาพแวดล้อม คือ input state เท่ากันทุกครั้ง แล้ว output ต้องเท่ากันทุกครั้ง แต่พอ test เขียน DB จริงโดยไม่คืนสภาพ มันจะทิ้ง "ขยะสถานะ" ไว้ให้ test ตัวถัดไป
ตัวอย่างที่เจอบ่อย: test A สร้าง order ทิ้งไว้ 1 record test B รัน GET /orders?limit=10 แล้ว assert ว่าได้ 5 record เพราะตอนเขียน test B คิดว่า seed มี 5 record. พอ A รันก่อน B จะเห็น 6 record — แดง. พอ test runner สลับลำดับ (หลาย framework รัน parallel หรือสุ่มลำดับเพื่อจับ coupling) ผลก็สุ่มตาม นี่คือ order-dependent test และมันกัดกร่อนความเชื่อมั่นในทั้ง suite เพราะพอ CI แดงแบบสุ่ม คนจะเริ่มกด re-run จนเขียวแล้ว merge — เท่ากับ test กลายเป็นพิธีกรรม ไม่ใช่ safety net
รากของปัญหาไม่ใช่ "DB จริงไม่ดี" แต่คือ ไม่มีขอบเขตของ state ต่อ test. ทางแก้ที่ยั่งยืนคือทำให้ทุก test เริ่มจาก known state เดียวกัน และคืนสภาพเมื่อจบ
Snapshot/restore pattern: dump ก่อน, รัน, restore หลัง
หลักคิดคือมอง DB เป็น state ที่ต้องมี checkpoint. ก่อน test แตะ ให้ dump collection ที่เกี่ยวข้องออกมาเก็บไว้ (ผมใช้ JSONL ต่อ collection — 1 บรรทัด 1 document อ่าน/เขียนทีละ document ได้ ไม่ต้องโหลดทั้งก้อนเข้า memory) พอ test รันเสร็จ ไม่ว่าจะผ่านหรือ fail ให้ restore กลับสภาพเดิม
// pattern กลางที่ผมใช้ครอบทุก integration test
// ใช้ afterEach เพื่อให้ restore แม้ test throw
let snapshotId: string;
beforeEach(async () => {
// dump collection ที่ test นี้จะแตะ ออกเป็น JSONL เก็บไว้
snapshotId = await db.snapshot(['orders', 'stock']);
});
afterEach(async () => {
// คืนสภาพ "เสมอ" — วางใน afterEach ไม่ใช่ท้าย test body
// เพราะถ้า assertion fail แล้ว throw ท้าย body จะไม่ทำงาน
await db.restore(snapshotId);
});
จุดที่คนพลาดบ่อยคือเอา restore ไปไว้ท้าย test body — พอ assertion แรก fail แล้ว throw โค้ด restore จะไม่ถูกเรียก state เน่าค้าง suite ทันที กฎเหล็กคือ restore ต้องอยู่ใน teardown hook ที่รับประกันว่ารันเสมอ (afterEach/finally) ไม่ผูกกับ happy path
อีกจุด: snapshot เฉพาะ collection ที่ test แตะจริง อย่า dump ทั้ง DB ทุกรอบ เพราะช้าและ scope กว้างเกินจำเป็น การจำกัด scope ทำให้ test เร็วขึ้นและอ่านออกว่า test นี้ "เป็นเจ้าของ" state ก้อนไหน
Seeding strategy: drop_first vs upsert vs insert ใช้เมื่อไหร่
ก่อน snapshot/restore จะมีความหมาย คุณต้องมี baseline state ที่แน่นอนก่อน นั่นคือหน้าที่ของ seed. ผมแยกไว้ 3 กลยุทธ์ และแต่ละแบบตอบโจทย์คนละสถานการณ์:
- drop_first — ลบ collection ทิ้งแล้ว insert fixture ใหม่ทั้งชุด. ใช้เป็น default ของ test suite เพราะให้ state ที่ สะอาดและ deterministic ที่สุด ไม่มีเศษข้อมูลเก่าปน. ราคาที่จ่ายคือช้ากว่าและ destructive — ซึ่งใน test DB ยอมรับได้เพราะเราตั้งใจให้มันเริ่มใหม่
- upsert — มี key แล้ว update ไม่มีก็ insert. เหมาะกับ fixture ที่ต้อง idempotent เช่น seed reference data (ประเภทสินค้า, ค่าคงที่ระบบ) ที่อาจถูก seed ซ้ำหลายรอบ รันกี่ครั้งผลก็เท่าเดิม ไม่พังถ้ามีอยู่แล้ว
- insert — เพิ่มอย่างเดียว ไม่แตะของเดิม. ใช้เมื่ออยากต่อยอด state ที่มีอยู่ (เช่น seed transaction เพิ่มบน account เดิม) แต่ต้องระวัง: ถ้า record ชนกันจะ error หรือเกิด duplicate — เป็นตัวเลือกที่เสี่ยงสุด ใช้เฉพาะตอนรู้แน่ว่า collection ว่างหรือ key ไม่ชน
หลักที่ transferable: เลือก strategy ตามว่าต้องการ determinism หรือ composability. Test suite ทั่วไปเอา determinism ก่อน จึงควรเป็น drop_first เป็นฐาน แล้วใช้ upsert/insert เฉพาะจุดที่มีเหตุผลชัด
Safety guard: ปฏิเสธถ้า DB name ไม่ match pattern test
นี่คือจุดที่ผมถือว่าห้ามประหยัด. เครื่องมือที่ drop หรือ overwrite DB ได้ คือเครื่องมือที่มีวันหนึ่ง config env หลุดแล้วชี้ไป prod. ป้องกันด้วย logic ตัวเดียว: ปฏิเสธการทำลายทันทีถ้าชื่อ DB ไม่ match pattern ของ test
// guard ที่ควรครอบทุก operation ที่ทำลายข้อมูลได้ (drop/reset)
const TEST_DB_PATTERN = /^test[_-]/i; // ต้องขึ้นต้นด้วย test_ หรือ test-
function assertSafeToDrop(dbName: string, allowOverride = false) {
if (TEST_DB_PATTERN.test(dbName)) return; // test_orders, TEST-shop → ผ่าน
if (allowOverride) return; // ประตูหลังที่ต้องตั้งใจเปิดเท่านั้น
// prod_orders, shop, main → ระเบิดทันที ไม่ยอมทำลาย
throw new Error(
`Refusing to drop non-test DB "${dbName}". ` +
`Name must match ${TEST_DB_PATTERN} or pass explicit override.`
);
}
ประเด็นสำคัญคือ guard นี้เป็น fail-closed — ค่า default คือ "ไม่ทำ" ถ้าไม่มั่นใจ ต้องมีคนตั้งใจส่ง override เข้ามาถึงจะข้ามได้ ไม่ใช่ fail-open ที่ทำก่อนแล้วค่อยเตือน. หลักการ transferable: operation ที่ทำลายข้อมูลต้องมี naming convention เป็นด่านตรวจ และ default ต้องปฏิเสธ. Convention ที่ผมชอบคือบังคับให้ test DB ทุกตัวขึ้นต้นด้วย test — เห็นชื่อปุ๊บรู้ทันทีว่าแตะได้ ส่วนอะไรที่ไม่ขึ้นต้นด้วย test ถือว่าห้ามแตะไว้ก่อน
Coverage gate ที่บังคับจริง: ไม่ถึง threshold = build แดง
Coverage 80% ที่เขียนไว้ใน README แต่ไม่มีอะไรบังคับ = 0%. ผมเจอโปรเจกต์ที่ coverage ค่อยๆ ไหลลงเดือนละนิดเพราะไม่มีด่านกั้น. Gate ที่ได้ผลคือผูกเข้า test runner ให้ exit code ไม่ใช่ 0 เมื่อ coverage ต่ำกว่า threshold แล้ว CI จะแดงเอง merge ไม่ได้
// jest.config.js — บังคับ threshold ที่ระดับ runner
module.exports = {
collectCoverage: true,
coverageThreshold: {
global: {
lines: 80, branches: 75, // ต่ำกว่านี้ jest exit 1 → CI แดง
functions: 80, statements: 80,
},
},
};
สอง pitfall ที่ทำให้ gate นี้เป็นแค่ทฤษฎี: หนึ่ง ตั้ง threshold ตอนโปรเจกต์ยังไม่มี test แล้วตั้งต่ำจน pass ง่ายเกินไป — วิธีที่ดีกว่าคือตั้งเป็น ratchet: ล็อกที่เลขปัจจุบัน ห้ามต่ำลง แล้วค่อยๆ ดันขึ้น. สอง coverage สูงแต่ assertion ปลอม — test ที่รันโค้ดผ่านแต่ไม่ได้ assert อะไรจริงจังก็ทำ coverage เขียวได้ Gate จับ "โค้ดถูกรัน" ไม่ได้จับ "โค้ดถูกตรวจ". ฉะนั้น coverage เป็น floor ไม่ใช่ ceiling — มันการันตีว่าไม่มีโค้ดที่ไม่มี test เฉียดเลย ไม่ได้การันตีว่า test ดี
Hidden bug curriculum: ออกแบบ test ให้จับ bug ที่ตั้งใจซ่อน
ตอนออกแบบ curriculum สอนคนเขียน test สาย backend ผมใช้วิธีที่ได้ผลมาก: ฝัง bug ไว้ในโค้ดโดยตั้งใจ แล้ววัดว่า test ที่คนเขียนจับได้กี่ตัว. เป็นการกลับด้าน — แทนที่จะวัดว่า test เขียว วัดว่า test จับผิดเป็นไหม
bug ที่ผมชอบซ่อนล้วนเป็นแนวที่ "รันปกติผ่าน" แต่พังตรง edge: idempotency ที่ไม่ทำงาน (ยิงซ้ำแล้วสร้าง record ซ้ำ), pagination ที่ข้าม/ซ้ำ index ตรงรอยต่อ cursor, race condition ตอนตัด stock พร้อมกัน, สิทธิ์ cancel ที่หลุดให้คนอื่น cancel ของเราได้, และ qty validation หละหลวม (รับ qty = 0 หรือติดลบ). ทุกตัวมีลายเซ็นเดียวกับ pitfall ในบทความนี้คือ happy path เขียว แต่ boundary พัง
บทเรียน transferable สำหรับทุกคน ไม่ใช่แค่คนสอน: test ที่ดีเกิดจากคิดแบบ Given/When/Then ที่ When ตั้งใจพาไปชายขอบ — qty = 0, stock = 1, ยิง request เดิมสองครั้ง, cursor ที่ชี้ record สุดท้ายพอดี. ถ้า test ของคุณจับ bug พวกนี้ไม่ได้ตอนซ้อม มันก็จับไม่ได้ตอน bug จริงหลุดขึ้น prod
แยก test DB จาก dev DB และ CI ต้อง provision เอง
ข้อสุดท้ายที่ทำให้ทั้งระบบข้างบนมั่นคง: test DB ต้องแยกกายภาพจาก dev DB. ถ้า test รันบน DB เดียวกับที่คุณเปิด dev อยู่ ทุกรอบที่ drop_first จะล้างข้อมูลที่คุณกำลังคลิกทดสอบด้วยมือทิ้ง — น่ารำคาญ และอันตรายถ้าเผลอชี้ผิดตัว. แยก DB คนละชื่อ (เช่น app_dev กับ test_app) แล้วให้ safety guard ข้างบนคุ้มกันอีกชั้น
ส่วนใน CI ห้ามพึ่ง DB ที่ตั้งไว้แล้วในเครื่องใคร — CI ต้อง provision test DB ขึ้นมาสดทุกครั้ง (service container / ephemeral instance) รัน seed แล้วทิ้งเมื่อจบ job. เหตุผลคือความ deterministic ระดับ pipeline: ไม่ว่ารันบนเครื่องใคร เวลาไหน ต้องได้ environment เท่ากันเป๊ะ. DB ที่ค้างจากรอบก่อนคือแหล่งของ "รันบนเครื่องฉันผ่าน" ที่ทำ CI ไม่น่าเชื่อถือ
สรุป: test ที่แตะ DB ต้องมีขอบเขตและด่านกัน
ทุกอย่างในบทความนี้แก้ปัญหาเดียวกัน — test ที่เขียน DB จริงจะเชื่อถือได้ต่อเมื่อมันเริ่มจาก state เดียวกัน คืนสภาพเสมอ และแตะได้เฉพาะ DB ที่ปลอดภัยจะแตะ. ถ้าจะจำกลับไปใช้:
- Snapshot ก่อน restore หลังเสมอ — dump collection ที่เกี่ยว วาง restore ใน teardown hook ที่รันแม้ test fail ไม่ใช่ท้าย body
- เลือก seeding strategy ตามเป้า — drop_first เพื่อ determinism (default), upsert เพื่อ idempotent reference data, insert เฉพาะตอนรู้ว่า key ไม่ชน
- Safety guard fail-closed — รับเฉพาะ DB ที่ชื่อขึ้นต้นด้วย test, ทำลายข้อมูลได้ต่อเมื่อ match pattern หรือ override ชัดเจน
- Coverage gate ผูก exit code — ต่ำกว่า threshold = build แดง; ตั้งแบบ ratchet และจำไว้ว่า coverage เป็น floor ไม่ใช่การันตีคุณภาพ assertion
- ออกแบบ test ให้จับ boundary — happy path เขียวไม่พอ; test ที่ดีคือ test ที่จับ bug ที่ตั้งใจซ่อนได้
- แยก test DB และให้ CI provision เอง — อย่าให้ test ล้าง dev DB, อย่าให้ CI พึ่ง state ค้างจากรอบก่อน




