Merge Request Testing in GitLab: Review Apps, Pipelines, and Coverage Visualization

Merge Request Testing in GitLab: Review Apps, Pipelines, and Coverage Visualization

Merge requests are the central quality gate in GitLab's workflow. Code doesn't reach the main branch until it passes review — but human review catches logical problems, not regressions. The automated tests in your CI pipeline are what prevent working code from breaking existing functionality.

GitLab provides several features that make CI pipelines more effective as merge request quality gates: review apps, test coverage visualization, blocking pipelines, and merge request-specific pipeline rules. This guide covers how to configure and use these features.

Pipelines for Merge Requests

By default, GitLab creates pipelines on two triggers: branch pushes and merge request events. For efficiency, you typically want pipelines to run once, not twice. The workflow keyword controls this:

workflow:
  rules:
    # Run for merge requests
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    # Run for the default branch
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
    # Run for release branches
    - if: '$CI_COMMIT_BRANCH =~ /^release\//'
    # Otherwise don't create a pipeline
    - when: never

With this configuration, pushes to feature branches only create pipelines when there's an associated merge request.

Blocking Merges on CI Failure

GitLab's Protected Branches settings let you require CI to pass before merging. But you can also control this at the pipeline level with blocking rules.

In the project settings under Settings > Merge Requests:

  • Enable "Pipelines must succeed" to block merging when CI fails
  • Enable "All threads must be resolved" to block merging on unresolved review comments
  • Configure "Required approvals" for human review requirements

These settings create a hard gate: the Merge button is disabled until all conditions are met.

Job-Level Merge Request Rules

You can configure specific jobs to run only on merge requests, allowing different test coverage on feature branches vs. the main branch:

unit-tests:
  stage: test
  script:
    - npm test
  rules:
    # Always run unit tests
    - when: always

integration-tests:
  stage: test
  script:
    - npm run test:integration
  rules:
    # Integration tests only on MRs and main branch
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
    - when: never

e2e-tests:
  stage: test
  script:
    - npm run test:e2e
  rules:
    # E2E tests only for MRs targeting main
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
    - when: never

Merge request variables available in jobs:

CI_MERGE_REQUEST_ID          — Internal MR ID
CI_MERGE_REQUEST_IID         — Project-level MR number (the !123 number)
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME — Feature branch name
CI_MERGE_REQUEST_TARGET_BRANCH_NAME — Target branch (usually main)
CI_MERGE_REQUEST_TITLE       — MR title
CI_MERGE_REQUEST_LABELS      — Comma-separated labels

Test Coverage Visualization

When your tests generate coverage reports, GitLab can display which lines are covered in the merge request diff view. Reviewers see inline indicators of whether the changed code is tested.

Setup:

  1. Generate a coverage report in Cobertura XML format
  2. Upload as a coverage artifact

JavaScript with Istanbul/NYC:

test:
  stage: test
  script:
    - npx jest --coverage --coverageReporters=cobertura
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml

Python with pytest-cov:

test:
  stage: test
  script:
    - pytest --cov=app --cov-report=xml:coverage.xml
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml

Ruby with SimpleCov:

# spec/spec_helper.rb
require 'simplecov'
require 'simplecov-cobertura'

SimpleCov.start do
  add_filter '/spec/'
  formatter SimpleCov::Formatter::CoberturaFormatter
end
test:
  script:
    - bundle exec rspec
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/coverage.xml

After configuration, the merge request diff shows colored line indicators: green for covered lines, red for uncovered. Changed lines that aren't covered by tests are immediately visible to reviewers.

Extracting coverage percentage for the pipeline badge and MR widget:

test:
  script:
    - pytest --cov=app --cov-report=term
  coverage: '/TOTAL\s+\d+\s+\d+\s+(\d+%)/'

The coverage field is a regex that extracts the coverage percentage from job output. GitLab uses this to display coverage in the MR.

Review Apps

Review apps are ephemeral deployment environments created automatically for each merge request. Each MR gets its own URL where the changes can be previewed and tested interactively.

Review apps are particularly valuable for:

  • Frontend changes where QA needs to see the actual rendering
  • API changes that need to be tested with real clients
  • Configuration changes that are difficult to validate without running the application

Basic review app configuration:

deploy-review:
  stage: deploy
  script:
    - echo "Deploying to review environment..."
    - ./scripts/deploy-review.sh $CI_ENVIRONMENT_SLUG
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    url: https://$CI_ENVIRONMENT_SLUG.review.yourapp.com
    on_stop: stop-review
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

