Testing Guide

PiSovereign maintains a comprehensive testing strategy across multiple layers to ensure reliability, correctness, and accessibility.


Test Architecture

┌──────────────────────────────────────────────┐
│          Playwright E2E Journeys             │  Real browser, live server
├──────────────────────────────────────────────┤
│       Vitest Component / Hook Tests          │  jsdom, mocked APIs
├──────────────────────────────────────────────┤
│         Vitest API / Store Tests             │  MSW-intercepted fetch
├──────────────────────────────────────────────┤
│       Rust Unit & Integration Tests          │  mockall, wiremock, axum-test
├──────────────────────────────────────────────┤
│           Rust Property Tests                │  proptest
└──────────────────────────────────────────────┘

Frontend Tests (Vitest)

Running Tests

# Run all unit / component / store tests
just web-test            # or: cd crates/presentation_web/frontend && npx vitest run

# Watch mode during development
npx vitest               # re-runs on file changes

# Coverage report
npx vitest run --coverage

Test File Conventions

CategoryLocationNaming
Component testsNext to component source*.test.tsx
Hook testssrc/hooks/*.test.ts
API module testssrc/api/*.test.ts
Store testssrc/stores/*.test.ts
Utility testssrc/utils/*.test.ts

Render Helpers

All components that use @solidjs/router primitives (useNavigate, useLocation, <A>) must be wrapped with the router-aware render helper:

import { renderWithProviders } from '@/test/render';

it('renders correctly', () => {
  renderWithProviders(() => <MyComponent />);
  expect(screen.getByText('Hello')).toBeInTheDocument();
});

renderWithProviders wraps the component in both <Router> with a catch-all <Route> (required by @solidjs/router v0.15+) and the <Toaster> provider. Components that don’t use router primitives can use plain render() from @solidjs/testing-library.

MSW (Mock Service Worker)

API calls are intercepted during tests via MSW. Default handlers live in src/test/mocks/handlers.ts and cover all API endpoints. Per-test overrides use server.use():

import { server } from '@/test/mocks/server';
import { http, HttpResponse } from 'msw';

it('handles error', async () => {
  server.use(
    http.get('/v1/my-endpoint', () =>
      HttpResponse.json({ error: 'fail' }, { status: 500 }),
    ),
  );
  // ... test error handling
});

jsdom Limitations

Some browser APIs are unavailable in jsdom and need polyfills:

  • IntersectionObserver — Stub via vi.stubGlobal:
    vi.stubGlobal('IntersectionObserver', class {
      observe() {}
      unobserve() {}
      disconnect() {}
    });
    
  • matchMedia — Already handled by the global test setup.
  • crypto.randomUUID — Already handled by the global test setup.

Known Patterns

  • The Button component uses aria-disabled instead of the native disabled attribute. Use toHaveAttribute('aria-disabled', 'true') instead of toBeDisabled().
  • Images with alt="" have role="presentation", not role="img". Query them with container.querySelector('img') instead of getByRole('img').

E2E Tests (Playwright)

Running Tests

# Run all E2E journeys (requires running backend on localhost:3000)
npx playwright test

# Run a specific journey
npx playwright test journeys/reminders.journey.spec.ts

# Show the test report
npx playwright show-report

# Debug mode (headed browser with inspector)
npx playwright test --debug

# List discovered tests
npx playwright test --list --reporter=list

Prerequisites

E2E tests require the full application stack running locally:

just docker-up       # Start all services
# Wait for the application to be healthy, then:
npx playwright test

Project Configuration

ProjectPurposeAuth
setupAuthenticates and saves sessionCreates
chromiumMain test suite (Desktop Chrome)Reuses
llmLLM-dependent tests (@llm tag)Reuses

Tests tagged @llm are excluded by default. Run them explicitly:

npx playwright test --project=llm

Journey Test Structure

Each journey spec follows a consistent pattern:

import { test, expect } from '../fixtures/auth.fixture';

test.describe('Feature Journey', () => {
  test('scenario name', async ({ page }) => {
    await test.step('navigate to page', async () => {
      await page.goto('/feature');
      await page.waitForLoadState('domcontentloaded');
    });

    await test.step('interact with UI', async () => {
      // Use role-based locators for accessibility
      await page.getByRole('button', { name: 'Action' }).click();
    });

    await test.step('verify result', async () => {
      await expect(page.getByText('Success')).toBeVisible();
    });
  });
});

Helper Utilities

  • e2e/helpers/form.helper.tsfillField(), clickButton(), expectToast(), expectModal(), closeModal(), testId()
  • e2e/helpers/navigation.helper.tsnavigateVia(), expectHeading(), waitForContentLoaded(), expectNoFatalError()

Bug Report Generation

Failed E2E tests automatically generate Markdown bug reports in bugreports/. Each report includes: test metadata, steps to reproduce, error details, and screenshots. This is powered by the custom BugreportReporter.

Locator Best Practices

  1. Prefer role-based locators: getByRole('button', { name: 'Save' })
  2. Use exact: true when button text is a substring of another (e.g., “Large” vs “X-Large”)
  3. Use regex for buttons with descriptions: Theme buttons include description text in their accessible name (e.g., “Dark Always dark theme”), so use getByRole('button', { name: /^Dark/ }) instead of { name: 'Dark', exact: true }
  4. Scope to sections when button names collide across different UI areas

Rust Tests

Running Tests

# All workspace tests
cargo test --workspace

# Specific crate
cargo test -p domain
cargo test -p application

# With output
cargo test --workspace -- --nocapture

Test Categories

TypeLocationTools
Unit tests#[cfg(test)] mod tests inlineassert!, mockall
Integrationcrates/*/tests/wiremock, axum-test
Property testscrates/*/tests/proptest_*.rsproptest

Mock Generation

Port traits are annotated for automatic mock generation:

#[cfg_attr(test, automock)]
#[async_trait]
pub trait MyPort: Send + Sync {
    async fn do_thing(&self, id: &str) -> Result<Thing, DomainError>;
}

In tests, create mocks via MockMyPort::new() and set expectations.


Coverage Targets

LayerTarget
Domain crate95–100%
Application crate90–100%
Integration crates85–95%
Frontend (Vitest)85–95%
E2E journeysAll pages

CI Integration

The full test suite runs as part of the mandatory workflow:

  1. just fmt + just web-fmt — Format check
  2. just lint — Clippy + ESLint
  3. cargo test --workspace — Rust tests
  4. npx vitest run — Frontend unit + component tests
  5. npx playwright test — E2E journeys
  6. cargo doc --workspace --no-deps — Doc generation

All steps must pass before committing.