Test Coverage: Measuring and Improving It
Test Coverage: Measuring and Improving It
Test coverage is a metric that tells you what percentage of your source code is executed when your test suite runs. A high coverage score does not guarantee a bug-free app, but it reveals untested code paths that are invisible otherwise. In Flutter, the toolchain integrates directly with Dart's built-in coverage infrastructure, making it straightforward to generate, visualise, and act on coverage data.
Generating an LCOV Coverage Report
Run your tests with the --coverage flag to collect raw coverage data. Flutter writes the results to coverage/lcov.info in the standard LCOV format:
Collecting coverage data
# Run all tests and collect coverage
flutter test --coverage
# Run a specific test file with coverage
flutter test test/services/cart_service_test.dart --coverage
# Output location
# coverage/lcov.info (created automatically)
The lcov.info file is a plain-text report understood by many CI tools, coverage services (Codecov, Coveralls), and local HTML generators.
Visualising Uncovered Lines with genhtml
genhtml is part of the LCOV suite and converts lcov.info into a browsable HTML report where covered lines are green and uncovered lines are red:
Generating and opening an HTML coverage report
# Install lcov (macOS)
brew install lcov
# Install lcov (Ubuntu/Debian)
sudo apt-get install lcov
# Generate the HTML report
genhtml coverage/lcov.info --output-directory coverage/html
# Open the report in your default browser (macOS)
open coverage/html/index.html
# Open on Linux
xdg-open coverage/html/index.html
The HTML report shows per-file and per-directory breakdowns, giving you a precise map of which functions, lines, and branches have never been touched by a test.
coverage/ to your .gitignore so generated HTML reports do not pollute your repository. Only commit coverage/lcov.info if your CI pipeline needs it as an artifact.Filtering Out Generated Files
Dart code generation (e.g., json_serializable, freezed, Riverpod @riverpod annotations) produces *.g.dart and *.freezed.dart files that inflate coverage numbers and create noise. Remove them with lcov --remove before generating the HTML report:
Removing generated files from coverage data
# Strip generated files and files under lib/generated/
lcov --remove coverage/lcov.info \
'*.g.dart' \
'*.freezed.dart' \
'*/generated/*' \
--output-file coverage/lcov_filtered.info
# Now build the HTML from the clean data
genhtml coverage/lcov_filtered.info --output-directory coverage/html
Setting Coverage Thresholds
A threshold enforces a minimum acceptable coverage percentage and fails your CI pipeline when coverage drops below it. Flutter itself does not have a built-in threshold flag, so teams implement this with a small shell script or a Makefile target:
Enforcing a minimum coverage threshold in CI
#!/usr/bin/env bash
# scripts/check_coverage.sh
set -e
THRESHOLD=80 # Fail if coverage drops below 80 %
flutter test --coverage
# Extract the overall line coverage percentage from lcov.info
LINES_HIT=$(grep -E "^LH:" coverage/lcov.info | awk -F: '{sum += $2} END {print sum}')
LINES_FOUND=$(grep -E "^LF:" coverage/lcov.info | awk -F: '{sum += $2} END {print sum}')
COVERAGE=$(echo "scale=2; $LINES_HIT * 100 / $LINES_FOUND" | bc)
echo "Coverage: ${COVERAGE}% (threshold: ${THRESHOLD}%)"
if (( $(echo "$COVERAGE < $THRESHOLD" | bc -l) )); then
echo "FAIL: Coverage ${COVERAGE}% is below the required ${THRESHOLD}%"
exit 1
fi
echo "PASS: Coverage threshold met."
lcov.info as a CI artifact and adding the provider's action to your workflow.Identifying Code Paths That Still Need Tests
Once you have an HTML report open, focus on the files with the lowest coverage percentages first. Inside a file, look for:
- Red lines — statements that were never reached during the test run
- Branch markers — red half-diamonds indicate an
if/elsebranch that was only partially tested - Uncovered functions — entire methods that no test has called
- Error-handling paths —
catchblocks and null-guard branches are commonly missed
A class with a deliberately uncovered error path
class PaymentService {
final ApiClient _client;
PaymentService(this._client);
// This method has two paths: success and the thrown exception.
// If tests only exercise the happy path, the catch block stays red.
Future<Receipt> charge(double amount) async {
try {
final response = await _client.post('/charge', {'amount': amount});
return Receipt.fromJson(response.data);
} on ApiException catch (e) {
// This branch is red until you write a test that
// simulates an ApiException from the mock client.
throw PaymentFailedException(e.message);
}
}
}
// To cover the error path, add a test like this:
void main() {
test('charge throws PaymentFailedException on ApiException', () async {
final mockClient = MockApiClient();
when(() => mockClient.post(any(), any()))
.thenThrow(ApiException('Network error'));
final service = PaymentService(mockClient);
expect(
() => service.charge(9.99),
throwsA(isA<PaymentFailedException>()),
);
});
}
Practical Workflow for Improving Coverage
Coverage improvement is most effective when it is incremental and purposeful, not a number-chasing exercise:
- Run
flutter test --coverageand generate the HTML report as part of every feature branch. - Set a ratchet rule: coverage may never decrease from one pull request to the next, even if the absolute threshold stays low initially.
- Prioritise business-critical code (payment logic, auth, data serialisation) over trivial getters and UI glue.
- Use
// coverage:ignore-lineand// coverage:ignore-start … // coverage:ignore-endto exclude genuinely untestable lines (platform channels, generated code kept in-source) from the report.
Summary
Running flutter test --coverage produces an LCOV report. genhtml converts that report into an HTML visualisation where uncovered lines appear in red. Filtering out generated files prevents inflated numbers. CI threshold scripts enforce a coverage floor so regressions are caught before they reach production. Finally, focus test-writing effort on business-critical logic, error-handling branches, and complex conditional paths rather than blindly increasing line counts.