|
| 1 | +# Testing Conventions |
| 2 | + |
| 3 | +How tests are structured in this repo, how to mock the seams (React Query, |
| 4 | +wallet, browser APIs), and how to set up an integration test. For |
| 5 | +util-specific guidance see the [Utils Testing Guide](./utils-testing-guide.md); |
| 6 | +for what hooks should do on failure paths (and therefore what your tests |
| 7 | +should assert), see [Error Handling in Hooks](./error-handling-in-hooks.md). |
| 8 | + |
| 9 | +The runner is **Vitest** (`vitest.config.ts`: jsdom environment, globals |
| 10 | +enabled, setup in `src/test/setup.ts`). Run everything with `pnpm test`, or a |
| 11 | +single file with `pnpm test <path>`. |
| 12 | + |
| 13 | +## File naming and co-location |
| 14 | + |
| 15 | +Tests live in a `__tests__/` folder next to the code they exercise: |
| 16 | + |
| 17 | +``` |
| 18 | +src/hooks/ |
| 19 | + ├─ useFormatXlm.ts |
| 20 | + └─ __tests__/ |
| 21 | + └─ useFormatXlm.test.ts |
| 22 | +src/pages/ |
| 23 | + ├─ LandingPage.tsx |
| 24 | + └─ __tests__/ |
| 25 | + ├─ LandingPage.holdings.test.tsx ← unit-ish page test |
| 26 | + └─ LandingPage.sellFlow.integration.test.tsx ← integration test |
| 27 | +``` |
| 28 | + |
| 29 | +- **Unit tests**: `<name>.test.ts` / `<name>.test.tsx`. |
| 30 | +- **Integration tests**: `<Page>.<feature>.integration.test.tsx` — one flow |
| 31 | + per file, named after the feature under test. Components may also co-locate |
| 32 | + a test directly beside the file (e.g. |
| 33 | + `src/components/common/__tests__/TradeDialog.clamp.integration.test.tsx`). |
| 34 | +- Reference the issue number in the top-level `describe` when the test |
| 35 | + exists to lock in an issue's acceptance criteria, e.g. |
| 36 | + `describe('LandingPage sell flow end-to-end (#644)', …)`. |
| 37 | + |
| 38 | +## Mocking React Query responses |
| 39 | + |
| 40 | +There are two established patterns — pick based on what the test is about. |
| 41 | + |
| 42 | +**1. Mock the service, keep React Query real** (preferred for integration |
| 43 | +tests — caching, invalidation and optimistic updates stay honest): |
| 44 | + |
| 45 | +```tsx |
| 46 | +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; |
| 47 | +import { courseService } from '@/services/course.service'; |
| 48 | + |
| 49 | +vi.mock('@/services/course.service', () => ({ |
| 50 | + courseService: { getCourses: vi.fn() }, |
| 51 | +})); |
| 52 | +const mockGetCourses = vi.mocked(courseService.getCourses); |
| 53 | + |
| 54 | +const renderPage = () => |
| 55 | + render( |
| 56 | + <QueryClientProvider |
| 57 | + client={new QueryClient({ defaultOptions: { queries: { retry: false } } })} |
| 58 | + > |
| 59 | + <MemoryRouter> |
| 60 | + <LandingPage /> |
| 61 | + </MemoryRouter> |
| 62 | + </QueryClientProvider> |
| 63 | + ); |
| 64 | + |
| 65 | +// in the test: |
| 66 | +mockGetCourses.mockResolvedValue([…fixtures…]); |
| 67 | +``` |
| 68 | + |
| 69 | +Always create a **fresh `QueryClient` per render** (never share one between |
| 70 | +tests — cached data leaks across cases) and disable retries so failure-path |
| 71 | +tests don't wait on backoff. |
| 72 | + |
| 73 | +**2. Mock the hook module wholesale** (for unit tests where query machinery |
| 74 | +is noise): |
| 75 | + |
| 76 | +```tsx |
| 77 | +vi.mock('@/hooks/useWallet', () => ({ |
| 78 | + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), |
| 79 | + useWalletHoldings: () => ({ data: [] }), |
| 80 | +})); |
| 81 | +``` |
| 82 | + |
| 83 | +Anything rendering a component that calls `useQuery`/`useMutation` **must** |
| 84 | +be wrapped in a `QueryClientProvider` unless every such hook is mocked out — |
| 85 | +a missing provider fails with `No QueryClient set`. |
| 86 | + |
| 87 | +## Mocking wallet connection state |
| 88 | + |
| 89 | +Wallet state flows through the hooks in `src/hooks/useWallet.ts` |
| 90 | +(`useWalletHoldings`, `useWalletActivity`, `useTradeMutation`). Component |
| 91 | +tests mock at that seam: |
| 92 | + |
| 93 | +```tsx |
| 94 | +vi.mock('@/hooks/useWallet', () => ({ |
| 95 | + // "connected wallet holding 2 keys of creator-a" |
| 96 | + useWalletHoldings: () => ({ |
| 97 | + data: [{ creatorId: 'creator-a', quantity: 2, priceStroops: 500_000, price: 0.05, pending: false }], |
| 98 | + }), |
| 99 | + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), |
| 100 | +})); |
| 101 | +``` |
| 102 | + |
| 103 | +For full-flow tests, prefer **not** mocking `useWallet` at all: the demo |
| 104 | +wallet seeds the featured creator with 3 held keys, and the real |
| 105 | +`useTradeMutation` exercises the optimistic-update and invalidation paths |
| 106 | +(see `LandingPage.sellFlow.integration.test.tsx`). Trade submissions resolve |
| 107 | +on real timers (~1.2s), so assert with |
| 108 | +`waitFor(…, { timeout: 5000 })` rather than fake timers. |
| 109 | + |
| 110 | +## Integration test setup |
| 111 | + |
| 112 | +The standard shell for a page-level integration test: |
| 113 | + |
| 114 | +1. **Providers**: wrap in `QueryClientProvider` (fresh client) and |
| 115 | + `MemoryRouter` — pages use react-router hooks. |
| 116 | +2. **Service mocks**: `vi.mock('@/services/course.service')` and resolve |
| 117 | + fixture data per test. |
| 118 | +3. **Toast sink**: mock `@/utils/toast.util` and assert on |
| 119 | + `showToast.success` / `error` / `transactionSuccess` calls instead of |
| 120 | + scraping toast DOM (no `<Toaster/>` is mounted in tests). |
| 121 | +4. **Presentation mocks** (copy from an existing integration test): |
| 122 | + `framer-motion` (pass-through elements), `@/components/common/CreatorCard` |
| 123 | + (lightweight article), `StellarConnectionQualityBadge`, |
| 124 | + `FeaturedCreatorAudienceChip`, and network/staleness hooks |
| 125 | + (`useNetworkMismatch`, `useStaleData`) pinned to healthy values. |
| 126 | +5. **Browser API stubs**, in `beforeEach`: |
| 127 | + - `matchMedia` — jsdom doesn't implement it; use the `mockMatchMedia` |
| 128 | + helper pattern found in the page tests. |
| 129 | + - `localStorage` / `sessionStorage` — newer Node versions (v22+ |
| 130 | + WebStorage, default in v25) shadow jsdom's storage with a global that |
| 131 | + has no working methods, so `window.localStorage.clear()` throws. New |
| 132 | + suites should install an in-memory stub (see `installStorageStub` in |
| 133 | + `LandingPage.sellFlow.integration.test.tsx`) instead of touching the |
| 134 | + global directly. |
| 135 | +6. **Cleanup**: `afterEach(cleanup)` — automatic unmount is not enabled. |
| 136 | + |
| 137 | +## Available test utilities |
| 138 | + |
| 139 | +There is deliberately no shared custom `render` yet; each suite composes its |
| 140 | +own providers. The reusable pieces to copy today: |
| 141 | + |
| 142 | +| Utility | Where | What it does | |
| 143 | +|---|---|---| |
| 144 | +| `src/test/setup.ts` | global setup | registers `@testing-library/jest-dom` matchers | |
| 145 | +| `mockMatchMedia()` | page test files | stubs `window.matchMedia` for jsdom | |
| 146 | +| `installStorageStub()` | `LandingPage.sellFlow.integration.test.tsx` | Node-version-proof localStorage/sessionStorage stub | |
| 147 | +| `makeQueryClient()` | `LandingPage.sort.integration.test.tsx` | fresh `QueryClient` with retries disabled | |
| 148 | +| `confirmTrade(side, amount)` | `LandingPage.holdingsSellBalanceUpdate.integration.test.tsx` | drives the trade dialog: open → amount → confirm | |
| 149 | +| `dispatchRejection(reason)` | `unhandledRejectionLogger.test.ts` | synthesizes an unhandled-rejection event | |
| 150 | + |
| 151 | +If you find yourself copying more than two of these into a new file, that is |
| 152 | +the signal to promote them into `src/test/` as shared utilities — do it in |
| 153 | +the same PR. |
| 154 | + |
| 155 | +## What good assertions look like here |
| 156 | + |
| 157 | +- Assert **user-visible outcomes** (rendered text, toast calls, holdings |
| 158 | + rows), not internal state. |
| 159 | +- For flows with optimistic updates, assert both the intermediate state |
| 160 | + (pending) and the settled state where practical. |
| 161 | +- Error paths deserve their own tests — see |
| 162 | + [Error Handling in Hooks](./error-handling-in-hooks.md) for the expected |
| 163 | + failure behaviour to pin down. |
0 commit comments