ตอนที่ 4 ของซีรีส์ Testing ใน Laravel ต่อจากตอนที่ 3 เรื่อง TIA
เอกสารของ Laravel แนะนำ trait RefreshDatabase ไว้ล้างฐานข้อมูลระหว่าง test ถ้าโปรเจกต์ยังเล็กและรัน test บน SQLite ในหน่วยความจำ วิธีนั้นก็ใช้ได้ดี โพสต์นี้เล่าว่าทำไมโปรเจกต์ของเราเลือกอีกทาง และทางนั้นต้องเตรียมอะไรบ้าง
RefreshDatabase ล้างฐานข้อมูลด้วย migrate:fresh
ถ้าเปิดโค้ดของ RefreshDatabase ใน vendor จะเห็นว่าทำงานสองขั้น
- test ตัวแรกของรอบสั่ง
migrate:freshซึ่ง drop ทุกตารางแล้วรัน migration ทั้งหมดใหม่ - test ทุกตัวหลังจากนั้นห่อด้วย transaction แล้ว rollback ตอนจบ
ขั้นที่ 2 คือสิ่งที่ DatabaseTransactions ทำอยู่แล้ว ที่ต่างกันคือขั้นที่ 1 และเราปิดขั้นนั้นไว้ใน AppServiceProvider
private function configureCommands(): void
{
DB::prohibitDestructiveCommands();
}
บรรทัดนี้ห้าม migrate:fresh, migrate:refresh, migrate:reset, migrate:rollback และ db:wipe รัน คำสั่งจะขึ้นแค่ This command is prohibited from running in this environment. แล้วจบ จะเปิดไว้เฉพาะตอน isProduction() ก็ได้ แต่เราเปิดทุก environment เพราะความเสียหายไม่ได้เกิดแค่บน production
.envในเครื่องอาจชี้ไปที่ฐานข้อมูลที่ไม่ได้มีไว้ทิ้ง เช่น ฐานข้อมูลตอนพัฒนาที่ดึงข้อมูลจาก production มาไว้ หรือฐานข้อมูลบนเซิร์ฟเวอร์ที่เปิด SSH tunnel ค้างไว้- AI agent ที่เจอ error ว่าไม่มี column อาจลองแก้ด้วย
migrate:freshเพราะเร็วที่สุด เราเขียนไว้ในCLAUDE.mdว่าห้ามใช้ก็จริง แต่คำสั่งในไฟล์ agent อาจทำตามไม่ครบทุกครั้ง บรรทัดในAppServiceProviderกันได้แน่นอนกว่า
พอปิด migrate:fresh แล้ว RefreshDatabase ก็ไม่มีประโยชน์ เราจึงต้องเตรียมตารางให้ฐานข้อมูล test ด้วยวิธีอื่น
แยกงานเป็นสองส่วน
- ตารางและข้อมูลตั้งต้น มาจากไฟล์ SQL ที่ commit ไว้ใน repo โหลดก่อนเริ่มรัน test
- ข้อมูลที่แต่ละ test สร้าง อยู่ใน transaction ที่ rollback ตอน test จบ
ส่วนที่สองตั้งไว้บรรทัดเดียวใน tests/Pest.php ตามที่เล่าไว้ในตอนแรก
pest()->extend(TestCase::class)
->use(DatabaseTransactions::class)
->in('Feature');
snapshot คือไฟล์ SQL ที่ commit ไว้ใน repo
เราสร้างไฟล์นี้ด้วยแพ็กเกจ spatie/laravel-db-snapshots ซึ่ง dump ฐานข้อมูลเป็นไฟล์ SQL และโหลดกลับได้ด้วยคำสั่ง artisan ใช้ได้ทั้ง MySQL, PostgreSQL และ SQLite
php artisan migrate --env=testing --force
php artisan snapshot:create testing --env=testing
ได้ไฟล์ storage/app/snapshots/testing.sql ในไฟล์มีตารางทั้งหมด แถวในตาราง migrations และข้อมูลที่ migration เขียนไว้ ของเว็บนี้มีข้อมูลที่อยู่ไทยทั้งชุด ได้แก่ จังหวัด 77 แถว อำเภอ 1,008 แถว ตำบล 8,939 แถว และรหัสไปรษณีย์ 7,549 แถว test ที่เกี่ยวกับฟอร์มที่อยู่จึง query ข้อมูลจริงได้เลยโดยไม่ต้องสร้างด้วย factory
ข้อมูลใน snapshot ควรเป็นข้อมูลอ้างอิงแบบนี้เท่านั้น ไม่มีข้อมูลลูกค้าหรือรหัสผ่านจริง เพราะไฟล์นี้อยู่ใน repo ทุกคนที่ clone ได้จะได้ไฟล์ไปด้วย ข้อมูลอื่นที่ test ต้องใช้ให้สร้างด้วย factory ใน test เอง
บน PostgreSQL ตั้ง pg_dump ไม่ให้เขียนชื่อเจ้าของตารางลงไฟล์ด้วย ไม่อย่างนั้นไฟล์ที่ dump จากเครื่องเราจะโหลดไม่ขึ้นบนเครื่องที่ไม่มี role ชื่อเดียวกัน
'pgsql' => [
// ...
'dump' => [
'addExtraOption' => '--no-owner --no-privileges',
],
],
commit ไฟล์เดียว ไฟล์อื่นในโฟลเดอร์ต้องไม่หลุดเข้า repo
โฟลเดอร์ storage/app/snapshots ไม่ได้มีแค่ไฟล์ของ test ขั้นตอน deploy ของเราสร้าง dump ของ production ไว้ที่เดียวกันก่อนรัน migration ใหม่ทุกครั้ง เผื่อต้องย้อนกลับ ไฟล์พวกนั้นมีข้อมูลจริง ห้ามเข้า repo เด็ดขาด เราจึงให้ .gitignore ในโฟลเดอร์ยกเว้นแค่ไฟล์ของ test
*
!.gitignore
!testing.sql
storage/app/.gitignore ของ Laravel เริ่มด้วย * จึงต้องเพิ่ม !snapshots/ ในไฟล์นั้นด้วย ไม่อย่างนั้น git จะไม่เข้าไปดูในโฟลเดอร์เลย เช็กได้ด้วย git add --dry-run storage/app/snapshots/testing.sql ถ้าขึ้นว่า add แปลว่าใช้ได้
CI โหลด snapshot ก่อน migrate ทุกรอบ
- name: Load the testing baseline
run: php artisan snapshot:load testing --env=testing --force
- name: Run migrations
run: php artisan migrate --env=testing --force
- name: Run tests
run: php artisan test --compact
CI ของเว็บนี้รันบน self-hosted runner คือเครื่องของเราเองที่ไม่ได้ล้างทิ้งหลังจบแต่ละรอบ ถ้าไม่มีขั้นโหลด snapshot ฐานข้อมูล test จะค้างสภาพจากรอบก่อน test อาจผ่านเพราะมีตารางหรือข้อมูลที่หลงเหลืออยู่ ทั้งที่บนฐานข้อมูลใหม่จะไม่ผ่าน พอโหลดไฟล์เดิมทุกรอบ ทุกรอบก็เริ่มจากจุดเดียวกัน
migrate ที่ตามมามีไว้รัน migration ที่ใหม่กว่า snapshot ถ้า snapshot ใหม่พอ ขั้นนี้จะขึ้นว่า Nothing to migrate
สร้าง snapshot ใหม่ใน commit เดียวกับ migration
ทุกครั้งที่เพิ่ม migration ให้สร้าง snapshot ใหม่ไปพร้อมกัน แล้วลองโหลดกลับก่อน commit
php artisan migrate --env=testing --force
php artisan snapshot:create testing --env=testing
php artisan snapshot:load testing --env=testing --force
php artisan migrate --env=testing --force
php artisan test --compact
ถ้า migrate บรรทัดที่สองขึ้นว่า Nothing to migrate และ test ผ่าน ก็ commit migration กับ testing.sql ไปด้วยกัน ถ้าปล่อยให้ migration ค้างอยู่หลัง snapshot ไปเรื่อย ๆ CI จะต้องรัน migration พวกนั้นซ้ำทุกรอบ data migration ที่อ่านค่าจากโค้ดซึ่งเปลี่ยนไปแล้วก็อาจรันซ้ำไม่ผ่าน
สิ่งที่เปลี่ยนไปตอนเขียน test
- ฐานข้อมูลไม่ได้ว่างตอนเริ่ม test อย่าตรวจว่าตารางมี 1 แถวหลังสร้างแถวแรก ให้ตรวจแถวที่ test สร้างเองด้วย
assertDatabaseHas() - factory ที่ใส่ค่าซ้ำกับข้อมูลใน snapshot จะชน unique key ถ้าต้องใช้จังหวัดเชียงใหม่ ให้หาแถวที่มีอยู่แล้วแทนการสร้างใหม่
- แก้แถวใน snapshot ระหว่าง test ได้ transaction จะ rollback ให้ตอนจบ test ถัดไปเห็นค่าเดิม
- อย่าเก็บ id ของแถวที่ factory สร้างไว้ใช้ข้าม test แถวนั้นหายไปตอน rollback แล้ว ข้อมูลที่อยากใช้ร่วมกันหลาย test ต้องอยู่ใน snapshot
- อย่าตรวจลำดับแถวที่ฐานข้อมูลเลือกเอง collation ในเครื่องเรากับใน CI อาจเรียงภาษาไทยไม่เหมือนกัน ใส่
orderByใน query หรือตรวจด้วยค่าที่ระบุแถวได้แทน
test ที่ยืนยันว่ารันกับฐานข้อมูลที่ถูกตัว
เว็บนี้มี test ตัวหนึ่งที่ไม่ได้ทดสอบฟีเจอร์ไหนเลย แค่ยืนยันว่า test กำลังคุยกับฐานข้อมูลของ test จริง ถ้าใครแก้ phpunit.xml หรือ .env.testing จนชี้ไปที่ฐานข้อมูลอื่น test ตัวนี้จะแดงเป็นตัวแรก
it('runs against the local testing database', function (): void {
expect(config('database.default'))->toBe('pgsql')
->and(config('database.connections.pgsql.database'))->toBe('phattarachai_testing');
});
ถ้ารัน test แบบ --parallel แต่ละ process ใช้ฐานข้อมูลของตัวเอง Laravel ตั้งชื่อต่อท้ายให้เป็น _test_1, _test_2 เราต้องโหลด snapshot ให้ทุกฐานด้วย ParallelTesting::setUpTestDatabaseBeforeMigrating() ใน AppServiceProvider และหลังสร้าง snapshot ใหม่ให้รันด้วย --recreate-databases สักรอบ
ตอนถัดไปเป็นอีกครึ่งของการแยก test ออกจากโลกภายนอก คือกันไม่ให้ test ส่งอีเมล ยิง API หรือเข้าคิวจริง






