เขียน test ให้ component React ในโปรเจกต์ Laravel ที่ใช้ Inertia ด้วย Vitest

test ฝั่ง PHP ตรวจได้ว่า Laravel ส่ง props อะไรให้หน้า แต่ไม่รู้ว่าหน้านั้น render ออกมาได้จริงไหม Vitest กับ Testing Library ปิดช่องนี้ โพสต์นี้อธิบายการตั้ง vitest.config.js แยกจาก Vite, ไฟล์ setup, การ mock Inertia, test ฝั่ง SSR และการวางใน CI

เผยแพร่
เวลาอ่าน
4 นาที
ในโพสต์นี้ 10 หัวข้อ
  1. test ฝั่ง PHP ไม่เห็นหน้าที่ render ออกมา
  2. ติดตั้ง
  3. แยก vitest.config.js ออกจาก vite.config.js
  4. ไฟล์ setup ที่แก้ปัญหา localStorage ใน CI
  5. mock Inertia ไว้ที่เดียว
  6. test สองแบบที่เราเขียนบ่อย
  7. test ที่ render หน้าแบบไม่มี DOM จับโค้ดที่ทำให้ SSR พัง
  8. วางใน CI และให้ AI agent รันเฉพาะส่วนที่แก้
  9. ควรเขียน test ให้อะไรก่อน
  10. ซีรีส์ Testing ใน Laravel

ตอนสุดท้ายของซีรีส์ Testing ใน Laravel ห้าตอนก่อนหน้าเป็นเรื่องฝั่ง PHP ทั้งหมด เริ่มจากตอนแรกเรื่อง Pest

test ฝั่ง PHP ไม่เห็นหน้าที่ render ออกมา

ในโปรเจกต์ Inertia เราเขียน test ฝั่ง Laravel แบบนี้ได้

it('shows the blog index', function (): void {
    $this->get('/blog')->assertInertia(fn ($page) => $page
        ->component('Blog/Index')
        ->has('posts.data', 10)
    );
});

test นี้ยืนยันว่า Laravel เลือกหน้า Blog/Index และส่ง posts ไป 10 ตัว แต่ไม่ได้รัน React เลย ถ้า component อ่าน post.author.name ตอนที่ author เป็น null หน้าจะพังแล้วขาวทั้งหน้า ส่วน test ฝั่ง PHP ยังเขียวอยู่

Vitest เป็น test runner ของ JavaScript ที่ใช้ระบบเดียวกับ Vite อ่าน import, JSX และ alias @/ ได้แบบเดียวกับตอน build เราใช้คู่กับ jsdom ที่จำลอง DOM ใน Node และ Testing Library ที่ค้นหา element ด้วยข้อความหรือ label แบบที่ผู้ใช้มองเห็น

ติดตั้ง

npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
"scripts": {
    "test": "vitest run",
    "test:watch": "vitest"
}

npm test รันครั้งเดียวแล้วจบ ใช้ใน CI ส่วน npm run test:watch คอยรันใหม่ทุกครั้งที่บันทึกไฟล์ ใช้ระหว่างแก้โค้ด

แยก vitest.config.js ออกจาก vite.config.js

เอกสารของ Vitest บอกว่าใส่ key test ใน vite.config.js ได้เลย แต่ในโปรเจกต์ Laravel เราแยกไฟล์ เพราะ vite.config.js โหลด laravel-vite-plugin และ plugin ของ Wayfinder ซึ่งเรียก php artisan ทุกครั้งที่เริ่ม ถ้ารวมไว้ไฟล์เดียว test ของ JavaScript จะช้าลงโดยไม่จำเป็น และรันไม่ได้เลยในเครื่องที่ยังไม่ได้ composer install

import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { fileURLToPath } from 'node:url';

