Qwik City Testing Guide: @builder.io/qwik Testing Utilities (2026)

Qwik City Testing Guide: @builder.io/qwik Testing Utilities (2026)

Qwik's resumability model is fundamentally different from hydration. Components serialize their state into HTML, and execution resumes exactly where the server left off — no JavaScript re-execution, no reconciliation. That makes Qwik fast, but it also means your test setup needs to account for how Qwik actually runs code: lazy-loaded chunks, QRL serialization, and the boundary between server and client execution.

This guide covers the testing stack for Qwik City in 2026: Vitest with the Qwik testing utilities for components, direct testing of server$ functions, and Playwright for E2E flows.

Project Setup

Qwik City projects use Vite, so Vitest integrates naturally:

npm install -D vitest @builder.io/qwik @builder.io/qwik-city jsdom

Create vitest.config.ts:

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { qwikVite } from '@builder.io/qwik/optimizer';

export default defineConfig({
  plugins: [qwikVite()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test-setup.ts'],
  },
});
// src/test-setup.ts
import '@testing-library/jest-dom';

Testing Qwik Components

Use createDOM from @builder.io/qwik/testing to render components in a lightweight jsdom environment. It returns a render function and a screen query helper.

// src/components/button/button.tsx
import { component$, Slot, $ } from '@builder.io/qwik';

interface ButtonProps {
  variant?: 'primary' | 'secondary';
  disabled?: boolean;
  onClick$?: () => void;
}

export const Button = component$<ButtonProps>(({
  variant = 'primary',
  disabled = false,
  onClick$,
}) => {
  return (
    <button
      class={`btn btn--${variant}`}
      disabled={disabled}
      onClick$={onClick$}
    >
      <Slot />
    </button>
  );
});
// src/components/button/button.test.tsx
import { createDOM } from '@builder.io/qwik/testing';
import { describe, it, expect } from 'vitest';
import { Button } from './button';

describe('Button', () => {
  it('renders slot content', async () => {
    const { render, screen } = await createDOM();
    await render(<Button>Click me</Button>);
    expect(screen.querySelector('button')?.textContent).toContain('Click me');
  });

  it('applies the correct variant class', async () => {
    const { render, screen } = await createDOM();
    await render(<Button variant="secondary">Secondary</Button>);
    const button = screen.querySelector('button');
    expect(button?.className).toContain('btn--secondary');
  });

  it('renders as disabled when the prop is set', async () => {
    const { render, screen } = await createDOM();
    await render(<Button disabled>Disabled</Button>);
    const button = screen.querySelector<HTMLButtonElement>('button');
    expect(button?.disabled).toBe(true);
  });

  it('uses primary as the default variant', async () => {
    const { render, screen } = await createDOM();
    await render(<Button>Default</Button>);
    expect(screen.querySelector('button')?.className).toContain('btn--primary');
  });
});

createDOM sets up a minimal Qwik container. You must await render(...) since Qwik rendering is asynchronous even in tests.

Testing Signals and Stores

// src/components/counter/counter.tsx
import { component$, useSignal } from '@builder.io/qwik';

export const Counter = component$(() => {
  const count = useSignal(0);

  return (
    <div>
      <span data-testid="count">{count.value}</span>
      <button onClick$={() => count.value--}>-</button>
      <button onClick$={() => count.value++}>+</button>
      <button onClick$={() => (count.value = 0)}>Reset</button>
    </div>
  );
});
// src/components/counter/counter.test.tsx
import { createDOM } from '@builder.io/qwik/testing';
import { describe, it, expect } from 'vitest';
import { Counter } from './counter';

