Effective CI for Dart & Flutter

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

  1. Introduction
  2. Install Flutter on Runner
  3. Prepare Stage
    1. Caching Dependencies
  4. Lint Stage
    1. Handle Deprecations
    2. Code Quality Reports
  5. Test Stage
    1. Collect Coverage Reports
    1. Tests Sharding
  6. Build Stage

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-image

Choose 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.yaml file that list build_runner as 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.

💡
This job caches all generated files and 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_status

Integrate this script into the analyze job:

analyze:
  extends: .lint
  script:
    - bash scripts/analyze_and_report.sh
  artifacts:
    reports:
      codequality: analyzer_report.json

Test 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 get

Create 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."
fi

The 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.bash

Collect 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.xml
  • coverage: 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
fi

Tests 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
fi

Use 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.yaml files.
  • 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.