PHPUnit Magento Integration Tests: Setup and Execution
AI generated
@test
assert
PHPUnit · Magento 2 · Integration Tests · Docker
Setting up and running Magento integration tests
from phpunit.xml to database isolation

Anyone building Magento modules without integration tests only notices regressions in staging or in production. The Magento Test Framework provides a complete environment with real database isolation, automatic fixture management and the full Magento DI container, provided it is set up correctly.

15 min read phpunit.xml · ObjectManager · @magentoDbIsolation · Docker Magento 2.4 · PHP 8.4 · PHPUnit 10

1. Why integration tests are essential in Magento

Magento 2 is a densely interconnected system: a single call to a repository passes through plugins, preferences, event observers and multiple database operations. Unit tests with mocked dependencies cannot capture this interplay, they test the parts, not the whole. Integration tests, on the other hand, boot the complete Magento DI container, connect to a real test database and execute code in the same context as production.

The practical benefit shows up with database migrations, complex repository queries and events: only an integration test proves that a newly created product is actually indexed, saved and retrievable again by SKU. Anyone relying solely on unit tests discovers such bugs during code review or after deployment, both of which are more expensive than a failing test in the CI pipeline. The Magento Test Framework makes this safety net attainable, but it requires a careful, one-time setup.

2. Structure of the Magento test infrastructure

Magento ships the Test Framework under dev/tests/integration/. It contains its own bootstrap file (framework/bootstrap.php) that initializes the Magento kernel, creates the test database and loads the DI container configuration. The test database is completely separate from the production or development database: Magento creates its own database on the first test run (configurable via install-config-mysql.php) and rolls back all write operations via transaction after each test.

The directory structure for your own integration tests follows Magento's convention: test classes live under dev/tests/integration/testsuite/Vendor/Module/. Fixtures are stored as PHP scripts in identically named subdirectories. The bootstrap environment variables (TESTS_BASE_URL, TESTS_MAGENTO_MODE) control whether tests run in a frontend or backend context. This structure is not optional, Magento integration tests only work with the bundled bootstrap, not with the standard PHPUnit bootstrap.

3. Configuring phpunit.xml for integration tests

The base configuration file lives at dev/tests/integration/phpunit.xml.dist and must be copied to phpunit.xml. The most important adjustments concern the <testsuite> element: for your own modules, register the path to your own test classes there instead of running all of Magento's tests along with them. That drastically reduces test runtime, from hours down to minutes. Setting the TESTS_CLEANUP environment variable to used ensures the test database is reset to its initial state on every run.

<!-- dev/tests/integration/phpunit.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
         bootstrap="framework/bootstrap.php"
         colors="true"
         beStrictAboutOutputDuringTests="true">

  <testsuites>
    <!-- Only run our own module tests, not all of Magento -->
    <testsuite name="Mironsoft Integration Tests">
      <directory>testsuite/Mironsoft</directory>
    </testsuite>
  </testsuites>

  <php>
    <ini name="date.timezone" value="Europe/Berlin"/>
    <const name="TESTS_INSTALL_CONFIG_FILE" value="etc/install-config-mysql.php"/>
    <const name="TESTS_GLOBAL_CONFIG_DIR" value="../../../app/etc"/>
    <const name="TESTS_CLEANUP" value="used"/>
    <const name="TESTS_BASE_URL" value="http://mironsoft.test/"/>
    <const name="TESTS_MAGENTO_MODE" value="developer"/>
    <const name="TESTS_ERROR_LOG_CLEAR" value="1"/>
  </php>

  <source>
    <include>
      <directory suffix=".php">../../../app/code/Mironsoft</directory>
    </include>
  </source>
</phpunit>

The file etc/install-config-mysql.php holds the database credentials for the test database, never the production database. In a Docker environment following the Mark Shust setup, the hostname is db, the user is magento, and the database naming convention is magento_test. Whoever maintains this file correctly ensures that integration tests never touch production data and that setting up a new development environment is reproducible.

4. Writing your first integration test class

A Magento integration test class extends Magento\TestFramework\TestCase\AbstractController (for controller tests) or extends PHPUnit\Framework\TestCase directly combined with the Magento\TestFramework\Helper\Bootstrap trait. The difference to a unit test: no mock container is built, instead the real Magento DI container is available. That means all plugins, preferences and compiled artifacts must be up to date, a common stumbling block when setting this up for the first time.

Test methods are regular PHPUnit methods with the #[Test] attribute or the @test annotation. Magento-specific annotations such as @magentoDbIsolation enabled, @magentoDataFixture and @magentoConfigFixture control the test framework's behavior before and after each test. These annotations are evaluated by the Magento bootstrap, not by PHPUnit itself, which explains why they only work in a properly set up Magento test environment.

<?php

declare(strict_types=1);

namespace Mironsoft\Catalog\Test\Integration;