describe('Counter', () => {
  it('starts at zero', async () => {
    const { render, screen } = await createDOM();
    await render(<Counter />);
    expect(screen.querySelector('[data-testid="count"]')?.textContent).toBe('0');
  });

  it('increments when the + button is clicked', async () => {
    const { render, screen, userEvent } = await createDOM();
    await render(<Counter />);
    const incrementButton = screen.querySelector('button:last-of-type') as HTMLButtonElement;
    await userEvent('.btn', 'click');
    // Use the correct button selector
    const buttons = screen.querySelectorAll('button');
    await userEvent(buttons[1], 'click'); // + button
    expect(screen.querySelector('[data-testid="count"]')?.textContent).toBe('1');
  });

  it('decrements below zero', async () => {
    const { render, screen, userEvent } = await createDOM();
    await render(<Counter />);
    const buttons = screen.querySelectorAll('button');
    await userEvent(buttons[0], 'click'); // - button
    expect(screen.querySelector('[data-testid="count"]')?.textContent).toBe('-1');
  });
});

Testing server$ Functions

server$ functions run exclusively on the server. Test them directly as async functions — no browser or Qwik runtime needed.

// src/lib/db.server.ts
import { server$ } from '@builder.io/qwik-city';

export interface Product {
  id: string;
  name: string;
  price: number;
  inStock: boolean;
}

// In production, this queries a database
const products: Product[] = [
  { id: '1', name: 'Widget A', price: 29.99, inStock: true },
  { id: '2', name: 'Widget B', price: 49.99, inStock: false },
  { id: '3', name: 'Widget C', price: 14.99, inStock: true },
];

export const getProducts = server$(async function () {
  return products.filter((p) => p.inStock);
});

export const getProductById = server$(async function (id: string) {
  const product = products.find((p) => p.id === id);
  if (!product) throw new Error(`Product ${id} not found`);
  return product;
});

export const calculateDiscount = (price: number, discountPercent: number): number => {
  if (discountPercent < 0 || discountPercent > 100) {
    throw new Error('Discount must be between 0 and 100');
  }
  return Math.round(price * (1 - discountPercent / 100) * 100) / 100;
};
// src/lib/db.server.test.ts
import { describe, it, expect } from 'vitest';
import { calculateDiscount } from './db.server';

// Test pure server-side logic directly — no Qwik runtime needed
describe('calculateDiscount', () => {
  it('applies a 10% discount correctly', () => {
    expect(calculateDiscount(100, 10)).toBe(90);
  });

  it('applies a 50% discount correctly', () => {
    expect(calculateDiscount(29.99, 50)).toBe(15);
  });

  it('returns the original price for 0% discount', () => {
    expect(calculateDiscount(49.99, 0)).toBe(49.99);
  });

  it('throws for negative discount values', () => {
    expect(() => calculateDiscount(100, -5)).toThrow('Discount must be between 0 and 100');
  });

  it('throws for discount values above 100', () => {
    expect(() => calculateDiscount(100, 101)).toThrow('Discount must be between 0 and 100');
  });
});

For server$ functions that make database calls, mock the database client and test the function logic:

// src/lib/users.server.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';

// Mock the database module before importing the server function
vi.mock('./database', () => ({
  db: {
    users: {
      findUnique: vi.fn(),
      create: vi.fn(),
    },
  },
}));

import { db } from './database';
import { createUser, getUserByEmail } from './users.server';

describe('getUserByEmail', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('returns the user when found', async () => {
    vi.mocked(db.users.findUnique).mockResolvedValueOnce({
      id: '1',
      email: 'alice@example.com',
      name: 'Alice',
    });

    const user = await getUserByEmail('alice@example.com');
    expect(user?.name).toBe('Alice');
    expect(db.users.findUnique).toHaveBeenCalledWith({
      where: { email: 'alice@example.com' },
    });
  });

  it('returns null when user is not found', async () => {
    vi.mocked(db.users.findUnique).mockResolvedValueOnce(null);
    const user = await getUserByEmail('notfound@example.com');
    expect(user).toBeNull();
  });
});

Testing Route Loaders

Qwik City route loaders (routeLoader$) run on the server per request. Test the underlying logic by extracting it into testable functions.

// src/routes/products/index.tsx
import { routeLoader$ } from '@builder.io/qwik-city';