export default defineConfig({
    plugins: [react()],
    resolve: { alias: { '@': fileURLToPath(new URL('./resources/js', import.meta.url)) } },
    test: {
        environment: 'jsdom',
        globals: true,
        include: ['tests/js/**/*.test.{js,jsx}'],
        globalSetup: ['tests/js/globalSetup.js'],
        setupFiles: ['tests/js/setup.js'],
        restoreMocks: true,
    },
});
  • alias ต้องตรงกับใน vite.config.js ไม่อย่างนั้น import ที่ใช้ได้ใน test อาจหาไฟล์ไม่เจอตอน build
  • include เก็บ test ไว้ใน tests/js โดยตั้งโฟลเดอร์ย่อยตาม resources/js เช่น test ของ resources/js/lib/thaiTaxId.js อยู่ที่ tests/js/lib/thaiTaxId.test.js โฟลเดอร์ resources/js จึงมีแต่โค้ดที่ใช้จริง
  • globalSetup ของเว็บนี้รัน php artisan wayfinder:generate ครั้งเดียวก่อนเริ่ม เพราะไฟล์ route ที่ Wayfinder สร้างไม่ได้อยู่ใน git ถ้า clone มาใหม่แล้วรัน test เลย import จาก @/routes จะหาไฟล์ไม่เจอ

ไฟล์ setup ที่แก้ปัญหา localStorage ใน CI

import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

class MemoryStorage {
    // getItem, setItem, removeItem, clear, key และ length เก็บไว้ใน Map
}

for (const name of ['localStorage', 'sessionStorage']) {
    Object.defineProperty(globalThis, name, {
        value: new MemoryStorage(),
        configurable: true,
        writable: true,
    });
}

afterEach(() => {
    cleanup();
    localStorage.clear();
    sessionStorage.clear();
});
  • jest-dom เพิ่ม matcher อย่าง toBeInTheDocument() และ toHaveAttribute()
  • cleanup() ถอด component ออกจาก DOM หลังแต่ละ test ไม่อย่างนั้น component จาก test ก่อนจะยังค้างอยู่ แล้ว getByText จะเจอข้อความเดียวกันสองที่
  • MemoryStorage แก้ปัญหาที่หายากที่สุดในชุดนี้ Node เวอร์ชันใหม่มี localStorage ของตัวเอง และ Vitest ไม่เอา localStorage ของ jsdom มาทับ บางเครื่อง Node ตั้งให้ใช้ได้ บางเครื่องได้ตัวที่เรียกแล้ว error อาการคือ test ผ่านในเครื่องเรา แต่ใน CI ขึ้น localStorage.clear is not a function ทั้งที่โค้ดเหมือนกัน เราเลยสร้าง storage ในหน่วยความจำขึ้นมาเองในไฟล์ setup ทุกเครื่องได้ตัวเดียวกัน

mock Inertia ไว้ที่เดียว

component ของ Inertia เรียก usePage(), useForm() หรือใช้ <Link> ซึ่งต้องมี Inertia app ครอบอยู่ ใน test เราแทนที่ทั้ง package ด้วยไฟล์ stub ไฟล์เดียว tests/js/inertia.jsx ที่คืน page ปลอม, Link ที่ render เป็น <a> และ router ที่ทุก method เป็น vi.fn()

vi.mock('@inertiajs/react', async () => (await import('../../inertia')).default);

test ที่ต้องการ props เฉพาะก็แก้ page.props ใน test นั้น ส่วน restoreMocks: true ใน config คืน mock ทุกตัวให้เป็นค่าเดิมหลังจบแต่ละ test

test สองแบบที่เราเขียนบ่อย

แบบแรกคือ function ที่ไม่มีหน้าจอ เช่น ตัวอ่านเลขทะเบียนนิติบุคคลของเว็บนี้ แบบนี้เขียนง่ายที่สุดและควรเริ่มก่อน

it('waits for 13 digits and reports the check digit it expected', () => {
    expect(readJuristicId('010756700007')).toBeNull();
    expect(readJuristicId('0107567000075')).toMatchObject({ valid: false, expected: 4 });
});

แบบที่สองคือ component ที่ผู้ใช้กดหรือพิมพ์ได้ สมมติเรามีช่องคำนวณราคารวม VAT

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import VatCalculator from '@/components/tools/VatCalculator';

it('shows the total with 7% VAT', async () => {
    const user = userEvent.setup();
    render(<VatCalculator />);

    await user.type(screen.getByLabelText('ราคาก่อน VAT'), '1000');

    expect(screen.getByText('รวม 1,070.00 บาท')).toBeInTheDocument();
});

