Flutter Test Coverage Tools: From `flutter test --coverage` to Actionable Reports
How to generate, read, and act on Flutter test coverage: the --coverage flag, LCOV output, genhtml, test_cov_console, filtering generated files, merging baseline data, and wiring coverage into CI with thresholds and Codecov.
Published on • October 10, 2026
AI Assistant

Coverage is a terrible goal and a good instrument. Chasing a number produces tests that execute code without asserting anything, but the same report tells you something more useful than a percentage: which files nobody has ever exercised at all. Those are the files where your next bug is hiding.
This post is about the tooling around Flutter coverage — how to turn flutter test --coverage into a report a human can actually read, how to keep generated files from wrecking the numbers, how to enforce a threshold in CI, and how to decide which of the three or four tools in the ecosystem you actually need.
In this tutorial, you will learn how to:
- Generate coverage data with
flutter test --coverageand understand the LCOV format - Render an HTML report with
genhtmland read line-level highlights - Use
test_cov_consolefor a terminal-friendly summary without a browser - Filter
*.g.dartand other generated sources out of the report - Merge incremental coverage into a baseline with
--merge-coverage - Set a coverage gate in CI and upload results to Codecov
- Choose sensible coverage targets for unit, widget, and integration layers
Key technologies: flutter test --coverage, LCOV (lcov.info), genhtml, test_cov_console, --merge-coverage, --coverage-path, Codecov, GitHub Actions.
Prerequisites
- A Flutter project with at least a few tests under
test/ lcovinstalled if you want HTML output (brew install lcov,sudo apt-get install lcov, orchoco install lcovon Windows)- A CI runner if you want the gate
Generating coverage
The flag is built into the Flutter tool:
flutter test --coverage
That runs your test suite and writes coverage/lcov.info at the project root. The tool exposes a few related options worth knowing:
--coverage-path— change the output location (defaults tocoverage/lcov.info)--coverage-package— a regex limiting which packages appear in the report; defaults to your current package--branch-coverage— collect branch coverage instead of only line coverage--merge-coverage— merge this run’s data into an existing baseline (implies coverage collection)--machine— emit the machine-readable event stream alongside coverage
A typical full pipeline in one line:
flutter test --coverage && \
lcov -r coverage/lcov.info 'lib/**.g.dart' -o coverage/lcov.info && \
genhtml coverage/lcov.info -o coverage/html
Reading the LCOV file
LCOV is a plain-text format, which is both its weakness and its charm. You can debug your coverage setup by opening the file:
SF:lib/src/auth/login_repository.dart
DA:12,1
DA:13,1
DA:14,0
DA:19,3
LF:4
LH:3
end_of_record
The meaning of each record:
SF:— source file pathDA:line,hitcount— a line and how many times it executed;0means never hitLF:/LH:— total lines and lines hit for the fileBRDA:/BRF:/BRH:— branch data, present only with--branch-coverageFNF:/FNH:— function name coverage
That third DA line with a hit count of 0 is the interesting one. The percentage tells you how much; the zeros tell you where.
The file paths are relative to your project root and use forward slashes even on Windows, which is worth remembering if you write a script that resolves them.
Making it readable: genhtml
genhtml is part of lcov and converts the data into a browsable HTML report:
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html
You get a directory-level summary that drills down to per-file breakdowns, and at the file level, green and red line highlighting in the source. That drill-down is the whole point — a single number cannot tell you that lib/src/payments/refund_flow.dart has never been executed.
Commit the coverage/ directory to .gitignore. It is generated output and the HTML is large.
A terminal alternative: test_cov_console
If you want a summary without opening a browser, test_cov_console reads the same lcov.info and prints a table:
dev_dependencies:
test_cov_console: ^0.2.2
flutter test --coverage
flutter pub run test_cov_console
----------------------------------------------|---------|---------|---------
File |% Lines |% Branch |% Funcs
----------------------------------------------|---------|---------|---------
lib/src/auth/login_repository.dart | 100.00 | 88.37 | 100.00
lib/src/auth/token_store.dart | 76.47 | 60.00 | 80.00
----------------------------------------------|---------|---------|---------
All files with unit testing | 91.20 | 74.11 | 95.00
Useful flags:
-e/--exclude— comma-separated path fragments to leave out-i/--ignore— hide files with no unit tests at all-c/--csv— write a CSV you can diff or feed into a dashboard-p/--pass=<MIN>— exit non-zero if total coverage is below a threshold-m/--multi— aggregate severallcov.infofiles across a workspace
That last flag matters if you use a Dart workspace: each package produces its own coverage/lcov.info, and --multi combines them.
Filtering generated files
This is the step most people skip, and it distorts the report badly. A Flutter project full of *.g.dart, *.freezed.dart, *.mocks.dart, and *.drift.dart files will show hundreds of lines that are never hand-written and often never directly executed. They drag the percentage down and bury the real files.
Strip them with lcov:
lcov -r coverage/lcov.info 'lib/**.g.dart' -o coverage/lcov.info
lcov -r coverage/lcov.info 'lib/**.freezed.dart' -o coverage/lcov.info
lcov -r coverage/lcov.info 'lib/**.mocks.dart' -o coverage/lcov.info
The -r flag removes matching records from the input and writes the result to the output. Chain the commands, or use a shell loop.
An alternative is to exclude them from collection in the first place via --coverage-package, but that filters by package name rather than file pattern, so the glob approach is more precise for in-package generated code.
Merging coverage across runs
Sometimes you do not want to re-run everything. --merge-coverage combines a partial run into a stored baseline:
# Build a baseline once
flutter test --coverage
cp coverage/lcov.info coverage/lcov.base.info
# Later: run one test file and fold it into the baseline
flutter test --merge-coverage test/auth/login_repository_test.dart
Each --merge-coverage invocation starts from lcov.base.info, applies the current run, and writes the combined result to coverage/lcov.info. So the baseline stays clean — you are not double-counting the same run twice.
This is the fastest loop for local development: add a test, run only that file, and see the updated report in a second or two instead of waiting for the full suite.
Choosing what to cover
Coverage measures execution, not correctness, and the three Flutter test layers execute very different amounts of code:
- Unit tests are cheap and should cover your logic heavily. This is where a high number is meaningful.
- Widget tests execute layout and interaction. They inflate line counts on UI files without necessarily asserting much.
- Integration tests execute almost everything, including code you have no assertions about.
If you run flutter test --coverage and get 95% because integration tests touched every file, you have learned nothing. Generate coverage from the layer you are actually evaluating — for most teams, that means flutter test test/ for the unit and widget suites, with integration coverage measured separately if at all.
A reasonable target set:
| Layer | Target | Why |
|---|---|---|
Core logic (lib/src/domain, lib/src/data) | 90%+ | This is where regressions cost the most |
| UI screens | 60–80% | State coverage matters more than line coverage |
| Generated code | Excluded | Not yours to test |
Enforcing a threshold in CI
A coverage number nobody checks is decoration. The simplest gate uses test_cov_console:
- name: Run tests with coverage
run: flutter test --coverage
- name: Enforce threshold
run: flutter pub run test_cov_console --pass=75
--pass=75 exits non-zero when total coverage falls below 75, which fails the job. It is the lowest-effort gate available and works well for a first pass.
For richer reporting, upload to a service that tracks trends:
- name: Upload to Codecov
uses: codecov/codecov-action@v5
with:
files: coverage/lcov.info
fail_ci_if_error: true
Codecov gives you per-directory coverage, diff coverage on pull requests (only the lines you changed), and a history chart. Diff coverage in particular solves the “the number went down because someone refactored a file” argument: it shows coverage on the lines in the PR, not the whole repo.
The pattern that works in practice is a two-tier gate:
- Diff coverage on every PR — new and modified lines must be covered. This is enforceable immediately and does not punish existing debt.
- Total coverage on main — a slow-moving threshold you ratchet upward over time, never downward.
Trying to enforce a high total-coverage number on day one produces pressure to write tests that assert nothing. Diff coverage does not have that failure mode.
Automating the whole pipeline
Here is a script that ties the steps together — run tests, strip generated files, render HTML, and gate on a threshold:
#!/usr/bin/env bash
set -euo pipefail
flutter test --coverage
# Remove generated sources from the report
for pattern in 'lib/**.g.dart' 'lib/**.freezed.dart' 'lib/**.mocks.dart'; do
lcov -r coverage/lcov.info "$pattern" -o coverage/lcov.tmp
mv coverage/lcov.tmp coverage/lcov.info
done
# Human-readable HTML
genhtml coverage/lcov.info -o coverage/html
# Gate on a threshold
flutter pub run test_cov_console --pass=75
Wire that into CI as a single step and the coverage question stops being a manual chore.
Common pitfalls
Coverage is not correctness. if (condition) { doThing(); } with a test that never sets condition to false still shows the line as covered in some configurations. Always pair coverage with assertions that would fail if the behaviour changed.
The suite and the coverage run can disagree. flutter test and flutter test --coverage use the same tests, but coverage collection slows the run noticeably. If a test passes normally and fails under coverage, the usual cause is a timing assumption — fix the test, do not drop the flag.
Web and native coverage differ. Conditional imports mean a file covered on Android may be untouched on web. Generate coverage per target if your app ships to both.
Do not compare numbers across projects. A project with a lot of pure Dart logic will always show a higher number than one that is mostly platform glue. Compare your own trend over time instead.