Testing
Northplane is tested at four layers. Each has one command you run locally and one CI job that gates merges; the e2e layer additionally gates the production deploy because the Deploy workflow only runs after a fully green CI run on main.
| Layer | What it proves | Local command | CI job |
|---|---|---|---|
| Go unit/integration tests | packages, storage on SQLite, API handlers via httptest, server wiring |
make test / make race |
test (ubuntu + macos) |
| Storage on PostgreSQL | the dual-backend store against a real PostgreSQL 16 | NORTHPLANE_TEST_PG_DSN=… go test ./internal/storage/... |
postgres (non-blocking) |
| Static analysis | gofmt, go vet, golangci-lint | make fmt, golangci-lint run ./... |
lint |
| Frontend unit/component | TypeScript, ESLint, Vitest + Testing Library + MSW in jsdom | cd web && npm run lint && npm test && npm run build |
ui |
| Typed codegen drift | generated types.gen.ts and docs/src/assets/openapi.json match the Go API |
make types-check |
types |
| End-to-end | the real northplaned --demo binary with the embedded SPA driven through Chromium |
make e2e |
e2e |
| Docs build | the Starlight site compiles and has no broken links | cd docs && npm run build |
docs |
Go tests
Section titled “Go tests”make test # go vet ./... && go test ./...make race # go test -race ./... (what CI runs)go test ./internal/storage/... -run TestAuditChain -vThere are 92 _test.go files under cmd/ and internal/. Conventions you will meet:
- Stores in a temp dir. Storage and API tests open a real SQLite store in
t.TempDir(); nothing touches your data directory. - HTTP through
httptest. API tests build anapi.APIand exercise handlers overhttptestservers (for exampleinternal/api/objects_test.gofor the ETag/If-Match flow,tenant_switch_test.gofor theX-Northplane-Tenantrules,openapi_spec_test.goandopenapi_docs_test.gofor the generated spec and Swagger UI).internal/server/server_test.goboots the whole process (TLS policy, security headers,/api/openapi.json). - Embedded assets compile in tests.
internal/webandinternal/docsembeddist/withgo:embed all:dist; both directories exist in a fresh checkout (the committed UI snapshot, andinternal/docs/dist/.gitkeep), andmake cleanrecreates the stubs, sogo test ./...never needsmake webormake docsfirst.internal/docs/docs_test.godrives the handler against an in-memory tree (HandlerFS) and covers routing, the canonicalindex.htmlredirect, pre-compressed serving, write rejection and the “not embedded” 501. - Fuzzing.
internal/nagioshasFuzzParsePerfdata; run it withgo test ./internal/nagios -run '^$' -fuzz FuzzParsePerfdata -fuzztime 30s. Generated corpora land intestdata/fuzz/, which is git-ignored.
PostgreSQL matrix
Section titled “PostgreSQL matrix”The store is dual-backend, and the storage suite runs every test against both when a DSN is present:
testStores(t)(internal/storage/storage_test.go) always opens SQLite in a temp dir; whenNORTHPLANE_TEST_PG_DSNis set it also opens PostgreSQL, andmatrix(t, fn)runs the body once per backend.resetPostgresexecutesDROP SCHEMA public CASCADE; CREATE SCHEMA public;before each PostgreSQL-backed test so the shared database starts pristine (without it audit chains grew across tests and object identities collided). Point the DSN at a throwaway database only.
docker run --rm -d --name np-pg \ -e POSTGRES_USER=np -e POSTGRES_PASSWORD=np -e POSTGRES_DB=northplane \ -p 5432:5432 postgres:16NORTHPLANE_TEST_PG_DSN='postgres://np:np@localhost:5432/northplane?sslmode=disable' \ go test ./internal/storage/...golangci-lint
Section titled “golangci-lint”CI runs golangci-lint v2.12.2 with the committed .golangci.yml (v2 format). Install the same version locally and run it from the repository root:
golangci-lint run ./...CI installs it with go install rather than downloading a prebuilt binary: the prebuilt releases are compiled with an older Go and abort against a go.mod that targets a newer one (“the Go language version used to build golangci-lint is lower than the targeted version”).
What the configuration says:
| Section | Setting | Effect |
|---|---|---|
issues |
max-issues-per-linter: 0, max-same-issues: 0 |
report every finding (no 50-per-linter / 3-identical caps) |
linters.default |
standard |
errcheck, govet, ineffassign, staticcheck, unused |
linters.enable |
misspell, unconvert, bodyclose |
high-signal extras that pass cleanly |
errcheck.exclude-functions |
fmt.Fprint, fmt.Fprintf, fmt.Fprintln, fmt.Sscanf, (*bufio.Writer).Write/WriteByte/WriteString/Flush, (*text/tabwriter.Writer).Flush, (*database/sql.Rows).Close, (io.ReadCloser).Close |
value-less returns only (stdout/stderr/builders, sticky buffered-writer errors surfaced at the checked Flush(), read-side closes); real I/O errors are never excluded |
exclusions.paths |
web/node_modules |
vendored third-party Go under node_modules is not linted |
exclusions.rules |
_test.go: errcheck on Close/Unsetenv; _test.go: bodyclose |
teardown closes and the test request helpers that already close resp.Body |
The lint job also fails when gofmt -l cmd internal lists any file; make fmt fixes that.
Frontend unit and component tests (Vitest)
Section titled “Frontend unit and component tests (Vitest)”cd webnpm test # vitest runnpm run test:watch # vitest (watch mode)npm run test:cov # vitest run --coverage (v8)Configuration lives in web/vitest.config.ts, separate from vite.config.ts so the app build stays untouched:
| Setting | Value |
|---|---|
environment |
jsdom |
globals |
true (describe/it/expect/vi without imports; ESLint registers the Vitest globals for test files) |
setupFiles |
./src/test/setup.ts |
css |
false |
include |
src/**/*.test.ts, src/**/*.test.tsx |
coverage |
provider v8, src/**/*.{ts,tsx} minus src/test/**, *.test.* and src/main.tsx |
| alias | @ → ./src (same as the app) |
The harness in web/src/test/:
setup.ts— registers jest-dom matchers; starts the MSW server withonUnhandledRequest: 'error'(an unexpected fetch fails the test), resets handlers after each test and closes the server afterwards; polyfills jsdom gaps: an in-memorylocalStorage(Node ≥ 22 ships its own that shadows jsdom’s),matchMedia,ResizeObserver,scrollIntoView,window.scrollTo.msw.ts— exportsserver,handlersandsampleProblems. Default handlers answerGET /api/v1/problems(two sample rows),GET /api/v1/alerts(empty) andGET /api/v1/events(empty). Override per test withserver.use(...).render.tsx—renderWithProviders(ui)renders inside a freshQueryClient(retries off,gcTime0) and an in-memory TanStack router with stub routes/objects,/objects/$id,/alerts,/incidents.
A page test looks like this (trimmed from src/pages/Problems.test.tsx):
import { describe, it, expect } from 'vitest'import { http, HttpResponse } from 'msw'import { screen } from '@testing-library/react'import { server } from '../test/msw'import { renderWithProviders } from '../test/render'import { ProblemsPage } from './Problems'
describe('<ProblemsPage />', () => { it('renders a row per problem', async () => { renderWithProviders(<ProblemsPage />) expect(await screen.findByText(/web-01 \/ http/)).toBeInTheDocument() })
it('shows the all-green empty state when there are no problems', async () => { server.use(http.get('/api/v1/problems', () => HttpResponse.json({ items: [] }))) renderWithProviders(<ProblemsPage />) expect(await screen.findByText(/all green|alles grün/i)).toBeInTheDocument() })})Note the bilingual regex: the existing tests tolerate both catalogs where a label is language-dependent.
There are 31 test files: api, branding, permissions, schemas, settings, tenant, types, types.gen at src/*.test.ts; components DualListPicker, TenantSwitcher, admin/{Agents,Bundles,Channels,DeadLetters,MCP}, alerting/IVRMenus, dash/{grid,series,util}, objects/ObjectForm; libs duration, humanize, perfdata, redact, uistream; pages Alerts, Events, Maintenance, Overview, Problems, Templates.
Two type-level checks run as part of npm run build (tsc -b), not Vitest: src/types.gen.conformance.ts asserts at compile time that the curated types in types.ts refine the generated DTOs in types.gen.ts (renamed or retyped backend fields fail the build), and tsconfig.app.json excludes *.test.ts(x) and src/test from that strict build — Vitest type-checks those on its own. Details are on the Frontend page.
End-to-end tests (Playwright)
Section titled “End-to-end tests (Playwright)”The e2e suite drives the real northplaned --demo binary — Go backend plus the embedded SPA — through Chromium. It is the one suite that proves the product works through a browser, which is why the e2e CI job is part of the gate in front of the production deploy.
make e2e # make web, build bin/northplaned, install Chromium, runcd web && npm run test:e2e # re-run against the existing bin/northplanedcd web && npm run test:e2e:report # open the HTML reportmake e2e rebuilds the UI and the binary first because the tests exercise the embedded SPA, not Vite — a stale bin/northplaned tests stale UI. Running npm run test:e2e directly requires bin/northplaned to exist (global setup fails with “Build it first: make build” otherwise).
Configuration (web/playwright.config.ts)
Section titled “Configuration (web/playwright.config.ts)”| Setting | Value | Why |
|---|---|---|
testDir / testMatch |
./e2e, **/*.spec.ts |
|
workers / fullyParallel |
1 / false |
one shared demo database; tests run serially and never race on mutations |
timeout / expect.timeout |
30 s / 7 s | |
actionTimeout / navigationTimeout |
10 s / 15 s | |
retries |
1 in CI, 0 locally |
|
forbidOnly |
in CI | a stray test.only fails the run |
reporter |
list (+ html with open: 'never' in CI) |
|
globalSetup / globalTeardown |
./e2e/global-setup.ts, ./e2e/global-teardown.ts |
boot/teardown the isolated demo server |
baseURL |
http://127.0.0.1:18973 (PW_PORT overrides) |
|
locale / timezoneId |
de-DE / Europe/Vienna |
deterministic German selectors regardless of the machine locale |
storageState |
the operator role by default; test.use({ storageState: authFile('admin') }) to switch |
|
trace / screenshot / video |
retain on failure / only on failure / retain on failure | |
projects |
chromium (Desktop Chrome) |
What global setup does
Section titled “What global setup does”- Requires
bin/northplaned; creates a temp data dirnp-e2e-*with a random 32-bytesecret.keyand aconfig.yaml(listen 127.0.0.1:<PORT>,baseUrl,dataDir,logLevel warn). - Runs
northplaned bootstrap-admin -config …and parses thenp_…token. - Spawns
northplaned serve -config … --demodetached withNP_DEFAULT_ADMIN_DISABLED=1, waits for/readyz. - Creates the admin user
[email protected]/e2e-admin-pass-2026through the API; the operator ([email protected]/operator-demo-2026!) and viewer ([email protected]/viewer-demo-2026!) come from the demo seed. - Form-logs-in each role via
POST /loginand saves thenp_sessioncookie as a Playwright storage state inweb/e2e/.auth-<PORT>/<role>.json; writesweb/e2e/.runtime-<PORT>.jsonwith pid, data dir and port.
Teardown sends SIGTERM to the process group and deletes the runtime file, the temp data dir and the auth dir. Because the port is part of every file name, several suites can run side by side with different PW_PORT values (parallel worktrees, authoring agents). Git ignores web/test-results/, web/playwright-report/, web/blob-report/, web/playwright/.cache/, web/e2e/.auth-*/ and web/e2e/.runtime-*.json.
Spec files
Section titled “Spec files”| File | Tests | Covers |
|---|---|---|
smoke.spec.ts |
4 | operator lands on the shell; Objects shows demo objects; anonymous is redirected to /login; viewer can load the app |
navigation.spec.ts |
12 | every sidebar route renders without ErrorState; active link; overview tiles/incidents/on-call; KPI drill-down; problems list + handled toggle; ⌘K palette; BPI tree → SLA; reports; on-call; ⌘I assistant |
objects.spec.ts |
7 | demo hosts/services; kind and state filters ↔ URL; full-text search; host create/edit/delete; service create + delete; “Jetzt prüfen” |
alerts-events.spec.ts |
9 | alerts list; status/severity filters drive the URL; incidents; ack + resolve round-trip on a minted CRITICAL alert; events list, type filter, types= param, NDJSON export link |
alerting-rules.spec.ts |
8 | rules + inline tester; admin CRUD rule; escalations + simulate; admin CRUD policy; groups |
alerting-windows.spec.ts |
5 | seeded recurring downtime; fixed downtime create/delete; silence create/delete; maintenance tabs; on-call rotation |
dashboards.spec.ts |
6 | shared demo dashboard; all seeded widgets render; wallboard mode; admin lifecycle; free layout + persisted time/refresh; delete |
admin-users.spec.ts |
10 | users CRUD, disable/enable, set-password, last-admin guard; roles; tenants; secrets |
admin-comms.spec.ts |
6 | contacts, contact groups, channels (create, test-send, delete), event sources, outgoing webhooks, heartbeats |
admin-mcp-agents.spec.ts |
5 | MCP endpoint + snippets; agents install one-liner + agent.yaml; dead letters; bundle dry-run plan |
agent-chat.spec.ts |
2 | agent page empty state + provider connection creation (Ollama, no key); admin policy tab |
CI mapping
Section titled “CI mapping”All jobs are in .github/workflows/ci.yml and run on pushes and pull requests to main:
| Job | Runs | Needs | Blocking |
|---|---|---|---|
ui |
npm ci, npm run lint, npm test, npm run build; uploads ui-dist (1 day) |
— | yes |
docs |
npm ci, npm run build in docs/ (fails on broken links); uploads docs-dist |
— | yes |
types |
make types-check (Go 1.25 + Node 22) |
— | yes |
lint |
gofmt check, golangci-lint v2.12.2 | — | yes |
test |
go vet, go test -race, builds the three binaries; matrix ubuntu-latest + macos-latest |
ui |
yes |
e2e |
builds bin/northplaned with the ui-dist artifact, npx playwright install --with-deps chromium, npm run test:e2e; uploads playwright-report (7 days) unless cancelled |
ui |
yes |
postgres |
go test ./internal/storage/... with NORTHPLANE_TEST_PG_DSN against a postgres:16 service |
ui |
no (continue-on-error) |
cross-build |
go build for linux/amd64, linux/arm64, darwin/arm64, windows/amd64 (northplaned skipped on windows) |
ui |
yes |
The Deploy workflow triggers on a completed CI run for main and proceeds only when that run concluded success, so every blocking job above gates production (see CI/CD).
Flakiness notes
Section titled “Flakiness notes”- e2e is serial on purpose.
workers: 1and a single demo database keep mutations from racing; the price is runtime, not reliability. Do not addfullyParallelto a spec. - Retries hide real failures. CI retries each e2e test once; if a test only passes on retry, fix the wait (prefer
await expect(locator).toBeVisible()over fixed sleeps) — the timeouts above are generous already. - Stale binary. A green Vite session says nothing about the embedded SPA. Before
npm run test:e2e, rebuild withmake web && make build(or simplymake e2e). - Readiness, not liveness. Both the dev loop and global setup wait for
/readyz, which goes green only after storage, the event bus and the scheduler are up;/healthzanswers as soon as the listener binds and is the wrong probe for “can I log in yet”. - PostgreSQL matrix. Beyond the known
TestAuditChainfailure, flakes there historically came from state leaking between tests;resetPostgresfixed that. If you see new ones, check that the test goes throughmatrix()and does not assume SQLite-only behaviour (for exampleUNIQUE constraint failederror text versus SQLSTATE23505). - Default admin seeding. Any test that boots
serveand wants the first-run/setuppage must setNP_DEFAULT_ADMIN_DISABLED=1(global setup does), otherwise the break-glass admin closes the gate before the first request. - Locale. Vitest runs with jsdom’s default locale, Playwright with
de-DE— see the two notes above before asserting on text.