Effective CI for Dart & Flutter
When you push code, the CI pipeline automatically validates it against lint rules, enforces formatting, runs tests and builds the application.
This guide covers best practices and optimization techniques for efficient Flutter CI pipelines. This example uses GitLab for CI configuration, but most other Git providers offer similar features. This guide is about CI part with focus on monorepo projects.
Outline
Introduction
CI pipelines run on clean, stateless runners that start fresh each time, ensuring reproducibility. Runners can be cloud-hosted (GitHub Actions, GitLab CI) or self-hosted for full control.
Key principles for effective pipelines:
- Idempotence: Each step yields consistent results across runs
- Isolation: No side effects between jobs
- Fast feedback: Results in minutes, not hours
Continuous Integration
CI ensures the main branch is always releasable. Every push or pull request should trigger a pipeline that includes:
- Linting – Enforce code style (
dart analyze,flutter format). - Testing – Run unit and integration tests.
- Building – Compile your app to catch issues early.
If any stage fails, merging is blocked. This prevents regressions and encourages small, incremental changes.
Install Flutter on CI Runner
To use Flutter CLI tools like flutter test or flutter analyze, the runner must have Flutter SDK installed. The simplest way is using a container image:
default:
image: your-image
jobs:
some-job:
# Overrides default image
image: your-imageChoose the lightest image with only the tools you need:
- Android build: Needs Android SDK and related tools.
- Static analysis and unit testing: Only needs Flutter SDK.
Tip: Use images like plugfox/flutter (~1 GB) to reduce pull time.
Prepare Job
The prepare job starts the CI pipeline and sets up the project. This includes fetching dependencies, running code generation, and completing other tasks required before building or launching the app.
In a single-module repository, you usually only need to run pub get and a build_runner. In a monorepo, however, you must repeat this process for each package—resolving dependencies and running code generation separately for each.
It is very important to use Pub Workspaces to manage dart & flutter monorepos.
Here is a sample script that bootstraps a multi-module project:
# bootstrap.bash
# Get workspace dependencies
flutter pub get
# For each package that has a pubspec.yaml file and build_runner dependency
# run generation
find . -type f -name "pubspec.yaml" -exec grep -q build_runner {} \; -exec dirname {} \; | while read -r dir; do
if [ -f "$dir/pubspec.yaml" ]; then
pushd $dir
printf "\nGenerating files for $dir\n"
dart run build_runner build -d
popd
fi
done
# Run l10n generation
cd app && bash scripts/l10n.bash
This script performs the following tasks:
- Resolves dependencies for the entire workspace using
flutter pub get. - Finds all packages with a
pubspec.yamlfile that listbuild_runneras a dependency, then runs code generation for each. - Generates localization files.
You can integrate this script into the CI config like this:
prepare:
stage: prepare
interruptible: true
allow_failure: false
# Re-use global rules and before_script snippets.
rules:
- !reference [.global_setup, rules]
before_script:
- !reference [.global_setup, before_script]
# Global CLI tools
- dart pub global activate coverage
- dart pub global activate cobertura
- dart pub global activate junitreport
- dart pub global activate dreport
script:
# Install dependencies for every package and trigger build_runner where needed.
- bash scripts/bootstrap.bash
# Artifacts needed by downstream jobs
artifacts:
expire_in: 30m
paths:
- "**/.dart_tool"
- "**/*.*.dart"
The prepare job installs global dependencies like coverage and runs the bootstrap script. Alternatively, you could move the installation of global dependencies into the bootstrap script itself.
dart_tool directories. These artifacts are reused by subsequent jobs.Caching Dependencies
Caching dependencies is a simple but effective way to optimize your pipeline. By reusing previously downloaded packages, you can reduce job execution time and minimize bandwidth usage.
Example configuration for GitLab:
# --------------------------------------------------------
# 1. Define default cache settings for all jobs
# --------------------------------------------------------
default:
cache:
# Use pubspec.lock content to generate a unique cache key
key:
files:
- pubspec.lock
# Fallback key if the primary key doesn’t exist
fallback_keys:
- "cache-$CI_COMMIT_REF_SLUG"
# Paths to cache (where Dart/Flutter stores downloaded packages)
paths:
- .pub-cache/
# --------------------------------------------------------
# 2. Override PUB_CACHE to point to a local directory
# --------------------------------------------------------
variables:
# Force Dart/Flutter to use a project-local cache folder
PUB_CACHE: "$CI_PROJECT_DIR/.pub-cache"
GitLab uses the contents of pubspec.lock to compute a cache key. If the file hasn't changed between pipeline runs, GitLab reuses the existing cache. If the file has changed—such as when you add or upgrade a dependency—GitLab creates a new cache entry.
If there's no exact match for the primary cache key, GitLab falls back to cache-$CI_COMMIT_REF_SLUG. This fallback provides a more general cache based on the branch name, which can still improve performance.
To ensure all jobs use the same local cache, set PUB_CACHE to $CI_PROJECT_DIR/.pub-cache. Without this override, Dart and Flutter use a global cache (e.g., ~/.pub-cache), which GitLab does not persist between jobs.
Lint Stage
The lint stage enforces code quality by running formatting and static analysis checks.
The following script formats only the relevant source folders (lib, test) in packages with a pubspec.yaml file, excluding generated files such as *.g.dart:
# scripts/format.bash
# Format Dart files in all packages (excluding codegen)
find app core feature -type f -name "pubspec.yaml" -exec dirname {} \; | while read -r dir; do
pushd "$dir" > /dev/null || continue
echo -e "\nFormatting $dir"
targets=()
for d in lib test; do
[[ -d $d ]] && targets+=("$d")
done
if ((${#targets[@]})); then
find "${targets[@]}" -name '*.dart' ! -name '*.*.dart' \
-exec dart format --line-length 100 {} +
fi
popd > /dev/null
done
Use this script in the formatting job. For static analysis, you could use flutter analyze separately:
# ───── Shared Lint Template ─────────────────────────────────────────────────────
.lint:
stage: lint
interruptible: true # Cancel if a newer pipeline starts
allow_failure: false
needs: [prepare] # Reuse artifacts from prepare stage
before_script:
- dart pub get
# ───── Formatting Step ──────────────────────────────────────────────────────────
format:
extends: .lint
script:
- bash scripts/format.bash
# ───── Static Analysis Step ─────────────────────────────────────────────────────
analyze:
extends: .lint
script:
- flutter analyze . --no-pub # Avoid redundant pub resolution
Handle Deprecations
By default, flutter analyze will fail on deprecated APIs. While useful locally, enforcing this in CI is discouraged, as it disrupts incremental upgrades. For CI pipelines it is recommended to ignore deprecation-related lints. Find more information at How to use @Deprecated in Dart.
Code Quality Reports
GitLab supports displaying Code Quality issues directly in the merge request UI. While optional, this feature is especially helpful in large monorepos, making it easier to spot dart analyze warnings at a glance. Similar functionality is also available on GitHub.

GitLab requires a specific Code Quality JSON schema, which differs from the raw dart analyze output. To bridge this gap, I have created dreport CLI to convert analyzer output into GitLab-compatible format.
Here is a script that analyzes code and converts it into codequality format:
# scripts/analyze_and_report.sh
# Run static analysis and capture output
dart analyze . --fatal-infos | tee analyzer_report.txt
analyze_status=${PIPESTATUS[0]}
# Convert analyzer output to GitLab's code quality JSON format
dreport analyze convert \
-i analyzer_report.txt \
-o analyzer_report.json \
-f gitlab
# Exit with analyzer status
if [ $analyze_status -ne 0 ]; then
echo "❌ Analyzer failed"
else
echo "✅ Analyzer passed."
fi
exit $analyze_statusIntegrate this script into the analyze job:
analyze:
extends: .lint
script:
- bash scripts/analyze_and_report.sh
artifacts:
reports:
codequality: analyzer_report.jsonTest Stage
The test stage verifies code correctness using automated tests at multiple levels: unit, widget, golden, and integration.
Unit and widget tests are often grouped for simplicity and faster execution. Golden and integration tests are usually split into separate jobs to improve visibility, isolate failures, and simplify debugging.
The following is a reusable default configuration template for test jobs in your CI pipeline:
.test:
stage: test
interruptible: true
allow_failure: false
needs: [prepare]
rules:
- !reference [.global_setup, rules]
before_script:
- !reference [.global_setup, before_script]
- dart pub getCreate a script that executes unit tests:
# In scripts/unit_test.bash
#!/bin/bash
set -e
# Find directories with a pubspec.yaml and a test/ folder
find_test_dirs() {
find . -type f -name "pubspec.yaml" -exec dirname {} \; | while read -r dir; do
if [ -d "$dir/test" ]; then
echo "$dir"
fi
done
}
# Capture the output of find_test_dirs and pass it to flutter test
test_dirs=$(find_test_dirs)
if [ -n "$test_dirs" ]; then
flutter test $test_dirs -x golden --no-pub --coverage --file-reporter json:reports/tests.json
else
echo "No directories with pubspec.yaml and test/ folder found."
fiThe flutter test command excludes golden tests, collects code coverage, and saves test results to reports/tests.json.
Run this script from the unit_tests job:
# In CI
unit_tests:
extends: .test
# Runs unit tests in all packages
script: bash scripts/unit_test.bashCollect Coverage Reports
Running flutter test --coverage collects data on which parts of your code run during tests. This data is known as code coverage. Higher coverage generally indicates broader test coverage across your codebase, though it doesn't guarantee test quality. Still, maintaining at least 80% coverage is a widely accepted best practice.
You can enforce a minimum coverage threshold in your CI pipeline. For example, if the threshold is 80%, the pipeline will fail if coverage drops below that value. This encourages developers to test new or modified code.
Configure the unit_tests job to run your tests and store lcov.info files:
# In CI
unit_tests:
extends: .test
script: bash scripts/unit_test.bash
artifacts:
when: always
expire_in: 1 day
paths:
- "**/lcov.info"Use a separate job to merge and process coverage files. This job also validates the minimum required coverage and outputs a Cobertura report compatible with GitLab:
unit_tests_coverage:
stage: test
allow_failure: false
variables:
MIN_COVERAGE: 83.9
rules:
- !reference [.global_setup, rules]
before_script:
- !reference [.global_setup, before_script]
needs: [unit_tests]
script: |
apt-get update && apt-get install -y lcov
bash scripts/ci/flutter_test_coverage.sh
coverage: '/\s*lines\.*:\s*([0-9.]+%)/'
artifacts:
when: always
expire_in: 1 days
paths: [cobertura-coverage.xml]
reports:
coverage_report:
coverage_format: cobertura
path: cobertura-coverage.xmlcoverage: Extracts and displays the test coverage percentage in the GitLab UI.coverage_report: Publishes a detailed Cobertura report.
Here’s the script that merges lcov.info files, filters out generated code, and enforces the minimum coverage threshold:
#!/usr/bin/env bash
MIN_COVERAGE=${MIN_COVERAGE:-80}
# Find and process all lcov.info files
find coverage modules common -name "lcov.info" -not -path "*/build/*" -not -path ".pub-cache/*" | while read -r file; do
package_dir=$(dirname $(dirname $file))
# Convert paths to be relative to the root of the project
sed "s|SF:lib/|SF:$package_dir/lib/|g" "$file" >>"combined_lcov.info"
done
# Filter out generated files and test files
if [ -f "combined_lcov.info" ]; then
echo "Filtering coverage data..."
lcov --remove "combined_lcov.info" \
'**/*.g.dart' \
'**/*.freezed.dart' \
'**/*.config.dart' \
'**/generated_plugin_registrant.dart' \
'**/generated/*' \
'**/generated/**/*' \
'**/__generated__/*' \
'**/*.gr.dart' \
'**/*.gen.dart' \
'**/test/*' \
'**/build/*' \
'**/models/*' \
'**/pigeons/*' \
--output-file "combined_lcov.info" \
--ignore-errors empty,unused >/dev/null 2>&1
# Generate cobertura report using Dart cobertura package
dart pub global run cobertura convert \
--input combined_lcov.info \
--output cobertura-coverage.xml \
--name "Flutter Motors (Min Coverage: $MIN_COVERAGE%)" \
--version "1.0.0"
lcov --summary "combined_lcov.info"
# Extract the coverage percentage from lcov summary
coverage_percentage=$(lcov --summary "combined_lcov.info" | grep "lines" | awk '{print $2}' | sed 's/%//')
# Check if coverage is below the minimum required
if (( $(echo "$coverage_percentage < $MIN_COVERAGE" | bc -l) )); then
echo "Coverage is $coverage_percentage%, which is below the minimum required coverage of $MIN_COVERAGE%"
exit 1
else
echo "Coverage is $coverage_percentage%, which meets or exceeds the minimum required coverage of $MIN_COVERAGE%"
fi
exit 0
else
echo "No coverage data found to merge"
exit 1
fiTests Sharding
If a test job takes more than 5–8 minutes, it's time to optimize your CI pipeline. One effective solution is test sharding—splitting your test suite into multiple groups that run in parallel. This reduces total test time.
For example, a test suite with 5,000 tests can be split into three shards, each running ~1,667 tests concurrently.
The flutter test tool supports sharding natively for single-module projects. However, it is not suitable for multi-module apps.
Use the following script to divide tests across shards and run them in parallel per package:
# Run Flutter tests in parallel, split across CI shards.
#
# ./flutter_test_runner.sh [SHARD_INDEX] [TOTAL_SHARDS] [SEED] [PKG_PARALLEL]
#
# SHARD_INDEX – 0‑based index of the current shard (default 0)
# TOTAL_SHARDS – how many shards the build is split into (default 1)
# SEED – deterministic shuffle seed (default 123)
# PKG_PARALLEL – packages tested in parallel *per shard* (default $(nproc || sysctl -n hw.ncpu || echo 4))
set -euo pipefail
# ──────────────────────────── arguments ────────────────────────────
SHARD_INDEX=${1:-0}
TOTAL_SHARDS=${2:-1}
SEED=${3:-123}
PKG_PARALLEL=${4:-$( (command -v nproc &>/dev/null && nproc) ||
(command -v sysctl &>/dev/null && sysctl -n hw.ncpu) || echo 4)}
# sanity checks
if [[ $SHARD_INDEX -lt 0 || $SHARD_INDEX -ge $TOTAL_SHARDS ]]; then
echo "❌ SHARD_INDEX must be in the range 0‑$((TOTAL_SHARDS - 1))" >&2
exit 1
fi
if [[ $TOTAL_SHARDS -lt 1 ]]; then
echo "❌ TOTAL_SHARDS must be ≥ 1" >&2
exit 1
fi
echo "▶️ Shard ${SHARD_INDEX}/${TOTAL_SHARDS}, seed $SEED, pkg‑parallel $PKG_PARALLEL"
# ──────────────────────── locate test packages ─────────────────────
echo "🔍 Scanning for Flutter packages that contain tests…"
PACKAGES=()
READ_CMD=(find . -name pubspec.yaml -type f)
[[ "$OSTYPE" != "darwin"* ]] && READ_CMD+=(-printf "%h\n") || READ_CMD+=(-exec dirname {} \;)
while IFS= read -r dir; do
[[ "$dir" == *"pub-cache"* || "$dir" == *".dart_tool"* ]] && continue
[[ -d "$dir/test" ]] && PACKAGES+=("$dir")
done < <("${READ_CMD[@]}")
TOTAL_PACKAGES=${#PACKAGES[@]}
[[ $TOTAL_PACKAGES -eq 0 ]] && {
echo "ℹ️ No packages with tests – nothing to do."
exit 0
}
echo "📦 Found $TOTAL_PACKAGES packages"
# ───────────────────────── shuffle deterministically ───────────────
SHUFFLED=()
for pkg in "${PACKAGES[@]}"; do
HASH=$(echo "$pkg$SEED" | cksum | awk '{print $1}')
SHUFFLED+=("$HASH:$pkg")
done
IFS=$'\n' SHUFFLED=($(sort <<<"${SHUFFLED[*]}"))
unset IFS
for i in "${!SHUFFLED[@]}"; do SHUFFLED[$i]="${SHUFFLED[$i]#*:}"; done
# ───────────────────────── partition into shards ───────────────────
PER_SHARD=$(((TOTAL_PACKAGES + TOTAL_SHARDS - 1) / TOTAL_SHARDS))
START=$((SHARD_INDEX * PER_SHARD))
END=$((START + PER_SHARD - 1))
((END >= TOTAL_PACKAGES)) && END=$((TOTAL_PACKAGES - 1))
TARGET_PACKAGES=("${SHUFFLED[@]:START:END-START+1}")
echo "🧩 This shard runs ${#TARGET_PACKAGES[@]} package(s):"
printf ' • %s\n' "${TARGET_PACKAGES[@]}"
# ────────────────────────── run in parallel ────────────────────────
TMP_STATUS=$(mktemp)
cleanup() { rm -f "$TMP_STATUS"; }
trap cleanup EXIT
run_tests() {
local PKG="$1"
echo "🚀 $(date +%T) $PKG – started"
pushd "$PKG" >/dev/null
if flutter test --no-pub --concurrency 8 -x golden --coverage \
--file-reporter json:test_report.json; then
echo "OK|$PKG" >>"$TMP_STATUS"
echo "✅ $(date +%T) $PKG – passed"
else
echo "FAIL|$PKG" >>"$TMP_STATUS"
echo "❌ $(date +%T) $PKG – failed"
fi
popd >/dev/null
}
export -f run_tests
export TMP_STATUS
printf '%s\n' "${TARGET_PACKAGES[@]}" |
xargs -n1 -P "$PKG_PARALLEL" -I{} bash -c 'run_tests "$@"' _ {}
# ──────────────────────────── summary ──────────────────────────────
FAILED_PACKAGES=()
while IFS= read -r line; do
if [[ "$line" == FAIL\|* ]]; then
FAILED_PACKAGES+=("${line#FAIL|}")
fi
done <"$TMP_STATUS"
if [[ ${#FAILED_PACKAGES[@]} -eq 0 ]]; then
echo -e "\n🎉 All packages passed on shard $SHARD_INDEX"
exit 0
else
echo -e "\n💥 Failures on shard $SHARD_INDEX:"
printf ' • %s\n' "${FAILED_PACKAGES[@]}"
echo "Total failed: ${#FAILED_PACKAGES[@]}"
exit 1
fiUse the script in your GitLab CI job as follows:
unit_tests:
extends: .test
parallel: 3
script: |
bash scripts/ci/flutter_test_sharded.sh "$(( ${CI_NODE_INDEX:-1} - 1 ))" "$CI_NODE_TOTAL" "$CI_PIPELINE_ID"
artifacts:
when: always
expire_in: 1 days
paths: ["**/lcov.info"]This setup runs unit tests in three shards, each handling a subset of packages in parallel and storing coverage results.
Build Stage
Including a build stage in your CI pipeline is optional but strongly recommended. It helps catch issues that linting and testing might miss, such as problems with native dependencies or build configuration errors (for example, issues in Gradle).
You should run the build stage automatically alongside other checks. The build artifacts it produces can later be used in your CD pipeline to publish your app to stores. I'll cover this process in upcoming articles.
Conclusion
CI is a series of automated steps that validate the quality of your Flutter app. Typically, it handles code analysis, formatting, testing, and optionally building.
A fast pipeline is essential. If it takes 30 minutes, an hour, or more, it becomes a bottleneck for your team’s productivity and morale.
To keep your pipeline efficient, apply these practices:
- Use Pub Workspaces for multi-module projects with multiple
pubspec.yamlfiles. - Create a single bootstrap job to set up the project before running other jobs.
- Cache dependencies to avoid redundant downloads.
- Parallelize jobs to avoid a long, sequential pipeline.
- Shard tests – split unit and widget tests across multiple jobs. For example, divide 5,000 tests into two shards of 2,500 each to run them concurrently.