GitLab CI artifacts:reports: JUnit Test Results and Code Coverage in the Merge Request Widget
AI generated
CI/CD
.yml
GitLab · CI/CD · Testing
GitLab CI artifacts:reports: JUnit and Coverage in the Merge Request Widget
Configuring PHPUnit output correctly for GitLab

A green test job in the pipeline overview only tells you that tests ran, not how many, which ones failed, or how well the code is actually covered. With artifacts:reports:junit and artifacts:reports:coverage_report, GitLab pulls exactly these details straight out of the test output and displays them in a structured way in the merge request widget, right down to colored lines in the diff. This article covers the concrete configuration for PHPUnit, from the correct output format setting to interpreting the results.

15 min read JUnit Report Code Coverage PHPUnit Merge Request Widget

1. Why a Plain Exit Code Is Not Enough

A test job in GitLab CI is considered successful as soon as the executed command returns exit code 0, and failed for any other code. This binary piece of information is enough for a rough go or no go decision, but it reveals nothing about which individual tests failed, how long they took, or whether a new change made test coverage worse. Anyone wanting to see these details has so far had to search through the full job logs, which quickly becomes impractical for large test suites with thousands of individual tests.

GitLab's report artifacts solve this by specifically collecting structured output files from the test run and rendering them in the GitLab UI. artifacts:reports:junit processes the industry standard JUnit XML format and shows individual test results directly in the merge request widget, while artifacts:reports:coverage_report reads coverage data in Cobertura format and generates both an overall percentage and colored line markers in the diff view from it. Both mechanisms require the test output to be in the correct format, which for PHPUnit requires deliberate configuration.

2. artifacts:reports:junit: Understanding the JUnit XML Format

The JUnit XML format originally comes from the Java ecosystem, but is nowadays supported by virtually every popular test framework, including PHPUnit for PHP and Jest or Mocha for Node.js. It describes every individual test class as a testsuite element with nested testcase elements, each carrying a name, a runtime, and in the case of a failure a failure or error child element including a stack trace. GitLab parses this structure and displays it in the merge request widget as an expandable list, with failed tests highlighted and shown at the top.

Particularly valuable is the automatic detection of newly failing tests compared to ones that already failed before: GitLab compares the merge request's JUnit result against the target branch's and explicitly marks which tests newly broke because of the current change, as opposed to already known, longer standing failures. This saves reviewers the tedious manual research of whether a red test was actually caused by the current merge request or was already failing before it.


# .gitlab-ci.yml
phpunit:
  stage: test
  script:
    - vendor/bin/phpunit --log-junit report.xml
  artifacts:
    when: always
    reports:
      junit: report.xml

3. Configuring PHPUnit for JUnit Output

PHPUnit produces the JUnit XML format via the command line parameter --log-junit followed by the target file path, which is entirely sufficient for a simple integration. For projects that control their PHPUnit configuration through a phpunit.xml file rather than command line parameters, the same effect can be achieved through the logging element inside that file, which has the advantage of keeping the configuration versioned in the repository instead of implicitly scattered across CI scripts.

The artifacts:when: always setting matters here, because without it the artifact only gets uploaded when the job exits successfully. Exactly when tests fail and the exit code is non zero, the JUnit report would not be available without this setting, even though the information about the failing tests is most valuable precisely at that moment. This pitfall frequently causes the merge request widget to remain empty on failed tests, even though the JUnit configuration looks correct at first glance.


<!-- phpunit.xml -->
<phpunit>
  <logging>
    <junit outputFile="report.xml"/>
  </logging>
</phpunit>

4. Setting Up artifacts:reports:coverage_report for Code Coverage

While junit maps individual test results, coverage_report deals with overall code coverage. GitLab expects the Cobertura XML format for this, which is likewise an industry standard and can be generated by PHPUnit through the coverage extension Xdebug or PCOV. Configuration happens in the artifacts:reports block under the coverage_report key, where both the path to the XML file and the path_type Cobertura need to be specified so GitLab interprets the format correctly.

