How to Test an External API with 0 Mocks?

EZZINE Imen
PHP Symfony Backend Developer
@imenezzine
@imenezzine1



I.Ezzine




Text




I.Ezzine




How to test an application consuming an external API?
I.Ezzine



- Using the real API
- Sandbox or dedicated environment
- Containerizing the API locally
- Using mocks
The possibilities
I.Ezzine


Using the real API
-
Billing per call
-
Is the external service unavailable?
-
Is the API subscription expired?
-
Limited to GET endpoints

I.Ezzine


Sandbox
- Tests depending on external factors can fail if the sandbox stops responding.
- Tests may run slower.
- External calls.
I.Ezzine



Virtualization
- Complex configuration
- Potentially erroneous data
- Limited case coverage
I.Ezzine



Using mocks
- Decoupling
- Risk of divergence between the Mock and the real API
- Code cluttered with simulation details
- Mock maintenance in case of API changes

I.Ezzine


use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
$responses = [
new MockResponse ($body1, $info1),
new MockResponse ($body2, $info2)
];
$client = new MockHttpClient($responses);
// responses in the same order as passed to MockHttpClient
$response1 = $client->request('...');
$response1 = $client->request('...');
How to define responses manually
use Symfony\Component\HttpClient\Response\JsonMockResponse;
$response = new JsonMockResponse([
'foo' => 'bar',
]);
I.Ezzine


use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Psr\Log\NullLogger;
class AuthenticatedClientTest extends TestCase
{
const BASE_AUTH_URI = 'https://example.com/auth';
const BASE_REST_URI = 'https://example.com/rest';
public function testRequestWithExpiredToken(): void
{
$authenticatedClient = new AuthenticatedClient(
new MockHttpClient([
function ($method, $url, $options): MockResponse {
$this->assertSame('POST', $method);
$this->assertSame(sprintf('%s/v2/token', self::BASE_AUTH_URI), $url);
$this->assertSame('{"grant_type":"client_credentials","client_id":"clientId","client_secret":"clientSecret","account_id":"accountId"}', $options['body']);
return new MockResponse('{"access_token":"expired_access_token"}', [
'http_code' => 200,
]);
},
function ($method, $url, $options): MockResponse {
$this->assertSame('GET', $method);
$this->assertSame(sprintf('%s/whatever', self::BASE_REST_URI), $url);
$this->assertArrayHasKey('headers', $options);
$this->assertArrayHasKey('normalized_headers', $options);
$this->assertArrayHasKey('authorization', $options['normalized_headers']);
$this->assertArrayHasKey(0, $options['normalized_headers']['authorization']);
$this->assertSame('Authorization: Bearer expired_access_token', $options['normalized_headers']['authorization'][0]);
return new MockResponse('', [
'http_code' => 401,
]);
},
function ($method, $url, $options): MockResponse {
$this->assertSame('POST', $method);
$this->assertSame(sprintf('%s/v2/token', self::BASE_AUTH_URI), $url);
$this->assertSame('{"grant_type":"client_credentials","client_id":"clientId","client_secret":"clientSecret","account_id":"accountId"}', $options['body']);
return new MockResponse('{"access_token":"new_access_token"}', [
'http_code' => 200,
]);
},
function ($method, $url, $options): MockResponse {
$this->assertSame('GET', $method);
$this->assertSame(sprintf('%s/whatever', self::BASE_REST_URI), $url);
$this->assertArrayHasKey('headers', $options);
$this->assertArrayHasKey(0, $options['normalized_headers']['authorization']);
$this->assertSame('Authorization: Bearer new_access_token', $options['normalized_headers']['authorization'][0]);
return new MockResponse('', [
'http_code' => 201,
]);
},
]),
'clientId',
'clientSecret',
self::BASE_AUTH_URI,
'accountId',
new NullLogger()
);
$authenticatedClient->request('GET', '/whatever');
}
}

