본문으로 건너뛰기

17. 테스팅

견고하고 유지보수 가능한 Svelte 애플리케이션을 개발하기 위해서는 체계적인 테스팅이 필수적입니다. 테스트는 코드의 품질을 보장하고, 리팩토링 시 기존 기능이 정상 작동하는지 확인하며, 새로운 기능 추가 시 발생할 수 있는 회귀 버그를 방지합니다. 이 장에서는 Vitest를 사용한 단위 테스트부터 Playwright를 활용한 E2E 테스트까지, Svelte 애플리케이션의 완전한 테스팅 전략을 마스터해보겠습니다.


17.1 단위 테스트

Vitest 설정

Vitest는 Vite를 기반으로 한 차세대 테스팅 프레임워크로, Svelte와 SvelteKit 프로젝트에 최적화되어 있습니다. Jest보다 훨씬 빠른 실행 속도와 우수한 개발자 경험을 제공하며, Vite의 설정을 그대로 활용할 수 있어 별도의 복잡한 설정이 필요없습니다. 특히 HMR(Hot Module Replacement)을 지원하여 테스트 코드를 수정하면 즉시 재실행되는 뛰어난 개발 경험을 제공합니다.

기본 설정

# Vitest와 관련 패키지 설치
npm install -D vitest @testing-library/svelte @testing-library/jest-dom jsdom
vitest.config.js
/// <reference types="vitest/config" />
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { svelteTesting } from '@testing-library/svelte/vite';

export default defineConfig({
plugins: [svelte(), svelteTesting()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest-setup.js'],
include: ['src/**/*.{test,spec}.{js,ts}'],
coverage: {
reporter: ['text', 'html'],
exclude: ['node_modules/', 'tests/'],
},
},
});
vitest-setup.js
import '@testing-library/jest-dom/vitest';

// 전역 설정
beforeAll(() => {
console.log('테스트 시작');
});

afterEach(() => {
// 각 테스트 후 정리
document.body.innerHTML = '';
});

package.json 스크립트 설정

package.json
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest watch"
}
}

컴포넌트 테스트

Svelte 컴포넌트를 테스트할 때는 사용자 관점에서 컴포넌트가 어떻게 동작하는지 검증하는 것이 중요합니다. @testing-library/svelte는 사용자 중심의 테스트 작성을 돕는 유틸리티를 제공하여 구현 세부사항보다는 실제 사용자 행동에 집중할 수 있게 합니다. Svelte 5의 새로운 Rune 시스템과 mount/unmount API를 활용하여 더 정확한 테스트가 가능합니다.

기본 컴포넌트 테스트

Counter.svelte
<!-- Counter.svelte -->
<script>
let { initialValue = 0, onIncrement } = $props();

let count = $state(initialValue);

function increment() {
count++;
onIncrement?.(count);
}
</script>

<div>
<p data-testid="count-display">카운트: {count}</p>
<button onclick="{increment}">증가</button>
<button onclick="{()" ="">count = 0}>리셋</button>
</div>
Counter.test.js
import { describe, it, expect, vi } from 'vitest';
import {
render,
screen,
fireEvent,
} from '@testing-library/svelte';
import Counter from './Counter.svelte';

describe('Counter 컴포넌트', () => {
it('초기값이 올바르게 표시되어야 함', () => {
render(Counter, { props: { initialValue: 5 } });

const display = screen.getByTestId('count-display');
expect(display).toHaveTextContent('카운트: 5');
});

it('증가 버튼 클릭 시 카운트가 증가해야 함', async () => {
const onIncrement = vi.fn();
render(Counter, { props: { onIncrement } });

const button = screen.getByText('증가');
await fireEvent.click(button);

expect(
screen.getByTestId('count-display')
).toHaveTextContent('카운트: 1');
expect(onIncrement).toHaveBeenCalledWith(1);
});

it('리셋 버튼 클릭 시 카운트가 0이 되어야 함', async () => {
render(Counter, { props: { initialValue: 10 } });

const resetButton = screen.getByText('리셋');
await fireEvent.click(resetButton);

expect(
screen.getByTestId('count-display')
).toHaveTextContent('카운트: 0');
});
});

