docs Home GitHub

Test coverage

Find unit tests of low value and discover the real coverage gaps in your code.

Perch Coverage tells you which of your existing unit tests aren’t useful and where you have holes in test coverage. Perch reads every test and every function in your repository. It follows each test through the call graph to the methods it reaches. Then it uses a model to figure out what each test is actually testing.

If your CI writes test results and a coverage report, Perch reads those too, and its numbers are measured. Test results are JUnit XML, which pytest, Vitest, Jest, nextest, Maven, Gradle and GoogleTest can all write. The coverage report can be LCOV, Cobertura, JaCoCo or coverage.py's. Without them, Perch estimates from the call graph and marks each estimate est.

Perch will identify tests that don’t have any assertions, tests that just test that a mock returns what you told it to return, tests that are duplicating other tests, and tests that hit the disk or network without mocking. For each method that your tests run, Perch will tell you the most important branch that isn’t being tested, and what kind of input you need to write a test for that branch. For each method that isn’t run by any tests, Perch will tell you whether it needs to be tested or not.

Point perch coverage at the JUnit XML and coverage report your CI already writes, and its numbers are measured:

$ perch coverage --junit reports/vitest/junit.xml --lcov reports/vitest/lcov.info
Source files      Methods tested  Lines  Branches  Untested branches
src/cart.ts               2 of 2   100%       75%                  1
src/checkout.ts           1 of 1   100%       50%                  1
src/inventory.ts          1 of 1   100%       75%                  1
All source                4 of 4   100%       70%                  3

Test files                    Quality  Duplicates  Weak  Unmocked I/O  Time
test/cart.test.ts        71% (5 of 7)           0     2             1   4ms
test/checkout.test.ts   100% (1 of 1)           0     0             0   1ms
test/inventory.test.ts   75% (3 of 4)           0     1             0   1ms
All tests               75% (9 of 12)           0     3             1   6ms

src/cart.ts
  ID        Line  Problem    Confidence  Test or method  Note
  d0bee7da    12  edge_case        100%  applyDiscount   Untested case: applyDiscount at the edge…

src/checkout.ts
  ID        Line  Problem    Confidence  Test or method  Note
  7087f25c     6  edge_case        100%  placeOrder      Untested case: placeOrder failing.

src/inventory.ts
  ID        Line  Problem    Confidence  Test or method  Note
  49b7e108     6  edge_case         83%  canFulfil       Untested case: canFulfil with input in a…

test/cart.test.ts
  ID        Line  Problem        Confidence  Test or method       Note
  f5aebe0a    28  no_assertion         100%  subtotal > adds up…  Asserts nothing.
  12d6d00b    32  mystery_guest         99%  subtotal > matches…  Uses a file, record or service …
  472ae559    32  infra                 97%  subtotal > matches…  Touches the network and environ…

test/inventory.test.ts
  ID        Line  Problem       Confidence  Test or method       Note
  3ca3cd17    23  asserts_mock         98%  canFulfil > return…  Checks a value its own mock retu…
Dropping 3 duplicate or weak tests saves 4ms of 6ms and leaves every method reached.
Tests are the ones Vitest 2.1.9 (its defaults) runs; 0 files no test framework covers are left out
.perch/coverage/index.html
19 requests  0 tokens in  $0.0000

The call graph and the coverage report find each problem. The percentage is how sure the model is that it needs fixing. A problem with - belongs to a test or method the model could not be asked about. --all lists every row. The HTML report shows the source with each problem under its line. It is one file, unless the repository has more than 8 MB of source. Then each file gets its own page under files/, next to index.html.

How it differs from a coverage report#

A coverage report just tells you what lines were run by your tests. But just because a line was run doesn’t mean it was tested. It’s easy to write tests that will pass no matter what the code does, and get 90% coverage while testing nothing.

Perch reads your coverage report for you, and tells you whether the lines that were run were actually tested. And for branches that weren’t run, Perch tells you which ones you should actually worry about, and what you need to do to run each branch. Rather than giving you a single coverage percentage, Perch gives you a list of specific problems, with the file and line number of each problem, and a confidence score for how sure it is that it’s actually a problem.

Perch only considers the code that your test frameworks will actually run. It does this by loading your Vitest or Jest config or reading your pytest and coverage.py settings, and excluding things like scripts, examples and docs tooling that can’t be covered by tests. When run on a branch, Perch will tell you how many of the lines that were changed on the branch were run by tests, and highlight which problems were found in code that was changed on the branch.

What it reads#

perch coverage reads the code your test frameworks run and measure, and leaves out the rest: release scripts, examples, docs tooling, CI actions.

  • Vitest and Jest: perch loads the config with your installed copy, the way the framework does. The tests are the ones it would run. Its coverage include and exclude decide the source; without an include, the source is what those tests import and the files beside them. Install your dependencies before running it, as for the tests themselves.
  • pytest: testpaths and python_files from pytest.ini, pyproject.toml, tox.ini or setup.cfg, and coverage.py's source and omit.
  • Go, Rust, Java, Kotlin, C++ and the rest: perch only reports coverage for the module that the test is in. That might be a Go module, a crate, a Gradle module, or something else. perch includes all the code that the module builds: all of src/ in a crate, all of src/main/ in a Maven or Gradle module, and all code outside of tools/, bench/, examples/, samples/, scripts/, or docs/ in any other project.

