NestJS OpenAPI Docs Tests
Writing front-end code to talk to a back-end is so much easier with an autogenerated client. We can use an OpenAPI spec document to generate this client code.
But how do we check that we aren’t breaking these clients, that the front-end code relies on when making changes to the back end code?
When using NestJS it is easy to add decorators on controller methods which will be used to generate an OpenAPI spec document. But to test that we are producing the document we expect, and that we don’t accidentally break something, we can add unit tests.
This also has the advantage of documenting for the front-end developers what the API looks like.
Example
After running the NestJS quickstart, then adding the NestJS OpenAPI bootstrap code, we can move the document creation code into its own function - in its own file:
function createApiDoc(app: INestApplication) {
const options = new DocumentBuilder()
.setTitle('Cats example')
.setDescription('The cats API description')
.setVersion('1.0')
.addTag('cats')
.build();
const document = SwaggerModule.createDocument(app, options);
return document;
}
Then we can write a test to create the doc and check that a section looks as it should - this one will test the SutController
class - which depends on a service called ExampleService
:
let app: INestApplication;
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [SutController],
providers: [ExampleService],
})
.overrideProvider(ExampleService)
.useValue({ getHello: () => 'Hello World!' })
.compile();
app = await module.createNestApplication();
});
it('hello API docs operationId should be AppController_getHello', async () => {
const docs = createApiDoc(app);
expect(docs).toMatchObject({
paths: { '/': { get: { operationId: 'AppController_getHello' } } },
});
});
If we add the operationID to an ApiOperation decorator on the controller method, like this:
@Get()
@ApiOperation({ operationId: "hello" })
getHello(): string {
return this.appService.getHello();
}
Then the test should fail, and we can fix it by updating the test to match the new operationId, like this:
it('hello API docs operationId should be hello', async () => {
const docs = createApiDoc(app);
expect(docs).toMatchObject({
paths: { '/': { get: { operationId: 'hello' } } },
});
});
This way we can demonstrate that the API is working as expected, and also document what the API looks like.
Complex response types
Usually to document a NestJS API, we’d add the NestJS Swagger plugin, which will automatically include default responses in the document. But this only works when running from the command line, so we can’t use it in our unit tests 😔
That means that if we add a test that the docs include the type for a 200 response, like this:
it('hello API docs should include 200 response type string', async () => {
const docs = createApiDoc(app);
const helloRes = docs.paths['/'].get.responses['200'] as ResponseObject;
const resSchema = helloRes.content?.['application/json']?.schema;
expect(resSchema).toEqual({ type: 'string' });
});
Then it will fail - with the following error:
Expected: {"type": "string"}
Received: undefined
To get around this limitation, we can explicitly document the default response type with an annotation. Though this is not as good as if we could use the Swagger plugin in our test of course!
@Get()
@ApiOperation({ operationId: 'hello' })
@ApiResponse({ status: 200, type: String })
getHello(): string {
return this.appService.getHello();
}
But now we have another passing test! 🥳 We have also documented the API in the tests, and prevented regressions. Time for a coffee! ☕