Securing SearchCriteria, Collections and EAV Access in Tests
AI generated
@test
assert
Magento · PHPUnit · SearchCriteria · EAV · Collections
SearchCriteria, Collections
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.

17 min read SearchCriteria · Collections · EAV · Repository · Unit + Integration Magento 2.4.x · PHPUnit 10/11 · PHP 8.4

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.

9. FAQ: SearchCriteria, Collections and EAV in Tests

1Test SearchCriteria in unit tests?
SQL filter correctness: only in the integration test. Service logic that processes repository results: unit test with a repository mock.
2Mock SearchCriteriaBuilder?
addFilter()->willReturnSelf(), create() returns a SearchCriteria mock. The repository mock returns a predefined SearchResults on every getList() call.
3Why are collections always integration tests?
Strong DB dependencies through SQL generation, EAV joins and the resource model. Not meaningfully simulated by mocks.
4EAV attributes in unit tests?
Configure a product mock with getData('attribute_code') = test value. This tests the processing logic without involving the EAV system.
5What is @magentoDbIsolation?
Wraps every integration test in a database transaction that is rolled back at the end. No manual cleanup needed. Default: enabled.
6Structuring test fixtures for Magento?
Minimal, self-contained, created via real Magento APIs (not SQL). File name reflects content: active_products.php.
7Does every repository need an integration test?
Standard CRUD delegation: no dedicated test needed. Custom joins, filters, aggregation: integration test recommended.
8Running integration tests in Docker?
bin/magento dev:tests:run integration or vendor/bin/phpunit dev/tests/integration/phpunit.xml inside the PHP container. Configure a dedicated test database via install-config-mysql.php.
9Does mocking EAV collections make sense?
No, complex internal EAV state cannot be meaningfully simulated. An integration test is simpler to write and more reliable.
10Testing custom EAV attributes?
In the integration test: create the attribute, save a product with the attribute value, load it and verify the value. Ensures EAV schema, storage and loading work together.