Making SOAP and Legacy Interfaces Testable in Magento
AI generated
@test
assert
PHPUnit · SOAP · Magento
Making SOAP and Legacy Interfaces Testable
Wrapper interfaces around hard to mock native clients

PHP's native SoapClient and similar proprietary legacy interfaces are hard to handle with classic PHPUnit mocks, because they open a network connection in the constructor and ship final declared methods. A thin wrapper interface solves exactly this problem and makes ERP or inventory system integrations in Magento projects cleanly testable.

15 min read SOAP Wrapper interface Magento integration

1. Why native SOAP clients are a testing problem

Many Magento projects connect to older ERP, PIM, or inventory management systems via SOAP, since these systems have often run in production for decades and switching to REST is not economically justifiable. PHP's built in SoapClient, however, opens a real connection to the WSDL endpoint already in the constructor, downloads the schema, and dynamically generates callable methods from it. This behavior makes it practically impossible to instantiate the class in a unit test without real network access.

On top of that, central SoapClient methods like __soapCall are declared final, which directly defeats PHPUnit's classic inheritance based mocking mechanism. A test can therefore neither bypass the constructor nor override the relevant methods without resorting to deeper PHP tricks like Runkit or unreliable reflection manipulation, which should be avoided in modern test environments anyway.

2. The core idea: a dedicated interface as a contract

The established solution is to never program directly against the SoapClient, but instead define a dedicated, narrow interface that describes exactly the operations your own module actually needs, such as getStockLevel or submitOrder. PHPUnit handles this interface without any trouble, since it is an ordinary PHP interface with no network access in the constructor and can be doubled normally via createMock().

The concrete implementation of that interface then encapsulates the real SoapClient call and translates between the SOAP specific data structure and your own domain objects. The rest of the module, from blocks and view models to service classes, only ever knows your own interface and is thereby fully decoupled from the peculiarities of the native SoapClient.


<?php
declare(strict_types=1);

namespace Mironsoft\ErpConnector\Api;

/**
 * Contract for the connection to the external ERP system via SOAP.
 * Knows nothing about SOAP details, only business operations.
 */
interface ErpClientInterface
{
    /**
     * Returns the current stock level for a SKU.
     *
     * @param string $sku
     * @return int
     * @throws \Mironsoft\ErpConnector\Api\ErpConnectionException
     */
    public function getStockLevel(string $sku): int;

    /**
     * Submits an order to the ERP system.
     *
     * @param array $orderData
     * @return string External ERP order number
     * @throws \Mironsoft\ErpConnector\Api\ErpConnectionException
     */
    public function submitOrder(array $orderData): string;
}

3. The concrete SOAP implementation of the wrapper

The interface implementation does not build the SoapClient in its own constructor, but either receives it via dependency injection or creates it lazily in a dedicated, overridable method. That second approach is especially useful when the SoapClient should only be instantiated on the first actual call for performance reasons, for example to avoid loading the WSDL unnecessarily on every page request.

All SOAP specific errors, such as a SoapFault when an endpoint is unreachable, are caught inside the wrapper class and translated into a dedicated, meaningful exception. Calling code therefore never needs to know that SOAP is involved at all, and a later switch of the transport protocol, say to REST, only affects this one wrapper class.


<?php
declare(strict_types=1);

namespace Mironsoft\ErpConnector\Model;

use Mironsoft\ErpConnector\Api\ErpClientInterface;
use Mironsoft\ErpConnector\Api\ErpConnectionException;

/**
 * Concrete SOAP based implementation of ErpClientInterface.
 */
class SoapErpClient implements ErpClientInterface
{
    private ?\SoapClient $soapClient = null;

    public function __construct(private readonly string $wsdlUrl)
    {
    }

