Integrating PHPUnit into GitLab CI and GitHub Actions
AI generated
@test
assert
PHPUnit · GitLab CI · GitHub Actions · JUnit · Coverage
PHPUnit in GitLab CI and GitHub Actions
from JUnit reports to matrix strategy

Tests that only ever run locally do not protect a project. Only integration into CI/CD turns test automation into a quality gate: every pull request is checked automatically, coverage reports are stored as artifacts, and failures are reported where they happen, in the code, not first at deploy time. This article shows the complete CI/CD integration of PHPUnit for both major platforms.

20 min read GitLab CI · GitHub Actions · JUnit · Coverage · Caching · Matrix PHP 8.x · PHPUnit 10/11 · GitLab 17+ · GitHub Actions

1. The basic principle: tests as a mandatory part of the pipeline

A CI/CD pipeline without automated tests is a deployment mechanism, not a quality assurance system. The difference is that a quality pipeline checks every commit before it lands on the main branch or in the production environment. PHPUnit tests are the central tool of that check: they ensure that every code change does not break existing behavior and that new functionality shows the expected behavior.

The architecture of a PHPUnit CI pipeline follows the same principle on both platforms, GitLab CI and GitHub Actions: first install dependencies (Composer), then run tests, then store reports. For larger projects a staged structure is added: static analysis first (fast and no database setup needed), then unit tests, then integration tests. This ordering makes type errors fail immediately, not only after a long database setup. The developer gets fast, precise feedback.

2. GitLab CI: configuring unit tests

GitLab CI configures pipelines via a .gitlab-ci.yml file in the project root. Stages define the execution order; jobs within the same stage run in parallel. For PHPUnit unit tests a job is created in the test stage that uses a PHP image as its base, installs Composer dependencies and then runs PHPUnit. The --log-junit flag produces a JUnit XML file, which GitLab reads as a test report and visualizes in the merge request panel.

A detail that is often overlooked: the Composer cache should be configured as a GitLab cache so that dependencies are not downloaded again on every job run. The cache key is based on the hash of the composer.lock file. If no dependencies have changed, the cached vendor/ folder is reused and the job starts noticeably faster.


# .gitlab-ci.yml: PHPUnit unit tests for PHP 8.4 projects

stages:
  - static-analysis
  - test
  - report

variables:
  COMPOSER_HOME: "${CI_PROJECT_DIR}/.composer"
  XDEBUG_MODE: "off"

.php-base: &php-base
  image: php:8.4-cli-alpine
  before_script:
    - apk add --no-cache git unzip curl
    - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
    - composer install --prefer-dist --no-progress --no-interaction --optimize-autoloader
  cache:
    key: "composer-${CI_COMMIT_REF_SLUG}-${CI_PROJECT_ID}"
    paths:
      - .composer/
      - vendor/
    policy: pull-push

phpstan:
  <<: *php-base
  stage: static-analysis
  script:
    - vendor/bin/phpstan analyse --no-progress --memory-limit=512M
  allow_failure: false

phpunit:unit:
  <<: *php-base
  stage: test
  script:
    - vendor/bin/phpunit --configuration phpunit.xml --testsuite unit
        --log-junit var/log/tests/junit-unit.xml
        --no-coverage
  artifacts:
    when: always
    reports:
      junit: var/log/tests/junit-unit.xml
    expire_in: 7 days
  coverage: '/^\s*Lines:\s*\d+.\d+\%/'

3. GitLab CI: integration tests with a database service

Integration tests that need a database require, in GitLab CI, the configuration of a service, an additional container that runs alongside the test job. The MySQL service is declared in the job configuration under services:. GitLab CI makes the service container reachable via the hostname mysql, which is used in the database connection parameters.

For Magento integration tests, additional environment variables are needed: database name, user, password, hostname. These are stored as CI/CD variables in GitLab and injected into the pipeline as environment variables. Sensitive values such as database passwords are defined as masked and protected variables so they do not appear in logs. The integration test job explicitly depends on the unit test job (needs: [phpunit:unit]), so integration tests only start once unit tests are green.


# .gitlab-ci.yml: Integration tests with a MySQL service

