Contract Testing MicroFrontends
TLDR; You need to define your integration points and define clear ownership around them. TypeScript will get you a nearly thorough compile time check. Write tooling and decouple concerns.
MicroFrontends are a few years late to the party in comparison to MicroServices, and over these years as Services architecture already had the revolution of ‘everything needs to be a separate service’ and the second revolution of ‘actually not everything needs to be a separate service’.
MicroFrontends will take on a very similar endeavor with regards to domain, size, operations, and deployments as MicroServices, but are there any differences? There are a two main in my opinion:
- Difference #1: Hierarchy
MicroFrontends can depend on each other on a very shallow hierarchy, usually in tree shape, and it’s even rare if a MicroFrontend has a second level of MicroFrontend. However, services are usually interconnected therefore can become a chaotic spiderweb.
- Difference #2: Communications
The other difference is how they integrate. MicroServices talk with network requests, usually RESTful, while MicroFrontends will communicate via multitude of ways, from window.postMessage()
to requests, params, shared objects, or even shared Redux state.
These connections will make your system fragile when it wants to evolve.
To ensure the principle of independence we need to agree on how these systems interconnect. This is what contracts are used for. You describe the available API of the service, and hide internal implementation. This allows flexibility and clear boundaries. These contracts will help you find bugs earlier, deploy faster and safer and make you need less end-to-end environments. For Services, this can be easily implemented as OpenAPIv3 or Pact.io, and the tests can run at pipeline level, ensuring what your component promises to accept and output is being kept at bay.
This approach sadly cannot be used directly in the MicroFrontend context, as the communication is not standardized as in a REST request, but is usually an in-house solution with its quirks.
However to allow Continuous Delivery, Continuous Integration, this would be the one of the most important things to achieve to not be blocked by other teams, and to deliver independently. As Dave Farley says in How well designed is your Microservice: If we want to evolve the system, add new functionality, we shouldn’t couple parts together. Having a contract will help independent deployments, untie dependencies and therefore delivery time and quality improves.
So how can we achieve the same sense of confidence and make sure every component does what they promise? As described earlier, the MicroFrontend structure is more tree-like, so on every interaction you’ll have a HOST that creates and loads the consumer. Sometimes 2 consumers will want to talk to each other, in these scenarios you can either pipe the communication through the host via an event system or some targeting, or just communicate directly.
You can still define a contract of your communications and the Host MicroFrontend will make sure it sends and receives accordingly. So how do we achieve this?
In our projects we went with the right props down and actions up to benefit from Redux-like advantages, like predictability of state, debuggability, maintainability. So the next step was to find out how we can actually test the contract being right for our components? We want to test if we are matching the same props from sender and receiver side, no mismatch in naming, typing (memberid
vs memberId
, string
vs number
), some cases we wanted to use specific key-values and we also wanted to make sure the actions are met with the agreed payload structure, and no missing fields, and potential errors during the process. For this, we ended up using TypeScript, what covered all these use cases, and if both parts are a TypeScript project, we get compile-time insurance, and if one of them is JavaScript, we can still use the contract as a definition and use other means to secure keeping the contracts rules.
All this definition becomes an easy to follow contract when defined with TypeScript:
I just drafted up a sample contract here, nothing super serious, just wanted to show how types will speak in a contract like this. But here comes the great part: the contract is evaluated at compile time, so you won’t be able to build if you have misconfigured it, and you also get contract code completion.
As you can see we can match payload types to actions and also avoid classic problems like is id
a number or string kind of issues as well. This of course seems obvious in this small example, but if you have a complex system, these mistypings will have some chance of making it unnoticed until production. If you only have TypeScript on only one side of the contract, it’s still a great way to define and share, but on the non-typescript side you’ll need to defend the contract in other ways, like JavaScript unit tests based on the TypeScript contract definition. If you want to modify or version your contract, you can use advanced markings like @deprecated
, and to make the process swift, you can publish to your favourite repository manager like artifactory/nexus/npm from where consumers can keep up to date with the agreed update process.
At this point we’re sure that both the host and consumer can adhere to the contract. But who is responsible for the full product end-to-end, and how do we test integration?
If the project is new, collaboration is inevitable, but contracts allow you to develop without blocking each other. Synchronization points will help you stay on track, and Feature Flags will help you deliver continuously in case a component is only partially done. Once a feature is released and we’re back to business as usual, we still want to have a process in case something slips through the contract and we have a Live incident. Since neither Host nor Consumer should be responsible for more than their side of the contract, we decided that Operations are responsible for the end to end journey, as they should already have a good set of monitoring and alerting in place, and they are the ones on call if anything breaks.
In this diagram you can see this point visualised. Both the Host and the Consumer are only responsible for their side of the contract. They can sort out their quality safety measures as they see fit, they need to make sure their isolated component operates as expected, as designed, and regression tests should be made to make that decision efficient. Some might go for VRT (Visual Regression Testing), some might invest heavily into monitoring, and some might focus on UAT tests running on cypress with the scope of their component, everything else mocked out.
As Geepaw Hill puts it, for automated macro tests the benefits are doubtful. The OPS team will need to focus on monitoring for contract usage.
This is a tough stance, and consideration will be required. If there is a new cross-cutting feature being released, that will need manual testing, and the contract is of course just data, and not necessarily correspond to business logic. The better your organization have domains defined, the less chance for leaking business logic over the contract.
This diagram represents our setup, where you can see how parts become interchangeable thanks to the contract. If you’re developing the Host side, you will not need to care about the actual MicroFrontend but use a Mock. And if you’re developing a MicroFrontend, you shouldn’t care who loads you until it’s defined in the contract. The Harness-Mock pair will also give you a good representation of the creation / loading process.
With some additional work we can help Operations and Devs to effectively identify which side have breached the contract. For this one of the Hosts have made a debug consumer application that we can enable with localStorage.setItem(‘useDebugMicroFrontend’, ‘true’)
. This will serve as a debug output to visualize props sent down and to be able to trigger actions going up. This helps in solving and narrowing down issues that TypeScript cannot defend against, like Fonts overrides, window variables and incorrect zIndices.
With all this work, we are now able to pinpoint who breached the contract, and therefore identify responsibilities fast, and also be able to evolve our frontends independently and swiftly. TypeScript helps a lot for not getting some troublesome issues, and with a good domain based isolation of teams making the ownership decisions of features and bugs will be a breeze :)
#contract-testing #microfrontends #ddd
Thanks for reaching the end, please consider clapping and subscribing!