Testing
สามชั้น สามเครื่องมือ
หัวข้อที่มีชื่อว่า “สามชั้น สามเครื่องมือ”การ test แอป Svelte คือ pyramid เดียวกับ frontend ทั่วไป โดยมี helper ที่รู้จัก Svelte ตรงชั้น component:
| ชั้น | เครื่องมือ | ครอบคลุมอะไร |
|---|---|---|
| Unit | Vitest | logic ล้วน, utility และ runed .svelte.js module |
| Component | @testing-library/svelte + Vitest | component ที่ render ใน jsdom — behaviour ไม่ใช่ internals |
| End-to-end | Playwright | แอปจริงใน browser จริง ข้ามหลายหน้า |
flowchart TB e2e["Playwright: end-to-end (few)"] --> comp["Testing Library: components (some)"] comp --> unit["Vitest: logic + runed modules (many)"]
ตัวอย่างของบทนี้เอง run จริง: ทุก fence ด้านล่างที่บรรทัดแรกเป็น path comment (// src/lib/x.ts หรือ <!-- src/lib/X.svelte -->) ถูกเก็บโดย harness ของคอร์สนี้เอง (tools/verify-snippets.mjs) เข้าไปในโปรเจกต์ SvelteKit จริงแล้ว type-check ด้วย svelte-check; fence ที่ลงท้าย .test.ts ยังถูกรันเพิ่มด้วย vitest เอง “ตัวอย่างนี้คือ test จริงที่ run ได้” ไม่ใช่คำเปรียบเปรยที่ไหนในบทนี้เลย — มันคือกลไกจริงที่พิสูจน์ทุก claim ในคอร์สนี้ที่ไม่เกี่ยวกับ browser (ชั้นของ Playwright ด้านล่าง)
unit test ด้วย Vitest
หัวข้อที่มีชื่อว่า “unit test ด้วย Vitest”logic ล้วน ๆ — formatter, การคำนวณ, ฟังก์ชัน update ของ store — ทดสอบด้วย Vitest ที่ใช้ config ร่วมกับ Vite จึงเข้าใจโปรเจกต์คุณตั้งแต่แรก:
export function formatPrice(amount: number): string { return `$${amount.toFixed(2)}`;}import { describe, it, expect } from 'vitest';import { formatPrice } from './money.ts';
describe('formatPrice', () => { it('adds a currency symbol', () => { expect(formatPrice(5)).toBe('$5.00'); });});✓ formatPrice > adds a currency symbolreactive state ใน module .svelte.js / .svelte.ts (rune นอก component) ทดสอบด้วยวิธีเดียวกัน — Vitest รันด้วย Svelte plugin ให้ rune ทำงานใน test
component test ด้วย Testing Library
หัวข้อที่มีชื่อว่า “component test ด้วย Testing Library”@testing-library/svelte render component เข้า jsdom แล้วให้ query และ interaction ที่สะท้อนพฤติกรรมผู้ใช้ — query ด้วย role/text ไม่ใช่ชื่อ class ภายใน:
<script lang="ts"> let { start = 0 }: { start?: number } = $props(); let count = $state(start);</script>
<button onclick={() => count++}>count: {count}</button>import { render, screen } from '@testing-library/svelte';import userEvent from '@testing-library/user-event';import { expect, test } from 'vitest';import Counter from './Counter.svelte';
test('increments on click', async () => { render(Counter, { props: { start: 0 } }); const button = screen.getByRole('button', { name: /count/i });
await userEvent.click(button);
expect(button).toHaveTextContent('count: 1');});✓ increments on clickวินัยคือ test behaviour ไม่ใช่ implementation assert สิ่งที่ผู้ใช้เห็นและทำ (text ที่มองเห็น, role, interaction) เพื่อให้ refactor ที่รักษา behaviour เดิมไว้ไม่ทำ test พัง
test rune นอก component: .svelte.ts + $effect.root
หัวข้อที่มีชื่อว่า “test rune นอก component: .svelte.ts + $effect.root”module .svelte.ts ใช้ rune ตรง ๆ ได้โดยไม่ต้องมี component — factory สไตล์ createCounter ของคอร์สนี้เองคือรูปร่างปกติ จุดที่ต้องระวังจุดเดียว: $effect ต้องการที่ให้ run และนอก component “ที่” นั้นคือ $effect.root(fn) — มันสร้าง effect scope ที่ไม่ผูกกับ component ซึ่งคุณต้อง teardown เอง (เรียก function ที่มัน return) แทนที่ lifecycle ของ component จะทำให้เอง
import { flushSync } from 'svelte';
export function createCounter(initial = 0) { let count = $state(initial); const doubled = $derived(count * 2); return { get count() { return count; }, get doubled() { return doubled; }, inc() { count += 1; }, };}
// Runes ($effect.root, $effect) only work inside .svelte / .svelte.ts files,// so the effect-driven assertions live here, not in the plain .test.ts file.export function traceDoubled(): number[] { const log: number[] = []; const counter = createCounter(1); const destroy = $effect.root(() => { $effect(() => { log.push(counter.doubled); }); }); flushSync(); counter.inc(); flushSync(); destroy(); return log;}import { test, expect } from 'vitest';import { traceDoubled } from './runeCounter.svelte.ts';
test('$derived recomputes inside a $effect.root-wrapped effect, outside any component', () => { expect(traceDoubled()).toEqual([2, 4]);});✓ $derived recomputes inside a $effect.root-wrapped effect, outside any componentสังเกตว่า code ที่ใช้ rune อยู่ที่ไหน: $effect.root/$effect ถูกเรียกจาก ข้างใน runeCounter.svelte.ts (ไฟล์ .svelte.ts จริง ที่ rune ใช้ได้ตามกฎหมาย) ไม่ใช่จากไฟล์ .test.ts ธรรมดาที่ import แล้วเรียกมัน — rune throw error จริง rune_outside_svelte ทันทีที่ถูกใช้ที่อื่น รวมถึงตรง ๆ ข้างใน test file เอง
end-to-end ด้วย Playwright
หัวข้อที่มีชื่อว่า “end-to-end ด้วย Playwright”Playwright ขับ browser จริงผ่านหน้าจริง — routing, form submission, navigation — ที่เป็นจุดที่ behaviour ฝั่ง server ของ SvelteKit (load function, form action) ต้อง verify:
import { test, expect } from '@playwright/test';
test('user can sign in', async ({ page }) => { await page.goto('/login'); await page.getByRole('button', { name: 'Sign in' }).click(); await expect(page).toHaveURL('/dashboard');});เก็บ test พวกนี้ให้น้อยและมีคุณค่าสูง — test พวกนี้ช้ากว่า unit และ component test แต่เป็นชั้นเดียวที่พิสูจน์ว่าทุกอย่างทำงานร่วมกันได้
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”สามชั้นมีอยู่เพราะแต่ละชั้นตอบคำถามคนละข้อ และไม่มีเครื่องมือตัวเดียวที่ตอบได้ทั้งหมดในราคาถูก: Vitest ถามว่า “function/module นี้ทำสิ่งที่ถูกต้องไหม”, Testing Library ถามว่า “component นี้ behave ถูกไหมเมื่อ user โต้ตอบด้วย ใน DOM ปลอม” และ Playwright ถามว่า “ทั้งระบบที่ deploy แล้วทำงานไหม ใน browser จริง ข้าม page load จริง” rune ทำให้ชั้นเดียวยุ่งยากขึ้นเชิงโครงสร้าง — $state/$derived/$effect มีอยู่แค่ข้างในไฟล์ .svelte/.svelte.js/.svelte.ts เท่านั้น ดังนั้นการ test มันนอก component (module .svelte.ts) ต้องการ $effect.root เพื่อให้ $effect มีที่ให้ run เลยด้วยซ้ำ; อีกสองชั้นไม่เจอปัญหานี้เพราะ lifecycle ของ component เอง (render() ของ Testing Library) หรือ browser จริง (Playwright) มี scope นั้นให้อยู่แล้ว
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ได้อะไร | ประโยชน์ | ต้นทุน |
|---|---|---|
test logic .ts ล้วนด้วย Vitest | เร็ว ไม่มี DOM ไม่มี component overhead (พิสูจน์แล้วด้านบน) | ครอบคลุมแค่ logic ที่ไม่แตะ state/effect/DOM |
@testing-library/svelte query ด้วย role/text | รอด refactor ภายในที่ไม่เปลี่ยน behavior (พิสูจน์แล้วด้านบน) | ช้ากว่า unit test ล้วน; ต้องมี jsdom |
$effect.root สำหรับ test module .svelte.ts | assert พฤติกรรมที่ขึ้นกับ effect ได้ (พิสูจน์แล้วด้านบน) โดยไม่มี component เลย | เป็น API เพิ่มอีกตัวที่ต้องรู้ และคุณต้องเรียก cleanup ที่ return เองไม่มีอะไรทำให้ |
| Playwright end-to-end | ชั้นเดียวที่พิสูจน์ว่าแอปที่ deploy แล้วทำงานจริง ข้าม navigation จริง | ช้า และ flaky ได้ถ้า scope ไม่แคบพอ — เก็บชั้นนี้ให้เล็ก |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “test rune ได้ตรง ๆ ข้างในไฟล์
.test.tsธรรมดา” ทำไม่ได้ — พิสูจน์แล้วด้านบนด้วย error จริงrune_outside_svelte; code ที่ใช้ rune ต้องอยู่ในไฟล์.svelte.tsที่ test import แล้วเรียก ไม่ใช่เขียน inline ใน test เอง - “
$effect.rootมีไว้แค่ใช้ขั้นสูง/ภายในเท่านั้น ไม่ใช่สิ่งที่ test code ควรหยิบใช้” มันคือวิธี documented และ supported ในการให้$effectมี scope นอก component — พิสูจน์แล้วด้านบนว่ามันคือสิ่งเดียวที่ทำให้ test พฤติกรรมที่ขึ้นกับ effect ของ module.svelte.tsเป็นไปได้เลย - “test behaviour แทน implementation แปลว่า test อะไรเจาะจงไม่ได้” พิสูจน์แล้วด้านบน:
expect(button).toHaveTextContent('count: 1')แม่นยำและเจาะจง — มันแค่ assert สิ่งที่ user จะเห็นจริง ไม่ใช่ชื่อหรือรูปร่างของตัวแปรภายใน
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เวลารีวิว test ที่ generate มา ให้เช็ค:
- test file พยายามเรียก
$state/$derived/$effectตรง ๆ แทนที่จะ import จาก module.svelte.tsหรือเปล่า — ความผิดพลาดที่ fail ทันทีด้วยrune_outside_svelteพิสูจน์แล้วด้านบน? - component test assert state ภายในหรือตัวแปร private (แตะ internals ของ component) แทนที่จะเป็น output ที่ render และ interaction ที่ user จริงจะเห็น ตามปรัชญาของ
@testing-library/svelteเองหรือเปล่า? - cleanup function ที่
$effect.rootreturn ไม่เคยถูกเรียกเลยหรือเปล่า — ทำให้ effect scope รั่ว ที่ lifecycle ของ component จริงจะ teardown ให้อัตโนมัติ แต่ test.svelte.tsแบบ manual ต้องทำเอง? - end-to-end test ของ Playwright ถูกใช้เช็คอะไรบางอย่างที่ unit หรือ component test ทำได้เร็วกว่ามากหรือเปล่า — ทำให้ชั้น e2e ที่ควร “น้อยแต่มีคุณค่าสูง” บวมกลายเป็น bulk ของ suite ทั้งหมด?
🐛 หา bug ในโค้ดนี้
import { test, expect } from 'vitest';import { $state, $derived } from 'svelte';
test('doubled updates', () => {let count = $state(1);const doubled = $derived(count * 2);count = 2;expect(doubled).toBe(4);});เฉลย
มีความผิดพลาดสองอย่างซ้อนกันตรงนี้ อย่างแรก rune ไม่ใช่ value ที่ import ได้เลย — $state/$derived เป็น compiler intrinsic ที่ compiler ของ Svelte รู้จักแค่ข้างในไฟล์ .svelte/.svelte.js/.svelte.ts เท่านั้น import { $state } from 'svelte' ไม่ตรงกับ export จริงตัวไหนเลยด้วยซ้ำ อย่างที่สอง แม้จะแก้ให้ใช้ rune syntax ตรง ๆ แล้ว code นี้ยังเขียน inline ในไฟล์ .test.ts ธรรมดา ซึ่ง fail ด้วย error จริง rune_outside_svelte ที่พิสูจน์แล้วก่อนหน้าในบทนี้: `$effect`/`$state`/`$derived` are only available inside .svelte and .svelte.js/ts files
วิธีแก้คือ pattern ที่พิสูจน์แล้วด้านบน: ย้าย logic ที่ใช้ rune เข้า module runeCounter.svelte.ts จริง (function ที่ test import แล้วเรียก) และหยิบ $effect.root มาใช้ก็ต่อเมื่อ assertion ขึ้นกับ effect ที่ run จริง — การอ่าน $derived เปล่า ๆ แบบนี้ไม่ต้องการขนาดนั้นด้วยซ้ำ