Skip to content

Frontend Unit Tests

Dejan Strbac edited this page Nov 12, 2026 · 1 revision

This page covers the frontend unit tests: the Vitest suite that runs ALPS's Lit components, stores and plugin frontends under jsdom. It explains how to run the suite, where tests go (plugin tests included), the helpers and shims every test file gets, what is typechecked, which tests are slow, and how to run the suite in a random order. Testing Overview shows how the three test tiers fit together, and the reference is the Frontend unit tests section of docs/E2E.md.

What these tests cover

The suite checks logic and render decisions: what a component shows for a given server answer, which request it sends, which event it fires, how a store reacts to a refusal. It runs under jsdom rather than a browser, so it does not see layout or paint (frontend/vitest.config.ts). No server runs: tests stub fetch (and, in the few components that use them, XMLHttpRequest or EventSource) or replace a plugin's service module. When something only fails against a real IMAP server, a real SMTP submission and a real browser together, the Browser Tests cover it.

The repository has no CI, so nothing runs these tests unless you do. make test runs only the Go Tests, and no Makefile target runs Vitest. The README asks contributors to run the Go tests and the frontend unit tests before sending a change.

Requirements

  • Node.js and npm. Use Node.js 20 (20.19 or later), 22 (22.12 or later), or 24 and later. The locked Vite 8.0.10 declares ^20.19.0 || >=22.12.0, and the locked Vitest 4.1.10 declares ^20.0.0 || ^22.0.0 || >=24.0.0, so on Node.js 23 npm prints an engine warning.
  • The packages from frontend/package-lock.json. The locked versions are Vitest 4.1.10, jsdom 27.0.1, TypeScript 6.0.3 and Vite 8.0.10.

The suite needs no Go toolchain, sora, PostgreSQL or browser. It does need bash and a POSIX /bin/sh, because e2e-harness.test.ts runs scripts/e2e.sh with stand-in programs in place of go, psql, curl, lsof, npm and npx (see Where the tests live).

Running the tests

cd frontend
npm install     # or npm ci, which installs exactly what package-lock.json lists
npm test        # typechecks the tests, then runs `vitest run`: every test file once