Svelte 5 mount/unmount API

mount-example.test.js
import { describe, it, expect, afterEach } from 'vitest';
import { mount, unmount, flushSync } from 'svelte';
import Counter from './Counter.svelte';

describe('Svelte 5 mount API', () => {
let app;
let target;

beforeEach(() => {
target = document.createElement('div');
document.body.appendChild(target);
});

afterEach(() => {
if (app) {
unmount(app);
}
document.body.innerHTML = '';
});

it('mount를 사용한 컴포넌트 테스트', () => {
app = mount(Counter, {
target,
props: { initialValue: 5 },
});

expect(target.textContent).toContain('카운트: 5');

const button = target.querySelector('button');
button.click();
flushSync(); // 반응성 업데이트 강제 실행

expect(target.textContent).toContain('카운트: 6');
});

it('props 업데이트 테스트', () => {
let count = $state(0);

app = mount(Counter, {
target,
props: { initialValue: count },
});

count = 10;
flushSync();

expect(target.textContent).toContain('카운트: 10');
});
});

브라우저 기반 테스팅 (vitest-browser-svelte)

# 브라우저 기반 테스팅 패키지 설치
npm install -D @vitest/browser vitest-browser-svelte playwright
vitest.config.browser.js
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';

export default defineConfig({
plugins: [svelte()],
test: {
browser: {
enabled: true,
name: 'chromium',
provider: 'playwright',
},
setupFiles: ['./vitest-setup-browser.js'],
},
});
Counter.browser.test.js
import { page } from '@vitest/browser/context';
import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { flushSync } from 'svelte';
import Counter from './Counter.svelte';

describe('브라우저 환경 컴포넌트 테스트', () => {
it('실제 브라우저에서 카운터 동작 확인', async () => {
let count = $state(0);

render(Counter, {
props: { initialValue: count },
});

const display = page.getByTestId('count-display');
await expect
.element(display)
.toHaveTextContent('카운트: 0');

count = 5;
flushSync(); // 외부 상태 변경 시 필요

await expect
.element(display)
.toHaveTextContent('카운트: 5');

const button = page.getByText('증가');
await button.click();

await expect
.element(display)
.toHaveTextContent('카운트: 6');
});
});
LoginForm.svelte
<!-- LoginForm.svelte -->
<script>
let { onSubmit } = $props();

let email = $state('');
let password = $state('');
let error = $state('');

async function handleSubmit(event) {
event.preventDefault();
error = '';

if (!email || !password) {
error = '모든 필드를 입력해주세요';
return;
}

try {
await onSubmit({ email, password });
} catch (err) {
error = err.message;
}
}
</script>

<form onsubmit="{handleSubmit}">
<input
type="email"
placeholder="이메일"
bind:value="{email}"
data-testid="email-input"
/>

<input
type="password"
placeholder="비밀번호"
bind:value="{password}"
data-testid="password-input"
/>

{#if error}
<p role="alert">{error}</p>
{/if}

<button type="submit">로그인</button>
</form>
LoginForm.test.js
import { describe, it, expect, vi } from 'vitest';
import {
render,
screen,
fireEvent,
waitFor,
} from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm.svelte';

describe('LoginForm 컴포넌트', () => {
it('필드가 비어있을 때 에러 메시지를 표시해야 함', async () => {
const onSubmit = vi.fn();
render(LoginForm, { props: { onSubmit } });

const submitButton = screen.getByText('로그인');
await fireEvent.click(submitButton);

expect(screen.getByRole('alert')).toHaveTextContent(
'모든 필드를 입력해주세요'
);
expect(onSubmit).not.toHaveBeenCalled();
});

it('올바른 데이터로 폼을 제출해야 함', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn().mockResolvedValue();
render(LoginForm, { props: { onSubmit } });

const emailInput = screen.getByTestId('email-input');
const passwordInput = screen.getByTestId(
'password-input'
);

await user.type(emailInput, 'test@example.com');
await user.type(passwordInput, 'password123');
await user.click(screen.getByText('로그인'));

await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
});
});

Svelte 5 반응성 테스트

