Setting Up PHPUnit Properly: Composer, Bootstrap, phpunit.xml and Test Suites
AI generated
@test
assert
PHPUnit · Composer · Testing · Setup
Setting Up PHPUnit Properly
Composer, Bootstrap, phpunit.xml and Test Suites

A half-heartedly set up PHPUnit project slows down every developer workflow: missing autoload paths, no bootstrap, no separation between unit and integration tests. This tutorial shows what a clean setup looks like from the start, reproducible, team-friendly and CI-ready.

12 min read Composer · phpunit.xml · Bootstrap · Test Suites · Autoloading PHPUnit 10/11 · PHP 8.2+

1. Why the setup is crucial

PHPUnit is installed in a few minutes, but a clean PHPUnit setup is a different matter. Without clearly defined test suites, unit tests and integration tests run mixed together, which needlessly extends execution time and slows down local feedback. Without a bootstrap file, environment variables or the autoloader are missing, so tests only run in certain contexts. Without a structured phpunit.xml, every team member ends up forcing their own command-line arguments.

A properly set up project, by contrast, lets you run just the fast unit tests with a single vendor/bin/phpunit --testsuite unit, and the integration tests against a real database with a second command. The CI pipeline uses the same configuration file, so local and remote conditions are identical. The effort of the initial setup already pays off once the second team member joins.

Especially in PHP 8 projects with strict types and constructor property promotion, a structured test setup is not a luxury but the foundation for refactoring safety. If you cannot immediately tell, after a type signature change, whether all callers are still correct, you do not have bad testing, you have testing with the wrong scope.

2. Installing PHPUnit via Composer

PHPUnit belongs exclusively in the require-dev section of composer.json. Production dependencies are strictly separated from test dependencies. In CI deployments you can then use composer install --no-dev to keep the production build lean. The correct command for installation is composer require --dev phpunit/phpunit ^11. PHPUnit 11 requires PHP 8.2, PHPUnit 10 is still compatible with PHP 8.1.

After installation, Composer places the executable script under vendor/bin/phpunit. In a team-wide environment it is worth abstracting this path via Makefiles or shell wrappers, so nobody needs a global PHPUnit installation locally. Globally installed PHPUnit versions regularly lead to version conflicts between projects, a common reason for tests that are green locally and red remotely.

Besides PHPUnit itself, it is worth setting up further test helper packages right away: phpunit/php-code-coverage for coverage reports (automatically installed alongside PHPUnit 11), fakerphp/faker for test data generation, and, if needed, mockery/mockery as an alternative to the built-in mocking API. Declaring all of these packages as require-dev keeps the production deployment size to a minimum.


{
  "name": "mironsoft/shop",
  "require": {
    "php": ">=8.2",
    "magento/product-community-edition": "2.4.8"
  },
  "require-dev": {
    "phpunit/phpunit": "^11",
    "fakerphp/faker": "^1.23",
    "mockery/mockery": "^1.6"
  },
  "autoload": {
    "psr-4": {
      "Mironsoft\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "Mironsoft\\Tests\\": "tests/"
    }
  },
  "scripts": {
    "test": "vendor/bin/phpunit",
    "test:unit": "vendor/bin/phpunit --testsuite unit",
    "test:integration": "vendor/bin/phpunit --testsuite integration",
    "test:coverage": "vendor/bin/phpunit --coverage-html var/coverage"
  }
}

3. Configuring autoloading correctly

The most common problem after installing PHPUnit: classes are not found in the test because the autoloader is not set up correctly. Composer uses PSR-4 autoloading, which means the namespace and directory structure must match exactly. The production code lives under src/ with the namespace prefix Mironsoft\\, the test code lives under tests/ with the prefix Mironsoft\\Tests\\.

The critical difference: production autoloading goes into autoload, test autoloading into autoload-dev. This prevents test classes from being included in the optimized autoloader of the production build. After every change to composer.json, composer dump-autoload must be run so the autoload files under vendor/composer/ are refreshed.

In Magento 2 projects, autoloading is more complex: Magento registers its own autoloader, which works alongside the Composer autoloader. Unit tests without a Magento bootstrap can use the plain Composer autoloader. Integration tests that need Magento classes must initialize the Magento autoloader via the bootstrap.

4. Understanding and writing the bootstrap file

The bootstrap file runs once before the first test. It is the right place for everything the entire test suite needs: including the Composer autoloader, loading environment variables from an .env.test file, setting the timezone, initializing test database connections. A typical bootstrap file for a PHP project without a framework is minimal, three to ten lines. A bootstrap for integration tests against a database can be considerably more extensive.

The common mistake: putting everything into the bootstrap that actually belongs in the setUp() of the test classes. The bootstrap runs once per test suite execution, not per test class or test method. Database connections opened in the bootstrap stay open for all tests, which can lead to conflicts during parallel execution. Database fixtures, test transactions and scope-specific initialization belong in setUp() and tearDown().