npm test runs npm run typecheck:test first (npm's pretest script), and Vitest starts only when the typecheck passes (see Typechecking). Run Vitest from frontend/, where vitest.config.ts lives. To run part of the suite:

npm test -- test/sieve-compiler.test.ts   # one file, after the typecheck of all tests
npm test -- sieve                         # every test file whose path contains "sieve"
npx vitest run -t "Reply All"             # tests whose full name matches the pattern, no typecheck
npx vitest --watch                        # stays running and re-runs tests when files change, no typecheck
npm run test:shuffle                      # typechecks the tests, then runs the suite in a random order

Arguments after -- go to Vitest only. The typecheck always covers the whole of frontend/test.

At main d1cc02cc (November 2026) the suite had 242 test files and 3,708 tests, and npm test took about 30 seconds on a laptop, about 4 of them for the typecheck. Main 92ec0e11 has 250 test files. Vitest runs files in parallel. With --no-file-parallelism the suite took about three minutes at a5443047.

A passing run can print a few Not implemented: navigation to another Document lines, most of them from test/mailto.test.ts. They are jsdom notices, not failures. The Test Files and Tests summary lines and the exit status show the result.

npm test does not rebuild frontend/dist. In the checkout it writes only Vitest's cache, under frontend/node_modules/.vite/vitest. e2e-harness.test.ts works in a temporary directory of its own and removes it when it finishes. npm run build does rewrite frontend/dist (see Typechecking).

Where the tests live

frontend/vitest.config.ts sets:

  • environment: 'jsdom'
  • include: ['test/**/*.test.ts']
  • setupFiles: ['test/helpers/setup.ts']

Vitest collects only frontend/test/**/*.test.ts. A *.test.ts placed next to a source in frontend/src/ or plugins/*/frontend/ is never run. A file in a subdirectory of frontend/test/ is collected.

Most files are named after the component or flow they cover, such as composer-draft-save.test.ts, mailbox-page-refresh.test.ts or login-webauthn.test.ts. Most tests fall into three kinds:

  • Module tests import a module and call it directly. For example, sieve-compiler.test.ts compiles filter rules and reads the resulting Sieve script back.

  • Component tests mount a custom element in jsdom, stub fetch (vi.stubGlobal('fetch', …)), and then check the shadow DOM, the requests and the events. password-settings.test.ts is a short example.

  • Source checks read the TypeScript sources of frontend/src and of every plugins/*/frontend with readSources() and enforce rules across all of them. Some files that contain such checks:

    • rtl-interface.test.ts fails on a physical side (margin-left, text-align: right and the like) in css blocks and style attributes, apart from a short list of allowed declarations in the test itself (LOCALIZATION.md).
    • sprite-address.test.ts fails when a source other than src/utils/ui.ts mentions sprite.svg, so icons are drawn through renderIcon() (PLUGINS.md).
    • i18n-dictionaries.test.ts checks every dictionary's keys, plural forms and placeholders, and checks that every literal t() key resolves (LOCALIZATION.md). It finds the core dictionaries by their files in src/i18n/, and expects each plugin to have a dictionary for every code in SUPPORTED_LANGUAGES, so a new language is checked as soon as its files exist.

    These checks cover a new plugin's frontend as soon as its files exist.

One file is of none of these kinds. e2e-harness.test.ts runs scripts/e2e.sh itself, with small shell scripts standing in for go, psql, curl, lsof, npm and npx, and checks where the script tells Playwright to write its traces and screenshots (see Browser Tests). It starts no real server and does not touch frontend/dist.

Plugin frontend tests

A plugin's frontend tests go in frontend/test, not in the plugin's directory. They import the plugin's code through a relative path:

import { SieveCompiler, type FilterRule } from '../../plugins/managesieve/frontend/sieve-compiler';

Two settings in frontend/vitest.config.ts are there for these imports:

  • resolve.alias pins lit, @lit, openpgp and rrule to frontend/node_modules. Plugin directories have no node_modules of their own, so without the aliases a test that imports a plugin file stops with Failed to resolve import "lit" from "../plugins/…". frontend/vite.config.ts has the same aliases for the build. A plugin frontend that starts using another package needs an alias for it in both files.
  • server.fs.allow: ['..'] lets Vite read files in the directory above frontend/, which holds plugins/. The comment beside it says it keeps such tests from failing with "Denied ID".

The in-tree plugins' tests follow the plugin's name or feature:

Plugin Test files
caldav calendar-*.test.ts, tasks-*.test.ts, assigned-tasks, invitations, meetings, dav-configuration-state
carddav contact-*.test.ts, contacts-*.test.ts
gpg gpg-*.test.ts
managesieve sieve-*.test.ts, auto-reply-settings
password password-settings

The table is not complete. Files named after a behavior rather than a plugin import plugin code too: some import one plugin, such as dav-gone (caldav) or settings-busy (managesieve), and some import several, such as field-labels, focus-visible and most sidebar-* files. To find every test that loads a plugin, search for its path, for example grep -l "plugins/caldav/" test/*.test.ts in frontend/. The base plugin has no plugin frontend: its interface is frontend/src. For the whole plugin test layout (Go tests, unit tests, browser specs), see Plugins and Testing a Plugin in docs/PLUGINS.md.

Helpers and jsdom shims

test/helpers/setup.ts runs before every test file. It installs shims for browser APIs that jsdom lacks, each one only when the API is missing:

  • scrollIntoView, as a no-op
  • ResizeObserver, as a no-op
  • <dialog> showModal(), show() and close(). close() fires close.
  • the Popover API: popover, showPopover() and hidePopover(), which fire toggle with newState
  • matchMedia, which answers min-width/max-width queries from innerWidth and returns false for every other query
  • document.execCommand, which returns false

The shims are installed once for every file. Components call these APIs from deferred work, such as a Lit update, firstUpdated or a timer. A throw there happens outside anything a test awaits, so Vitest reports it against whichever test happens to be running.

The other helper modules in frontend/test/helpers:

Module Exports Use
dom.ts mount, update, cleanup, shadow, shadowAll, text, record, flush, click, waitFor, and the install… shims Mount a custom element and wait for its first render, query its shadow root (shadow fails naming the selector), read its visible text without <style> and <script> contents, collect events, let microtasks and a setTimeout(0) settle, click and wait for the re-render, wait for a condition
sidebar.ts settleSidebar, inSidebar, setSidebar Mount sidebar content inside a real alps-sidebar, then collapse, expand or peek it
sources.ts readSources Read source text from disk with node:fs for the source checks
styles.ts rulesOf, ruleMatches, backgroundOf, backgroundsOf Parse a component's CSS and find the rules whose selector matches a node, for checks such as "does any rule paint this background". There is no cascade: specificity and inheritance are ignored and every @media block counts

Some things to know when you use them:

  • Always await mount(...). Before the first render the shadow root is empty, so a check that something is absent would pass for a component that does render it.
  • Call cleanup() from afterEach. It removes everything mount() created.
  • waitFor(condition, what, maxTicks = 200) counts ticks, not milliseconds, so a loaded machine waits as long as an idle one. It needs a real setTimeout. In a test with fake timers, either fake only Date (vi.useFakeTimers({ toFake: ['Date'] }), as meetings.test.ts does) or switch back with vi.useRealTimers() before you call waitFor.
  • readSources() reads files with node:fs on purpose. Vite's transform cache can serve a file's previous contents, and a check against stale text proves nothing.

Writing a component test

frontend/test/password-settings.test.ts shows the usual shape: import the element, stub fetch, mount, act, check, clean up. Here are its helper and its first test, with the table of cases shortened:

import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, mount, record } from './helpers/dom';
import '../../plugins/password/frontend/password-settings';

type El = HTMLElement & Record<string, any>;
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });

async function change(status: number, body: unknown) {
  vi.stubGlobal('fetch', vi.fn(async () => Response.json(body, { status })));
  const i18nStore = Object.assign(new EventTarget(), { t: (key: string) => `translated:${key}` });
  const page = await mount<El>('alps-password-settings', { i18nStore });
  page.passwordForm = { old: 'current', new: 'new password', confirm: 'new password' };
  const notices = record<CustomEvent>(window, 'show-toast');
  await page.submitPasswordChange();
  return { page, notice: notices.at(-1)!.detail };
}

describe('password change messages', () => {
  it.each([
    [403, 'current_password_incorrect', 'webauthn.errors.wrong_password'],
    [429, 'rate_limited', 'settings.password.tooManyAttempts'],
    // …
  ])('translates refusal %s/%s while keeping the entered passwords', async (status, code, key) => {
    const { page, notice } = await change(Number(status), { code, error: 'English from server' });
    expect(notice.message).toBe(`translated:${key}`);
    expect(notice.type).toBe('error');
    expect(page.passwordForm.old).toBe('current');
  });
});

A test whose result depends on the time zone should set the zone itself, as calendar-views.test.ts and contact-view.test.ts do: they set process.env.TZ and restore it in afterEach. A test that leaves the zone alone runs in the machine's zone and must pass in every zone (see Slow tests, test order and time zones). A test that depends on the date fixes the clock with vi.useFakeTimers({ now: ... }) or vi.setSystemTime(...).

Typechecking

Command (in frontend/) What it checks Side effect
npm run build tsc over src and ../plugins/*/frontend/**/*.ts (the include of tsconfig.json), then vite build Rewrites the checked-in frontend/dist
npx tsc The same tsc step without the build (tsconfig.json sets noEmit: true) None
npm run typecheck:test The unit tests: test and vitest.config.ts, through tsconfig.test.json, with Vite's client types and Node.js types, and every source file they import. npm test runs it first None
npm run typecheck:e2e The browser specs: e2e and playwright.config.ts, through tsconfig.e2e.json, with Node.js types None

tsconfig.test.json extends tsconfig.json, so the tests are checked with the sources' compiler options, and an unused import, local or parameter in a test is an error. Apart from include, it differs only in types: it adds the Node.js types, which the test helpers that read files or start processes need. A separate config keeps those types out of tsconfig.json, where browser code would otherwise see Node.js globals. Vitest itself compiles the tests without checking their types, so npx vitest run and npx vitest --watch run a test file that has type errors.

A change to a component's types can break a test that uses it. npx tsc and npm run build do not see that, because they leave frontend/test out. npm test, or npm run typecheck:test on its own, does.

Slow tests, test order and time zones

The docs list no frontend unit test as slow or flaky. In the runs made for this page, the suite passed every time in its default order. These files took the longest:

File Time (one run)
login-webauthn.test.ts about 8 s
reader-conversation-actions.test.ts about 7.5 s
login-webauthn-recovery.test.ts, contact-save-lifetime.test.ts, mailbox-page-refresh.test.ts, mailbox-page-listing-cache.test.ts, mailbox-page-failed-switch.test.ts, settings-store.test.ts about 3.5 s each

Because files run in parallel, these times overlap. The Vitest part of the run still takes about 25 seconds.

Test order

npm test runs the files in parallel and the tests within each file in the order they are written. npm run test:shuffle typechecks the tests, as npm test does, then runs vitest run --sequence.shuffle, which shuffles both the files and the tests within each file. A test that only passes after another test has run, or that breaks when another test runs first, fails there. Vitest prints the order's seed at the start, as Running tests with seed "<seed>", and the same seed gives the same order again:

npm run test:shuffle
npm run test:shuffle -- --sequence.seed=<seed>

Each test should start from the state a fresh page has. Tests of components that keep state outside the element, in localStorage, in a store that outlives a page, or in navigation still queued from the last test, put that state back in beforeEach. auto-reply-settings.test.ts, mailbox-page-failed-switch.test.ts and mailbox-page-select-all-matching.test.ts, for example, fire session-cleared on window, as a sign-out does, and clear localStorage before each test. The last of these also lets the navigation an earlier test queued settle before it mounts its page. A test that needs jsdom to treat a focus as keyboard focus says so itself rather than relying on the previous test's clicks, as the keyboard-focus test in popup.test.ts does.

Time zones

The full suite passed with TZ=UTC, with TZ=Pacific/Kiritimati (UTC+14) and in Central European time in the runs made for this page. A test that runs in the machine's zone has to hold in every zone, including those far from UTC, such as America/Adak (UTC−9 in September) and Pacific/Kiritimati (UTC+14). meetings.test.ts, for example, moves a meeting by adding the same number of days to the start and end dates the editor shows, rather than by setting fixed dates, so a meeting that crosses midnight in the machine's zone keeps its length.

When a test fails

  • If npm test stops before Vitest starts, with error TS… lines from tsc, the typecheck failed. Each line names the file and position. Fix the types; npx vitest run shows the tests' own results in the meantime.
  • Re-run the one file (npm test -- test/<file>.test.ts) or the one test (npx vitest run -t "<name>") to get a short report.
  • An error that Vitest reports as unhandled can come from a different test. A component's deferred work can throw after its own test has finished, and the error is then reported against whichever test is running. If a new component calls a browser API that jsdom lacks, a shim in test/helpers/dom.ts, installed from setup.ts, is the place for it.
  • Each test file runs in its own environment, but the tests within a file share one. If a test passes when run alone with -t and fails when its whole file runs, or fails only under npm run test:shuffle, an earlier test in that file has left something behind. Check that the file calls cleanup() and vi.unstubAllGlobals() (or restores its spies) in afterEach, and puts back any state kept outside the element in beforeEach (see Test order).
  • A test that imports a plugin file and stops with Failed to resolve import "<package>" from "../plugins/…" reaches a package that resolve.alias does not pin. See Plugin frontend tests.

See also

Clone this wiki locally