Integration Tests with Jest, Supertest, Knex, and Objection in TypeScript

Recently, I set up unit and integration tests for a Node API in TypeScript, and I couldn't find a lot of resources for setting up and tearing down, database seeding, and hooking everything up in TypeScript, so I'll share the approach I went with.

Prerequisites

This article will help if:

Goals

  • You want to be able to spin up a test database, make real API calls with responses and errors, and tear down the database at the end of the tests.

This is not meant to be a complete tutorial that gives step-by-step instructions for every detail, but will give you the big picture of setting up the TypeScript API with Objection and making a test suite for it.

Installation

This app involves objection, knex, pg, express, and typescript, with jest and supertest for testing.

npm i objection knex pg express
npm i -D typescript jest jest-extended supertest ts-jest ts-node

Setup

Assume you have an API with an endpoint at GET /books/:id that returns a Book object. Your Objection model for the Book would look like this, assuming there's a book table in the database:

book.model.ts
import { Model } from 'objection'

export class Book extends Model {
  id!: string
  name!: string
  author!: string

  static tableName = 'book' // database table name
  static idColumn = 'id' // id column name
}

export type BookShape = ModelObject<Book>

Here's an Express app with a single endpoint. It's essential to export app and NOT run app.listen() here so tests won't start the app and cause issues.

app.ts
import express, { Application, Request, Response, NextFunction } from 'express'
import { Book } from './book.model'

// Export the app
export const app: Application = express()

app.use(express.json())
app.use(express.urlencoded({ extended: true }))

// GET endpoint for the book
app.get(
  '/books/:id',
  async (request: Request, response: Response, next: NextFunction) => {
    try {
      const { id } = request.params

      const book: BookShape = await Book.query().findById(id)

      if (!book) {
        throw new Error('Book not found')
      }

      return response.status(200).send(book)
    } catch (error) {
      return response.status(404).send({ message: error.message })
    }
  }
)

The index.ts is where you would set up your database connection and start the app.

index.ts
import Knex from 'knex'
import { Model } from 'objection'

// Import the app
import { app } from './app'

// Set up the database (assuming Postgres)
const port = 5000
const knex = Knex({
  client: 'pg',
  connection: {
    host: 'localhost',
    database: 'books_database',
    port: 5432,
    password: 'your_password',
    user: 'your_username',
  },
})

// Connect database to Objection
Model.knex(knex)

// Start the app
app.listen(port, () => console.log(`*:${port} - Listening on port ${port}`))

So now you have a complete API for the /books/:id endpoint. This API would start with:

tsc && npm start

Or you could use nodemon to get a dev server going.

Migration

In Knex, you can use a migration to seed the schema/data instead of just using raw SQL. To make a migration, you'd just use the Knex CLI to create a migration file:

knex migrate:make initial-schema

And set up the data - in this case, making book table with a few columns:

db/migrations/initial-chema.js
exports.up = async function (knex) {
  await knex.schema.createTable('book', function (table) {
    table.increments('id').primary().unique()
    table.string('name').notNullable()
    table.string('author').notNullable()
  })
}

exports.down = async function (knex) {
  await knex.schema.dropTable('book')
}

Similar instructions are available for seed.

Test Configuration

Your basic jest.config.js would look something like this:

jest-config.js
module.exports = {
  clearMocks: true,
  moduleFileExtensions: ['ts'],
  roots: ['<rootDir>'],
  testEnvironment: 'node',
  transform: {
    '^.+\\.ts?$': 'ts-jest',
  },
  setupFilesAfterEnv: ['jest-extended'],
  globals: {
    'ts-jest': {
      diagnostics: false,
    },
  },
  globalSetup: '<rootDir>/tests/global-setup.ts',
  globalTeardown: '<rootDir>/tests/global-teardown.ts',
}

Note the globalSetup and globalTeardown properties and their corresponding files. In those files, you can seed and migrate the database, and tear it down when you're done.

Global setup

In the global setup, I made a two step process - first connect without the database to create it, then migrate and seed the database. (Migration instructions are in the Knex documentation.)

tests/global-setup.ts
import Knex from 'knex'

const database = 'test_book_database'

// Create the database
async function createTestDatabase() {
  const knex = Knex({
    client: 'pg',
    connection: {
      /* connection info without database */
    },
  })

  try {
    await knex.raw(`DROP DATABASE IF EXISTS ${database}`)
    await knex.raw(`CREATE DATABASE ${database}`)
  } catch (error) {
    throw new Error(error)
  } finally {
    await knex.destroy()
  }
}

// Seed the database with schema and data
async function seedTestDatabase() {
  const knex = Knex({
    client: 'pg',
    connection: {
      /* connection info with database */
    },
  })

  try {
    await knex.migrate.latest()
    await knex.seed.run()
  } catch (error) {
    throw new Error(error)
  } finally {
    await knex.destroy()
  }
}

Then just export the function that does both.

tests/global-setup.ts
module.exports = async () => {
  try {
    await createTestDatabase()
    await seedTestDatabase()
    console.log('Test database created successfully')
  } catch (error) {
    console.log(error)
    process.exit(1)
  }
}

Global teardown

For teardown, just delete the database.

tests/global-teardown.ts
module.exports = async () => {
  try {
    await knex.raw(`DROP DATABASE IF EXISTS ${database}`)
  } catch (error) {
    console.log(error)
    process.exit(1)
  }
}

Integration Tests

With an integration test, you want to be able to seed some data in the individual test, and be able to test all the successful responses as well as error responses.

In the test setup, you can add any additional seed data to the database that you want, creating a new Knex instance and connecting it to the Objection model.

These tests will utilize Supertest, a popular library for HTTP assertions.

Import supertest, knex, objection, and the app, seed whatever data you need, and begin writing tests.

books.test.ts
import request from 'supertest'
import Knex from 'knex'
import { Model } from 'objection'

import { app } from '../app'

describe('books', () => {
  let knex: any
  let seededBooks

  beforeAll(async () => {
    knex = Knex({
      /* configuration information with test_book_database */
    })
    Model.knex(knex)

    // Seed anything
    seededBooks = await knex('book')
      .insert([{ name: 'A Game of Thrones', author: 'George R. R. Martin' }])
      .returning('*')
  })

  afterAll(() => {
    knex.destroy()
  })

  decribe('GET /books/:id', () => {
    // Tests will go here
  })
})

Successful response test

At this point, all the setup is ready and you can test a successful seed and GET on the endpoint.

tests/books.test.ts
it('should return a book', async () => {
  const id = seededBooks[0].id

  const { body: book } = await request(app).get(`/books/${id}`).expect(200)

  expect(book).toBeObject()
  expect(book.id).toBe(id)
  expect(book.name).toBe('A Game of Thrones')
})

Error response test

It's also important to make sure all expected errors are working properly.

tests/books.test.ts
it('should return 404 error ', async () => {
  const badId = 7500
  const { body: errorResult } = await request(app)
    .get(`/books/${badId}`)
    .expect(404)

  expect(errorResult).toStrictEqual({
    message: 'Book not found',
  })
})

Conclusion

Now once you run npm run test, or jest, in the command line, it will create the test_book_database database, seed it with any migrations you had (to set up the schema and any necessary data), and you can access the database in each integration test.

This ensures the entire process from database seeding to the API controllers are working properly. This type of code will give you full coverage on the models, routes, and handlers within the app.

Comments