Skip to content

Test Coverage Plan — Path to 80%

Status: Revised after /office-hours (2026-05-22). Strategy locked, wedge committed, full controller backlog parked pending real traffic data. Owner: Gercek (CEO). Linear: RAK-355 (this plan) · RAK-339 (umbrella). Source data: Coverlet run on master sha 3546eece, 2026-05-22 04:05 UTC.

This is the plan, not the work. The work is downstream: dozens of follow-up PRs across multiple quarters.

TL;DR after office-hours

  • Real goal isn't 80% line coverage — it's confidence to ship AI-written PRs without breaking prod. Coverage % is the mechanism, not the target.
  • First chunk = "Foundation + PaymentsController wedge" as a single PR. Validates the AI test-writing loop end-to-end on the smallest critical controller.
  • Subsequent chunks ranked by App Insights + GA + DB query patterns, not by file size or my prior guesswork. CEO is granting access.
  • Hot-path coverage replaces raw % as the priority metric. Coverage % stays as a tracked number; not a target.
  • Chunking pattern locked: one controller = one Linear card = one branch = one PR = one AI agent. See test-coverage-chunks.md and test-coverage-task-template.md.
  • CI saturation fix (Premise 5): coverage moves to a nightly cron + per-PR delta check. Shipped in parallel with Chunk 0.

1. Why this plan exists

The CEO set a target of 80% line coverage for the production codebase. Today's number is 1.5% headline (and most of that denominator is noise — see §2). That's a roughly 50× increase over a brownfield codebase of roughly 186k coverable lines with 383 existing unit tests.

Five months ago we had no measurement. Today we do, via the coverlet step that landed in #13815 and the permission fix in #13833. That gives us a baseline. The next question is what we do with it.

The honest answer is that 80% on this codebase is a multi-quarter initiative — not a sprint, not a hackathon week, not "let an AI generate tests over the weekend." This document lays out why, what it costs, and what the realistic intermediate milestones look like.

2. Where we actually are

Headline number

Metric Value
Line coverage 1.5% (2,906 of 186,449)
Branch coverage 1.3% (801 of 60,945)
Method coverage 2.7% (830 of 30,169)
Test count 383 MSTest tests, all TestCategory=Unit
Assemblies in report 31 (~25 are third-party NuGet noise)

The 1.5% is misleadingly low because coverlet auto-instrumented ~25 third-party NuGet assemblies that are loaded into the test process (AWSSDK.*, Intuit.Ipp.*, Microsoft.ApplicationInsights, SendGrid, HtmlAgilityPack, etc.) — they all show 0% coverage and inflate the denominator by roughly 5-10× depending on assembly. Fixing the coverlet exclude filters (5-line follow-up) will move the headline number without adding a single test.

Coverage on code we actually own

Assembly Coverage Notes
Models 6.7% Pure POCOs/DTOs/ViewModels mostly. No model logic tested.
Services 2.6% Only ~5 services out of ~149 have tests. PaymentService = 0%.
Application 0.3% No controller tests. The 0.3% is incidental — POCOs touched by service tests.
Raklet.Webjob.Common 0% Background job logic untested
Raklet.WebFramework 0%
Raklet.Money / Raklet.Sms / Raklet.Email / Queues 0%
Raklet.Services.Google / Raklet.GoogleRecaptcha 0%

What the 383 existing tests actually do

Sampling CheckoutServiceTests.cs, MembershipCouponHelperTests.cs, OrganisationMembershipUnitTests.cs:

  • Pure POCO builder pattern — tests hand-roll fake objects via private MakeDebt() / MakePayment() helpers
  • Zero mocking — no Moq, NSubstitute, FakeItEasy, or any other framework in Raklet.UnitTests/packages.config
  • All synchronous — no async Task test methods anywhere in the suite
  • Direct instantiation — when a test needs a service, it news up the real one with hand-built input objects
  • No DbContext — none of the tests touch RakletDb; they test pure functions and computed values only

This is fine for what it tests. It does not extend to controllers, DB-bound service methods, async pipelines, HTTP boundary logic, or anything that needs a fake of any kind.

