← Back to blog

Magento 2 Integration Tests: Setup, Fixtures and Tips

Rodrigo Biazzi

Unit tests check a class in isolation. Integration tests check that your code works with the real Magento underneath: dependency injection, plugins, configuration, the database and the indexers. They are slower and need more setup, but they catch the bugs that unit tests cannot, like a plugin that never fires because of a typo in di.xml or a repository that saves an attribute to the wrong store.

This article covers the setup of the Magento integration test framework in 2.4.8, how to write tests with fixtures, and how to keep run times reasonable.

How the framework works

The integration framework lives in dev/tests/integration. When you run it, it installs a complete Magento into a separate database using the settings you provide, then boots the application for each test. Tests get a real object manager through Magento\TestFramework\Helper\Bootstrap, and fixtures create the data they need.

Because of that installation step, the test database must be separate from your development database. The framework will create tables, insert data and, depending on settings, drop everything and reinstall.

Setup

A dedicated database

Create an empty database for tests. In a Docker environment like the one in the Warden article, create it in the same database container:

CREATE DATABASE magento_integration_tests;
GRANT ALL ON magento_integration_tests.* TO 'magento'@'%';

install-config-mysql.php

Copy the template and edit it:

cd dev/tests/integration
cp etc/install-config-mysql.php.dist etc/install-config-mysql.php
cp phpunit.xml.dist phpunit.xml

The config file returns the same options you would pass to setup:install. A version for a container stack with OpenSearch and RabbitMQ:

<?php
return [
    'db-host' => 'db',
    'db-user' => 'magento',
    'db-password' => 'magento',
    'db-name' => 'magento_integration_tests',
    'db-prefix' => '',
    'backend-frontname' => 'backend',
    'search-engine' => 'opensearch',
    'opensearch-host' => 'opensearch',
    'opensearch-port' => 9200,
    'admin-user' => \Magento\TestFramework\Bootstrap::ADMIN_NAME,
    'admin-password' => \Magento\TestFramework\Bootstrap::ADMIN_PASSWORD,
    'admin-email' => \Magento\TestFramework\Bootstrap::ADMIN_EMAIL,
    'admin-firstname' => \Magento\TestFramework\Bootstrap::ADMIN_FIRSTNAME,
    'admin-lastname' => \Magento\TestFramework\Bootstrap::ADMIN_LASTNAME,
    'amqp-host' => 'rabbitmq',
    'amqp-port' => '5672',
    'amqp-user' => 'guest',
    'amqp-password' => 'guest',
];

If the project does not use RabbitMQ, remove the AMQP keys. Use a different OpenSearch index prefix from development if both share the same OpenSearch service, so tests do not overwrite your development indexes.

phpunit.xml

In your copy of phpunit.xml, two settings matter most:

  • TESTS_CLEANUP: when enabled, the framework reinstalls Magento on every run. Disable it locally so the installation is reused and runs start in seconds instead of minutes. Enable it in CI or after changing modules or schema.
  • The test suite directories: point them at your own Test/Integration folders instead of the whole core suite.
<testsuites>
    <testsuite name="Project">
        <directory>../../../app/code/Vendor/*/Test/Integration</directory>
    </testsuite>
</testsuites>
<php>
    <const name="TESTS_CLEANUP" value="disabled"/>
</php>

What deserves an integration test

Integration tests cost more to write and to run, so spend them where the framework is part of the risk:

  • Plugins and observers: does the plugin actually run when the core method is called through the repository?
  • Repositories and resource models: does the custom attribute survive a save and load in a different store view?
  • Configuration paths and scopes, including defaults from config.xml
  • Extension attributes, which depend on XML declarations and plugins working together
  • Totals collectors, quote and order flows, where several modules interact

If a test does not need any of that, it is probably a unit test in disguise and will run much faster as one.

Running tests

Run PHPUnit from the integration folder so relative paths in the configuration resolve:

cd dev/tests/integration
../../../vendor/bin/phpunit -c phpunit.xml
../../../vendor/bin/phpunit -c phpunit.xml --filter FreeShippingThresholdTest

The first run installs Magento and can take a while. Generated code and caches for the test installation go under dev/tests/integration/tmp. If something behaves strangely after changing di.xml or adding a module, delete the sandbox folder in tmp and run again with cleanup enabled.

Writing a test with annotations

Integration tests live in Test/Integration inside the module. This one checks a free shipping service against real configuration, using the store scope and the real DI wiring:

<?php
declare(strict_types=1);

namespace Vendor\Shipping\Test\Integration\Model;

use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\TestCase;
use Vendor\Shipping\Model\FreeShippingThreshold;

class FreeShippingThresholdTest extends TestCase
{
    private FreeShippingThreshold $model;

    protected function setUp(): void
    {
        $this->model = Bootstrap::getObjectManager()->get(FreeShippingThreshold::class);
    }

    /**
     * @magentoConfigFixture current_store vendor_shipping/general/free_threshold 200
     */
    public function testRemainingUsesStoreConfig(): void
    {
        $this->assertSame(50.0, $this->model->getRemaining(150.0));
    }

    /**
     * @magentoConfigFixture current_store vendor_shipping/general/free_threshold 0
     */
    public function testDisabledWhenZero(): void
    {
        $this->assertSame(0.0, $this->model->getRemaining(150.0));
    }
}

