Running Flutter tests in GitHub Actions

By Chukwuemeka Eze · 27 October 20242,002 views
Running Flutter tests in GitHub Actions

Running Flutter tests in CI catches regressions before they reach users. A well-configured GitHub Actions workflow runs on every pull request, blocks merges when tests fail, and gives the team confidence to ship fast.

Basic workflow

# .github/workflows/test.yml
name: Tests

on:
  pull_request:
    branches: [main, develop]
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.22.0'
          channel: 'stable'
          cache: true  # Cache Flutter SDK across runs

      - name: Install dependencies
        run: flutter pub get

      - name: Analyze
        run: flutter analyze --no-fatal-infos

      - name: Test
        run: flutter test --coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          file: coverage/lcov.info

Caching for faster runs

Flutter downloads packages every run without a cache. Cache ~/.pub-cache:

- name: Cache pub dependencies
  uses: actions/cache@v4
  with:
    path: ~/.pub-cache
    key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
    restore-keys: |
      ${{ runner.os }}-pub-

With caching, flutter pub get goes from 60–90 seconds to 3–5 seconds on cache hit.

Running specific test types

- name: Unit tests
  run: flutter test test/unit/ --coverage

- name: Widget tests
  run: flutter test test/widget/

# Integration tests need a device or emulator
- name: Start emulator
  uses: reactivecircus/android-emulator-runner@v2
  with:
    api-level: 34
    script: flutter test integration_test/ -d emulator-5554

Multi-platform matrix

Run tests across Flutter channels to catch breaking changes early:

strategy:
  matrix:
    flutter-version: ['3.19.0', '3.22.0']
    os: [ubuntu-latest, macos-latest]
  fail-fast: false  # Continue other matrix runs even if one fails

runs-on: ${{ matrix.os }}

steps:
  - uses: subosito/flutter-action@v2
    with:
      flutter-version: ${{ matrix.flutter-version }}

Test result reporting

Output test results as JUnit XML for GitHub's test summary:

- name: Test
  run: |
    flutter test --reporter json | tee test-results.json
    dart run junitreport:tojunit \
      --input test-results.json \
      --output test-results.xml

- name: Publish test results
  uses: EnricoMi/publish-unit-test-result-action@v2
  if: always()  # Run even if tests fail
  with:
    files: test-results.xml

Conditional steps

Only run expensive steps when tests pass:

jobs:
  test:
    runs-on: ubuntu-latest
    outputs:
      tests-passed: ${{ steps.test.outcome == 'success' }}
    steps:
      - id: test
        run: flutter test

  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - run: flutter build apk --release

Code coverage enforcement

Fail the build if coverage drops below a threshold:

- name: Check coverage
  run: |
    COVERAGE=$(lcov --summary coverage/lcov.info 2>&1 | grep 'lines' | awk '{print $2}' | tr -d '%')
    echo "Coverage: $COVERAGE%"
    if (( $(echo "$COVERAGE < 80" | bc -l) )); then
      echo "Coverage $COVERAGE% is below 80% threshold"
      exit 1
    fi

Monorepo setup

For projects with multiple packages:

strategy:
  matrix:
    package:
      - packages/core
      - packages/ui
      - packages/api
      - app

steps:
  - name: Test ${{ matrix.package }}
    working-directory: ${{ matrix.package }}
    run: flutter test --coverage

Secrets for integration tests

Some integration tests need real credentials (Firebase, API keys). Store as GitHub secrets and expose to the workflow:

- name: Create .env
  run: |
    echo "FIREBASE_API_KEY=${{ secrets.FIREBASE_API_KEY_TEST }}" >> .env
    echo "API_BASE_URL=https://test.api.example.com" >> .env

- name: Integration tests
  run: flutter test integration_test/
  env:
    FLUTTER_TEST_FIREBASE_PROJECT: ${{ secrets.FIREBASE_PROJECT_TEST }}

Parallelizing slow test suites

Split tests across multiple runners:

strategy:
  matrix:
    shard: [1, 2, 3]

- name: Test (shard ${{ matrix.shard }}/3)
  run: |
    flutter test \
      --shard-index ${{ matrix.shard }} \
      --total-shards 3

Common pitfalls

Not pinning the Flutter version. channel: stable installs whatever is current stable — which changes. Pin to a specific version (flutter-version: '3.22.0') and update deliberately.

Tests that pass locally but fail in CI. Common causes: timezone differences (use DateTime.utc()), path separators (path.join() not '/'), or file system assumptions. Run tests with TZ=UTC to catch timezone issues locally.

Slow test suites blocking PRs. Widget tests with real images or network requests are slow. Mock all I/O in tests; use flutter_test's pumpAndSettle timeout parameter to catch infinite animations.

No --no-fatal-infos on analyze. Info-level lint warnings become errors in CI without this flag, breaking builds over style issues that don't affect correctness.

Comments

No comments yet. Be the first!

Sign in to leave a comment.