Testing¶
Scribegate ships three layered .NET test projects, a Vitest suite for the SPA,
and a Playwright smoke suite that drives the full stack from a real browser.
All of it runs on every pull request via .github/workflows/ci.yml.
Layers & where each test belongs¶
| Layer | Project | Reference | Use for |
|---|---|---|---|
| Core domain | tests/Scribegate.Core.Tests |
Core only | Pure value/invariant tests, slug/frontmatter helpers, anything that should never touch a DB or HTTP. xUnit v3 + FluentAssertions + NSubstitute. |
| Data + storage | tests/Scribegate.Data.Tests |
Core + Data | EF configurations, migrations, FTS5 triggers, store-level queries. Spins up a real SQLite file per test. |
| Full stack | tests/Scribegate.Web.Tests |
Core + Data + Web | API endpoints via WebApplicationFactory<Program>, auth flows, Markdown parity snapshots. |
| SPA | src/Scribegate.Web/Client/src/** |
— | Vitest + jsdom. Colocated .test.ts files for component logic, shared src/__tests__/ for cross-cutting/parity. |
| End-to-end | tests/Scribegate.E2E |
— | Playwright spec(s) that drive the SPA against a real ASP.NET host with a fresh SQLite DB per run. One golden-path smoke spec — auth variants and feature-by-feature coverage stay in the API/SPA layers. |
Pick the lowest layer that can reach the code under test. A slug-regex test
does not need WebApplicationFactory.
How to add a .NET test¶
Core¶
- Add a class under
tests/Scribegate.Core.Tests/. [Fact]or[Theory]methods with FluentAssertions.- Nothing to wire up — the project already references Core.
Data¶
- Add a class under
tests/Scribegate.Data.Tests/. - Instantiate
TempSqliteFixtureinside the test (or promote it to anIAsyncLifetimefixture if multiple tests share the same DB). - Call
CreateAndMigrateAsync()to get aScribegateDbContextwith all migrations applied against a unique temp.dbfile. await usingthe fixture — disposal clears the SQLite connection pool beforermdirso Windows doesn't fight the file locks.
Web / integration¶
- Add a class under
tests/Scribegate.Web.Tests/. - Implement
IClassFixture<ScribegateWebAppFactory>(or instantiateawait using var factory = new ScribegateWebAppFactory()if each test needs a fresh DB). - Use
factory.CreateClient()and speak HTTP — this is the real host with real middleware. The factory pointsScribegate:DataPathat a unique temp dir and swapsIWebhookDispatcherwith a no-op so nothing calls out during the test.
How to add a SPA test¶
- Colocated unit: put
foo.test.tsnext tofoo.ts. Vitest picks it up viainclude: ['src/**/*.test.ts']invite.config.ts. - Cross-cutting: put it under
src/__tests__/. - The shared setup file (
src/__tests__/setup.ts) installs an in-memorylocalStorageshim so auth state tests are deterministic. - Use
@open-wc/testing'sfixture/htmlhelpers for Lit components.
How to add a Playwright spec¶
The E2E suite is intentionally tiny — one golden-path smoke spec that proves the auth → write → review → merge loop works end-to-end. Don't add specs that duplicate API or component coverage; reach for the layer that exercises the narrowest surface.
- Add
specs/<feature>.spec.tsundertests/Scribegate.E2E/. Each spec mints its own user(s) via the registration UI orrequest.post('/api/v1/auth/register')so the suite is parallel-safe against the single shared SQLite DB the webServer launches. - Prefer semantic locators (
getByRole,getByLabel,getByPlaceholder,getByText) — they pierce Lit's shadow DOM. CSS selectors do not. - If a flow needs a stable hook the accessibility tree can't reach, add a
data-testid="..."to the component and usegetByTestId(...). Don't add testids speculatively. - Self-approval is forbidden by the proposal service. The golden-path spec registers a second user via the API and adds them as a Reviewer to avoid driving the members-page UI; mirror that pattern when you need a second identity.
Running locally¶
cd tests/Scribegate.E2E
npm ci
npm run install:browsers # one-time, installs Chromium under ~/.cache
npm test # full suite, headless
npm run test:headed # watch a browser
npm run test:debug # Playwright Inspector
SKIP_CLIENT_BUILD=1 npm test # reuse the existing wwwroot for fast re-runs
PLAYWRIGHT_PORT=5199 npm test # change the host port (default 5099)
The webServer config builds the SPA, copies dist/ into
src/Scribegate.Web/wwwroot/, then launches dotnet run --no-launch-profile
against a unique temp Scribegate:DataPath (cleaned up on exit). Playwright
probes GET /healthz before running specs.
Markdown parity workflow¶
- Corpus:
tests/fixtures/markdown/corpus.json. Each entry hasid,description, andmarkdown. - Goldens:
- Markdig:
tests/fixtures/markdown/markdig-golden/{id}.html - marked:
tests/fixtures/markdown/marked-golden/{id}.html - The first time a theory case runs with no golden, the test writes the current output and passes (seeding). Subsequent runs assert byte equality. Goldens must be committed — CI never seeds.
- Intentional pipeline change? Delete the affected golden files and rerun;
the test will re-seed. Review the diff in
git statusbefore committing. - Cross-pipeline (Markdig vs marked) divergence is tracked as a TODO test
in both sides. The authoritative list of known divergences lives in
docs/markdown.md.
Flake quarantine¶
- .NET: tag the test with
[Trait("Flaky", "true")]and include a link to the tracking issue in the xml doc. Skip with[Fact(Skip = "flaky — see #NNN, expires YYYY-MM-DD")]. - Vitest: use
it.skipIf(process.env.CI)ordescribe.skipwith the same comment format. - Every quarantine entry must carry an issue link and a 30-day expiry. If the expiry passes with no fix, delete the test rather than extend the skip.
CI shape¶
Four parallel jobs (see .github/workflows/ci.yml):
test-dotnet— matrixubuntu-latest+windows-latest. Restores, builds, installs thedotnet-coverageglobal tool, then runs each of the three xUnit v3 executable test projects viadotnet-coverage collect -f cobertura -o coverage/<layer>.cobertura.xml .... Thedotnet-coveragewrapper instruments the process out-of-band and emits Cobertura XML — this decouples coverage from the in-runner MTP extension whose package versions don't yet line up withxunit.v32.0.0.test-frontend— ubuntu only.npm ci,tsc --noEmit,npm run test:coverage(Vitest with thecoberturareporter; output atsrc/Scribegate.Web/Client/coverage/cobertura-coverage.xml), thennpm run test:parityas a sanity check.test-e2e— ubuntu only. Builds the SPA + copies it intowwwroot, builds the ASP.NET host, installs Chromium via a cached browser store, then runs the Playwright suite. The PlaywrightwebServerconfig launchesdotnet runagainst a fresh tempScribegate:DataPathand waits for/healthzbefore the first spec.publish-check— unchanged end-to-end build / publish sanity.
Coverage artifacts¶
Each CI run uploads three workflow artifacts:
| Artifact | Job | Contents |
|---|---|---|
coverage-dotnet-ubuntu-latest |
test-dotnet (linux) | core.cobertura.xml, data.cobertura.xml, web.cobertura.xml |
coverage-dotnet-windows-latest |
test-dotnet (windows) | same three files |
coverage-frontend |
test-frontend | cobertura-coverage.xml (Vitest v8 provider) |
Open the run in GitHub Actions and download the artifact zip to inspect a report locally (every Cobertura viewer — VS Code "Coverage Gutters", ReportGenerator, IntelliJ — accepts these files unchanged).
Coverage gate¶
The test-dotnet and test-frontend jobs each run scripts/check-coverage.mjs
against their Cobertura output and fail the build if any layer's measured
line-rate drops below the floor in coverage-thresholds.json. It's a
soft floor (regression detector), not a hard target — each entry is the first
measured value rounded down by ~2 percentage points, and the values move up
only after the suite stabilises at a new level. Tightening a floor when you
add tests is a one-line PR.
A separate coverage-badge job runs only on main pushes: it downloads the
ubuntu Cobertura artifacts, weights each layer by its lines-valid count,
and force-pushes a coverage.json in Shields.io endpoint format to the
coverage-data orphan branch. The README badge reads that file via
raw.githubusercontent.com — PR branches never touch it.
Generating coverage locally¶
# .NET — one file per test project
dotnet tool install --global dotnet-coverage
dotnet build Scribegate.slnx -c Release -p:SkipClientBuild=true
dotnet-coverage collect -f cobertura -o coverage/core.cobertura.xml \
"dotnet run --no-build -c Release --project tests/Scribegate.Core.Tests"
# Frontend
cd src/Scribegate.Web/Client && npm run test:coverage
# → src/Scribegate.Web/Client/coverage/cobertura-coverage.xml
The coverage/ directories are gitignored.
Known gotchas¶
- SQLite file locks on Windows.
Microsoft.Data.Sqlitepools connections. Always callSqliteConnection.ClearAllPools()before deleting a temp data dir;TempSqliteFixtureandScribegateWebAppFactoryboth do this. public partial class Program { }is required insrc/Scribegate.Web/Program.cssoWebApplicationFactory<Program>can find the entry point. Top-level statements generate an internalProgramclass by default, which the factory cannot see across assemblies.- FTS5 needs a real SQLite file. The
DocumentFtsvirtual table and its triggers are created via raw SQL in the migration. They work against real connections; trying to use:memory:or the EF InMemory provider will silently drop the FTS table. - FTS5 and
content=''. The originalDocumentFtstable was declaredcontent='', contentless_delete=1withDocumentId UNINDEXED. SQLite's contentless FTS5 tables retain none of their column data — even UNINDEXED columns come back NULL, andsnippet()/highlight()cannot reconstruct output. TheFixFtsRowidJoinmigration rebuilt the table as a plainfts5(Content)and switched the triggers + search query to link viaDocuments.rowid. If you introduce a new FTS virtual table, avoid contentless mode unless you genuinely want the index-only tradeoff. Scribegate:DataPathis read at CreateBuilder time. Program.cs reads the key immediately afterWebApplication.CreateBuilder(args)— before anyWebApplicationFactoryConfigureAppConfigurationhook fires. Setting the key via the factory's configuration hook alone is not enough: theDbContextis already registered with the defaultdata/relative path by the time the hook runs, and every test ends up sharing one SQLite file in the test project'sbin/Debug/.../data/directory.ScribegateWebAppFactorytherefore also re-registers theDbContextinConfigureTestServiceswith the per-factory temp path. Keep both overrides — the configuration hook still gives the app the right DataPath for the git mirror root and any future code paths that read the key later.- Vitest + Node 22+ localStorage. Modern Node ships a file-backed
global
localStoragethat warns and has no.clear()on worker processes.src/__tests__/setup.tsreplaces it with an in-memory shim for every test file.