Decoupling tests from APIs

Changing interfaces (e.g., API web handlers, queue event handlers, web pages) is common and can impact many tests. How can we decouple them from those changes?

Luís Soares
CodeX
5 min readAug 24, 2022

--

Photo by Bernd Dittrich on Unsplash

When refactoring, recall the pain of updating dozens of tests. Also, consider the verbosity of making HTTP calls or resorting to CSS selectors in the tests, which negatively impacts tests as documentation (one of the testing goals). How can we solve this? The solution is to apply the fundamental theorem of software engineering: “We can solve any problem by introducing an extra level of indirection”. We need testing clients to mediate access to our UIs and APIs.

Page Object

For web-based UIs, you probably heard about the Page Object pattern, where the testing entry points are pages, and each method represents a user action (e.g., “open blog post”). Here’s how it looks in the tests (adapted from a real-life project):

@Test
fun `edit profile`() {
Homepage(driver)
.navigate()
.`accept cookies`()
.`go to login`()
.`login`("main@example.com", "qwerty")
.`open profile`()
.`set name`("john")
.`save`()
}

I’ve described this pattern in terms of HTML, but the same pattern applies equally well to any UI technology. I’ve seen this pattern used effectively to hide the details of a Java swing UI and I’ve no doubt it’s been widely used with just about every other UI framework out there too. Page Object ~ Martin Fowler

Applying Page Object to other interfaces

What if we generalize the Page Object pattern to target any interface rather than just web UIs? Beware that I’m talking about external-facing APIs (e.g., GRPC, web, event handlers, etc.), not internal code-based APIs. We can abstract the tests’ entry points and segregate them all in a testing client. Let’s call it a SUT client or proxy. It contains a set of reusable functions to interact with the SUT, mimicking its real-life usage.

Using a SUT client in testing (green is test code; blue is implementation code).

Being abstract is something profoundly different from being vague (…) The purpose of abstraction is not to be vague but to create a new semantic level in which one can be absolutely precise. ~ Edsger W. Dijkstra

If the test subject is a web API, the abstraction is a web API client (not a generic one, but one for your web API) where each method is an HTTP call (e.g., updateProfile).

object HttpClient {

private val httpClient = newHttpClient()

fun `create user`(email: String, name: String, password: String): HttpResponse<Void> =
httpClient.send(
newBuilder()
.POST(ofString(""" { "email": "$email", "name": "$name", "password": "$password"} """))
.uri(URI("http://localhost:8081/users")).build(), discarding()
)

fun `list users`(): HttpResponse<String> =
httpClient.send(newBuilder().GET().uri(URI("http://localhost:8081/users")).build(), ofString())

fun `delete user`(email: String): HttpResponse<String> =
httpClient.send(
newBuilder().DELETE().uri(URI("http://localhost:8081/users/$email")).build(),
ofString()
)
}

Check examples of the client being used.

How?

The general recipe starts by isolating all API invocations into their abstraction. This means a file with reusable functions to target the desired API. Its function names should begin with verbs that depict user actions (e.g., placeOrder, confirmLoan). This is a clear example of ubiquitous language use in the tests.

Now, all tests can use those testing utilities (to arrange, act, and assert). Our tests can now use clear and stable surface areas as entry points. Those entry points (interfaces) can also be considered implementation details. This way, we isolate them in a single place. If they change, we update a single place.

Here’s an example of a web API client to be used in your tests (in Python):

# in a test:
def test_cant_create_repeated_users():
App().start(1234)
api_client = ApiClient("http://localhost:1234")
api_client.create_user('e@x.com')

response = api_client.create_user('e@x.com')

assert response.status_code == 409


# in a shared place only visible by tests:
class ApiClient:
_client: HttpClient

def create_user(self, name: str):
... # http call with _client
def get_user(self, id: str):
... # http call with _client
def list_users(self, offset: str, count: str):
... # http call with _client

Guidelines

Beware that these clients should be dumb and only mirror the real APIs. That said, don’t add any logic or abstraction to the clients. For example:

  • Don’t validate the inputs; make them optional raw strings (e.g., in a paginated listing, the count parameter should be a string). This way, you can also use the clients to test for invalid inputs.
  • Don’t assert in the clients to avoid coupling them to happy scenarios only.
  • Don’t store any state in the clients; instead, store it outside and pass it in.
  • Don’t do multiple steps: each client function is just a proxy to one business-sounding user action.
  • Don’t reference the apps’ internals (e.g., domain entities, repositories, DTOs, enums, etc.). This helps to keep the tests black-boxed.

On the other hand, don’t try to abstract the responses. For example, an HTTP response (for web APIs) is too complex to be worth the effort.

Benefits

These SUT clients are instrumental if you follow a vertical approach to testing, where the unit is a use case, and each test focuses on business interaction (in opposition to testing low-level technical details). They’re also a great help in end-to-end tests (e.g., against web APIs).

We should be wary of shared utilities, but this is an exception; this is testing code, so the practices vary slightly from the production code. At7 first, the proposed abstraction seems overkill, but it pays from early on. The tests become:

  • Easier to write: The IDE provides more help; it autocompletes and validates the SUT client functions and parameter names. The set of clients ends up looking like a DSL that documents the app’s intents.
  • Easier to read: The pattern tackles repetitive code and hides the underlying technical details. By replacing numerous complex calls, such as HTTP calls, with simple function calls (one-liners), the focus shifts from the “how” to the “what”. Tests demonstrate strong documentation capabilities by ensuring Arrange, Act, and Assert” are made at the same level of abstraction (Single Level of Abstraction). They read like a story.
  • Improved safety net: As a rule of thumb, each test should use the same entry point (e.g., a web API) to Arrange, Act, and Assert. Due to that, some things are indirectly tested in the Arrange and Assert of multiple tests, so they don’t need to be directly tested. A more comprehensive range of combinations is tested.
  • Less refactoring pain: SUT clients insulate the blast radius of potential changes to APIs — these can be easily made in a single SUT client rather than updating dozens of tests. This can protect tests from minor updates, such as updating an HTTP endpoint URL, and more significant changes, such as replacing a communication protocol (e.g., from GRPC to HTTP). This approach allows for easier maintenance and updating of the tests over time.

--

--

Luís Soares
CodeX
Writer for

I write about automated testing, Lean, TDD, CI/CD, trunk-based dev., user-centric dev, domain-centric arch, coding good practices,