The config fixture writes the value for the duration of the test and restores it afterwards. current_store refers to the default store of the test installation. The same class has a pure unit test in the PHPUnit unit test article. The integration test adds confidence that the config path, scope and DI are wired correctly.

Data fixtures with attributes

Recent 2.4 releases added PHP attributes for fixtures, and new core tests use them. Data fixtures are classes that create entities through service contracts and clean them up afterwards. A test that needs a product:

<?php
declare(strict_types=1);

namespace Vendor\Catalog\Test\Integration\Model;

use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Test\Fixture\Product as ProductFixture;
use Magento\TestFramework\Fixture\DataFixture;
use Magento\TestFramework\Fixture\DataFixtureStorageManager;
use Magento\TestFramework\Fixture\DbIsolation;
use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\TestCase;

class ProductLabelTest extends TestCase
{
    #[
        DbIsolation(true),
        DataFixture(ProductFixture::class, ['sku' => 'label-test', 'price' => 49.9], 'product')
    ]
    public function testProductIsSavedWithPrice(): void
    {
        $fixture = DataFixtureStorageManager::getStorage()->get('product');
        $repository = Bootstrap::getObjectManager()->get(ProductRepositoryInterface::class);

        $product = $repository->get($fixture->getSku());

        $this->assertSame(49.9, (float) $product->getPrice());
    }
}

The third argument to DataFixture is an alias, used to read the created entity from the fixture storage. Core modules ship fixture classes in their Test/Fixture folders for products, categories, customers, carts and orders. Look at those before writing your own, and when you do write one, implement the same revertible fixture interface so cleanup happens automatically.

Older tests use file based fixtures with @magentoDataFixture Magento/Catalog/_files/product_simple.php. They still work, but they are harder to parameterize. Keep one style per test class.

Isolation

Two isolation levels exist, available both as annotations and attributes:

  • DB isolation runs the test inside a transaction and rolls it back. Data created by the test disappears. Use it by default for anything that writes.
  • App isolation resets application state after the test, including the object manager instances. It is needed when a test changes shared singletons, the current store or the area. It is expensive, so use it only when a test proves it needs it.

Some code cannot run inside a transaction, for example code that triggers DDL or relies on a commit callback, such as certain indexer operations. Those tests need DB isolation disabled and explicit cleanup.

Areas and controllers

By default tests run in the global area. Use @magentoAppArea frontend or adminhtml when the code depends on area specific DI or plugins. For controller tests, extend Magento\TestFramework\TestCase\AbstractController, which gives you dispatch() and helpers to assert on the response and redirects. For admin controllers, AbstractBackendController logs in an admin user. GraphQL and REST endpoints have their own API functional test framework in dev/tests/api-functional, which needs a running web server.

Keeping it fast

  • Run only your test suites. The full core suite takes hours.
  • Keep TESTS_CLEANUP disabled locally and enable it when the schema changes.
  • Prefer DB isolation over app isolation.
  • Test logic in unit tests and use integration tests for the wiring and the database. A good split is many unit tests and a smaller number of focused integration tests.
  • In CI, run integration tests in a separate job with database and OpenSearch service containers, so a slow job does not block the fast checks. A pipeline layout is in the GitHub Actions article.

FAQ

Why does the first integration test run take so long?

The framework installs a full Magento into the test database before running anything. With TESTS_CLEANUP disabled, later runs reuse that installation and start much faster.

Can integration tests use my development database?

They should not. The framework creates and modifies data freely and may reinstall the schema. Always point install-config-mysql.php at a separate, empty database.

What is the difference between DbIsolation and AppIsolation?

DbIsolation wraps the test in a transaction that is rolled back, so data changes disappear. AppIsolation resets the application state, such as the object manager and registry, after the test. Use AppIsolation only when needed, since it is slower.

Should I use annotations or PHP attributes for fixtures?

Recent 2.4 releases support PHP attributes such as DataFixture and DbIsolation, and new core tests use them. Annotations still work. Pick one style per test class and keep it consistent.

I am available for freelance Magento and Adobe Commerce work, including test suites for custom modules. If you need help with this, get in touch.

Related articles