ตอนที่ 5 ของซีรีส์ Testing ใน Laravel ต่อจากตอนที่ 4 เรื่องฐานข้อมูล
ในโพสต์เรื่องทดสอบอีเมล เราเล่าไว้ว่าถ้าตั้ง .env ส่งผ่าน Gmail จริงตอนพัฒนา อีเมลอาจหลุดไปถึงลูกค้า test ก็เจอเรื่องเดียวกัน test ที่สร้างคำสั่งซื้อแล้วลืม Mail::fake() จะส่งอีเมลยืนยันจริงทุกครั้งที่รัน test ที่เรียก API ของ payment gateway โดยไม่ fake ก็ยิงไปที่ server จริง และผ่านเฉพาะตอนที่ server นั้นตอบ
ปัญหาคือทุกอย่างนี้เงียบ test ยังเขียว ไม่มีอะไรเตือน
ปิดทุกทางไว้ใน TestCase
แทนที่จะให้แต่ละ test จำเอง เราเปิด fake ไว้ใน setUp() ของ TestCase ซึ่งทุก test ใน tests/Feature ใช้
abstract class TestCase extends BaseTestCase
{
#[Override]
protected function setUp(): void
{
parent::setUp();
Http::preventStrayRequests();
Mail::fake();
Queue::fake();
Notification::fake();
}
}
Mail::fake()เก็บอีเมลไว้ในหน่วยความจำแทนการส่ง แล้วตรวจได้ด้วยMail::assertSent()Queue::fake()เก็บ job ไว้แทนการรัน ตรวจด้วยQueue::assertPushed()ได้ผลพลอยได้คือ test เร็วขึ้นด้วย เพราะงานหนักอย่างย่อรูปหรือสร้าง PDF ที่สั่งผ่านคิวไม่ได้รันจริงในทุก testNotification::fake()กัน notification ทุกช่องทาง ทั้งอีเมล SMS และ LINEHttp::preventStrayRequests()ทำให้ request ที่ส่งผ่านHttpfacade แล้วไม่ได้ fake ไว้โยน exception แทนการส่งจริง
test ที่ไม่ได้ส่งอะไรเลยก็ได้ fake ไปด้วย ไม่เป็นไร ค่าใช้จ่ายแทบไม่มี และวันหนึ่งที่โค้ดในแอปเริ่มส่งอีเมลเพิ่ม test เดิมก็ยังปลอดภัย
test ที่ต้องการของจริงค่อยเปิดเอง
บาง test อยากเห็นของจริง เช่น อยากรัน job จนจบเพื่อดูผลในฐานข้อมูล หรือดูหน้าตาอีเมลที่ render ออกมา ให้ test นั้นเปิดเฉพาะทางที่ต้องใช้ helper พวกนี้ใส่ไว้ใน tests/Pest.php ให้ทุกไฟล์เรียกได้
function withRealQueue(): void
{
if (Queue::isFake()) {
Queue::swap(Queue::getFacadeRoot()->queue);
}
}
function withRealMail(): void
{
if (Mail::isFake()) {
Mail::swap(Mail::getFacadeRoot()->manager);
}
}
fake ของ Laravel เก็บตัวจริงไว้ข้างใน helper จึงแค่สลับกลับไป ส่วน test ถัดไปเริ่มด้วยแอปใหม่และได้ fake กลับมาเหมือนเดิม
Http::preventStrayRequests() กับ Http::fake()
test ที่เรียก API ภายนอกต้องบอกไว้ว่าอยากได้คำตอบแบบไหน URL ไหนไม่ได้ fake ไว้จะโยน StrayRequestException ทันที
it('marks the order paid when the gateway confirms', function (): void {
Http::fake([
'api.payment.example/*' => Http::response(['status' => 'paid']),
]);
// ...
});
ถ้าแอปเรียก service ที่รันอยู่ในเครื่องเราเอง เช่น ตัวแปลง PDF ที่ 127.0.0.1 ใช้ Http::allowStrayRequests() ระบุเฉพาะ URL นั้นได้ ทางอื่นยังปิดอยู่
ค่าใน phpunit.xml
ค่า env ใน phpunit.xml ทับค่าใน .env ตอนรัน test ของเว็บนี้ตั้งไว้แบบนี้
<php>
<env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
<env name="INERTIA_SSR_ENABLED" value="false"/>
</php>
BCRYPT_ROUNDS=4hash รหัสผ่านแบบเบาที่สุด ค่าปกติคือ 12 ซึ่งตั้งใจให้ช้าเพื่อกันการเดารหัส แต่ใน test ทุกครั้งที่ factory สร้าง user ก็ต้อง hash ใหม่ ลดรอบลงแล้ว test เร็วขึ้นชัดเจนCACHE_STORE,SESSION_DRIVERเป็นarrayเก็บในหน่วยความจำ test จึงไม่ไปเขียน cache หรือ session ทับกับแอปที่เปิดอยู่ในเครื่องMAIL_MAILER=arrayกันอีเมลอีกชั้น ถ้ามี test ไหนเปิด mail จริงกลับมาด้วยwithRealMail()อีเมลก็ยังไม่ออกไปนอกเครื่องPULSE_ENABLED,TELESCOPE_ENABLED,NIGHTWATCH_ENABLEDเป็นfalseปิดเครื่องมือดูแลระบบ Telescope กับ Pulse จะไม่เขียนข้อมูลของ test ลงฐานข้อมูล ส่วน Nightwatch เป็นบริการภายนอก ถ้าไม่ปิดก็จะส่งข้อมูลจาก test ออกไปด้วยINERTIA_SSR_ENABLED=falseถ้าเปิด SSR ไว้ Inertia จะส่ง request ไปที่ server ของ Node ทุกครั้งที่ render หน้า test จะผ่านเฉพาะตอนที่เรารันnpm run devทิ้งไว้
สิ่งที่ preventStrayRequests() ไม่ได้กัน
Http::preventStrayRequests() ดูแลเฉพาะ request ที่ส่งผ่าน Http facade ทางอื่นต้องปิดเอง
- mailer ที่ตั้งชื่อแยก
MAIL_MAILER=arrayตั้งแค่ mailer หลัก ถ้าโค้ดเรียกMail::mailer('etax')mailer ตัวนั้นใช้ transport ของตัวเอง ซึ่งมักเป็น SMTP ต้องตั้งค่าในphpunit.xmlให้ mailer ตัวนั้นเป็นarrayด้วย - disk ที่ชี้ไปที่เก็บไฟล์ร่วม ถ้า path ของ disk มาจาก env เช่น โฟลเดอร์ที่ mount มาจาก NAS test ที่ลืม
Storage::fake()จะเขียนไฟล์ลงที่จริง วิธี fake disk มีในโพสต์เรื่อง test อัปโหลดไฟล์ - คำสั่งที่รันผ่าน
Processรันจริงทุกครั้ง ถ้าอยากให้ test ที่ลืม fake ไม่ผ่านเหมือน HTTP ใช้Process::preventStrayProcesses()แต่ถ้า test ตั้งใจเรียกโปรแกรมในเครื่อง เช่น ตัวจัดการไฟล์ PDF ก็ไม่ต้องเปิด - SDK ที่ไม่ได้ใช้
Httpfacade library ของผู้ให้บริการบางตัวมี HTTP client ของตัวเองpreventStrayRequests()จึงไม่เห็น ต้อง mock ตัว SDK หรือห่อไว้หลัง class ของเราที่ fake ได้
เขียน test ยืนยันว่าทุกทางยังปิดอยู่ แล้วลองทำให้แดง
ทุก test ได้ประโยชน์จาก fake พวกนี้โดยไม่ได้ตั้งใจตรวจ ถ้าวันหนึ่งใครลบบรรทัดหนึ่งออกจาก setUp() ก็จะไม่มี test ไหนแดง เราจึงมีไฟล์หนึ่งที่ตรวจเรื่องนี้โดยตรง
it('blocks un-faked outbound HTTP', function (): void {
expect(fn () => Http::get('https://example.com'))->toThrow(StrayRequestException::class);
});
it('fakes mail, queue and notifications', function (): void {
expect(Mail::getFacadeRoot())->toBeInstanceOf(MailFake::class)
->and(Queue::getFacadeRoot())->toBeInstanceOf(QueueFake::class)
->and(Notification::getFacadeRoot())->toBeInstanceOf(NotificationFake::class);
});
it('renders pages without calling the SSR server', function (): void {
expect(config('inertia.ssr.enabled'))->toBeFalse();
});
เขียนเสร็จแล้วลองลบ Queue::fake() ออกจาก TestCase แล้วรัน test ไฟล์นี้ ต้องเห็นว่าแดง แล้วค่อยใส่กลับ test ที่ไม่เคยแดงให้เห็นสักครั้ง เราก็ไม่รู้ว่าตรวจอะไรได้จริงหรือเปล่า
สำคัญขึ้นอีกเมื่อให้ AI agent รัน test
ตอนให้ Claude Code ช่วยทำงาน agent รัน test วนหลายสิบรอบต่องาน และเขียน test ใหม่เองด้วย ถ้า test ที่ agent เขียนลืม fake สักตัว อีเมลก็จะออกไปหลายสิบฉบับก่อนที่เราจะเห็น การเปิด fake ไว้ใน TestCase ทำให้ test ทุกตัวปลอดภัยตั้งแต่บรรทัดแรก ไม่ว่าใครหรืออะไรเป็นคนเขียน
ตอนสุดท้ายของซีรีส์ย้ายไปฝั่ง JavaScript กับการเขียน test ให้ component React ด้วย Vitest






