| name | frontend-testing |
| description | Use when writing, editing, or reviewing frontend tests in the Nocturne Web app (src/Web/packages/app). Covers vitest-browser-svelte component tests, Node unit tests, and Playwright e2e tests. Triggers on .test.ts, .svelte.test.ts, vitest, test patterns, testing questions. |
Frontend Testing Skill
Unbreakable Rules
- Always use locators (
page.getBy*()) in browser tests - never containers from vitest-browser-svelte
- Use
.first(), .nth(), .last() for multiple elements to avoid strict mode violations
- Always use
untrack() when accessing $derived values in tests
- Use real FormData/Request objects in server tests - minimal mocking only
- Co-locate tests with components (
.svelte.test.ts for browser, .test.ts for node)
- Follow naming conventions: kebab-case files, snake_case variables
- Start tests with "Foundation First" approach using
.skip blocks for planning
- Never click SvelteKit form submit buttons in browser tests - test state directly
- Use
await expect.element() for all locator assertions in browser tests
- Never mock the API client - use remote functions as the testing boundary
Test Architecture
src/Web/packages/app/
├── vitest.workspace.ts # Loads both configs
├── vitest.config.ts # Node environment (unit + server tests)
├── vitest.browser.config.ts # Chromium via Playwright (component tests)
├── src/
│ ├── lib/components/
│ │ ├── my-component.svelte
│ │ ├── my-component.svelte.test.ts # Browser component test
│ │ └── my-component.test.ts # Node unit test (if needed)
│ └── lib/server/
│ └── auth.test.ts # Server-side unit test
└── e2e/
└── demo.test.ts # Playwright e2e test
File naming
| Test type | Suffix | Runner | When to use |
|---|
| Browser component | .svelte.test.ts | vitest-browser-svelte + Chromium | Testing rendered Svelte components |
| Node unit | .test.ts | vitest (Node/happy-dom) | Pure logic, server code, utilities |
| E2E | e2e/*.test.ts | Playwright | Full user workflows across pages |
Commands
cd src/Web/packages/app
pnpm test:unit
pnpm test:browser
pnpm test:e2e
pnpm test
pnpm lint
Browser Component Tests (.svelte.test.ts)
Basic pattern
import { render } from "vitest-browser-svelte";
import { page } from "@vitest/browser/context";
import { describe, it, expect } from "vitest";
import MyComponent from "./my-component.svelte";
describe("MyComponent", () => {
it("renders the title", async () => {
render(MyComponent, { props: { title: "Hello" } });
await expect.element(page.getByText("Hello")).toBeVisible();
});
it("handles multiple matching elements", async () => {
render(MyComponent, { props: { items: ["a", "b", "c"] } });
await expect.element(page.getByRole("listitem").first()).toBeVisible();
});
});
Locator priority (prefer top to bottom)
page.getByRole("button", { name: "Submit" }) - accessible role + name
page.getByLabel("Email") - form inputs
page.getByText("Hello") - visible text
page.getByTestId("chart") - last resort
Testing reactive state with $derived
import { untrack } from "svelte";
import { flushSync } from "svelte";
it("updates derived value", async () => {
const result = render(MyComponent, { props: { count: 0 } });
const value = untrack(() => result.component.derivedValue);
expect(value).toBe(0);
flushSync();
});
What NOT to do in browser tests
const { container } = render(MyComponent);
container.querySelector(".my-class");
page.getByRole("button", { name: "Save" });
await page.getByRole("button", { type: "submit" }).click();
await expect.element(page.getByText("Saved")).toBeVisible();
expect(page.getByText("Hello")).toBeVisible();
await expect.element(page.getByText("Hello")).toBeVisible();
Node Unit Tests (.test.ts)
Server-side testing with real objects
import { describe, it, expect } from "vitest";
it("parses form data correctly", async () => {
const formData = new FormData();
formData.set("email", "test@example.com");
formData.set("name", "Test User");
const request = new Request("http://localhost/api/submit", {
method: "POST",
body: formData,
});
const result = await parseSubmission(request);
expect(result.email).toBe("test@example.com");
});
What to mock, what not to mock
| Mock | Don't mock |
|---|
| External HTTP services | FormData, Request, Response |
| Time/dates (vi.useFakeTimers) | Zod schemas |
| Random values | URL parsing |
Never mock the Nocturne API client (apiClient). Tests that need API data should either:
- Test the component with pre-resolved props (browser tests)
- Test the remote function's transformation logic in isolation (node tests)
Foundation First Approach
When writing a new test file, start by planning coverage with .skip blocks:
describe("AlarmProfile", () => {
it.skip("renders default alarm thresholds");
it.skip("validates high threshold > low threshold");
it.skip("saves profile on form submission");
it.skip("shows error for invalid time range");
it.skip("disables save button when form is invalid");
});
Then implement one test at a time, removing .skip as you go. This prevents scope creep and ensures comprehensive coverage before writing any test logic.
Path Aliases
All test configs share these aliases:
| Alias | Path |
|---|
$lib | src/lib |
$api | src/lib/api |
$api-clients | src/lib/api-clients |
$routes | src/routes |
Checklist
Before finishing any testing task:
- Tests use locators, not container queries (browser tests)
- Multiple-element locators use
.first() / .nth() / .last()
- All locator assertions use
await expect.element()
- No mocked API client - remote functions are the boundary
- Tests are co-located with the code they test
pnpm lint passes after changes