Testing Flutter Applications

Test Coverage: Measuring and Improving It

16 min Lesson 11 of 12

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.

Tip: Add 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."
Note: Many teams also use Codecov or Coveralls with their GitHub Actions workflow. These services comment on pull requests with a coverage diff, making regressions immediately visible to reviewers. Configure them by uploading 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/else branch that was only partially tested
  • Uncovered functions — entire methods that no test has called
  • Error-handling pathscatch blocks 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 --coverage and 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-line and // coverage:ignore-start … // coverage:ignore-end to exclude genuinely untestable lines (platform channels, generated code kept in-source) from the report.
Warning: Chasing 100 % coverage is a trap. Tests written solely to increase a number often test implementation details instead of behaviour, making refactoring painful. Aim for meaningful coverage of logic branches, not syntactic line counts.

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.