reactivity.test.js
import { describe, it, expect } from 'vitest';
import { flushSync } from 'svelte';

describe('Svelte 5 Rune 반응성 테스트', () => {
it('$state와 $derived 반응성 테스트', () => {
let count = $state(0);
let doubled = $derived(count * 2);
let tripled = $derived(count * 3);

expect(count).toBe(0);
expect(doubled).toBe(0);
expect(tripled).toBe(0);

count = 5;
flushSync(); // 반응성 업데이트 강제 실행

expect(doubled).toBe(10);
expect(tripled).toBe(15);
});

it('중첩된 반응성 테스트', () => {
let user = $state({
name: '홍길동',
settings: {
theme: 'light',
notifications: true,
},
});

let displayName = $derived(
user.settings.theme === 'dark'
? `🌙 ${user.name}`
: `☀️ ${user.name}`
);

expect(displayName).toBe('☀️ 홍길동');

user.settings.theme = 'dark';
flushSync();

expect(displayName).toBe('🌙 홍길동');
});

it('$effect 부수효과 테스트', () => {
let count = $state(0);
let effectRuns = 0;

$effect(() => {
effectRuns++;
console.log('Count changed:', count);
});

flushSync(); // 초기 effect 실행
expect(effectRuns).toBe(1);

count = 1;
flushSync();
expect(effectRuns).toBe(2);

count = 2;
flushSync();
expect(effectRuns).toBe(3);
});
});

스토어 테스트

Svelte 스토어는 애플리케이션의 상태 관리 핵심이므로 철저한 테스트가 필요합니다. 스토어의 초기값, 업데이트 로직, 구독 메커니즘 등을 검증하여 예상대로 동작하는지 확인해야 합니다. 특히 커스텀 스토어나 derived 스토어의 경우 복잡한 로직을 포함할 수 있으므로 다양한 시나리오를 테스트하는 것이 중요합니다.

기본 스토어 테스트

userStore.js
import { writable, derived } from 'svelte/store';

function createUserStore() {
const { subscribe, set, update } = writable({
id: null,
name: '',
email: '',
isLoggedIn: false,
});

return {
subscribe,
login: userData => {
set({
...userData,
isLoggedIn: true,
});
},
logout: () => {
set({
id: null,
name: '',
email: '',
isLoggedIn: false,
});
},
updateProfile: updates => {
update(user => ({ ...user, ...updates }));
},
};
}

export const userStore = createUserStore();

export const userDisplayName = derived(userStore, $user =>
$user.isLoggedIn ? $user.name || $user.email : 'Guest'
);
userStore.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { get } from 'svelte/store';
import { userStore, userDisplayName } from './userStore.js';

describe('userStore', () => {
beforeEach(() => {
userStore.logout();
});

it('초기 상태가 올바르게 설정되어야 함', () => {
const user = get(userStore);
expect(user).toEqual({
id: null,
name: '',
email: '',
isLoggedIn: false,
});
});

it('로그인이 정상적으로 작동해야 함', () => {
const userData = {
id: 1,
name: '홍길동',
email: 'hong@example.com',
};

userStore.login(userData);
const user = get(userStore);

expect(user.isLoggedIn).toBe(true);
expect(user.name).toBe('홍길동');
expect(user.email).toBe('hong@example.com');
});

it('프로필 업데이트가 정상적으로 작동해야 함', () => {
userStore.login({
id: 1,
name: '홍길동',
email: 'hong@example.com',
});
userStore.updateProfile({ name: '김철수' });

const user = get(userStore);
expect(user.name).toBe('김철수');
expect(user.email).toBe('hong@example.com');
});
});

describe('userDisplayName', () => {
it('로그인 상태에 따라 올바른 이름을 표시해야 함', () => {
expect(get(userDisplayName)).toBe('Guest');

userStore.login({
id: 1,
name: '홍길동',
email: 'hong@example.com',
});
expect(get(userDisplayName)).toBe('홍길동');

userStore.logout();
expect(get(userDisplayName)).toBe('Guest');
});
});

실습해보기: Svelte REPL에서 이 코드를 직접 실행해보세요!