3. The math of 80%

Lines % of coverable Net new lines to cover
Today 2,906 1.5%
10% 18,645 10% +15,739
25% 46,612 25% +43,706
50% 93,225 50% +90,319
80% 149,159 80% +146,253

For a quick gut check on test-writing throughput: the existing 383 tests cover 2,906 lines. That's roughly 7.6 covered lines per test — but the existing tests are unusually high-leverage because they target pure-math service helpers. New controller and DB-bound tests will cover fewer lines per test (more boilerplate, more assertion overhead, more setup). A more realistic ratio for new controller-layer tests is 3-5 lines per test.

So to add 146,253 covered lines at 3-5 lines/test, we'd need to write on the order of 30,000-50,000 new tests. Even at an optimistic 30 tests per engineer per day (which assumes the infrastructure is in place and the controllers are already refactored for testability), that's 1,000-1,700 engineer-days of focused test-writing work. With one engineer full-time, that's 4-7 years. With three engineers full-time, 1.3-2.3 years.

This is the honest scale of "80%" on a codebase that has 9 years of code, ~200 controllers, and zero test infrastructure beyond pure unit tests on math helpers. Whether that's the right target is exactly what /office-hours should challenge.

4. What blocks us today

Five concrete pieces of infrastructure are missing. None of them is a 1-day fix; together they're a foundation phase before any controller test gets written.

4.1 No mocking framework

Raklet.UnitTests/packages.config has only MSTest + EntityFramework. No Moq, no NSubstitute, no FakeItEasy. Decision point: pick one and adopt it as the project standard. Moq is the de-facto standard for .NET Framework codebases of this vintage and integrates with EF6's DbSet<T>/IDbSet<T> patterns.

4.2 No in-memory DbContext for EF6

EF Core has UseInMemoryDatabase(). EF6 does not. Options:

  • Effort.EF6 — third-party, gives you an in-memory provider. Mature but adds a dependency. Last release was 2017; works fine but unmaintained.
  • Hand-rolled fake DbSets — implement IDbSet<T> over List<T>. More code per test but no external dependency. Most teams end up here for EF6 codebases.
  • Real SQL Server (LocalDB) — slowest but most accurate. Needs CI provisioning (we established earlier this is non-trivial — see RAK-339 step 2 retrospective).

Recommendation: hand-rolled IDbSet<T> fake helpers + Moq for everything else. Pattern lives in a new Raklet.UnitTests.Infrastructure namespace.

4.3 Controllers do new RakletDb() inline

Sample of the 5 high-traffic controllers:

Controller LOC Dependency pattern
Raklet.Api/Controllers/App/Events/EventsController.cs 800 Direct: new CosmosDbEventService() line 57
Raklet.Api/Controllers/App/Payments/PaymentsController.cs 83 Direct: new OrganisationMembershipService(new RakletDb()) line 27
Raklet.Api/Controllers/App/Membership/SubscriptionController.cs 255 Partial injection but bypasses with new RakletDb()
Raklet.Api/Controllers/AccountController.cs 1,251 Partial: ctor injects interfaces but also hard-creates context
Raklet.Api/Controllers/App/Contact/ContactController.cs 401 Direct: new RakletDb() line 45

These controllers are not testable as-is without first refactoring the inline new calls into constructor injection. That refactor touches production code paths — not a "delete this line, type this line" mechanical change. Each refactor needs careful manual + automated regression testing.

4.4 No async test patterns

All 383 existing tests are synchronous. Controllers are async-heavy (async Task<IHttpActionResult> everywhere). MSTest supports async test methods natively, but the team has no established convention — what to await, how to handle continuation context, how to test cancellation, how to mock Task<T>-returning service methods. This is a one-time pattern decision, but it has to happen before async controller tests can be written.

4.5 No ControllerTestBase / fixture

ApiController and Controller exposes User, Request, Url, HttpContext, RouteData — none of which exist outside the ASP.NET runtime. Every controller test needs a stubbed ControllerContext with a mock HttpContextBase, principal, route data, and request URI. Without a shared base class, each test re-implements this. With a base class, each test gets it free.

