From 946ee4cb7e4c7e8d6d3dca015e4d407e14c862ad Mon Sep 17 00:00:00 2001 From: Will Matos Date: Tue, 23 Jun 2026 14:25:00 -0500 Subject: [PATCH] Add frontend testing guide --- DEVELOPER_GUIDE.md | 9 +- README.md | 4 +- docs/TESTING_GUIDE.md | 121 ++++++++++++++++++ .../__tests__/testingGuideExamples.test.tsx | 74 +++++++++++ 4 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 docs/TESTING_GUIDE.md create mode 100644 src/lib/__tests__/testingGuideExamples.test.tsx diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index ad0ef0a..83e3333 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -111,10 +111,11 @@ Fix all errors before committing. Warnings are informational but should be addre - Update that document whenever the settlement or early-exit API contracts, modal copy, or confirmation safeguards change. ## ๐Ÿงช Testing Procedures -*(Note: Testing framework setup is currently in progress)* - -- **Unit Tests**: We plan to use Vitest + React Testing Library. -- **Integration Tests**: Test user flows (e.g., creating a commitment) end-to-end. +- The testing stack is Vitest, React Testing Library, happy-dom, and V8 coverage. The detailed conventions live in [Frontend Testing Guide](docs/TESTING_GUIDE.md). +- **Unit Tests**: Use Vitest for utilities, hooks, and backend helpers. +- **Component Tests**: Use React Testing Library with role, label, and text queries. +- **Integration Tests**: Test route contracts and user flows without live network or wallet calls. +- **Mocks**: Keep `fetch`, Freighter, timer, storage, and chain mocks scoped to each test file. - **Linting**: Run `pnpm lint` before committing to ensure code quality. ## ๐Ÿ”„ Contribution Workflow diff --git a/README.md b/README.md index b4237df..3534964 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ For a deep dive into the system design, modules, and data flow, please refer to ## ๐Ÿงช Testing -This project uses **Vitest** for unit and integration testing of API routes. +This project uses **Vitest** for unit and integration testing of API routes, utilities, and React components. See [Frontend Testing Guide](docs/TESTING_GUIDE.md) for Vitest, React Testing Library, happy-dom, fetch, Freighter, and fake-timer conventions. ### Running Tests @@ -90,6 +90,8 @@ Tests demonstrate: - Testing request/response handling - Parameter validation and error handling - Mock data without external dependencies +- React Testing Library patterns for user-facing component behavior +- Wallet, fetch, and timer mocks that stay local to each test To add new API route tests, create a `.test.ts` file in `tests/api/` following the same pattern. diff --git a/docs/TESTING_GUIDE.md b/docs/TESTING_GUIDE.md new file mode 100644 index 0000000..cbe2e76 --- /dev/null +++ b/docs/TESTING_GUIDE.md @@ -0,0 +1,121 @@ +# Frontend Testing Guide + +CommitLabs frontend tests use Vitest, React Testing Library, happy-dom, and V8 coverage. Use this guide when adding component, hook, utility, or route tests. + +## Commands + +The project scripts are package-manager neutral. Use `pnpm` in the standard contributor flow, or the equivalent `npm` command when working from `package-lock.json`. + +```bash +pnpm test +pnpm test:watch +pnpm test:coverage + +npm test +npm run test:watch +npm run test:coverage +``` + +Run a focused file while developing: + +```bash +pnpm vitest run src/components/Example.test.tsx +npx vitest run src/components/Example.test.tsx +``` + +Coverage thresholds live in `vitest.config.ts`. Keep new tests focused on user-visible behavior, API contracts, and edge cases rather than snapshots. + +## Placement And Naming + +- Put component tests next to the component or in the nearest `__tests__` folder. +- Put shared utility tests under `src/lib/__tests__`, `src/utils/__tests__`, or the nearest existing test folder. +- Use `.test.ts` for non-JSX tests and `.test.tsx` for tests that render React. +- Add `// @vitest-environment happy-dom` at the top of DOM or React Testing Library tests. +- Prefer role, label, and text queries over test IDs. Use test IDs only for mocked internals or when the user-facing surface has no stable accessible name. + +## React Testing Library Pattern + +Test behavior the user can observe: + +```tsx +// @vitest-environment happy-dom + +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +function SaveButton({ onSave }: { onSave: () => void }) { + return +} + +it('calls the save action', () => { + const onSave = vi.fn() + + render() + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })) + + expect(onSave).toHaveBeenCalledTimes(1) +}) +``` + +## Mocking Fetch + +Use a scoped mock and restore it after each test: + +```ts +const originalFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = originalFetch + vi.restoreAllMocks() +}) + +it('reads API data', async () => { + globalThis.fetch = vi.fn(async () => + new Response(JSON.stringify({ status: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) as typeof fetch + + const response = await fetch('/api/health') + await expect(response.json()).resolves.toEqual({ status: 'ok' }) +}) +``` + +## Mocking Freighter + +Mock `@stellar/freighter-api` at module scope before importing the code under test: + +```ts +vi.mock('@stellar/freighter-api', () => ({ + isConnected: vi.fn(async () => ({ isConnected: true })), + getAddress: vi.fn(async () => ({ address: 'GCOMMITLABS...' })), +})) +``` + +Then assert wallet-dependent behavior through the public helper or component output, not by reaching into private state. + +## Fake Timers + +Freeze time when relative labels, expiration windows, or nonce timestamps matter: + +```ts +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-06-23T12:00:00Z')) +}) + +afterEach(() => { + vi.useRealTimers() +}) +``` + +## Route And Backend Tests + +- Prefer direct handler calls with mock `Request` objects for App Router routes. +- Mock network, storage, wallet, and chain dependencies. +- Assert status, headers, and response body shape. +- Keep request IDs, CORS, rate-limit, and error-contract tests close to the route or backend helper. + +## Example Suite + +`src/lib/__tests__/testingGuideExamples.test.tsx` runs examples for fetch mocks, Freighter mocks, fake timers, and RTL user-facing queries. Update that file when this guide changes a pattern that should stay executable. diff --git a/src/lib/__tests__/testingGuideExamples.test.tsx b/src/lib/__tests__/testingGuideExamples.test.tsx new file mode 100644 index 0000000..4c3e3fd --- /dev/null +++ b/src/lib/__tests__/testingGuideExamples.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment happy-dom + +import { fireEvent, render, screen } from '@testing-library/react' +import React, { useState } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getAddress, isConnected } from '@stellar/freighter-api' + +vi.mock('@stellar/freighter-api', () => ({ + isConnected: vi.fn(async () => ({ isConnected: true })), + getAddress: vi.fn(async () => ({ address: 'GCOMMITLABSEXAMPLEADDRESS' })), +})) + +const originalFetch = globalThis.fetch + +async function readHealthStatus() { + const response = await fetch('/api/health') + const body = await response.json() + return body.status as string +} + +function ExampleCounter() { + const [count, setCount] = useState(0) + + return ( +
+ {count} + +
+ ) +} + +describe('testing guide examples', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-06-23T12:00:00Z')) + }) + + afterEach(() => { + globalThis.fetch = originalFetch + vi.useRealTimers() + vi.clearAllMocks() + }) + + it('mocks fetch with a JSON response', async () => { + globalThis.fetch = vi.fn(async () => + new Response(JSON.stringify({ status: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) as typeof fetch + + await expect(readHealthStatus()).resolves.toBe('ok') + expect(globalThis.fetch).toHaveBeenCalledWith('/api/health') + }) + + it('mocks Freighter wallet calls', async () => { + await expect(isConnected()).resolves.toEqual({ isConnected: true }) + await expect(getAddress()).resolves.toEqual({ address: 'GCOMMITLABSEXAMPLEADDRESS' }) + }) + + it('uses fake timers for deterministic time assertions', () => { + expect(new Date().toISOString()).toBe('2026-06-23T12:00:00.000Z') + }) + + it('uses role and label queries for component behavior', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'Increment' })) + + expect(screen.getByRole('region', { name: 'Example counter' })).toBeInTheDocument() + expect(screen.getByLabelText('count')).toHaveTextContent('1') + }) +})