เขียน test หนึ่งตัวต่อหนึ่งกฎ ไม่ใช่หนึ่งตัวต่อหนึ่ง input

test สิบตัวที่ต่างกันแค่ตัวเลขเดียว ต้องสร้างข้อมูล เปิด transaction และ boot แอปสิบรอบ เพื่อความมั่นใจของกฎข้อเดียว ผมจึงเขียนกฎหนึ่งข้อเป็น test หนึ่งตัว แล้วใส่ input เป็นตารางในนั้น แต่ทุกบรรทัดต้องบอกได้ว่าเคสไหนพัง และบาง test ก็ห้ามรวม

เผยแพร่
เวลาอ่าน
4 นาที
แท็ก
#Laravel#Testing
ในโพสต์นี้ 5 หัวข้อ
  1. test ที่ต่างกันแค่ตัวเลขเดียวคือกฎเดียวกัน
  2. กฎหนึ่งข้อเป็น test หนึ่งตัว แล้วใส่ input เป็นตาราง
  3. dataset ของ Pest ไม่ได้ลดจำนวน test
  4. บาง test ต้องแยกไว้ ถ้ารวมแล้วจะพังไม่เป็น
  5. เรื่องที่ต้องระวังตอนรวม test

โพสต์นี้เป็นความเห็นของผมจากการเขียน test ให้หลายโปรเจกต์ และเป็นเรื่องที่ผมกลับมาแก้บ่อยที่สุดเวลา review test ที่ AI agent หรือตัวผมเองเขียนไว้

test ที่ต่างกันแค่ตัวเลขเดียวคือกฎเดียวกัน

สมมติร้านขายน้ำดื่มที่ขายได้ทั้งเป็นขวดและเป็นแพ็ก แพ็กละ 12 ขวด test ที่เขียนกันตามธรรมชาติจะหน้าตาแบบนี้

it('คิดราคา 1 แพ็ก', function () {
    $product = Product::factory()->create(['price_per_pack' => 60]);

    expect($product->price(1, Unit::Pack))->toEqual(60);
});

it('คิดราคา 6 ขวด', function () {
    $product = Product::factory()->create(['price_per_pack' => 60]);

    expect($product->price(6, Unit::Bottle))->toEqual(30);
});

it('คิดราคา 2 แพ็ก', function () { /* ... */ });

สามตัวนี้ทดสอบกฎข้อเดียว คือ "แปลงหน่วยแล้วคิดราคาถูกต้อง" แต่แต่ละตัวต้อง boot แอปใหม่ เปิด transaction ใหม่ สร้าง product ใหม่ แล้ว rollback ทิ้ง ชุด test ของเว็บนี้มี 541 ตัว รันจบใน 28 วินาที เฉลี่ยตัวละราว 50 ms และใน test ที่ต่างกันแค่ตัวเลข ส่วนที่ซ้ำกันทุกตัวคือการเตรียมของ ไม่ใช่การตรวจ สิบตัวที่ต่างกันแค่ตัวเลขก็คือจ่ายค่าเตรียมสิบรอบ เพื่อความมั่นใจของกฎข้อเดียว

นอกจากเรื่องเวลา รายการ test ที่ยาวยังอ่านยากด้วย ชื่อ test ควรบอกว่าระบบทำอะไร ถ้าครึ่งหนึ่งของรายการเป็น "คิดราคา 1 แพ็ก", "คิดราคา 2 แพ็ก" เราจะหากฎข้ออื่นที่ซ่อนอยู่ระหว่างนั้นไม่เจอ

กฎหนึ่งข้อเป็น test หนึ่งตัว แล้วใส่ input เป็นตาราง

ผมเขียนแบบนี้แทน

it('แปลงหน่วยแล้วคิดราคาตามราคาต่อแพ็ก', function () {
    $product = Product::factory()->create(['price_per_pack' => 60]);

    $cases = [
        // [จำนวน, หน่วย, ราคาที่ควรได้]
        [1, Unit::Pack,   60],
        [6, Unit::Bottle, 30],
        [2, Unit::Pack,   120],
    ];

    foreach ($cases as [$qty, $unit, $expected]) {
        expect($product->price($qty, $unit))
            ->toEqual($expected, "price({$qty}, {$unit->value})");
    }
});

ข้อมูลตั้งต้นสร้างครั้งเดียว ตารางอ่านแล้วเห็นทุกเคสในที่เดียว เพิ่มเคสใหม่ก็แค่เพิ่มหนึ่งบรรทัด

ส่วนที่ขาดไม่ได้คือ ข้อความที่สองของ toEqual() ถ้าไม่มี พอเคสที่สามพัง Pest จะบอกแค่ว่า test นี้พัง แต่ไม่บอกว่าพังที่ input ไหน แย่กว่า test แยกที่เรามีอยู่เดิมเสียอีก ใส่ชื่อเคสไว้ทุก assertion ในลูป ข้อความ error จะบอกว่า price(2, pack) ได้ค่าผิด

test ที่เดินตามลำดับก็รวมได้ในแนวเดียวกัน อย่างใน repo ของเว็บนี้ test เรื่องปักหมุดผลงานที่หน้าแรก สร้างผลงานสามชิ้นครั้งเดียว แล้วไล่เลื่อนขึ้นลงตามตาราง เช็กลำดับทุกขั้น แทนที่จะสร้างสามชิ้นใหม่ทุกครั้งเพื่อเลื่อนแค่ครั้งเดียว

$cases = [
    'c up'              => [$c, 'up',   ['a' => 1, 'c' => 2, 'b' => 3]],
    'a up stays first'  => [$a, 'up',   ['a' => 1, 'c' => 2, 'b' => 3]],
    'b down stays last' => [$b, 'down', ['a' => 1, 'c' => 2, 'b' => 3]],
    'a down'            => [$a, 'down', ['c' => 1, 'a' => 2, 'b' => 3]],
];