The last lines of the output say which frameworks decided it:

$ perch coverage --paths lib/helpers/combineURLs.js
...
Tests are the ones Vitest 4.1.11 (vitest.config.js), Vitest 4.1.11 (tests/module/esm/vitest.config.js), Vitest 4.1.11 (tests/smoke/esm/vitest.config.js) run; 70 files no test framework covers are left out

A config that cannot load is named with the error, and perch reads every test it finds instead. ignore: in perch.yaml leaves out more on top.

Reading your CI's reports#

Name the files with flags, or list them in perch.yaml so every run finds them. A glob names every file it matches, and a glob that matches nothing is an error.

coverage_reports:
  junit: [reports/junit/*.xml]
  lcov: [coverage/lcov.info]
FlagReportWhat it gives
--junitJUnit XMLEach test's time and result
--lcovLCOVLine and branch hits, and per-test lines when each test has its own TN: record
--coberturaCobertura XMLLine and branch hits
--jacocoJaCoCo XMLLine and branch hits
--contextscoverage.py JSON from coverage json --show-contextsLine and branch hits, and the lines each test ran

A run perch cannot match to a test is listed as unmatched and counted on stderr. It is never guessed.

Supported languages and frameworks#

LanguageFrameworkJUnit XML fromCoverage fromSetting perch needs
Pythonpytest--junitxmlcoverage.py, with --cov-context=test for per-test lines
Pythonunittestunittest-xml-reportingcoverage.pydynamic_context = test_function for per-test lines
JavaScript, TypeScript, TSXVitest--reporter=junitV8, as LCOV or Cobertura
JavaScript, TypeScript, TSXJestjest-junitIstanbul, as LCOV or Coberturajest-junit: addFileAttribute: "true", ancestorSeparator: " > ", classNameTemplate: "{classname}", titleTemplate: "{title}"
JavaScript, TypeScriptMochamocha-junit-reporterc8, as LCOVjenkinsMode: true, suiteTitleSeparatedBy: " > "
JavaScript, TypeScriptnode:test--test-reporter=junit--test-reporter=lcov
Rustlibtest-Z unstable-options --format junit --report-timecargo-llvm-cov, as LCOV--report-time, or every time reads 0
Rustnextesta profile with JUnit outputcargo-llvm-cov, as LCOV
JavaJUnit 5Maven Surefire or GradleJaCoCo XMLGradle, for a class with more than one @ParameterizedTest: junit.jupiter.params.displayname.default = {displayName} [{index}] {arguments} in junit-platform.properties
JavaJUnit 4Maven SurefireJaCoCo XML
JavaTestNGMaven SurefireJaCoCo XML
C++GoogleTest--gtest_output=xmlgcovr, as Cobertura or LCOV
C++Catch2 v3-r junitgcovr--warn NoAssertions, or a test with no assertion has no run
C++doctest-r=junitgcovr

Other languages are not supported yet. A test in a framework perch does not recognize is not found, and its runs are listed as unmatched.

Checking a branch#

--since REF lists each source file the branch changed where the tests missed a changed line of code, and how many they missed. The share of changed lines of code that ran is patch coverage. The problems below it are the ones in methods and tests the branch changed:

$ perch coverage --since main --junit reports/vitest/junit.xml --lcov reports/vitest/lcov.info
Changed since main  Untested lines  Patch coverage
src/main.ts                      1              0%
All changed source               1             50%

src/cart.ts
  ID        Line  Problem    Confidence  Test or method  Note
  d0bee7da    12  edge_case        100%  applyDiscount   Untested case: applyDiscount at the edge…
Dropping 3 duplicate or weak tests saves 4ms of 6ms and leaves every method reached.
Tests are the ones Vitest 2.1.9 (its defaults) runs; 0 files no test framework covers are left out
Whole repository: 4 of 4 methods reached, 100% of lines ran, 6 problems in code this branch did not change
.perch/coverage/index.html

Changed comments and blank lines are not counted. A file no coverage report covers shows not measured. Test files are left out of the table.

With --since, perch exits 3 only for a problem in changed code. It compares with a saved run only when --diff names one.

Fixing and dismissing problems#

Each problem in the HTML report has a Copy fix prompt button. The prompt names the problem, its file and line, the evidence, and the change to make. Paste it into your coding agent.

perch close <id> --reason "..." sets a problem aside, and later runs leave it out. perch reopen <id> lists it again. The Dismiss button in the report copies that command and hides the problem on the page.

Comparing runs#

Each run is saved under .perch/coverage. The next run at another commit says what changed. --diff REF compares with the run saved at that commit and prints the change in each file.

perch coverage exits 3 when it lists a problem and 1 when it could not run.

Edit this page on GitHub