5. The phased roadmap

Each phase is a separately-shippable cluster of PRs with its own decision gate at the end. The CEO can pause the initiative at any phase boundary if the cost/benefit isn't holding up.

Phase 0 — Cleanup & honest baseline (1 PR, 1 day)

  • Add NuGet/third-party assembly excludes to the coverlet step so the headline number reflects code we own
  • Re-tag any other mistagged tests (one cluster already fixed in #13822)
  • Land the trend-tracking workflow output (current is one-shot per run)

End state: Honest baseline number visible. Likely lands somewhere in the 3-5% range depending on how aggressive the excludes are.

Phase 1 — Foundation (3-5 working days, 1 engineer)

  • Add Moq to Raklet.UnitTests packages.config
  • Add Raklet.UnitTests.Infrastructure with: FakeDbSet<T>, RakletDbFake, ControllerTestBase, MockHttpContextBuilder
  • Establish + document the async test pattern
  • Pick ONE reference controller (recommend PaymentsController.cs, smallest at 83 LOC) and write a complete test pack as the canonical pattern
  • Land a docs/engineering/test-pattern.md showing the pattern

End state: Other engineers (and AI agents) can copy the pattern to write controller tests. Coverage moves by maybe 0.5%.

Phase 2 — Tier 1 controllers (3-5 weeks, 1-2 engineers)

Top 5 controllers, ranked by traffic / blast-radius:

  1. AccountController (1,251 LOC, 12+ public methods) — biggest, most critical. ~5-8 working days end-to-end (refactor + tests).
  2. EventsController (800 LOC, 6 methods) — ~3-4 days.
  3. ContactController (401 LOC, 2+ methods) — ~2-3 days.
  4. SubscriptionController (255 LOC, 4+ methods) — ~2-3 days.
  5. PaymentsController (83 LOC, 1 method) — already done in Phase 1.

Each controller is a separately-shippable PR. Each refactor introduces constructor injection cleanly and gets a happy-path + auth-failure + 2-3 edge-case tests minimum.

End state: Coverage on Raklet.Api jumps from 0% to roughly 8-15% on the Raklet.Api assembly. Application assembly still 0.3%.

Phase 3 — Coverage trend gate (1 PR, 1 day)

Once 2+ weeks of Phase 2 data are in, add a CI check that fails PRs which drop coverage by more than 0.5%. This is the mechanism that keeps Phase 4 from regressing.

End state: Every PR is forced to maintain or improve coverage.

Phase 4 — Tier 2 fan-out (3-6 months, 2-3 engineers in parallel)

After Tier 1 proves the pattern, the next ~25-50 highest-traffic controllers across Raklet.Api + Application. Each follows the same recipe: refactor for injection, write happy-path + auth + edge cases.

Realistic cadence: each controller takes 2-5 days. At 1 engineer, that's ~3-6 months for 50 controllers. At 3 engineers in parallel, 4-8 weeks.

End state: Application + Raklet.Api combined coverage in the 20-35% range. This is the realistic "mid" target — meaningful production safety net without taking 2 years.

Phase 5 — Service layer (concurrent with Phase 4, or after)

Services/ has ~149 service files at 2.6% coverage. Most are DB-and-external-API-bound (payment processors, email senders, Cosmos writers). These need the same DbSet fake + Moq pattern as controllers, plus interface stubs for external HTTP clients.

Realistic addition to overall coverage: another 15-25% assembly-wide if done aggressively, fewer if we target only the highest-blast-radius services.

Phase 6 — Long tail to 80% (12+ months from start, multiple engineers)

Everything else: Models methods, Raklet.WebFramework, Raklet.Webjob.Common, Queues, the small Raklet.* utility libs, edge controllers nobody touches.

This phase is where the diminishing returns kick in. The marginal value of testing the 78th controller is much lower than the 5th. The CEO should seriously consider whether to stop at 50-60% and reallocate engineering time to product instead.

Phase 7 — Backend TypeScript (parallel stream, 3-5 days)

Raklet.Backend/ is 611 .ts files with 4 Jest test files (~24 tests). This is a smaller scope but completely separate stack — different team member, different tooling (Jest already wired in tests.yml). Worth running as a parallel track once Phase 1 is unblocked.

6. Cost model

These are honest ranges. Lower bound assumes everything goes well; upper bound assumes typical realities (refactor surprises, regression hunts, build-system friction, the 1,251-LOC AccountController turning out to be 80% interconnected with seven other things).

Phase Engineering time Calendar (1 eng) Calendar (3 eng) Coverage at end
0. Honest baseline 1 day 1 day 1 day ~3-5% (just measurement)
1. Foundation 3-5 days 1 week 3-5 days ~5-6%
2. Tier 1 (top 5 controllers) 15-25 days 3-5 weeks 1-2 weeks ~10-15%
3. Trend gate 1 day 1 day 1 day (mechanism)
4. Tier 2 (next 50 controllers) 100-250 days 5-12 months 2-4 months ~25-40%
5. Service layer 50-150 days 3-8 months 5-12 weeks +15-25%
6. Long tail to 80% 200-500 days 1-2 years 5-12 months up to 80%
7. TS backend (parallel) 3-5 days 1 week (separate stack)

Total to 80%: roughly 18-30 months with 1 engineer full-time, or 6-12 months with 3 engineers full-time. Plus the opportunity cost of those engineers not shipping product.

6.5. Office-hours outcome (2026-05-22 session)

Ran the /office-hours skill against this plan. Six forcing questions, five answered (smart-skipped Q2/Q3/Q6 because Q1 + the plan already covered them). Resolved decisions below — full Q&A in the session, this summary captures only what changed in the plan.

Q1 — Demand reality. 80% is not literally an audit / sale / certification demand. The real demand is shipping confidence for AI-written PRs: the CEO wants codex/cursor writing features, and that's only safe with a real safety net of tests underneath. Coverage % is downstream of this goal, not the goal itself.

Q3 (data-gathering moment). CEO challenged my "high-traffic controllers" claim — correctly pointing out that I named EventsController/PaymentsController/etc. by file size and pattern-matching, not by real traffic data. Granted access incoming for Application Insights, Google Analytics, and database query patterns. Provisional rankings in this doc are placeholders until the data lands; chunks 2-N will be re-ranked.

Q4 — Narrowest wedge. PaymentsController (83 LOC, 1 public method, thin orchestration over OrganisationMembershipService). Foundation scaffolding + first test pack ship in one PR. Sized 5-7 days (generous per Q5) to absorb the learning surprises.

Q5 — Observation. No prior observation of codex/cursor writing tests against Raklet specifically. Therefore the wedge PR is intentionally a learning experiment, not a "ship and forget" deliverable. Its "What we learned" section is the most important artifact of the whole initiative.

Premise check. Five premises listed; CEO directed challenge on premise 1 ("coverage % is the right proxy for shipping confidence"). Decision: chunk by hot-path priority, not by raw coverage %. Coverage % remains a tracked metric but isn't what we optimize against. Other four premises kept as working assumptions (the wedge validates premise 2; premises 3 and 4 are confirmed; premise 5 gets its own fix chunk).

Chunking approach. Picked Approach B — controller-per-chunk with task template: one Linear card + one branch + one PR + one AI agent per controller. Codified in test-coverage-task-template.md. AccountController's 1,251 LOC gets sub-chunked as a special case.

What's new in the repo as a result of office-hours: - test-coverage-task-template.md — canonical task body for each chunk's Linear card - test-coverage-chunks.md — ordered chunk backlog (Chunk 0 + Chunk 1 ready to ship, chunks 2-N parked on data)


7. (Original) Decision points for /office-hours — kept for reference

The CEO needs to commit to a position on each of these. The phased plan above is a menu, not a destination — /office-hours should challenge each of the assumptions below.

7.1 Is 80% the right target?

80% is a common industry "good" threshold but it's optimized for greenfield codebases or for codebases that have been disciplined from day one. For brownfield codebases with 9 years of accreted controller logic, a more realistic high-value target is often 40-60%:

  • Below 20% — coverage is too sparse to catch regressions reliably
  • 20-40% — good return on investment; covers happy paths and high-blast-radius logic
  • 40-60% — diminishing returns start; covers most realistic regression surfaces
  • 60-80% — high cost per marginal test; mostly catching edge cases and exotic branches
  • 80%+ — only justified for compliance-bound or safety-critical software

Question for office-hours: is the 80% number driven by a specific need (audit, sale, certification, due diligence) or is it aspirational? The plan changes meaningfully based on the answer.

7.2 What's the time budget?

The above estimates assume 1-3 engineers focused on this. Realistic options:

  • 0.25 engineer (one person, 1 day/week) — Phase 0 + maybe Phase 1 in a quarter. Plan stalls. Not recommended.
  • 1 engineer full-time — Phases 0-3 in ~2 months, then Phase 4 rolling over 6-12 months. Realistic mid-target.
  • 3 engineers full-time for 6 months — Phases 0-5 done; coverage realistically in 40-60% range. Most cost-effective high-impact path.
  • Whole team for a quarter — feasible but costs product velocity. Only justified by external pressure.

7.3 Do we refactor for testability or leave the codebase alone?

Phase 2's main cost driver is refactoring controllers from new RakletDb() inline to constructor injection. Two ways out:

  • Yes, refactor — controllers become testable, future tests are cheap. Cost: 5-10 days of careful refactoring per Tier 1 controller.
  • No, don't refactor — wrap each controller test in a heavier fixture that swaps factories. Tests still possible but more complex, fragile, and slower to write. Long-term cost is higher.

Recommend yes-refactor, but the cost is real. This is a real question for office-hours.

7.4 AI-generated tests — yes, no, how much?

Modern coding agents can generate test scaffolding faster than humans. Three positions:

  • No — every test handwritten and reviewed line-by-line. Highest quality, slowest throughput.
  • Yes, with review — AI generates the test, engineer reviews. Maybe 3-5× faster. Real quality risk if review is rubber-stamped.
  • Yes, autonomous — AI generates and lands tests via a /loop or scheduled job; humans only review samples. Highest throughput; highest risk of tests that pass without verifying anything meaningful (the "test that always passes" failure mode).

The CEO's stated AI-adoption goals (per memory) probably point toward option 2 or 3. Worth surfacing this as a deliberate choice rather than a default.

7.5 What do we stop doing to make room?

This is the hard one. Engineering time is fungible. The above estimates of 18-30 engineer-months don't exist unless something else gets deprioritized. Office-hours should pin down what.

8. Open risks

  • Refactoring AccountController (1,251 LOC). This is the single-biggest risky change in Phase 2. If it breaks login or registration, the blast radius is everyone. Recommendation: do it with paired QA, behind feature flags if possible, with the pre-merge browse-ui-tests gate that pr-ci.yml provides.
  • CI runner capacity. ci-vm-1 is already saturated today. Each Tests run that produces coverage adds ~3-5 minutes of dotnet-job time. At 80% coverage with 30k tests, that could blow up. Plan needs a coverage-only nightly run, not on every PR. Investigate in Phase 1.
  • Coverlet on .NET Framework 4.7.2. Working today (verified in RAK-340) but the third-party landscape moves slowly. If coverlet.console gets abandoned, we'd need a fallback. Low probability, high impact.
  • AI test slop. If we adopt AI-generated tests heavily, the failure mode of "tests that don't actually assert anything" becomes a real risk. Need a sample-review discipline.

9. Next actions (post office-hours)

After /office-hours revises this plan, the deliverables are:

  1. Commit to a target curve — pick a row in §6's cost model
  2. Open follow-up Linear cards for each committed phase (one card per phase, not per controller — controllers become subtasks under the phase card as we get to them)
  3. Start Phase 0 — the cleanup that doesn't depend on any decision
  4. Pencil in a Phase 1 start date based on engineering allocation

Source: this plan was generated by an AI agent (Claude) against master sha 3546eece. The agent has not yet written a single test. Coverage numbers come from coverlet.console output uploaded as the dotnet-coverage artifact on Tests workflow run 26253865435.