use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Api\Data\ProductInterfaceFactory;
use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\TestCase;

/**
 * Integration test for custom product repository behavior.
 */
class ProductSaveTest extends TestCase
{
    private ProductRepositoryInterface $productRepository;
    private ProductInterfaceFactory $productFactory;

    protected function setUp(): void
    {
        parent::setUp();
        $objectManager = Bootstrap::getObjectManager();
        $this->productRepository = $objectManager->get(ProductRepositoryInterface::class);
        $this->productFactory    = $objectManager->get(ProductInterfaceFactory::class);
    }

    /**
     * @test
     * @magentoDbIsolation enabled
     * @magentoAppIsolation enabled
     */
    public function productIsSavedAndRetrievableBySku(): void
    {
        $product = $this->productFactory->create();
        $product->setSku('test-integration-sku-001')
                ->setName('Integration Test Product')
                ->setTypeId('simple')
                ->setAttributeSetId(4)
                ->setPrice(19.99);

        $saved = $this->productRepository->save($product);

        $this->assertNotEmpty($saved->getId());

        $loaded = $this->productRepository->get('test-integration-sku-001');
        $this->assertSame('Integration Test Product', $loaded->getName());
        $this->assertEquals(19.99, (float) $loaded->getPrice());
    }
}

5. Using the ObjectManager in tests and the DI bootstrap

In integration tests, using the ObjectManager is explicitly allowed and expected, unlike in production code, where the ObjectManager should never be called directly. Bootstrap::getObjectManager() returns the fully initialized DI container, which knows all compiled factory and proxy classes. That allows you to create real instances of repositories, services and factories, just as production code would.

Important: the ObjectManager in the test shares the same state as the Magento bootstrap, which means configuration changes set via @magentoConfigFixture are immediately visible to the ObjectManager. With @magentoAppIsolation enabled, the ObjectManager is reset between tests, which prevents cached instances from one test affecting the results of the next. This isolation comes at a cost, though: every test run with app isolation takes longer because the container is rebuilt.

6. Database isolation with @magentoDbIsolation

The @magentoDbIsolation enabled annotation is the centerpiece of the Magento Test Framework. It activates a database transaction for the marked test class or method, which is automatically rolled back after the test. This means every test starts with the same database state, regardless of the order in which tests run or what the previous test wrote to the database. This behavior makes integration tests deterministic, a property that would be nearly impossible to achieve without isolation.

Isolation has its limits: DDL operations (table changes), operations on MyISAM tables and external systems such as Elasticsearch or Redis are not rolled back. Anyone writing tests that affect the search index or the cache has to clean these up manually after the test or combine it with @magentoAppIsolation enabled. The annotation can be set at class or method level, at class level it applies as the default for all methods in the class.

7. Running tests inside the Docker container

In the Mark Shust Docker setup, PHPUnit runs via bin/phpunit, which executes the command inside the PHP container. The decisive difference from running locally: inside the container, all database connections are correctly configured, the required PHP extensions (xdebug, sodium) are present and the Magento bootstrap finds all necessary configuration files. Anyone running PHPUnit locally on the host system almost always ends up with connection errors to the test database or missing PHP extensions.

# Execute integration tests for a specific module
# Run from project root, bin/phpunit uses the PHP container
bin/phpunit -c dev/tests/integration/phpunit.xml \
  --testsuite "Mironsoft Integration Tests" \
  --filter "ProductSaveTest" \
  --colors=always \
  -v

# Run all integration tests with coverage (slow, only for CI)
bin/phpunit -c dev/tests/integration/phpunit.xml \
  --coverage-html dev/tests/integration/coverage \
  --coverage-filter app/code/Mironsoft

# Run single test method
bin/phpunit -c dev/tests/integration/phpunit.xml \
  "dev/tests/integration/testsuite/Mironsoft/Catalog/Test/Integration/ProductSaveTest.php" \
  --filter productIsSavedAndRetrievableBySku

# Rebuild generated code before testing (mandatory after class changes)
bin/magento setup:di:compile && \
  bin/phpunit -c dev/tests/integration/phpunit.xml

Execution time is the biggest difference from unit tests: a single integration test with database access typically takes 1 to 5 seconds. Across a hundred tests, that adds up to several minutes. The recommended workflow: run individual tests via --filter during development, run the entire suite in the CI pipeline. The TESTS_CLEANUP=used option ensures the test database is only rebuilt when the installation configuration has changed.

8. Common errors and how to fix them

The most common error when setting this up for the first time: "Could not connect to the database". The cause is almost always an incorrect install-config-mysql.php, either wrong credentials, the wrong hostname (localhost on the host system, db inside the container) or a test database that does not exist. The fix: test the database connection inside the container explicitly with bin/cli mysql -h db -u magento -p magento_test.