foreach ($cases as $case => [$post, $direction, $expected]) {
    $this->patch(route('admin.posts.pin.update', $post), ['direction' => $direction]);

    expect(pinnedSlugs(PostType::Work))->toBe($expected, $case);
}

dataset ของ Pest ไม่ได้ลดจำนวน test

คนที่ใช้ Pest มาสักพักจะนึกถึง ->with([...]) หรือ dataset ก่อน ซึ่งก็เขียนออกมาเป็นตารางเหมือนกัน แต่ Pest เอาแต่ละแถวของ dataset ไปรันเป็น test แยกหนึ่งตัว beforeEach, transaction และ factory ทำงานใหม่ทุกแถว จำนวน test กับเวลาที่ใช้จึงเท่าเดิม ได้แค่โค้ดที่สั้นลง

dataset ยังมีที่ใช้ของมัน เมื่อแต่ละเคสต้องเริ่มจากสภาพที่สะอาดจริง ๆ ซึ่งพาไปถึงหัวข้อถัดไป

บาง test ต้องแยกไว้ ถ้ารวมแล้วจะพังไม่เป็น

test ที่ดีต้องพังได้เมื่อโค้ดผิด การรวม test บางแบบทำให้เคสหนึ่งไปช่วยให้อีกเคสผ่าน ผมแยกไว้เสมอในกรณีเหล่านี้

  • assertion ที่นับรวมทั้ง test ของ fake ที่กันไม่ให้ test ส่งของออกไปข้างนอก อย่าง Queue::assertNothingPushed(), Http::assertSentCount() หรือ Notification::assertNothingSent() นับทุกอย่างตั้งแต่ต้น test ถ้ารวมในลูป ผลของเคสแรกจะไปปนกับเคสที่สอง
  • mock ที่ขัดกันเอง เคสหนึ่งต้องการ shouldReceive('send')->never() อีกเคสต้องการ ->once() อยู่ใน test เดียวกันไม่ได้
  • markTestSkipped() ข้ามทั้ง test ไม่ใช่แค่เคสเดียว เคสอื่นจะหายไปเงียบ ๆ
  • ผู้ใช้ที่ล็อกอินแล้วออกไม่ได้ สลับ actingAs() เป็นคนอื่นทีละเคสได้ แต่กลับไปเป็น guest กลางลูปไม่ได้

ฟอร์มติดต่อของเว็บนี้เป็นตัวอย่างของข้อแรก test ที่ตรวจว่าฟอร์มไม่บันทึกและไม่ส่งแจ้งเตือนเมื่อข้อมูลไม่ครบ เช็กทั้ง Lead::count() เป็นศูนย์และ Queue::assertNothingPushed() ถ้ารวมสี่เคสไว้ในลูปเดียว แล้วเคสที่สองบันทึกพลาดไปหนึ่งแถว เคสที่สามจะเป็นคนที่พัง ทั้งที่ไม่ได้ผิด test นี้จึงใช้ dataset ให้แต่ละแถวเริ่มจากฐานข้อมูลว่าง

it('rejects a submission without its fields or with a failed Turnstile check', function (array $payload, string $field) {
    // ...
    expect(Lead::query()->count())->toBe(0);
    Queue::assertNothingPushed();
})->with([
    'no contact'     => [contactPayload(['contact' => '']), 'contact'],
    'no message'     => [contactPayload(['message' => '']), 'message'],
    'no token'       => [contactPayload(['turnstile' => '']), 'turnstile'],
    'token rejected' => [contactPayload(), 'turnstile'],
]);

เรื่องที่ต้องระวังตอนรวม test

ถ้ารวมแล้ว test ยังผ่านอยู่ ไม่ได้แปลว่ารวมถูก มีเรื่องที่ทำให้เคสหลังผ่านไปเฉย ๆ ทั้งที่ควรพัง

  • Http::fake() ซ้ำในลูปไม่ได้แทนที่ของเดิม Laravel เอา stub ใหม่ไปต่อท้าย และใช้ตัวแรกที่ URL ตรงกัน ทุกเคสจึงได้ response ของเคสแรก ให้ fake ครั้งเดียวด้วย closure ที่อ่านค่าของเคสปัจจุบันแทน
  • matcher บางตัวไม่รับข้อความชื่อเคส toContain() รับหลายค่า ข้อความที่ใส่ไปจะกลายเป็นอีกค่าที่ต้องหา ส่วน toBeEmpty() หรือ assertSee() ไม่มีที่ให้ใส่ ให้เขียนเป็นนิพจน์ที่ได้ค่า bool แทน อย่าง expect(in_array($slug, $slugs))->toBeTrue($case) สิ่งที่ตรวจยังเหมือนเดิม
  • ข้อมูลที่ค้างจากเคสก่อน ถ้าโค้ดที่ทดสอบไล่ทั้งตาราง เช่น job ที่ส่งอีเมลให้ทุกคนที่ครบกำหนด โค้ดก็จะนับแถวที่เคสก่อนสร้างไว้ด้วย ลบออกท้ายแต่ละเคส หรือแยก test ไป

ทั้งหมดนี้ไปทางเดียวกับฐานข้อมูล test แบบ snapshot ที่ผมใช้แทน RefreshDatabase คือจ่ายค่าเตรียมให้น้อยที่สุด แต่ไม่ยอมแลกกับ test ที่พังไม่เป็น ถ้าชุด test ยังช้าอยู่หลังรวมแล้ว ลองดูการรันเฉพาะ test ที่โค้ดเราแก้ด้วย TIA ต่อได้ครับ

อ่านต่อ

ล่าสุด

ดูทั้งหมด →