Testing
This guide discusses techniques for testing Nitric applications locally and in the cloud.
We attempt to make the local environment for Nitric as similar as possible to the cloud, so that tests act the same locally as they do in the cloud. That said, there will always be differences between environments and we recommend final testing be performed in a non-production cloud environment.
Unit Testing
In this example we'll write a small API, with some tricks that make unit testing easier.
We'll start by defining the Nitric resources for our application in their own files, separate from handlers and other code. This separation helps with resource reuse and can provide isolation that makes unit testing easier.
import { api } from '@nitric/sdk'
export const helloApi = api('main')
import { bucket } from '@nitric/sdk'
export const imageBucket = bucket('images')
We want to be able to test individual API route handler callbacks without running the entire API or application. Since testing anonymous services in the route callback can be difficult, we'll separate the callbacks into their own files to help with isolation.
import { imageBucket } from '../resources/buckets'
const imageWriter = imageBucket.allow('write')
export const handleHello = async (ctx) => {
const { name } = ctx.req.params
ctx.res.body = `Hello ${name}`
return ctx
}
export const handleAddImage = async (ctx) => {
const { name } = ctx.req.params
await imageWriter.file(name).write(Buffer.from(name))
ctx.res.body = `Successfully added '${name}' image to bucket!`
return ctx
}
import { helloApi } from '../resources/apis'
import { handleHello, handleAddImage } from '../handlers/hello'
helloApi.get('/:name', handleHello)
helloApi.post('/:name', handleAddImage)
Writing the tests
Asserting
Next create a test
directory, then add the example test file as shown below. The logic being split into separate services makes it very easy to test our API.
In this example we're testing that if the function is passed a context with a set name parameter, it should return the same context with a body added to the response. Since the handleHello
function is async we use an async test and await the results.
import { handleHello } from '../handlers/hello'
describe('Testing Hello Service', () => {
describe("Given the name 'Test'", () => {
const ctx = { req: { params: { name: 'Test' } }, res: { body: '' } }
test("It should respond 'Hello Test'", async () => {
await expect(handleHello(ctx)).resolves.toEqual({
...ctx,
res: {
body: 'Hello Test',
},
})
})
})
})
Mocking
The next function handleAddImage
is a bit more challenging to test since it relies on a bucket resource. Unit tests shouldn't perform side-effects, such as reading and writing files, so we'll mock the bucket to keep the tests fast, repeatable, and isolated.
We'll go over it section by section, but here is the full example:
...
describe('Given name is valid', () => {
let writerSpy;
let bucketSpy;
let handleAddImage;
beforeAll(async () => {
writerSpy = jest.spyOn(File.prototype, 'write');
bucketSpy = jest.spyOn(BucketResource.prototype, 'for');
// Doing dynamic imports to mock the BucketResource.for
const helloModule = await import('../services/hello');
handleAddImage = helloModule.handleAddImage;
})
afterAll(() => {
jest.resetAllMocks();
});
beforeEach(() => {
jest.clearAllMocks();
})
test('It should add a new image to the bucket', async () => {
writerSpy.mockResolvedValue();
await expect(handleAddImage(ctx))
.resolves.toEqual({
...ctx,
res: {
body: "Successfully added 'Test'"
}
})
expect(writerSpy).toBeCalledTimes(1)
});
});
Let's start by looking at the mocks. These are done in the beforeAll function, which means it happens before all the tests are run.
The services to create a bucket for
and write to it write
make gRPC calls on the backend. This means that they need to be mocked, since during unit testing the Nitric server is not available to call, and will result in an UNAVAILABLE error. We mock these using jest's spyOn
functions on the object prototype.
This works fine for mocking the write
method as it's within the handleAddImage
function, but the for
method is called when the module is imported. For that reason, we add a dynamic import after for
is mocked.
import { File, BucketResouce } from '@nitric/sdk';
...
let writerSpy;
let bucketSpy;
let handleAddImage;
beforeAll(async () => {
writerSpy = jest.spyOn(File.prototype, 'write');
bucketSpy = jest.spyOn(BucketResource.prototype, 'for');
// Doing dynamic imports to include the BucketResource.for method mock
const helloModule = await import('../services/hello');
handleAddImage = helloModule.handleAddImage;
})
We also add afterAll
and beforeEach
functions to reset the mocks.
afterAll(() => {
jest.resetAllMocks()
})
beforeEach(() => {
jest.clearAllMocks()
})
Now that we have the mocks, we can use them for the tests.
test('It should add a new image to the bucket', async () => {
writerSpy.mockResolvedValue()
await expect(handleAddImage(ctx)).resolves.toEqual({
...ctx,
res: {
body: "Successfully added 'Test'",
},
})
expect(writerSpy).toBeCalledTimes(1)
})
Running the tests
The tests can now be run using Jest, which can be added to your package.json
as a test
script.
"scripts": {
"test": "jest"
}
You can then run the tests locally with:
npm run test
The output should be something like this:
Testing Hello Service
Given we want a greeting
✓ responds with Hello Test (4 ms)
Given we want to add a new image to a bucket
✓ adds a new image to the bucket (5 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 2.292 s, estimated 6 s
Ran all test suites.
✨ Done in 5.18s.
Integration Testing
There are many ways to perform integration tests in Nitric projects. In this example we'll use the testing framework jest and use the HTTP testing library supertest to test an HTTP APT.
Let's start by installing the required dependencies.
npm install --save-dev jest supertest @types/jest @types/supertest
Writing the API
Next, we'll write a small API to do some testing on. Just like in the Unit Testing example, we'll start by creating some Nitric resources.
import { api } from '@nitric/sdk'
export const helloApi = api('main')
import { bucket } from '@nitric/sdk'
export const imageBucket = bucket('images')
Next, let's create an API by defining our routes and their callback functions:
import { imageBucket } from '../resources/buckets'
import { helloApi } from '../resources/apis'
const imageWriter = imageBucket.allow('write')
export const handleHello = async (ctx) => {
const { name } = ctx.req.params
ctx.res.body = `Hello ${name}`
return ctx
}
export const handleAddImage = async (ctx) => {
const { name } = ctx.req.params
await imageWriter.file(name).write(Buffer.from(name))
ctx.res.body = `Successfully added '${name}' image to bucket!`
return ctx
}
helloApi.get('/:name', handleHello)
helloApi.post('/:name', handleAddImage)
Writing the Tests
Now we can start writing the test. First create a test
test directory, then add a test file to it named integration.test.ts
. For the test, we want to create an agent using supertest, then point the agent at the URL of our API.
import supertest from 'supertest'
describe('Testing Hello Api', () => {
const api = supertest('http://localhost:4001')
})
We can then add tests that make requests to the API. We'll start with testing the GET /:name
route.
This request has the name parameter set to 'test'. This means we expect the response to be 'Hello test' and the status code to be 200. We're provided with a done
function in the test callback, we call it when the test is resolved or encounters errors. This is to stop timeouts on the test, as we are testing an async operation.
import supertest from 'supertest'
import assert from 'assert'
describe('Testing Hello Api', () => {
const api = supertest('http://localhost:4001')
describe('Given a request to GET /:name', () => {
test('It should respond with Hello test', (done) => {
api
.get('/test')
.expect(200)
.then((res) => {
assert.equal(res.text, 'Hello test')
done()
})
.catch((err) => done(err))
})
})
})
We can add another test case for hitting the POST /:name
route. This will need to test whether the response from the API resolves correctly, as well as whether the image bucket was updated. It makes a post request to our route and then checks the response just like the last one. Additionally, this test reads from the bucket to verify that the correct content was written.
Make sure you remove the call to done from the assertion in the API route. Otherwise, the bucket test will not be run.
import supertest from 'supertest';
import assert from 'assert';
import { imageBucket } from '../resources/buckets';
describe('Testing Hello Api', () => {
const api = supertest('http://localhost:4001')
...
describe('Given a request to POST /:name', () => {
test('It should add an image to the bucket', (done) => {
api
.post('/test')
.expect(200)
.then(res => {
assert.equal(res.text, "Successfully added 'test' image to bucket!")
})
.catch(err => done(err))
imageBucket
.allow('read')
.file('name')
.read()
.then(val => {
assert.equal(val.toString(), 'test')
done();
})
.catch((err) => done(err));
})
});
});
Local vs Deployed Tests
There are two options for running our tests:
- On your local machine
- Against a deployed API
Local Tests
For a local run, we first need to run the local API, then run the tests. Add this test script to your package.json
.
"scripts": {
"test": "jest"
}
We can then start the API with nitric start
and run the test.
nitric start
npm run test
This will produce the output:
Testing Hello Service
Given a request to GET /:name
✓ responds with Hello test (4 ms)
Given a request to POST /:name
✓ adds a new image to the bucket (5 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 3.292 s, estimated 5 s
Ran all test suites.
✨ Done in 5.18s.
Deployed Tests
When you have an API deployed to the cloud, most cloud providers have a feature in the console to do endpoint testing. However, the tests are often just checking if the API returns a 200 status. The process we are following here will lead to more robust testing and confidence in your application.
The first step is to ensure the application is deployed before running our tests. The quickstart provides details on how to do this for the first time if you're unfamiliar.
Now we can test against the deployed API. To do this we need to swap out our supertest agent host from the localhost
endpoint to the deployed endpoint. This new endpoint will look something like this:
const api = request('https://xxxx.us-east-1.amazonaws.com')
Just like with the local test we can run the test script with npm run test
. This will run the tests against the deployed instance and return your test results.