Testing Method | Pros | Cons |
---|---|---|
Real API | - Realism of tests - Result reliability |
- External dependency - Cost |
Sandbox | - Test isolation - Result reliability |
- Feature limitations - External dependency |
Mock | -Total control - Speed |
- Deviation from Reality - Maintenability ² |
Local API Containerization | - Test isolation - Realism of tests |
- Complex configuration - Local resource dependency |
I.Ezzine




No tests?

I.Ezzine

What are we looking for?
- Minimal code?
- Less configuration or rather automation?
- Reduced maintainability?
- Complete decoupling?
I.Ezzine



"Don’t Mock What You Don’t Own"
the London School of TDD – the one that loooves mocks.
I.Ezzine



I.Ezzine

Video Cassette Recorder

Existing solutions
- Guzzle VCR https://github.com/dshafik/guzzlehttp-vcr
- PHP-HTTP VCR Plugin https://github.com/php-http/vcr-plugin
I.Ezzine




I.Ezzine



How does it work?
I.Ezzine



Some Theory

I.Ezzine



How does it work?
Overwriting PHP functions (fopen(), file_get_contents(),
curl_exec()...)"
Retrieve all
details of the request
(metadata)
Execute the real request
Retrieve all details of the response
Storage









Request
Response

How do we proceed?
Basic single client
\VCR\VCR::configure()
->setCassettePath(base_path().'/tests/cassettes');
Storage path
\VCR\VCR::class;
Mode
//record
\VCR\VCR::configure()->setMode('new_episodes');
//record and replay
\VCR\VCR::configure()->setMode('once');
On
Eject
Off
\VCR\VCR::turnOn();
\VCR\VCR::turnOff();
\VCR\VCR::eject();
\VCR\VCR::configure()->setStorage('json');
Storage format
I.Ezzine


How do we proceed?
\VCR\VCR::configure()->enableRequestMatchers(['method', 'url', 'host']);
- Request Matching
\VCR\VCR::configure()
->addRequestMatcher(
'custom_matcher',
function (\VCR\Request $first, \VCR\Request $second) {
// custom request matching
return true;
}
)
->enableRequestMatchers(['method', 'url', 'custom_matcher']);
- Custom request matching
I.Ezzine


- StreamWrapper
- SoapClient
- cUrl
\VCR\VCR::configure()->enableLibraryHooks(['curl'])
How do we proceed?
- Library hooks

I.Ezzine

With PHPUnit?
class VCRTest extends TestCase
{
/**
* @vcr unittest_annotation_test
*/
public function testInterceptsWithAnnotations()
{
// Requests are intercepted and stored into
// tests/fixtures/unittest_annotation_test
$result = file_get_contents('http://google.com');
$this->assertEquals(
'This is a annotation test dummy.',
$result,
'Call was not intercepted (using annotations).'
);
// VCR is automatically turned on and off.
}
}
PHPUnit TestListener for PHP-VCR phpunit-testlistener-vcr
Why should we use it?
- Much faster tests
- Independence from the API
- HTTP request inspection
- Deterministic tests
- Monitoring changes in external APIs
- Detection of unexpected HTTP requests
- Reduced dependency on network calls
- Simplified updating
- No manual mocking
I.Ezzine



Concrete Example
I.Ezzine



How does it work?
@vcr
Scenario: retrieve a product
Given 1 product with category "category_1" exist in the PIM
When I retrieve the product from the pim with the root category "test"
Then I should get the expected pim product
I.Ezzine


Feature: creating an image via an API
@vcr
Scenario: Using the recorded response from PHP-VCR
Given I'm using PHP-VCR
When I make a request to the create image API
Then the response should contain the image URL
# config/tests/boostrap.php
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__).'/../vendor/autoload.php';
require __DIR__.'/VCRMatcher/CustomBodyMatcher.php';
\VCR\VCR::configure()
->addRequestMatcher(
'custom_body_matcher',
new CustomBodyMatcher()
)
->enableRequestMatchers(['method', 'url', 'custom_body_matcher', 'post_fields'])
->enableLibraryHooks(['curl'])
->setCassettePath(dirname(__DIR__, 2).'/tests/Environment/IO/cassettes')
->setStorage('yaml')
->setMode('once');
// Turn on for Initializing VCR
\VCR\VCR::turnOn();
// Turn off for DI
\VCR\VCR::turnOff();
(new Dotenv())->bootEnv(dirname(__DIR__).'/../.env');
A bit of configuration!
I.Ezzine