For PHPUnit itself, a coverage element with a clover or cobertura report is additionally needed in phpunit.xml, with Cobertura directly delivering the format GitLab expects and therefore representing the simpler path. Without an installed coverage extension such as Xdebug or PCOV in the test container, the coverage report either stays empty or the job fails with an error, which is a common first stumbling block during setup, especially in slim Docker images that deliberately omit Xdebug to make test execution faster.


# .gitlab-ci.yml
phpunit:
  stage: test
  variables:
    XDEBUG_MODE: coverage
  script:
    - vendor/bin/phpunit --log-junit report.xml --coverage-cobertura coverage.xml
  artifacts:
    when: always
    reports:
      junit: report.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml

5. Showing the Overall Coverage Percentage as a Pipeline Badge

In addition to the structured coverage_report, GitLab offers the Test coverage parsing project setting, where a regular expression can be stored that extracts the overall coverage percentage as text directly from the job logs and shows it as a badge on the pipeline overview page as well as a repository badge. For PHPUnit, an expression like Lines:\s*\d+\.\d+\% works well, matching the line produced by PHPUnit's text output, provided PHPUnit runs with the additional --coverage-text parameter.

This text based extraction is independent of the structured coverage_report and can even be used without it, but it only covers the aggregated overall percentage, not the line accurate view in the diff. In practice both usually pay off together: the regex for the quickly visible badge on the project overview, and the structured coverage_report for the detailed, line based view directly in the merge request diff, where reviewers can immediately see which newly added lines are actually covered by a test.


# .gitlab-ci.yml
phpunit:
  script:
    - vendor/bin/phpunit --coverage-text --colors=never
  coverage: '/^\s*Lines:\s*\d+\.\d+\%/'

6. How the Results Are Displayed in the Merge Request Widget

When a JUnit report is present, the merge request widget shows a compact summary with the total number of tests, the number of failures and their runtime, expandable down to individual failing test cases including their stack trace, without reviewers having to open the full job logs. When a coverage_report is present, GitLab adds colored margin markers to the diff view: green for newly added lines covered by tests, red for uncovered lines, pointing specifically to where a change was introduced without an accompanying test.

This visual feedback directly inside the code review context is considerably more effective than a plain overall coverage percentage, because it points precisely at the lines that actually changed, instead of delivering an abstract, project wide metric that can easily hide new, untested lines within a large, otherwise well tested codebase. A reviewer can see at a glance whether exactly the newly introduced business logic was actually tested, regardless of how high the project's overall coverage happens to be.

7. Enforcing Coverage Thresholds as a Quality Gate

Through the Merge request approvals project setting, rules can be defined that block a merge if coverage falls below a defined threshold or drops compared to the target branch. For PHPUnit projects, a separate job that parses the Cobertura file and explicitly fails with exit 1 when a minimum value is not met tends to work well, and integrates cleanly with existing rules conditions, for example to only apply on merge requests against main.

A realistic sense of proportion matters here: an overly high global threshold quickly leads developers to write tests just to hit the number, instead of covering genuinely meaningful cases. A more pragmatic approach is to insist primarily that coverage does not drop through a given merge request, rather than enforcing an absolute threshold, combined with targeted, higher requirements for newly written, security relevant code.

8. Outlook: JUnit and Coverage for Other Languages and Frameworks

The JUnit and Cobertura formats are deliberately designed to be language agnostic, which is why the same GitLab configuration works unchanged for other test frameworks, as long as they support a compatible output format. Jest for JavaScript produces JUnit XML through the jest-junit reporter, Node coverage can be output in Cobertura format either through the nyc package or Jest's built in coverage feature, while Go tests follow the same path through go-junit-report and gocover-cobertura.

For teams with mixed technology stacks, this means the once learned GitLab report configuration is reusable across project boundaries, regardless of the programming language involved. This considerably reduces the ramp up effort for new projects, since only the language specific tool for producing the JUnit or Cobertura file needs to be swapped out, while the GitLab side artifacts:reports configuration stays structurally identical.

9. Best Practices and a Format Comparison

