WorkWingman — Testing (Technical)¶
How WorkWingman proves its behavior today, and the disciplined testing program it is building toward. This document is deliberately honest about the line between what exists now and what is planned (labelled Roadmap — do not treat roadmap items as working).
What exists now: backend and frontend unit/integration test counts grow with every merged
feature (xUnit + NSubstitute + Bogus; Flurl HttpTest for the REST clients on the backend;
vitest + @faker-js/faker +
HttpTestingController; a spec per screen and per service), 6 Playwright.NET UI E2E
journeys (tests/WorkWingman.E2E) driving the built Angular app
against a stubbed API, an Electron shell smoke E2E (tests/electron-e2e),
a determinism/churn CI job (each suite re-run ×5), and mutation testing on both
backend (Stryker.NET) and frontend (StrykerJS), scheduled weekly. Still roadmap: 100% coverage
and broader E2E journeys.
Testing philosophy¶
The rules every test in this repo is held to:
- Test behavior, not implementation. A test asserts on observable outcomes (a returned value, a status code, a thrown exception, rendered DOM) — never on private internals. You should be able to refactor the implementation without touching the test.
- Deterministic, always. A test either passes every time or it is a bug. We do not retry-mask, sleep-and-hope, or depend on wall-clock time, network reachability, or execution order. Flakiness is treated as a defect in the test, not a nuisance to paper over.
- Mock the edges, exercise the core. External systems (HTTP APIs, browsers, the OS) are
simulated so the logic under test is the only variable. In-memory hosting
(
WebApplicationFactory) runs the real API pipeline without a socket, Kestrel, or a browser. - Fast enough to run on every change. The whole suite is designed to run in CI on every push and pull request (see CI).
Tooling in use today¶
| Tool | Role | Docs |
|---|---|---|
| xUnit | Backend test framework ([Fact], [Theory], [InlineData]) |
xunit.net · GitHub |
| Microsoft.NET.Test.Sdk | Test host / VSTest runner integration | GitHub |
| coverlet | Cross-platform code-coverage collector (XPlat code coverage in test.runsettings) |
GitHub |
WebApplicationFactory<Program> |
In-memory ASP.NET Core integration host (no Kestrel, no browser) | learn.microsoft.com |
| Flurl.Http testable HTTP | Fluent HTTP client whose calls are simulated in ResiliencePipelineTests |
flurl.dev testable-http · GitHub |
| vitest | Frontend test runner (Angular 22 uses it via @angular/build) |
vitest.dev · GitHub |
Angular TestBed |
Component/service test harness | angular.dev/guide/testing |
The test pyramid¶
flowchart TD
U["Unit tests<br/>(pure logic: parsing, classification, vault rules)"]
I["Integration tests<br/>(WebApplicationFactory — real API pipeline, in-memory)"]
E["End-to-end tests<br/>(Roadmap: Playwright.NET journeys, Electron shell)"]
M["Mutation tests<br/>(Stryker.NET + StrykerJS — do the tests actually catch bugs?)"]
U --> I --> E --> M
Most tests are cheap, fast unit tests at the base. A thin band of integration tests exercise the wired-up API. E2E and mutation testing sit at the top; both are now wired (mutation runs weekly on a schedule).
What exists today — backend (xUnit tests)¶
Location: tests/WorkWingman.Tests (targets net10.0).
| Test file | What it proves |
|---|---|
ResiliencePipelineTests |
The Polly transient-fault predicate classifies 429, 500, 503, and network failures (no response) as retryable, and 400 / 401 / 404 as non-retryable. A separate test drives the default pipeline through two transient failures and asserts it retries then succeeds on the third attempt (result 42, exactly 3 attempts). |
AtsDetectorTests |
ATS detection from a job description page: Workday is detected and its tenant host is extracted (cvshealth.wd1.myworkdayjobs.com); Greenhouse, Lever, and iCIMS are each detected from their host markers; unmarked HTML resolves to Unknown. |
VaultServiceTests |
A locked vault refuses access — listing entries or resolving a secret throws InvalidOperationException. After unlock, CreateAtsEntry names the entry from the tenant (workday-acme), files it under the Ats category, and stores a resolvable secret. Resolving an unknown entry throws KeyNotFoundException. |
ApiSmokeTests |
In-memory API via WebApplicationFactory<Program>: seven GET endpoints return 200 (jobs, connections, settings, applications, intake, story, vault status); the locked-vault entries endpoint does not return 200 (never leaks secrets); the dependency check (GET) is open; install is rejected (401) without a token or trusted origin (hostile-page simulation); and install passes the auth gate from the trusted dev origin http://localhost:4200 (then 400 on empty id list). |
DependencyDoctorTests + VersionParsingTests + DependencyManifestTests |
Version extraction pulls the first semver out of noisy --version output (git version 2.49.0.windows.1 → 2.49.0); comparison enforces minimums; the manifest is valid (every dependency has an id, purpose, min-version, install metadata, and an https:// info URL); the doctor returns a status for every manifest entry (never Unknown); installing an unknown id fails gracefully without throwing; and optional gaps do not block launch while a missing required dependency does. |
LinkedInPostingSignalExtractorTests + SelectBySelectorLikeClassTests + CollectCandidateTextTests |
Repost-count/original-date/current-age parsing from fixture LinkedIn job-page HTML (see job-signals.md): repost count with and without an explicit number, original date in both rendered formats, case-insensitivity, and graceful degradation to null/unavailable on empty, malformed, or classless markup — no real network, no live LinkedIn page. |
WarnNoticeSourceTests + SecEdgarSourceTests + LayoffsFyiSourceTests + CompanyLayoffServiceTests |
Each layoff source adapter's fetch/normalize logic against Flurl HttpTest stubs (Texas WARN Socrata feed, SEC EDGAR full-text search, and the intentionally-no-op layoffs.fyi adapter — see job-signals.md for why); CompanyLayoffService merges across sources, dedupes same-company/same-date events keeping the highest-priority source, and returns results newest-first, degrading gracefully when a source is empty or unreachable. |
IncomeTaxEstimatorTests |
Exhaustive federal/state/local bracket boundary math (at-cap, one-dollar-over, no-tax states, flat states, progressive states), FICA wage-base and Additional-Medicare-threshold boundaries, local-tax city/state matching, and guard clauses, against a synthetic fixture dataset — plus sanity checks that the real default 2026 dataset loads all 51 state entries and behaves correctly for known cases (see col-tax.md). |
CostOfLivingServiceTests + CostOfLivingSourceTests |
Per-field merge across mocked ICostOfLivingSources, unconfigured sources skipped and never called, first-configured-source-wins on a field conflict, and graceful degradation when a source throws — plus config-gating (IsConfigured) for EiaGasSource, BlsCpiSource, and the paid, config-stubbed NumbeoSource, none of which ever reach the network when unconfigured. |
CityComparisonServiceTests |
Raw take-home delta math, cost-of-living-adjusted delta (full data, partial data, one side missing entirely → null, equal indexes → adjusted equals raw), and resilience when the cost-of-living lookup throws. |
ConceptVisualPromptBuilderTests + StudyVisualGeneratorTests + MagnificSourceTests + StudyVisualsControllerTests + StudyVisualServiceTests |
See study-visuals.md: the prompt builder is deterministic and never references a copyrighted work (regression-guarded against a forbidden-terms list); GeminiImageSource/OpenAiImageSource never call their API without a configured key (asserted via an empty Flurl HttpTest call log), hit the exact verified endpoint/verb/header, and degrade gracefully (never throw) on an empty/malformed response or an HTTP error; MagnificSource drives its async create-then-poll upscale flow to completion, failure, and a bounded timeout with no hang; the image-serving endpoint never serves a path outside its own directory; StudyVisualService orchestrates generate/regenerate/delete/enhance against a real temp-directory LocalJsonStore, scoping each gallery to its study item and falling back across enhancers. Stryker.NET mutation score: 92.75% on **/Visuals/*.cs. |
What exists today — frontend (2 vitest tests)¶
Location: frontend/src/app/app.spec.ts. Angular 22 runs
these through vitest via @angular/build.
| Test | What it proves |
|---|---|
should create the app |
The root App standalone component instantiates under TestBed with router + HTTP providers. |
should render the sidebar brand |
After the fixture stabilizes, the sidebar .brand-name renders and contains "Work Wingman". |
How to run the tests¶
Backend (Windows, .NET 10):
# from the repo root — the unit/integration suite CI gates on
dotnet test tests/WorkWingman.Tests/WorkWingman.Tests.csproj --settings test.runsettings
The ScraperLab tier: compiled always, run on request¶
The 13 WorkWingman.ScraperLab.*.Tests projects are in WorkWingman.slnx and stay there, so
every one of them compiles on every build — including CI's. That is the point: a lab that stops
compiling has to break something. It used to break nothing, and
WorkWingman.ScraperLab.Google.Tests sat with 20 compile errors while
dotnet build WorkWingman.slnx reported 0 Error(s), because the solution had never heard of it.
SolutionMembershipTests now fails if
any .csproj drifts out of the solution again.
Membership is not the run-selection lever. Compilation is exhaustive; execution is selective.
Each lab test assembly carries [assembly: AssemblyTrait("Category", "Lab")], so:
# fast loop — everything except the lab tier
dotnet test WorkWingman.slnx --filter Category!=Lab
# full loop — labs included
dotnet test WorkWingman.slnx
Lab tests drive a real headless Chromium against offline loopback fixtures. Browser-launching test
classes take a context from
SharedBrowser rather than launching their
own — one browser per test assembly, not one per test. It lives in WorkWingman.TestSupport because
assemblies are what parallelise, so a helper only one assembly can reach caps only that one.
A bare dotnet test (or dotnet test WorkWingman.slnx) also picks up WorkWingman.E2E, which
serves the built Angular app and fails fast with DirectoryNotFoundException: Angular build
output not found until you have run npm run build in frontend/. That red is a missing
prerequisite, not a broken test — the UI E2E is a separate CI job that builds the frontend first.
To run it locally, follow tests/WorkWingman.E2E/README.md.
The suite runs at xunit's default parallel width. It used to cap at 0.25x of the machine's cores
(tests/WorkWingman.Tests/xunit.runner.json, since deleted): the apply-engine tests each launched
their own headless Chromium, so at full width the browsers oversubscribed the box and the wall-clock
waits inside them — and inside the engines under test — expired, producing timeouts in a rotating
cast of tests that all passed in isolation. The cap read as "greener and faster," which is the
tell that it was never a CPU cap: a thread cap relieving CPU contention does not speed a suite up. It
was rate-limiting browser spawns. Once SharedBrowser
bound each assembly to one browser (WW-93/WW-92), the storm was gone and the cap became pure cost —
measured, interleaved, capped vs uncapped on the same tree: uncapped ~74s vs capped ~119s, both
3654/3654 green, uncapped green even at 1.6 GB free. So it was deleted (WW-95); the real fix was
bounding browsers, not threads.
Coverage is collected by coverlet via the
XPlat code coverage data collector declared in
test.runsettings.
Frontend (Angular, vitest):
cd frontend
npm test
Continuous integration¶
.github/workflows/ci.yml runs on every push and
pull_request to master / main:
- Backend job (
windows-latest): installs the .NET 10 SDK, buildsRelease, then runsdotnet test --no-build --settings test.runsettings. - Frontend job (
ubuntu-latest): installs Node 24,npm ci,npm run build, thennpm test. - Security (dependencies) job (
ubuntu-latest):dotnet list package --vulnerable --include-transitiveandnpm audit --audit-level=highgate every push/PR on known-vulnerable packages — see security.md for the exact parse logic and the allowlist.
All jobs must be green to merge.
Shipped (was roadmap)¶
Backend coverage with mocked dependencies¶
Services, clients, and pipelines are driven with substituted collaborators:
NSubstitute for Core interfaces,
Bogus for seed-controlled fake data
(TestData.cs), and
Flurl HttpTest to stub the REST clients (asserting
URL/verb/bearer with no live network). Learn more: mocking lets you assert on how a unit
uses its collaborators (test doubles).
Frontend unit coverage¶
A spec per component and service across all 10 screens plus the services, using
@faker-js/faker view-model builders and the mockApi() helper
(spec-support.ts), with
HttpTestingController for the API service.
UI E2E in C# (Playwright for .NET)¶
tests/WorkWingman.E2E drives the built Angular app against a
stubbed API (Playwright route interception,
CORS-hardened) via a loopback static server, using the
Page Object Model. Journeys: shell + navigation,
theme persistence across routes, the job queue, the judgement-call pause→resolve flow, and
connections. Runs as its own CI job. Selenium remains the
documented fallback only.
Electron shell E2E (TypeScript)¶
Playwright's .NET binding cannot launch Electron, so the shell smoke suite
(tests/electron-e2e) is TypeScript
(_electron): it boots the packaged shell,
confirms the renderer loads, and that it reaches the loopback API. Runs headless under
xvfb in its own CI job.
Mutation testing — backend¶
Stryker.NET proves the tests catch bugs by
injecting faults and checking a test fails. Config:
stryker-config.json. It runs weekly
(and on demand) via mutation.yml, uploading the HTML
report — mutation is too slow for per-push.
Scope decision (documented survivors): mutation targets the pure-logic files the unit
tests cover — Services, Clients, Http, Vault, DependencyManifest, VersionParsing,
AtsDetector. It excludes the I/O-bound classes — DependencyDoctor and ProcessRunner
(winget/npm/Playwright subprocess paths) and the Playwright browser automation
(BrowserSession, LinkedInJobsScraper, WorkdayAutomationEngine). Those spawn real
processes/browsers and belong to integration tests, not unit mutation — mutating them only
produces no-coverage/timeout noise. additional-timeout is raised so slow-but-real
Polly/async mutants aren't mis-scored as timeouts.
Mutation testing — frontend¶
StrykerJS does the same for the
Angular frontend via its
vitest-runner. Config:
frontend/stryker.conf.json. Like the backend it runs
weekly (and on demand) via mutation.yml.
The Angular-22 blocker and the fix. Angular 22 runs unit tests through the
@angular/build:unit-test builder, which owns the vitest environment and injects a virtual
TestBed-bootstrap module — so ng test works, but a standalone vitest run (which is what
StrykerJS's vitest-runner drives) has no Angular bootstrap and fails to load. The fix is a
standalone Angular vitest config that reproduces what the builder provides:
frontend/vitest.config.ts— a plain vitest config that compiles Angular sources with@analogjs/vite-plugin-angular, runs injsdom, and loads the setup file below. This is additive:npm teststill uses the Angular builder (ng test); only Stryker and any barenpx vitest runuse this config.frontend/src/test-setup.ts— initializes the AngularTestBed(BrowserTestingModule+platformBrowserTesting), mirroring the builder's generated bootstrap. The app is zoneless, so it deliberately does not importzone.jsor addprovideZoneChangeDetection()— the TestBed uses default zoneless change detection, exactly as underng test. All 15 existing spec files pass unchanged undernpx vitest run.
Config notes. coverageAnalysis: "perTest" with vitest test isolation on (isolate:
true) is what lets Stryker attribute mutant coverage correctly across every mutated file; each
isolated worker builds its own TestBed once (guarded by an ANGULAR_TESTBED_SETUP symbol).
vitest.related is set to false so Stryker runs the full spec set rather than relying on
vitest's module-graph "related files" resolution, which the Analog compile step does not expose.
Scope decision (documented survivors): mutate targets src/app/**/*.ts minus the specs,
the testing/ faker fixtures, and the pure-wiring files
(main.ts, app.config.ts, app.routes.ts) — mutating bootstrap/route tables only produces
no-coverage noise. The pure-logic services score well (e.g. theme.service ~91%); feature
components carry many no-coverage mutants because their specs assert rendered output and
service interactions rather than every internal branch — a known gap tracked under coverage on
the Roadmap, not a runner failure.
Determinism / churn testing¶
The Determinism (churn) CI job re-runs both suites ×5 and fails if any result flips — a
flaky test is a bug, never retry-masked
(test independence). Bogus and faker
data are seeded for reproducibility.
Roadmap (planned)¶
- 100% coverage targets — expand backend + frontend coverage to every branch, including the
I/O classes via integration tests (real
ProcessRunner/doctor against a sandbox). On the frontend this also means killing the component no-coverage mutants that StrykerJS surfaces (see Mutation testing — frontend) by asserting more internal branches, not just rendered output. - Broader E2E journeys — extend the Playwright.NET suite across the full flow (onboarding steps → story → docs → approve → applied → settings) with a Page Object per screen.
- Randomized test order — add xUnit/vitest order randomization on top of churn to surface hidden inter-test coupling.
Related docs¶
- Plain-language version: ../plain/testing.md
- Technical overview: overview.md
- Architecture deep dive: ../ARCHITECTURE.md