The magic of PHP-VCR!
# tests/MockServerContexte.php
private bool $vcrOn = false;
/**
* @BeforeScenario
*/
public function setupVCR(BeforeScenarioScope $scenarioEvent): void
{
if ($scenarioEvent->getScenario()->hasTag('vcr')) {
\VCR\VCR::turnOn();
$this->vcrOn = true;
$scenarioCassette = strtolower(
str_replace(' ', '_', $scenarioEvent->getScenario()->getTitle())
);
$suiteName = $scenarioEvent->getSuite()->getName();
\VCR\VCR::insertCassette($suiteName.'/'.$scenarioCassette.'.yaml');
}
}
/**
* @AfterScenario
*/
public function ejectCassette(): void
{
if ($this->vcrOn) {
\VCR\VCR::eject();
}
\VCR\VCR::turnOff();
}
I.Ezzine


How does it look?
-
request:
method: POST
url: 'https://api.example.com/images'
headers:
Host: api.example.com
Accept-Encoding: ''
X-Auth-Client: your_auth_client
X-Auth-Token: your_auth_token
Accept: application/json
User-Agent: GuzzleHttp/7
Content-Type: 'multipart/form-data; boundary=your_boundary'
body: !!binary |
LS00NGIxNzhkZmRkZTFhNzQ1YWNjZjMwMTM4MmUzOWY4MTUxOTBjYzc1DQ
pDb250ZW50LURpc3Bvc2l0aW9uOiBmb3JtLWRhdGE7IG5hbWU9ImltYWdlX
2ZpbGUiOyBmaWxlbmFtZT0iaWNvbi5wbmciDQpDb250ZW50LUxlbmd0aDogMzEwDQp
Db250ZW50LVR5cGU6IGltYWdlL3BuZw0KDQqJUE5HDQoaCgAAAA1JSERSAAAAEAAAABAIBgA
AAB/z/2EAAAAJcEhZcwAACxIAAAsSAdLdfvwAAADoSURBVDiNpZPRDAAAElFTkSuQmCC
response:
status:
http_version: '1.1'
code: '200'
message: OK
headers:
Content-Type: application/json
Transfer-Encoding: chunked
Connection: keep-alive
Vary: Accept-Encoding
X-Request-ID: 838d78668230cc576f6bd482d1229b45
X-Rate-Limit-Requests-Left: '16101494'
X-Rate-Limit-Time-Reset-Ms: '29999'
X-Rate-Limit-Requests-Quota: '16101495'
X-Rate-Limit-Time-Window-Ms: '30000'
Strict-Transport-Security: 'max-age=31536000; includeSubDomains'
body: '{"data":{"id":1,"product_id":2,"is_thumbnail":false,"sort_order":1,"description":"",
"image_file":"y\/550\/icon__19355.png",
"url_zoom":"https:\/\/cdn11.bigcommerce.com\/s-sxuyyapoo3\/products\/493\/images\
/426\/icon__19355.1637747487.1280.1280.png?c=1","url_standard":"https:\/\/cdn11.bigcommerce.com\/s-
How to maintain this over time?
- Regular updates
- Cassette versioning
- Semantic naming of cassettes

I.Ezzine


PHP-VCR is a powerful tool for simplifying integration testing by recording and replaying HTTP requests.
Whether you opt to use its static methods or integrate it with PHPUnit, PHP-VCR can significantly enhance the stability and consistency of your tests by reducing reliance on external services.
By leveraging it wisely, you can make your tests more reliable and faster.
Conclusion
I.Ezzine


Thank you
Questions?
I.Ezzine


SymfonyOnLine 2024
By imenezzine
SymfonyOnLine 2024
- 214