17.2 통합 테스트

Playwright 설정

Playwright는 Microsoft에서 개발한 강력한 E2E 테스팅 프레임워크로, 실제 브라우저에서 애플리케이션을 테스트할 수 있습니다. Chromium, Firefox, WebKit을 모두 지원하여 크로스 브라우저 테스팅이 가능하며, 자동 대기 메커니즘으로 안정적인 테스트를 작성할 수 있습니다. 스크린샷, 비디오 녹화, 네트워크 가로채기 등 다양한 디버깅 기능도 제공하여 테스트 실패 원인을 쉽게 파악할 수 있습니다.

설치 및 기본 설정

# Playwright 설치
npm init playwright@latest

# 브라우저 설치 (중요!)
npx playwright install
playwright.config.js
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',

use: {
baseURL: 'http://localhost:4173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},

projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],

webServer: {
command: 'npm run build && npm run preview',
port: 4173,
reuseExistingServer: !process.env.CI,
},
});

E2E 테스트 작성

E2E 테스트는 사용자의 실제 워크플로우를 시뮬레이션하여 애플리케이션이 전체적으로 올바르게 동작하는지 검증합니다. 페이지 간 네비게이션, 폼 제출, 데이터 영속성 등 통합적인 기능을 테스트하여 단위 테스트로는 발견하기 어려운 문제를 찾아낼 수 있습니다. Playwright의 자동 대기 기능과 재시도 메커니즘으로 안정적인 테스트를 작성할 수 있습니다.

기본 E2E 테스트

tests/homepage.spec.js
import { test, expect } from '@playwright/test';

test.describe('홈페이지', () => {
test('홈페이지가 정상적으로 로드되어야 함', async ({
page,
}) => {
await page.goto('/');

await expect(page).toHaveTitle(/Welcome to SvelteKit/);
await expect(page.locator('h1')).toBeVisible();
await expect(page.locator('h1')).toContainText(
'Welcome'
);
});

test('네비게이션이 정상 작동해야 함', async ({
page,
}) => {
await page.goto('/');

await page.click('text=About');
await expect(page).toHaveURL('/about');

await page.click('text=Contact');
await expect(page).toHaveURL('/contact');
});
});

복잡한 사용자 시나리오 테스트

tests/user-flow.spec.js
import { test, expect } from '@playwright/test';

test.describe('사용자 플로우', () => {
test('회원가입 및 로그인 플로우', async ({ page }) => {
// 회원가입 페이지로 이동
await page.goto('/signup');

// 폼 작성
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'Test123!@#');
await page.fill(
'[name="confirmPassword"]',
'Test123!@#'
);
await page.check('[name="terms"]');

// 제출
await page.click('button[type="submit"]');

// 성공 메시지 확인
await expect(
page.locator('.success-message')
).toBeVisible();
await expect(page).toHaveURL('/welcome');

// 로그인 테스트
await page.goto('/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'Test123!@#');
await page.click('button[type="submit"]');

// 대시보드로 리다이렉트 확인
await expect(page).toHaveURL('/dashboard');
await expect(
page.locator('text=Welcome back')
).toBeVisible();
});

test('장바구니 플로우', async ({ page }) => {
await page.goto('/products');

// 상품 추가
await page.click('[data-product-id="1"] button');
await expect(page.locator('.cart-count')).toHaveText(
'1'
);

// 장바구니 페이지로 이동
await page.click('.cart-icon');
await expect(page).toHaveURL('/cart');

// 수량 변경
await page.fill('[data-product-id="1"] input', '3');
await page.press(
'[data-product-id="1"] input',
'Enter'
);

// 총액 확인
const total = await page
.locator('.total-price')
.textContent();
expect(total).toMatch(/[0-9,]+/);

// 결제 진행
await page.click('text=Proceed to Checkout');
await expect(page).toHaveURL('/checkout');
});
});

시각적 회귀 테스트

시각적 회귀 테스트는 UI 변경사항을 자동으로 감지하여 의도하지 않은 시각적 변경을 방지합니다. Playwright의 스크린샷 비교 기능을 활용하면 픽셀 단위로 정확한 비교가 가능하며, 다양한 뷰포트와 브라우저에서 일관된 UI를 보장할 수 있습니다. 디자인 시스템을 유지하고 리팩토링 시 UI 회귀를 방지하는 데 매우 효과적입니다.

