Pest คืออะไร และเขียน test ต่างจาก PHPUnit ตรงไหนบ้างในโปรเจกต์ Laravel

Pest เป็นเครื่องมือเขียน test ของ PHP ที่ทำงานอยู่บน PHPUnit ตัวเดิม แต่เขียนเป็นฟังก์ชันสั้น ๆ แทน class โพสต์นี้เทียบ test เดียวกันทั้งสองแบบ อธิบาย expect(), dataset, arch test คำสั่งที่ใช้บ่อย และการให้ AI agent รัน test

เผยแพร่
เวลาอ่าน
5 นาที
ในโพสต์นี้ 10 หัวข้อ
  1. Pest ทำงานอยู่บน PHPUnit ตัวเดิม
  2. test เดียวกัน เขียนได้สองแบบ
  3. expect() ตรวจผลเป็นประโยค
  4. ตั้ง base class ไว้ครั้งเดียวใน tests/Pest.php
  5. dataset ทดสอบค่าหลายชุดด้วย test เดียว
  6. arch test กันโค้ดที่ไม่อยากเห็นในโปรเจกต์
  7. คำสั่งที่ใช้บ่อย
  8. ย้ายจาก PHPUnit มา Pest
  9. ให้ AI agent รัน test
  10. ซีรีส์ Testing ใน Laravel

โพสต์นี้เป็นตอนแรกของซีรีส์ Testing ใน Laravel ต่อจากชุดเครื่องมือตรวจโค้ด ที่เล่าเรื่อง Pint, Rector และ PHPStan ไปแล้ว เครื่องมือชุดนั้นอ่านโค้ดโดยไม่รัน ส่วน test รันโค้ดจริงแล้วดูว่าผลออกมาตรงกับที่ตั้งใจไหม ซีรีส์นี้จะไล่ตั้งแต่ตัว Pest, ของใหม่ใน Pest 5, การรัน test ให้เร็วขึ้น ไปจนถึงการจัดฐานข้อมูลและกันไม่ให้ test ส่งอะไรออกไปข้างนอก รายชื่อทุกตอนอยู่ท้ายโพสต์

ตอนสร้างโปรเจกต์ใหม่ Laravel รองรับทั้ง Pest และ PHPUnit ให้เลือกตั้งแต่แรก ถ้าเปิดโปรเจกต์เก่าแล้วเจอ test เป็น class ก็คือ PHPUnit ถ้าเจอ it('…', function () { … }) ก็คือ Pest

Pest ทำงานอยู่บน PHPUnit ตัวเดิม

Pest เป็นโปรเจกต์ของ Nuno Maduro คนเดียวกับที่ทำ Laravel Pint และ Pest เขียนไว้ในเอกสารเองว่าสร้างอยู่บน PHPUnit ตัวที่รัน test จริงจึงยังเป็น PHPUnit เพียงแต่ Pest ให้วิธีเขียนอีกแบบที่สั้นกว่า ผลที่ตามมาคือ

  • phpunit.xml ใช้ไฟล์เดิม ค่า env สำหรับ test ที่ตั้งไว้ก็ยังใช้ได้
  • test class แบบ PHPUnit ที่มีอยู่ รันใน Pest ได้โดยไม่ต้องแก้ จะค่อย ๆ เขียนไฟล์ใหม่เป็น Pest ไปพร้อมกับไฟล์เก่าก็ได้
  • php artisan test เลือกใช้ Pest ให้เองถ้าติดตั้งไว้ คำสั่งที่เคยพิมพ์จึงไม่ต้องเปลี่ยน
  • assertion ของ Laravel อย่าง assertRedirect(), assertSee() หรือ assertDatabaseHas() ใช้ได้เหมือนเดิม เพราะ $this ใน test ของ Pest ก็คือ TestCase ของ Laravel

Pest เวอร์ชันล่าสุดตอนนี้คือ 5.3.3 ต้องใช้ PHP 8.4 ขึ้นไป ถ้าโปรเจกต์ยังอยู่บน PHP 8.3 ให้ใช้ Pest 4 ไปก่อน วิธีเขียนเหมือนกันทุกอย่าง ส่วนของใหม่ใน Pest 5 แยกไว้ในตอนที่ 2

