Imagine arriving at a new codebase. This codebase contains a lot of legacy code. Badly-designed, untested, highly-coupled, unreadable, hard-to-understand spaghetti-code.

This codebase also has quality issues, a lot of it related to this legacy code. As you can guess, it would often happen that changes to one part of the code, would break another part in unpredictable ways.

These issues would mostly be discovered through an extensive manual QA effort before each release. Or, a bit too often, by the customers in production.

We started writing e2e tests using Playwright to automate this QA effort as much as possible. Playwright is a lot more efficient at finding 500-error pages than any human being. It doesn't care about all the legacy code and doesn't need it to be written in a testable way to still provide some safety net against typical legacy code bugs.

Testing legacy with external API calls isn't easier

All was well again and the test suite started to grow (and with it, the confidence we aren't breaking too much). So naturally, we arrived at a part of the software that has to interact with an external API. In our case, this is the Google Maps API, which calculates the travelling distance between 2 addresses. Ideally, we don't want to call this API when running our tests.

In a classic unit test setup, we would mock the API client. Either by using a mocking library or using specific mock implementations. Here, however, we are running playwright tests against the frontend, and we have very little control over individual pieces of our backend.

We could look at the environment, and when in test, use a mocked API client, but this approach has some drawbacks. Firstly, not all code is suited to swap implementations. We have code that uses the curl functions directly, without any abstraction. Secondly, in our playwright tests, we have very little control over what the "API" should return.

Mocking the API, not the API call

I landed on a solution that avoids external network calls and gives the tests full control over what is being returned.

Instead of calling the actual API, we mock this API itself in our tests and call that mocked API in the backend code. Swapping HTTP client implementations is hardly possible without some effort. It's a lot less effort to make the base URL of your API configurable.

Within our test, we start tiny, temporary HTTP servers. This allows us to run assertions against the incoming call and control the response the API returns.

Some code

As a proof of concept, I set up a very simple Laravel project using Sail. Here's the docker-compose, which contains a service for our Laravel app.

version: '3'
services:
    laravel.test:
        build:
            context: ./vendor/laravel/sail/runtimes/8.2
            dockerfile: Dockerfile
            args:
                WWWGROUP: '${WWWGROUP}'
        image: sail-8.2/app
        extra_hosts:
            - 'host.docker.internal:host-gateway'
        ports:
            - '${APP_PORT:-80}:80'
        environment:
            WWWUSER: '${WWWUSER}'
            LARAVEL_SAIL: 1
        volumes:
            - '.:/var/www/html'

In the Laravel app, we'll define a route that will call the API and return the API response. (Note: host.docker.internal is the url on which you can reach your host system from within a container. In a proper testing setup, this url would be configurable)

<?php

Route::get('/api', function () {
    $response = Http::post('http://host.docker.internal:8080/test-url');

    return new JsonResponse($response->json());
});

And finally, the Playwright test. It will start a server on port 8080, do an assertion on the incoming call and return some obvious test data. As the final assertion, we'll check the Laravel app actually returned the test data.

import {test, expect} from '@playwright/test';
import http from "http";

test('Sends api call', async ({request}) => {
    const server = http
        .createServer(async (req, res) => {
            await expect(req.url).toBe('/test-url');
            res.writeHead(200, { "Content-Type": "application/json" });
            res.end(JSON.stringify({test: 'test'}));
        })
        .listen(8080);

    const response = await request.get('http://localhost/api');
    await expect(await response.json()).toEqual({test: 'test'});
    
    server.close();
})

What do you think? Have you ever encountered this situation, and how did you solve it?