GitLab CI Testing: Unit, Integration, and E2E Tests in Pipelines

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.xml

Jobs 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:unit

The 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:integration

Services 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-*.xml

GitLab 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 test

This 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.xml

For pytest:

python-tests:
  script:
    - pip install pytest pytest-cov
    - pytest --junitxml=report.xml
  artifacts:
    reports:
      junit: report.xml

When 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.xml

For 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_URL

Mark 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.com

Pipeline Optimization

Only run tests when relevant files change:

unit-tests:
  script:
    - npm run test:unit
  rules:
    - changes:
        - src/**/*
        - tests/unit/**/*
        - package.json

Skip 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_BRANCH

Run 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: manual

Artifacts 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 week

when: 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 test

The 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:

  1. Check the job log — GitLab highlights the failing command
  2. Download artifacts if screenshots or reports were saved
  3. Click "Download artifacts" from the job page
  4. 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_failure

Retry 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 stages to sequence install → unit/integration → E2E
  • Caches node_modules with a lock-file-based key
  • Runs services (Postgres, Redis) as Docker sidecars
  • Reports JUnit XML so GitLab shows failures in merge requests
  • Uses parallel for test sharding across multiple agents
  • Saves artifacts when: on_failure for debugging
  • Uses rules to skip tests for irrelevant changes
  • Uses needs for 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.

Read more