phpunit:integration:
  stage: test
  image: php:8.4-cli-alpine
  needs:
    - job: phpunit:unit
      artifacts: false
  services:
    - name: mysql:8.0
      alias: mysql
  variables:
    MYSQL_DATABASE: magento_test
    MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    MYSQL_USER: magento
    MYSQL_PASSWORD: ${DB_PASSWORD}
    DB_HOST: mysql
    DB_NAME: magento_test
    DB_USER: magento
    DB_PASSWORD: ${DB_PASSWORD}
    XDEBUG_MODE: "off"
  before_script:
    - apk add --no-cache git unzip curl mariadb-client
    - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
    - composer install --prefer-dist --no-progress --no-interaction
    - mysqladmin ping -h mysql --wait=30
    - mysql -h mysql -u root -p${DB_ROOT_PASSWORD} ${MYSQL_DATABASE} < dev/tests/integration/db/schema.sql
  script:
    - vendor/bin/phpunit --configuration phpunit-integration.xml
        --log-junit var/log/tests/junit-integration.xml
        --no-coverage
  artifacts:
    when: always
    reports:
      junit: var/log/tests/junit-integration.xml
    expire_in: 7 days

4. GitHub Actions: configuring unit tests

GitHub Actions configures workflows via YAML files in the .github/workflows/ directory. A workflow consists of one or more jobs, which in turn consist of steps. For PHPUnit unit tests a workflow is created that triggers on push and pull request events, installs PHP in the desired version (via actions/setup-php), installs Composer dependencies and runs PHPUnit.

The actions/setup-php action from the shivammathur repository is the standard for PHP setup in GitHub Actions. It installs PHP in the desired version, configures Xdebug or pcov and provides all the common extensions. With coverage: pcov in the action configuration, pcov is enabled automatically, no manual extension installation needed. That makes the configuration considerably more compact than in GitLab CI with a plain PHP Alpine image.


# .github/workflows/phpunit.yml: Unit tests in GitHub Actions

name: PHPUnit Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

jobs:
  phpunit-unit:
    name: "PHPUnit Unit Tests (PHP ${{ matrix.php }})"
    runs-on: ubuntu-latest
    strategy:
      matrix:
        php: ['8.3', '8.4']
      fail-fast: false

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup PHP ${{ matrix.php }}
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          extensions: mbstring, intl, gd, zip
          coverage: pcov
          ini-values: pcov.enabled=1, memory_limit=512M

      - name: Cache Composer dependencies
        uses: actions/cache@v4
        with:
          path: ~/.composer/cache
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-composer-

      - name: Install Composer dependencies
        run: composer install --prefer-dist --no-progress --no-interaction

      - name: Run PHPUnit unit tests
        run: |
          vendor/bin/phpunit \
            --configuration phpunit.xml \
            --testsuite unit \
            --log-junit var/log/tests/junit-unit.xml \
            --coverage-clover var/log/tests/coverage.xml \
            --no-interaction

      - name: Upload JUnit report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: junit-unit-php${{ matrix.php }}
          path: var/log/tests/junit-unit.xml

      - name: Upload Coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage-php${{ matrix.php }}
          path: var/log/tests/coverage.xml

5. GitHub Actions: matrix strategy for multiple PHP versions

The matrix strategy in GitHub Actions makes it possible to run the same job simultaneously with different configurations. For PHP projects the most common use is parallel testing against multiple PHP versions: PHP 8.3 and PHP 8.4 in a single workflow that starts both tests at the same time on a push. That ensures the code is compatible with the current and the next PHP version.

The matrix strategy can be extended: besides PHP versions, different database versions (MySQL 8.0 vs. MariaDB 10.6) or operating system variants (ubuntu vs. macos) can also be included in the matrix. With fail-fast: false, all matrix jobs run to completion even if one of them fails. That gives full information about which combinations work, not just whether the first failing combination has a problem. For production projects, fail-fast: false is recommended for the test matrix; for static analysis, fail-fast: true can make sense.

6. JUnit reports and coverage reports as artifacts

JUnit XML is a standardized format for test reports that both platforms support directly. GitLab CI reads JUnit XML via the artifacts.reports.junit key and visualizes the results in the merge request panel: failed tests are shown with their error messages, without having to search through pipeline logs. GitHub Actions shows JUnit reports in the pull request's check panel when a suitable action is used (for example, actions/upload-artifact combined with a test reporter action).

