Matrix testing is one of the most powerful techniques for ensuring your application works across multiple environments, configurations, and platforms. In modern CI/CD pipelines, matrix testing allows you to run the same test suite across different combinations of variables automatically. This comprehensive tutorial will guide you through implementing matrix testing strategies that scale with your development workflow.
What is Matrix Testing?
Matrix testing, also known as combinatorial testing, is a technique where you run tests across multiple dimensions of configuration variables simultaneously. Instead of testing each configuration separately, you create a matrix of all possible combinations and execute tests for each permutation.
For example, if you need to test your application on:
- 3 operating systems (Linux, macOS, Windows)
- 3 programming language versions (Node.js 16, 18, 20)
- 2 database versions (PostgreSQL 13, 14)
Traditional testing would require 18 separate manual configurations. Matrix testing automates this entire process, running all combinations in parallel.
Prerequisites
Before implementing matrix testing, ensure you have:
Required Tools:
- CI/CD platform (GitHub Actions, GitLab CI, Jenkins, or CircleCI)
- Version control system (Git)
- Docker (optional but recommended for reproducible environments)
- Basic knowledge of YAML for CI configuration
Technical Knowledge:
- Understanding of CI/CD pipeline concepts
- Familiarity with your chosen CI platform
- Basic scripting skills (Bash, Python, or JavaScript)
Environment Setup:
- Access to your repository with CI/CD enabled
- Appropriate permissions to modify pipeline configurations
- Test suite already in place
Step 1: Define Your Test Matrix Dimensions
Start by identifying which variables need testing across multiple values. Common dimensions include:
Infrastructure Variables:
- Operating systems (ubuntu-latest, macos-latest, windows-latest)
- Architecture (x86, ARM64)
- Cloud providers (AWS, Azure, GCP)
Application Variables:
- Programming language versions
- Framework versions
- Dependency versions
- Database systems and versions
Configuration Variables:
- Environment types (development, staging, production)
- Feature flags
- Integration endpoints
Create a document listing your critical dimensions:
# matrix-config.yaml
dimensions:
os: [ubuntu-22.04, ubuntu-20.04, macos-13, windows-2022]
node_version: [16, 18, 20]
database: [postgres:13, postgres:14, postgres:15]
Verification Checkpoint: Review your dimensions with your team. Prioritize combinations that represent actual user environments. Avoid testing combinations that don’t exist in production.
Step 2: Implement Matrix Testing in GitHub Actions
GitHub Actions provides native matrix strategy support. Here’s how to implement it:
# .github/workflows/matrix-tests.yml
name: Matrix Testing
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
name: Test on ${{ matrix.os }} with Node ${{ matrix.node }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [16, 18, 20]
include:
# Add specific combinations with extra config
- os: ubuntu-latest
node: 20
experimental: true
exclude:
# Skip problematic combinations
- os: macos-latest
node: 16
fail-fast: false # Continue testing other combinations even if one fails
max-parallel: 5 # Limit concurrent jobs
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
flags: ${{ matrix.os }}-node-${{ matrix.node }}
Expected Output: This configuration creates 8 jobs (3 OS × 3 Node versions - 1 excluded combination). Each job runs independently, and you’ll see results for each combination in the GitHub Actions UI.
Step 3: Implement Advanced Matrix with Database Services
Real-world applications often require testing with different databases. Here’s how to add service containers to your matrix:
name: Matrix with Database Testing
jobs:
integration-test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [18, 20]
database:
- type: postgres
version: '13'
port: 5432
- type: postgres
version: '14'
port: 5432
- type: mysql
version: '8.0'
port: 3306
services:
database:
image: ${{ matrix.database.type }}:${{ matrix.database.version }}
env:
POSTGRES_PASSWORD: testpass
MYSQL_ROOT_PASSWORD: testpass
ports:
- ${{ matrix.database.port }}:${{ matrix.database.port }}
options: >-
--health-cmd="pg_isready"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- name: Configure database connection
run: |
echo "DATABASE_TYPE=${{ matrix.database.type }}" >> $GITHUB_ENV
echo "DATABASE_PORT=${{ matrix.database.port }}" >> $GITHUB_ENV
- name: Run integration tests
run: npm run test:integration
env:
DATABASE_URL: ${{ matrix.database.type }}://user:testpass@localhost:${{ matrix.database.port }}/testdb
Verification Checkpoint: Run this workflow and verify that each database service starts correctly. Check the logs to ensure database connections are established before tests run.
Step 4: Optimize Matrix Execution with Dynamic Matrices
For large matrices, you can generate combinations dynamically to reduce redundancy:
jobs:
prepare-matrix:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- name: Generate dynamic matrix
id: set-matrix
run: |
# Generate matrix based on changed files or conditions
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
# Limited matrix for PRs
echo 'matrix={"os":["ubuntu-latest"],"node":[20]}' >> $GITHUB_OUTPUT
else
# Full matrix for main branch
echo 'matrix={"os":["ubuntu-latest","macos-latest","windows-latest"],"node":[16,18,20]}' >> $GITHUB_OUTPUT
fi
test:
needs: prepare-matrix
runs-on: ${{ matrix.os }}
strategy:
matrix: ${{ fromJson(needs.prepare-matrix.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- name: Run tests
run: echo "Testing on ${{ matrix.os }} with Node ${{ matrix.node }}"
Expected Output: Pull requests will run only 1 job, while pushes to main will run the full matrix of 9 jobs. This significantly reduces CI costs and time for routine development.
Step 5: Implement Matrix Testing in GitLab CI
GitLab CI uses parallel jobs for matrix testing:
# .gitlab-ci.yml
.test_template:
stage: test
script:
- npm ci
- npm test
test:
extends: .test_template
image: node:${NODE_VERSION}
parallel:
matrix:
- NODE_VERSION: ['16', '18', '20']
OS: ['ubuntu', 'alpine']
DATABASE:
- TYPE: 'postgres'
VERSION: '13'
- TYPE: 'postgres'
VERSION: '14'
services:
- name: ${DATABASE_TYPE}:${DATABASE_VERSION}
alias: database
variables:
DATABASE_URL: "${DATABASE_TYPE}://user:pass@database:5432/testdb"
before_script:
- echo "Testing Node ${NODE_VERSION} on ${OS} with ${DATABASE_TYPE} ${DATABASE_VERSION}"
Verification Checkpoint: GitLab will create 12 jobs (3 Node versions × 2 OS × 2 database versions). Check the pipeline graph to visualize all combinations.
Step 6: Add Matrix Testing for Jenkins
Jenkins requires a more programmatic approach using Groovy:
// Jenkinsfile
pipeline {
agent none
stages {
stage('Matrix Build') {
matrix {
axes {
axis {
name 'OS'
values 'linux', 'windows', 'mac'
}
axis {
name 'NODE_VERSION'
values '16', '18', '20'
}
axis {
name 'DATABASE'
values 'postgres:13', 'postgres:14', 'mysql:8.0'
}
}
excludes {
exclude {
axis {
name 'OS'
values 'mac'
}
axis {
name 'NODE_VERSION'
values '16'
}
}
}
stages {
stage('Test') {
agent {
docker {
image "node:${NODE_VERSION}"
label "${OS}"
}
}
steps {
sh 'npm ci'
sh 'npm test'
}
}
}
}
}
}
post {
always {
publishHTML([
reportDir: 'coverage',
reportFiles: 'index.html',
reportName: "Coverage Report - ${OS}-${NODE_VERSION}-${DATABASE}"
])
}
}
}
Expected Output: Jenkins creates a visual matrix with all combinations, allowing you to drill down into specific test results for each configuration.
Step 7: Implement Smart Matrix Testing with Caching
Optimize matrix execution time using effective caching strategies:
name: Optimized Matrix Testing
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
node: [18, 20]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- name: Cache node modules
uses: actions/cache@v3
with:
path: |
~/.npm
node_modules
key: ${{ runner.os }}-node-${{ matrix.node }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-${{ matrix.node }}-
${{ runner.os }}-node-
- name: Install dependencies
run: npm ci
- name: Cache build artifacts
uses: actions/cache@v3
with:
path: dist
key: ${{ runner.os }}-build-${{ matrix.node }}-${{ github.sha }}
- name: Run tests
run: npm test
Verification Checkpoint: Monitor your workflow execution time. Properly configured caching should reduce execution time by 30-50% on subsequent runs.
Best Practices for Matrix Testing
1. Prioritize Critical Combinations
Not all combinations are equally important. Use data-driven prioritization:
strategy:
matrix:
include:
# Production-critical configurations (highest priority)
- os: ubuntu-latest
node: 20
priority: critical
# Common user configurations
- os: ubuntu-latest
node: 18
priority: high
# Edge cases and future compatibility
- os: macos-latest
node: 20
priority: medium
2. Use Fail-Fast Strategically
Control how matrix failures affect your pipeline:
strategy:
fail-fast: false # Test all combinations even if some fail
matrix:
# ... matrix configuration
Set fail-fast: true when:
- Running on limited CI resources
- Testing critical features where one failure indicates systemic issues
- Debugging specific combinations
Set fail-fast: false when:
- Investigating platform-specific issues
- Generating comprehensive compatibility reports
- Some combinations are known to be flaky
3. Implement Matrix Artifact Collection
Collect and organize test results from all matrix combinations:
steps:
- name: Run tests
run: npm test -- --coverage
- name: Upload test results
uses: actions/upload-artifact@v3
if: always()
with:
name: test-results-${{ matrix.os }}-node-${{ matrix.node }}
path: |
coverage/
test-results/
retention-days: 30
- name: Generate matrix report
if: always()
run: |
echo "## Test Results: ${{ matrix.os }} - Node ${{ matrix.node }}" >> $GITHUB_STEP_SUMMARY
echo "Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
echo "$(cat test-results/summary.txt)" >> $GITHUB_STEP_SUMMARY
4. Monitor Matrix Performance
Track matrix execution metrics to optimize costs and time:
- name: Report matrix metrics
run: |
echo "Matrix combination: ${{ matrix.os }}-${{ matrix.node }}"
echo "Start time: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
npm test
echo "End time: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "Status: ${{ job.status }}"
Use these metrics to identify:
- Slowest combinations that need optimization
- Frequently failing configurations
- Redundant or unnecessary matrix dimensions
Common Pitfalls and Solutions
Pitfall 1: Matrix Explosion
Problem: Too many dimensions create hundreds of combinations, overwhelming CI resources.
Solution: Use strategic exclusions and conditional matrices:
strategy:
matrix:
os: [ubuntu, macos, windows]
node: [16, 18, 20]
database: [postgres, mysql, mongodb]
# This creates 27 combinations!
exclude:
# Remove unlikely production combinations
- os: windows
database: mongodb
- os: macos
node: 16
# Now down to 23 combinations
Pitfall 2: Resource Contention
Problem: All matrix jobs start simultaneously, causing resource exhaustion or hitting rate limits.
Solution: Implement controlled parallelism:
strategy:
max-parallel: 4 # Limit concurrent jobs
matrix:
# ... configuration
Pitfall 3: Inconsistent Test Environments
Problem: Tests pass in some matrix combinations but fail in others due to environment differences.
Solution: Use Docker for consistent environments:
jobs:
test:
runs-on: ${{ matrix.os }}
container:
image: node:${{ matrix.node }}
options: --user root
strategy:
matrix:
os: [ubuntu-latest]
node: [16, 18, 20]
Tools and Resources
Recommended Tools
| Tool | Use Case | Pros | Cons |
|---|---|---|---|
| GitHub Actions | General purpose matrix testing | Native matrix support, good UI, free for public repos | Limited to GitHub |
| GitLab CI | Enterprise environments | Self-hosted option, integrated with GitLab | Learning curve for matrix syntax |
| Jenkins | Legacy systems | Highly customizable, plugin ecosystem | Complex setup, verbose syntax |
| CircleCI | Docker-heavy workflows | Excellent Docker support, parallelism | Cost can be high for large matrices |
Essential Resources
- GitHub Actions Matrix Strategy Documentation
- GitLab CI Parallel Matrix Jobs
- Jenkins Matrix Project Plugin
Conclusion
Matrix testing transforms your CI/CD pipeline from testing individual configurations to comprehensive cross-platform validation. By implementing the strategies in this tutorial, you can:
- Test all critical platform combinations automatically
- Detect environment-specific bugs early
- Reduce manual testing overhead by 70-80%
- Build confidence in cross-platform compatibility
Key Takeaways:
- Start with a minimal matrix and expand based on actual failures
- Use dynamic matrices to optimize costs and execution time
- Prioritize critical combinations over exhaustive coverage
- Monitor and continuously improve matrix performance
Next Steps:
- Implement a basic matrix for your most critical test suite
- Monitor matrix execution for 2-3 weeks and collect metrics
- Expand matrix dimensions based on production issues
- Consider implementing parallel test execution for even faster results
Matrix testing is not just about running more tests—it’s about running the right tests at the right time across the configurations that matter most to your users.