Buddy has frontend unit tests, backend integration and event-shape tests, and nightly and manual mutation-testing workflows. Run commands from the repository root unless a command changes directory explicitly.
Install dependencies once:
cd src/frontend/buddy
npm installRun the Angular unit suite once with the Vitest-backed builder:
npm test -- --watch=falsevitest-base.config.ts (the builder's runnerConfig) caps Vitest at half the
cores and raises the per-test timeout to 15 seconds, so the settle()-based
specs don't time out when dev servers, language servers or Stryker load the
machine.
Run mutation testing with StrykerJS:
npm run test:mutationThe HTML mutation report is written to
src/frontend/buddy/reports/mutation/index.html. See the
frontend overview for configuration and
resource considerations.
Run the Playwright suite, which drives a real browser against the real backend API and Keycloak instance (not mocked):
cd src/frontend/buddy
npx playwright install --with-deps chromium # once per machine
npm run test:e2eThis needs Postgres, Keycloak, and Mailpit running -- inside the devcontainer
they already are, seeded automatically from
.devcontainer/keycloak/buddy-realm.json (see the
development container guide).
playwright.config.ts starts the Angular dev server and the backend API
itself, giving that API a larger anonymous rate-limit budget (1000 tokens, 100 per
second) because every logged-out request in the suite shares one localhost IP;
production limits are unchanged, and an already-running dev API keeps its own
limits. Most specs authenticate via a fast direct-grant token fetch
(e2e/support/auth-fixture.ts) rather than driving Keycloak's hosted login
form; e2e/login.spec.ts is the one spec that drives the real form, to prove
the redirect/PKCE flow itself still works.
task docs:screenshots (npm run screenshots in src/frontend/buddy) seeds a fresh demo family
and uses a separate Playwright config
(src/frontend/buddy/playwright.screenshots.config.ts) to capture every page listed in
src/frontend/buddy/screenshots/pages.ts, once at desktop size (1280×800) into docs/screenshots/
and once as an iPhone 15 into docs/screenshots/mobile/, regenerating
docs/screenshots/README.md. It needs the same Postgres, Keycloak, and
Mailpit prerequisites as the e2e suite.
src/frontend/buddy/src/app/screenshot-coverage.spec.ts runs as part of the regular Vitest suite
and fails when a route is missing from pages.ts (or its documented
UNCAPTURED_ROUTES exceptions). See the doc-screenshots skill for details.
The frontend has no zone.js dependency and runs zoneless. Component tests
that use fixture.whenStable() to wait for a component's ngOnInit (or a
click handler) to finish its async work will find that it resolves
immediately and does nothing, because ApplicationRef's stability tracking
only waits on PendingTasks entries -- things like in-flight HttpClient
requests -- and a plain Promise returned by a stubbed/mocked service is
never registered as one. Tests built against provideHttpClient +
HttpTestingController are unaffected, since HttpClient registers a
pending task per request; this only bites tests that stub the service layer
directly (the pattern used throughout this app's component specs).
The fix used across the child feature specs
(home.spec.ts,
child-mealplan.spec.ts)
is a macrotask flush instead of whenStable():
async function settle(fixture: ComponentFixture<unknown>) {
fixture.detectChanges();
await new Promise((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
}A setTimeout callback only runs after the microtask queue is fully drained,
so this reliably flushes any depth of chained awaits in a mocked service
call (including a Promise.all of several mocked calls), as long as nothing
in that chain schedules a further macrotask itself.
jsdom doesn't implement window.matchMedia, which ThemeService calls on construction. A global
stub in
src/test-setup.ts (wired up via angular.json's
test.options.setupFiles) keeps any spec that injects ThemeService through real DI from
crashing with "matchMedia is not a function". Specs that care about a specific OS preference
(e.g. theme.service.spec.ts) stub matchMedia themselves instead of relying on the default.
Build the complete backend solution:
dotnet build src/backend/backend.slnx --configuration ReleaseRun the full integration suite:
dotnet test src/backend/backend.slnx --configuration ReleaseThe integration suite uses Testcontainers for PostgreSQL, Keycloak, and Mailpit. A working Docker daemon is required. The development container and the GitHub-hosted CI runner both provide one.
Run only the container-free event serialization checks:
dotnet test src/backend/backend.slnx \
--filter FullyQualifiedName~EventShapeTestsRun a narrower test class or namespace with the standard .NET test filter:
dotnet test src/backend/backend.slnx \
--filter FullyQualifiedName~Features.PickupsThe integration testing strategy documents the Testcontainers fixture, endpoint coverage guard, and event-shape golden files.
If tests fail in BuddyApiFixture.CreateUserAsync with 401 (Unauthorized),
take 10 s or more each, or the run stops making progress while the test
Keycloak container sits at about one full core of CPU, check the clock before
the fixture. On WSL2 the VM's wall clock can keep stepping backwards (seen:
-500 ms every 5 s plus a ±7.6 s swing). Each backward step makes Keycloak's
Hibernate spin in SimpleTimestamper.next() until real time catches up, and
tokens stamped before the step look issued in the future, so the admin API
rejects them. It isn't memory: the heap stays well under its 512 MB cap. CI is
unaffected.
Measure the drift for 30 s (prints nothing but the summary when the clock is steady):
python3 -c '
import time
p = time.time() - time.monotonic(); n = 0
for _ in range(600):
time.sleep(0.05); o = time.time() - time.monotonic()
if abs(o - p) > 0.002: n += 1; print(f"wall clock stepped {(o - p) * 1000:+.0f} ms")
p = o
print(f"jumps={n}")'Any jumps mean the fix is on the Windows side: run wsl --shutdown and reopen
the devcontainer. If the jumps return, two time services are correcting the
clock against each other: keep only one (WSL's own sync, or systemd-timesyncd
/ chrony in the distro) and run w32tm /resync on the host.
Restore the repository-local Stryker.NET tool and run it from the integration test project:
cd src/backend/buddy.IntegrationTests
dotnet tool restore
dotnet strykerA full run is intentionally slow because mutants are checked against real
container-backed integration tests. Reports are written under
src/backend/buddy.IntegrationTests/StrykerOutput/. See the
mutation testing strategy for
verified scoping instructions and expected runtime characteristics.
Performance and load tests for the API live in src/load-tests and run with
k6 against an isolated, production-sized
Compose stack (own Postgres and Keycloak, the API built from the working tree,
Grafana on http://localhost:3300):
task load:stack:up
task test:load # smoke
task test:load PROFILE=load VUS=50 HOLD=10m # or stress, spike, soak, breakpoint, rate-limit
task load:stack:downtask test:load:typecheck checks the scripts against the generated API types.
Pull requests run the type check and a smoke run in the e2e workflow; the
Load tests (k6) workflow runs smoke, load and rate-limit nightly and any
profile on demand. See src/load-tests/README.md
and the design in
load-testing-k6.md.
.github/workflows/backend-tests.ymlrestores, builds, and runs the backend suite for backend changes..github/workflows/frontend-tests.ymltype-checks the app and spec tsconfigs, runs the Vitest unit suite, and runs the en/da translation parity check (.claude/skills/i18n/check-parity.mjs) for frontend changes..github/workflows/e2e-tests.ymlstarts Postgres, Keycloak, and Mailpit via.devcontainer/docker-compose.ymland runs the Playwright suite against a real backend and frontend. A CI-only override (docker-compose.ci.yml, written by the workflow) publishes Postgres onlocalhost:5432for the API running on the runner..github/workflows/mutation-testing.ymlruns mutation testing nightly and on demand. See nightly mutation testing..github/workflows/codeql.ymlruns CodeQL'ssecurity-and-qualityqueries on the backend (C#, built with the SDK fromglobal.json) and the frontend (TypeScript, no build) for changes undersrc/, and weekly. Findings appear under the repository's Security → Code scanning alerts. The backend config excludescs/useless-assignment-to-local,cs/useless-upcastandcs/useless-cast-to-self, which misread the .NET 11 preview code (about 1000 false positives, none real). Results in source-generator output underobj/(Marten/JasperFx evolvers) and in the committed Wolverine code inInternal/Generatedare filtered out of the SARIF withjqbefore it is uploaded..github/workflows/security-scan.ymlchecks for known vulnerabilities on every backend, frontend, theme or Keycloak image change, and daily (new advisories arrive for code that hasn't changed):- NuGet:
dotnet list package --vulnerable --include-transitivefails on any advisory. A local restore already fails on one too, because NuGet audit warnings (NU1901-NU1904) are errors underTreatWarningsAsErrors. Lift a vulnerable transitive package with a pin in the "Transitive security pins" group ofsrc/backend/Directory.Packages.props. - npm:
audit-ci --moderateinsrc/frontend/buddyandthemes/buddy/build, dev dependencies included. Advisories with no patched release are allowlisted in.github/workflows/security-scan.ymlwith a reason (currentlyGHSA-vfj7-8cjw-p6xm, braces). - Container images: builds the API, web and Keycloak images and scans
them with Trivy. Every finding is uploaded to Security → Code scanning
(category
trivy-<image>); the job fails on high or critical ones that have a fixed version. Accept one in.trivyignorewith a reason and anexp:date.
- NuGet:
The backend and frontend test workflows also measure line and branch coverage.
Each one writes the totals to the job summary, and on a pull request keeps one
comment per side up to date (.github/scripts/coverage-report.sh). The full
report is the run's backend-coverage or frontend-coverage artifact; the
frontend one includes an HTML report. Coverage is reported only, and no
threshold fails a build. Mutation testing is the stronger check of test quality.
To measure locally:
# Backend: Cobertura XML under ./coverage (coverlet, the API assembly only)
dotnet test src/backend/backend.slnx --settings src/backend/coverage.runsettings --results-directory coverage
# Frontend: src/frontend/buddy/coverage/buddy, open index.html for the HTML report
cd src/frontend/buddy && npx ng test --watch=false --coverage --coverage-reporters=html --coverage-reporters=text-summarytask hooks:install (with AGENT=none to skip the AI documentation sync)
adds a pre-commit hook. It runs Prettier and ESLint on staged frontend files,
the en/da parity check when translations change, dotnet format whitespace
on staged C# files, and Prettier plus tsc on staged load-test files. See .devcontainer/git-hooks/README.md.
The mutation workflow runs every night at 02:00 UTC and can also be started
from Actions → Mutation testing → Run workflow, where a scope input picks
frontend (default), backend or both.
Scheduled runs can't take inputs, so they read the MUTATION_SCOPE repository
variable (Settings → Secrets and variables → Actions → Variables) and fall
back to frontend when it is unset. The backend job stays opt-in because
Stryker.NET doesn't support .NET 11 yet, so a backend run is expected to fail
for now. Once it's supported, set MUTATION_SCOPE=both; no code change is
needed.
A full frontend run doesn't fit in one job, so the job works through the
source files in batches with scripts/mutation-batch.sh on all runner vCPUs,
for up to 230 minutes. Then it stops and caches its progress
(reports/stryker-incremental.json and .mutation-batch-progress), and the
next run carries on from there. A first pass over every file takes a few
nights. After that, a night only re-runs the files whose source or specs
changed. With no cache (first run, or evicted after 7 days unused), the run
starts from the committed stryker-incremental.json and goes through every
file again.
With the command test runner, Stryker stores the whole test output of every
mutant run as its statusReason, which pushed the report past GitHub's 100 MB
file limit. When the batch script exits, scripts/mutation-slim-report.mjs
keeps only the last 1000 characters of each reason, so both the cached and the
committed report stay small. It also drops report entries for files that were
deleted or fall outside the mutate globs in stryker.conf.json, which
Stryker never re-tests and which would otherwise stay in the score for good.
The workflow runs it once more before saving the cache. When the time budget
stops a batch mid-run, the script puts back the report from before that batch,
so a half-run batch adds no findings and is re-run next time (it runs Stryker
directly, not through npx, so timeout waits for Stryker to finish writing).
Run it yourself after a direct npx stryker run.
The script skips files it can't learn anything new about.
reports/mutation-fingerprints.json, committed next to the report, holds a
hash of each file and its specs as of the file's recorded results. When the
report shows no surviving or uncovered mutants for a file and that hash still
matches, the file is counted as covered without running Stryker, so a batch of
such files finishes in seconds (--dry-run lists them). A change to the file or
to any of its specs makes it run again. After re-running Stryker on files
yourself (/mutation-fix does, with --force), record their new hash with
scripts/mutation-batch.sh --record <files>.
Each batch runs only the specs that exercise its files: the colocated
foo.spec.ts and every spec that imports foo. A file that no spec imports,
such as a child component, uses the specs of the files that import it. The
script passes these specs to ng test --include through the MUTATION_SPECS
variable in stryker.conf.json. This is much cheaper than running the whole
suite for every mutant. The cost is that a mutant only an unrelated spec would
kill is reported as surviving. Without MUTATION_SPECS, Stryker runs the whole
suite as before.
Each run writes the mutation score and the number of files covered to the job
summary. It uploads an HTML report of all results so far
(scripts/mutation-html.mjs) and the incremental JSON as the
frontend-mutation-report artifact.
Runs on the default branch also keep one open issue, Frontend mutation
testing findings (label mutation-testing), up to date. It lists every
surviving and uncovered mutant as a checklist grouped by file, with a link to
the line, the mutator, and the original and replacement code. Each run rewrites
the issue, so a fixed mutant just drops off the list. The issue is closed once
nothing is left. If the list is longer than GitHub's issue-body limit, the
complete version is in mutation-findings.md in the artifact. To generate the
same list locally from your own baseline, run
node scripts/mutation-issue.mjs in src/frontend/buddy.
Before submitting a documentation-only change, run the relevant Markdown/link
checks and git diff --check. Application tests are only necessary when the
change includes executable samples or source code.