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.

resources/apis.js
import { api } from '@nitric/sdk'

export const helloApi = api('main')
resources/buckets.js
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.

handlers/hello.js
import { imageBucket } from '../resources/buckets'

const imageWriter = imageBucket.for('writing')

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
}
services/hello.js
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.

test/example.test.js
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:

test/example.test.js
  ...

  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.

resources/apis.js
import { api } from '@nitric/sdk'

export const helloApi = api('main')
resources/buckets.js
import { bucket } from '@nitric/sdk'

export const imageBucket = bucket('images')

Next, let's create an API by defining our routes and their callback functions:

services/hello.js
import { imageBucket } from '../resources/buckets'
import { helloApi } from '../resources/apis'

const imageWriter = imageBucket.for('writing')

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.

tests/integration.test.js
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.

tests/integration.test.js
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.

tests/integration.test.ts
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
        .for('reading')
        .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:

  1. On your local machine
  2. 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:

tests/integration.test.js
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.