Introduction
End-to-end tests for SvelteKit represent a critical layer in your testing pyramid, catching real user flows that unit and integration tests miss. After 40 days of building robust Svelte components and SvelteKit applications, it's time to lock down the entire system with tests that verify users can actually accomplish their goals. This guide covers end-to-end tests for SvelteKit: best practices explained through real production patterns we've learned the hard way.
Playwright is the default choice
For SvelteKit projects, Playwright has become the industry standard and it ships as the default testing framework when you scaffold a new project. Unlike Cypress, which runs in the same process as your application, Playwright runs as a separate process that controls a real browser instance. This matters because it gives you true end-to-end coverage—your tests run against the actual runtime your users experience, not a simulation.
Playwright supports Chromium, Firefox, and WebKit out of the box, letting you catch cross-browser issues during development rather than after deployment. For most teams, testing against Chromium is sufficient for CI/CD pipelines, but periodically running Firefox and WebKit locally catches rendering quirks early.
Setting up Playwright in a new SvelteKit project takes seconds:
npm create svelte@latest my-app -- --typescript --playwright
cd my-app && npm installThis scaffolds a tests/ directory with example test files and a playwright.config.ts configuration. The default config already handles many concerns: it sets up a test server, configures base URLs, and disables browser sandboxing in CI environments.
Run against the built app
A common mistake we see is running tests against the development server. Your dev server has hot module reloading, sourcemaps, and development-only middleware that your production build doesn't include. When you test only against dev mode, you miss bugs that only surface in production—lazy loading issues, CSS-in-JS compilation problems, or incorrect build-time optimizations.
The right approach is to build your SvelteKit app first, then run tests against the built output using SvelteKit's built-in preview server. Playwright's configuration handles this elegantly:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
webServer: {
command: 'npm run build && npm run preview',
port: 5173,
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: 'http://localhost:5173',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
With this setup, your CI pipeline runs npm run build automatically, then spins up the preview server and runs tests against real built output. The reuseExistingServer flag speeds up local development—you build once, then iterate on tests without rebuilding.
For local development, many teams keep the preview server running in one terminal and run tests in another, cutting iteration time dramatically.
Test forms with and without JavaScript
SvelteKit's form handling spans both client-side and server-side concerns. Your forms should work when JavaScript fails—network timeout, script parse error, or user has JavaScript disabled. Testing both scenarios catches this.
Let's say you have a contact form that uses SvelteKit's form actions. A form action is a server-side function that processes form submissions:
// src/routes/contact/+page.server.ts
import { fail } from '@sveltejs/kit';
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
const email = data.get('email');
const message = data.get('message');
if (!email || !message) {
return fail(400, { email, message, missing: true });
}
// Send email, validate, etc.
await sendEmail(email, message);
return { success: true };
},
};
Test this form both with JavaScript enabled and disabled. Here's a practical example:
// tests/forms.spec.ts
import { test, expect } from '@playwright/test';
test('contact form submits with JavaScript enabled', async ({ page }) => {
await page.goto('/contact');
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('textarea[name="message"]', 'Hello world');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/contact?success=true');
await expect(page.locator('text=Message sent')).toBeVisible();
});
test('contact form submits without JavaScript', async ({ page }) => {
// Disable JavaScript execution
await page.context().setExtraHTTPHeaders({});
await page.goto('/contact');
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('textarea[name="message"]', 'Hello world');
await page.click('button[type="submit"]');
// Form should still submit via traditional POST
await expect(page).toHaveURL('/contact');
await expect(page.locator('text=Message sent')).toBeVisible();
});
For the no-JavaScript test, you disable client-side script execution. This forces the browser to fall back to traditional form submission. If your form action works correctly, users without JavaScript still get a functional experience.
This matters more than it sounds—bad deployments, corporate firewalls, or network errors can prevent scripts from loading. Graceful degradation catches these scenarios.
Mock external APIs
End-to-end tests should be fast and reliable. If your tests hit real payment APIs, email services, or third-party databases, they become slow, flaky, and expensive. The solution is to mock external APIs using Playwright's network interception features.
Playwright lets you intercept HTTP requests and respond with mock data:
// tests/checkout.spec.ts
import { test, expect } from '@playwright/test';
test('checkout flow with mocked payment API', async ({ page }) => {
// Mock the payment processor API
await page.route('https://api.payment-processor.com/**', route => {
if (route.request().method() === 'POST') {
route.abort('blockedbyclient');
}
});
// Mock successful charge response
await page.route('**/api/charge', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
transactionId: 'txn_123456',
}),
});
});
await page.goto('/checkout');
await page.fill('input[name="cardNumber"]', '4242 4242 4242 4242');
await page.fill('input[name="cardExpiry"]', '12/25');
await page.click('button:has-text("Pay")');
await expect(page).toHaveURL('/order-confirmation');
await expect(page.locator('text=Order confirmed')).toBeVisible();
});
By mocking the payment API, your test runs instantly and never charges a real card. You also test the exact response your application expects. If the payment processor API changes, your mock stays stable, making tests reliable.
For APIs your app calls from the backend (server load functions), mock them using environment variables or a test-mode flag:
// src/lib/server/paymentClient.ts
export async function chargeCard(amount: number) {
const apiUrl = process.env.PAYMENT_API_URL || 'https://api.payment-processor.com';
const response = await fetch(`${apiUrl}/charge`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${process.env.PAYMENT_API_KEY}` },
body: JSON.stringify({ amount }),
});
return response.json();
}
In your test environment, set PAYMENT_API_URL to a local mock server, or use a service like Mockoon that runs locally and responds with realistic data.
Common mistakes
We've seen several patterns that slow teams down. First, not waiting for network requests: after clicking a button that triggers a server call, always wait for the navigation or response before asserting. Use page.waitForURL() or page.waitForLoadState() to ensure the page has finished loading.
Second, testing implementation details: don't query by data-testid attributes unless necessary. Query by visible text or role whenever possible. If your selectors are too brittle, tests break when you refactor UI without changing behavior.
Third, flaky waits: avoid hardcoded sleeps like await page.waitForTimeout(2000). Instead, wait for elements or network idle states. This makes tests faster on powerful machines and more reliable on slower CI runners.
Fourth, not testing error cases: mock API failures, network timeouts, and validation errors. Test that your application handles these gracefully. Users will encounter these states in production.
Key takeaways
- Playwright is the default for SvelteKit projects; it controls a real browser instance and supports multiple browsers.
- Always run tests against the built application, not the dev server, to catch production-only bugs.
- Test both with and without JavaScript enabled to ensure graceful degradation and form action reliability.
- Mock external APIs to keep tests fast, reliable, and cost-effective; use environment variables or Playwright's route interception.
- Wait for network requests and page state changes explicitly instead of using arbitrary timeouts.
- Query by visible text and role instead of brittle selectors to keep tests maintainable as UI evolves.






Comments
Loading comments…