Wrapper-Interfaces um schwer mockbare native Clients
Der native SoapClient von PHP und aehnliche proprietaere Legacy-Schnittstellen sind mit klassischen PHPUnit-Mocks nur schwer zu handhaben, weil sie Netzwerkverbindungen im Konstruktor aufbauen und final deklarierte Methoden mitbringen. Ein duennes Wrapper-Interface loest genau dieses Problem und macht ERP- oder Warenwirtschafts-Anbindungen in Magento-Projekten sauber testbar.
Inhaltsverzeichnis
- 1. Warum native SOAP-Clients ein Testproblem sind
- 2. Das Grundprinzip: Ein eigenes Interface als Vertrag
- 3. Die konkrete SOAP-Implementierung des Wrappers
- 4. Den Wrapper selbst testen, ohne echtes SOAP
- 5. Konsumierenden Code isoliert testen
- 6. Die Anbindung ueber di.xml sauber verdrahten
- 7. Ein gezielter Integrationstest fuer die reale SOAP-Anbindung
- 8. Timeouts und langsame ERP-Antworten simulieren
- 9. Uebertragung auf andere proprietaere Legacy-Schnittstellen
- 10. Zusammenfassung
- 11. FAQ
1. Warum native SOAP-Clients ein Testproblem sind
Viele Magento-Projekte binden aeltere ERP-, PIM- oder Warenwirtschaftssysteme ueber SOAP an, weil diese Systeme oft schon seit Jahrzehnten produktiv laufen und ein Wechsel auf REST wirtschaftlich nicht vertretbar ist. PHPs eingebauter SoapClient baut jedoch bereits im Konstruktor eine echte Verbindung zum WSDL-Endpunkt auf, laedt das Schema herunter und generiert daraus dynamisch aufrufbare Methoden. Dieses Verhalten macht es praktisch unmoeglich, die Klasse in einem Unit-Test ohne echten Netzwerkzugriff zu instanziieren.
Zusaetzlich sind zentrale Methoden des SoapClient wie __soapCall als final deklariert, was PHPUnits klassischen Mock-Mechanismus, der auf Vererbung basiert, direkt aushebelt. Ein Test kann also weder den Konstruktor umgehen noch die relevanten Methoden ueberschreiben, ohne auf tiefere PHP-Tricks wie Runkit oder unzuverlaessige Reflection-Manipulation zurueckzugreifen, die in modernen Testumgebungen ohnehin vermieden werden sollten.
2. Das Grundprinzip: Ein eigenes Interface als Vertrag
Die etablierte Loesung ist, niemals direkt gegen den SoapClient zu programmieren, sondern ein eigenes, schmales Interface zu definieren, das exakt die Operationen beschreibt, die das eigene Modul tatsaechlich benoetigt, etwa getStockLevel oder submitOrder. Dieses Interface kennt PHPUnit problemlos, da es eine gewoehnliche PHP-Schnittstelle ohne Netzwerkzugriff im Konstruktor ist und sich damit ganz normal ueber createMock() doubeln laesst.
Die konkrete Implementierung dieses Interfaces kapselt dann den echten SoapClient-Aufruf und uebersetzt zwischen der SOAP-spezifischen Datenstruktur und den eigenen Domaenen-Objekten. Der gesamte Rest des Moduls, von den Blocks ueber die ViewModels bis zu den Service-Klassen, kennt ausschliesslich das eigene Interface und ist damit vollstaendig von den Eigenheiten des nativen SoapClient entkoppelt.
<?php
declare(strict_types=1);
namespace Mironsoft\ErpConnector\Api;
/**
* Vertrag fuer die Anbindung an das externe ERP-System via SOAP.
* Kennt keine SOAP-Details, nur fachliche Operationen.
*/
interface ErpClientInterface
{
/**
* Liefert den aktuellen Lagerbestand fuer eine SKU.
*
* @param string $sku
* @return int
* @throws \Mironsoft\ErpConnector\Api\ErpConnectionException
*/
public function getStockLevel(string $sku): int;
/**
* Uebermittelt eine Bestellung an das ERP-System.
*
* @param array $orderData
* @return string Externe ERP-Bestellnummer
* @throws \Mironsoft\ErpConnector\Api\ErpConnectionException
*/
public function submitOrder(array $orderData): string;
}
3. Die konkrete SOAP-Implementierung des Wrappers
Die Implementierung des Interfaces baut den SoapClient nicht im eigenen Konstruktor auf, sondern erhaelt ihn entweder per Dependency Injection oder erzeugt ihn lazy in einer eigenen, ueberschreibbaren Methode. Dieser zweite Ansatz ist besonders praktisch, wenn der SoapClient aus Performance-Gruenden erst beim ersten tatsaechlichen Aufruf instanziiert werden soll, etwa um das WSDL nicht bei jedem Seitenaufruf unnoetig zu laden.
Alle SOAP-spezifischen Fehler, etwa ein SoapFault bei einem nicht erreichbaren Endpunkt, werden in der Wrapper-Klasse abgefangen und in eine eigene, sprechende Exception uebersetzt. Dadurch muss der aufrufende Code niemals wissen, dass ueberhaupt SOAP im Spiel ist, und ein spaeterer Wechsel des Transportprotokolls, etwa zu REST, betrifft ausschliesslich diese eine Wrapper-Klasse.
<?php
declare(strict_types=1);
namespace Mironsoft\ErpConnector\Model;
use Mironsoft\ErpConnector\Api\ErpClientInterface;
use Mironsoft\ErpConnector\Api\ErpConnectionException;
/**
* Konkrete SOAP-basierte Implementierung des ErpClientInterface.
*/
class SoapErpClient implements ErpClientInterface
{
private ?\SoapClient $soapClient = null;
public function __construct(private readonly string $wsdlUrl)
{
}
/**
* Erzeugt den nativen SoapClient lazy, ueberschreibbar fuer Tests.
*
* @return \SoapClient
*/
protected function createSoapClient(): \SoapClient
{
return $this->soapClient ??= new \SoapClient($this->wsdlUrl, ['exceptions' => true]);
}
public function getStockLevel(string $sku): int
{
try {
$result = $this->createSoapClient()->__soapCall('GetStock', [['sku' => $sku]]);
return (int) $result->stockLevel;
} catch (\SoapFault $fault) {
throw new ErpConnectionException('SOAP-Aufruf GetStock fehlgeschlagen: ' . $fault->getMessage(), 0, $fault);
}
}
public function submitOrder(array $orderData): string
{
try {
$result = $this->createSoapClient()->__soapCall('SubmitOrder', [$orderData]);
return (string) $result->externalOrderId;
} catch (\SoapFault $fault) {
throw new ErpConnectionException('SOAP-Aufruf SubmitOrder fehlgeschlagen: ' . $fault->getMessage(), 0, $fault);
}
}
}
4. Den Wrapper selbst testen, ohne echtes SOAP
Der Wrapper selbst laesst sich testen, indem die geschuetzte createSoapClient-Methode in einer anonymen Unterklasse ueberschrieben wird und stattdessen ein PHPUnit-Mock fuer die __soapCall-Methode zurueckgibt. Da PHPUnit __soapCall auf einem gemockten Objekt problemlos konfigurieren kann, solange die Instanziierung des SoapClient selbst umgangen wird, laesst sich exakt das Rueckgabeobjekt simulieren, das der echte SOAP-Endpunkt liefern wuerde.
Dieser Test bestaetigt zwei Dinge gleichzeitig: dass die Wrapper-Klasse die SOAP-Antwort korrekt in einen einfachen Integer oder String uebersetzt, und dass ein SoapFault tatsaechlich in die eigene ErpConnectionException umgewandelt wird. Beides waere ohne den Seam der ueberschreibbaren createSoapClient-Methode nicht sauber pruefbar.
use PHPUnit\Framework\TestCase;
final class SoapErpClientTest extends TestCase
{
public function testGetStockLevelParsesSoapResponse(): void
{
$soapClientMock = $this->createMock(\SoapClient::class);
$soapClientMock->method('__soapCall')
->with('GetStock', [['sku' => 'TEST-SKU']])
->willReturn((object) ['stockLevel' => 42]);
$wrapper = new class($soapClientMock) extends SoapErpClient {
public function __construct(private \SoapClient $mock)
{
parent::__construct('https://erp.example.com/service?wsdl');
}
protected function createSoapClient(): \SoapClient
{
return $this->mock;
}
};
self::assertSame(42, $wrapper->getStockLevel('TEST-SKU'));
}
public function testGetStockLevelTranslatesSoapFault(): void
{
$soapClientMock = $this->createMock(\SoapClient::class);
$soapClientMock->method('__soapCall')
->willThrowException(new \SoapFault('Server', 'ERP nicht erreichbar'));
$wrapper = new class($soapClientMock) extends SoapErpClient {
public function __construct(private \SoapClient $mock)
{
parent::__construct('https://erp.example.com/service?wsdl');
}
protected function createSoapClient(): \SoapClient
{
return $this->mock;
}
};
$this->expectException(ErpConnectionException::class);
$wrapper->getStockLevel('TEST-SKU');
}
}
5. Konsumierenden Code isoliert testen
Sobald das Interface steht, koennen alle Klassen, die vom ERP-Bestand abhaengen, etwa ein Magento-ViewModel, das den Lagerbestand auf der Produktdetailseite anzeigt, ganz gewoehnlich per Constructor Injection gegen das Interface programmiert und im Test mit einem einfachen createMock(ErpClientInterface::class) versehen werden. Diese Tests sind vollstaendig unabhaengig davon, ob die ERP-Anbindung ueber SOAP, REST oder in Zukunft ueber ein Event-basiertes System erfolgt.
Diese Entkopplung ist der eigentliche Gewinn des Wrapper-Ansatzes: Ohne ihn muesste jeder Test, der irgendwo in der Aufrufkette von ERP-Daten abhaengt, sich mit den Eigenheiten von SOAP auseinandersetzen. Mit dem Interface reduziert sich der Testaufwand fuer Konsumenten auf ein einfaches Mock-Setup mit zwei oder drei Zeilen Code.
final class StockLevelViewModelTest extends TestCase
{
public function testDisplaysLowStockWarningBelowThreshold(): void
{
$erpClient = $this->createMock(ErpClientInterface::class);
$erpClient->method('getStockLevel')->with('TEST-SKU')->willReturn(3);
$viewModel = new StockLevelViewModel($erpClient, lowStockThreshold: 5);
self::assertTrue($viewModel->isLowStock('TEST-SKU'));
}
}
6. Die Anbindung ueber di.xml sauber verdrahten
Damit Magento in Produktion tatsaechlich die SOAP-Implementierung verwendet, waehrend Tests ausschliesslich gegen das Interface programmieren, wird die Bindung ganz regulaer ueber di.xml als preference deklariert. Diese Konfiguration ist reine Infrastruktur und beeinflusst die eigentliche Testbarkeit nicht, sie stellt lediglich sicher, dass Magentos Objektmanager im laufenden System die richtige konkrete Klasse instanziiert, sobald das Interface angefordert wird.
Fuer Umgebungen mit mehreren ERP-Systemen, etwa unterschiedliche Anbindungen fuer verschiedene Mandanten im Dual-Vendor-Setup, laesst sich diese preference sogar per virtualType und Konstruktor-Argument fuer jeden Mandanten individuell konfigurieren, ohne dass das Interface selbst oder die darauf aufbauenden Tests angepasst werden muessen.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<preference for="Mironsoft\ErpConnector\Api\ErpClientInterface"
type="Mironsoft\ErpConnector\Model\SoapErpClient" />
<type name="Mironsoft\ErpConnector\Model\SoapErpClient">
<arguments>
<argument name="wsdlUrl" xsi:type="string">https://erp.example.com/service?wsdl</argument>
</arguments>
</type>
</config>
7. Ein gezielter Integrationstest fuer die reale SOAP-Anbindung
Neben den schnellen Unit-Tests gegen das Interface bleibt eine wichtige Frage offen: Funktioniert die konkrete SoapErpClient-Klasse tatsaechlich gegen den echten oder einen realistischen Test-Endpunkt des ERP-Systems. Dafuer eignet sich ein separater, klar gekennzeichneter Integrationstest, der in einer eigenen PHPUnit-Testsuite liegt und nur gezielt, etwa nightly oder vor einem Release, gegen eine Staging-Instanz des ERP-Systems laeuft, nicht bei jedem regulaeren CI-Lauf.
Diese klare Trennung zwischen schnellen, isolierten Unit-Tests fuer die Geschaeftslogik und seltenen, echten Integrationstests fuer die tatsaechliche Netzwerkverbindung verhindert, dass die Hauptpipeline von einer instabilen oder langsamen ERP-Testumgebung abhaengig wird, waehrend trotzdem regelmaessig verifiziert wird, dass der Wrapper mit dem echten System kompatibel bleibt.
8. Timeouts und langsame ERP-Antworten simulieren
Aeltere ERP-Systeme antworten haeufig deutlich langsamer als moderne REST-APIs, teils mit Antwortzeiten von mehreren Sekunden unter Last, und gelegentlich bleibt eine Anfrage ganz aus, weil das Altsystem selbst blockiert. Der SoapClient bricht in diesem Fall entweder mit einem SoapFault ab, sobald das konfigurierte connection_timeout ueberschritten wird, oder haengt ohne Timeout-Konfiguration im schlimmsten Fall unbegrenzt. Ein Wrapper, der niemals gegen dieses Verhalten getestet wurde, gibt in Produktion keine verlaessliche Fehlermeldung, sondern laesst den gesamten Request-Handler des Shops haengen.
Im Test laesst sich ein Timeout simulieren, indem der gemockte __soapCall-Aufruf eine SoapFault-Instanz mit einer fuer Timeouts typischen Fehlermeldung wirft, etwa 'Could not connect to host'. So kann geprueft werden, ob der Wrapper diesen Fall korrekt in die eigene ErpConnectionException uebersetzt und ob eine umgebende Retry-Logik, etwa mit exponentiellem Backoff, nach einer begrenzten Anzahl an Versuchen tatsaechlich abbricht, statt endlos zu wiederholen.
public function testGetStockLevelStopsRetryingAfterMaxAttempts(): void
{
$soapClientMock = $this->createMock(\SoapClient::class);
$soapClientMock->method('__soapCall')
->willThrowException(new \SoapFault('HTTP', 'Could not connect to host'));
$wrapper = new class($soapClientMock) extends RetryingSoapErpClient {
public function __construct(private \SoapClient $mock)
{
parent::__construct('https://erp.example.com/service?wsdl', maxRetries: 3);
}
protected function createSoapClient(): \SoapClient
{
return $this->mock;
}
};
$this->expectException(ErpConnectionException::class);
$wrapper->getStockLevel('TEST-SKU');
}
9. Uebertragung auf andere proprietaere Legacy-Schnittstellen
Das gleiche Wrapper-Prinzip funktioniert nicht nur fuer SOAP, sondern fuer jede schwer testbare Legacy-Schnittstelle, etwa eine proprietaere Binaerprotokoll-Anbindung an eine Warenwirtschaft, einen FTP-basierten Batch-Datenaustausch, oder eine Altsystem-Bibliothek mit finalen Klassen und Netzwerkzugriff im Konstruktor. Entscheidend ist immer derselbe Schritt: ein schmales, fachlich formuliertes Interface definieren, das die technischen Details der Legacy-Anbindung vollstaendig verbirgt.
Wichtig ist, das Interface bewusst klein und fachlich zu halten, statt es als generische Kopie der Legacy-API zu gestalten. Ein Interface mit Methoden wie getStockLevel oder submitOrder ist wartbarer und leichter zu mocken als ein Interface, das jede einzelne SOAP-Operation eins zu eins nachbildet, selbst wenn nur zwei oder drei davon im Projekt tatsaechlich benoetigt werden.
| Problem im nativen SoapClient | Loesung im Wrapper | Nutzen fuer den Test |
|---|---|---|
| Verbindungsaufbau im Konstruktor | Lazy createSoapClient()-Methode | Instanziierung ohne Netzwerkzugriff moeglich |
| __soapCall ist final | Eigenes Interface mit fachlichen Methoden | Interface laesst sich normal mocken |
| SoapFault als generischer Fehler | Uebersetzung in eigene Domaenen-Exception | Fehlerpfade gezielt testbar |
| Enge Kopplung an SOAP-Struktur | Konsumenten kennen nur das Interface | Unit-Tests fuer Konsumenten ganz ohne SOAP |
| Kein isolierter Verbindungstest moeglich | Separate Integrationstest-Suite | Reale Kompatibilitaet regelmaessig verifiziert |
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
SOAP in Magento testen: Das Wichtigste auf einen Blick
Kernproblem
Nativer SoapClient baut Verbindung im Konstruktor auf, __soapCall ist final.
Loesung
Schmales, fachliches Wrapper-Interface entkoppelt Konsumenten von SOAP-Details.
Wrapper-Test
Lazy createSoapClient()-Methode als Seam, ueberschreibbar in Test-Subklassen.
Trennung
Schnelle Unit-Tests gegen das Interface, seltene Integrationstests gegen die echte Anbindung.