Testing Magento Indexers in Isolation
AI generated
@test
assert
PHPUnit · Magento · Indexer
Magento Indexers
tested in isolation with PHPUnit

A full reindex run is far too slow and too coarse for a test. Extracting the actual indexer logic into a separate class and calling it directly with prepared input data turns testing into a matter of milliseconds instead of minutes.

15 min read Indexer interface Reindex Batch processing

1. Why a full reindex run is not a good test

The obvious way to test a custom indexer is to call bin/magento indexer:reindex mironsoft_search_boost in a script and then check the index table. That works, but it is a poor default approach for several reasons: a full reindex run needs a working Magento installation with a database, takes anywhere from several seconds to minutes depending on catalog size, and along the way tests the entire Magento indexer infrastructure, not just your own logic.

It gets even more problematic when a test suite in a CI pipeline needs many such full reindex runs to cover different input constellations. Test runtime explodes, and a failing test initially only tells you that something in the overall indexing process is wrong, not which specific part of your own business logic caused the problem. The better path is to separate the actual calculation logic from Magento's indexer infrastructure.

2. Extracting indexer logic into its own testable class

The key to isolated indexer tests is a clean separation: the class that implements Magento\Framework\Indexer\ActionInterface delegates the actual calculation to a separate service class that has no dependency on the indexer infrastructure and instead accepts simple input data such as product IDs or attribute arrays, returning a simple result array or value object. This service class can then be tested completely without a Magento bootstrap.

In practice this means the actual 'reindex row', 'reindex list', or 'reindex full' method of the indexer class becomes a thin adapter: it fetches the raw data (for example via a repository), passes it to the service class, and writes the result into the index table. The adapter itself is trivial enough that it does not need extra testing, while the actual calculation logic in the service class is fully covered by unit tests.


<?php
declare(strict_types=1);

namespace Mironsoft\SearchBoost\Model\Indexer;

class SearchBoostCalculator
{
    /**
     * Calculates a search boost score from raw product data.
     *
     * @param array $productData Associative array with keys: price, qty, review_count
     * @return float The calculated boost score, always >= 0.
     */
    public function calculate(array $productData): float
    {
        $price = (float) ($productData['price'] ?? 0.0);
        $qty = (float) ($productData['qty'] ?? 0.0);
        $reviewCount = (int) ($productData['review_count'] ?? 0);

        if ($price <= 0.0) {
            return 0.0;
        }

        $availabilityFactor = min($qty / 10, 1.0);
        $popularityFactor = min($reviewCount / 50, 1.0);

        return round(($availabilityFactor * 0.6 + $popularityFactor * 0.4) * 100, 2);
    }
}

3. Testing the service class with prepared input data

With the calculation logic in a standalone class, the test becomes trivial: you call calculate() directly with different input arrays and check the result against an expected value. No object manager, no database, no Magento bootstrap time, just a plain PHP call. Such tests run in a fraction of a second and can be repeated as often as needed in a loop or as a data provider for many edge cases.

This is especially valuable for edge cases that would be hard to produce deliberately in a real reindex run, for example a product with a price of zero, negative stock due to a data error, or an unusually high review count. With prepared input data, all these cases can be tested in seconds, without having to create a matching product in the database each time.


<?php
declare(strict_types=1);

namespace Mironsoft\SearchBoost\Test\Unit\Model\Indexer;

use Mironsoft\SearchBoost\Model\Indexer\SearchBoostCalculator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

class SearchBoostCalculatorTest extends TestCase
{
    private SearchBoostCalculator $calculator;

    protected function setUp(): void
    {
        $this->calculator = new SearchBoostCalculator();
    }

    #[DataProvider('productDataProvider')]
    public function testCalculateReturnsExpectedScore(array $productData, float $expected): void
    {
        self::assertSame($expected, $this->calculator->calculate($productData));
    }

    public static function productDataProvider(): array
    {
        return [
            'zero price returns zero' => [['price' => 0.0, 'qty' => 50, 'review_count' => 100], 0.0],
            'high qty and reviews' => [['price' => 19.99, 'qty' => 20, 'review_count' => 60], 100.0],
            'no reviews at all' => [['price' => 19.99, 'qty' => 5, 'review_count' => 0], 30.0],
            'missing keys default to zero' => [['price' => 5.0], 0.0],
        ];
    }
}

4. Securing the thin adapter with a targeted integration test

Even with the calculation logic fully covered by unit tests, one question remains: does the adapter actually write the result correctly into the index table, and does reindexRow() call the service class with the correct raw data? A single, deliberately lean integration test is enough for that, one that creates a product, calls reindexRow() for its ID, and then directly queries the index table.

This integration test is deliberately not meant to cover all calculation cases, the unit tests already do that completely. It only checks the wiring: does the correct raw data arrive at the adapter, is the service class actually invoked, and does the result land in the right place in the database. A single such test per indexer is usually enough.


