GitLab CI Testing: Unit, Integration, and E2E Tests in Pipelines
GitLab CI is tightly integrated with GitLab repositories — pipelines run automatically, test results appear in merge requests, and everything lives in one platform. The .gitlab-ci.yml file defines your entire pipeline. This guide shows how to structure it for comprehensive automated testing.
Basic Pipeline Structure
GitLab CI pipelines are defined in .gitlab-ci.yml at the root of your repository:
stages:
- install
- test
- e2e
default:
image: node:20
install:
stage: install
script:
- npm ci
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
unit-tests:
stage: test
script:
- npm run test:unit -- --reporter=junit --output-file=junit.xml
artifacts:
when: always
reports:
junit: junit.xml
integration-tests:
stage: test
script:
- npm run test:integration -- --reporter=junit --output-file=junit-integration.xml
artifacts:
when: always
reports:
junit: junit-integration.xmlJobs in the same stage run in parallel. Stages execute sequentially — test only starts after install completes.
Caching Dependencies
GitLab CI caching works per-runner. For best results, use a cache key based on your lock file:
.node-cache: &node-cache
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
policy: pull
install:
cache:
<<: *node-cache
policy: push-pull
script:
- npm ci
unit-tests:
<<: *node-cache
script:
- npm run test:unitThe pull policy means test jobs use the cache but don't write to it — only the install job updates the cache. This prevents cache corruption from parallel jobs writing simultaneously.
Services: Running Databases and Dependencies
Services are Docker containers that run alongside your job:
integration-tests:
stage: test
image: node:20
services:
- name: postgres:16
alias: postgres
variables:
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
- name: redis:7
alias: redis
variables:
DATABASE_URL: postgresql://postgres:testpass@postgres/testdb
REDIS_URL: redis://redis:6379
script:
- npm ci
- npm run test:integrationServices are accessible by their alias name. Health check configuration in your test setup (retry connections) is more reliable than sleep in pipelines since service startup time varies.
Parallel Testing with parallel
The parallel keyword splits a single job into multiple instances:
e2e-tests:
stage: e2e
image: mcr.microsoft.com/playwright:v1.44.0-jammy
parallel: 4
script:
- npm ci
- npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
artifacts:
when: always
paths:
- playwright-report/
reports:
junit: test-results/e2e-*.xmlGitLab provides CI_NODE_INDEX (1-based) and CI_NODE_TOTAL automatically when parallel is set. A 40-minute E2E suite on 4 parallel jobs takes ~10 minutes.
Matrix Testing
Test across multiple configurations using parallel: matrix:
test:
image: node:$NODE_VERSION
parallel:
matrix:
- NODE_VERSION: ['18', '20', '22']
DATABASE: ['postgres', 'mysql']
services:
- name: $DATABASE:latest
alias: db
script:
- npm ci
- npm testThis generates 6 jobs (3 versions × 2 databases) from a single job definition.
JUnit Test Reports in Merge Requests
GitLab parses JUnit XML artifacts and displays results directly in merge requests:
unit-tests:
script:
- npx jest --reporters=default --reporters=jest-junit
artifacts:
when: always
reports:
junit: junit.xml
variables:
JEST_JUNIT_OUTPUT_FILE: junit.xmlFor pytest:
python-tests:
script:
- pip install pytest pytest-cov
- pytest --junitxml=report.xml
artifacts:
reports:
junit: report.xmlWhen a merge request has failing tests, GitLab shows which specific tests failed compared to the target branch — you see the delta, not just the total.
Coverage Visualization
GitLab can display coverage data inline in merge request diffs (which lines were covered):
unit-tests:
script:
- npx jest --coverage --coverageReporters=cobertura
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xmlFor the coverage percentage badge on the project page, set a regex in Settings > CI/CD > General > Test coverage parsing:
All files\s*\|\s*([\d.]+)Environment Variables and Secrets
Store secrets in GitLab CI/CD Variables (Settings > CI/CD > Variables):
integration-tests:
script:
- npm run test:integration
variables:
API_KEY: $TEST_API_KEY # From CI/CD Variables
DATABASE_URL: $TEST_DATABASE_URLMark variables as Protected to limit them to protected branches, and Masked to hide values in job logs.
For environment-specific values:
deploy-tests:
script:
- npm run test:smoke
environment:
name: staging
variables:
APP_URL: https://staging.example.comPipeline Optimization
Only run tests when relevant files change:
unit-tests:
script:
- npm run test:unit
rules:
- changes:
- src/**/*
- tests/unit/**/*
- package.jsonSkip CI for documentation-only changes:
unit-tests:
rules:
- if: $CI_COMMIT_MESSAGE =~ /\[skip ci\]/
when: never
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCHRun only fast tests on feature branches, full suite on main:
unit-tests:
script:
- npm run test:unit
rules:
- when: always
e2e-tests:
script:
- npm run test:e2e
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
when: manualArtifacts and Test Outputs
Save test artifacts for debugging failures:
e2e-tests:
script:
- npx playwright test
artifacts:
when: on_failure
paths:
- playwright-report/
- test-results/
expire_in: 1 weekwhen: on_failure saves disk space by only keeping artifacts when they're needed for debugging.
For artifacts needed by downstream jobs:
build:
script:
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 hour
e2e-tests:
needs: [build]
script:
- npx playwright testThe needs keyword creates a DAG (directed acyclic graph) — e2e-tests starts as soon as build finishes without waiting for other jobs in the same stage.
Reviewing Test Failures
When tests fail in GitLab CI:
- Check the job log — GitLab highlights the failing command
- Download artifacts if screenshots or reports were saved
- Click "Download artifacts" from the job page
- For JUnit failures, the merge request shows exactly which test cases failed
For flaky tests, GitLab's retry mechanism is straightforward:
unit-tests:
script:
- npm run test:unit
retry:
max: 2
when:
- runner_system_failure
- stuck_or_timeout_failureRetry for infrastructure failures, not for flaky application tests — retrying application failures hides real bugs.
Testing Deployed Applications
GitLab CI validates that your code works in isolation. Verifying that your entire deployed application works — across browsers, with real user sessions, in production-like conditions — requires a different approach.
HelpMeTest connects to your running application and executes functional tests written in plain English. No .gitlab-ci.yml configuration, no browser driver setup. You can trigger HelpMeTest runs from GitLab CI as a deployment verification step using a simple API call.
Summary
A production GitLab CI test pipeline:
- Uses
stagesto sequence install → unit/integration → E2E - Caches
node_moduleswith a lock-file-based key - Runs services (Postgres, Redis) as Docker sidecars
- Reports JUnit XML so GitLab shows failures in merge requests
- Uses
parallelfor test sharding across multiple agents - Saves artifacts
when: on_failurefor debugging - Uses
rulesto skip tests for irrelevant changes - Uses
needsfor DAG-based job ordering
GitLab CI's tight integration with merge requests — showing which tests failed, which lines lack coverage, which pipelines are blocking — makes it one of the better platforms for test-driven development workflows.