Testing Patterns
This guide documents our established patterns for testing, using real examples from our codebase.
Test Organization
Section titled “Test Organization”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 Testing
Section titled “Unit Testing”Unit tests verify isolated pieces of functionality. We use Vitest for unit testing (migrated from Jest for better performance and ESM support).
Testing Setup
Section titled “Testing Setup”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 testafterEach(() => { 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 elseexport * from "@solidjs/testing-library";Testing Pure Functions
Section titled “Testing Pure Functions”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(""); });});Testing Components
Section titled “Testing Components”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(); });});Testing Data Transformations
Section titled “Testing Data Transformations”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); });});Testing GraphQL Operations
Section titled “Testing GraphQL Operations”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(); });});Component Testing
Section titled “Component Testing”We use Storybook for component testing, focusing on behavior and accessibility.
Component Stories
Section titled “Component Stories”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 }); },};Accessibility Testing
Section titled “Accessibility Testing”Each component story automatically runs accessibility checks:
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 Testing
Section titled “Integration Testing”Integration tests verify component interactions and data flow.
Testing Component Composition
Section titled “Testing Component Composition”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
Section titled “Contract Testing”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.
When to Use
Section titled “When to Use”- GraphQL API interactions (Rakiura BFF, CAPI temporary)
- Defining expected request/response shapes
- Catching breaking API changes before deployment
- Enabling safe independent deployments
Quick Start
Section titled “Quick Start”npm run test:contract # Run testsnpm run test:contract:watch # Watch modenpm run pact:publish # Publish to broker (requires VPN)npm run pact:can-i-deploy # Check deployment safetyKey Concepts
Section titled “Key Concepts”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-deployto gate releases and prevent breaking changes - Let backend teams run
pact:verifyto 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.tsinsrc/pages/{route}/_graphql/(index pages) orsrc/pages/{route}/[slug]/_graphql/(detail pages) - Components:
src/components/{component}/graphql/
- Pages:
- 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)
Documentation
Section titled “Documentation”- Complete guide:
src/graphql/README.md - AI rules:
.cursor/contract-testing.mdc - Examples:
src/graphql/queries/example/(reference patterns)
High-Value vs. Low-Value Tests
Section titled “High-Value vs. Low-Value Tests”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.
Low-Value Tests to Avoid
Section titled “Low-Value Tests to Avoid”❌ Basic Prop Rendering
// Low value: Just testing that the framework worksit("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 designedit("applies className prop", () => { render(() => <Button className="primary" />); expect(screen.getByRole("button")).toHaveClass("primary");});High-Value Tests to Focus On
Section titled “High-Value Tests to Focus On”✅ Component-Specific Logic
// High value: Tests conditional rendering logic specific to this componentit("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 behaviorit("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 conditionsit("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 featuresit("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();});Best Practices
Section titled “Best Practices”-
Test Organization
- Colocate tests with implementation
- Use descriptive test names
- Group related tests using Vitest’s
describeandit - Keep tests focused and isolated
-
Test Setup
- Use centralized
vitest.setup.tsfor global configuration - Leverage shared test utilities for common patterns
- Handle cleanup properly after each test
- Use type-safe component rendering
- Use centralized
-
Test Coverage
- Test edge cases
- Verify error states
- Check accessibility
- Test user interactions
-
Test Maintenance
- Use type-safe mocks
- Avoid implementation details
- Write readable assertions
- Document complex tests
-
Performance
- Mock external dependencies
- Use fast matchers
- Optimize test setup
- Clean up after tests
Anti-Patterns to Avoid
Section titled “Anti-Patterns to Avoid”❌ Don’t: Test implementation details
// Bad: Testing internal stateexpect(component.internal.count).toBe(1);✅ Do: Test observable behavior
// Good: Testing user-facing outputexpect(screen.getByText("Count: 1")).toBeInTheDocument();❌ Don’t: Write brittle tests
// Bad: Testing exact markupexpect(container.innerHTML).toBe("<div class='card'>...</div>");✅ Do: Test functionality
// Good: Testing functionalityexpect(screen.getByRole("article")).toBeInTheDocument();❌ Don’t: Test framework functionality
// Bad: Testing that props workexpect(screen.getByText(props.title)).toBeInTheDocument();✅ Do: Test component-specific behavior
// Good: Testing component-specific behaviorexpect(screen.getByLabelText("Next")).toBeDisabled();Decision Checklist
Section titled “Decision Checklist”When implementing tests, ask:
-
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?
-
Test Structure
- Are tests colocated with code?
- Is naming clear and consistent?
- Are edge cases covered?
- Is setup/teardown handled?
-
Test Quality
- Do tests verify behavior?
- Are mocks type-safe?
- Is accessibility tested?
- Are errors handled?
-
Test Maintenance
- Are tests readable?
- Is setup shared appropriately?
- Are dependencies mocked?
- Is cleanup automated?
-
Test Performance
- Are tests isolated?
- Is setup optimized?
- Are assertions efficient?
- Is parallel execution possible?
Migration Notes
Section titled “Migration Notes”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:
- Update imports to use
vitestinstead ofjest - Replace any Jest-specific APIs with Vitest equivalents
- Update test configuration in
vitest.config.ts - Ensure proper cleanup in component tests
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(); });});