<?php
declare(strict_types=1);

namespace Mironsoft\SearchBoost\Test\Integration\Model\Indexer;

use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Framework\App\ResourceConnection;
use Magento\TestFramework\Helper\Bootstrap;
use Mironsoft\SearchBoost\Model\Indexer\SearchBoostIndexer;
use PHPUnit\Framework\TestCase;

/**
 * @magentoDataFixture Magento/Catalog/_files/product_simple.php
 */
class SearchBoostIndexerTest extends TestCase
{
    public function testReindexRowWritesScoreToIndexTable(): void
    {
        $objectManager = Bootstrap::getObjectManager();
        /** @var ProductRepositoryInterface $productRepository */
        $productRepository = $objectManager->create(ProductRepositoryInterface::class);
        $product = $productRepository->get('simple');

        /** @var SearchBoostIndexer $indexer */
        $indexer = $objectManager->create(SearchBoostIndexer::class);
        $indexer->executeRow((int) $product->getId());

        /** @var ResourceConnection $resourceConnection */
        $resourceConnection = $objectManager->create(ResourceConnection::class);
        $connection = $resourceConnection->getConnection();
        $select = $connection->select()
            ->from('mironsoft_search_boost_index', ['score'])
            ->where('product_id = ?', $product->getId());

        self::assertNotFalse($connection->fetchOne($select));
    }
}

5. Testing batch processing and memory usage in isolation

Many indexers process products in batches to limit memory usage for large catalogs. This batching logic, for example splitting a list of ten thousand product IDs into chunks of five hundred, can also be tested completely in isolation by making the chunk size an injectable constructor parameter and checking with a small test list of IDs whether the chunks are formed correctly.

A test can, for example, use a chunk size of two with five test IDs and check that exactly three chunks are produced, the last one with only one element. Such edge cases, for example a total count that does not divide evenly by the chunk size, or an empty input list, are rarely tested deliberately in real reindex runs, but are exactly the cases that can lead to incomplete or duplicate index entries in production.


<?php
declare(strict_types=1);

namespace Mironsoft\SearchBoost\Test\Unit\Model\Indexer;

use Mironsoft\SearchBoost\Model\Indexer\BatchSplitter;
use PHPUnit\Framework\TestCase;

class BatchSplitterTest extends TestCase
{
    public function testSplitsIdsIntoCorrectlySizedChunks(): void
    {
        $splitter = new BatchSplitter(chunkSize: 2);

        $chunks = $splitter->split([1, 2, 3, 4, 5]);

        self::assertCount(3, $chunks);
        self::assertSame([1, 2], $chunks[0]);
        self::assertSame([3, 4], $chunks[1]);
        self::assertSame([5], $chunks[2]);
    }

    public function testEmptyInputProducesNoChunks(): void
    {
        $splitter = new BatchSplitter(chunkSize: 100);

        self::assertSame([], $splitter->split([]));
    }
}

6. Testing behavior with faulty or missing input data

In practice, an indexer does not always run with clean data. A product can be deleted during a reindex run, an attribute value can briefly be missing due to a concurrent import, or a foreign key can point to an already deleted category. Isolated tests are excellent for simulating exactly such faulty input deliberately, without having to reproduce a real race condition state in the database.

A test can, for example, pass an input array without the expected 'price' key and check that the service class does not fail with a PHP warning but reacts in a well-defined way, for example with a score of zero instead of an exception. Such tests make explicit how robust the indexer logic is supposed to be against incomplete data, a decision that otherwise often stays implicit and undocumented in the code.

7. Treating mview changelog and scheduled mode behavior separately

Magento indexers can run in 'Update on Save' or 'Update by Schedule' mode. In schedule mode, changes are first written to a changelog table and only actually processed by a cron job later. This mview mechanism itself is core Magento functionality and does not need to be retested, but your own indexer class should have a test that confirms executeList() actually processes all given IDs and none are silently skipped.

A simple test for that calls executeList() with a list of three ID values on a mocked or stubbed repository and checks that the service class is called for exactly three records, no more and no fewer. This catches a common bug where a loop accidentally only processes the first element of a list, or a 'break' instead of a 'continue' ignores the remaining IDs.


<?php
declare(strict_types=1);

namespace Mironsoft\SearchBoost\Test\Unit\Model\Indexer;

use Mironsoft\SearchBoost\Model\Indexer\SearchBoostCalculator;
use Mironsoft\SearchBoost\Model\Indexer\ProductDataProvider;
use Mironsoft\SearchBoost\Model\Indexer\SearchBoostIndexWriter;
use PHPUnit\Framework\TestCase;

