Learnixo
Back to blog
AI Systemsintermediate

GitHub Actions Matrix — Parallel Builds and Multi-Environment Testing

Use GitHub Actions matrix strategy to run jobs across multiple .NET versions, operating systems, and test configurations in parallel — reducing total CI time for multi-target libraries and clinical platform modules.

Asma Hafeez KhanMay 16, 20265 min read
GitHub ActionsCI/CDMatrix.NETParallel Testing
Share:𝕏

When to Use Matrix Builds

Matrix builds run the same job configuration with different parameters in parallel.

Use matrix for:
  → Testing a NuGet library against multiple .NET versions (6, 8, 9)
  → Testing across multiple OSes (Linux, Windows, macOS — for cross-platform libs)
  → Running tests for each module independently in parallel
  → Multi-region deployment (deploy to UK South and UK West simultaneously)
  → Smoke testing against multiple browser configurations

When NOT to use matrix:
  → Sequential dependencies (deploy to staging before production)
  → Simple single-target applications (.NET 8, Linux only — matrix adds overhead for no benefit)
  → When parallel jobs would compete for the same resource (one DB per job is better)

Basic Matrix — Multiple .NET Versions

YAML
# Test a shared library or NuGet package against multiple .NET versions
name: Library CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        dotnet-version: ['6.0.x', '8.0.x', '9.0.x']
      fail-fast: false  # run all matrix jobs even if one fails

    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET ${{ matrix.dotnet-version }}
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ matrix.dotnet-version }}

      - name: Restore
        run: dotnet restore SharedKernel/SharedKernel.csproj

      - name: Test
        run: |
          dotnet test SharedKernel.Tests/SharedKernel.Tests.csproj \
            --framework net${{ matrix.dotnet-version | split('.') | first }}.0 \
            --logger "trx;LogFileName=results-${{ matrix.dotnet-version }}.trx"

Matrix Across Multiple Dimensions

YAML
# Test across OS × .NET version combinations
jobs:
  test-cross-platform:
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]
        dotnet-version: ['8.0.x', '9.0.x']
        exclude:
          # Don't test .NET 6 on Windows — not required
          - os: windows-latest
            dotnet-version: '6.0.x'
        include:
          # Extra combination: macOS with .NET 8 only
          - os: macos-latest
            dotnet-version: '8.0.x'

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

    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ matrix.dotnet-version }}

      - name: Test
        run: dotnet test Clinical.sln --configuration Release

Parallel Module Tests

YAML
# Run each module's tests in parallel — fast feedback for large codebases

jobs:
  test-modules:
    strategy:
      matrix:
        module:
          - name: Patients
            path: Modules/Patients/Patients.Tests
          - name: Prescriptions
            path: Modules/Prescriptions/Prescriptions.Tests
          - name: LabResults
            path: Modules/LabResults/LabResults.Tests
          - name: Billing
            path: Modules/Billing/Billing.Tests
      fail-fast: false  # don't stop all tests if one module fails

    runs-on: ubuntu-latest
    name: "Tests: ${{ matrix.module.name }}"

    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.x'

      - name: Restore
        run: dotnet restore ${{ matrix.module.path }}/${{ matrix.module.name }}.Tests.csproj

      - name: Test ${{ matrix.module.name }}
        run: |
          dotnet test ${{ matrix.module.path }}/${{ matrix.module.name }}.Tests.csproj \
            --configuration Release \
            --logger "trx;LogFileName=${{ matrix.module.name }}-results.trx" \
            --collect:"XPlat Code Coverage"

      - name: Upload results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results-${{ matrix.module.name }}
          path: "**/${{ matrix.module.name }}-results.trx"

Matrix with Database Per Job

YAML
# Integration tests: each matrix job gets its own SQL Server
# Prevents tests from interfering with each other across modules

jobs:
  integration-tests:
    strategy:
      matrix:
        module: [Patients, Prescriptions, LabResults]

    runs-on: ubuntu-latest

    services:
      sqlserver:
        image: mcr.microsoft.com/mssql/server:2022-latest
        env:
          ACCEPT_EULA: Y
          SA_PASSWORD: YourStrong!Passw0rd
        ports:
          - 1433:1433
        options: >-
          --health-cmd "/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P YourStrong!Passw0rd -Q 'SELECT 1'"
          --health-interval 10s
          --health-retries 10

    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.x'

      - name: Run ${{ matrix.module }} integration tests
        run: |
          dotnet test Modules/${{ matrix.module }}/${{ matrix.module }}.IntegrationTests \
            --configuration Release \
            --filter "Category=Integration"
        env:
          ConnectionStrings__Clinical: "Server=localhost,1433;Database=${{ matrix.module }}Test;..."

# Each matrix job has its own SQL Server service container
# Tests for Patients, Prescriptions, and LabResults run in parallel
# Total time: max(individual module test time), not sum of all modules

Merging Coverage Reports from Matrix Jobs

YAML
# Collect coverage from all parallel jobs and merge into one report

  merge-coverage:
    runs-on: ubuntu-latest
    needs: test-modules  # wait for all matrix jobs

    steps:
      - uses: actions/checkout@v4

      - name: Download all coverage artifacts
        uses: actions/download-artifact@v4
        with:
          pattern: coverage-*
          merge-multiple: true
          path: ./coverage

      - name: Install ReportGenerator
        run: dotnet tool install --global dotnet-reportgenerator-globaltool

      - name: Merge and generate report
        run: |
          reportgenerator \
            -reports:"./coverage/**/coverage.cobertura.xml" \
            -targetdir:"./coverage/merged" \
            -reporttypes:"Cobertura;HtmlSummary"

      - name: Upload merged coverage
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage/merged/Cobertura.xml

Production issue I've seen: A team's CI pipeline ran all tests sequentially — unit tests for 5 modules, then integration tests for each module, one after another. Total CI time: 22 minutes. Every PR waited 22 minutes for feedback. Developers would start a PR, forget about it, and come back later. When they returned, the context switch cost made it harder to act on feedback. Converting the tests to a matrix build (5 modules in parallel, each with their own database) reduced total CI time to 6 minutes. Developer feedback loops improved measurably — PRs were reviewed and merged the same day instead of the next day.


Key Takeaway

Matrix builds run the same job with different parameters in parallel — useful for multi-.NET-version libraries, cross-platform testing, and per-module parallel test execution. Use fail-fast: false to see all failures rather than stopping at the first. Each matrix job gets its own service containers (database, Redis) — tests run in complete isolation. Merge coverage reports from parallel jobs with ReportGenerator before uploading to Codecov. Don't use matrix for single-version, single-platform applications — the overhead outweighs the benefit.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.