Contract testing with OpenAPI & TypeScript

Posted 16 June 2023


In larger organisations with multiple software development teams working together cross-boundary interactions between systems is a often a point of failure. Establishing clearly defined APIs and agreement on how these function can help reduce a large amount of integration errors. Using tools like OpenAPI and TypeScript can provide these clear definitions that can be validated with automated testing.

Scenario

Imagine a scenario where a team of engineers are implementing a Python backend service. They are working closely with a team implementing a web application that will use this service for part of the functionality. They agree on the system requirements and how the API will function, but over time multiple teams become consumers of this service, features are added and changed and bugs are introduced.

How can we add automated testing to help catch integration issues? End to end integration tests are a good idea but have some downsides. Spinning up the entire stack requires agreement on tooling and the tests can be slow to run. Add in multiple consumers and this sort of set-up requires a lot of engineering time and effort to maintain.

Contract testing, ie validating that requests made to the service return the expected status codes and data structure, can help catch unnoticed bugs and breaking changes requiring minimal agreement on technology choices.

Using OpenAPI and TypeScript

OpenAPI is a popular standard for documenting APIs, compatible with JSON Schema and a wide array of frameworks. TypeScript is a popular choice for introducing static typing to JavaScript applications.

With the backend service providing an OpenAPI YAML or JSON schema frontend applications can use openapi-typescript to generate type definitions for the service's API:

$ npx openapi-typescript https://service.dev/openapi.json -o ./schema.d.ts

These types can be manually referenced when making requests and handling responses, or used with a client like openapi-fetch to provide type-safe requests.

Running tsc --noEmit can run type-checking to catch any issues.

Integrating with CI

Within the frontend project it's fairly straightforward: add a CI step that pulls the latest OpenAPI spec from the production service to generate type definitions with openapi-typescript and then run type checking with tsc.

For the backend project if your CI platform supports it you can trigger a pipeline in consuming projects. For example with GitLab CI's multi-project pipelines:

# Backend service

## Generate `openapi.json` and store as an artifact
generate-openapi:
  stage: test
  artifacts:
    paths:
      - openapi.json
    when: always
  script:
   - make generate-openapi

## Trigger a downstream pipeline in consumer
validate-openapi:
  stage: test
  needs: ["generate-openapi"]
  variables:
    UPSTREAM_REF: $CI_MERGE_REQUEST_REF_PATH
  trigger:
    project: path/to/consumer
    branch: master
    strategy: depend

# Frontend service
## Generate types from `openapi.json` schema and use `tsc` to type check 
validate-openapi:
  stage: test
  needs:
   - project: path/to/service 
     job: generate-openapi
     ref: $UPSTREAM_REF
     artifacts: true
  rules:
    - if: $CI_PIPELINE_SOURCE == "pipeline"
  script:
    - npx openapi-typescript openapi.json --output path/to/schema.ts
    - tsc --noEmit

Any changes made to the backend service are now validated for breaking changes across consuming applications.

Summing up

In an ideal world you would have a cross-functional team that maintains it's own stack in entirety. In larger organisations this often isn't the case and cross-team collaboration can be challenging to coordinate. Good communication is key, but automated contract testing can help catch issues that would otherwise slip through to production. Even with a single service/consumer maintained by a single team API changes can prove difficult to reason as system complexity grows. OpenAPI and TypeScript are great tools for an improved developer experience and can be used quite simply to form strong, testable contracts between systems.


Related posts

Platform team challenges

Published

Challenges faced when introducing a platform team

Dynamically load remoteEntry.js files

Published

Control loading Webpack Module Federation remoteEntry.js files to improve peformance

Mock server-sent events (SSE) with msw

Published

Mock Service Worker supports mocking SSE


Thanks for reading

I'm Alex O'Callaghan and this is my personal website where I write about software development and do my best to learn in public. I currently work at Mintel as a Principal Engineer working primarily with React, TypeScript & Python.

I've been leading one of our platform teams, first as an Engineering Manager and now as a Principal Engineer, maintaining a collection of shared libraries, services and a micro-frontend architecture.