No More Mocking! Write Better Tests for Microservices-powered Server-side Rendered Applications with Contract Tests
I could never quite wrap my head around how to test SSR applications (Nuxt, Next.js, Laravel, etc.) that talk to HTTP APIs (e.g., microservices).
Using the built-in mocking capabilities of tools like Playwright and Cypress is not possible in this scenario because we can only mock the communication between the browser and our SSR application. However, we want to mock the communication between the SSR application and the microservices.
Real E2E testing, not mocking the requests to external HTTP APIs, is also impractical in most situations because such tests are slow, flaky, and hard to set up.
So, how can we tackle this? For a long time, I was at a loss. But then I learned about Contract Testing with Specmatic.
In this article, we’ll:
- explore the differences between SPAs, monoliths, and the full-stack frameworks plus microservices combo regarding testing and mocking;
- learn that we should opt for Contract Testing when building a full-stack application and microservices, database seeding when building monolithic web apps talking directly to a database, and mocking when dealing with SPAs;
- discover how to utilize the API first approach by creating an OpenAPI specification for a new microservice and using it to spin up a stub server with Specmatic to power our Playwright tests;
- learn the basics of Contract Testing and how it helps us stub API services for E2E tests.
Disclaimer: Contract Testing is its own type of testing that ensures that an agreed-upon contract between a provider (e.g., a microservice) and a consumer (other services, web, or mobile applications) is honored by all parties. This article is about how we can use a particular aspect of Contract Testing performed with Specmatic to improve our E2E tests. Specmatic allows us to spin up a stub server based on a service contract, which we can use to drive our E2E tests. This article is about marrying Contract Testing and E2E testing, not Contract Testing itself.
Why the Combination of SSR Applications and Microservices is Fundamentally Different from Testing SPAs
Testing SPAs is a breeze. When relying on an SPA architecture, typically, our application runs entirely in the browser, talking to HTTP APIs (e.g., microservices) to get its data. When we want to test such an application, we can simply mock all HTTP requests our SPA makes.
Testing traditional monoliths (backend applications talking to a single database) is a piece of cake, too. Monolithic frameworks like Laravel or Ruby on Rails have built-in tools for automatically creating and seeding database tables to get our application in a predefined state before each test.
Okay, testing SPAs is a breeze, and testing monoliths is a piece of cake. So everything is fine, isn’t it? No!
Although there was a brief period of standalone SPA hype, the tides have changed. We’ve realized we need a layer between our frontends and the (micro)services that deliver data. Born was the BFF pattern. Finally, we did go full circle, embracing a modern flavor of full-stack frameworks like Next.js and Nuxt.
Those modern, mostly React or Vue.js-based full-stack frameworks can be used to build traditional monolithic applications talking to a single database but also applications that primarily get their data via their built-in BFF layer by calling HTTP endpoints of various microservices.
So with this third type of applications, which are not SPAs and not monoliths but full-stack applications getting their data from a bunch of microservices, we face a challenge:
The green arrow represents requests from the client-side (browser) to the server-side part of our application (e.g., server routes in Next.js and Nuxt). We can mock those requests in tools like Playwright and Cypress.
Conversely, the red arrows indicate requests made from the server-side (BFF) part of our application to other servers (e.g., microservices). And here is the thing: we can’t mock those requests with traditional test frameworks!
Why Can’t We Just Mock the Requests from the Browser to the Full-stack Framework?
So what is the problem here? Why can’t we just mock the requests the client-side code makes to the server-side layer (be it in the form of a standalone BFF or a backend layer integrated into a full-stack framework like Next.js)?
To answer this, we first have to answer a more important question:
What is our goal with E2E tests?
My answer is: We want to write tests to quickly gather feedback on whether our application does what it should. Now we have to clarify: what is our application?
Let’s imagine we have a BFF, an SPA, and a few microservices, and we treat each as a separate application:
Okay, now what I said earlier holds: We can test the client-side part perfectly fine, mocking all the requests to the BFF. Our BFF and microservices have some tests of their own, too. But there is a problem with this definition of what qualifies as a standalone application: it is a lie!
The client-side and server-side pieces work together to solve our users’ particular problems. Each is utterly useless on its own. Therefore, together, they form a single application. Now, one can argue that, by this logic, we also must include the services in our definition of an application. But that’s not the case.
A service is valuable on its own. Multiple applications can consume the same service to serve the needs of their particular users. And therefore, a service is a standalone application deserving of its own tests.
So now that we’ve settled on what an application is and is not let’s return to what we want to achieve with testing:
We want to write tests to quickly gather feedback on whether our application does what it should.
When to use Contract Testing, Seeding, and Traditional Network Interception-based Mocking
So, as already specified above, there are currently three dominant types of web applications we see in the wild:
- Purely client-side rendered SPAs directly fetching data from (micro)services.
- Monolithic full-stack framework-based (e.g., Nuxt, Next.js, Laravel, or Ruby on Rails) applications. Those applications feature server-side rendering and server-side code reading data from a single database.
- Non-monolithic full-stack framework-based (e.g., Nuxt, Next.js, Laravel, or Ruby on Rails) applications. Their only difference from monolithic applications is that non-monolithic apps typically get their data from (micro)services.
For purely client-side rendered SPAs, we can opt for network request interception-based mocking. Test frameworks like Playwright and Cypress have this already baked in. With other test frameworks, we can use a library like MSW to do the job.
With server-rendered monolithic applications, the most straightforward way to run our tests is to spin up one or multiple mock databases and use database migrations and seeding to ensure we have the correct data.
Yet, I think the future of building web applications lies in non-monolithic server-side rendered applications with frameworks like Next.js fetching data from microservices. Therefore, what interests us most is ensuring we can test such server-side rendered, non-monolithic applications most effectively.
The most promising way forward is to piggyback on the artifacts provided by contract testing (namely, OpenAPI specifications) to achieve independent testability when using modern SSR-powered frameworks in combination with microservices.
Using a Stub Server Fed with OpenAPI Specifications from Contract Testing to Test Microservice-powered Full-stack Applications
That’s a mouthful of a headline. So, let’s break it down into more digestible pieces.
- OpenAPI specifications provide a clear contract for the shape of our microservices, outlining their endpoints and data formats.
- We can use Specmatic to generate a stub server from these OpenAPI specifications, which allows us to simulate the behavior of our microservices for our tests.
- Now, we can write tests in Playwright or Cypress relying on the stub server powered by the OpenAPI contracts.
OpenAPI Specification and Stub Server for a Chocolate Bar Microservice
Let’s create basic OpenAPI specification:
openapi: 3.0.0
info:
title: Chocolate Bar API
version: 1.0.0
description: API for chocolate bars
paths:
/chocolate-bars:
get:
summary: Get list of chocolate bars
responses:
"200":
description: A list of chocolate bars
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/ChocolateBarResource"
components:
schemas:
ChocolateBarResource:
type: object
properties:
id:
type: string
type:
type: string
attributes:
type: object
properties:
title:
type: string
sugar:
type: string
taste:
type: string
Now, we can start a Specmatic stub server and point it to the directory where we store the OpenAPI specification file for our microservice.
specmatic stub ./specifications
When we open our browser and navigate to http://localhost:9000/chocolate-bars
, we will see a mock response containing a list of chocolate bars.
Writing Tests
Before we write our first test, ensure that your application points to the stub server URL and not to the actual API endpoints. You might want to implement it to change the URL at runtime via an environment variable.
// ...
it("should show a list of tasty chocolate bars", async ({ driver }) => {
const chocolateBar = await driver.setUp(chocolateBarDSLFactory);
await chocolateBar.open();
await chocolateBar.expectToSeeList();
});
In this test, we open the page displaying a list of tasty chocolate bars and expect to see them.
Let’s take a quick look at the implementation of the open()
method:
const open = async () => {
await await fetch("http://0.0.0.0:9000/_specmatic/expectations", {
body: JSON.stringify({
"http-request": {
method: "GET",
path: "/chocolate-bars",
},
"http-response": {
body: {
data: [
{
attributes: {
sugar: "a lot",
taste: "yummy",
title: "Adult Malo",
},
id: "1",
type: "chocolate-bar",
},
{
attributes: {
sugar: "don't ask don't tell",
taste: "very yummy",
title: "Smickers",
},
id: "2",
type: "chocolate-bar",
},
{
attributes: {
sugar: "little",
taste: "meh",
title: "Croppers",
},
id: "3",
type: "chocolate-bar",
},
],
},
status: 200,
},
}),
method: "POST",
});
await page.goTo("/chocolate-bars");
};
Specmatic allows us to tell its stub server how to respond to HTTP requests. In this case, we call http://0.0.0.0:9000/_specmatic/expectations
to tell Specmatic to respond with three chocolate bar objects.
With that, we’ve successfully set up the basics to allow us to test our application without relying on external services. This gives us more control over our testing environment and helps ensure consistent and reliable test results.
Wrapping It Up
Now that we have our testing environment with Specmatic, we can confidently proceed with writing tests for our application. Establishing a robust testing environment early on in the development process is essential. This approach not only streamlines our testing process but also enhances our application’s overall quality and reliability. By leveraging contract testing and its artifacts for our browser tests, we can also ensure that our application behaves as intended under different scenarios and is in sync with our backend services.