Link to original blog: this blog here. This will be the basis of this blog so it's required reading as I won't be treading over old ground. It's also an amazing read by a great test engineer.
Co-authored with the help of Nathan Hardy, who came up with the idea for this solution. If blogs aren't for you, you look at my code example on this on my GitHub here.
One of the complexities of building distributed and advanced software is testing the integrations between the different technologies. At Kablamo, when we are building client-facing applications we tend to use a known tech stack. React/TS for our front end, Golang for our backend and a RESTful API; following an OpenApi 3.0 spec. This means that we have a handy YAML file to compare our front end to. I've wanted for years to be able to leverage this file for automated testing as this is often our source of truth for both the Testing team and downstream systems.
This is a producer-driven contract test. As opposed to a consumer-driven contract test (which you should also have), and this will allow a producer of a microservice to be able to check the validity of the objects and types they are producing without having to rely on a mocking service.
Orval is the brain of this module. It is a restful client generator that takes in a swagger schema file and
generates type-safe code. It has a bunch of uses from react-query
to axios-functions
.
Using Orval in this way allows us to take the supplied OpenAPI spec and do two things:
We chose to use Axios as our requests library over the playwright requests library because of how easily we can generate the request functions using orval, allowing us the step of defining our endpoints, which we would have to do manually if we used the base playwright library.
To install Orval use the command below:
bashnpm install --save-dev orval
Here is the example of the orval config that we are using:
typescriptimport { defineConfig } from "orval";import "dotenv/config";import path from "path";const target = path.join(__dirname, "pkg", "openapi.yml"); // Path to OpenAPI spec fileexport default defineConfig({Zod: {input: {target: target,validation: false,},output: {mode: "tags",target: ".zod/index.ts",schemas: ".zod/types",client: "zod",},},axios: {input: {target: target,validation: false,},output: {mode: "tags",target: ".axios/index.ts",schemas: ".axios/types",client: "axios-functions",},},});
One side note is Orval's integration with Zod is quite new as of 2024, so expect some rough edges if your
swagger specs some advanced or seldom-used features. One thing we encountered were issues around anyOf
and allOf
at the top-level of a request or response body
which affected our ability to generate responses. We had to flatten the schemas to solve this issue,
so there might be post-processing of your OpenAPI spec file required.
Zod is a Typescript-based validation library. Zod uses the concept of a schema to compare the shape of one object to another. This is more useful than the scope of an API and can be used for comparing any two JSON objects.
The way we are using Zod is to compare the response that we get from Axios (or playwright requests) to a pre-generated z.object
. This gives us quick
type-safe testing for APIs and allows our front end team to have confidence in the schema they are hooking into.
It is worth saying that we are autogenerating this schema but if your code had odd requirements or had external dependencies you needed to test, you can manually define a zod schema to extend this testing beyond Orval.
You can install Zod easily using:
bashnpm install zod
Once all the prerequisites are installed you can then just generate a handy package.json
script or run it in your shell.
json"codegen": "orval --config orval.config.ts"
You should see generated files called .axios
and .zod
. I would recommend adding these to your .gitignore
as they should be generated
at the point of testing to avoid getting stale.
Also, expect some schemas like this Axios:
typescriptexport const getApiV1Authors = <TData = AxiosResponse<Author[]>>(options?: AxiosRequestConfig,): Promise<TData> => {return axios.get(`/api/v1/Authors`, options);};export const postApiV1Authors = <TData = AxiosResponse<Author>>(author: Author,options?: AxiosRequestConfig,): Promise<TData> => {return axios.post(`/api/v1/Authors`, author, options);};
and from Zod:
typescriptexport const getApiV1AuthorsResponseItem = zod.object({id: zod.number().optional(),idBook: zod.number().optional(),firstName: zod.string().nullish(),lastName: zod.string().nullish(),});export const getApiV1AuthorsResponse = zod.array(getApiV1AuthorsResponseItem);export const postApiV1AuthorsBody = zod.object({id: zod.number().optional(),idBook: zod.number().optional(),firstName: zod.string().nullish(),lastName: zod.string().nullish(),});export const postApiV1AuthorsResponse = zod.object({id: zod.number().optional(),idBook: zod.number().optional(),firstName: zod.string().nullish(),lastName: zod.string().nullish(),});
Then we can combine all of this in a playwright script:
typescriptimport { test, expect } from "@playwright/test";import { getApiV1Authors } from "../.axios/authors";import { getApiV1AuthorsResponse } from "../.zod/authors";test("Get Authors", async () => {const response = await getApiV1Authors();expect(response.status).toBe(200);expect(response.data).toMatchSchema(getApiV1AuthorsResponse);});
And just like that you have schema matching in Playwright! Enjoy!