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 (
+
+
+
+
+ )
+}
+
+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')
+ })
+})