Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
121 changes: 121 additions & 0 deletions docs/TESTING_GUIDE.md
Original file line number Diff line number Diff line change
@@ -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 <button onClick={onSave}>Save changes</button>
}

it('calls the save action', () => {
const onSave = vi.fn()

render(<SaveButton onSave={onSave} />)
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.
74 changes: 74 additions & 0 deletions src/lib/__tests__/testingGuideExamples.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section aria-label="Example counter">
<output aria-label="count">{count}</output>
<button type="button" onClick={() => setCount((current) => current + 1)}>
Increment
</button>
</section>
)
}

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(<ExampleCounter />)

fireEvent.click(screen.getByRole('button', { name: 'Increment' }))

expect(screen.getByRole('region', { name: 'Example counter' })).toBeInTheDocument()
expect(screen.getByLabelText('count')).toHaveTextContent('1')
})
})