ตอนสุดท้ายของซีรีส์ 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 อาจหาไฟล์ไม่เจอตอน buildincludeเก็บ 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 ต่อได้ครับ






