|
| 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