Coverage reports stored as artifacts enable trend tracking: how has coverage changed over time? GitLab CI can extract a percentage value directly from the test's coverage output (with the coverage key and a regular expression) and display it in the merge request widget. A simple regex like /^\s*Lines:\s*\d+.\d+\%/ extracts the value from PHPUnit's text output. GitHub Actions can pass coverage reports on to Codecov or SonarCloud via actions such as codecov/codecov-action.

Feature GitLab CI GitHub Actions Note
JUnit reports Native (artifacts.reports.junit) Via action (upload-artifact) GitLab visualizes directly in the MR
Coverage widget Native (coverage regex) Via Codecov or SonarCloud GitLab simpler to configure
Database service Services key Services in job config Both equally easy
Matrix strategy Parallel keyword Native (matrix) GitHub Actions more elegant
PHP setup PHP Docker image manually setup-php action GitHub Actions more convenient

7. Caching strategies for faster pipelines

Caching Composer dependencies is the single most important measure for reducing CI pipeline runtime. Composer typically installs hundreds of packages and can take several minutes if everything has to be downloaded fresh. With correct caching this step shrinks to seconds, because only changed or new packages need to be downloaded. Both platforms support cache keys based on file hashes: if composer.lock has not changed, the stored cache is used.

Beyond Composer packages, it is worth caching the PHPUnit result cache (.phpunit.cache/) between pipeline runs. This cache holds information about recently failed tests and makes it possible to run the failing tests first on repeated runs. In GitLab CI the cache is configured via the cache: key with a suitable policy; in GitHub Actions via actions/cache. Another caching candidate in Magento projects: the generated code directory (generated/), which is rebuilt on every build and costs several seconds.


# .github/workflows/phpunit.yml: Full integration with services and caching

jobs:
  phpunit-integration:
    name: "PHPUnit Integration Tests"
    runs-on: ubuntu-latest
    needs: phpunit-unit

    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_DATABASE: magento_test
          MYSQL_ROOT_PASSWORD: rootpassword
          MYSQL_USER: magento
          MYSQL_PASSWORD: magento
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=3

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup PHP 8.4
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          extensions: mbstring, intl, pdo_mysql
          coverage: none  # No coverage for integration tests in CI

      - name: Cache Composer dependencies
        uses: actions/cache@v4
        with:
          path: ~/.composer/cache
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}

      - name: Install dependencies
        run: composer install --prefer-dist --no-progress

      - name: Wait for MySQL and import schema
        run: |
          mysql -h 127.0.0.1 -u root -prootpassword magento_test \
            < dev/tests/integration/db/schema.sql

      - name: Run integration tests
        env:
          DB_HOST: 127.0.0.1
          DB_NAME: magento_test
          DB_USER: magento
          DB_PASSWORD: magento
          XDEBUG_MODE: "off"
        run: |
          vendor/bin/phpunit \
            --configuration phpunit-integration.xml \
            --log-junit var/log/tests/junit-integration.xml \
            --no-interaction

      - name: Upload JUnit report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: junit-integration
          path: var/log/tests/junit-integration.xml

9. Summary

Integrating PHPUnit into GitLab CI and GitHub Actions follows the same basic principle, but differs in the details. Both platforms support JUnit XML reports, artifact storage, database services and caching. GitHub Actions offers somewhat more convenient PHP configuration with the matrix strategy and the setup-php action; GitLab CI integrates JUnit reports and coverage widgets natively in the merge request panel without additional third-party integrations.

The most important configuration principles for both platforms: cache Composer dependencies, store JUnit XML as an artifact, split unit and integration tests into separate jobs, run static analysis before tests, and measure coverage only when needed (not on every feature branch build). A cleanly structured CI pipeline built on these measures gives developers fast, precise feedback on every commit and turns quality checking into an invisible, self-evident routine.

PHPUnit in CI/CD, the essentials at a glance

GitLab CI JUnit

artifacts.reports.junit reads the XML file and visualizes results directly in the merge request panel. The coverage regex extracts the percentage value.

GitHub Actions

setup-php action for easy PHP setup with pcov. Matrix strategy for parallel tests across multiple PHP versions. actions/upload-artifact for reports.

Caching

Composer cache with hashFiles(composer.lock) as the key. PHPUnit result cache for defects-first ordering. Saves minutes per pipeline run.

Pipeline structure

Static analysis, then unit tests, then integration tests. Each stage can only start once the previous one succeeded. Coverage only in dedicated jobs.