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:
- You're using TypeScript as the language for an API in Node/Express.
- You're using Objection.js as an ORM for your API, which runs on Knex behind the scenes.
- You're using Jest for testing.
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:
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.
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.
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:
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:
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.)
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.
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.
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.
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.
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.
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