Mock data ที่โกหก: เมื่อ summary ไม่ตรงกับ detail แล้วพัง debug ตอนต่อ backend
Mock ที่ hardcode ระหว่างรอ backend เป็นแหล่ง bug ที่มองข้าม เพราะมันดูทำงานได้ในหน้าจอ. บทความนี้เล่าเคสจริงที่ summary บอก 6 รายการ ฿400 แต่ detail มีแค่ 2 รายการ ฿75 แล้วสรุปเป็นกฎว่า mock ต้อง internally consistent ไม่งั้นมันซ่อน bug จนถึงวันต่อ API.
อ่าน ~8 นาที
ปัญหา: mock ที่ไม่ consistent คือ bug ที่รอปะทุตอน integration
ผมรีวิว PR หน้าหนึ่งของฟีเจอร์เช่าอุปกรณ์ในแอปตัวหนึ่ง หน้ายังไม่ต่อ backend จริง ทีมเลย hardcode mock ไว้ก่อนเพื่อให้ UI เดินได้ ทุกอย่างดูเรียบร้อย เปิดหน้ามาเห็นตัวเลขครบ กดปุ่มแล้ว flow วิ่งจนจบ ดูผ่านตาแล้ว "ใช้ได้" จนผมนั่งไล่ตัวเลขในไฟล์ mock ทีละบรรทัดถึงเห็นว่ามันโกหกอยู่สองที่
นี่คือแก่นของบทความ: การ hardcode mock ระหว่างรอ backend เป็นแหล่ง bug ที่คนมองข้ามที่สุด เพราะ mock ที่ผิดไม่ทำให้ test แดง ไม่ทำให้หน้าจอ error มันแค่แสดงตัวเลขที่ดูสมเหตุสมผลออกมา วิธีคิดที่ถูกมีข้อเดียว: mock ต้อง internally consistent — ยอดรวมต้องมาจากรายการจริง flow ต้องผ่านทุก step เหมือนของจริง — ไม่งั้นมันจะซ่อน bug ไว้ แล้วตอนต่อ backend คุณจะแยกไม่ออกว่า bug ที่เห็นมาจาก mock เดิมหรือมาจาก API ใหม่
เคสจริง: summary บอก 6 รายการ ฿400 แต่ detail มี 2 รายการรวม ฿75
ในไฟล์ข้อมูล mock มีสองค่าที่ควรจะพูดเรื่องเดียวกัน แต่กลับถูก hardcode แยกกันคนละที่:
// summary ที่เอาไปโชว์หัวหน้าจอ ("รวม ฿400 · 6 รายการ")
export const USAGE_SUMMARY = { total: 400, count: 6 };
// แต่ detail ที่ render เป็น list ด้านล่างมีแค่ 2 รายการ
export const USAGE_HISTORY = [
{ id: 'u1', amount: 30 },
{ id: 'u2', amount: 45 },
];
// รวมจริง = 75 บาท, 2 รายการ ≠ 400 บาท, 6 รายการ
หน้าจอเลยแสดงหัวว่า "฿400 · 6 รายการ" แต่ list ข้างล่างมีแค่สองแถวรวม 75 บาท ในมุมคนดู UI มันดูเหมือน bug เล็กๆ เรื่องตัวเลขไม่ตรง แต่ในมุม integration มันคือระเบิดเวลา เพราะวันที่ต่อ API จริง แล้ว summary กับ list ไม่ตรงกันขึ้นมา คุณจะไม่รู้เลยว่าเป็นเพราะ backend คำนวณผิด หรือเพราะ UI เคยชินกับ mock ที่ไม่ตรงมาตั้งแต่แรก คุณเสียเวลา debug ปัญหาที่จริงๆ มีมาตั้งแต่ก่อนมี backend
บทเรียนแรก: ทุกครั้งที่ค่าสองค่าใน mock ควรสัมพันธ์กัน แต่ถูกพิมพ์แยกกัน มันคือ bug ที่รอเกิด ไม่ใช่เพราะเลขผิด แต่เพราะโครงสร้างมันเปิดช่องให้ผิดได้เงียบๆ
Flow ที่ mock ข้าม step: ปุ่มกระโดดข้าม confirm modal ที่ควรมี
ปัญหาที่สองไม่ใช่เรื่องตัวเลข แต่เป็นเรื่อง flow ในหน้าเดียวกันมี modal ยืนยันการคืนอุปกรณ์เขียนไว้ครบ export ออกมาแล้วด้วย แต่ไล่ดูโค้ดจริงไม่มีใครเรียกมันเลย ปุ่ม "คืนอุปกรณ์" กระโดดไปหน้า success ตรงๆ:
// ปุ่มคืน — กระโดดข้าม confirm ไป success ทันที
onClick={() => setModal('returnSuccess')}
// ReturnConfirmModal มีอยู่ ถูก export แต่ไม่เคยถูก render
// flow ที่ตั้งใจไว้ควรเป็น: Return → ReturnConfirm → ReturnSuccess
// flow ที่ mock ทำจริง: Return → ReturnSuccess (ข้าม confirm)
ตอนใช้ mock มันดู "ผ่าน" เพราะกดแล้วได้หน้า success สมใจ แต่ step ยืนยันยอดค่ามัดจำ/ค่าปรับที่ควรจะมีหายไปทั้ง step พอต่อ backend จริง ตรงนี้จะกลายเป็นบั๊กหนัก เพราะผู้ใช้กดคืนโดยไม่เห็นยอดที่จะถูกหัก และคนเขียน API ฝั่ง backend ก็ทึกทักว่า frontend มี confirm step ให้แล้ว
หลักการที่ transferable: mock ที่ดีต้องเดินผ่านทุก state เหมือน flow จริง ไม่ใช่แค่พา user ไปถึงปลายทางให้เร็วที่สุด ถ้า flow จริงมี 3 step แล้ว mock ลัดเหลือ 2 step แปลว่าคุณกำลังทดสอบระบบที่ไม่มีอยู่จริง แล้ว step ที่ถูกลัดไปจะไม่มีใครเคยเห็นมันทำงานเลยจนกว่าจะขึ้น prod
กฎข้อ 1: derive summary จาก detail อย่า hardcode สองที่
วิธีแก้เคสแรกไม่ใช่ไปพิมพ์ total: 75, count: 2 ให้ตรง เพราะพรุ่งนี้มีคนเพิ่มรายการที่สามก็ผิดอีก วิธีที่ถูกคืออย่าให้มี source of truth สองที่ตั้งแต่แรก — ให้ summary คำนวณจาก detail เสมอ:
export const USAGE_HISTORY = [
{ id: 'u1', amount: 30 },
{ id: 'u2', amount: 45 },
];
// derive — ไม่มีทางหลุดจากกัน เพราะมาจาก array เดียว
export const USAGE_SUMMARY = {
total: USAGE_HISTORY.reduce((s, r) => s + r.amount, 0),
count: USAGE_HISTORY.length,
};
ตอนต่อ backend จริง กฎนี้ยังใช้ต่อได้เลย ถ้า API ส่ง summary กับ list มาแยกกัน ให้ตั้งคำถามทันทีว่า "backend การันตีไหมว่าสองอันนี้มาจาก transaction เดียวกัน" ถ้าไม่ ก็ derive ที่ frontend หรือใส่ assertion เตือน หลักคิดคือ ค่าที่ derive ได้ อย่าเก็บซ้ำ ทุกสำเนาที่สองของความจริงคือโอกาสที่มันจะเพี้ยนจากกัน
กฎข้อ 2: mock ต้องสะท้อน edge case จริง ไม่ใช่แค่ happy path
mock ส่วนใหญ่ที่ผมเจอสวยเกินจริง — list มีข้อมูลพอดีสวย ไม่มีวันว่าง ไม่มีวัน error ตัวเลขลงตัวหมด แต่ backend จริงส่ง empty array มาได้ ส่ง error มาได้ ส่งยอด 0 หรือค่าติดลบมาได้ ถ้า mock ไม่เคยจำลอง state พวกนี้ UI ก็ไม่เคยถูกทดสอบกับมัน แล้ววันแรกที่ผู้ใช้เจอหน้าว่างก็คือวันที่หน้าจอพัง
// อย่ามี mock แค่ตัวเดียวที่สวยงาม — เตรียม fixture ครบทุก state
export const MOCK_EMPTY = []; // ผู้ใช้ใหม่ยังไม่มีประวัติ
export const MOCK_ONE = [{ id: 'u1', amount: 30 }]; // boundary: รายการเดียว
export const MOCK_ERROR = { error: 'FETCH_FAILED' }; // API ล่ม
export const MOCK_NORMAL = USAGE_HISTORY; // happy path
การมี fixture หลายชุดบังคับให้คุณเขียน empty state, error state, loading state ตั้งแต่ตอนยังไม่มี backend พอ API มาจริง UI พร้อมรับทุกหน้าตาของข้อมูล ไม่ใช่แค่หน้าตาที่สวยที่สุดหน้าเดียว
กฎข้อ 3: แยก mock layer ให้สลับเป็น real API ได้โดยไม่แตะ component
ปัญหาที่ทำให้ mock กลายเป็น bug ซ่อนตัว คือมันปนอยู่ใน component — import { USAGE_HISTORY } ตรงๆ ในไฟล์ที่ render พอจะต่อ API ก็ต้องรื้อทุกไฟล์ที่เผลอ import mock ไว้ และถ้ารื้อไม่หมดก็จะมีจุดที่ยังกิน mock อยู่โดยไม่รู้ตัว วิธีที่ยั่งยืนกว่าคือให้ component คุยกับฟังก์ชันเดียว ไม่ว่าเบื้องหลังจะเป็น mock หรือ API จริง:
// data layer — component ไม่รู้ว่าข้างในเป็น mock หรือ fetch
export async function getUsageHistory() {
if (process.env.NEXT_PUBLIC_USE_MOCK === '1') {
return USAGE_HISTORY; // โหมด mock
}
const res = await fetch('/api/usage/history');
return res.json(); // โหมดจริง — สลับด้วย env ตัวเดียว
}
// component เรียกผ่าน useSWR ตัวเดียว ไม่ผูกกับ mock โดยตรง
const { data } = useSWR('usage-history', getUsageHistory);
พอแยก layer แบบนี้ การต่อ backend คือแก้ที่เดียว ไม่ใช่ตามล่า import ทั่วโปรเจกต์ และมันเปิดทางให้ทำกฎข้อสุดท้ายได้ด้วย
กฎข้อ 4: ใช้ type/schema เดียวกันทั้ง mock และ real เพื่อจับ mismatch ตั้งแต่ compile
bug ทั้งหมดข้างบนเกิดได้เพราะ mock กับ API "ไม่ได้ผูกสัญญากันไว้" ทางแก้ที่แข็งแรงที่สุดคือให้ทั้งสองฝั่งอ้าง type เดียวกัน ถ้า mock ผิดรูป TypeScript จะแดงตั้งแต่ compile ไม่ต้องรอไปเจอตอน runtime:
// types/usage.interface.ts — สัญญากลางที่ทั้ง mock และ API ต้องเคารพ
export interface UsageRecord { id: string; amount: number; }
export interface UsageSummary { total: number; count: number; }
// mock ถูกบังคับให้ตรง type — พิมพ์ field ขาดหรือผิดชนิด = compile error
export const USAGE_HISTORY: UsageRecord[] = [
{ id: 'u1', amount: 30 },
];
// ฟังก์ชันดึงข้อมูลจริงก็ประกาศ return type เดียวกัน
export async function getUsageHistory(): Promise<UsageRecord[]> { /* ... */ }
ถ้าวันหนึ่ง API เปลี่ยน field amount เป็น price โค้ดจะแดงทันทีว่า mock ไม่ตรง สัญญา — นี่คือความต่างระหว่าง bug ที่เจอตอน compile กับ bug ที่เจอตอน user กดปุ่มบน prod หลักการ transferable: ให้ type เป็นสัญญาที่ mock และ real ผูกร่วมกัน mock ไม่ใช่ข้อมูลปลอมลอยๆ แต่คือ implementation หนึ่งของ contract เดียวกับ backend
สรุป: mock ที่ดูทำงานได้ อันตรายกว่า mock ที่พัง
mock ที่ crash เราแก้ทันที แต่ mock ที่ "ดูทำงานได้" ทั้งที่ข้อมูลโกหกและ flow ลัด step คือสิ่งที่หลุด review แล้วไปโผล่เป็นบั๊กสับสนตอน integration ถ้าจะจำกลับไปข้อเดียว จำว่า mock ต้อง internally consistent และผูกสัญญาเดียวกับของจริง
- Derive อย่า hardcode สองที่ — summary ต้องคำนวณจาก detail ไม่งั้นมันเพี้ยนจากกันเงียบๆ
- Flow ต้องผ่านทุก step — ถ้า mock ลัด confirm step step นั้นจะไม่เคยถูกทดสอบจนขึ้น prod
- ครอบ edge case — เตรียม fixture empty/error/boundary ไม่ใช่แค่ happy path หน้าเดียว
- แยก data layer — component คุยกับฟังก์ชันเดียว สลับ mock↔API ด้วย env ไม่ต้องแตะ UI
- Type เดียวกันทั้งสองฝั่ง — ให้ mock และ real ผูก contract เดียว จับ mismatch ตั้งแต่ compile
- รีวิว mock ด้วยสายตาคนต่อ backend — ถามว่า "ถ้าเลขนี้มาจาก API จริงแล้วไม่ตรง ฉันจะรู้ไหมว่าใครผิด" ก่อนบอกว่างานเสร็จ