stop-review:
  stage: deploy
  script:
    - ./scripts/destroy-review.sh $CI_ENVIRONMENT_SLUG
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    action: stop
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      when: manual

The $CI_ENVIRONMENT_SLUG variable is a URL-safe version of the environment name (e.g., review-fix-checkout-btn-123).

Kubernetes-based review apps using Auto DevOps:

# If using GitLab Auto DevOps, review apps are configured automatically
# For custom Kubernetes deployments:

deploy-review:
  stage: deploy
  image: bitnami/kubectl:latest
  script:
    - kubectl create namespace review-$CI_ENVIRONMENT_SLUG || true
    - envsubst < k8s/review-deployment.yaml | kubectl apply -f -
    - kubectl rollout status deployment/app -n review-$CI_ENVIRONMENT_SLUG
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    url: https://$CI_ENVIRONMENT_SLUG.review.k8s.yourapp.com
    kubernetes:
      namespace: review-$CI_ENVIRONMENT_SLUG

Merge Request Approval Integration

GitLab allows configuring approval rules that require specific team members or groups to approve changes. You can combine these with CI requirements:

# .gitlab/CODEOWNERS (similar to GitHub's CODEOWNERS)
# These paths require approval from the specified groups

/src/payments/     @payments-team
/infra/           @devops-team
/.gitlab-ci.yml   @devops-team
/src/auth/        @security-team

With CODEOWNERS configured, merge requests that touch payments code automatically require approval from the payments team. Combined with CI pipeline requirements, this creates a layered quality gate.

Automated Test Comments on MRs

GitLab's JUnit report integration posts failed tests as comments on merge requests, showing test names and failure messages inline. Configure it with:

test:
  script:
    - npm test -- --reporters=jest-junit
  artifacts:
    when: always
    reports:
      junit: junit-results.xml
    expire_in: 1 week

When tests fail, the MR shows a test failure count in the overview and a list of failed tests in the Test tab. Reviewers can see exactly which tests failed without opening the job log.

Performance Testing on MRs

For performance-sensitive applications, you can configure merge request pipelines to compare performance metrics against the target branch:

performance:
  stage: test
  image: sitespeedio/sitespeed.io
  script:
    - sitespeed.io --outputFolder performance-results --plugins.load /sitespeed-plugin/performance-budgets.js $REVIEW_APP_URL
  artifacts:
    paths:
      - performance-results/
    reports:
      performance: performance-results/performance.json
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      when: always

GitLab's Performance Report feature compares the performance results between the MR branch and the target branch, showing regressions directly in the MR view.

Similarly, load testing results can be compared:

load-test:
  stage: test
  script:
    - k6 run --out json=load-results.json load-tests/checkout.js
  artifacts:
    reports:
      load_performance: load-results.json

Working with Draft MRs

GitLab treats "Draft:" prefixed MRs differently in some workflow contexts. To prevent CI from deploying review apps for drafts:

deploy-review:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TITLE !~ /^Draft:/'

Or use GitLab's draft status variable:

deploy-review:
  rules:
    - if: '$CI_MERGE_REQUEST_DRAFT == "false" && $CI_PIPELINE_SOURCE == "merge_request_event"'

A Practical MR Pipeline Configuration

Putting it together — a pipeline that provides fast feedback on MRs with appropriate quality gates:

workflow:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

stages:
  - lint
  - test
  - analyze
  - deploy

lint:
  stage: lint
  script:
    - npm run lint
    - npm run typecheck
  rules:
    - when: always

unit-test:
  stage: test
  parallel: 4
  script:
    - npm test -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL --reporters=jest-junit
  artifacts:
    when: always
    reports:
      junit: junit.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml
  coverage: '/Statements\s*:\s*(\d+\.?\d*)%/'

security-scan:
  stage: analyze
  script:
    - npm audit --audit-level=high
    - npx semgrep --config auto src/
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

deploy-review:
  stage: deploy
  script:
    - ./scripts/deploy-review.sh
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    url: https://$CI_ENVIRONMENT_SLUG.review.yourapp.com
    on_stop: stop-review
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_DRAFT == "false"'

stop-review:
  stage: deploy
  script:
    - ./scripts/destroy-review.sh
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    action: stop
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      when: manual

This pipeline runs lint and tests on every MR push, provides coverage visualization, deploys review apps for non-draft MRs, and includes security scanning. The stop-review job gives reviewers a button to clean up the review environment when they're done.

Read more

Start now free