Skip to content

Testing Patterns

This guide documents our established patterns for testing, using real examples from our codebase.

We organize tests following a feature-based structure, colocating test files with their implementation:

src/
components/
articleCard/
articleCard.tsx # Component implementation
articleCard.spec.ts # Unit tests
articleCard.stories.tsx # Storybook tests
transform.ts # Data transformation
transform.spec.ts # Transform unit tests
utils/
format/
formatDate.ts # Utility implementation
formatDate.spec.ts # Unit tests

🤔 Why This Pattern?

  • Tests live next to implementation
  • Clear test ownership
  • Easy test discovery
  • Simplified imports

Unit tests verify isolated pieces of functionality. We use Vitest for unit testing (migrated from Jest for better performance and ESM support).

We maintain a centralized test setup in vitest.setup.ts:

import "@testing-library/jest-dom";
import { cleanup } from "@solidjs/testing-library";
import { afterEach } from "vitest";
// Cleanup after each test
afterEach(() => {
cleanup();
});

And a shared test utilities file in src/test/test-utils.tsx:

import { render as solidRender } from "@solidjs/testing-library";
import { createRoot, JSX } from "solid-js";
let dispose: (() => void) | undefined;
export function render(component: () => JSX.Element) {
// Cleanup any previous render
if (dispose) dispose();
// Create a single root for the test
createRoot(d => {
dispose = d;
return solidRender(component)();
});
}
// Re-export everything else
export * from "@solidjs/testing-library";
utils/format/formatDate.spec.ts
import { describe, it, expect } from "vitest";
import { formatDate } from "./formatDate";
describe("formatDate", () => {
it("formats date according to locale", () => {
const date = new Date("2024-01-15");
expect(formatDate(date, "en-US")).toBe("January 15, 2024");
});
it("handles invalid dates", () => {
expect(formatDate(null, "en-US")).toBe("");
});
});
components/tooltip/tooltip.spec.tsx
import { describe, it, expect } from 'vitest';
import { render } from '../../test/test-utils';
import { Tooltip } from './tooltip';
describe('Tooltip', () => {
it('renders children content', () => {
render(() => (
<Tooltip content="Tooltip content">
<button>Hover me</button>
</Tooltip>
));
expect(screen.getByText('Hover me')).toBeInTheDocument();
expect(screen.getByText('Tooltip content')).toBeInTheDocument();
});
});
components/articleCard/transform.spec.ts
import { transformArticleData } from "./transform";
import type { ArticleQuery } from "./types";
describe("transformArticleData", () => {
it("transforms article data correctly", () => {
const mockData: ArticleQuery = {
article: {
title: "Test Article",
tags: [{ title: "Travel" }],
readTime: 5,
},
};
const result = transformArticleData(mockData);
expect(result).toEqual({
title: "Test Article",
meta: {
tags: ["Travel"],
readTime: 5,
},
});
});
it("handles missing data gracefully", () => {
const mockData: ArticleQuery = {
article: {
title: "Test Article",
tags: null,
readTime: null,
},
};
const result = transformArticleData(mockData);
expect(result.meta.tags).toEqual([]);
expect(result.meta.readTime).toBe(0);
});
});
pages/articles/[slug]/_graphql/api.spec.ts
import { describe, it, expect, vi } from "vitest";
import { getArticleBySlug } from "./api";
import { graphqlApi } from "#graphql/graphqlClient";
vi.mock("#graphql/graphqlClient");
describe("getArticleBySlug", () => {
it("returns transformed article data", async () => {
vi.mocked(graphqlApi).mockResolvedValue({
data: {
article: {
title: "Test",
content: "Content",
tags: [],
},
},
});
const result = await getArticleBySlug("test-slug");
expect(result).toBeTruthy();
expect(result.title).toBe("Test");
});
it("handles missing required data", async () => {
vi.mocked(graphqlApi).mockResolvedValue({
data: {
article: {
title: "Test",
// Missing content
tags: [],
},
},
});
const result = await getArticleBySlug("test-slug");
expect(result).toBeNull();
});
});

We use Storybook for component testing, focusing on behavior and accessibility.

