QA How-To
GitHub Actions matrix testing: Step by Step (2026)
Master GitHub Actions matrix testing with runnable YAML, include and exclude rules, sharding, dynamic values, artifacts, cost controls, and interview Q&A.
23 min read | 2,964 words
TL;DR
Define matrix variables under `strategy.matrix`, reference values through the `matrix` context, and control execution with `fail-fast` and `max-parallel`. Keep the dimensions small, isolate data, name artifacts uniquely, and use framework-aware report merging.
Key Takeaways
- A matrix multiplies variables into independent jobs, so count combinations before adding axes.
- Use matrices only for supported variations, known risks, experiments, or deliberate shards.
- Use `exclude` for invalid pairs and `include` for explicit metadata or extra combinations.
- Separate `fail-fast`, `continue-on-error`, `max-parallel`, and workflow concurrency decisions.
- Give every combination isolated data, a readable job name, and uniquely named artifacts.
- Validate dynamic matrix JSON and never execute untrusted matrix values as shell fragments.
- Measure wall-clock feedback and total runner cost as coverage grows.
GitHub Actions matrix testing turns one reviewed job definition into several test jobs with different operating systems, runtimes, browsers, shards, or feature modes. The matrix is valuable when those variations represent a real support contract and each result remains easy to diagnose.
The safest approach is to start with one axis, calculate the generated jobs, control failure and concurrency behavior, and publish uniquely named evidence from every combination. This guide shows static and dynamic matrices, include and exclude, Playwright browser coverage, sharding, artifact handling, and interview-ready design reasoning.
TL;DR
jobs:
test:
strategy:
fail-fast: false
max-parallel: 4
matrix:
os: [ubuntu-latest, windows-latest]
node: [22, 24]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test
This creates four jobs because two operating systems multiplied by two Node versions equals four combinations. Matrix design is multiplication, so count every dimension before committing it.
| Matrix goal | Best mechanism |
|---|---|
| Test every combination | Multiple matrix variables |
| Add one unusual combination | include |
| Remove an invalid combination | exclude |
| Limit simultaneous load | max-parallel |
| Preserve all compatibility evidence | fail-fast: false |
| Allow a preview variation to fail | Job-level continue-on-error |
| Generate values from repository state | Job output plus fromJSON() |
| Divide one large suite | A shard index matrix |
1. GitHub Actions Matrix Testing: The Execution Model
A matrix is defined under jobs.<job_id>.strategy.matrix. GitHub expands the variables into separate job runs. Each job receives a matrix context with the selected values and runs independently on its assigned runner.
If a matrix has three operating systems, two runtime versions, and two browsers, it generates twelve jobs before any include additions. Each job has its own checkout, setup, execution, logs, status, and artifacts. This isolation improves visibility, but repeated setup also costs time.
Matrix testing is different from test-runner parallelism. A Jest, pytest, JUnit, or Playwright process may use multiple workers inside one runner. A matrix creates multiple CI jobs, possibly on different operating systems or machines. Sharding uses multiple jobs to partition one suite, while compatibility testing may repeat the same suite in every variation.
The matrix is expanded when the workflow is planned. Expressions can choose values, and a prior job can produce JSON for a dynamic matrix. The generated workflow still needs guardrails. GitHub enforces a maximum matrix job count per workflow run, but a design far below the platform maximum can still overwhelm a test environment.
Use stable, short keys such as os, node, browser, and shard. Refer to them consistently in job names, setup inputs, commands, environment variables, and artifact names. The matrix should be readable without mentally executing complex expressions.
2. Decide Which Coverage Deserves a Matrix
Every axis should correspond to a supported variation, known regression risk, or intentional experiment. Do not multiply combinations because YAML makes it easy.
A product supporting current Node LTS lines may need a Node matrix for its package tests. A web application may need representative browser projects. A desktop integration may need Windows and macOS. A service that deploys only to one Linux image generally does not gain useful pull request coverage from several unrelated operating systems.
Separate required and scheduled coverage. Pull requests need fast risk-based feedback. A nightly or release workflow can run broader combinations, older supported runtimes, mobile emulation, locales, or database versions. This tiering preserves developer feedback without abandoning compatibility.
Use the following questions:
- Is this variation in the documented support policy?
- Has it produced distinct defects?
- Does the test command actually exercise the variation?
- Can the target environment handle simultaneous jobs?
- Is test data isolated between combinations?
- Will a failure identify the axis and owner?
- Is the setup time proportional to the value?
A matrix cannot repair weak test design. If tests share one customer account, increasing jobs increases collisions. If a browser project is ignored by the test command, the browser label becomes false assurance. Validate one variation first and confirm each matrix value changes the intended runtime.
The test automation strategy guide helps connect coverage layers to release risk before encoding them in CI.
3. Build a Runnable Operating System and Node Matrix
The following workflow runs the same npm tests on two hosted operating-system labels and two Node versions. It gives every combination a readable name and limits concurrency:
name: Compatibility tests
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
test:
name: Test, ${{ matrix.os }}, Node ${{ matrix.node }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 4
matrix:
os: [ubuntu-latest, windows-latest]
node: [22, 24]
steps:
- name: Check out source
uses: actions/checkout@v5
- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
- name: Install locked dependencies
run: npm ci
- name: Run tests
run: npm test
This example assumes both runtime versions belong in the project's support policy. Replace them when the policy changes, and verify action/runtime compatibility through official documentation. Do not copy version arrays into many repositories without an owner.
Using windows-latest and ubuntu-latest follows the current hosted labels, but "latest" can move to a newer image. Teams that require a controlled image can use an explicit supported label and review upgrades separately. The test suite should not depend on shell-specific syntax if it runs across operating systems. Prefer package scripts or cross-platform Node code over inline Bash.
fail-fast: false lets all compatibility jobs finish after one fails, which gives a complete map of impact. max-parallel is a capacity control, not a job-count reducer. All combinations still run, but no more than four execute simultaneously.
Make the job name part of the required-check design. A stable descriptive name helps reviewers see exactly which support target failed.
4. Shape Combinations with Include and Exclude
The Cartesian product is only the starting point. exclude removes combinations that are invalid or intentionally unsupported. include augments matching combinations or creates additional ones.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [22, 24]
exclude:
- os: macos-latest
node: 22
include:
- os: ubuntu-latest
node: 24
coverage: true
- os: ubuntu-latest
node: 25
experimental: true
The macOS and Node 22 pair is removed. The matching Ubuntu and Node 24 combination receives coverage: true. The Node 25 record creates an extra experimental combination because it does not match the base product.
Missing properties need safe handling. A value that exists only on an included row may be absent for other jobs. Use expressions or define a default matrix value when the command needs a Boolean consistently. Avoid elaborate include behavior that only one maintainer understands. An explicit object list is sometimes clearer than a product plus many exceptions.
Explain every exclusion in a nearby comment or support-policy document. Excluding a known broken combination can silently normalize a product defect. If the pair is unsupported, say so. If it is temporarily broken, track a defect and expiry.
Use include for metadata as well as combinations. A row can carry a browser channel, expected-failure flag, test command, or runner label. However, do not pass arbitrary command strings from untrusted dynamic data into a shell. Keep commands reviewed and choose between them with controlled conditions.
Review the expanded job list in a pull request after changing the matrix. The effective combinations, not the YAML line count, determine coverage and cost.
5. Control Fail-Fast, Continue-on-Error, and Parallelism
These settings answer different questions:
| Setting | Scope | Effect |
|---|---|---|
strategy.fail-fast |
Matrix strategy | Can a non-tolerated failure cancel other matrix jobs? |
continue-on-error |
Job | Is this job's failure tolerated for workflow outcome? |
max-parallel |
Matrix strategy | How many generated jobs can run simultaneously? |
timeout-minutes |
Job | How long may one combination run? |
| Workflow concurrency | Workflow run | Should an older run be cancelled by a newer one? |
For compatibility evidence, fail-fast: false is often appropriate because an early Linux failure should not hide whether Windows also fails. For an expensive destructive suite where any failure invalidates all remaining work, cancellation may be reasonable.
An experimental row can be nonblocking:
continue-on-error: ${{ matrix.experimental == true }}
strategy:
fail-fast: false
matrix:
experimental: [false]
node: [22, 24]
include:
- node: 25
experimental: true
Make tolerated failures visibly experimental. Do not turn continue-on-error on for the whole job merely to keep a red check away from branch protection. That converts a quality signal into decoration.
max-parallel protects scarce runners, rate-limited dependencies, device pools, and shared environments. Choose it from measured capacity. If four jobs each launch several test workers, actual concurrency is matrix jobs multiplied by workers. Include application replicas, database limits, and test data when estimating safe load.
Workflow-level concurrency can cancel obsolete runs from the same branch. Use a group based on workflow and reference, not one global group that makes unrelated branches cancel each other.
6. GitHub Actions Matrix Testing for Playwright Browsers
Playwright projects can run multiple browsers inside one command, or a matrix can assign one browser to each job. A browser matrix provides isolated status and artifacts, while a single job has less repeated setup. Choose based on suite size and diagnosis needs.
This workflow runs each browser on Ubuntu and installs only the selected browser:
name: Browser matrix
on:
pull_request:
permissions:
contents: read
jobs:
browser:
name: Playwright, ${{ matrix.browser }}
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: lts/*
cache: npm
- run: npm ci
- name: Install selected browser
run: npx playwright install --with-deps ${{ matrix.browser }}
- name: Run selected project
run: npx playwright test --project=${{ matrix.browser }}
env:
CI: "true"
- name: Upload report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v5
with:
name: playwright-report-${{ matrix.browser }}
path: |
playwright-report/
test-results/
retention-days: 14
The playwright.config.ts file must define projects named chromium, firefox, and webkit. The browser argument comes from a fixed reviewed matrix, not user-controlled text.
If all browser jobs hit one staging environment, isolate accounts and mutable records. Three browsers do not merely increase compute, they triple concurrent application activity. Use the GitHub Actions for Playwright guide for complete setup, reports, and secrets.
A browser matrix is compatibility coverage, not sharding. Each job usually runs the whole selected project. If that is too slow, add a controlled shard axis only after calculating the resulting job count.
7. Use a Shard Matrix for One Large Suite
Sharding partitions tests across jobs rather than repeating the suite. For Playwright, run --shard=1/4, --shard=2/4, and so on. A matrix can carry both index and total:
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
steps:
- run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
Keep shardTotal in the matrix so the command is self-describing and artifact names can include it. For general frameworks, use their supported shard, partition, or test-group mechanism. Do not split files with a brittle shell expression that changes ordering unexpectedly.
With Playwright, configure the blob reporter in shard jobs and merge reports later. Each artifact needs a unique name such as blob-report-1. A merge job uses needs to wait for the matrix, downloads the blobs, and runs npx playwright merge-reports.
Test distribution may be uneven when a few files dominate duration. Playwright can balance at test level when tests are fully parallel, while default file-level grouping can leave a long tail. Measure shard durations and refactor oversized, stateful files only when it improves test independence.
Sharding multiplies fixture setup and may duplicate global initialization. Ensure any global setup is idempotent or designed for parallel runs. A database reset from one shard must not delete another shard's data.
The correct shard count changes as the suite grows. Review startup overhead, queue time, target capacity, duration spread, and cost. More shards eventually stop helping.
8. Build a Dynamic Matrix from Trusted Data
A dynamic matrix is useful when values come from a repository manifest, changed package list, or a prior discovery step. The producer job writes compact JSON to $GITHUB_OUTPUT, and the consumer parses it with fromJSON().
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
browsers: ${{ steps.matrix.outputs.browsers }}
steps:
- id: matrix
name: Define browser list
shell: bash
run: echo 'browsers=["chromium","firefox"]' >> "$GITHUB_OUTPUT"
test:
needs: prepare
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
browser: ${{ fromJSON(needs.prepare.outputs.browsers) }}
steps:
- run: echo "Testing ${{ matrix.browser }}"
In a real repository, the producer can read a committed configuration file using a reviewed script. Validate the output schema, allowed values, empty-list behavior, and maximum count. Treat branch-controlled output as untrusted input when the consumer has secrets, privileged permissions, or self-hosted runner access.
Dynamic matrices can hide coverage during code review because reviewers cannot see the final list in YAML. Print a sanitized summary in the prepare job and document the source. Fail clearly when discovery returns no work unless an empty run is intentional.
Do not encode shell fragments as matrix values and execute them. Prefer typed metadata such as package name, test group, runtime, and runner label, then call a fixed repository command with safe arguments. Validate identifiers before using them in paths or artifact names.
A dynamic matrix is justified when it prevents stale duplication or narrows work correctly. For a stable three-browser list, static YAML is simpler and safer.
9. Preserve Artifacts and Aggregate Results Correctly
Every matrix job must use a unique artifact name. Uploading test-results from ten jobs under an ambiguous name creates conflicts or makes diagnosis difficult. Include stable matrix values and the run attempt:
with:
name: results-${{ matrix.os }}-node-${{ matrix.node }}-attempt-${{ github.run_attempt }}
path: test-results/
Paths inside an artifact can still collide when a later merge downloads several artifacts into one directory. Use separate directories or an artifact download option that merges only formats designed for merging. Never concatenate JUnit XML files as raw text. Use a reporter or result tool that understands the schema.
Aggregation is different from gating. Every required matrix job can report its own status, while a final summary job creates one report. If branch protection expects a single stable check, a final gate job can inspect needs results and fail when a required variation failed. Design this explicitly and test cancelled, skipped, and tolerated cases.
Upload evidence with if: !cancelled() or always() according to artifact behavior. always() also runs after cancellation and can delay termination. !cancelled() usually preserves failed-test evidence without doing work after a deliberate stop.
Retention can vary by suite. Compatibility logs may need a short triage window, while release evidence may go to a controlled archive. Artifacts can contain tokens, user data, screenshots, or proprietary output, so minimize paths and access.
The CI test reporting guide provides patterns for JUnit, HTML, and trend systems beyond one workflow run.
10. Govern Cost, Reliability, and Matrix Growth
Track generated job count, total runner time, queue time, wall-clock feedback, cancellation rate, test duration, retry rate, and artifact storage. A matrix may shorten elapsed time while increasing total compute. That can be a good trade when pull request feedback is valuable, but it should be intentional.
Assign ownership to each axis. Runtime support should follow the product's support policy. Browser coverage should follow customer and risk data. Experimental combinations need an expiry or promotion decision. Exclusions need a reason. Scheduled coverage needs active review, not silent accumulation.
Use reusable workflows when many repositories share the same supported matrix, security policy, and reporting contract. Keep repository-specific commands as inputs. Pin and review reusable workflow references according to organization policy, and avoid granting a called workflow more permissions than its caller needs.
Watch for runner image drift under -latest labels. Keep dependencies locked, record runtime versions in logs, and investigate sudden multi-repository failures as possible infrastructure changes. A controlled runner image can reduce drift but increases maintenance ownership.
Test the matrix design itself. Temporarily fail one known variation in a safe branch to confirm artifact collection, fail-fast behavior, final gates, and notifications. Verify that a skipped optional row does not produce a false green summary. Confirm obsolete runs cancel only within the intended branch.
A healthy matrix communicates coverage at a glance. If reviewers need a spreadsheet to decode it, simplify the design.
Interview Questions and Answers
Q: What does a GitHub Actions matrix do?
It expands one job definition into multiple jobs using combinations of declared values. Each job gets a matrix context and an independent runner, status, log, and artifact set. I use it for meaningful compatibility variations or for shard indexes, not as a substitute for test design.
Q: What is the difference between include and exclude?
exclude removes a combination from the base Cartesian product. include can add metadata to matching combinations or create additional combinations that do not match the base product. I document exceptions because they otherwise hide support decisions.
Q: How are fail-fast and continue-on-error different?
fail-fast controls whether a matrix failure can cancel sibling jobs. continue-on-error says whether one job's failure is tolerated in the workflow outcome. For compatibility testing, I often disable fail-fast and reserve continue-on-error for clearly labeled experiments.
Q: How do you prevent a matrix from overloading a test environment?
I calculate jobs multiplied by in-run workers, then set max-parallel from environment and runner capacity. I isolate data and monitor dependency limits. Broader coverage can move to a schedule instead of running simultaneously on every pull request.
Q: When would you use a dynamic matrix?
I use one when a trusted discovery step determines packages, services, or configurations from repository state. The producer emits validated JSON and the consumer uses fromJSON(). I avoid it for small stable lists because static YAML is easier to review.
Q: Is a browser matrix the same as sharding?
No. A browser matrix normally repeats coverage across browser implementations. Sharding divides one suite into portions to reduce duration. Combining them multiplies jobs, so I calculate the total and confirm the value of every combination.
Q: How do you aggregate matrix reports?
Every job uploads a uniquely named machine-readable artifact. A dependent job downloads them and uses the test framework's supported merge tool, such as Playwright blob report merging. I keep aggregation separate from the required status logic.
Common Mistakes
- Adding dimensions without calculating the Cartesian product.
- Confusing CI matrix jobs with workers inside a test runner.
- Testing unsupported variations while missing actual customer configurations.
- Excluding a broken combination without a defect, owner, or explanation.
- Using
continue-on-errorto hide required failures. - Leaving
fail-faston when complete compatibility evidence is needed. - Allowing all jobs to run simultaneously against a small shared environment.
- Using the same account and mutable data in every combination.
- Giving artifacts the same name across matrix jobs.
- Concatenating JUnit XML rather than using a schema-aware merger.
- Passing unvalidated dynamic matrix values into a shell command.
- Making a simple static list dynamic and obscuring review.
- Combining browsers, operating systems, runtimes, shards, and roles on every pull request.
- Forgetting that
-latestrunner labels can move to newer images. - Measuring wall-clock speed while ignoring total runner time and queue cost.
Conclusion
GitHub Actions matrix testing is most effective when it expresses a real compatibility contract or a deliberate shard plan. Define small, meaningful axes, count the expanded jobs, label each result, preserve all required failures, and control parallel load.
Start with the two-axis runnable example, then use include, exclude, dynamic output, or sharding only when each solves a specific problem. Unique artifacts, isolated data, stable final gates, and regular coverage review keep the matrix useful instead of letting it become an expensive YAML multiplication table.
Interview Questions and Answers
How would you design a QA test matrix?
I begin with the product support policy and defect history, then choose the smallest meaningful axes. I calculate combinations, separate pull request and scheduled coverage, isolate data, and set parallel limits from environment capacity. Every job gets a clear name and artifact contract.
Explain the matrix `include` behavior.
`include` augments compatible base combinations with properties or creates an additional combination when it cannot merge without overwriting a base value. I use it for metadata and a few explicit special rows. If exceptions dominate, I replace the product with a clear object list.
How do you preserve evidence after one matrix job fails?
I set `fail-fast: false` when complete coverage is needed and upload artifacts with a failure-tolerant condition. Every artifact name includes the matrix identity. A dependent aggregation job merges only supported report formats.
What risks come with a dynamic matrix?
Coverage is harder to review, output can be empty or oversized, and untrusted values can reach paths, runner selection, or commands. I validate a typed allowlist, print a sanitized summary, cap the count, and keep shell commands fixed.
How do you estimate matrix concurrency?
I multiply active matrix jobs by workers or threads inside each job, then consider target services and test data. I use `max-parallel` to cap simultaneous jobs and can reduce in-run workers. I verify with environment telemetry rather than runner count alone.
When would you avoid a matrix?
I avoid one when variations are unsupported, setup overhead exceeds value, data cannot be isolated, or a simple test-runner project already reports variations clearly. I also avoid a dynamic matrix for a tiny stable list. Readability and reliable signal come first.
How do you create one required status from many matrix jobs?
I use a stable dependent gate job that evaluates required `needs` outcomes and distinguishes failures, cancellations, skips, and tolerated experiments. Branch protection requires that gate. I test its behavior with controlled failures before relying on it.
Frequently Asked Questions
How does a GitHub Actions matrix generate jobs?
GitHub forms combinations from the variables under `strategy.matrix` and creates an independent job for each one. Two operating systems and two runtime versions produce four jobs before include or exclude adjustments.
What is `max-parallel` in a GitHub Actions matrix?
`max-parallel` limits how many generated matrix jobs may execute at the same time. It does not remove combinations. Use it to protect runner capacity, shared test environments, rate limits, and mutable test data.
When should `fail-fast` be false?
Set it to false when you need results and artifacts from every compatibility combination even after one fails. Keep cancellation when later jobs have no diagnostic value or continuing would create unnecessary risk.
Can GitHub Actions create a matrix dynamically?
Yes. A prior job can write JSON to `$GITHUB_OUTPUT`, expose it as a job output, and a dependent job can parse it with `fromJSON()`. Validate allowed values, size, empty behavior, and trust before using the result.
What is the difference between matrix testing and sharding?
Matrix compatibility testing repeats tests across variations such as browsers or runtimes. Sharding divides one suite into portions. A shard index can be implemented as a matrix, but its purpose is distribution rather than repeated compatibility.
How should matrix artifacts be named?
Include stable matrix values and optionally the run attempt, such as `results-ubuntu-node-24-attempt-1`. Unique names prevent conflicts and make a failure easy to connect to its evidence.
Can I use `continue-on-error` for an experimental matrix row?
Yes. Add an explicit experimental Boolean to that row and bind job-level `continue-on-error` to it. Keep required rows blocking and make tolerated failures visible so experiments do not become permanent ignored checks.