SOAP- und Legacy-Schnittstellen in Magento testbar machen
AI generated
@test
assert
PHPUnit · SOAP · Magento
SOAP- und Legacy-Schnittstellen testbar machen
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.

15 Min. Lesezeit SOAP Wrapper Interface Magento Integration

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.

11. FAQ: SOAP in Magento testen: Das Wichtigste auf einen Blick

1Warum laesst sich der native PHP SoapClient nicht einfach mit PHPUnit mocken?
Weil er bereits im Konstruktor eine echte Verbindung zum WSDL-Endpunkt aufbaut und zentrale Methoden wie __soapCall als final deklariert sind, was PHPUnits vererbungsbasierten Mock-Mechanismus direkt verhindert.
2Was ist der grundlegende Loesungsansatz fuer testbare SOAP-Integrationen?
Ein eigenes, schmales Interface definieren, das nur die fachlich benoetigten Operationen beschreibt, etwa getStockLevel oder submitOrder. Der aufrufende Code programmiert ausschliesslich gegen dieses Interface, nie direkt gegen den SoapClient.
3Wie wird die SOAP-Verbindung testbar innerhalb der Wrapper-Klasse selbst?
Ueber eine lazy, geschuetzte createSoapClient-Methode, die in einer anonymen Testunterklasse ueberschrieben wird und einen PHPUnit-Mock statt eines echten SoapClient zurueckgibt.
4Wie behandelt der Wrapper einen SoapFault?
Der Wrapper faengt den SoapFault innerhalb der jeweiligen Methode ab und uebersetzt ihn in eine eigene, sprechende Exception, sodass konsumierender Code nur die eigene Domaenen-Exception kennen muss, nicht die SOAP-spezifische Fehlerklasse.
5Wie teste ich Code, der vom ERP-Interface abhaengt, ohne SOAP-Details zu kennen?
Ueber ein einfaches createMock(ErpClientInterface::class), das die benoetigten Methoden konfiguriert. Da das Interface keine SOAP-Eigenheiten enthaelt, ist dieser Test vollstaendig unabhaengig vom Transportprotokoll.
6Wo wird die konkrete SOAP-Implementierung in Magento verdrahtet?
Ganz regulaer ueber eine preference in der di.xml, die das Interface auf die konkrete SoapErpClient-Klasse abbildet. Diese Konfiguration betrifft nur die Produktionsverdrahtung und hat keinen Einfluss auf die Testbarkeit.
7Braucht man trotz Unit-Tests noch einen echten Integrationstest?
Ja, ein separater, gezielt ausgefuehrter Integrationstest gegen eine Staging-Instanz des ERP-Systems bleibt wichtig, um zu verifizieren, dass die konkrete SOAP-Implementierung mit dem echten Endpunkt kompatibel ist. Er sollte aber nicht Teil jedes regulaeren CI-Laufs sein.
8Funktioniert das Wrapper-Prinzip auch fuer andere Legacy-Schnittstellen als SOAP?
Ja, das gleiche Prinzip laesst sich auf jede schwer testbare Legacy-Schnittstelle anwenden, etwa proprietaere Binaerprotokolle, FTP-basierte Batch-Anbindungen oder Altsystem-Bibliotheken mit finalen Klassen und Netzwerkzugriff im Konstruktor.
9Wie gross sollte das Wrapper-Interface sein?
Bewusst klein und fachlich formuliert, mit nur den Operationen, die das Projekt tatsaechlich benoetigt, statt einer eins-zu-eins-Kopie der gesamten Legacy-API. Das haelt das Interface leicht mockbar und wartbar.
10Was ist der wichtigste Vorteil dieses Ansatzes fuer die restliche Codebasis?
Konsumierender Code, etwa ViewModels oder Service-Klassen, wird vollstaendig von den technischen Details der Legacy-Anbindung entkoppelt. Ein spaeterer Wechsel des Transportprotokolls, etwa von SOAP zu REST, betrifft ausschliesslich die Wrapper-Implementierung, nicht den restlichen Code.