tests/visual.spec.js
import { test, expect } from '@playwright/test';

test.describe('시각적 회귀 테스트', () => {
test('컴포넌트 스크린샷 비교', async ({ page }) => {
await page.goto('/components');

// 버튼 컴포넌트
const button = page.locator('.button-primary');
await expect(button).toHaveScreenshot(
'button-primary.png'
);

// 호버 상태
await button.hover();
await expect(button).toHaveScreenshot(
'button-primary-hover.png'
);

// 카드 컴포넌트
const card = page.locator('.card');
await expect(card).toHaveScreenshot('card.png', {
maxDiffPixels: 100,
threshold: 0.2,
});
});

test('반응형 레이아웃 테스트', async ({ page }) => {
const viewports = [
{ width: 320, height: 568, name: 'mobile' },
{ width: 768, height: 1024, name: 'tablet' },
{ width: 1920, height: 1080, name: 'desktop' },
];

for (const viewport of viewports) {
await page.setViewportSize(viewport);
await page.goto('/');
await expect(page).toHaveScreenshot(
`homepage-${viewport.name}.png`
);
}
});

test('다크모드 테스트', async ({ page }) => {
await page.goto('/');

// 라이트 모드 스크린샷
await expect(page).toHaveScreenshot('light-mode.png');

// 다크 모드 전환
await page.click('[data-testid="theme-toggle"]');
await page.waitForTimeout(300); // 애니메이션 대기

// 다크 모드 스크린샷
await expect(page).toHaveScreenshot('dark-mode.png');
});
});

실습해보기: Svelte REPL에서 이 코드를 직접 실행해보세요!


17.3 테스트 전략

테스트 피라미드

테스트 피라미드는 효과적인 테스트 전략의 기초로, 단위 테스트를 기반으로 하고 통합 테스트와 E2E 테스트를 적절히 조합합니다. 많은 단위 테스트로 빠른 피드백을 얻고, 중간 수준의 통합 테스트로 컴포넌트 간 상호작용을 검증하며, 소수의 E2E 테스트로 핵심 사용자 시나리오를 확인합니다. 이러한 균형잡힌 접근 방식은 테스트 실행 시간과 유지보수 비용을 최적화하면서도 높은 신뢰성을 보장합니다.

테스트 레벨별 전략

효과적인 테스트 전략은 테스트 피라미드 원칙을 따라 각 레벨별로 적절한 비중을 배분하는 것입니다.

**단위 테스트 (70%)**는 테스트의 기반을 형성합니다. 빠른 실행 속도와 격리된 환경에서 실행되어 신속한 피드백을 제공합니다. 비즈니스 로직, 유틸리티 함수, 스토어 등 애플리케이션의 핵심 로직을 검증하는 데 집중합니다.

**통합 테스트 (20%)**는 컴포넌트 간 상호작용을 검증합니다. API 통합, 라우팅, 여러 컴포넌트가 함께 동작하는 시나리오를 테스트하여 개별 단위가 올바르게 연결되어 있는지 확인합니다.

**E2E 테스트 (10%)**는 가장 적은 비중이지만 가장 중요한 사용자 시나리오를 검증합니다. 크리티컬 패스, 결제 플로우, 회원가입 프로세스 등 비즈니스 핵심 기능이 전체적으로 올바르게 동작하는지 확인합니다.

테스트 조직화 예제

src/
├── lib/
│ ├── components/
│ │ ├── Button.svelte
│ │ └── Button.test.js
│ ├── stores/
│ │ ├── user.js
│ │ └── user.test.js
│ └── utils/
│ ├── validation.js
│ └── validation.test.js
├── routes/
│ ├── +page.svelte
│ └── +page.test.js
tests/
├── e2e/
│ ├── auth.spec.js
│ └── checkout.spec.js
└── integration/
├── api.test.js
└── navigation.test.js

모킹과 스텁