    /**
     * Creates the native SoapClient lazily, overridable for 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 call GetStock failed: ' . $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 call SubmitOrder failed: ' . $fault->getMessage(), 0, $fault);
        }
    }
}

4. Testing the wrapper itself, without real SOAP

The wrapper itself can be tested by overriding the protected createSoapClient method in an anonymous subclass, returning a PHPUnit mock for the __soapCall method instead. Since PHPUnit can configure __soapCall on a mocked object without trouble as long as instantiating the SoapClient itself is bypassed, the exact return object a real SOAP endpoint would deliver can be simulated precisely.

This test confirms two things at once: that the wrapper class correctly translates the SOAP response into a plain integer or string, and that a SoapFault is indeed converted into your own ErpConnectionException. Neither would be cleanly testable without the seam provided by the overridable createSoapClient method.


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 unreachable'));

        $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. Testing consuming code in isolation

Once the interface is in place, every class that depends on ERP stock data, such as a Magento view model displaying stock levels on the product detail page, can be programmed against the interface via ordinary constructor injection and equipped in tests with a plain createMock(ErpClientInterface::class). These tests are entirely independent of whether the ERP connection runs over SOAP, REST, or, in the future, an event based system.

This decoupling is the real payoff of the wrapper approach: without it, every test anywhere down the call chain that depends on ERP data would have to deal with SOAP's peculiarities. With the interface, the test effort for consumers shrinks to a simple two or three line mock setup.


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. Wiring the connection cleanly through di.xml

For Magento to actually use the SOAP implementation in production while tests exclusively program against the interface, the binding is declared entirely conventionally through di.xml as a preference. This configuration is pure infrastructure and does not affect testability at all, it merely ensures Magento's object manager instantiates the right concrete class at runtime whenever the interface is requested.

For environments with multiple ERP systems, for example different connections for different tenants in a dual vendor setup, this preference can even be configured individually per tenant via virtualType and a constructor argument, without needing to touch the interface itself or the tests built on top of it.


<?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. A targeted integration test for the real SOAP connection

Beyond the fast unit tests against the interface, one important question remains open: does the concrete SoapErpClient class actually work against the real, or a realistic test, endpoint of the ERP system. A separate, clearly marked integration test is well suited for this, living in its own PHPUnit test suite and run only deliberately, for example nightly or before a release, against a staging instance of the ERP system, not on every regular CI run.

This clear separation between fast, isolated unit tests for business logic and rare, real integration tests for the actual network connection prevents the main pipeline from depending on an unstable or slow ERP test environment, while still regularly verifying that the wrapper remains compatible with the real system.

8. Simulating timeouts and slow ERP responses

Older ERP systems often respond noticeably slower than modern REST APIs, sometimes with response times of several seconds under load, and occasionally a request never comes back at all because the legacy system itself is stuck. In that case the SoapClient either aborts with a SoapFault once the configured connection_timeout is exceeded, or, without timeout configuration, hangs indefinitely in the worst case. A wrapper that has never been tested against this behavior gives no reliable error message in production, instead leaving the entire request handler of the shop hanging.

In a test, a timeout can be simulated by having the mocked __soapCall call throw a SoapFault instance with a message typical of timeouts, such as 'Could not connect to host'. This lets you verify that the wrapper correctly translates this case into its own ErpConnectionException, and that surrounding retry logic, for example with exponential backoff, actually stops after a limited number of attempts instead of retrying indefinitely.


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. Applying this to other proprietary legacy interfaces

The same wrapper principle works not just for SOAP, but for any hard to test legacy interface, such as a proprietary binary protocol connection to an inventory system, an FTP based batch data exchange, or a legacy library with final classes and network access in the constructor. The decisive step is always the same: define a narrow, business oriented interface that fully hides the technical details of the legacy connection.

It matters to keep the interface deliberately small and business focused, rather than shaping it as a generic copy of the legacy API. An interface with methods like getStockLevel or submitOrder is more maintainable and easier to mock than an interface that mirrors every single SOAP operation one to one, even if only two or three of them are actually needed in the project.

Problem in native SoapClient Solution in the wrapper Benefit for testing
Connection opened in the constructor Lazy createSoapClient() method Instantiation possible without network access
__soapCall is final Dedicated interface with business methods Interface can be mocked normally
SoapFault as a generic error Translation into a dedicated domain exception Error paths become deliberately testable
Tight coupling to SOAP structure Consumers only know the interface Unit tests for consumers with no SOAP at all
No isolated connection test possible Separate integration test suite Real compatibility verified regularly

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

Testing SOAP in Magento: The Essentials at a Glance

Core problem

Native SoapClient opens a connection in the constructor, __soapCall is final.

Solution

A narrow, business focused wrapper interface decouples consumers from SOAP details.

Wrapper test

Lazy createSoapClient() method as a seam, overridable in test subclasses.

Separation

Fast unit tests against the interface, rare integration tests against the real connection.

11. FAQ: Testing SOAP in Magento: The Essentials at a Glance

1Why can't PHP's native SoapClient simply be mocked with PHPUnit?
Because it already opens a real connection to the WSDL endpoint in the constructor, and central methods like __soapCall are declared final, which directly defeats PHPUnit's inheritance based mocking mechanism.
2What is the basic solution for testable SOAP integrations?
Define a dedicated, narrow interface that describes only the operations actually needed, such as getStockLevel or submitOrder. Calling code is programmed exclusively against this interface, never directly against the SoapClient.
3How does the SOAP connection itself become testable inside the wrapper class?
Through a lazy, protected createSoapClient method that gets overridden in an anonymous test subclass, returning a PHPUnit mock instead of a real SoapClient.
4How does the wrapper handle a SoapFault?
The wrapper catches the SoapFault inside each respective method and translates it into a dedicated, meaningful exception, so consuming code only ever needs to know its own domain exception, not the SOAP specific error class.
5How do I test code that depends on the ERP interface without knowing any SOAP details?
Through a simple createMock(ErpClientInterface::class) configured with the needed methods. Since the interface contains no SOAP specifics, this test is entirely independent of the transport protocol.
6Where is the concrete SOAP implementation wired up in Magento?
Entirely conventionally through a preference in di.xml that maps the interface to the concrete SoapErpClient class. This configuration only affects production wiring and has no bearing on testability.
7Do I still need a real integration test despite having unit tests?
Yes, a separate, deliberately run integration test against a staging instance of the ERP system remains important to verify that the concrete SOAP implementation is compatible with the real endpoint. It should not, however, be part of every regular CI run.
8Does the wrapper principle work for legacy interfaces other than SOAP too?
Yes, the same principle applies to any hard to test legacy interface, such as proprietary binary protocols, FTP based batch connections, or legacy libraries with final classes and network access in the constructor.
9How large should the wrapper interface be?
Deliberately small and business focused, with only the operations the project actually needs, rather than a one to one copy of the entire legacy API. That keeps the interface easy to mock and maintain.
10What is the biggest benefit of this approach for the rest of the codebase?
Consuming code, such as view models or service classes, becomes fully decoupled from the technical details of the legacy connection. A later switch of the transport protocol, say from SOAP to REST, only affects the wrapper implementation, not the rest of the code.