isoliert mit PHPUnit testen
Ein kompletter Reindex-Lauf ist fuer einen Test viel zu langsam und zu grobkoernig. Wer die eigentliche Indexer-Logik in eine separate Klasse auslagert und direkt mit praeparierten Eingabedaten aufruft, testet in Millisekunden statt Minuten.
Inhaltsverzeichnis
- 1. Warum ein voller Reindex-Lauf kein guter Test ist
- 2. Die Indexer-Logik in eine eigene, testbare Klasse auslagern
- 3. Die Service-Klasse mit praeparierten Eingabedaten testen
- 4. Den duennen Adapter mit einem gezielten Integrationstest absichern
- 5. Batch-Verarbeitung und Speicherverbrauch isoliert testen
- 6. Verhalten bei fehlerhaften oder fehlenden Eingabedaten testen
- 7. Mview-Changelog und Scheduled-Mode-Verhalten getrennt betrachten
- 8. Indexer-Status und Invalidierung nicht mit isolierten Tests verwechseln
- 9. Eine Testabdeckungs-Checkliste fuer eigene Indexer
- 10. Zusammenfassung
- 11. FAQ
1. Warum ein voller Reindex-Lauf kein guter Test ist
Der naheliegende Weg, einen eigenen Indexer zu testen, ist der Aufruf von bin/magento indexer:reindex mironsoft_search_boost in einem Skript und die anschliessende Pruefung der Indextabelle. Das funktioniert, ist aber aus mehreren Gruenden ein schlechter Standardansatz: Ein voller Reindex-Lauf braucht eine funktionierende Magento-Installation mit Datenbank, dauert je nach Katalogumfang mehrere Sekunden bis Minuten und testet nebenbei die gesamte Indexer-Infrastruktur von Magento mit, nicht nur die eigene Logik.
Noch problematischer wird es, wenn der Test in einer CI-Pipeline viele solcher vollen Reindex-Laeufe braucht, um verschiedene Eingabekonstellationen abzudecken. Die Testlaufzeit explodiert, und ein fehlgeschlagener Test sagt zunaechst nur, dass irgendetwas im gesamten Indexierungsprozess nicht stimmt, nicht, welcher konkrete Teil der eigenen Business-Logik das Problem verursacht. Der bessere Weg ist, die eigentliche Berechnungslogik von der Magento-Indexer-Infrastruktur zu trennen.
2. Die Indexer-Logik in eine eigene, testbare Klasse auslagern
Der Schluessel zu isolierten Indexer-Tests ist eine klare Trennung: Die Klasse, die Magento\Framework\Indexer\ActionInterface implementiert, delegiert die eigentliche Berechnung an eine separate Service-Klasse, die keine Abhaengigkeit zur Indexer-Infrastruktur hat und stattdessen einfache Eingabedaten wie Produkt-IDs oder Attribut-Arrays entgegennimmt und ein einfaches Ergebnis-Array oder ein Werteobjekt zurueckgibt. Diese Service-Klasse laesst sich dann vollstaendig ohne Magento-Bootstrap testen.
In der Praxis bedeutet das, dass die eigentliche 'reindex row', 'reindex list' oder 'reindex full' Methode der Indexer-Klasse nur noch als duenner Adapter fungiert: Sie holt die Rohdaten (etwa ueber ein Repository), reicht sie an die Service-Klasse weiter und schreibt das Ergebnis in die Index-Tabelle. Der Adapter selbst ist trivial genug, um ihn nicht extra testen zu muessen, waehrend die eigentliche Rechenlogik in der Service-Klasse vollstaendig durch Unit-Tests abgedeckt wird.
<?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. Die Service-Klasse mit praeparierten Eingabedaten testen
Mit der Berechnungslogik in einer eigenstaendigen Klasse wird der Test trivial: Man ruft calculate() direkt mit verschiedenen Eingabe-Arrays auf und prueft das Ergebnis gegen einen erwarteten Wert. Kein Object Manager, keine Datenbank, keine Magento-Bootstrap-Zeit, nur ein reiner PHP-Aufruf. Solche Tests laufen in Sekundenbruchteilen und lassen sich beliebig oft in einer Schleife oder als Data-Provider fuer viele Grenzfaelle wiederholen.
Besonders wertvoll ist das fuer Randfaelle, die in einem echten Reindex-Lauf nur schwer gezielt zu erzeugen waeren, etwa ein Produkt mit Preis null, negativer Lagermenge durch einen Datenfehler, oder eine ungewoehnlich hohe Review-Anzahl. Mit praeparierten Eingabedaten lassen sich all diese Faelle in Sekunden durchtesten, ohne jeweils ein passendes Produkt in der Datenbank anlegen zu muessen.
<?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. Den duennen Adapter mit einem gezielten Integrationstest absichern
Auch wenn die Berechnungslogik vollstaendig durch Unit-Tests abgedeckt ist, bleibt eine Restfrage offen: Schreibt der Adapter das Ergebnis tatsaechlich korrekt in die Index-Tabelle, und ruft reindexRow() die Service-Klasse mit den richtigen Rohdaten auf? Dafuer reicht ein einzelner, bewusst schlanker Integrationstest, der ein Produkt anlegt, reindexRow() fuer dessen ID aufruft und anschliessend direkt die Index-Tabelle abfragt.
Dieser Integrationstest ist bewusst nicht dazu da, alle Berechnungsfaelle abzudecken, das leisten die Unit-Tests bereits vollstaendig. Er prueft ausschliesslich die Verdrahtung: Kommen die richtigen Rohdaten beim Adapter an, wird die Service-Klasse tatsaechlich aufgerufen, und landet das Ergebnis am richtigen Ort in der Datenbank. Ein einziger solcher Test pro Indexer reicht in der Regel aus.
<?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. Batch-Verarbeitung und Speicherverbrauch isoliert testen
Viele Indexer verarbeiten Produkte in Batches, um den Speicherverbrauch bei grossen Katalogen zu begrenzen. Diese Batch-Logik, etwa das Aufteilen einer Liste von zehntausend Produkt-IDs in Chunks von fünfhundert, laesst sich ebenfalls vollstaendig isoliert testen, indem man die Chunk-Groesse als Konstruktor-Parameter injizierbar macht und mit einer kleinen Testliste von IDs prueft, ob die Chunks korrekt gebildet werden.
Ein Test kann zum Beispiel mit einer Chunk-Groesse von zwei und fuenf Test-IDs pruefen, dass exakt drei Chunks entstehen, die letzten davon mit nur einem Element. Solche Grenzfaelle, etwa eine Gesamtanzahl, die nicht glatt durch die Chunk-Groesse teilbar ist, oder eine leere Eingabeliste, werden in echten Reindex-Laeufen selten bewusst getestet, sind aber genau die Faelle, die in Produktion zu unvollstaendigen oder doppelten Indexeintraegen fuehren koennen.
<?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. Verhalten bei fehlerhaften oder fehlenden Eingabedaten testen
Ein Indexer laeuft in der Praxis nicht immer mit sauberen Daten. Ein Produkt kann waehrend eines Reindex-Laufs geloescht werden, ein Attributwert kann durch einen parallelen Import kurzzeitig fehlen, oder ein Fremdschluessel kann auf eine bereits geloeschte Kategorie zeigen. Isolierte Tests eignen sich hervorragend, um genau solche fehlerhaften Eingaben gezielt zu simulieren, ohne einen echten Race-Condition-Zustand in der Datenbank herstellen zu muessen.
Ein Test kann etwa ein Eingabe-Array ohne den erwarteten Schluessel 'price' uebergeben und pruefen, dass die Service-Klasse nicht mit einer PHP-Warnung abbricht, sondern definiert reagiert, etwa mit einem Score von null statt einer Exception. Solche Tests machen explizit, wie robust die Indexer-Logik gegenueber unvollstaendigen Daten sein soll, eine Entscheidung, die sonst oft implizit und undokumentiert im Code verborgen bleibt.
7. Mview-Changelog und Scheduled-Mode-Verhalten getrennt betrachten
Magento-Indexer koennen im 'Update on Save' oder im 'Update by Schedule' Modus laufen. Im Schedule-Modus werden Aenderungen zunaechst in eine Changelog-Tabelle geschrieben und erst durch einen Cron-Job tatsaechlich verarbeitet. Diese Mview-Mechanik selbst ist Magento-Kernfunktionalitaet und muss nicht neu getestet werden, aber die eigene Indexer-Klasse sollte einen Test dafuer haben, dass sie bei executeList() tatsaechlich alle uebergebenen IDs verarbeitet und keine stillschweigend uebersprungen werden.
Ein einfacher Test dafuer ruft executeList() mit einer Liste von drei ID-Werten auf einem gemockten oder gestubbten Repository auf und prueft, dass die Service-Klasse fuer genau drei Datensaetze aufgerufen wird, nicht mehr und nicht weniger. Das faengt einen haeufigen Fehler ab, bei dem eine Schleife versehentlich nur das erste Element einer Liste verarbeitet oder ein 'break' statt eines 'continue' die restlichen IDs ignoriert.
<?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. Indexer-Status und Invalidierung nicht mit isolierten Tests verwechseln
Der Status eines Indexers, sichtbar in bin/magento indexer:status, wird von Magentos eigener Indexer-Infrastruktur verwaltet, nicht von der eigenen Klasse. Ein haeufiger Denkfehler ist der Versuch, in einem isolierten Test zu pruefen, ob der Indexer nach einer Aenderung als 'invalid' markiert wird. Das ist Aufgabe der Mview-Infrastruktur und des indexer.xml-Setups, nicht der eigenen Berechnungslogik, und gehoert daher nicht in die isolierten Unit-Tests der Service-Klasse.
Wer dennoch pruefen moechte, ob die eigene indexer.xml-Konfiguration korrekt mit den richtigen Change-Log-Tabellen und View-Namen verdrahtet ist, sollte das als separaten, bewusst deklarierten Integrationstest behandeln, der explizit die Magento-Indexer-Konfiguration abfragt, statt es mit dem Testen der eigenen Rechenlogik zu vermischen. Diese saubere Trennung haelt die schnellen Unit-Tests schnell und die raren Integrationstests fokussiert.
9. Eine Testabdeckungs-Checkliste fuer eigene Indexer
Zusammengefasst ergibt sich fuer jeden eigenen Indexer eine klare Struktur: Die eigentliche Berechnungslogik liegt in einer eigenstaendigen Service-Klasse mit vollstaendiger Unit-Test-Abdeckung fuer Normalfaelle, Grenzfaelle und fehlerhafte Eingaben. Die Batch-Verarbeitung wird separat mit einer eigenen, injizierbaren Chunk-Groesse getestet. Der duenne Adapter, der die Indexer-Interfaces implementiert, bekommt genau einen gezielten Integrationstest pro relevanter Methode wie executeRow() und executeList().
Diese Struktur macht die Test-Suite insgesamt schnell, weil der Grossteil der Faelle ueber Unit-Tests ohne Datenbankzugriff abgedeckt wird, und gleichzeitig verlaesslich, weil die wenigen Integrationstests die tatsaechliche Verdrahtung mit Magentos Indexer-Infrastruktur absichern. Ein voller bin/magento indexer:reindex-Lauf bleibt der manuellen Verifikation und dem Staging-Deployment vorbehalten, nicht der automatisierten Test-Suite.
| Testziel | Ebene | Werkzeug | Typische Laufzeit |
|---|---|---|---|
| Berechnungslogik (Normalfaelle) | Unit-Test | Direkter Aufruf der Service-Klasse | Millisekunden |
| Berechnungslogik (Grenzfaelle, fehlerhafte Daten) | Unit-Test mit Data-Provider | Praeparierte Eingabe-Arrays | Millisekunden |
| Batch-Aufteilung | Unit-Test | Injizierbare Chunk-Groesse | Millisekunden |
| Adapter-Verdrahtung (executeRow/executeList) | Integrationstest | Ein Produkt-Fixture, Index-Tabellen-Abfrage | Sekunden |
| Kompletter Reindex-Lauf | Manuell / Staging | bin/magento indexer:reindex | Sekunden bis Minuten |
Mironsoft
Testautomatisierung, Magento-Qualitätssicherung und CI-Integration
Tests, die echte Fehler finden statt nur grün zu leuchten?
Wir prüfen bestehende PHPUnit-Suiten auf Implementierungsdetail-Tests, flaky Tests und fehlende Coverage an kritischen Stellen und bauen daraus eine Teststrategie, die bei jedem Magento-Update wirklich Sicherheit gibt.
Test-Audit
Bestehende Suiten auf Mocking-Antipatterns und blinde Flecken prüfen.
Teststrategie
Unit-, Integrations- und MFTF-Tests sinnvoll für Magento-Projekte kombinieren.
CI-Integration
Schnelle, zuverlässige Testläufe in GitLab CI oder GitHub Actions einrichten.
10. Zusammenfassung
Indexer-Tests: Das Wichtigste auf einen Blick
Kernidee
Berechnungslogik in eine eigenstaendige Service-Klasse auslagern und ohne Magento-Bootstrap testen.
Was vermeiden
Testabdeckung durch wiederholte volle bin/magento indexer:reindex Laeufe erreichen wollen.
Ergaenzung
Ein schlanker Integrationstest pro Adapter-Methode sichert die Verdrahtung mit der echten Index-Tabelle ab.
Bonus
Batch-Groesse als Konstruktor-Parameter injizierbar machen, um Chunk-Bildung isoliert zu testen.