A second common error: "Class not found" for factory or proxy classes. This happens when setup:di:compile has not been run or the generated code directory is empty. Magento integration tests need the compiled DI artifacts, even in developer mode. A third error: tests interfering with each other because @magentoDbIsolation is missing. This shows up as non-deterministic failures that only occur when tests run in a particular order.

9. Unit vs. integration tests: what to test when

Unit tests and integration tests complement each other, they do not replace one another. The decision on which type to use depends on what you want to prove. Unit tests prove that a single class produces the correct result for defined inputs, fast, isolated, without database access. Integration tests prove that the system works as a whole: repositories actually write to the database, events are actually fired, plugins engage in the correct order.

Criterion Unit test Integration test When to use
Speed Milliseconds 1 to 5 seconds Unit tests for fast feedback
Database access None (mocked) Real test database Integration for DB logic
Plugin behavior Not testable Fully testable Integration for plugins
Isolation Fully isolated Transaction rollback @magentoDbIsolation enabled
Setup effort Minimal High, one time Worth it from the first module

The recommended split for Magento projects: cover all calculation and transformation logic in classes that are testable without a DI container with unit tests. Prove repository operations, observers, plugins and complex service chains with integration tests. Cover controller behavior and template output with MFTF (functional tests) or Playwright. A realistic distribution for a mid-sized module: 60% unit tests, 30% integration tests, 10% functional tests.

Mironsoft

Magento 2 development, testing and quality assurance

Building Magento modules with full test coverage?

We set up the integration test environment for your Magento project, write the first tests for your critical modules and integrate PHPUnit into the CI/CD pipeline, so every change is validated automatically.

Setup & configuration

Set up phpunit.xml, the test database and the bootstrap for your Docker environment

Test implementation

Write integration tests for repositories, services and plugins

CI integration

Integrate PHPUnit into GitHub Actions or GitLab CI with test-report artifacts

10. Summary

Setting up Magento 2 integration tests with PHPUnit requires a one-time effort, but it pays off with every subsequent development cycle. The core components are: a correct phpunit.xml with its own <testsuite> block, an install-config-mysql.php with test database credentials, test classes under dev/tests/integration/testsuite/Vendor/Module/, and the consistent use of @magentoDbIsolation enabled. Run inside the Docker container via bin/phpunit, tests execute in the same environment as production code.

The most important insight: integration tests are not a luxury, they are insurance. Anyone writing a plugin that changes repository behavior can only prove with an integration test that the plugin works correctly together with all other plugins, the ORM and the database. Unit tests cannot deliver that proof. The investment in setting up the test environment is a one-time cost, the benefit comes back with every refactoring, every extension and every Magento version upgrade.

Magento Integration Tests: The Key Points at a Glance

Configuration

phpunit.xml with its own testsuite block and install-config-mysql.php with test database credentials, never the production DB.

Database isolation

@magentoDbIsolation enabled rolls back all DB operations after the test, making tests deterministic and independent of execution order.

ObjectManager

Explicitly allowed in tests: Bootstrap::getObjectManager() returns the full DI container, real instances instead of mocks.

Execution

Run bin/phpunit inside the Docker container. Before the first run, bin/magento setup:di:compile, generated code is mandatory for tests.

11. FAQ: Magento Integration Tests with PHPUnit

1Do I need a separate test database?
Yes, absolutely. Magento creates its own test database on the first test run. Never configure the production or development database as the test database.
2Why does the test fail with "Class not found"?
Factory and proxy classes need bin/magento setup:di:compile. After every class or DI change, di:compile must be run again.
3Am I allowed to use the ObjectManager in tests?
Yes, in integration tests Bootstrap::getObjectManager() is the recommended tool. In production code it is forbidden, in test code it is allowed.
4What does @magentoAppIsolation enabled do?
Resets the DI container after the test. More expensive than DB isolation, but necessary when cached singletons affect tests.
5Can I run tests locally without Docker?
Technically yes, but not recommended. Inside the Docker container, DB, PHP version and extensions are identical to the CI pipeline.
6How long do integration tests take?
1 to 5 seconds per test. Bootstrap time about 10 to 30 seconds. 50 tests: 3 to 10 minutes. Use --filter during development.
7How do I set configuration values for tests?
@magentoConfigFixture current_store path/to/config value, applies for the test, reset afterward. Global scope with @magentoConfigFixture global.
8Are Elasticsearch operations rolled back?
No. Only MySQL transactions. Elasticsearch, Redis and filesystem changes persist, clean up manually or use @magentoAppIsolation.
9How do I test a plugin with an integration test?
After di:compile, the plugin is automatically active. Fetch the original interface via the ObjectManager and call it, the interceptor engages transparently.
10Integration tests in a GitHub Actions pipeline?
Start the Docker Compose stack, install Magento, then run bin/phpunit with the integration phpunit.xml. Save a JUnit XML artifact for the GitHub test reporter.