test หาช่องกรอกจาก label แล้วตรวจข้อความที่ผู้ใช้เห็น ไม่ได้ดู state ข้างใน component ถ้าวันหนึ่งเราเขียน component ใหม่ทั้งตัวแต่หน้าจอยังทำงานเหมือนเดิม test ก็ยังผ่าน

test ที่ render หน้าแบบไม่มี DOM จับโค้ดที่ทำให้ SSR พัง

ถ้าเปิด Server-side Rendering ของ Inertia หน้าเดียวกันจะ render บน Node ก่อนส่งให้ browser บน Node ไม่มี window หรือ document component ที่อ่าน window.innerWidth ตอน render จะทำให้ SSR ของหน้านั้นพัง เว็บนี้มีไฟล์ test ที่สั่ง Vitest ให้รันใน environment ของ Node แล้ว render ทุกหน้าสาธารณะด้วย renderToString

// @vitest-environment node
import { renderToString } from 'react-dom/server';
import Home from '@/pages/Home/Index';

it('renders the home page without a DOM', () => {
    const html = renderToString(<Home seo={seo} work={[]} posts={[]} packages={SIX_PACKAGES} />);

    expect(html).toContain('ระบบของคุณ');
});

comment บรรทัดแรกเปลี่ยน environment เฉพาะไฟล์นี้ ไฟล์อื่นยังใช้ jsdom ตามเดิม

วางใน CI และให้ AI agent รันเฉพาะส่วนที่แก้

ใน CI เราวาง npm test ไว้หลัง npm run build และก่อนขั้นที่เตรียมฐานข้อมูล test ฝั่ง JavaScript ไม่ต้องใช้ฐานข้อมูลและจบเร็ว ถ้าพังก็หยุดตั้งแต่ตรงนี้

      - name: Install Node dependencies and build frontend
        run: npm ci --no-audit --no-fund && npm run build

      - name: Run JS tests
        run: npm test

ระหว่างแก้โค้ด Vitest มีคำสั่ง related ที่คล้าย TIA ของ Pest แต่ดูจาก import แทน coverage ให้ path ของไฟล์ที่แก้ไป Vitest จะรันเฉพาะ test ที่ import ไฟล์นั้นทั้งทางตรงและทางอ้อม

npx vitest related resources/js/components/contact/ContactForm.jsx --run
 Test Files  4 passed (4)
   Affected  4 of 79 test files (related to resources/js/components/contact/ContactForm.jsx)
      Tests  18 passed (18)
   Duration  1.61s

คำสั่งนี้เหมาะจะเขียนไว้ใน CLAUDE.md ให้ AI agent รันหลังแก้ component แต่ละตัว แล้วรัน npm test ทั้งชุดก่อนจบงาน เหมือนที่ทำกับ TIA ฝั่ง PHP ถ้าตั้ง hook ของ Claude Code ให้รัน ESLint กับ Prettier หลังแก้ไฟล์อยู่แล้ว จะเพิ่ม vitest related เข้าไปในจังหวะเดียวกันก็ได้

ควรเขียน test ให้อะไรก่อน

  • function ที่ไม่มีหน้าจอ ตัวแปลงวันที่ ตัวตรวจเลขบัตร ตัวจัดรูปแบบตัวเลข ผลลัพธ์ชัด เขียนง่าย และพังบ่อยเมื่อมีคนไปแก้
  • component ที่มี logic ฟอร์มที่เปิดปิดช่องตามค่าที่เลือก ปุ่มที่ disabled ตามเงื่อนไข
  • hook ที่ใช้ localStorage หรือ DOM ตรงนี้คือที่ที่ปัญหาจาก environment โผล่บ่อยที่สุด

ส่วนที่ไม่ต้องเขียนใน Vitest คือ logic ที่จริง ๆ แล้วอยู่ฝั่ง server ซึ่ง test ฝั่ง PHP ตรวจอยู่แล้ว และสิ่งที่ต้องใช้ browser จริง เช่น layout หรือ animation

ซีรีส์ Testing ใน Laravel

ครบหกตอนแล้ว รายชื่อทุกตอนอยู่ท้ายตอนแรก ถ้าอยากอ่านเรื่องเครื่องมือตรวจโค้ดที่รันคู่กับ test ใน CI ลองดูชุด lint และ formatter ต่อได้ครับ

อ่านต่อ

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

ล่าสุด

ดูทั้งหมด →