class SearchBoostIndexWriterTest extends TestCase
{
    public function testAllGivenIdsAreProcessedExactlyOnce(): void
    {
        $dataProviderMock = $this->createMock(ProductDataProvider::class);
        $dataProviderMock->method('fetchByIds')
            ->with([10, 20, 30])
            ->willReturn([
                10 => ['price' => 10.0, 'qty' => 5, 'review_count' => 2],
                20 => ['price' => 20.0, 'qty' => 5, 'review_count' => 2],
                30 => ['price' => 30.0, 'qty' => 5, 'review_count' => 2],
            ]);

        $calculator = new SearchBoostCalculator();
        $writer = new SearchBoostIndexWriter($dataProviderMock, $calculator);

        $results = $writer->processIds([10, 20, 30]);

        self::assertCount(3, $results);
    }
}

8. Not confusing indexer status and invalidation with isolated tests

An indexer's status, visible in bin/magento indexer:status, is managed by Magento's own indexer infrastructure, not by your own class. A common mistake is trying to check in an isolated test whether the indexer gets marked as 'invalid' after a change. That is the job of the mview infrastructure and the indexer.xml setup, not your own calculation logic, and therefore does not belong in the isolated unit tests of the service class.

If you still want to check whether your own indexer.xml configuration is correctly wired with the right changelog tables and view names, treat that as a separate, deliberately declared integration test that explicitly queries Magento's indexer configuration, instead of mixing it with testing your own calculation logic. This clean separation keeps the fast unit tests fast and the rare integration tests focused.

9. A test coverage checklist for custom indexers

In summary, a clear structure emerges for every custom indexer: the actual calculation logic lives in a standalone service class with full unit test coverage for normal cases, edge cases, and faulty input. Batch processing is tested separately with its own injectable chunk size. The thin adapter that implements the indexer interfaces gets exactly one targeted integration test per relevant method such as executeRow() and executeList().

This structure keeps the overall test suite fast, because most cases are covered by unit tests without database access, and reliable at the same time, because the few integration tests secure the actual wiring with Magento's indexer infrastructure. A full bin/magento indexer:reindex run remains reserved for manual verification and staging deployment, not the automated test suite.

Test goal Level Tool Typical runtime
Calculation logic (normal cases) Unit test Direct call to the service class Milliseconds
Calculation logic (edge cases, faulty data) Unit test with data provider Prepared input arrays Milliseconds
Batch splitting Unit test Injectable chunk size Milliseconds
Adapter wiring (executeRow/executeList) Integration test A product fixture, index table query Seconds
Full reindex run Manual / staging bin/magento indexer:reindex Seconds to minutes

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

Indexer Testing: Key Takeaways

Core idea

Extract calculation logic into a standalone service class and test it without a Magento bootstrap.

What to avoid

Trying to achieve test coverage through repeated full bin/magento indexer:reindex runs.

Complement

A lean integration test per adapter method secures the wiring with the real index table.

Bonus

Make the batch size an injectable constructor parameter to test chunk formation in isolation.

11. FAQ: Indexer Testing: Key Takeaways

1Why is a full bin/magento indexer:reindex run not a good test?
It needs a working database, takes seconds to minutes depending on catalog size, and tests the entire indexer infrastructure along the way instead of specifically checking your own calculation logic.
2How do I separate indexer logic so it becomes testable in isolation?
The class implementing the indexer interface delegates the actual calculation to a separate service class without Magento dependencies, which accepts simple input data and returns a result.
3Do I need an object manager in the test for the service class?
No, if the service class has no Magento dependencies, it can be instantiated with new and called directly with prepared arrays, with no object manager or bootstrap at all.
4How do I test the batch processing of an indexer?
By making the chunk size an injectable constructor parameter and using a unit test with a small test list to check whether chunks are formed correctly and completely, including cases that don't divide evenly.
5Do I still need an integration test for the indexer?
Yes, a single lean integration test per relevant method such as executeRow() checks whether the adapter fetches the correct raw data and writes the result correctly into the index table.
6How do I test that executeList() really processes all IDs?
With a mocked repository that returns prepared data for a specific list of IDs, and an assertion that the service class is called for exactly that number of records.
7Do I need to test indexer status like invalid or valid in tests?
No, that is the job of Magento's own mview infrastructure and the indexer.xml setup, not your own calculation logic. It does not belong in isolated unit tests of the service class.
8How do I test faulty or incomplete input data for an indexer?
By deliberately passing an input array without expected keys or with implausible values and checking that the service class reacts in a well-defined way, for example with a fallback value instead of an unexpected exception.
9Does testing the indexer.xml configuration belong in the same test as the calculation logic?
No, that should be a separate, deliberately declared integration test that explicitly queries the Magento indexer configuration, to keep the tests focused and fast.
10How does this split affect the runtime of the test suite?
Most cases run through fast unit tests without database access, while only a few targeted integration tests check the actual wiring, which speeds up the entire suite noticeably.