components/articleCard/articleCard.stories.tsx
import { ArticleCard } from "./articleCard";
export default {
title: "Components/ArticleCard",
component: ArticleCard,
args: {
article: {
title: "Sample Article",
excerpt: "This is a sample article excerpt",
readTime: 5,
},
},
};
export const Default = {};
export const WithLongTitle = {
args: {
article: {
title:
"This is a very long article title that should wrap to multiple lines",
excerpt: "Sample excerpt",
readTime: 5,
},
},
};
export const Loading = {
args: {
isLoading: true,
},
};
export const InteractionTest = {
name: "Interaction Test (test)",
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await step("Click article card", async () => {
const card = canvas.getByRole("link");
await userEvent.click(card);
});
await step("Verify navigation", async () => {
// Verify navigation behavior
});
},
};

Each component story automatically runs accessibility checks:

.storybook/test-runner.ts
import { getStoryContext } from "@storybook/test-runner";
import { checkA11y } from "axe-playwright";
export const test = {
async preRender(page) {
await checkA11y(page, {
detailedReport: true,
detailedReportOptions: {
html: true,
},
});
},
};

Integration tests verify component interactions and data flow.

components/articleList/articleList.spec.ts
import { render, screen } from "@testing-library/solid";
import { ArticleList } from "./articleList";
describe("ArticleList", () => {
it("renders list of articles", () => {
const articles = [
{ id: 1, title: "Article 1" },
{ id: 2, title: "Article 2" }
];
render(() => <ArticleList articles={articles} />);
expect(screen.getAllByRole("article")).toHaveLength(2);
});
it("handles empty state", () => {
render(() => <ArticleList articles={[]} />);
expect(screen.getByText(/no articles/i)).toBeInTheDocument();
});
});

Contract testing verifies API interactions between frontend (consumer) and backend APIs (providers) using Pact. Tests define expected requests/responses without requiring the actual backend to run.

  • GraphQL API interactions (Rakiura BFF, CAPI temporary)
  • Defining expected request/response shapes
  • Catching breaking API changes before deployment
  • Enabling safe independent deployments
Terminal window
npm run test:contract # Run tests
npm run test:contract:watch # Watch mode
npm run pact:publish # Publish to broker (requires VPN)
npm run pact:can-i-deploy # Check deployment safety

Consumer: lp-frontend-astro
Providers: rakiura (target BFF), capi (temporary reference)
Co-location: Contract tests should be co-located with components/pages that use them

Purpose: Contracts serve as living documentation of expected API behavior that both frontend and backend teams can reference. They:

  • Enable pact:can-i-deploy to gate releases and prevent breaking changes
  • Let backend teams run pact:verify to catch breaking changes before deployment
  • Document the API shape the frontend expects (request/response structure, error handling)
  • Provide a reference for unit tests that mock the GraphQL layer—mocks should align with contract tests

Files:

  • Component/page-specific:
    • Pages: {queryName}.contract.spec.ts in src/pages/{route}/_graphql/ (index pages) or src/pages/{route}/[slug]/_graphql/ (detail pages)
    • Components: src/components/{component}/graphql/
  • Shared queries: src/graphql/queries/{feature}/ (used by multiple components/pages)
  • Reference examples: src/graphql/queries/example/ (learning patterns only)
  • Naming: {queryName}.query.api.graphql (Rakiura) or {queryName}.query.cms.graphql (Contentful)

Note: The _graphql/ directory lives at the same level as the page file. For articles/index.astro, use articles/_graphql/. For articles/[slug]/index.astro, use articles/[slug]/_graphql/.

Examples: 4 test files with 8 scenarios (single resource, lists, validation, redirects)

  • Complete guide: src/graphql/README.md
  • AI rules: .cursor/contract-testing.mdc
  • Examples: src/graphql/queries/example/ (reference patterns)

Not all tests provide equal value. Focus on testing behaviors that are most likely to break or that are core to your component’s functionality.

Basic Prop Rendering

// Low value: Just testing that the framework works
it("renders the title", () => {
render(() => <Card title="Card Title" />);
expect(screen.getByText("Card Title")).toBeInTheDocument();
});

Framework Functionality

// Low value: Testing that React/Solid.js/etc. works as designed
it("applies className prop", () => {
render(() => <Button className="primary" />);
expect(screen.getByRole("button")).toHaveClass("primary");
});

Component-Specific Logic

// High value: Tests conditional rendering logic specific to this component
it("shows error state when hasError is true", () => {
render(() => <FormField hasError={true} errorMessage="Invalid input" />);
expect(screen.getByText("Invalid input")).toBeVisible();
expect(screen.getByRole("textbox")).toHaveAttribute("aria-invalid", "true");
});

Interactive Behavior