test เดียวกัน เขียนได้สองแบบ

สมมติเราอยากเช็กว่าคนที่ยังไม่ล็อกอิน เปิดหน้าหลังบ้านแล้วระบบต้องพาไปหน้า login

// PHPUnit
namespace Tests\Feature;

use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

class AdminPostsTest extends TestCase
{
    #[Test]
    public function guests_are_redirected_to_login(): void
    {
        $this->get('/admin/posts')->assertRedirect('/login');
    }
}

// Pest
it('redirects guests to the login page', function (): void {
    $this->get('/admin/posts')->assertRedirect('/login');
});

บรรทัดที่ทดสอบจริงเหมือนกันทุกตัวอักษร ที่หายไปคือ namespace, use, class และ method ฝั่ง Pest เหลือแค่ชื่อ test กับ closure ชื่อที่ขึ้นต้นด้วย it อ่านต่อกันเป็นประโยค เวลา test ไม่ผ่าน terminal ก็แสดงชื่อนี้ให้เห็นเลยว่าพฤติกรรมไหนพัง

ถ้าไม่อยากขึ้นต้นด้วย it ก็ใช้ test('…') แทนได้ และจัดกลุ่ม test ที่เกี่ยวข้องกันด้วย describe()

expect() ตรวจผลเป็นประโยค

PHPUnit ตรวจผลด้วย $this->assertSame($expected, $actual) ซึ่งต้องจำลำดับ argument ให้ถูก Pest ใช้ expect() ที่เอาค่าที่ได้ขึ้นก่อน แล้วต่อด้วยสิ่งที่คาดหวัง

it('adds 7% VAT to the subtotal', function (): void {
    $invoice = new Invoice(subtotal: 1000);

    expect($invoice->vat())->toBe(70.0)
        ->and($invoice->total())->toBe(1070.0)
        ->and($invoice->lines())->toBeArray()->not->toBeEmpty();
});

->and() เปลี่ยนไปตรวจค่าถัดไปในสายเดียวกัน ส่วน ->not กลับความหมายของ expectation ที่ตามมา เขียนแบบนี้แล้วอ่านออกเสียงได้เกือบเป็นภาษาคน

ตั้ง base class ไว้ครั้งเดียวใน tests/Pest.php

ใน PHPUnit ทุกไฟล์ต้อง extends TestCase และใส่ trait อย่าง RefreshDatabase เอง ใน Pest เราบอกไว้ที่เดียวว่าโฟลเดอร์ไหนใช้อะไร เว็บนี้ตั้งไว้แบบนี้

pest()->extend(TestCase::class)
    ->use(DatabaseTransactions::class)
    ->in('Feature');

ไฟล์ใหม่ใน tests/Feature ได้ TestCase ของ Laravel และห่อทุก test ด้วย transaction ทันทีโดยไม่ต้องเขียนอะไรเพิ่ม เหตุผลที่เราใช้ DatabaseTransactions แทน RefreshDatabase ที่เอกสาร Laravel แนะนำ อยู่ในตอนที่ 4 ส่วน TestCase ของเว็บนี้ปิดการส่งอีเมลและการยิง API ออกไปข้างนอกไว้ทุก test ซึ่งเล่าไว้ในตอนที่ 5

helper ที่หลายไฟล์ใช้ร่วมกันก็ประกาศเป็นฟังก์ชันไว้ใน tests/Pest.php เหมือนกัน อย่าประกาศไว้ในไฟล์ test เพราะตอนรันแบบ --parallel แต่ละ process โหลดไฟล์ไม่เหมือนกัน ฟังก์ชันที่ประกาศในไฟล์หนึ่งจึงอาจไม่มีในอีก process

dataset ทดสอบค่าหลายชุดด้วย test เดียว