A solid approach to reports starts with artifacts:when: always, so failed test jobs reliably upload their reports too, followed by a deliberate decision on whether to also maintain a text regex for the quick badge in addition to the structured coverage_report. For new projects it pays off to set up both report types from the start, rather than retrofitting them later once the test suite has already grown considerably and fixes become more effortful.

The table below compares the relevant report formats and their respective role in the GitLab context, to help quickly pick the right combination when setting things up for a concrete project, without having to try out every format individually.

Report Type GitLab Key Expected Format Display
Test results artifacts:reports:junit JUnit XML List in the merge request widget, new vs. existing
Code coverage artifacts:reports:coverage_report Cobertura XML Colored line markers in the diff view
Overall coverage badge coverage (regex) Text output in the job log Percentage on the pipeline and project page
Quality gate Dedicated job plus approval rule Evaluated Cobertura value Merge block when threshold is not met

Mironsoft

CI/CD pipelines, zero-downtime deployments and release automation

Deployments that run without downtime and without the nail-biting?

We review existing GitLab pipelines for fragile deployment steps and missing safeguards, then build a release process with zero-downtime deployments, automated checks and a rollback you can actually trust in an emergency.

Pipeline Review

Checking an existing .gitlab-ci.yml for fragility, missing stages and security gaps.

Zero-Downtime Deployment

Building symlink releases, health checks and rollback strategies for Magento stores.

CI/CD Automation

Connecting tests, security scans and deployments into one reliable pipeline.

10. Summary

GitLab JUnit and Coverage Reports: Key Takeaways

Two report types

junit for individual test results, coverage_report for line accurate coverage in the diff.

Always upload

artifacts:when: always is mandatory, otherwise the report is missing exactly when tests fail.

PHPUnit flags

--log-junit for JUnit, --coverage-cobertura for coverage, both combinable in a single run.

Language agnostic

JUnit and Cobertura are industry standards, the same GitLab configuration fits many test frameworks.

11. FAQ: GitLab JUnit and Coverage Reports: Key Takeaways

1What is the difference between artifacts:reports:junit and coverage_report?
junit shows individual test results with success, failure and runtime in the merge request widget, while coverage_report reads code coverage data and produces line accurate, colored markers in the diff view as well as an overall percentage.
2Why doesn't my JUnit report show up even though the configuration looks correct?
Most often artifacts:when: always is missing. Without this setting the artifact only gets uploaded on a successful job exit code, meaning exactly when tests fail and the report would be most valuable, it is not.
3How does PHPUnit produce JUnit compatible XML?
Through the command line parameter --log-junit followed by the target file path, or alternatively through a logging element with a junit child element inside the phpunit.xml configuration file.
4Which coverage format does GitLab expect?
Cobertura XML. PHPUnit produces it through the --coverage-cobertura parameter, which requires an installed coverage extension such as Xdebug or PCOV in the test container.
5Why does my coverage report stay empty?
Usually a coverage extension such as Xdebug or PCOV is missing from the Docker image used, since slim PHP images often deliberately omit it to speed up regular test execution. Additionally, with Xdebug 3 the XDEBUG_MODE variable needs to be set to coverage.
6Can I produce both JUnit and coverage from a single PHPUnit run?
Yes, --log-junit and --coverage-cobertura can be combined in a single command, so one test run produces both report files at once without having to run the test suite twice.
7How do I show overall coverage as a badge on the project overview?
Through the Test coverage parsing project setting with a regular expression that extracts the percentage from the job log text output, for example Lines:\s*\d+\.\d+\% when using PHPUnit's --coverage-text.
8Can I block a merge if coverage drops?
Yes, through a combination of a dedicated job that evaluates the Cobertura file and fails when a threshold is not met, together with the project's merge request approval rules, which can build on that job's status.
9Does the same configuration work for JavaScript or Go projects too?
Yes, both the JUnit and the Cobertura format are language agnostic industry standards. For JavaScript, jest-junit for instance produces the JUnit XML, for Go, go-junit-report and gocover-cobertura handle the same task.
10Does GitLab show which tests newly fail because of my merge request?
Yes, GitLab automatically compares the merge request's JUnit result against the target branch's and explicitly marks which tests newly fail because of the current change, separate from failures that already existed before.