Skip to content
Blog

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 --coverage and understand the LCOV format
  • Render an HTML report with genhtml and read line-level highlights
  • Use test_cov_console for a terminal-friendly summary without a browser
  • Filter *.g.dart and 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/
  • lcov installed if you want HTML output (brew install lcov, sudo apt-get install lcov, or choco install lcov on 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 to coverage/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 path
  • DA:line,hitcount — a line and how many times it executed; 0 means never hit
  • LF: / LH: — total lines and lines hit for the file
  • BRDA: / BRF: / BRH: — branch data, present only with --branch-coverage
  • FNF: / 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 several lcov.info files 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:

LayerTargetWhy
Core logic (lib/src/domain, lib/src/data)90%+This is where regressions cost the most
UI screens60–80%State coverage matters more than line coverage
Generated codeExcludedNot 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:

  1. Diff coverage on every PR — new and modified lines must be covered. This is enforceable immediately and does not punish existing debt.
  2. 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.

Further reading