모킹과 스텁은 테스트를 격리하고 외부 의존성을 제어하는 핵심 기법입니다. 외부 API, 데이터베이스, 타사 서비스 등을 모킹하여 테스트를 예측 가능하고 빠르게 만들 수 있습니다. Vitest의 강력한 모킹 기능을 활용하면 복잡한 시나리오도 쉽게 테스트할 수 있습니다.

api.test.js
import {
describe,
it,
expect,
vi,
beforeEach,
} from 'vitest';
import { fetchUserData, updateUserProfile } from './api.js';

// fetch 모킹
global.fetch = vi.fn();

describe('API 함수', () => {
beforeEach(() => {
fetch.mockClear();
});

it('사용자 데이터를 가져와야 함', async () => {
const mockData = { id: 1, name: '홍길동' };

fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockData,
});

const result = await fetchUserData(1);

expect(fetch).toHaveBeenCalledWith('/api/users/1');
expect(result).toEqual(mockData);
});

it('API 오류를 처리해야 함', async () => {
fetch.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
});

await expect(fetchUserData(999)).rejects.toThrow(
'User not found'
);
});

it('네트워크 지연을 시뮬레이션해야 함', async () => {
vi.useFakeTimers();

fetch.mockImplementation(
() =>
new Promise(resolve => {
setTimeout(() => {
resolve({
ok: true,
json: async () => ({ id: 1 }),
});
}, 3000);
})
);

const promise = fetchUserData(1);

vi.advanceTimersByTime(3000);
const result = await promise;

expect(result).toEqual({ id: 1 });
vi.useRealTimers();
});
});

컴포넌트 의존성 모킹

UserProfile.test.js
import { describe, it, expect, vi } from 'vitest';
import {
render,
screen,
waitFor,
} from '@testing-library/svelte';
import UserProfile from './UserProfile.svelte';

// API 모듈 모킹
vi.mock('./api.js', () => ({
fetchUserData: vi.fn(),
updateUserProfile: vi.fn(),
}));

import { fetchUserData, updateUserProfile } from './api.js';

describe('UserProfile 컴포넌트', () => {
it('사용자 데이터를 로드하고 표시해야 함', async () => {
fetchUserData.mockResolvedValue({
id: 1,
name: '홍길동',
email: 'hong@example.com',
});

render(UserProfile, { props: { userId: 1 } });

expect(
screen.getByText('로딩 중...')
).toBeInTheDocument();

await waitFor(() => {
expect(
screen.getByText('홍길동')
).toBeInTheDocument();
expect(
screen.getByText('hong@example.com')
).toBeInTheDocument();
});
});

it('로드 실패 시 에러를 표시해야 함', async () => {
fetchUserData.mockRejectedValue(
new Error('네트워크 오류')
);

render(UserProfile, { props: { userId: 1 } });

await waitFor(() => {
expect(
screen.getByText(/오류가 발생했습니다/)
).toBeInTheDocument();
});
});
});

CI/CD 통합

지속적 통합과 배포 파이프라인에 테스트를 통합하면 코드 품질을 자동으로 보장할 수 있습니다. GitHub Actions, GitLab CI, Jenkins 등 다양한 CI/CD 도구와 쉽게 통합할 수 있으며, 풀 리퀘스트마다 자동으로 테스트를 실행하여 문제를 조기에 발견할 수 있습니다. 테스트 커버리지 리포트와 시각적 회귀 테스트 결과를 자동으로 생성하여 팀 전체가 코드 품질을 모니터링할 수 있습니다.

GitHub Actions 설정

.github/workflows/test.yml
name: Tests

on:
push:
branches: [main, develop]
pull_request:
branches: [main]

jobs:
unit-tests:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3

- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Run unit tests
run: npm run test:coverage

- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage-final.json

e2e-tests:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3

- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Install Playwright browsers
run: npx playwright install --with-deps

- name: Build application
run: npm run build

- name: Run E2E tests
run: npm run test:e2e

- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: playwright-report/
retention-days: 30

테스트 커버리지 설정

vitest.config.js
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
exclude: [
'node_modules/',
'tests/',
'*.config.js',
'.svelte-kit/',
],
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
});

