This doc follows the source-first approach in docs/README.md.
All tests are Storybook integration stories with inline .test() assertions. There are no standalone *.test.ts or *.spec.ts files — the only .test. file is src/shared/test/actor.test.stories.tsx, which tests the kahraman integration.
The default stabilization strategy is:
- Loaded-state stories:
play: () => I.waitExit(role('status')) - Loading-state stories: do not wait for exit; assert loading UI directly
.wait()exists and is still supported, but should be rare and used for edge cases
Grepping test|spec|vitest inside an entity directory will not find its tests — they live elsewhere.
| What you are looking for | Where to look |
|---|---|
| Integration tests | src/app/integration/*.stories.tsx |
| Current product test coverage | Integration stories above |
| Reusable page actor helpers | src/pages/<page>/testing.ts |
| Mock handlers and fixture data | src/entities/<entity>/mocks/handlers.ts, .../data.ts |
| Actor/helper self-tests | src/shared/test/actor.test.stories.tsx |
To find tests for an entity, search for .test( in src/app/integration/ or look for the entity name in story file names.
| File | Why read it |
|---|---|
kahraman |
Base actor and locator DSL (I.see, role, text, .wait(), .all(), .within()) |
src/shared/test/pageActor.ts |
Application-specific actor extensions |
src/shared/test/actor.test.stories.tsx |
Focused examples of scoping and locator behavior |
src/app/integration/Articles.stories.tsx |
Canonical master-detail integration patterns (default, error, loading, detail states) |
src/app/integration/Connections.stories.tsx |
Advanced master-detail and mobile navigation coverage |
src/app/integration/Dashboard.stories.tsx |
Simple page with success/error/loading variants |
src/pages/articles/testing.ts |
Page actor style for master-detail pages |
src/pages/dashboard/testing.ts |
Page actor style for simple pages |
src/entities/item/mocks/handlers.ts |
All handler variants including retrySucceeds assert pattern |
src/app/mocks/handlers.ts |
Central default MSW handler registry |
src/shared/mocks/utils.ts |
Shared mock helpers (to500, neverResolve, etc.) |
Story tests should validate user-observable behavior, not implementation details or whatever the current markup happens to render.
Prefer assertions that are:
- Accessible: query by role, accessible name, heading, link, button, or visible text before falling back to lower-level DOM access.
- Meaningful: cover the behavior the story name promises, including important positive and negative expectations.
- Source-backed: expected fixture data should come from mocks, messages, or documented UX copy—not arbitrary strings invented to satisfy the current component output.
- Scoped: use
I.scope(...)/.within(...)for master-detail pages so list assertions do not accidentally pass against detail content, or vice versa. - Stable: wait for explicit loading/status transitions instead of adding broad sleeps or waiting for unrelated UI.
Avoid tests that only assert a generic element exists when a specific expectation is available. For example, prefer link(/Storage/).options({ current: 'page' }) over a bare link().options({ current: 'page' }).
Test names should describe what the user sees or experiences, not which assertion method is used. A good name reads like a product requirement: 'shows only electronics items after filtering'. If the name mentions a helper or DOM concept, rephrase it from the user's perspective.
Each story export represents a distinct user-visible state or interaction context. Tests on that story assert behavior meaningful for that state.
Do: split a monolithic Default story when tests exercise different interaction states.
// Each story = a distinct interaction context
export const Default = meta.story({ name: 'Default' })
Default.test('renders page heading and sample items', ...)
export const FilteredByCategory = meta.story({ name: 'Filtered by Category' })
FilteredByCategory.test('shows only electronics items', ...)
export const SortedByPrice = meta.story({ name: 'Sorted by Price' })
SortedByPrice.test('sorts ascending by default', ...)Don't: pile every test onto Default when they test different interaction states.
// Bad — filtering, sorting, and empty state all on one story
export const Default = meta.story({ name: 'Default' })
Default.test('filters by category', ...)
Default.test('sorts by price', ...)
Default.test('shows empty state', ...)Default should only contain tests for the component mounted with no interaction. The exception is integration stories where play handles stabilization and subsequent tests click through to different detail states — those are part of the same user flow.
| Variant | Name pattern |
|---|---|
| Happy path (desktop) | Default |
| Happy path (mobile) | Default (Mobile) |
| Error state | <Feature> Load Server Error |
| Error state (mobile) | <Feature> Load Server Error (Mobile) |
| Loading state | <Feature> Request Loading State |
| Loading state (mobile) | <Feature> Request Loading State (Mobile) |
Mobile test titles still use [mobile] prefix in .test(...) names.
- For loaded-state stories, add
play: () => I.waitExit(role('status')). - Apply the same rule to async error stories where UI appears after initial request resolution.
- For stories that intentionally keep loading visible (
*.loadinghandlers), do not usewaitExitfor that loading target. - For detail requests triggered by user action, click first, then wait for the relevant status to exit, then assert detail content.
List/page load failures and detail load failures are different user states. Test them separately and expect copy that matches the failed operation:
- list request failure: page/list error title and description, plus retry affordance
- detail request failure: detail-specific error title and description, scoped to
role('main')on master-detail pages - retry success: use a retry-specific MSW handler that fails first and then succeeds, click
Try again, and assert the loaded content appears - retry failure: persistent error handlers should keep the user in the same error state after
Try again - 404 detail responses: render a specific not-found state, not a generic server-error state
- persistent loading: loading status remains visible and unrelated terminal states are absent
If a test exposes that the UI copy is misleading, fix the product copy and update messages/mocks accordingly instead of weakening the assertion to match the old behavior.
The actor is codecept-style and should stay declarative. Extend per-page actors in src/pages/<page>/testing.ts.
Key base methods:
I.see(locator)/I.dontSee(locator)— assert element presence or absenceI.waitExit(locator)— wait for an element to disappear (stabilization)I.click(locator)/I.fill(locator, value)/I.selectOption(locator, value)/I.clear(locator)/I.press(key)— interactionsI.scope(locator, callback)/I.within(locator, callback)— scoped queries (aliases, both restore scope on exit)I.seeInField(locator, value)/I.dontSeeInField(locator, value)— assert input/select valueI.seeChecked(locator)/I.dontSeeChecked(locator)— assert checked state for checkboxes/radios/switchesI.seeDisabled(locator)/I.dontSeeDisabled(locator)— assert disabled stateI.seeAttribute(locator, name, value?)/I.dontSeeAttribute(locator, name)— assert attributes when no user-facing locator/state existsI.seeNumberOfElements(locator, count)— assert element count (use.all()locators)I.grabTextFrom(locator)/I.grabTextFromAll(locator)— extract text content forexpect()assertionsI.tryTo(callback)— run an assertion and returntrue/falseinstead of throwingI.retryTo(callback, maxTries, pollInterval)— retry a callback up tomaxTriestimesI.hopeThat(callback)— soft assertion: collects failures without throwing; callI.hopeThat.noErrors()at the end to fail the test with all collected errorsI.resolveLocator(locator)— escape hatch for direct DOM access
When to use grab vs see:
- Use
I.see(...)/I.dontSee(...)when you only need to assert presence. - Use state-specific actor assertions when possible:
I.seeChecked(...),I.dontSeeChecked(...),I.seeDisabled(...),I.dontSeeDisabled(...),I.seeInField(...), andI.dontSeeInField(...). - Use
I.seeAttribute(...)/I.dontSeeAttribute(...)only when the state is meaningful but not exposed through a better accessible query or actor helper. - Prefer improving accessibility over attribute assertions. If a state can be expressed in the accessible name, role, description, or visible text, expose it there and assert it with
I.see(...)instead. - Use
I.grabTextFrom(...)/I.grabTextFromAll(...)when you need the actual text for furtherexpect()comparisons (sorting, length, partial matches, computed checks). - Use
I.seeNumberOfElements(...)when you need to assert an exact count (e.g., after filtering). - Use
I.grabValueFrom(...)when you need the current.valueof a form element for anexpect()check. For most form assertions,I.seeInField(...)/I.dontSeeInField(...)are sufficient.
When to use tryTo vs hopeThat:
I.tryTo(...)for a single conditional check that should not fail the test (e.g., "does this item exist?").I.hopeThat(...)when you want to collect multiple soft failures and report them all at once. Always pair withI.hopeThat.noErrors()at the end of the test.
Prefer grab helpers over raw I.resolveLocator(...) when extracting text or values.
The actor augments failures with CodeceptJS-style diagnostics (borrowed from a
comparison experiment preserved on the experiment/codecept-comparison branch):
- On failure, the error message ends with a step trace — every actor call that ran,
✔/✖, with locator labels (e.g.✖ I.see(heading "X")). - Element-not-found output is capped by
kahraman/previewinstead of dumping the whole rendered tree. - For failed role queries, the accessible-roles listing is filtered to the queried role, so the near-miss candidate is what you see.
- The code frame is retargeted to the page-actor or story call site, rather than kahraman internals.
- A failure screenshot is written to
__screenshots__/next to the story file (gitignored). VITE_TEST_STEPS=true mise run test:run <file>logs each actor step live, likecodeceptjs run --steps.
Keep reusable expectations in src/pages/<page>/testing.ts when they describe page-level behavior shared by multiple stories. This keeps stories readable and prevents duplicated strings from drifting.
Good candidates for page actors:
- page/list/detail loading checks
- page/list/detail error checks, including descriptions and retry buttons
- canonical happy-path content checks
- common mobile navigation actions such as
goBack()
Keep story-local helpers only when they are specific to one component story or one-off interaction.
Use .wait() only for edge cases where I.waitExit(role('status')) is not the right tool.
Common valid cases:
- Asserting loading UI appears:
await I.see(role('status', 'Loading ...').wait()) - Local async transitions without a stable status-exit contract
- Targeted interaction assertions where no stable status-exit contract exists yet (for example, short timer/sidebar countdown checks or other local async UI transitions)
If a loaded-state integration story can be stabilized with play: () => I.waitExit(role('status')), prefer that over locator .wait() calls.
Each entity exposes handlers in src/entities/<entity>/mocks/handlers.ts:
.default: successful response (usually with delay).error: failing response.loading: never resolves
Default handlers are aggregated in src/app/mocks/handlers.ts and used by Storybook preview. Story-level overrides replace only specific keys.
Handlers like .retrySucceeds are factory functions (()) that close over mutable state (e.g. an error counter). The factory is required so each story gets its own isolated state.
The canonical pattern keeps successful resolvers separate from their URL bindings, then composes retry handlers with withRetrySuccess(resolver). See src/entities/item/mocks/handlers.ts for the reference implementation.
Key details about withRetrySuccess, Error500, and other error classes are in src/shared/mocks/utils.ts — error classes extend Error but return an HttpResponse via assign, so throwing them inside an MSW handler produces the corresponding HTTP error response.
Every route loader that performs a fetch must have a Storybook/browser regression proving the pending request receives and aborts a RequestInit.signal when the route stops matching. This is required for route-based fetches, including nested detail loaders.
Use .loading MSW handlers plus the shared fetch-boundary probe in #shared/test/routeFetchAbortProbe:
const usersFetchAbortProbe = createRouteFetchAbortProbe(USERS_API_PATH, 'users')
export const AbortsPendingUsersRequestOnNavigation = meta.story({
name: 'Aborts Pending Users Request On Navigation',
beforeEach: routeFetchAbortLifecycle(usersFetchAbortProbe),
parameters: {
msw: { handlers: { users: users.loading } },
},
})
AbortsPendingUsersRequestOnNavigation.test(
'aborts the pending users request when navigating away',
async () => {
await expectRouteFetchAbortOnNavigation(usersFetchAbortProbe, () => I.click(link('Timer')), {
assertLoading: () => I.seeLoading(),
})
},
)Important details:
- Put probe setup/teardown in story-level
beforeEach, returning the cleanup callback. Do not usetry/finallyinside the.test()body for normal probe cleanup. - Assert at the app
fetch(..., { signal })boundary. In the browser Storybook runner, MSW resolverrequest.signalis not a reliable proof that the client-side fetch received the route abort signal. - Keep the MSW handler pending (
.loading) so navigation away is the only thing that can abort the request. - Navigate to a non-fetching route such as
Timerwhen possible; this keeps the assertion focused on the request being torn down. - When adding or changing this check, mutation-test it locally at least once: temporarily remove the loader's
{ signal: abortVar.require().signal }, confirm the focused story fails, restore the signal, and confirm it passes.
Mobile stories use Storybook viewport globals:
globals: { viewport: { value: 'sm', isRotated: false } }
To reuse desktop configuration in mobile variants, pass parameters: DesktopStory.input.parameters.
hk check # default project quality gate
hk fix # apply auto-fixes, then validate
mise run test:run <file> # single story file
mise run test:run # single run (CI)
mise run test:coverage # single run + coverage report
mise run test # watch modeCoverage uses @vitest/coverage-v8 through vp test run --coverage.
Vite+ currently aliases vitest to @voidzero-dev/vite-plus-test. Because that package reports its Vite+ package version while bundling upstream Vitest internally, coverage can warn about mixed vitest@0.1.x and @vitest/coverage-v8@4.1.x versions. Keep @vitest/coverage-v8 pinned to the bundled upstream Vitest version and treat the warning as a Vite+ alpha aliasing false-positive. See docs/tooling.md.
Thresholds:
| Metric | Threshold |
|---|---|
| Lines | 80% |
| Functions | 80% |
| Branches | 75% |
| Statements | 80% |
Excluded from coverage:
*.stories.tsx*.test.{ts,tsx}src/shared/styled-system/src/shared/components/ui/src/main.tsx
Some branches are intentionally left uncovered because they cannot be exercised through the UI or the test infrastructure.
Defensive code unreachable from the UI:
src/pages/timer/model/model.tslines 45–46: theremaining() <= 0guard in the running change hook. The UI disables the Start button when the timer reaches zero, sorunning.setTrue()is never called with zero remaining. This is a defensive check against programmatic misuse.src/pages/calculator/ui/CalculatorPage.tsxline 43: thedefaultcase incalculate(). The function is only called when a prior operator exists (prev !== null && op), so the argument is always one of+,-,*,/. The default exists for TypeScript exhaustiveness.
Topbar conditional renders (localStorage + responsive visibility):
src/widgets/app-shell/ui/AppShell.tsxline 226: the LanguageSwitcheronValueChange. The language button is present on desktop, but the current UI does not expose the selected locale as a stable user-facing state in the top bar. Prefer adding accessible state before covering this path.src/widgets/app-shell/ui/sidebar.tsxline 17: theSidebarToggleButtonclick handler. This mobile-only button uses the same responsive CSS pattern and is not found in the accessibility tree at thesmviewport in headless tests.src/pages/settings/ui/SettingsPage.tsxlines 130, 252: the notifications form dirty save button and the language selectonValueChange. The save button shares text with the profile form's save button, making it ambiguous to target. The language select callback (localeAtom.set) is a one-line delegation already exercised by the settings Theme/Density select tests which use the sameCollectionSelect+onValueChangepattern.
Shared infrastructure guards (corrupt-storage, environment, and non-JSON defenses):
src/shared/model/locale.tslines 40–45: theisLocale(value) ? value : baseLocalecoercion inwithParamsandfromSnapshot. These run only whenlocalStorageholds a value that is not a configured locale, so normal UI flows never reach thebaseLocalefallback. Defensive against corrupted persisted state, like the timerremaining() <= 0guard above.src/shared/model/theme.tslines 7, 18–20:reatomMediaQuery('(prefers-color-scheme: dark)')and thesystembranch ofresolvedthat reads it. The resolved value depends on the hostprefers-color-schememedia query, which is not deterministic in headless Chromium; thecoerceThemePreferenceinvalid-value fallback is the same corrupt-storage defense as the locale coercion.src/shared/router.tsline 12:createAppPath's non-empty-basePathbranch (return normalizedPath ? ... : basePath).basePathis derived fromBASE_URLby stripping slashes; the app deploys at the root (BASE_URLis'/'), sobasePathis empty and theif (!basePath)branch (line 11) is the normal code path in dev and production. The line 12 branch is only reached when the app is served under a subpath.src/shared/api/index.tsline 5 (and the line 4if) and line 40 (and the line 36 nullish branch):composeApiUrl's empty/root-path early return andparseResponsePayload'sresponse.text()fallback. Every caller passes a non-empty path starting with/, and every MSW handler returns JSON or a204; the text-content and empty-path paths are defenses against malformed responses/inputs.src/pages/usage/ui/UsagePage.tsxandsrc/pages/usage/ui/UsageCard.tsx: the storage-bar color ternarypercentage >= 90 ? 'red.9' : percentage >= 70 ? 'orange.9' : 'blue.9'.percentageis derived fromUsageData; the default MSW data currently resolves to42, so normal usage stories reach theblue.9branch. Although story-specific MSW data could force the other branches, asserting the generated color token would test a presentational implementation detail rather than user-observable accessible state.
- Create typed mock data in
src/entities/<entity>/mocks/data.ts. - Add
default/error/loading/retrySucceedshandlers insrc/entities/<entity>/mocks/handlers.ts(seesrc/entities/item/mocks/handlers.tsfor reference). - Register defaults in
src/app/mocks/handlers.ts. - Create
src/pages/<page>/testing.tswith page actor methods for reusable content, loading, error, and navigation expectations. - Add
src/app/integration/<Page>.stories.tsxwithDefault,Default (Mobile), error, and loading variants. - If the route loader fetches data, add an
Aborts Pending <Feature> Request On Navigationstory withcreateRouteFetchAbortProbe,routeFetchAbortLifecycle, andexpectRouteFetchAbortOnNavigation. - Add
play: () => I.waitExit(role('status'))to loaded-state and async error variants, but not to persistent-loading or abort-probe stories. - Review the tests against the quality bar above: assertions should be specific, accessible, scoped, and backed by the intended UX/mocks.
- Find or create an error-variant MSW handler in
src/entities/<entity>/mocks/handlers.tsthat triggers the branch (e.g.logoutErrorreturning HTTP 500). - Add a story to the existing integration file (
src/app/integration/<Entity>.stories.tsx) withmsw.handlersoverriding the relevant endpoint. - Assert the user-visible outcome (e.g. redirect to login despite API failure).
- If the branch cannot be reached from the UI, document it under "Known uncovered branches" in the Coverage section above.