@magentoDataFixture, rollback and fixture classes
Without clean fixtures, integration tests are fragile: tests create test data that affects the next test, rollbacks fail and the fixture script sits at a different path than expected. The Magento Test Framework offers a sophisticated fixture system, provided you understand the mechanisms behind it.
Table of Contents
- 1. What are Magento test fixtures?
- 2. The @magentoDataFixture annotation in detail
- 3. Writing a fixture script
- 4. Rollback scripts: why and how
- 5. Fixture classes with DataFixtureInterface
- 6. Path resolution and fixture location
- 7. Fixture dependencies and execution order
- 8. Common pitfalls and how to fix them
- 9. Fixture approaches compared
- 10. Summary
- 11. FAQ
1. What are Magento test fixtures?
A test fixture is a defined, known database state that is established before a test so that the test runs under controlled conditions. In Magento, this specifically means: a fixture script creates products, categories, customer data or configuration values that the test needs. After the test, this data is either deleted explicitly via a rollback script or, if @magentoDbIsolation enabled is active, rolled back via a transaction.
The decisive difference from setUp() methods: fixtures can be shared across tests. If ten tests need the same product, the fixture only has to run once when it is annotated at class level. The Magento Test Framework distinguishes between fixture scripts (simple PHP files that are executed directly) and fixture classes (implementations of DataFixtureInterface that can use the DI container). As of Magento 2.4.4, fixture classes are the recommended approach because they are type-safe, testable and reusable.
2. The @magentoDataFixture annotation in detail
The @magentoDataFixture annotation accepts either a path to a PHP script or the fully qualified class name of a fixture class. Paths are resolved relative to the Magento root or the test suite base, and this exact resolution is one of the most common sources of errors. If the annotation is set at class level, the fixture runs once for all test methods in the class. At method level, it runs once per test method. Both variants can be combined: a class-level fixture sets up base data, and a method-level fixture adds method-specific data.
As of Magento 2.4.4, the preferred syntax for fixture classes is the PHP 8 attribute syntax: #[DataFixture(ProductFixture::class, ['sku' => 'test-001'], 'product')]. The third argument is an alias through which the test can access the object returned by the fixture without having to query the database again. This fixture data is made accessible to the test via $this->fixtures->get('product'), an elegant pattern that makes tests more readable and faster.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Test\Integration;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\TestFramework\Fixture\DataFixture;
use Magento\TestFramework\Fixture\DataFixtureStorage;
use Magento\TestFramework\Fixture\DataFixtureStorageManager;
use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\TestCase;
// Built-in Magento fixture classes
use Magento\Catalog\Test\Fixture\Product as ProductFixture;
use Magento\Catalog\Test\Fixture\Category as CategoryFixture;
/**
* Tests using the modern DataFixture attribute syntax (Magento 2.4.4+).
*/
class ProductWithCategoryTest extends TestCase
{
private DataFixtureStorage $fixtures;
protected function setUp(): void
{
parent::setUp();
$this->fixtures = Bootstrap::getObjectManager()
->get(DataFixtureStorageManager::class)
->getStorage();
}
/**
* @test
* @magentoDbIsolation enabled
*/
#[DataFixture(CategoryFixture::class, ['name' => 'Test Category'], 'cat')]
#[DataFixture(ProductFixture::class, ['sku' => 'fixture-test-001', 'category_ids' => ['$cat.id$']], 'product')]
public function productIsAssignedToCategory(): void
{
// Retrieve fixture objects directly, no DB query needed
$category = $this->fixtures->get('cat');
$product = $this->fixtures->get('product');
$this->assertNotEmpty($category->getId());
$this->assertContains(
(int) $category->getId(),
$product->getCategoryIds()
);
}
}
3. Writing a fixture script
A classic fixture script is a plain PHP file with no namespace that is executed directly in the test environment. The Magento DI container is available via Bootstrap::getObjectManager(). Typically, the script creates records and stores the relevant IDs in the registry or a global variable so that the test can access them. This pattern shows up in many Magento core tests and works reliably, but it has one drawback: communication between fixture and test happens through global state.
By convention, the fixture script lives in the same directory as the test class, inside a _files/ subfolder. Alternatively, the path can be given as an absolute path or relative to the test suite root. Important: the fixture script does not have to contain only the setup code, for explicit rollbacks you need a second script with the suffix _rollback.php that removes the data created by the fixture again. This rollback script is automatically executed by the framework after the test if @magentoDbIsolation is disabled.
<?php
// dev/tests/integration/testsuite/Mironsoft/Catalog/Test/Integration/_files/simple_product.php
// Fixture script: creates a simple product for integration tests
use Magento\Catalog\Api\Data\ProductInterfaceFactory;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\TestFramework\Helper\Bootstrap;
$objectManager = Bootstrap::getObjectManager();
$productFactory = $objectManager->get(ProductInterfaceFactory::class);
$productRepository = $objectManager->get(ProductRepositoryInterface::class);
$product = $productFactory->create();
$product
->setSku('mironsoft-fixture-product')
->setName('Fixture Test Product')
->setTypeId('simple')
->setAttributeSetId(4)
->setPrice(29.99)
->setStatus(1)
->setVisibility(4)
->setStockData(['qty' => 100, 'is_in_stock' => 1]);
$productRepository->save($product);
4. Rollback scripts: why and how
Rollback scripts are the explicit counterpart to fixture scripts and are needed when @magentoDbIsolation disabled is set. This is the case for tests that explicitly test across transaction boundaries, or when fixtures are set at class level and should not be rolled back for every single test method. The rollback script carries the same name as the fixture script with the suffix _rollback.php and is called automatically by the framework.
A common mistake: the rollback script tries to delete data that has already been rolled back by the transaction rollback, which produces an error because the entity no longer exists. The solution is a defensive check: wrap repository operations in a try-catch block, or explicitly check whether the entity still exists before deleting it. Rollback scripts should be idempotent, running them multiple times must not produce an error.
<?php
// _files/simple_product_rollback.php
// Rollback script: removes the fixture product created by simple_product.php
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\Registry;
use Magento\TestFramework\Helper\Bootstrap;
$objectManager = Bootstrap::getObjectManager();
$registry = $objectManager->get(Registry::class);
$productRepository = $objectManager->get(ProductRepositoryInterface::class);
// Disable security check for deletion in test context
$registry->unregister('isSecureArea');
$registry->register('isSecureArea', true);
try {
// Idempotent: no error if product was already removed by DB rollback
$productRepository->deleteById('mironsoft-fixture-product');
} catch (NoSuchEntityException) {
// Product already gone, rollback ran or fixture never created it
}
$registry->unregister('isSecureArea');
$registry->register('isSecureArea', false);
5. Fixture classes with DataFixtureInterface
Fixture classes implement the Magento\TestFramework\Fixture\DataFixtureInterface interface and have several advantages over fixture scripts: they can receive dependencies via constructor injection, are type-safe, can return data (for the fixtures alias mechanism) and are reusable across multiple test classes. Magento 2.4.4 introduced built-in fixture classes for all core entities, products, categories, customers, CMS pages, which can be used directly with parameters.
You write your own fixture classes for domain-specific test data. The apply() method receives an array of parameters, creates the entity and returns a DataObject that the alias in the test can reference. The framework manages the lifecycle: apply() is called before the test, and the framework handles cleanup based on DB isolation. No separate rollback scripts are needed, if @magentoDbIsolation enabled is active, the transaction takes care of cleanup.
6. Path resolution and fixture location
Path resolution for @magentoDataFixture follows a specific search order that you need to know. For a path like Mironsoft/Catalog/Test/Integration/_files/simple_product.php, the framework searches in the test suite root directory, i.e. under dev/tests/integration/testsuite/. For an absolute path with ::class syntax, the fixture class is loaded via the autoloader. A path like ../../app/code/Mironsoft/Catalog/Test/Integration/_files/simple_product.php is relative to the bootstrap directory, a frequent source of confusion.
The recommended structure always places fixture scripts under the same directory as the test class: dev/tests/integration/testsuite/Mironsoft/Catalog/Test/Integration/_files/. This keeps paths shorter, makes fixture scripts easier to find and avoids ambiguity. When using fixture classes (the modern syntax), the problem disappears entirely, since classes are resolved through the standard autoloader.
7. Fixture dependencies and execution order
When one fixture depends on another as a prerequisite, for example a product needs a category, which needs a root catalog node, the execution order has to be correct. With the script annotation, fixtures run in the order in which they are declared as annotations. The same rule applies with the attribute syntax. The framework offers no explicit dependency mechanism between fixtures, dependencies have to be resolved through ordering or through a composite fixture.
A cleaner pattern for complex fixture dependencies: a dedicated fixture script or a fixture class that creates all the necessary prerequisites in the correct order. This prevents subtle bugs caused by annotation ordering and centralizes the fixture logic in a single place. With alias syntax, a fixture can reference the output of a previous fixture: ['category_id' => '$cat.id$'] references the id field of the fixture with the alias cat.
8. Common pitfalls and how to fix them
Pitfall 1: the fixture creates data, but the test cannot find it. The cause is almost always a caching problem: Magento caches repository results in the request context. A newly created product may not be visible in the repository's cache if it was previously cached for a different entity (e.g. an empty result). Solution: explicitly invalidate the repository's cache or request a fresh repository instance.
Pitfall 2: tests pass individually but fail as part of the suite. This points to missing isolation, a previous test has left behind data or state that interferes with this test. Solution: make sure @magentoDbIsolation enabled is set and check whether the test accesses external services (Elasticsearch, Redis) that are not rolled back by a transaction. Pitfall 3: the rollback script fails with "Entity not found". The rollback script has to be defensive, always wrap delete operations in try-catch.
| Fixture Type | Syntax | Rollback | Recommendation |
|---|---|---|---|
| Script fixture | @magentoDataFixture path/to/file.php |
Manual via _rollback.php | Legacy tests, simple scenarios |
| Fixture class, old | @magentoDataFixture Vendor\...\Fixture |
Automatic via DB isolation | Magento 2.4.x without PHP 8 attributes |
| Fixture class, new | #[DataFixture(Class::class, [...], 'alias')] |
Automatic via DB isolation | Recommended from Magento 2.4.4 |
| setUp() method | PHP code in setUp() | Manual in tearDown() | Only if no fixture alternative exists |
| Core fixtures | Magento\Catalog\Test\Fixture\Product |
Automatic | Always check core fixtures first |
9. Fixture best practices summarized
Good fixtures follow the principle of minimalism: they only create the data the test genuinely needs. A fixture that creates a complete product with all attributes, stock data, price rules and category links when the test only checks the SKU unnecessarily slows down the test and makes it fragile against schema changes. Minimally sufficient fixtures are faster, clearer in intent and easier to maintain.
Mironsoft
Magento 2 testing, fixtures and quality assurance
Replace fragile fixtures with robust test data management?
We migrate your existing fixture scripts to modern DataFixture classes, resolve isolation problems and build a maintainable fixture library for your Magento project.
Fixture migration
Migrate script fixtures to type-safe DataFixture classes
Isolation analysis
Find and fix non-deterministic tests caused by isolation problems
Fixture library
Develop reusable fixture classes for your domain model
10. Summary
Magento test fixtures are the foundation of deterministic integration tests. The development is clearly heading toward fixture classes with the PHP 8 attribute syntax: type-safe, reusable, free of path resolution problems and with the elegant alias mechanism for accessing fixture data in the test. For new tests, always check first whether Magento core fixture classes already exist for the entities you need (Magento\Catalog\Test\Fixture\Product, Category, Customer, etc.), which saves considerable implementation effort.
The critical property of good fixtures is minimality and isolation: only create what the test genuinely needs, and make sure fixtures are fully cleaned up after the test. @magentoDbIsolation enabled is the simplest solution for this, fixtures inside a transaction, rollback at the end of the test. Anyone who sets fixtures at class level and disables DB isolation needs explicit rollback scripts, which must be defensive and idempotent.
Magento test fixtures, the essentials at a glance
Modern syntax
#[DataFixture(Product::class, ['sku' => 'x'], 'alias')], type-safe, reusable, no path problem. Prefer this from Magento 2.4.4 onward.
Rollback strategy
With @magentoDbIsolation enabled, the transaction handles cleanup. Without isolation, every fixture needs a matching _rollback.php.
Use core fixtures
Magento ships fixture classes for all standard entities. Always check Magento\Catalog\Test\Fixture\ first before writing your own scripts.
Minimalism principle
A fixture only creates the data the test genuinely needs. Excess fixture data slows down tests and increases coupling to the database schema.