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: neverWith 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: neverMerge 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 labelsTest 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:
- Generate a coverage report in Cobertura XML format
- 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.xmlPython with pytest-cov:
test:
stage: test
script:
- pytest --cov=app --cov-report=xml:coverage.xml
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xmlRuby with SimpleCov:
# spec/spec_helper.rb
require 'simplecov'
require 'simplecov-cobertura'
SimpleCov.start do
add_filter '/spec/'
formatter SimpleCov::Formatter::CoberturaFormatter
endtest:
script:
- bundle exec rspec
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage/coverage.xmlAfter 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: manualThe $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_SLUGMerge 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-teamWith 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 weekWhen 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: alwaysGitLab'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.jsonWorking 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: manualThis 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.