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.
Table of Contents
- 1. Why native SOAP clients are a testing problem
- 2. The core idea: a dedicated interface as a contract
- 3. The concrete SOAP implementation of the wrapper
- 4. Testing the wrapper itself, without real SOAP
- 5. Testing consuming code in isolation
- 6. Wiring the connection cleanly through di.xml
- 7. A targeted integration test for the real SOAP connection
- 8. Simulating timeouts and slow ERP responses
- 9. Applying this to other proprietary legacy interfaces
- 10. Summary
- 11. FAQ
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.