<?php

declare(strict_types=1);

// tests/bootstrap.php: Test bootstrap for PHPUnit
// Loaded once before the first test in the suite

// 1. Composer autoloader
$autoloader = require dirname(__DIR__) . '/vendor/autoload.php';

// 2. Load test environment variables (not committed to VCS)
if (file_exists(dirname(__DIR__) . '/.env.test')) {
    $lines = file(dirname(__DIR__) . '/.env.test', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
    foreach ($lines as $line) {
        if (str_starts_with(trim($line), '#') || !str_contains($line, '=')) {
            continue;
        }
        [$key, $value] = explode('=', $line, 2);
        $_ENV[trim($key)] = trim($value);
        putenv(trim($key) . '=' . trim($value));
    }
}

// 3. Set default timezone to avoid date() warnings in tests
date_default_timezone_set($_ENV['APP_TIMEZONE'] ?? 'Europe/Berlin');

// 4. Ensure error reporting is strict in test environment
error_reporting(E_ALL);
ini_set('display_errors', '1');

5. Building phpunit.xml step by step

The phpunit.xml is the central configuration file for PHPUnit. It defines which test files are loaded, which suites exist, which bootstrap file is used, and how coverage reports are generated. PHPUnit looks for this file in the project root. An alternative file can be specified with the --configuration flag, which is useful for different CI environments.

Since PHPUnit 10, the XML schema is stricter. Deprecated attributes such as verbose directly on the <phpunit> element have been removed. Migrating from PHPUnit 9 to 10 or 11 therefore always starts with validating phpunit.xml against the new schema: vendor/bin/phpunit --migrate-configuration converts most deprecated attributes automatically. Whatever is still flagged afterward has to be adjusted manually.

The coverage configuration inside phpunit.xml determines which source files are included in the coverage report. The <source> element specifies the directory of the production code to be analyzed. Without this element, PHPUnit only measures coverage for the code that was actually executed, not the gaps for classes that were never run, which leads to deceptively high coverage figures.


<?xml version="1.0" encoding="UTF-8"?>
<!-- phpunit.xml: PHPUnit 11 configuration -->
<phpunit
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.0/phpunit.xsd"
  bootstrap="tests/bootstrap.php"
  colors="true"
  cacheDirectory=".phpunit.cache"
  executionOrder="depends,defects"
  failOnWarning="true"
  failOnRisky="true"
>
  <testsuites>
    <testsuite name="unit">
      <directory>tests/Unit</directory>
    </testsuite>
    <testsuite name="integration">
      <directory>tests/Integration</directory>
    </testsuite>
    <testsuite name="functional">
      <directory>tests/Functional</directory>
    </testsuite>
  </testsuites>

  <source>
    <include>
      <directory suffix=".php">src</directory>
    </include>
    <exclude>
      <directory>src/generated</directory>
    </exclude>
  </source>

  <coverage>
    <report>
      <html outputDirectory="var/coverage/html"/>
      <clover outputFile="var/coverage/clover.xml"/>
    </report>
  </coverage>

  <php>
    <env name="APP_ENV" value="test"/>
    <env name="DB_NAME" value="shop_test"/>
  </php>
</phpunit>

6. Test suites: separating unit, integration and functional

The separation into test suites is not a convention, it is a practical necessity. Unit tests test individual classes in isolation, without database connections, HTTP requests or filesystem access. They run in milliseconds and give immediate feedback. In a healthy project, unit tests are the largest suite, hundreds or thousands of fast tests that can run after every code change.

Integration tests test the interplay of classes, often against a real test database. They are considerably slower and run less often, typically before every commit or in the CI pipeline. Functional tests (also called end-to-end tests) test the application from the outside, for example via HTTP requests or browser automation. This suite runs the slowest and usually only in full CI pipelines.

The directory structure reflects this separation: tests/Unit/, tests/Integration/, tests/Functional/. Within each suite, the directory hierarchy mirrors the namespace structure of the production code. A class Mironsoft\Catalog\PriceCalculator gets its unit test under tests/Unit/Catalog/PriceCalculatorTest.php, so the mapping is always unambiguous and automatically discoverable.

7. Writing your first test correctly

A PHPUnit test is a class that extends PHPUnit\Framework\TestCase. The class name ends in Test, test methods start with test or are annotated with the #[Test] attribute (since PHPUnit 10). PHP 8 attributes have completely replaced the @test docblock annotation, anyone still writing @test in new tests should treat that as tech debt.

A good unit test follows the AAA pattern: Arrange (prepare the test subjects), Act (call the method under test), Assert (check the result). Each test method tests exactly one behavior. Tests with multiple independent assertions that check different behaviors should be split up. The test name describes the tested behavior in plain language, for example testCalculatesTotalPriceWithDiscount().

8. Code coverage configuration

Code coverage shows which lines of the production code were executed during the tests. PHPUnit supports Xdebug (the most common), PCOV (faster for pure coverage measurement) and the phpdbg interpreter. For local development with PhpStorm, Xdebug is the natural choice since it uses the same debugger. For CI pipelines where only coverage needs to be measured, PCOV is considerably faster.

The coverage report should not be the only quality metric. High coverage without meaningful assertions is worthless, tests that execute code but assert nothing increase the coverage number without checking the correctness of the code. A more sensible approach is a combination of mutation testing (e.g. with Infection PHP) and coverage thresholds: failOnLowCoverage in the CI pipeline sets a minimum coverage value and prevents coverage regressions.

9. Configuration approaches compared

There are several ways to configure PHPUnit. A direct comparison shows which approach fits which scenario.

Aspect Without a config file With phpunit.xml Benefit
Test suites All tests always run --testsuite unit/integration Fast local feedback
Bootstrap Manual via --bootstrap Loaded automatically Cannot be forgotten
Coverage No source filter <source> defined precisely Realistic coverage, no distortion
Environment variables System-wide or hacked into .env <php><env> in phpunit.xml Reproducible, identical across the team
Execution order Filesystem order executionOrder="depends,defects" Failed tests run first

Mironsoft

PHPUnit setup, test architecture and CI integration for PHP projects

Want PHPUnit set up professionally?

We set up PHPUnit correctly, with a clean bootstrap, structured test suites, coverage configuration and CI integration. No more manual debugging of autoload problems.

Setup review

Analysis of your existing PHPUnit setup for common configuration problems

Suite architecture

Cleanly separate and structure unit, integration and functional tests

CI integration

Integrate PHPUnit into GitHub Actions, GitLab CI or Jenkins with coverage reports

10. Summary

A clean PHPUnit setup consists of four core elements: correctly split autoloading in composer.json, a bootstrap file that initializes exactly what is necessary, a valid phpunit.xml with a source filter and suites, and a directory structure that clearly separates unit, integration and functional tests. Together, these four elements enable reproducible test runs for every developer and every CI environment without manual configuration work.

The most common mistake in a PHPUnit setup is mixing responsibilities: too much logic in the bootstrap, no separation of test suites, a missing autoload-dev section in Composer. These mistakes catch up with you at the latest once the project grows and the test suite takes minutes instead of seconds without suite separation. A clean initial setup costs two hours and saves time every day afterward.

Setting up PHPUnit properly: the essentials at a glance

Composer

PHPUnit only in require-dev. Separate autoload and autoload-dev. Define Composer scripts for test, test:unit, test:integration.

Bootstrap

Only global initialization: autoloader, environment variables, timezone. No test setup that belongs in setUp().

phpunit.xml

Correct schema version, source element for coverage, executionOrder="depends,defects" for faster debugging.

Test suites

Unit in milliseconds, integration against a test database, functional for end-to-end. Separate directories, separate run times.

11. FAQ: Setting Up PHPUnit Properly

1Which PHPUnit version for PHP 8.2?
PHPUnit 11 for PHP 8.2 and 8.3. PHPUnit 10 for PHP 8.1+. PHPUnit 9 no longer for new projects, no active development.
2What belongs in the bootstrap file?
Only global initialization: autoloader, environment variables, timezone. Test-specific setup belongs in setUp().
3How do I separate unit and integration tests?
Separate directories (tests/Unit, tests/Integration) and separate testsuites in phpunit.xml. Unit without external dependencies, integration may test against a database.
4Why does coverage show incorrect figures?
Without a source element, classes that were never run are not counted. Set source to the src directory for realistic figures.
5@test vs. the #[Test] attribute?
#[Test] is the modern PHP 8 variant and fully replaces @test. Use only the attribute for all new tests.
6Does phpunit.xml need to be in the repository?
Yes. phpunit.xml defines the shared test configuration. A phpunit.xml.dist as template plus a gitignored phpunit.xml for local overrides is also common.
7What does executionOrder="depends,defects" mean?
Most recently failed tests run first. After a code change, immediately see whether the regression test is now green.
8Xdebug or PCOV for coverage?
Xdebug for local development with PhpStorm debugging. PCOV for CI pipelines, 3 to 5 times faster, no debugging overhead.
9Keep test classes out of the production autoloader?
autoload-dev instead of autoload in composer.json. Run composer install --no-dev during production deployment.
10Migrating from PHPUnit 9 to 11?
vendor/bin/phpunit --migrate-configuration converts phpunit.xml automatically. Afterward replace @annotation docblocks with #[Attribute].