Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ecd05ec
feat: add automated test suite PoC — 57 tests across 3 layers
tuliomir Apr 28, 2026
8f9eeb2
fix: make entire test suite pass — fix pre-existing test failures
tuliomir Apr 14, 2026
67c4b44
chore: allow-scripts entry for jest-circus's unrs-resolver
tuliomir Apr 28, 2026
4f4e9d3
fix: align reown.test.js with rejectAll saga's actual yield order
tuliomir Apr 28, 2026
bdbf958
test: add slice-refactor safety net + testing guide
tuliomir Apr 28, 2026
49a4e57
chore: address review nits + pin jest-circus
tuliomir Apr 28, 2026
ec3b546
docs: annotate review findings that were intentionally not fixed
tuliomir Apr 28, 2026
940f684
fix(ci): regenerate package-lock.json with Node 22
tuliomir Apr 28, 2026
0864921
fix(lint): import jest globals in reducer test files
tuliomir Apr 28, 2026
d19358e
chore: address second review pass — lint hygiene + duplicate test
tuliomir Apr 28, 2026
26638c6
test: address third-pass review — strengthen contracts, dedupe helper
tuliomir Apr 28, 2026
ee6848a
docs: add AGENTS.md, CLAUDE.md, and writing-tests skill
tuliomir Apr 28, 2026
7a20c5f
docs: refine test docs per QA audit — gotchas first, less filler
tuliomir Apr 28, 2026
d5ff9f0
fix(test): address CodeRabbit lint findings on PR #863
tuliomir Apr 28, 2026
86e8352
test: restore nanoContract coverage lost in 8f9eeb2
tuliomir Apr 28, 2026
037b6e8
fix(test): import jest globals in utils + InitWallet tests
tuliomir Apr 28, 2026
1c6dc07
fix(test): address CodeRabbit lint findings on screen tests
tuliomir Apr 29, 2026
adeff1a
test: tighten screen test contracts per CodeRabbit nitpicks
tuliomir Apr 29, 2026
1e114e7
fix: address Copilot review on PR #863
tuliomir Apr 29, 2026
a5ae036
docs: break circular AGENTS.md ↔ CLAUDE.md pointer
tuliomir Apr 29, 2026
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
156 changes: 156 additions & 0 deletions .claude/skills/writing-tests/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
---
name: writing-tests-hathor-wallet-mobile
description: Use when adding or modifying any test file in this repo (paths under __tests__/, files matching *.test.ts / *.test.tsx / *.test.js). Encodes the conventions surfaced by repeated code reviews so each new test PR doesn't re-fight the same nits — jest-globals import, three-layer reducer safety net, helper reuse, lock-regen Node version, mock annotation pattern, and the anti-patterns list.
---

# Writing tests in hathor-wallet-mobile

The full reference is `docs/testing-guide.md` (in this repo). This skill
distills the rules that have been raised in review and must be followed
on every PR.

## 1. Always import jest globals

The repo's `.eslintrc` has no `env.jest` and no test-file override.
Without explicit imports, `describe`/`it`/`expect`/`jest`/`beforeEach`
trip `no-undef` and CI's `npm run lint` fails.

```ts
import { describe, it, expect } from '@jest/globals';
// add jest, beforeEach, etc. as needed
```

## 2. Test the public contract, not implementation

Dispatch real action creators against the **root** `reducer`. Do **not**
import internal `onXxx` handlers — they'll be renamed or inlined when
the reducer migrates to RTK slices.

```ts
// ✅ Good — survives any internal reorg
import { reducer } from '../../src/reducers/reducer';
import { resetWalletSuccess } from '../../src/actions';
const next = reducer(state, resetWalletSuccess());

// ❌ Bad — breaks when onResetWalletSuccess is inlined into a slice
import { onResetWalletSuccess } from '../../src/reducers/reducer';
```

## 3. Reducer tests pin three contracts

Every reducer test should pin three layers so a future refactor surfaces
drift in explicit, reviewable diffs:

1. **Behavior** — action in → state out.
2. **State-shape contract** — sorted list of keys at each level of
`getInitialState()` for the sub-tree you touch:
```ts
expect(Object.keys(state.x).sort()).toEqual([...]);
```
Use **sorted-keys equality** (not `toHaveProperty`) so a key that's
moved under a sub-tree fails the test. Pin keying conventions too
(e.g. `state.tokens` is `{ [uid]: token }` — assert one sample uid).
3. **Action-type contract** — literal `.type` strings for every in-scope
action creator: `expect(myAction(x).type).toBe('MY_ACTION')`.
Use **minimal valid payloads**, not `{}` / `null`. A future RTK
`prepare` callback validating inputs would throw before reaching
`.type`, masking the assertion behind a creator-level error.

Canonical examples: `__tests__/reducers/reducer.wallet.test.ts`,
`__tests__/reducers/reducer.reown.test.ts`.

## 4. Reuse `__tests__/helpers/`, don't redefine

Available helpers:

- `getInitialState.js` — root-reducer initial state. Always import.
- `renderWithProviders.tsx` — mount React Native components with Redux + theme.
- `mockStore.ts` — preconfigured store factory.
- `mockNavigation.ts` — `createMockNavigation()` / `createMockRoute()`
factories. This file does **not** call `jest.mock(...)` itself; do
that at the top of your test file (Babel only hoists `jest.mock` at
module scope).

**New helpers**: prefer `.js`, not `.ts`. The repo has no TS-aware
import resolver — `.ts` helpers fail `import/no-unresolved` from any
consumer. Use `.ts` only if you're prepared to add the resolver config.

## 5. Saga tests use `redux-saga-test-plan`

Drive the saga end-to-end against the real reducer. Mock only at the
I/O boundary (`@hathor/wallet-lib`, fetch, async storage). Reducer in a
saga test must be the real one — mocking it asserts your mocks, not
your code.

For function-identity comparisons inside `.provide([...])`, import the
real saga function (`isWalletServiceEnabled`, etc.). These will *not*
appear in `no-unused-vars` lint output but are required at runtime.
Verify with `grep` before deleting an "unused" saga import.

## 6. Component / screen tests use `renderWithProviders`

Mount with the helper, find by visible text or `testID`, assert on
behavior the user cares about. Avoid `toMatchSnapshot()` blobs —
explicit `expect(...).toBe(...)` per invariant is reviewable.

## 7. Mocks that don't match production imports → annotate inline

`jestMockSetup.js` pattern: if a `jest.mock(...)` factory exposes the
wrong shape (named-only when production imports default + named, etc.)
and **no current test exercises that code path**, add a comment block
listing the named exports a future test would need. Don't preemptively
restructure the mock — that's scope creep, and reviewers raise the
mismatch when they see it without the context.

Examples already in the file:
- `@react-native-firebase/messaging`
- `@hathor/unleash-client`

## 8. Anti-patterns to avoid

- **Silent `return` to skip a test** when fixtures might collide.
Replace with an explicit guard (`expect(x).not.toEqual(y)`) so a
failing fixture is never silently masked.
- **Duplicate near-clone tests.** Two tests that render the same
component, do the same interaction, and assert the same text are
redundant — keep one with a unique, accurate name.
- **Unused destructures from `render(...)`**. Drop `queryByText` if
you don't call it.
- **Unused imports.** Run `npx eslint <test-file>` before committing
and clean up `no-unused-vars` warnings — but verify with grep first;
saga tests sometimes use imports as function-identity references
that lint can miss.
- **`toStrictEqual` with `Error` instances** is fine — Jest ≥ 24
structurally compares `name` + `message`. Don't capture-and-reuse
the original `Error` reference unless you need referential identity.

## 9. Lockfile regen for test deps must use Node 22

If your test work adds or upgrades a dev dep, regenerate
`package-lock.json` on the same Node version CI uses
(`.github/workflows/main.yml` matrix → 22.x):

```sh
nvm install 22 && nvm use 22
npm install --no-audit --no-fund
```

Different Node majors resolve transitive deps differently. A lock
generated on Node 24 / npm 11 will fail CI's `npm ci` with errors
like *"Missing: typescript@6.0.3 from lock file"*.

