with PHPUnit tests
A new attribute or a changed attribute set can silently break existing product data or frontend logic. Testing attribute setup scripts and the logic that depends on them catches such regressions before deployment instead of after.
Table of Contents
- 1. Why EAV changes carry a special testing risk
- 2. Verifying an attribute setup script itself with an integration test
- 3. Securing assignment to attribute sets and groups
- 4. Protecting existing product data from destructive attribute changes
- 5. Testing frontend logic that reads attribute values in isolation
- 6. Checking the attribute set structure as a whole against an expected reference
- 7. Including the impact on indexers and search filters
- 8. Testing data patches for backward compatibility and idempotency
- 9. A reusable checklist for attribute changes
- 10. Summary
- 11. FAQ
1. Why EAV changes carry a special testing risk
The Entity-Attribute-Value model makes Magento flexible, but also fragile against careless changes. A new attribute does not land in a single column, but in its own EAV table, gets attached to one or more attribute sets, and depending on its 'scope' can suddenly be stored per store view instead of globally. A single misconfigured data patch can cause an attribute that used to be global to suddenly hold different values per store view, even though that was never intended.
It gets even riskier when an existing attribute is changed, for example switching the input type from 'text' to 'select'. Existing product data stored as free text no longer matches the new options, and frontend code that outputs the value directly suddenly shows an internal option ID instead of a readable label. Without tests, this often only surfaces once a customer reports a broken product page.
2. Verifying an attribute setup script itself with an integration test
The most direct way to secure an attribute setup script is an integration test that actually runs the data patch (or checks its already-applied effect) and then queries the resulting attribute definition via ProductAttributeRepositoryInterface. This verifies that the attribute actually arrived in the system with the correct code, input type, and scope, not merely that the setup script ran without error.
Checking the scope is especially important, because an incorrectly set scope is the most common cause of later surprises. A test that explicitly checks getIsGlobal() against the expected value immediately catches cases where a developer accidentally chose 'Store View' instead of 'Global', a mistake that is easy to miss in the admin panel but has massive consequences for data consistency.
<?php
declare(strict_types=1);
namespace Mironsoft\ProductAttributes\Test\Integration\Setup\Patch\Data;
use Magento\Catalog\Api\ProductAttributeRepositoryInterface;
use Magento\Eav\Model\Entity\Attribute\ScopedAttributeInterface;
use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\TestCase;
class AddMaterialAttributeTest extends TestCase
{
public function testMaterialAttributeIsCreatedWithExpectedProperties(): void
{
$objectManager = Bootstrap::getObjectManager();
/** @var ProductAttributeRepositoryInterface $attributeRepository */
$attributeRepository = $objectManager->create(ProductAttributeRepositoryInterface::class);
$attribute = $attributeRepository->get('material');
self::assertSame('select', $attribute->getFrontendInput());
self::assertSame(
ScopedAttributeInterface::SCOPE_GLOBAL,
(int) $attribute->getScope() !== null ? $attribute->getIsGlobal() : null
);
self::assertFalse((bool) $attribute->getIsRequired());
self::assertTrue((bool) $attribute->getIsVisibleOnFront());
}
}
3. Securing assignment to attribute sets and groups
An attribute is of little use if it exists but is not assigned to the correct attribute sets. Especially in shops with many product types, for example apparel, electronics, and accessories each with their own attribute sets, it is easy to forget one of the sets when creating a new attribute. A test that checks the assignment via AttributeSetRepositoryInterface and the associated group makes this gap visible immediately, instead of only discovering it once an editor notices the attribute is missing in the admin panel.
It is worth checking not just membership in the set, but also which attribute group the attribute lands in. An attribute that accidentally ends up in the 'General' group instead of 'Technical Data' is technically assigned correctly, but causes confusion in day-to-day editorial work. A test can make this expectation explicit and thereby also document how the attribute set is supposed to be structured.
<?php
declare(strict_types=1);
namespace Mironsoft\ProductAttributes\Test\Integration\Setup\Patch\Data;
use Magento\Catalog\Model\ResourceModel\Eav\Attribute as CatalogAttribute;
use Magento\Eav\Model\Config as EavConfig;
use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\TestCase;
class MaterialAttributeSetAssignmentTest extends TestCase
{
public function testMaterialIsAssignedToApparelAttributeSet(): void
{
$objectManager = Bootstrap::getObjectManager();
/** @var EavConfig $eavConfig */
$eavConfig = $objectManager->create(EavConfig::class);
/** @var CatalogAttribute $attribute */
$attribute = $eavConfig->getAttribute('catalog_product', 'material');
$attributeSetInfo = $attribute->getAttributeSetInfo();
$apparelSetId = $this->resolveAttributeSetId('Apparel');
self::assertArrayHasKey($apparelSetId, $attributeSetInfo);
self::assertSame('Technical Data', $attributeSetInfo[$apparelSetId]['group_id'] !== null
? $this->resolveGroupName($attributeSetInfo[$apparelSetId]['group_id'])
: null);
}
private function resolveAttributeSetId(string $name): int
{
// Helper implementation resolves the attribute set id by name.
return 4;
}
private function resolveGroupName(int $groupId): string
{
// Helper implementation resolves the group name by id.
return 'Technical Data';
}
}
4. Protecting existing product data from destructive attribute changes
The riskiest category of change is not creating new attributes, but changing existing ones. Switching the input type from 'text' to 'select', deleting an option value, or changing 'multiselect' to 'select' can render existing product data useless. A good test simulates exactly this case: it first creates a product with the old attribute value, then runs the change (or checks its already-applied state), and verifies that the old data was either correctly migrated or at least not silently lost.
This matters especially for data patches that rename or delete option values. A test that creates a product with the affected option value before the change and checks after the change whether the product value can still be resolved catches exactly the cases where an editor cleaning up attribute options accidentally removes values that are still in use.
<?php
declare(strict_types=1);
namespace Mironsoft\ProductAttributes\Test\Integration\Setup\Patch\Data;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\TestCase;
/**
* @magentoDataFixture Magento/Catalog/_files/product_simple.php
*/
class MaterialOptionMigrationTest extends TestCase
{
public function testExistingProductKeepsResolvableMaterialValue(): void
{
$objectManager = Bootstrap::getObjectManager();
/** @var ProductRepositoryInterface $productRepository */
$productRepository = $objectManager->create(ProductRepositoryInterface::class);
$product = $productRepository->get('simple');
$materialValue = $product->getData('material');
self::assertNotNull($materialValue, 'Material value must not have been silently wiped.');
self::assertNotSame('', (string) $materialValue);
}
}
5. Testing frontend logic that reads attribute values in isolation
Beyond the setup itself, it is worth testing every class that reads a specific EAV attribute value and derives display or business logic from it, for example a view model that translates a 'material' attribute into a readable badge text. A unit test with a mocked ProductInterface covers how the class behaves when the attribute value is present, empty, or unexpected, for example a numeric option ID instead of a text label.
The case of an empty or null attribute value is often forgotten in practice, but it is real: not every product necessarily has every attribute filled in, especially after an attribute set change, when existing products have not yet had the new attribute maintained. A test that explicitly simulates an empty value and expects a sensible fallback prevents 'N/A' or an empty badge from suddenly appearing in the frontend.
<?php
declare(strict_types=1);
namespace Mironsoft\ProductAttributes\Test\Unit\ViewModel;
use Magento\Catalog\Api\Data\ProductInterface;
use Mironsoft\ProductAttributes\ViewModel\MaterialBadge;
use PHPUnit\Framework\TestCase;
class MaterialBadgeTest extends TestCase
{
public function testReturnsReadableLabelWhenMaterialIsSet(): void
{
$productMock = $this->createMock(ProductInterface::class);
$productMock->method('getData')->with('material')->willReturn('cotton');
$viewModel = new MaterialBadge();
self::assertSame('Cotton', $viewModel->getBadgeText($productMock));
}
public function testReturnsFallbackWhenMaterialIsEmpty(): void
{
$productMock = $this->createMock(ProductInterface::class);
$productMock->method('getData')->with('material')->willReturn(null);
$viewModel = new MaterialBadge();
self::assertSame('', $viewModel->getBadgeText($productMock));
}
}
6. Checking the attribute set structure as a whole against an expected reference
In larger shops with many attribute sets, it pays off to have a test that compares the complete structure of an attribute set, meaning all assigned attributes together with their groups, against an expected reference list. This catches cases where a developer accidentally removes an existing attribute from a set during refactoring without intending to, for example due to a faulty merge in the data patch.
Such a structure test is deliberately coarse: it does not check every detail, but compares the set of attribute codes in the set against an expected set. That makes it robust against small, intentional changes to individual attributes, but sensitive to accidentally adding or removing whole attributes, which in practice is the far more common and damaging mistake.
<?php
declare(strict_types=1);
namespace Mironsoft\ProductAttributes\Test\Integration\Setup\Patch\Data;
use Magento\Eav\Model\Config as EavConfig;
use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\TestCase;
class ApparelAttributeSetStructureTest extends TestCase
{
public function testApparelAttributeSetContainsExpectedAttributes(): void
{
$objectManager = Bootstrap::getObjectManager();
/** @var EavConfig $eavConfig */
$eavConfig = $objectManager->create(EavConfig::class);
$expectedAttributeCodes = [
'name', 'sku', 'price', 'material', 'size', 'color', 'season',
];
$entityType = $eavConfig->getEntityType('catalog_product');
$actualAttributeCodes = array_map(
static fn ($attribute) => $attribute->getAttributeCode(),
$eavConfig->getEntityAttributes($entityType, null)
);
foreach ($expectedAttributeCodes as $code) {
self::assertContains($code, $actualAttributeCodes, "Missing expected attribute: {$code}");
}
}
}
7. Including the impact on indexers and search filters
EAV attributes with layered navigation filtering enabled or with search index relevance have an effect that goes beyond the pure product data model. If an attribute is switched from 'Use in Layered Navigation: Yes' to 'No', it disappears from the filter, but existing code that explicitly looks for that filter suddenly no longer finds it. A test should therefore also check whether the relevant indexer flags of the attribute, for example getIsFilterable() and getIsSearchable(), match the expected state.
This check should deliberately be kept separate from an actual reindex run. It is not about whether the indexer works correctly, but about whether the attribute configuration itself sets the correct flags that the indexer then uses as input. A separate test for the actual indexer logic is its own topic with its own tools, see the article on testing indexer classes in isolation.
8. Testing data patches for backward compatibility and idempotency
An often overlooked aspect of attribute setup scripts is idempotency: a data patch can be executed more than once in some deployment scenarios, for example when a module is installed in different environments with a slightly different execution state. A patch that throws an error on the second run because it tries to create an already existing attribute again can block an entire deployment.
A test that runs the patch object twice in a row and expects no error on the second run catches exactly this risk. In practice this usually means the setup script checks whether an attribute already exists before creating it, instead of blindly relying on addAttribute(). This test is an important safety net especially for complex multi-module deployments with several dependencies.
<?php
declare(strict_types=1);
namespace Mironsoft\ProductAttributes\Test\Integration\Setup\Patch\Data;
use Magento\Framework\Setup\Patch\DataPatchInterface;
use Magento\TestFramework\Helper\Bootstrap;
use Mironsoft\ProductAttributes\Setup\Patch\Data\AddMaterialAttribute;
use PHPUnit\Framework\TestCase;
class AddMaterialAttributeIdempotencyTest extends TestCase
{
public function testPatchCanBeAppliedTwiceWithoutError(): void
{
$objectManager = Bootstrap::getObjectManager();
/** @var AddMaterialAttribute $patch */
$patch = $objectManager->create(AddMaterialAttribute::class);
self::assertInstanceOf(DataPatchInterface::class, $patch);
$patch->apply();
$patch->apply();
self::assertTrue(true, 'No exception was thrown on the second apply() call.');
}
}
9. A reusable checklist for attribute changes
From the previous examples, a reusable checklist can be derived that fits any new attribute setup script: does the attribute exist with the correct input type and scope, is it assigned to the expected attribute sets and groups, does existing product data survive changes or get correctly migrated, does dependent frontend logic behave correctly with empty values, and is the patch idempotent.
This checklist translates directly into a test class per attribute setup script, with one test method per point. The result is a test class that not only checks technical correctness, but at the same time serves as readable documentation of what the team deliberately decided when creating this attribute, a value that goes far beyond mere error prevention.
| What to check | Test level | Typical bug without a test | Recommended assertion |
|---|---|---|---|
| Attribute exists with correct scope | Integration test | Scope wrongly set to Store View instead of Global | assertSame() on getIsGlobal() |
| Attribute set assignment | Integration test | Attribute missing from one of the product type sets | assertArrayHasKey() in the attribute set info array |
| Existing product data stays usable | Integration test with fixture | Option value no longer resolvable after renaming | assertNotNull() on the migrated value |
| Frontend logic with empty attribute value | Unit test with mocked product | Empty attribute leads to a broken badge or layout | assertSame() on the expected fallback text |
| Idempotency of the data patch | Integration test, patch applied twice | Second run throws an exception and blocks deployment | No error on the second apply() call |
Mironsoft
Test automation, Magento quality assurance, and CI integration
Tests that catch real bugs instead of just turning green?
We review existing PHPUnit suites for implementation-detail tests, flaky tests, and missing coverage at critical points, then build a test strategy that provides real confidence with every Magento update.
Test Audit
Reviewing existing suites for mocking antipatterns and blind spots.
Test Strategy
Meaningfully combining unit, integration, and MFTF tests for Magento projects.
CI Integration
Setting up fast, reliable test runs in GitLab CI or GitHub Actions.
10. Summary
EAV Attribute Testing: Key Takeaways
Core idea
Deliberately test attribute setup, attribute set assignment, existing product data, and frontend fallbacks.
Biggest risk
Input type or scope changes on existing attributes silently break legacy data.
Tool
Integration tests with ProductAttributeRepositoryInterface and EavConfig for the real structure.
Extra safeguard
An idempotency test ensures data patches don't block a second deployment.