The SPA’s tests run under Vitest in a simulated
browser (happy-dom), against a mock REST API built with
MSW. No test talks to a real backend: every fetch
the SPA makes is answered by a mock handler, and a request to an endpoint
with no handler fails the test.
The harness covers four kinds of test:
src/domain/, src/models/), the
shared composables (src/composables/) and single stores.Tests exercise behaviour through public interfaces (store actions and state, exported API functions, rendered output and navigation), so that refactoring the internals does not require rewriting them.
An architecture test (tests/architecture.test.ts) checks the
layering rules of architecture.md on every run.
Browser end-to-end testing (Playwright) is out of scope for this harness.
All commands run from frontend/:
pnpm test # run every test once
pnpm test:watch # re-run affected tests on file changes
pnpm test:coverage # run once with v8 coverage and thresholds
pnpm test tests/stores/session.test.ts # one file
pnpm test tests/stores/ # one directory
pnpm test -t "shares one refresh" # tests whose name matches
pnpm test tests/api/http.test.ts -t "401 handling"
From the repo root, make test-frontend runs pnpm test.
pnpm type-check (vue-tsc -b) type-checks the tests as well as src/,
through tsconfig.vitest.json. pnpm fmt / pnpm fmt:check format
src/ and tests/.
frontend/
vitest.config.ts # merges vite.config.ts; environment, setup, coverage
tsconfig.vitest.json # type-checks tests/ (types: vitest, node, vite/client)
tests/
setup.ts # global setup, the conftest.py analogue
architecture.test.ts # layering rules over every src/ import
mocks/
server.ts # MSW server + request log
handlers.ts # default happy-path handlers for every endpoint
factories.ts # make* DTO factories (factory-boy analogue)
helpers/ # fixtures, re-exported from helpers/index.ts:
# withAuth, expire, respondOnce401 (auth.ts)
# withAccounts (accounts.ts)
# mountWithApp (mount.ts), withSetup (withSetup.ts)
domain/ # tests for src/domain/
api/ # tests for src/api/
models/ # tests for src/models/
stores/ # tests for src/stores/
composables/ # tests for src/composables/
features/ # tests for src/features/
components/ # tests for src/components/
views/ # tests for src/views/
router/ # tests for src/router/
tests/ mirrors src/, the same convention as app/tests/ mirroring
app/. A test for src/stores/budgets.ts is tests/stores/budgets.test.ts;
a test for src/views/LoginView.vue is tests/views/LoginView.test.ts.
Only files named *.test.ts are collected.
Test APIs are imported explicitly (import { describe, expect, it } from
"vitest"); Vitest globals are not enabled.
| pytest | Here |
|---|---|
conftest.py |
tests/setup.ts (runs before every test file) |
| factory-boy factories | tests/mocks/factories.ts (makeBudget({...})) |
| fixtures | tests/helpers/ (withAuth(), withAccounts(), mountWithApp(), withSetup()) |
@pytest.mark.parametrize |
it.each([...]) / describe.each([...]) |
| freezegun | vi.useFakeTimers() + vi.setSystemTime(...) |
pytest.mark.xfail(strict=True) |
it.fails(...) |
mocker.patch |
vi.spyOn(...), vi.fn() (restored after each test) |
vitest.config.ts merges vite.config.ts, so the @/ alias and the Vue
plugin are the same as in the app build. It sets:
environment: 'happy-dom' with a page URL of http://localhost/app/.
src/api/http.ts fetches relative URLs (/api/v1/...); the page URL
gives them an origin to resolve against, as the Django-served shell does
in the browser.setupFiles: ['tests/setup.ts'], include: ['tests/**/*.test.ts'].restoreMocks: true and unstubEnvs: true, so spies and vi.stubEnv()
values are undone after every test.env: { TZ: 'America/New_York', LC_ALL: 'en_US.UTF-8' }, so local-time
arithmetic and toLocaleDateString(undefined, ...) output are the same
on every machine (see Time and timezones).vite.config.ts loads its mkcert certificates from deployment/ssl/ only
when they exist and otherwise leaves the dev server on plain HTTP, so the
config loads under Vitest in CI, where the certificates are absent.
fetch reaches MSWThe environment is happy-dom (not jsdom). Vitest’s happy-dom environment
installs happy-dom’s fetch, Request, Response, Headers and
FormData as globals. MSW’s setupServer().listen() (from msw/node)
then replaces globalThis.fetch with an interceptor that wraps whatever
fetch was installed, so the single fetch() call in src/api/http.ts
reaches the mock handlers. This was verified with a throwaway test before
the harness was built; no delegation to Node’s fetch is needed.
Two happy-dom behaviours the harness accounts for:
Headers keeps the header-name case the caller used. The request log
lower-cases names, as the Fetch spec does, so tests read
headers.authorization.FormData body is serialized with a multipart/form-data; boundary=...
content type set by happy-dom, as a browser would, so the transport’s
“don’t set Content-Type for FormData” behaviour is observable.tests/setup.ts doeswindow.__mibudge = { adminEmail: 'admin@example.com' } at module
level, as the Django shell template does in production
(useShellConfig() reads it).onUnhandledRequest: 'error'.resetPlugin, as main.ts does.createApp({}), because Pinia applies plugins
only once it is installed in an app.initApi(createSessionHttpClient()), so the api registry
uses that Pinia’s session store for tokens and refresh.sessionStorage and localStorage.enableAutoUnmount(afterEach) unmounts every mounted component, so
modal scroll locks and window listeners don’t leak.server.resetHandlers() drops any server.use(...) overrides.tests/mocks/handlers.ts has a happy-path handler for every endpoint the
SPA calls, with paths and shapes from docs/openapi.yaml (list them with
grep -n '^ /api' docs/openapi.yaml). List endpoints return DRF’s
{count, next, previous, results} envelope with one factory-built item;
detail endpoints echo the requested id; PATCH/POST handlers echo the
submitted fields. POST /api/token/ returns LOGIN_TOKEN,
POST /api/token/refresh/ returns REFRESHED_TOKEN and
POST /api/token/logout/ returns 204.
The defaults do not check the Authorization header; a test that needs a
401 installs one (below).
tests/mocks/factories.ts builds schema-valid DTOs with overridable
fields: makeUser, makeBank, makeBankAccount, makeBudget,
makeTransaction, makeAllocation, makeInternalTransaction,
makeFundingSummary, makeApiKey, makeInvitation,
makeNotificationPreference, makeChannelPreference, and makePage(results)
for the pagination envelope. Money values are decimal strings ("12.34"),
as the API sends them. Ids come from a sequence, so every object is
distinct.
const account = makeBankAccount({ name: "Household" });
const rent = makeBudget({ name: "Rent", budget_type: "R", bank_account: account.id });
const page = makePage([rent], { next: "http://localhost/api/v1/budgets/?page=2" });
server.use(...) prepends handlers for the current test only:
import { http, HttpResponse } from "msw";
import { server } from "../mocks/server";
server.use(
http.get("/api/v1/budgets/", () => HttpResponse.json(makePage([rent]))),
http.get("/api/v1/bank-accounts/:id/funding-summary/", () =>
new HttpResponse(null, { status: 500 }),
),
);
Pass { once: true } as the third argument of http.get(...) for a
handler that answers only the next matching request.
tests/helpers/auth.ts:
withAuth(token?, user?) puts the session store into the logged-in
state: an access token (default TEST_TOKEN) and a user (a UserDto,
default makeUser(), mapped to the model).expire(token?) makes every /api/v1/* request carrying
Bearer <token> get 401, as the backend answers an expired access
token. Requests with any other token fall through to the normal handler,
so after a refresh the retry succeeds.respondOnce401(path, method?) answers only the next request to path
with 401, e.g. respondOnce401("/api/token/refresh/", "post") for an
expired refresh cookie or respondOnce401("/api/token/", "post") for bad
credentials.withAuth();
expire(TEST_TOKEN);
await api.budgets.list(); // 401 → refresh → retry with REFRESHED_TOKEN
tests/helpers/accounts.ts:
withAccounts(dtos, activeId?) seeds the bank-accounts cache with the
given BankAccountDtos and makes the first (or activeId) the active
account, without going through the API. It returns the
account-context store.tests/mocks/server.ts records every intercepted request, in order, as
{method, url, path, headers, body} (path is pathname + query string;
header names are lower-case; body is parsed JSON, field → value for
multipart with a File recorded as its name, or undefined). The query
helpers are async because body parsing is:
import { lastRequest, requestLog, requestsTo } from "../mocks/server";
expect(await requestsTo("POST", "/api/token/refresh/")).toHaveLength(1);
const req = await lastRequest();
expect(req?.path).toBe("/api/v1/budgets/?bank_account=abc&archived=false");
expect(req?.headers.authorization).toBe(`Bearer ${TEST_TOKEN}`);
expect(req?.body).toEqual({ name: "Rent" });
requestsTo matches the pathname exactly and ignores the query string.
src/domain/ and src/models/ are pure, so import the function and
call it. Prefer it.each tables:
it.each([
["RRULE:FREQ=MONTHLY;BYMONTHDAY=1,15", "Every month on the 1st and the 15th"],
["RRULE:FREQ=WEEKLY;BYDAY=MO,FR", "Every week on Mo, Fr"],
])("%j → %j", (rule, text) => {
expect(rruleHuman(rule)).toBe(text);
});
Model tests (tests/models/) build a DTO with a factory, map it, and
assert on the model. They also run the reverse mapping for request
bodies, including the null, empty-string and default cases:
const budget = budgetFromDto(makeBudget({ balance: "12.50", target_date: "" }));
expect(budget.balance.equals(Money.of("12.50"))).toBe(true);
expect(budget.targetDate).toBeNull();
tests/api/http.test.ts covers the transport (createHttpClient):
paths, headers, bodies and query strings, pagination, empty responses,
DRF error parsing, and the 401 → refresh → retry cycle. It builds its own
client with a stub refresh. Its “session-wired client” block runs the
same cycle through createSessionHttpClient() and the session store.
tests/api/resources.test.ts is one it.each table with a row per
endpoint function in src/api/resources/*.ts, called through the api
registry (api.budgets.list(...)). Each row gives:
/api/v1, with trailing slash and query string;The test answers that exact endpoint with a unique payload. It checks
that the function sends the request as described and returns the
payload. api is bound to the session-wired client in tests/setup.ts,
and the table runs with withAuth().
Setup already activates a fresh Pinia, so call the store directly and assert on its public state after awaiting its actions:
withAuth();
server.use(http.get("/api/v1/budgets/", () => HttpResponse.json(makePage([rent]))));
const store = useBudgetsStore();
await store.fetchList({ bank_account: account.id });
expect(store.byId(rent.id)?.name).toBe("Rent"); // cached as a model
Call a composable that uses only reactivity directly inside the test.
A composable that registers lifecycle hooks or onScopeDispose (for
example useModal, useInfiniteList or useFindShortcut) must run
inside a component. Use withSetup from tests/helpers:
const { result, wrapper } = withSetup(() => useModal(() => open.value, onClose));
// ...
wrapper.unmount(); // runs the composable's cleanup
A feature composable that needs the router uses mountWithApp with a
small host component instead, or is tested through its view.
Mount with mountWithApp(Component, { route, props, pinia }) from
tests/helpers/mount.ts. It installs the app’s real router (memory history,
so no browser URL changes) and a Pinia, navigates to route, mounts, and
flushes pending promises.
A presentational component (src/components/) needs no router, store
or mock API. Mount it with mount(Component, { props }) and assert on
the output and wrapper.emitted().
To check which store actions a container calls, pass a testing Pinia
with stubActions: false. In a setup store every returned function is
an action, including lookups like bankAccounts.byId. With stubbed
actions those would return undefined, so here actions are spied on
and still run:
const pinia = createTestingPinia({
createSpy: vi.fn,
stubActions: false,
initialState: {
session: { accessToken: "t" },
bankAccounts: { accounts: [bankAccountFromDto(account)], loaded: true },
accountContext: { activeBankAccountId: account.id },
},
});
await mountWithApp(AppShell, { route: "/budgets/", pinia });
expect(useBudgetsStore(pinia).fetchOne).toHaveBeenCalledWith(account.unallocated_budget);
initialState is keyed by each store’s returned state refs:
bankAccounts.accounts, budgets.cache.
Use @pinia/testing 1.x; 2.x requires Pinia 4.
Seed the stores the view reads (withAuth(), withAccounts()), set up
any server.use(...) overrides, then mountWithApp and assert on the
rendered output, the request log and the router:
withAuth();
withAccounts([account]);
server.use(http.get("/api/v1/budgets/", () => new HttpResponse(null, { status: 500 })));
const { wrapper } = await mountWithApp(BudgetsView, { route: "/budgets/" });
expect(wrapper.text()).toContain("HTTP 500");
Route components are lazy-loaded. When a view navigates without awaiting
the navigation (for example LoginView calls router.replace() after
login), wait for it with
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe("/")).
createAppRouter(createMemoryHistory("/app/")) from src/router builds a
router with the app’s routes and auth guard; push a path and read
router.currentRoute.value.
The suite runs with TZ=America/New_York and LC_ALL=en_US.UTF-8
(env in vitest.config.ts, passed to the worker process), so “local
time” is Eastern and default-locale date formatting is US English.
Freeze the clock with fake timers:
afterEach(() => vi.useRealTimers());
it("...", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-24T03:30:00Z"));
expect(todayDateStr("America/Los_Angeles")).toBe("2026-09-23");
});
Fake timers also replace setTimeout; with MSW or pending promises, use
vi.useFakeTimers({ toFake: ["Date"] }) to freeze only the clock.
To run a single test in another browser timezone, stub TZ; Node applies
a TZ change immediately, and unstubEnvs restores New York afterwards:
it("in Asia/Tokyo", () => {
vi.stubEnv("TZ", "Asia/Tokyo");
expect(parseLocalDate("2026-08-01").getDate()).toBe(1);
});
The profile timezone (user.timezone) is a separate input: pass it to the
date helpers, or set it with withAuth(TEST_TOKEN, makeUser({ timezone })).
Every test has a Gherkin comment block directly above its it(...). It
describes what the production code guarantees, not what the test does:
// GIVEN: three requests in flight whose access token has expired
// WHEN: each receives 401 and the transport refreshes the token
// THEN: exactly one refresh is sent to the server
// AND: every original request is retried with the new token and resolves
//
it("shares one refresh across concurrent 401s", async () => { ... });
Test mechanics (MSW overrides, fake timers, why a value was chosen) go in
ordinary comments next to the code that sets them up, never in the
Gherkin. A table-driven it.each has one block describing the rule the
table exercises.
//,
directly above the code it describes.src/.it.failsA known bug is recorded as an it.fails(...) test that asserts the
correct behaviour, with a comment naming the bug. The test passes
while the bug exists and fails once the bug is fixed, which prompts the
fixing change to convert it to a plain it(...):
// Known bug: `formatThing` (src/domain/thing.ts) drops the last
// character of a two-word name ("Grocerie").
// Convert to `it(...)` when the bug is fixed.
//
it.fails("keeps the whole name", () => {
expect(formatThing("Weekly Groceries")).toBe("Weekly Groceries");
});
Current it.fails tests:
| Test | Bug |
|---|---|
| — | None. The last one (formatDateHeader showing the previous day when the browser zone differed from the profile zone) was fixed and is now a plain it.each over browser zones in tests/domain/dates.test.ts. |
pnpm test:coverage runs v8 coverage over src/** and writes an HTML
report to frontend/coverage/ (gitignored by the root .gitignore), plus a
text summary on the console.
Per-directory line thresholds in vitest.config.ts fail the run when
coverage drops below them:
| Directory | Lines |
|---|---|
src/api/** |
80% |
src/composables/** |
80% |
src/domain/** |
80% |
src/models/** |
80% |
src/stores/** |
80% |
src/api/schema.d.ts is a type declaration file and has no runtime
code to cover.
Views and components have no threshold yet. To raise a threshold or add
one, edit coverage.thresholds in vitest.config.ts, for example
'src/components/**': { lines: 60 }, and check pnpm test:coverage still
passes. Raise thresholds as coverage grows so they keep guarding it.
The Drone mibudge Tests pipeline has a frontend tests step that runs
pnpm test:coverage in node:22. It depends on frontend lint, which
runs pnpm install --frozen-lockfile into the shared
frontend-node-modules volume, and runs in parallel with frontend build.
A failing test, an unmet coverage threshold, or a layering violation
caught by the architecture test fails the pipeline.
The frontend lint step also checks that the generated API types are
current: pnpm gen:api-types && git diff --exit-code src/api/schema.d.ts.
After changing the REST API, run make api-schema and
pnpm gen:api-types, and commit both files (see
api-and-models.md).
tests/architecture.test.ts reads the import statements of every file
under src/: static, side-effect, dynamic and re-export imports,
including SFC <script> blocks. It resolves relative imports to @/...
and checks each layer’s forbidden imports (see
architecture.md). It also checks that
api/http.ts is the only file that calls fetch(.
A violation fails with the file and the import:
× components/ are presentational
- Expected: []
+ Received: ["components/budgets/BudgetCard.vue imports @/api"]
Fix it by moving the call up a layer, not by adding an exception. The
rule table’s allowed list is for type-only or error-helper modules
(@/api/dto for models, @/api/errors for composables).
Every Drone step installs pnpm@9, matching Dockerfile, the lockfile
format (lockfileVersion: '9.0'), and packageManager in
frontend/package.json. Use pnpm 9 locally too; a different major
rewrites pnpm-lock.yaml, and CI’s --frozen-lockfile install fails on a
stale lockfile. After pnpm add, commit the updated pnpm-lock.yaml.
A new API function in src/api/resources/*.ts
tests/mocks/handlers.ts
if none exists (shape from docs/openapi.yaml).tests/api/resources.test.ts: call, method, path
with query string, JSON body.tests/mocks/factories.ts if it returns a new DTO.A new or changed model in src/models/
it.each table in tests/models/<model>.test.ts (or a block in
tests/models/resources.test.ts for small models) for *FromDto
and any *To*Dto: nulls, empty strings, defaults, money and dates.A new or changed store in src/stores/
tests/stores/<store>.test.ts: each action’s effect on public state,
the requests it sends (request log), and its error path.reset(). Add its seeded state to the sign-out
test in tests/stores/reset.test.ts.A new composable in src/composables/
tests/composables/<name>.test.ts, using withSetup if it
registers lifecycle hooks: its state transitions, cleanup on
unmount, and the stale-response or race case if it loads data.A new feature composable or component in src/features/
tests/views/) or directly in
tests/features/<section>/: the requests it sends, the store
updates it makes, and its error message.A new view in src/views/
tests/views/<View>.test.ts with mountWithApp: rendered data comes
from the mock response, the error state appears on a failing
endpoint, and any navigation it triggers lands.tests/router/guards.test.ts row for whether
it is public or protected.A new component in src/components/
tests/components/<Component>.test.ts for props → rendered output
and emitted events.A new pure helper in src/domain/
it.each table in tests/domain/<module>.test.ts, including edge
cases (empty input, month/year boundaries, timezones).A bug you found but are not fixing
it.fails test asserting the correct behaviour, with a comment
naming the bug, and a row in the it.fails table above.