Share a Flutter test and coverage report

flutter test --coverage gives you coverage/lcov.info. That is not a report — it is a machine format that a tool is supposed to read. Yet it is routinely what gets uploaded as a CI artifact and then never opened, since opening it accomplishes nothing.

The readable artifact is one conversion away.

Convert it

flutter test --coverage || true

# strip generated code first — it dominates the numbers otherwise
lcov --remove coverage/lcov.info \
  '*.g.dart' '*.freezed.dart' '*.mocks.dart' \
  -o coverage/filtered.info

genhtml coverage/filtered.info -o coverage/html
# → coverage/html/index.html

genhtml ships with lcov (brew install lcov, apt install lcov). The filtering step is not optional in practice: a Flutter project with json_serializable and freezed generates a great deal of Dart, and including it produces a coverage number that means nothing.

The output is a directoryindex.html, a page per source file, plus stylesheets — so publish the folder.

Drop the folder in

Drag coverage/html/ (or a zip) into Comma:

  • index.html becomes the report body — the page carrying the comment layer.
  • The per-file pages and CSS upload alongside it, with relative references rewritten, so the drill-down from summary to directory to annotated Dart resolves.
  • Scripts run inside a sandboxed iframe (allow-scripts, no allow-same-origin), so the sortable columns keep working.

From CI

curl -fsS -X PATCH "https://commareports.com/api/v1/reports/$REPORT_ID" \
  -H "Authorization: Bearer $COMMA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --rawfile html coverage/html/index.html \
        --arg title "Coverage — $(git rev-parse --short HEAD)" \
        '{title: $title, html: $html}')"

The API is JSON-only, so the per-file pages go up through POST /api/v1/reports/$REPORT_ID/assets as base64 — one call per file, worth scripting once. See the API reference.

The || true on the test command and an if: always() on the step keep the publish alive when a test fails, which is when the report matters. Use a scoped token (reports:write) from CI secrets and PATCH a saved report id. See publishing from CI.

Test results, if you want them too

flutter test --machine emits a JSON event stream that JUnit converters consume, which is the route if your pipeline wants a results view alongside coverage:

flutter test --machine | tojunit --output junit.xml

See share a JUnit report. For integration tests driving a real device, the shape is closer to the Android and Xcode reports.

What the URL changes

  • The PR links the coverage. Reviewers click instead of running the suite and genhtml locally.
  • Review lands on the widget. Select the uncovered branch, leave a thread — "this is the error state, it has no test" — and it stays anchored across runs. See commenting on HTML.
  • Revision diffs beat the percentage, especially in Flutter where the number swings whenever the generated-code filter changes.

Who can see it

The coverage report contains your Dart source. Access is per report — private, your team, any signed-in user with the link, or public — and team or private is the right default for a private repo. Domain-gating is Enterprise. See sharing & access control.

Limits

  • Entry HTML: 5 MB. Assets: 25 MB per file, 250 MB and 500 files total. A large app can exceed 500 source pages — publish the feature directory under review rather than the whole tree.
  • Scripts run, sandboxed — no same-origin access.
  • 60 requests/minute per token.

Try it

Comma is free — unlimited reports, unlimited commenters, unlimited revision history.

Publish a coverage report →

Related