CI แดงทั้งที่ commit นั้นไม่ได้แตะโค้ดส่วนนั้นเลย กดรันใหม่ก็เขียว test แบบนี้เรียกว่า flaky test ทางที่ง่ายที่สุดคือกดรันใหม่แล้วลืมไป แต่ทุกครั้งที่ทำแบบนั้น เราจะเชื่อสีแดงของ CI น้อยลงอีกนิด จนวันที่แดงเพราะโค้ดพังจริง ก็กดรันใหม่ไปตามความเคยชิน
commit ที่ผมไล่ดูมาจากงานหลายแบบ ทั้งระบบ ERP ระบบหลังบ้านร้านค้าออนไลน์ ระบบเชื่อม SAP และแอปส่วนตัว ทุกโปรเจกต์รัน test ด้วย Pest บน PostgreSQL โหลดตารางและข้อมูลตั้งต้นจาก snapshot ที่ commit ไว้ แล้วห่อแต่ละ test ด้วย DatabaseTransactions (อธิบายไว้ในจัดฐานข้อมูลสำหรับ test ด้วย snapshot) แต่ต้นเหตุเกือบทุกข้อเกิดได้กับ RefreshDatabase หรือ MySQL เหมือนกัน
flaky test เกิดจากสิ่งที่ test ไม่ได้กำหนดเอง
test ตัวหนึ่งควรให้ผลเดิมทุกครั้งที่รันกับโค้ดเดิม ถ้าผลเปลี่ยนได้ แปลว่ามีบางอย่างที่ test อ่านไปใช้ แต่ปล่อยให้คนอื่นเป็นคนกำหนด ต้นเหตุทั้ง 10 แบบที่เจอแบ่งได้ห้ากลุ่ม
| สิ่งที่ test ไม่ได้กำหนดเอง | แบบที่เจอ |
|---|---|
| ค่าสุ่มจาก factory | 1. ชนกับข้อมูลใน snapshot · 2. สุ่มจากตัวเลือกน้อยเกินไป · 3. เทียบข้อความกับค่าสุ่ม |
| ลำดับแถว | 4. query ที่ไม่มี ORDER BY |
| เวลา | 5. นาฬิกาจริงของเครื่อง · 6. timezone และโปรแกรมภายนอกที่ดูเหมือนเรื่องเวลา |
| สิ่งที่ค้างจาก test อื่น | 7. ตัวแปร static · 8. แถวใน snapshot ที่หยิบแบบสุ่ม |
| สิ่งรอบ ๆ test | 9. ตัวอย่างน้อยเกินไปและการนับจังหวะ async · 10. ไฟล์ที่ต้อง generate ก่อนรัน |
กดรันใหม่จนเขียวจึงไม่ได้แก้อะไร test ยังพังได้ในอัตราเดิม เพียงแต่รอบนี้ดวงดี
ค่าสุ่มจาก factory ต้องไม่ชนกับข้อมูลอื่น
กลุ่มนี้เจอบ่อยที่สุด เพราะ factory สุ่มค่าใหม่ทุกครั้ง test จึงได้ข้อมูลไม่เหมือนเดิมสักรอบ
1. ค่าสุ่มไปชนกับข้อมูลใน snapshot
snapshot มีข้อมูลตั้งต้นอยู่แล้ว ถ้า factory สุ่มค่าจากรูปแบบเดียวกับข้อมูลจริง สักวันจะสุ่มได้ค่าที่มีอยู่แล้ว เช่น รหัสสามตัวอักษรที่ไปตรงกับรหัสจริงตัวหนึ่ง หรือเลขเอกสารที่สุ่มตามรูปแบบเดียวกับเลขจริง
ทางแก้คือให้ค่าจาก factory อยู่ในรูปแบบที่ข้อมูลจริงไม่มีทางมี หรือคิดจาก id ที่ไม่ซ้ำอยู่แล้ว
// รหัสจริงเป็นตัวอักษรสามตัว factory ใช้ตัวอักษรสองตัวตามด้วยตัวเลข
'code' => fake()->unique()->bothify('??##'),
// เลขเอกสารคิดจาก id ของแถวแม่ และขึ้นต้นด้วย T ที่เลขจริงไม่มี
'document_no' => fn (array $attributes) => sprintf('T%07d', $attributes['order_id']),
2. สุ่มจากตัวเลือกไม่กี่ค่า ในที่ที่ค่าต้องไม่ซ้ำ
randomElement() จากรายการสามถึงสิบค่า หรือ random_int() ในช่วงแคบ ๆ ใช้ได้ถ้า test สร้างแถวเดียว แต่พอ test สร้างสองแถวขึ้นไปภายใต้กฎว่าห้ามซ้ำ จะชนกันราว 1 ใน N รอบ ชื่อสิบชื่อชนกันราว 1 ใน 10 ป้ายที่อยู่สามแบบชนกันราว 1 ใน 3
ใช้ fake()->unique() ต่อท้ายด้วย uuid นับเลขเองด้วยตัวนับ หรือกำหนดค่าใน test ไปเลย
ความไม่ซ้ำทางอ้อมหายากกว่า ตัวอย่างล่าสุดมาจาก music แอปจัดเพลงส่วนตัวของผม แอปมีฟีเจอร์หาเพลงที่คล้ายกับเพลย์ลิสต์ ซึ่งให้แต่ละศิลปินมีได้ไม่เกินหนึ่งเพลง test ของฟีเจอร์นี้สร้างเพลงสองเพลงของวงเดียวกัน แล้วตรวจว่าเพลงที่สองไม่ติดมา ผ่านเกือบทุกครั้ง แต่บางรอบของการรันทั้ง suite มีเพลงที่สองโผล่มาเกิน
TrackFactory สุ่ม key ของศิลปินจาก 1–999 แล้วหาศิลปินด้วย firstOrCreate() ตาม key นั้น ถ้าเพลงสองเพลงใน test เดียวกันสุ่มได้ key ตรงกัน เพลงหลังจะไปอยู่ใต้ศิลปินของเพลงแรกเงียบ ๆ และได้ชื่อศิลปินแบบสุ่มของเพลงแรกไปด้วย ฟีเจอร์นับเพลงต่อศิลปินจากชื่อ เพลงสองเพลงที่ควรเป็นวงเดียวกันจึงกลายเป็นคนละชื่อ และเพลงที่ฟีเจอร์ควรตัดทิ้งก็หลุดออกมา ไม่มี unique key ตัวไหนพังเลย
// เดิม สุ่มจาก 1–999 สองเพลงอาจได้ศิลปินคนเดียวกันโดยไม่ตั้งใจ
'artist_rating_key' => fake()->numberBetween(1, 999),
// ใหม่ ไม่ซ้ำกัน และสูงกว่า key ทุกตัวที่ test กำหนดเอง (ต่ำกว่าล้านทั้งหมด)
'artist_rating_key' => fake()->unique()->numberBetween(1_000_000, 9_999_999),
3. assertion ที่เทียบข้อความกับค่าสุ่ม
ชื่อจาก Faker มีเครื่องหมายอย่าง ' และ & อยู่บ้าง เช่น O'Connell พอ Blade หรืออีเมล escape ออกมาเป็น ' การตรวจด้วย toContain($user->name) ก็พัง อีกแบบคือการตรวจว่าหน้าไม่มีเลข 595 ซึ่งพังในวันที่ id แบบสุ่มออกมาเป็น 78595
ถ้า test ตรวจข้อความ ให้กำหนดชื่อเอง หรือ escape ค่าที่คาดด้วย e() ก่อน และค่าที่ตรวจว่า "ไม่มี" ต้องเป็นค่าที่ไม่มีทางไปซ่อนอยู่ใน id หรือตัวเลขอื่นบนหน้า
expect($html)->toContain(e($customer->name));
ใส่ ORDER BY ทุกครั้งที่ลำดับมีความหมาย
4. query ที่ไม่มี ORDER BY
เอกสารของ PostgreSQL บอกไว้ตรง ๆ ว่าถ้าไม่ใส่ ORDER BY ลำดับแถวที่ได้จะไม่แน่นอน ในทางปฏิบัติ Postgres มักคืนแถวตามลำดับที่เก็บอยู่ในดิสก์ ซึ่งเปลี่ยนไปได้เมื่อ test ก่อนหน้าเขียนแถวแล้ว rollback ทิ้งไว้ อีกเรื่องคือ collation ในเครื่องเรากับใน CI อาจเรียงภาษาไทยกับภาษาอังกฤษไม่เหมือนกัน รายการที่ในเครื่องขึ้นต้นด้วยภาษาไทย บน CI อาจขึ้นต้นด้วยภาษาอังกฤษ
->first(), pluck(), min('name') และ eager load ได้ลำดับนี้มาด้วยทั้งหมด ถ้าลำดับเป็นส่วนหนึ่งของฟีเจอร์ ให้ใส่ orderBy() ไว้ใน query ของโค้ดเอง ถ้าลำดับไม่สำคัญ ให้ test เทียบแบบไม่สนลำดับ
// ลำดับเป็นฟีเจอร์ ใส่ไว้ใน query
Customer::query()->orderBy('id')->first();
// ลำดับไม่สำคัญ เทียบด้วยค่าที่ระบุแถวได้
$rows = collect($response->json('data'))->keyBy('slug');
expect($titles)->toEqualCanonicalizing(['ใบเสนอราคา', 'ใบแจ้งหนี้']);
ตรึงเวลาไว้ใน test ที่เกี่ยวกับวันที่
5. นาฬิกาจริงของเครื่อง
วันที่ที่เขียนตายตัวไว้ว่าเป็น "อนาคต" จะกลายเป็นอดีตในสักวัน วันที่ที่สุ่มอาจตกวันเสาร์หลังเที่ยง ซึ่งเป็นเวลาที่กฎตัดรอบเปลี่ยน และ assertion ที่ยอมให้ต่างกันหนึ่งวินาทีก็อาจไม่พอบนเครื่อง CI ที่ช้า
ตรึงเวลาไว้ที่บรรทัดแรกของ test ด้วย $this->travelTo() หรือ $this->freezeTime() (เอกสารของ Laravel) วันที่ที่สุ่มให้อยู่ห่างจากจุดตัดรอบของธุรกิจ และเวลาที่สร้างหลายค่าให้นับจากจุดเริ่มเดียวกัน แทนการเรียก now() หลายครั้ง
$this->travelTo(CarbonImmutable::create(2026, 10, 14, 9, 0));
'due_at' => fake()->dateTimeBetween('+2 weeks', '+6 weeks'),
6. อาการเหมือนเรื่องเวลา แต่ต้นเหตุอยู่ที่อื่น
มีครั้งหนึ่งที่ตรึงเวลาแล้ว test ก็ยังพัง ต้นเหตุจริงมีสองข้อ ข้อแรกคือคอลัมน์ timestamptz ที่อ่านกลับมาเป็น UTC ขณะที่ now() ของแอปเป็น Asia/Bangkok เวลาสองฝั่งจึงต่างกัน 7 ชั่วโมงพอดี ข้อที่สองคือ test ไปเรียกโปรแกรมภายนอกตัวจริงที่ติดตั้งอยู่บนเครื่อง CI เพราะ Process::fake() ไม่ได้ดักคำสั่งนั้นไว้
ถ้าเวลาต่างกันเป็นชั่วโมงลงตัว ให้สงสัย timezone ก่อน เทียบเวลาใน timezone เดียวกันเสมอ และกัน process ที่ไม่ได้ fake ไว้เหมือนที่กัน HTTP request
Http::preventStrayRequests();
Process::preventStrayProcesses();
เรื่องการกันไม่ให้ test ออกไปข้างนอก มีอธิบายต่อในกัน test ไม่ให้ส่งอีเมล ยิง API หรือเข้าคิวจริง
ล้าง state ที่ค้างจาก test ก่อนหน้า
7. ตัวแปร static ที่อยู่ข้าม test
cache ที่เก็บในตัวแปร static ของฟังก์ชัน ตัวนับใน factory ที่เป็น static หรือจำนวนครั้งที่เรียก mock ใน Vitest อยู่ต่อจาก test หนึ่งไปอีก test ใน process เดียวกัน ผลจึงขึ้นกับว่า test ไหนรันก่อน รันตัวเดียวผ่าน รันทั้ง suite พัง
เก็บค่าแบบนี้ใน static property ที่มีเมธอดล้าง แล้วเรียกเมธอดนั้นใน setUp() ของ TestCase ฝั่ง Vitest ใช้ mockClear() ใน beforeEach ส่วน static ในฟังก์ชันไม่มีใครล้างจากข้างนอกได้ จึงควรเลี่ยง
protected function setUp(): void
{
parent::setUp();
PriceTable::flush();
}
8. แถวใน snapshot ที่หยิบแบบสุ่ม
Product::inRandomOrder()->take(3) หยิบสินค้าจาก snapshot ได้สะดวก แต่ในตัวอย่างที่เจอ บางรอบหยิบได้สินค้าประเภทที่โค้ดไม่นับ test จึงพังราว 1 ใน 22 รอบ อีกแบบคือข้อมูลตั้งต้นที่หลาย test ใช้ร่วมกัน แล้ว test ตัวหนึ่งลบทิ้งในจังหวะที่ transaction ไม่ได้ห่อไว้
สร้างแถวที่กฎต้องใช้ด้วย factory ใน test เอง ข้อมูลที่สร้างจะหายไปตอน rollback อยู่แล้ว ถ้าต้องใช้แถวใน snapshot จริง ให้หาด้วยเงื่อนไขที่แน่นอน เช่น รหัสหรือประเภทที่กฎต้องการ อย่าสุ่มหยิบ
test สถิติ งาน async และไฟล์ที่ต้อง generate
9. ตัวอย่างน้อยเกินไป และการนับจังหวะ async
test ที่ตรวจว่าการสุ่มแบบถ่วงน้ำหนักเอียงไปทางที่ควร ถ้าสุ่มแค่ 5 ครั้งแล้วเทียบกับระยะห่าง 5 แต้ม จะพังราว 1 ใน 4 รอบ การตั้ง seed ช่วยให้ลำดับเลขสุ่มเหมือนเดิม แต่ไม่ได้บอกว่าแถวไหนจะได้เลขไหน ทางแก้คือสุ่มให้มากพอ เช่น ฝั่งละ 200 ครั้ง แล้วตรวจด้วยอัตราส่วนที่ห่างจากค่าที่สุ่มได้ตามธรรมชาติพอสมควร
ฝั่ง JavaScript การเขียน await Promise.resolve() สองครั้งแล้วหวังว่างาน async จะเสร็จ เป็นการเดาจังหวะของ scheduler ให้รอจนเงื่อนไขเป็นจริงแทน
await vi.waitFor(() => expect(save).toHaveBeenCalledOnce());
10. ไฟล์ที่ต้อง generate ก่อนรัน
test ของ React ในเว็บนี้ import route helper ที่ Wayfinder สร้างไว้ใน resources/js/routes ซึ่งอยู่ใน .gitignore และจะมีก็ต่อเมื่อ Vite build แล้ว ใน worktree ใหม่ test จึงพังเพราะหาไฟล์ไม่เจอ แต่รันอีกรอบหลัง build ก็ผ่าน
ให้ Vitest สร้างไฟล์เหล่านี้ใน globalSetup ก่อน worker ตัวแรกเริ่ม
import { execFileSync } from 'node:child_process';
export default function setup() {
execFileSync('php', ['artisan', 'wayfinder:generate'], { stdio: 'pipe' });
}
ส่วนการตั้ง Vitest ให้โปรเจกต์ Laravel มีอธิบายไว้ในเขียน test ให้ component React ด้วย Vitest
พิสูจน์ด้วยการรันซ้ำหลายร้อยรอบ ทั้งก่อนและหลังแก้
test ที่พังรอบเดียวแล้วรันใหม่เขียว ยังบอกไม่ได้ว่าแก้แล้วหรือยัง สิ่งที่บอกได้คืออัตราที่พัง ผมใช้ขั้นตอนเดิมทุกครั้ง
- อ่านค่าจริงที่ assertion ได้ ค่าจริงมักบอกต้นเหตุอยู่แล้ว เช่น
'ในข้อความ แถวที่เกินมา key ที่ชน unique หรือเวลาที่ต่างกันเป็นชั่วโมงพอดี - ทำให้พังซ้ำได้ก่อนแก้ ใส่
->repeat()ต่อท้าย test ในเครื่อง แล้วรันทั้งแบบตัวเดียว ทั้ง suite และสลับลำดับด้วย--order-by=randomนับว่าพังกี่รอบ - แก้ที่ต้นเหตุ แล้วรันซ้ำจำนวนเท่าเดิม ต้องผ่านครบทุกรอบ แล้วค่อยเอา
->repeat()ออกก่อน commit
test('it leaves out the list\'s members, low ratings, other versions of a seed\'s song, and caps each artist', function (): void {
// ...
})->repeat(600);
test ใน music ที่เล่าไว้ในข้อ 2 พัง 7 จาก 600 รอบก่อนแก้ หลังแก้ factory ผ่าน 600 จาก 600 รอบ และทั้ง suite 1,032 test ก็ผ่านหมด ตัวเลขคู่นี้คือหลักฐานที่ commit แก้ควรมี
ค่าจริงของ test ตัวนี้คือมีเพลงเกินมาหนึ่งเพลง ถ้าดูแค่ว่ารายการไม่ตรง ทางที่ดูง่ายที่สุดคือคิดว่าเป็นเรื่องลำดับ แล้วไป sort ทั้งสองฝั่งก่อนเทียบ แต่ assertion ตัวนี้เทียบแบบไม่สนลำดับอยู่แล้ว เพลงที่เกินมาชี้ไปที่ข้อมูลที่ test สร้างเอง ถ้าไปแก้ที่ assertion ก็จะได้ test ที่เขียวแต่ไม่ได้ตรวจกฎข้อนี้อีกแล้ว