// Extract the logic into a testable pure function
export async function loadProducts(category?: string) {
  const products = await fetchProductsFromDB(category);
  return {
    products,
    total: products.length,
    category: category ?? 'all',
  };
}

export const useProductsLoader = routeLoader$(async (reqEvent) => {
  const category = reqEvent.query.get('category') ?? undefined;
  return loadProducts(category);
});
// src/routes/products/index.test.ts
import { describe, it, expect, vi } from 'vitest';
import { loadProducts } from './index';

vi.mock('./db', () => ({
  fetchProductsFromDB: vi.fn(async (category?: string) => {
    if (category === 'widgets') {
      return [{ id: '1', name: 'Widget A', category: 'widgets' }];
    }
    return [
      { id: '1', name: 'Widget A', category: 'widgets' },
      { id: '2', name: 'Gadget B', category: 'gadgets' },
    ];
  }),
}));

describe('loadProducts', () => {
  it('returns all products when no category is specified', async () => {
    const result = await loadProducts();
    expect(result.products).toHaveLength(2);
    expect(result.total).toBe(2);
    expect(result.category).toBe('all');
  });

  it('returns filtered products for a specific category', async () => {
    const result = await loadProducts('widgets');
    expect(result.products).toHaveLength(1);
    expect(result.category).toBe('widgets');
  });
});

Playwright E2E for Qwik City

Component tests verify individual parts. Playwright tests verify full page flows, routing, resumability, and lazy loading in a real browser.

npm install -D @playwright/test
npx playwright install chromium
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  webServer: {
    command: 'npm run preview',
    port: 4173,
    reuseExistingServer: !process.env.CI,
  },
  use: {
    baseURL: 'http://localhost:4173',
  },
});
// e2e/products.test.ts
import { test, expect } from '@playwright/test';

test.describe('product listing page', () => {
  test('loads products on the initial page render (no JS required)', async ({ page }) => {
    // Qwik sends HTML with data — products should be visible before any JS runs
    await page.goto('/products');
    const items = page.locator('[data-testid="product-item"]');
    await expect(items).toHaveCount({ min: 1 });
  });

  test('filtering by category updates the product list', async ({ page }) => {
    await page.goto('/products');
    await page.selectOption('[data-testid="category-filter"]', 'widgets');
    // Qwik resumes and handles the interaction
    await expect(page).toHaveURL(/category=widgets/);
    const items = page.locator('[data-testid="product-item"]');
    await expect(items.first()).toContainText('Widget');
  });

  test('navigating to a product detail page works', async ({ page }) => {
    await page.goto('/products');
    await page.locator('[data-testid="product-item"]').first().click();
    await expect(page).toHaveURL(/\/products\/.+/);
    await expect(page.locator('h1')).toBeVisible();
  });
});

What Tests Won't Catch

  • A QRL serialization error that only manifests on a specific combination of server and client state
  • Resumability breaking because a component references a non-serializable value (e.g., a class instance)
  • Edge function cold starts causing the first server$ call to time out
  • A lazy chunk failing to load in production due to a CDN path misconfiguration
  • Core Web Vitals regressions after adding a large dependency that delays Time to Interactive

Monitoring Qwik City Apps in Production with HelpMeTest

Install HelpMeTest:

curl -fsSL https://helpmetest.com/install | bash

Write plain-English tests that run against your deployed app:

Go to https://myqwikapp.com/products
Verify at least one product is visible on the page
Click the first product item
Verify the product detail page is visible with a title
Go back to the products page
Select "widgets" from the category dropdown
Verify the page updates to show only widget products

HelpMeTest runs these every few minutes in a real browser. If your Qwik app breaks in production — a server$ function starts failing, a lazy chunk fails to load, routing breaks after a deploy — you know immediately.

Free tier: 10 tests, unlimited health checks. Pro: $100/month for unlimited tests and parallel execution.

Your Vitest tests prove the components and server logic are correct. Playwright proves the resumability and routing work. HelpMeTest proves the deployed app is working right now.


Start at helpmetest.com — free tier, no credit card.

Read more