ถ้าอยากลองหลายค่ากับกฎเดียวกัน ใส่ ->with() ต่อท้าย test แล้ว Pest จะรัน closure ซ้ำทีละชุด

it('accepts Thai mobile numbers', function (string $phone): void {
    expect(ThaiPhone::isValid($phone))->toBeTrue();
})->with([
    '0812345678',
    '081-234-5678',
    '+66812345678',
]);

dataset แต่ละแถวนับเป็น test หนึ่งตัว และเริ่มแอป Laravel ใหม่ทุกแถว กับฟังก์ชันเล็ก ๆ แบบนี้ไม่เป็นไร แต่ถ้าแต่ละแถวต้องสร้างข้อมูลในฐานข้อมูลหรือยิง request เวลาจะบวกขึ้นตามจำนวนแถว กรณีนั้นผมมักเขียนเป็น array ของเคสไว้ใน test เดียวแล้ววนตรวจแทน

arch test กันโค้ดที่ไม่อยากเห็นในโปรเจกต์

arch test ไม่ได้รันโค้ดของเรา แต่ดูว่าโค้ดเรียกใช้อะไรบ้าง ตัวที่ใช้กันบ่อยที่สุดคือกัน dd() กับ dump() ที่ลืมไว้ไม่ให้หลุดขึ้น production

arch('no debug calls left')
    ->expect(['dd', 'dump'])
    ->not->toBeUsed();

arch()->preset()->security();

preset security ห้ามฟังก์ชันอย่าง eval และ md5 ส่วน preset laravel ตรวจรูปแบบที่ Laravel ใช้กัน เช่น controller ต้องลงท้ายด้วย Controller ถ้าเปิดแล้วโปรเจกต์เก่าแดงเยอะ ค่อย ๆ เพิ่มทีละข้อแบบตัวอย่างแรกก่อนก็ได้

ในโพสต์ของ Rector เราใช้กฎ RemoveDumpDataDeadCodeRector ลบ dd() ให้อีกชั้น สองตัวนี้ทำงานต่างกันนิดหน่อย Rector ลบให้ในเครื่องและเห็นแค่การเรียกแบบฟังก์ชัน ส่วน arch test ทำให้ test แดงถ้ามีไฟล์ไหนเรียกใช้ arch test มากับ Pest 2 ถ้าอยากอ่านที่มาเพิ่ม มีเล่าไว้ในสรุป Pest 2

คำสั่งที่ใช้บ่อย

php artisan test --compact
php artisan test --filter="redirects guests"
php artisan test tests/Feature/AdminPostsTest.php
php artisan test --parallel
php artisan test --profile
  • --compact แสดงจุดหนึ่งจุดต่อ test ที่ผ่าน และแสดงรายละเอียดเฉพาะตัวที่ไม่ผ่าน
  • --filter รันเฉพาะ test ที่ชื่อตรงกับข้อความ ส่วนการใส่ path รันเฉพาะไฟล์นั้น
  • --parallel แบ่ง test ไปรันหลาย process พร้อมกัน ต้องติดตั้ง brianium/paratest ซึ่งมากับ Pest อยู่แล้ว
  • --profile บอกว่า test ตัวไหนช้าที่สุด ใช้หาตัวที่ควรแก้ก่อน

ตอนรันไม่ใส่ --compact จะเห็นชื่อ test ทุกตัว นี่คือ test ชุดหนึ่งของเว็บนี้

php artisan test tests/Feature/TestEnvironmentGuardTest.php
   PASS  Tests\Feature\TestEnvironmentGuardTest
  ✓ it blocks un-faked outbound HTTP                                     0.10s
  ✓ it lets faked HTTP through                                           0.02s
  ✓ it fakes mail, queue and notifications                               0.02s
  ✓ it renders pages without calling the SSR server                      0.01s
  ✓ it runs against the local testing database                           0.01s

  Tests:    5 passed (8 assertions)
  Duration: 0.19s

