Test Coverage
LLM4S measures statement coverage with sbt-scoverage, one policy
per module. There is no single project-wide threshold: each module declares its own policy, and the CI coverage
job checks the floors of the modules it measures (with the exceptions below).
How coverage is enforced
Per-module floors
Every sbt project in build.sbt makes an explicit decision with one of two settings (defined in
project/Dependencies.scala, object Coverage):
| Setting | Meaning |
|---|---|
coverageFloor(n) |
Statement coverage must stay at or above n percent. It sets coverageMinimumStmtTotal := n and coverageFailOnMinimum := true for that module. |
coverageDisabled |
The module is deliberately not measured. It carries a comment in build.sbt saying why. |
A floor is the measured statement coverage rounded down to the nearest 5, so a module has headroom for normal churn. Floors only ratchet up: raise one when a module’s coverage grows, and never lower one to make a change pass.
A floor is never inherited. The build-wide default is Coverage.Policy.Undeclared, so a new module has to choose.
The policy check
sbt coveragePolicyCheck lists every module’s policy and fails if any module has declared neither a floor nor an
opt-out. It runs in CI as a quick check, and prints one line per module:
1
2
3
4
Coverage policy per module:
agent floor 80%
configPolicy disabled
...
Run it to see the current floors. The numbers are deliberately not copied here, because they change.
What CI runs
Coverage runs in four parallel legs, the Code Coverage (<leg>) jobs (coverage-leg in
.github/workflows/ci.yml). Each leg lists the modules it owns in the job’s matrix and runs
<module>/test <module>/coverageReport for each, in order; <module>/coverageReport is the step that enforces that
module’s floor. A report counts every measurement taken so far in the leg, so a module’s floor is judged on its own
tests plus those of modules that ran ahead of it in the same leg. The legs keep the order the single coverage job
used, and mostly keep a module with the modules whose tests exercise its code (rag before knowledgegraph,
media and observability; image before observabilityPrometheus; the providers before providerTestkit).
Two such pairs are now split: providerTestkit no longer counts rag’s, mcp’s, speech’s and image’s tests
(96.66% to 96.46%, floor 90), nor agent observability’s (93.19% to 93.14%, floor 90). Each leg uploads its modules’ Codecov flags, and only
those, so every flag is uploaded once. The Code Coverage job passes only when every leg does, and is what
All Tests Pass waits for. It also writes the aggregate report from the legs’ coverage data, which is informational
only, because a build-wide average is not a meaningful gate.
knowledgegraphNeo4j and benchmarks declare floors but are in no leg, so CI does not currently enforce their
floors. The benchmark workflow runs tests without coverage.
The Integration Tests job adds the modules/it Docker tier’s hits (#2349). Some code is reachable only against a
real service: the pgvector, Qdrant and permission-index stores in rag, the Elasticsearch/OpenSearch and Redis/Valkey
stores, memoryPostgres, knowledgegraphNeo4j, and the Postgres checkpointer and run queue in
agentCheckpointPostgres. The job instruments just those modules (its test step’s COVERAGE_MODULES), runs
sbt testIntegration, writes each module’s coverageReport with its floor switched off, and uploads each report under
the module’s own flag. Codecov merges the two uploads of a flag on a commit, so the integration hits count towards
patch and project coverage; the floors stay with the legs, because a report from the integration suites alone is not
what a floor measures. knowledgegraphNeo4j, in no leg, is uploaded only from this job. To run it locally against
the services, as CI does: sbt "set rag / coverageEnabled := true" "set rag / coverageFailOnMinimum := false"
"set rag / coverageMinimumStmtTotal := 0" testIntegration rag/coverageReport (those three settings and one report
per module).
A new module with a floor has to join a leg, normally the one holding its dependencies, after them, with an upload step for
its flag, in the same change that adds it. scripts/check-coverage-legs.sh, run in Quick Checks against the build sbt
loaded, fails until it does: it checks that every module with a floor is in exactly one leg (or in the script’s
short, reasoned exemption list) and has exactly one upload step, under the codecov.yml flag for its directory.
The integration job’s uploads are the one allowed second upload of a flag: the script checks that each module in its
COVERAGE_MODULES has exactly one upload there, of its own report under its own flag, that the job switches those
floors off, and that the job uploads nothing else.
To run one leg locally as CI does, list its modules’ commands after coverage, for example
sbt coverage core/test core/coverageReport memory/test memory/coverageReport.
Codecov
codecov.yml configures the pull-request statuses:
patch: new and changed lines must be covered at 90%, with a 5% threshold.project: the target isauto(the base commit’s coverage), with a 1% threshold.- Flags: one flag per module, whose
pathsare that module’ssrc/main/scala. Codecov only attributes a file to a flag whosepathscontain it, so a module with no flag silently disappears from per-module reporting instead of failing. Every change that adds a module adds its flag tocodecov.yml.
Only main sources are tracked. modules/benchmarks is ignored.
Not measured
ThisBuild / coverageExcludedPackages excludes these packages:
org.llm4s.runner.*org.llm4s.deploy.DeployServiceMain(the deploy service’s entry point; its routes and checks are measured)org.llm4s.examples.http.*Main(the HTTP reference apps’ entry points; their routes and servers are measured)org.llm4s.samples.*org.llm4s.workspace.*
Running coverage locally
Aliases from build.sbt:
| Command | Does |
|---|---|
sbt cov |
clean; coverage; test; coverageAggregate; coverageReport; coverageOff: the whole test suite with coverage, plus the aggregate report at the root. It is for looking at the combined picture and enforces no module floor, because the root project’s own policy is disabled. |
sbt covReport |
clean; coverage; test; coverageReport; coverageOff: the same for the current project only. |
To check one module the way CI does, including its floor:
1
sbt coverage core/test core/coverageReport
Replace core with the module’s sbt project name (the one coveragePolicyCheck prints). The report is written to
target/scala-3.7.1/scoverage-report/ inside the module’s directory: index.html for reading, scoverage.xml for tooling.
Run sbt coverageOff afterwards, or start a new sbt session, so later compiles are not instrumented.
To see a module’s report without failing on its floor, for a one-off run:
1
sbt "set core / coverageFailOnMinimum := false" coverage core/test core/coverageReport
Troubleshooting
- Coverage is 0% everywhere. Run
coveragebeforetestin the same sbt invocation. coveragePolicyChecksays a module isUNDECLARED. AddcoverageFloor(<pct>)orcoverageDisabled(with a comment) to the project inbuild.sbt, and add its flag tocodecov.ymlin the same change.- A module’s floor fails after your change. Add tests for the new code. Do not lower the floor.