테스팅 모범 사례 (2025)

최신 Svelte 테스팅 트렌드와 모범 사례를 따르면 더 안정적이고 유지보수 가능한 테스트를 작성할 수 있습니다. 브라우저 기반 테스팅, 시각적 회귀 테스트, 성능 테스트 등 다양한 테스트 기법을 조합하여 완벽한 품질 보증 체계를 구축할 수 있습니다. 특히 Svelte 5의 새로운 기능들을 활용하면 더 정확하고 효율적인 테스트가 가능합니다.

브라우저 vs JSDOM 선택 기준

테스트 환경을 선택할 때는 테스트의 목적과 복잡도를 고려해야 합니다. 간단한 단위 테스트나 로직 검증에는 JSDOM이 충분하며 실행 속도도 빠릅니다. 반면 복잡한 사용자 상호작용, 실제 브라우저 API(IntersectionObserver, WebGL 등) 사용, 또는 시각적 요소가 중요한 경우에는 브라우저 기반 테스팅을 선택하는 것이 좋습니다.

flushSync 사용 시점

Svelte 5의 반응성 시스템을 테스트할 때 flushSync는 필수적인 도구입니다. 외부에서 $state 값을 변경한 후에는 반드시 flushSync()를 호출하여 DOM 업데이트를 강제로 실행해야 합니다. 특히 테스트에서 동기적으로 결과를 검증해야 할 때나 여러 상태 변경을 순차적으로 테스트할 때 반드시 사용해야 합니다.

테스트 구조화 패턴

모든 테스트는 AAA 패턴을 따라 작성하는 것이 좋습니다. Arrange 단계에서 테스트에 필요한 환경과 데이터를 준비하고, Act 단계에서 테스트하려는 동작을 실행하며, Assert 단계에서 결과를 검증합니다. 마지막으로 Cleanup 단계에서 테스트 환경을 정리하여 다른 테스트에 영향을 주지 않도록 합니다.

효과적인 모킹 전략

모킹은 필요한 최소한으로 제한해야 합니다. 외부 API, 데이터베이스, 타사 서비스 등 제어할 수 없는 외부 의존성만 모킹하고, 가능한 한 실제 구현을 사용하는 것이 좋습니다. 과도한 모킹은 테스트의 신뢰성을 떨어뜨리고 실제 버그를 놓칠 수 있습니다.

테스트 이름 작성 규칙

테스트 이름은 "무엇을", "언제", "어떤 결과"의 구조로 작성하면 명확합니다. 예를 들어 "로그인 버튼 클릭 시 폼이 제출되어야 함"처럼 테스트의 의도가 명확히 드러나도록 작성합니다. 이렇게 하면 테스트가 실패했을 때 어떤 기능이 문제인지 즉시 파악할 수 있습니다.

실습해보기: Svelte REPL에서 이 코드를 직접 실행해보세요!


정리

Svelte 애플리케이션의 완전한 테스팅 전략을 마스터했습니다! 이제 다음과 같은 핵심 개념들을 이해했습니다:

핵심 요약

  • 단위 테스트: Vitest와 @testing-library/svelte로 컴포넌트와 비즈니스 로직을 빠르고 정확하게 테스트
  • 통합 테스트: Playwright로 실제 브라우저에서 E2E 테스트를 수행하여 사용자 시나리오 검증
  • 테스트 전략: 테스트 피라미드, 모킹, CI/CD 통합으로 효율적이고 신뢰성 있는 테스트 환경 구축

실무 활용 팁

  • 단위 테스트를 많이 작성하고 E2E 테스트는 핵심 플로우에만 집중
  • 테스트 작성 시 사용자 관점에서 생각하고 구현 세부사항보다는 행동에 집중
  • CI/CD 파이프라인에 테스트를 통합하여 자동화된 품질 보장 체계 구축

다음 단계: 18장 "배포"에서는 다양한 플랫폼에 Svelte 애플리케이션을 배포하는 방법을 알아보겠습니다. Vercel, Netlify, AWS 등 주요 플랫폼별 최적화 전략과 Docker 컨테이너화까지 마스터해보세요!