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.
Table of Contents
- 1. Why a full reindex run is not a good test
- 2. Extracting indexer logic into its own testable class
- 3. Testing the service class with prepared input data
- 4. Securing the thin adapter with a targeted integration test
- 5. Testing batch processing and memory usage in isolation
- 6. Testing behavior with faulty or missing input data
- 7. Treating mview changelog and scheduled mode behavior separately
- 8. Not confusing indexer status and invalidation with isolated tests
- 9. A test coverage checklist for custom indexers
- 10. Summary
- 11. FAQ
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.