Skip to content

Commit 741035c

Browse files
committed
docs: add TESTING-STANDARDS.md
Adds a new standards doc for front end component testing.
1 parent d07d445 commit 741035c

1 file changed

Lines changed: 251 additions & 0 deletions

File tree

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
# Frontend Test Standards
2+
3+
This document is the standard for how test files in this codebase should
4+
be structured going forward. New and refactored specs should follow it.
5+
It isn't exhaustive -- add to it as new patterns get established.
6+
7+
## Mounting utilities
8+
9+
Two shared entry points live in `tests/`. Pick based on what the
10+
component under test actually needs:
11+
12+
| Utility | Use when the component... |
13+
|---|---|
14+
| `mountComponent` | Establishes its own `<Form>` context internally, or needs no vee-validate form context at all -- just Pinia / provide / stubs. This is the default. |
15+
| `mountWithFormContext` | **Assumes an ancestor** already provides vee-validate's form context (a field/section component that doesn't render its own `<Form>`). |
16+
17+
`mountWithFormContext` mounts the component inside a synthetic harness
18+
that calls `useForm()` on its behalf. Reach for it only when the
19+
component genuinely has no other way to get a form context in the test
20+
-- using it on a component that owns its own `<Form>` creates a nested,
21+
shadowed context instead of an error, which is a quiet bug to track down
22+
later. When in doubt, start with `mountComponent`; only switch to
23+
`mountWithFormContext` if the component throws on a missing form
24+
context (e.g. `useIsFormDirty`/`useFormValues`/`useField` with no
25+
provider).
26+
27+
## Router mocking
28+
29+
`tests/mockRouter.ts` exports a shared `mockRouter` (`{ push, replace }`)
30+
and `resetMockRouter()`. It's deliberately separate from the mounting
31+
utilities above -- routing is an unrelated concern, not something every
32+
mounted component needs. Use it in any spec that needs to assert on
33+
navigation:
34+
35+
```ts
36+
import { mockRouter, resetMockRouter } from '../utils/mockRouter';
37+
38+
// vi.mock is hoisted, so it can't reference `mockRouter` directly from the
39+
// shared util at declaration time -- but it CAN return the same object
40+
// reference at call time, which is all that matters for spies.
41+
vi.mock('vue-router', () => ({
42+
useRouter: () => mockRouter,
43+
useRoute: () => ({ params: {}, query: {} })
44+
}));
45+
46+
beforeEach(() => {
47+
resetMockRouter();
48+
});
49+
50+
// later, in a test:
51+
expect(mockRouter.replace).toHaveBeenCalledWith({ name: RouteName.EXT_GENERAL });
52+
```
53+
54+
If a spec needs `useRoute()` to return different query params per test
55+
(rather than a fixed object), define its own local `vi.fn()` wrapper
56+
around it instead -- `mockRouter` only covers `useRouter()`'s
57+
`push`/`replace`, since that's the part every spec asserting on
58+
navigation needs the same way.
59+
60+
## Authn store mocking (exception to "seed real Pinia state")
61+
62+
`useAuthNStore` (`src/store/authnStore.ts`) is a Pinia *setup* store. Even
63+
under `createTestingPinia`, only the store's returned actions get
64+
auto-stubbed -- the setup() body itself still runs for real, which
65+
unconditionally constructs a real `AuthService` / `oidc-client-ts`
66+
`UserManager`. With `monitorSession` on by default, that spins up a
67+
`SessionMonitor` whose constructor fires an unawaited `getUser()`, logging
68+
`"[UserManager] getUser: user not found in storage"` to the console in
69+
every spec that touches the real store -- there's no `piniaState` to seed
70+
around this, since it happens before any state is read.
71+
72+
Because of that, specs depending on `useAuthNStore` are a deliberate
73+
exception to the "seed real Pinia state, don't mock the store" rule below:
74+
use `tests/mockAuthNStore.ts` instead.
75+
76+
```ts
77+
import { mockAuthNStore, resetMockAuthNStore } from '../../../mockAuthNStore';
78+
79+
vi.mock('@/store/authnStore', () => ({
80+
default: () => mockAuthNStore,
81+
useAuthNStore: () => mockAuthNStore
82+
}));
83+
84+
beforeEach(() => {
85+
resetMockAuthNStore();
86+
});
87+
88+
// later, in a test:
89+
mockAuthNStore.getIsAuthenticated.value = true;
90+
mockAuthNStore.getProfile.value = testProfile;
91+
```
92+
93+
Mock the leaf module `@/store/authnStore`, not the `@/store` barrel --
94+
that keeps every other store re-exported from `@/store` real for specs
95+
that need both. This doesn't apply to any other store; every other store
96+
should still be driven via real `piniaState` per the gotchas section
97+
below.
98+
99+
## Local mount factory
100+
101+
Every spec defines exactly one local factory that wraps the appropriate
102+
shared utility above with that file's own defaults (props, piniaState,
103+
stubs, provide, etc). Name it **`mount` + the component's name** --
104+
e.g. `mountCancelButton`, `mountNaturalDisasterCard`,
105+
`mountProjectListNavigator`. The pattern is fixed; the name itself
106+
isn't -- every spec should be immediately recognizable as
107+
"`mount` + whatever this file is testing."
108+
109+
```ts
110+
function mountCancelButton(options: { editable?: boolean } = {}) {
111+
const onClicked = vi.fn();
112+
113+
const { wrapper } = mountWithFormContext(CancelButton, {
114+
fields: ['testField'],
115+
componentProps: { ...options, onClicked }
116+
});
117+
118+
return { wrapper, onClicked };
119+
}
120+
```
121+
122+
(from `CancelButton.spec.ts` -- see also `ContactCardIntakeForm.spec.ts` for
123+
the same pattern with real `piniaState` and multiple returned values.)
124+
125+
Rules for the local mount factory:
126+
127+
- **Always takes an options object with defaults**, never positional
128+
arguments -- `mountCancelButton({ editable: false })`, not
129+
`mountCancelButton(false)`.
130+
- **Always returns an object**, even if it only contains `wrapper`. Never
131+
return a bare wrapper. This keeps every call site's destructuring
132+
(`const { wrapper } = mountCancelButton()`) valid regardless of which
133+
spec you copy it from.
134+
- Lives near the top of the file, immediately after fixtures (see layout
135+
below), so it's the first thing a reader sees before the tests
136+
themselves.
137+
138+
## File layout
139+
140+
Every spec follows the same section order, marked with a one-line
141+
comment banner (not a boxed/bordered one -- keep it light):
142+
143+
```ts
144+
// Mocks
145+
146+
vi.mock('vue-router', () => ({ ... }));
147+
148+
// Fixtures
149+
150+
const testFoo = { ... };
151+
152+
// Mount
153+
154+
function mountComponentName(overrides = {}) { ... }
155+
156+
beforeEach(() => { ... });
157+
158+
// Tests
159+
160+
describe('ComponentName', () => {
161+
describe('rendering', () => { ... });
162+
describe('validation', () => { ... });
163+
describe('user interaction', () => { ... });
164+
});
165+
```
166+
167+
Notes:
168+
169+
- **Mocks always come first, unconditionally.** `vi.mock` calls are
170+
hoisted by Vitest regardless of where they're written in the file, so
171+
placing them anywhere else misrepresents execution order to a reader.
172+
- **Fixtures** are named, reusable, known-good pieces of test data (e.g.
173+
`testProject`, `sampleContact`) -- pulled out of individual `it()`
174+
blocks so multiple tests share the exact same input, one place to
175+
update when a type shape changes, and tests read as behavior rather
176+
than data setup.
177+
- Keep every section present even when it's nearly empty (e.g. a
178+
component with barely any fixtures). Consistency across the suite
179+
matters more than saving a few lines in short files.
180+
- **`// Tests` goes after `beforeEach`/`afterEach`, immediately before
181+
the root `describe` block** -- not before them. The lifecycle hooks
182+
are setup tightly coupled to the mount factory (often resetting mocks
183+
it depends on), so they stay grouped under `// Mount`; `// Tests`
184+
marks where the actual test bodies begin. Every section gets a banner,
185+
including this last one -- consistency across the whole file matters
186+
more than a banner being technically redundant with the `describe`
187+
title beneath it.
188+
- Within the root `describe`, group nested `describe`s by concern, not
189+
by method/prop name. Prefer a small, consistent vocabulary across the
190+
whole codebase: `rendering`, `validation`, `user interaction`, or a
191+
specific named behavior (e.g. `form error reporting`). Don't let each
192+
file invent its own taxonomy.
193+
194+
## Known gotchas (read before writing Pinia-store-dependent tests)
195+
196+
**Never `vi.spyOn` a Pinia getter -- drive the real state instead.**
197+
A Pinia getter is a `computed()` ref stored as a plain property value,
198+
not a real accessor descriptor. `vi.spyOn(store, 'someGetter', 'get')`
199+
does not reliably attach, and direct assignment
200+
(`store.someGetter = value`) silently no-ops (computed refs are
201+
read-only; Vue just logs a dev warning). This applies **even when the
202+
getter looks like a callable method** -- e.g. `authzStore.can(...)` is
203+
actually `computed(() => (initiative, resource, action) => boolean)`,
204+
not a plain action, despite being called like one. The only reliable
205+
fix is to seed the real state the getter derives from, via
206+
`piniaState`, e.g.:
207+
208+
```ts
209+
mountComponent(Foo, {
210+
piniaState: { form: { formType: FormType.NEW, formState: FormState.UNLOCKED } }
211+
});
212+
```
213+
214+
Plain **actions** (`setFormError`, `setAuthorizationContext`, etc.) are
215+
ordinary functions and spy normally -- `createTestingPinia` stubs them
216+
automatically by default, or `vi.spyOn(store, 'someAction')` works fine
217+
if you need a custom implementation. The distinction is getters vs.
218+
actions, not "looks like a function call" vs. "looks like a property."
219+
220+
**`.props('key')` can fail to typecheck on a union of wrapper types.**
221+
`VueWrapper#props()` is overloaded (`props(): Props` and
222+
`props<K>(key: K): Props[K]`). When you call `.props('key')` on a value
223+
whose type is a union of *different* generic instantiations of
224+
`VueWrapper` (e.g. iterating `[...findAllComponents(A), ...findAllComponents(B)]`),
225+
TypeScript can't merge the overload sets across the union and falls back
226+
to the zero-arg overload only. Use `.props().key` in that situation --
227+
it works because the zero-arg overload is common to every member of the
228+
union. A single, non-unioned `findComponent(X).props('key')` is fine as-is.
229+
230+
**A provided/injected value that's read via template auto-unwrap must be
231+
a genuine `ref`/`computed`, not a plain object shaped like one.**
232+
Vue's template auto-unwrap only triggers for real reactive refs (objects
233+
carrying the internal ref marker), not a plain `{ value: x }` object that
234+
merely looks ref-shaped. If the real app provides a value via
235+
`provide(someKey, computed(() => ...))` and the component reads it
236+
*inside its template* with no `.value`, the test's `provide` option must
237+
use an actual `ref()`/`computed()` too:
238+
239+
```ts
240+
import { ref } from 'vue';
241+
242+
provide: {
243+
[someKey]: ref('some-value') // not { value: 'some-value' }
244+
}
245+
```
246+
247+
If the component instead reads the injected value via explicit `.value`
248+
in `<script setup>` logic (not the template), a plain `{ value: x }`
249+
object works fine either way, since that's just manual property access,
250+
not Vue's unwrap magic. Check the actual call site before assuming which
251+
one you need.

0 commit comments

Comments
 (0)