ค่าจริงที่พังบอกว่าจะไปดูตรงไหน การรันซ้ำบอกว่าแก้ถูกที่แล้ว
สิ่งที่ไม่ควรทำคือให้ CI รันซ้ำเองเมื่อพัง ข้าม test ตัวนั้น หรือผ่อน assertion ให้หลวมลง ทั้งสามทางทำให้ CI เขียว แต่ต้นเหตุยังอยู่ และบางครั้งต้นเหตุนั้นก็เป็นบั๊กในโค้ดจริงด้วย
เช็กลิสต์ก่อน commit test ใหม่
ทั้ง 10 ข้อกันได้ตั้งแต่ตอนเขียน ผมใช้คำถามชุดนี้ไล่ดู test ใหม่ทุกตัวก่อน commit
| ถามตัวเองว่า | ถ้าใช่ |
|---|---|
| factory สุ่มค่าจากรูปแบบเดียวกับข้อมูลใน snapshot ไหม | ใช้รูปแบบที่ข้อมูลจริงไม่มี หรือคิดจาก id |
| test สร้างหลายแถวที่ต้องไม่ซ้ำ จากตัวเลือกไม่กี่ค่าไหม | fake()->unique(), uuid หรือกำหนดค่าเอง |
| ตรวจข้อความที่มาจากค่าสุ่มไหม | กำหนดค่าเอง หรือเทียบกับ e() |
| ผลขึ้นกับลำดับแถวไหม | orderBy() ในโค้ด หรือ keyBy() / toEqualCanonicalizing() ใน test |
มีวันที่ตายตัว หรือเรียก now() หลายครั้งไหม |
travelTo() / freezeTime() ที่บรรทัดแรก |
| เวลาที่เทียบมาจากคอลัมน์ที่มี timezone ไหม | เทียบใน timezone เดียวกัน |
| มีอะไรเก็บใน static ไหม | ล้างใน setUp() หรือ beforeEach |
| หยิบแถวจาก snapshot แบบสุ่มไหม | สร้างเองด้วย factory หรือหาด้วยเงื่อนไขที่แน่นอน |
| ตรวจผลการสุ่มจากตัวอย่างไม่กี่ครั้งไหม | เพิ่มตัวอย่าง และตรวจด้วยอัตราส่วน |
| test ใช้ไฟล์ที่ไม่ได้อยู่ใน git ไหม | สร้างใน globalSetup ก่อนรัน |
ถ้า test ตัวไหนเคยพังแบบสุ่มมาแล้ว ให้รัน ->repeat() สักห้าร้อยรอบก่อนเชื่อว่าแก้แล้ว