ทั้งเว็บมี test ฝั่ง PHP ราว 500 ตัว รันจบในเครื่องประมาณครึ่งนาที พอโปรเจกต์โตถึงหลักพัน test รอบหนึ่งอาจกินเวลาหลายนาที ซึ่งตอนที่ 3 จะเล่าวิธีรันเฉพาะ test ที่โค้ดที่เราเพิ่งแก้ไปถึง

ย้ายจาก PHPUnit มา Pest

ไม่จำเป็นต้องย้ายทีเดียวทั้งโปรเจกต์ ติดตั้ง Pest แล้ว test class เดิมก็รันต่อได้ ไฟล์ใหม่ค่อยเขียนเป็น Pest ถ้าอยากแปลงของเก่าทั้งหมด Pest มี plugin ชื่อ Drift ที่แปลงให้อัตโนมัติ

composer require pestphp/pest-plugin-drift --dev
./vendor/bin/pest --drift

Drift แปลงได้เกือบหมด แต่ตอนเราย้ายโปรเจกต์ลูกค้าที่ใช้ Pest 5 ก็เจอบางจุดที่แปลงแล้วยังผ่าน แต่ตรวจคนละเรื่องกับของเดิม ก่อนและหลังแปลงจึงควรเทียบว่ามี test ครบจำนวนเดิม และผลของแต่ละตัวไม่เปลี่ยน แค่ดูว่าทั้งชุดยังเขียวอยู่ไม่พอ อีกเรื่องที่เจอคือ helper ที่เคยเป็น method ใน class กลายเป็นฟังก์ชันระดับ global ชื่อที่ซ้ำกับฟังก์ชันของ Laravel อย่าง route() หรือ today() จะชนกัน ต้องตั้งชื่อใหม่

ให้ AI agent รัน test

ถ้าใช้ Claude Code หรือ agent ตัวอื่นช่วยเขียนโค้ด test คือสิ่งที่บอก agent ว่างานที่ทำไปใช้ได้จริงหรือยัง โปรเจกต์ Laravel ที่ติดตั้ง Laravel Boost จะได้คำแนะนำให้ agent รัน test ที่แคบที่สุดที่ครอบคลุมงานก่อน ด้วย --filter หรือ path ของไฟล์ แล้วค่อยรันทั้งชุด

อีกแพ็กเกจของทีม Laravel ชื่อ laravel/pao ทำอีกอย่าง ติดตั้งเป็น dev dependency แล้วพอเห็นว่าคนรันเป็น agent ก็เปลี่ยนผลของ Pest เป็น JSON บรรทัดเดียว agent อ่านง่ายและเปลือง token น้อยกว่าผลแบบเต็ม

php artisan test --compact tests/Feature/TestEnvironmentGuardTest.php
{"tool":"pest","result":"passed","tests":5,"passed":5,"assertions":8,"duration_ms":187}

ตอนเราเปิด terminal รันเองจะยังเห็นผลแบบปกติ เพราะ pao ดูจาก environment variable ที่ agent ตั้งไว้ ถ้าเห็น JSON แบบนี้ใน log ของ agent ก็ไม่ต้องตกใจ

ซีรีส์ Testing ใน Laravel

  1. Pest คืออะไร และต่างจาก PHPUnit ตรงไหน (โพสต์นี้)
  2. Pest 5 มีอะไรใหม่ ตั้งแต่ TIA, plugin สำหรับ AI agent ไปจนถึง plugin ของ PHPStan และ Rector
  3. TIA ใน Pest 5 รันเฉพาะ test ที่โค้ดที่แก้ไปถึง พร้อมตัวเลขจากโปรเจกต์จริง
  4. ฐานข้อมูลสำหรับ test snapshot ที่ commit ไว้กับ DatabaseTransactions แทน RefreshDatabase
  5. กัน test ไม่ให้ส่งอะไรออกไปข้างนอก ทั้งอีเมล API คิว และค่าใน phpunit.xml
  6. Vitest สำหรับ component React ในโปรเจกต์ Laravel ที่ใช้ Inertia

อ่านต่อ

ถูกอ้างถึงใน

ล่าสุด

ดูทั้งหมด →