and EAV Access in Tests
SearchCriteria queries, collection filters and EAV attributes are the three most common data access patterns in Magento, and at the same time the three most common places where tests are either missing or structured incorrectly. This article shows which test type fits which access pattern and how to secure each one correctly.
Table of Contents
- 1. Which test for which data access?
- 2. SearchCriteria in unit tests: mocking the repository
- 3. SearchCriteria in integration tests: the real database
- 4. Collections: securing filters and joins in tests
- 5. EAV access: loading and testing attributes
- 6. Test fixtures for Magento integration tests
- 7. Test strategies for Magento data access compared
- 8. Summary
- 9. FAQ
1. Which test for which data access?
Magento offers two main routes for data access: the repository pattern with SearchCriteria (service contracts, the recommended route since Magento 2.1) and direct collection queries (an older pattern, but still widespread, especially in the admin area). EAV access (Entity-Attribute-Value) is a cross-cutting topic that can happen either through repositories or directly through collections. The choice of test strategy depends on this access pattern.
For services and ViewModels that get repositories injected through interfaces, unit tests with repository mocks are the right choice: fast, no database connection, full control over return values. For the repositories themselves, and for collection queries that contain SQL joins or EAV-specific joins, integration tests with a real Magento bootstrap are required. A common mistake in Magento projects is writing unit tests for collection logic that implicitly depends on real EAV joins, and then being surprised that production behavior deviates from the test.
2. SearchCriteria in unit tests: mocking the repository
When a service or ViewModel queries data through a repository interface, the repository is the external dependency boundary. In unit tests, this repository is mocked. The mock must be configured to respond to specific SearchCriteria calls with predefined results. The correct pattern: configure the SearchCriteriaInterface mock as the return value for SearchCriteriaBuilder::create(), and set up the repository so that it returns a prepared SearchResultsInterface on every getList() call.
A common mistake is instantiating the SearchCriteriaBuilder for real in unit tests, which pulls in Magento framework dependencies. Instead, the builder should either be mocked or replaced with a test double that returns SearchCriteriaInterface instances. Since the unit test does not verify the correctness of the SQL query but rather the logic of the service that processes the result, it is correct to have the repository mock always return a predefined result list, regardless of the SearchCriteria object passed in.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Test\Unit\Model;
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Api\SearchResultsInterface;
use Mironsoft\Catalog\Api\ProductRepositoryInterface;
use Mironsoft\Catalog\Model\ActiveProductService;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Unit test for ActiveProductService.
* Repository is mocked, no database, no Magento bootstrap.
* Tests the service logic, not the SearchCriteria SQL.
*/
#[CoversClass(ActiveProductService::class)]
final class ActiveProductServiceTest extends TestCase
{
private MockObject&ProductRepositoryInterface $repositoryMock;
private ActiveProductService $service;
protected function setUp(): void
{
$this->repositoryMock = $this->createMock(ProductRepositoryInterface::class);
$searchCriteriaBuilderMock = $this->createMock(
\Magento\Framework\Api\SearchCriteriaBuilder::class
);
// Builder returns a SearchCriteria mock, we don't care about its content in unit tests
$searchCriteriaMock = $this->createMock(SearchCriteriaInterface::class);
$searchCriteriaBuilderMock->method('addFilter')->willReturnSelf();
$searchCriteriaBuilderMock->method('create')->willReturn($searchCriteriaMock);
$this->service = new ActiveProductService(
repository: $this->repositoryMock,
searchCriteriaBuilder: $searchCriteriaBuilderMock
);
}
#[Test]
public function testGetActiveProducts_WhenRepositoryReturnsItems_ReturnsFilteredList(): void
{
$productMock = $this->createConfiguredMock(
\Mironsoft\Catalog\Api\Data\ProductInterface::class,
['getId' => 1, 'getSku' => 'ACTIVE-001', 'getStatus' => 1]
);
$searchResultsMock = $this->createConfiguredMock(
SearchResultsInterface::class,
['getItems' => [$productMock], 'getTotalCount' => 1]
);
// Repository always returns our prepared results, regardless of SearchCriteria
$this->repositoryMock
->expects($this->once())
->method('getList')
->willReturn($searchResultsMock);
$result = $this->service->getActiveProducts();
self::assertCount(1, $result);
self::assertSame('ACTIVE-001', $result[0]->getSku());
}
}
3. SearchCriteria in integration tests: the real database
The correctness of a SearchCriteria query, whether the filter criteria actually deliver the correct database rows, can only be verified by an integration test against a real database. Magento integration tests use the official test bootstrap under dev/tests/integration, which starts the full Magento framework with the DI container, real repositories and a dedicated test database. The ObjectManager is available in the integration test and instantiates all objects through the real DI system.
Integration tests in Magento typically inherit from Magento\TestFramework\TestCase\AbstractController for controller tests, or directly from PHPUnit\Framework\TestCase with Magento annotations for service tests. The most important feature: @magentoDbIsolation enabled (the default) ensures that all database changes are rolled back after each test. This lets tests write test data into the database without having to worry about cleanup.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Test\Integration\Model;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Integration test for SearchCriteria-based product queries.
* Uses real Magento bootstrap and database.
* Run with: bin/magento dev:tests:run integration
*
* @magentoDbIsolation enabled
* @magentoDataFixture Mironsoft_Catalog::Test/Integration/_files/active_products.php
*/
final class ProductSearchIntegrationTest extends TestCase
{
private ProductRepositoryInterface $productRepository;
private SearchCriteriaBuilder $searchCriteriaBuilder;
protected function setUp(): void
{
$objectManager = Bootstrap::getObjectManager();
$this->productRepository = $objectManager->get(ProductRepositoryInterface::class);
$this->searchCriteriaBuilder = $objectManager->get(SearchCriteriaBuilder::class);
}
#[Test]
public function testGetList_WithStatusFilter_ReturnsOnlyActiveProducts(): void
{
$searchCriteria = $this->searchCriteriaBuilder
->addFilter('status', 1, 'eq')
->addFilter('visibility', [2, 3, 4], 'in')
->create();
$result = $this->productRepository->getList($searchCriteria);
// Fixture creates 3 active + 2 disabled products
self::assertGreaterThanOrEqual(3, $result->getTotalCount());
foreach ($result->getItems() as $product) {
self::assertEquals(1, $product->getStatus(), "Product {$product->getSku()} must be active");
}
}
}
4. Collections: securing filters and joins in tests
Magento collections are an older abstraction layer over Zend_Db queries. They offer addFieldToFilter(), addAttributeToFilter() (for EAV) and direct getSelect()->join() calls. The main problem for tests: collections have strong implicit dependencies on the database connection and the EAV system. A ProductCollection->load() without a database connection always fails. That is why collection tests are almost always integration tests.
For unit tests of code that consumes collections (rather than producing them), there is a different approach: abstract the service or ViewModel behind a repository interface and mock the repository. The collection code itself is then secured in an integration test that uses real data. This separation, unit test for consumer logic, integration test for collection query, is the recommended pattern and avoids the complexity of mocking collections.
5. EAV access: loading and testing attributes
The EAV system (Entity-Attribute-Value) is one of the most complex subsystems in Magento. Product attributes, category attributes and customer attributes are loaded through separate tables with dynamic joins. These joins are database-specific and cannot be meaningfully simulated in unit tests. EAV access therefore needs to be secured in integration tests.
For code that reads EAV attributes (for example, a ViewModel that calls $product->getData('custom_attribute')), there is nevertheless a unit test strategy: the product object is passed in as a mock with predefined getData() return values. The EAV system behind it is not tested, and that is fine, because the goal of the unit test is the ViewModel's processing logic, not the EAV system itself. The integration test then ensures that the EAV attribute is actually loaded and returns the correct values.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Test\Unit\ViewModel;
use Magento\Catalog\Api\Data\ProductInterface;
use Mironsoft\Catalog\ViewModel\ProductSpecificationViewModel;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Unit test for EAV-consuming ViewModel.
* Product is mocked, EAV system not involved.
* Tests: how the ViewModel processes EAV attribute values.
*
* Integration test (separate file) verifies the EAV attribute itself loads correctly.
*/
#[CoversClass(ProductSpecificationViewModel::class)]
final class ProductSpecificationViewModelTest extends TestCase
{
private ProductSpecificationViewModel $viewModel;
protected function setUp(): void
{
$this->viewModel = new ProductSpecificationViewModel();
}
#[Test]
public function testGetMaterial_WhenEavAttributeSet_ReturnsFormattedValue(): void
{
// Mock product with EAV attribute value pre-set
$productMock = $this->createConfiguredMock(
ProductInterface::class,
// getData() returns the raw EAV value, EAV loading is not tested here
['getData' => 'cotton_organic']
);
$result = $this->viewModel->getMaterial($productMock);
// ViewModel formats the raw value: 'cotton_organic' → 'Cotton (Organic)'
self::assertSame('Cotton (Organic)', $result);
}
#[Test]
public function testGetMaterial_WhenEavAttributeNotSet_ReturnsEmptyString(): void
{
$productMock = $this->createConfiguredMock(
ProductInterface::class,
['getData' => null]
);
$result = $this->viewModel->getMaterial($productMock);
self::assertSame('', $result);
}
}
6. Test fixtures for Magento integration tests
Test fixtures in Magento integration tests are PHP files that are included via the @magentoDataFixture annotation. They run before the test and create test data in the database. Because of @magentoDbIsolation enabled, all database changes are rolled back after the test. Fixture files should be minimal: create only the data the test actually needs, and have no dependencies on other fixtures. Well-structured fixtures are self-contained, idempotent (can be run multiple times without causing conflicts), and produce stable, predictable test data.
7. Test strategies for Magento data access compared
Choosing the right test strategy for a Magento data access pattern depends on the question: what should the test verify? The service logic that processes repository results, or the correctness of the database query itself? These two questions lead to two different test types.
| Data access | Unit test | Integration test | What is verified |
|---|---|---|---|
| Repository via interface | Mock the repository | Optional | Service logic, not SQL |
| SearchCriteria filter | Not meaningful | Real database access | Correctness of the SQL filter |
| Collection with joins | Not meaningful | Real database access | Join correctness, filter results |
| Loading an EAV attribute | Product mock with getData() | Real EAV, real DB | Attribute values and EAV join |
| Processing an EAV value | Use a product mock | Optional | Processing logic in the ViewModel |
The most common mistake in Magento projects: writing integration tests for service logic when unit tests with repository mocks would have been enough. This drives up test runtime and makes tests dependent on database state. The second most common variant: writing unit tests for collection logic, which leads to mocks that poorly reflect the real collection API and create a false sense of confidence in the tests.
Mironsoft
Magento test architecture, SearchCriteria, EAV and integration test setup
Want your Magento data access made testable?
We analyze your repositories, collections and EAV access, choose the right test strategy for each access type, and implement unit and integration tests with the correct fixtures for your Magento project.
Test audit
Analyze existing Magento tests and identify tests that are structured incorrectly
Strategy consulting
Define unit vs. integration for every data access type, build the test pyramid
Implementation
Set up tests, fixtures and integration test bootstrap for your Magento project
8. Summary
The correct test strategy for Magento data access follows a clear rule: what are we testing? If we are testing the service logic that processes repository results, unit tests with repository mocks are the right approach. If we are testing the correctness of a SearchCriteria query, a collection filter or an EAV join, we need an integration test with a real database.
The most common symptom of a poorly structured Magento test setup: integration tests for everything, because "you can never be sure how Magento works internally." The result is slow, fragile test suites that fail on every database state problem. The right answer is not more integration tests, but better abstraction: code that works through repository interfaces can be fully secured with unit tests. Only the implementation of the repositories themselves, and collection queries with database logic, need integration tests.
SearchCriteria, Collections and EAV: the key points at a glance
Repository interface
Mock the repository in unit tests. Verify service logic, not SQL. The repository itself needs an integration test.
SearchCriteria filter
Only verifiable in an integration test. Create fixtures, run real filters, check results against expected records.
EAV attributes
EAV loading: integration test. Processing EAV values: unit test with a product mock and predefined getData() return values.
Fixtures
@magentoDataFixture for integration tests. Minimal and self-contained. @magentoDbIsolation rollback cleans up automatically.