Testing Qwik Components: From Unit to E2E Tests
Qwik is a resumable, performance-first framework that ships near-zero JavaScript to the client by default. While its lazy-loading and fine-grained reactivity model make it incredibly fast, they also introduce unique testing challenges. Components are not just rendered HTML — they are serialized state machines that can pause and resume execution. This tutorial walks you through the full testing pyramid for Qwik applications, from isolated unit tests to full end-to-end (E2E) flows.
Why Testing Qwik Components Matters
Qwik's resumability model means that the same component can render on the server, serialize its state into the DOM, and then resume on the client without re-executing the component. This dual execution context creates subtle bugs that traditional React or Vue testing strategies may not catch. A proper test suite ensures:
- Components render correctly in both SSR and CSR contexts.
- State serialization and resumability work as expected.
- Event handlers, which are lazily attached, fire correctly after hydration.
- Integration with Qwik City routing and loaders behaves as intended.
- Visual regressions are caught before they reach production.
Setting Up the Testing Environment
Qwik projects created with the official starter come pre-configured with Vitest for unit testing and Playwright for E2E testing. If you are adding tests to an existing project, install the necessary dependencies:
npm install -D vitest @vitest/ui jsdom @playwright/test
Create a vitest.config.ts file at the root of your project:
import { defineConfig } from 'vitest/config';
import { qwikVite } from '@builder.io/qwik/optimizer';
import { qwikCity } from '@builder.io/qwik-city/vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig(() => {
return {
plugins: [qwikCity(), qwikVite(), tsconfigPaths()],
test: {
globals: true,
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
},
},
};
});
The key here is including the qwikVite and qwikCity plugins so that the Qwik JSX transform and optimizer run during tests. Without these, your component imports will fail to compile.
Unit Testing Qwik Components
Unit tests focus on isolated pieces of logic — a single component, a pure function, or a Qwik signal. Qwik provides a testing utility called component$ testing via @builder.io/qwik/testing, but in most cases you can render components directly using @testing-library/dom or @testing-library/preact (which is compatible with Qwik's JSX output).
Testing a Simple Component
Consider a basic counter component:
// src/components/counter/counter.tsx
import { component$, useSignal } from '@builder.io/qwik';
export const Counter = component$(() => {
const count = useSignal(0);
return (
<div>
<p data-testid="count">Count: {count.value}</p>
<button data-testid="increment" onClick$={() => count.value++}>
Increment
</button>
</div>
);
});
Here is the corresponding unit test:
// src/components/counter/counter.test.tsx
import { describe, it, expect } from 'vitest';
import { render } from '@builder.io/qwik/testing';
import { screen, fireEvent } from '@testing-library/dom';
import { Counter } from './counter';
describe('Counter', () => {
it('renders initial count', async () => {
await render(<Counter />);
expect(screen.getByTestId('count')).toHaveTextContent('Count: 0');
});
it('increments count on button click', async () => {
await render(<Counter />);
const button = screen.getByTestId('increment');
await fireEvent.click(button);
expect(screen.getByTestId('count')).toHaveTextContent('Count: 1');
});
});
Notice that render is asynchronous. Qwik components must be rendered through the framework's rendering pipeline, which involves scheduling and flushing effects. The @builder.io/qwik/testing package wraps this complexity so you get a clean, promise-based API.
Testing Signals and State
Qwik signals are the backbone of reactivity. You can test signal-based logic in isolation without rendering a component:
// src/utils/use-cart.test.ts
import { describe, it, expect } from 'vitest';
import { useSignal, useStore } from '@builder.io/qwik';
import { createDOM } from '@builder.io/qwik/testing';
describe('cart store', () => {
it('adds items to the store', async () => {
const { render } = await createDOM();
await render(() => {
const cart = useStore({ items: [] as string[] });
cart.items.push('apple');
expect(cart.items).toHaveLength(1);
});
});
});
For more complex signal interactions, prefer testing through the component's rendered output rather than poking at internal signals directly. This keeps your tests resilient to refactoring.
Testing Props and Conditional Rendering
// src/components/greeting/greeting.tsx
import { component$ } from '@builder.io/qwik';
interface GreetingProps {
name: string;
isLoggedIn?: boolean;
}
export const Greeting = component$(({ name, isLoggedIn = false }: GreetingProps) => {
return (
<div>
{isLoggedIn ? (
<p data-testid="welcome">Welcome back, {name}!</p>
) : (
<p data-testid="hello">Hello, {name}! Please log in.</p>
)}
</div>
);
});
// src/components/greeting/greeting.test.tsx
import { describe, it, expect } from 'vitest';
import { render } from '@builder.io/qwik/testing';
import { screen } from '@testing-library/dom';
import { Greeting } from './greeting';
describe('Greeting', () => {
it('shows login prompt when not logged in', async () => {
await render(<Greeting name="Alice" />);
expect(screen.getByTestId('hello')).toHaveTextContent(
'Hello, Alice! Please log in.'
);
});
it('shows welcome message when logged in', async () => {
await render(<Greeting name="Alice" isLoggedIn />);
expect(screen.getByTestId('welcome')).toHaveTextContent(
'Welcome back, Alice!'
);
});
});
Integration Testing with Qwik City
Integration tests verify that multiple components work together, often within the context of a route. Qwik City provides a routeLoader$ and routeAction$ API that you will want to test in conjunction with your page components.
Testing Route Loaders
// src/routes/products/index.tsx
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
export const useProducts = routeLoader$(async () => {
const res = await fetch('https://api.example.com/products');
return res.json();
});
export default component$(() => {
const products = useProducts();
return (
<ul data-testid="product-list">
{products.value.map((p: any) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
});
To test this, you need to mock the global fetch and render the component within a Qwik City context. The @builder.io/qwik-city/testing utilities help with this:
// src/routes/products/index.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render } from '@builder.io/qwik/testing';
import { screen } from '@testing-library/dom';
import ProductPage from './index';
describe('Products page', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
json: () => Promise.resolve([
{ id: 1, name: 'Widget' },
{ id: 2, name: 'Gadget' },
]),
}));
});
it('renders a list of products from the loader', async () => {
await render(<ProductPage />);
const list = await screen.findByTestId('product-list');
expect(list.children).toHaveLength(2);
expect(list.textContent).toContain('Widget');
expect(list.textContent).toContain('Gadget');
});
});
Testing Route Actions
Route actions handle form submissions and mutations. Testing them involves simulating user input and form submission:
// src/routes/contact/index.tsx
import { component$ } from '@builder.io/qwik';
import { routeAction$, Form } from '@builder.io/qwik-city';
export const useSubmitContact = routeAction$(async (data) => {
// In a real app, send to an API
return { success: true, name: data.name };
});
export default component$(() => {
const action = useSubmitContact();
return (
<Form action={action}>
<input name="name" data-testid="name-input" />
<button type="submit" data-testid="submit">Send</button>
{action.value?.success && (
<p data-testid="success">Thanks, {action.value.name}!</p>
)}
</Form>
);
});
// src/routes/contact/index.test.tsx
import { describe, it, expect } from 'vitest';
import { render } from '@builder.io/qwik/testing';
import { screen, fireEvent } from '@testing-library/dom';
import ContactPage from './index';
describe('Contact page', () => {
it('shows success message after form submission', async () => {
await render(<ContactPage />);
const input = screen.getByTestId('name-input') as HTMLInputElement;
const submit = screen.getByTestId('submit');
input.value = 'Bob';
await fireEvent.input(input);
await fireEvent.click(submit);
const success = await screen.findByTestId('success');
expect(success).toHaveTextContent('Thanks, Bob!');
});
});
End-to-End Testing with Playwright
E2E tests run against a real browser and a running dev or preview server. They are the most realistic tests you can write, covering the full stack from browser to server. Qwik's starter includes Playwright out of the box.
Playwright Configuration
Ensure your playwright.config.ts points to your preview server:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
use: {
baseURL: 'http://localhost:4173',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: {
command: 'npm run preview',
url: 'http://localhost:4173',
reuseExistingServer: !process.env.CI,
},
});
Writing Your First E2E Test
// e2e/counter.spec.ts
import { test, expect } from '@playwright/test';
test('counter increments when clicked', async ({ page }) => {
await page.goto('/');
await expect(page.getByTestId('count')).toHaveText('Count: 0');
await page.getByTestId('increment').click();
await expect(page.getByTestId('count')).toHaveText('Count: 1');
await page.getByTestId('increment').click();
await expect(page.getByTestId('count')).toHaveText('Count: 2');
});
Testing Navigation and Routing
// e2e/navigation.spec.ts
import { test, expect } from '@playwright/test';
test('user can navigate from home to about', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'About' }).click();
await expect(page).toHaveURL('/about/');
await expect(page.getByRole('heading', { name: 'About Us' })).toBeVisible();
});
test('404 page shows for unknown routes', async ({ page }) => {
await page.goto('/nonexistent-route/');
await expect(page.getByText('Page not found')).toBeVisible();
});
Testing Form Flows End-to-End
// e2e/contact.spec.ts
import { test, expect } from '@playwright/test';
test('contact form submission flow', async ({ page }) => {
await page.goto('/contact/');
await page.getByTestId('name-input').fill('Alice');
await page.getByTestId('submit').click();
await expect(page.getByTestId('success')).toHaveText('Thanks, Alice!');
});
Testing Resumability Specifically
One of Qwik's key selling points is resumability — the browser does not need to re-execute component code on hydration. You can write a test that verifies no unnecessary JavaScript is shipped by intercepting script requests:
// e2e/resumability.spec.ts
import { test, expect } from '@playwright/test';
test('page loads with minimal JS bundles', async ({ page }) => {
const scripts: string[] = [];
page.on('response', (response) => {
if (response.url().endsWith('.js')) {
scripts.push(response.url());
}
});
await page.goto('/');
// The initial page should ship very few JS chunks
expect(scripts.length).toBeLessThan(5);
});
Best Practices
1. Test Behavior, Not Implementation
Avoid asserting on internal signals or private functions. Instead, interact with the component the way a user would — click buttons, fill inputs, and read rendered text. This makes your tests more durable across refactors.
2. Use data-testid Attributes Sparingly
Prefer semantic queries like getByRole or getByLabelText in Playwright. Reserve data-testid for elements that have no accessible name or are difficult to target otherwise.
3. Mock External Dependencies at the Boundary
Mock fetch calls, environment variables, and third-party APIs at the edge of your application. Do not mock Qwik internals or your own component logic — that defeats the purpose of the test.
4. Keep Unit Tests Fast and Isolated
Unit tests should run in milliseconds. If a test requires network access or file system reads, it belongs in the integration or E2E layer. Use Vitest's vi.mock and vi.stubGlobal to keep unit tests hermetic.
5. Run E2E Tests in CI on Every Pull Request
Configure your CI pipeline to build the project, start the preview server, and run the full Playwright suite. Use Playwright's sharding feature to parallelize across multiple CI runners for faster feedback.
6. Test Both SSR and Client-Side Rendering
Qwik components render on the server first. Make sure your tests account for this by verifying that initial HTML output is correct before any client-side JavaScript runs. Playwright's page.goto waits for the initial load, and you can use page.waitForLoadState('domcontentloaded') to assert on pre-hydration state.
7. Snapshot Test with Caution
Snapshot tests can catch unexpected UI changes, but they are brittle in Qwik because serialized state and lazy-loaded chunks can produce non-deterministic output. If you use snapshots, scope them narrowly to specific component outputs rather than entire pages.
Conclusion
Testing Qwik components requires a slightly different mindset than testing React or Vue components, primarily because of Qwik's resumability model and lazy event handler attachment. By leveraging Vitest for fast unit tests, the Qwik testing utilities for component rendering, and Playwright for comprehensive E2E coverage, you can build a robust test suite that catches bugs across the entire rendering lifecycle — from server serialization to client-side interaction. Start with unit tests for individual components and pure logic, layer in integration tests for routes and loaders, and cap everything off with E2E tests that exercise real user journeys in a real browser. With these tools and practices in place, you can ship Qwik applications with confidence, knowing that your resumable components behave correctly in every context they encounter.