// High value: Tests component's unique interactive behavior
it("expands dropdown when clicked", async () => {
render(() => <Dropdown options={["Option 1", "Option 2"]} />);
await userEvent.click(screen.getByRole("button"));
expect(screen.getByText("Option 1")).toBeVisible();
expect(screen.getByText("Option 2")).toBeVisible();
});

Edge Cases

// High value: Tests how component handles boundary conditions
it("displays fallback when image fails to load", async () => {
render(() => <Avatar src="invalid-url.jpg" fallback="JD" />);
// Simulate image load error
const img = screen.getByRole("img");
fireEvent.error(img);
expect(screen.getByText("JD")).toBeVisible();
expect(img).not.toBeVisible();
});

Accessibility

// High value: Tests critical accessibility features
it("maintains focus trap inside dialog", async () => {
render(() => <Dialog isOpen={true}>
<button>First</button>
<button>Last</button>
</Dialog>);
// Focus should cycle within the dialog
await userEvent.tab();
expect(screen.getByText("First")).toHaveFocus();
await userEvent.tab();
expect(screen.getByText("Last")).toHaveFocus();
await userEvent.tab();
expect(screen.getByText("First")).toHaveFocus();
});
  1. Test Organization

    • Colocate tests with implementation
    • Use descriptive test names
    • Group related tests using Vitest’s describe and it
    • Keep tests focused and isolated
  2. Test Setup

    • Use centralized vitest.setup.ts for global configuration
    • Leverage shared test utilities for common patterns
    • Handle cleanup properly after each test
    • Use type-safe component rendering
  3. Test Coverage

    • Test edge cases
    • Verify error states
    • Check accessibility
    • Test user interactions
  4. Test Maintenance

    • Use type-safe mocks
    • Avoid implementation details
    • Write readable assertions
    • Document complex tests
  5. Performance

    • Mock external dependencies
    • Use fast matchers
    • Optimize test setup
    • Clean up after tests

Don’t: Test implementation details

// Bad: Testing internal state
expect(component.internal.count).toBe(1);

Do: Test observable behavior

// Good: Testing user-facing output
expect(screen.getByText("Count: 1")).toBeInTheDocument();

Don’t: Write brittle tests

// Bad: Testing exact markup
expect(container.innerHTML).toBe("<div class='card'>...</div>");

Do: Test functionality

// Good: Testing functionality
expect(screen.getByRole("article")).toBeInTheDocument();

Don’t: Test framework functionality

// Bad: Testing that props work
expect(screen.getByText(props.title)).toBeInTheDocument();

Do: Test component-specific behavior

// Good: Testing component-specific behavior
expect(screen.getByLabelText("Next")).toBeDisabled();

When implementing tests, ask:

  1. Test Value

    • Does this test verify component-specific behavior?
    • Is this test focused on something that could reasonably break?
    • Does this test cover interaction or conditional logic?
    • Am I testing behavior instead of implementation details?
  2. Test Structure

    • Are tests colocated with code?
    • Is naming clear and consistent?
    • Are edge cases covered?
    • Is setup/teardown handled?
  3. Test Quality

    • Do tests verify behavior?
    • Are mocks type-safe?
    • Is accessibility tested?
    • Are errors handled?
  4. Test Maintenance

    • Are tests readable?
    • Is setup shared appropriately?
    • Are dependencies mocked?
    • Is cleanup automated?
  5. Test Performance

    • Are tests isolated?
    • Is setup optimized?
    • Are assertions efficient?
    • Is parallel execution possible?

We chose Vitest over Jest for the following reasons:

  • Native ESM support
  • Better performance and watch mode
  • Seamless integration with Vite
  • Compatible with existing Jest-like syntax
  • Built-in TypeScript support

When migrating tests from Jest:

  1. Update imports to use vitest instead of jest
  2. Replace any Jest-specific APIs with Vitest equivalents
  3. Update test configuration in vitest.config.ts
  4. Ensure proper cleanup in component tests
components/articleCard/articleCard.spec.ts
import { render } from '../../test/test-utils';
import { ArticleCard } from './articleCard';
describe('ArticleCard', () => {
it('renders correctly', () => {
render(() => <ArticleCard article={{ title: 'Test Article', excerpt: 'This is a sample article excerpt', readTime: 5 }} />);
expect(screen.getByText('Test Article')).toBeInTheDocument();
expect(screen.getByText('This is a sample article excerpt')).toBeInTheDocument();
});
});