## 10. Verify before claiming done

Before opening / updating the PR:

```sh
nvm use 22
npx jest --no-coverage # full suite green
npx eslint <files-you-touched> # 0 errors
npm ci --dry-run # lock and package.json in sync
```

If you touch a contract block (state shape or action types), do a
quick **mutation drill**: temporarily break the production code and
confirm your test fails, then revert. This catches "tested but
doesn't actually assert" mistakes.
60 changes: 60 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Agent guide for hathor-wallet-mobile

This file is the entry point for AI coding agents (Claude Code, Cursor,
Codex, etc.) working in this repo. Read it first; follow the pointers
for depth.

## Repo conventions

### 1. Pin every dependency version exactly

`package.json` MUST use exact versions — no `^`, no `~`. This is a
supply-chain mitigation and is **mandatory**. When a CodeRabbit-style
review flags a version mismatch, fix both sides to the same exact
version. After editing `package.json`, regenerate `package-lock.json`
following rule (3) below.

### 2. Use Node 22 / npm 10 for any lockfile regeneration

CI runs Node 22.x (`.github/workflows/main.yml` matrix). Running
`npm install` with a different major (e.g. Node 24 / npm 11) produces
a divergent lockfile that fails CI's `npm ci`.

```sh
nvm install 22 && nvm use 22
npm install --no-audit --no-fund
```

### 3. Tests follow `docs/testing-guide.md`

Read it before writing tests. The short version:

- **Always import jest globals** in `.test.ts` / `.test.tsx`:
`import { describe, it, expect } from '@jest/globals';`
(the repo's ESLint has no `env.jest`, so `no-undef` will fire).
- **Test the public contract**: dispatch real action creators against
the *root* `reducer`; never import internal `onXxx` handlers.
- **Reducer tests pin three contracts**: behavior (action → state),
initial-state shape (sorted-keys equality), action-type strings.
Canonical examples: `__tests__/reducers/reducer.{wallet,reown}.test.ts`.
- **Reuse `__tests__/helpers/`** — don't redefine setup boilerplate.
Note: helpers use `.js`, not `.ts`; the repo has no TS-aware import
resolver, so `.ts` helpers fail `import/no-unresolved`.
- For Claude Code: a more detailed skill auto-loads on test work — see
`.claude/skills/writing-tests/SKILL.md`. Other agents should read
`docs/testing-guide.md` directly.

### 4. Commits

- Conventional Commits (`feat:`, `fix:`, `chore:`, `test:`, `docs:`).
- 50-char title cap, 72-col body wrap.
- Sign commits with GPG before merge (`git commit -S` or sign-and-amend
before push). CI may require all commits signed.

### 5. PRs

- Assign to `tuliomir`. Tag `tests` for test PRs, `bug` for fixes,
`dependencies` for dep changes.
- Add to project 15 with status "In Progress WIP".
- Description should be concise; explicit breaking-change notes when
applicable.
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Claude Code instructions

This file is intentionally a thin pointer. The canonical agent guide for
this repo is [`AGENTS.md`](./AGENTS.md) — read it first.

For test work, the more detailed Claude Code skill at
`.claude/skills/writing-tests/SKILL.md` will auto-load when you start
modifying files under `__tests__/` or matching `*.test.{ts,tsx,js}`.
13 changes: 12 additions & 1 deletion __tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,18 @@ import renderer from 'react-test-renderer';

jest.mock('redux-saga', () => () => ({ run: jest.fn() }));
jest.mock('redux', () => ({
createStore: jest.fn(),
createStore: jest.fn(() => ({
getState: jest.fn(() => ({})),
dispatch: jest.fn(),
subscribe: jest.fn(),
replaceReducer: jest.fn(),
})),
legacy_createStore: jest.fn(() => ({
getState: jest.fn(() => ({})),
dispatch: jest.fn(),
subscribe: jest.fn(),
replaceReducer: jest.fn(),
})),
applyMiddleware: jest.fn(),
}));

Expand Down
10 changes: 10 additions & 0 deletions __tests__/helpers/getInitialState.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Returns the root reducer's initial state for use in reducer tests.
*
* Always import the helper instead of redefining it per file — both the
* testing-guide.md and prior code review have called out duplicated
* setup as a smell.
*/
import { reducer } from '../../src/reducers/reducer';

export const getInitialState = () => reducer(undefined, { type: '@@INIT' });
42 changes: 42 additions & 0 deletions __tests__/helpers/mockNavigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Test helper: mock navigation objects for React Navigation 7.
*
* Provides plain mock objects that can be passed as props or returned
* from `useNavigation()`/`useRoute()` in a `jest.mock(...)` factory at
* module scope in the consuming test file.
*
* Note: this file does NOT call `jest.mock(...)` itself. `jest.mock` is
* hoisted by Babel only when written at top-level of the test module —
* wrapping it inside an exported helper would defeat the hoist and run
* after the modules under test are already imported.
*/
import { jest } from '@jest/globals';

export function createMockNavigation() {
return {
navigate: jest.fn(),
push: jest.fn(),
replace: jest.fn(),
goBack: jest.fn(),
reset: jest.fn(),
setOptions: jest.fn(),
setParams: jest.fn(),
dispatch: jest.fn(),
addListener: jest.fn(() => jest.fn()), // returns unsubscribe
canGoBack: jest.fn(() => true),
isFocused: jest.fn(() => true),
getParent: jest.fn(),
getState: jest.fn(() => ({
index: 0,
routes: [{ name: 'MockRoute', key: 'mock-key' }],
})),
};
}

export function createMockRoute(params: Record<string, unknown> = {}) {
return {
key: 'mock-route-key',
name: 'MockRoute',
params,
};
}
21 changes: 21 additions & 0 deletions __tests__/helpers/mockStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Test helper: creates a Redux store with optional preloaded state.
*
* Uses the real reducer from the app so that dispatched actions
* produce the same state transitions as production code.
*/
import { legacy_createStore as createStore } from 'redux';
import { reducer } from '../../src/reducers/reducer';

/**
* Create a Redux store for testing with optional state overrides.
*
* @param preloadedState - Partial state merged on top of the reducer's
* built-in initialState. Pass only the keys you care about.
*/
export function createTestStore(preloadedState: Record<string, unknown> = {}) {
// Dispatching an unknown action returns initialState from the reducer,
// which we then merge with the caller's overrides.
const baseState = reducer(undefined, { type: '@@INIT' });
return createStore(reducer, { ...baseState, ...preloadedState } as any);
}
46 changes: 46 additions & 0 deletions __tests__/helpers/renderWithProviders.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Test helper: renders a React component wrapped in all the providers
* that the Hathor Wallet app expects (Redux, Navigation).
*
* Usage:
* const { getByText } = renderWithProviders(<MyScreen />, {
* preloadedState: { isOnline: true },
* });
*/
import React from 'react';
import { render, RenderOptions } from '@testing-library/react-native';
import { Provider } from 'react-redux';
import { NavigationContainer } from '@react-navigation/native';
import { createTestStore } from './mockStore';

interface ExtendedRenderOptions extends Omit<RenderOptions, 'wrapper'> {
/** Partial Redux state merged on top of initialState. */
preloadedState?: Record<string, unknown>;
/** Supply your own store (e.g. if you need to dispatch in the test). */
store?: ReturnType<typeof createTestStore>;
/** If false, skip wrapping in NavigationContainer (default: true). */
withNavigation?: boolean;
}

export function renderWithProviders(
ui: React.ReactElement,
{
preloadedState = {},
store = createTestStore(preloadedState),
withNavigation = true,
...renderOptions
}: ExtendedRenderOptions = {},
) {
function Wrapper({ children }: { children: React.ReactNode }) {
const content = <Provider store={store}>{children}</Provider>;
if (withNavigation) {
return <NavigationContainer>{content}</NavigationContainer>;
}
return content;
}

return {
store,
...render(ui, { wrapper: Wrapper, ...renderOptions }),
};
}
Loading
Loading