Day 3 Overview: Testing Practices + Integration Tests

Day 3 Overview: Testing Practices + Integration Tests

Testing Practices


Unit Tests for Express Middleware

Let’s look at an example:

import {initDb, generate} from 'til-server-test-utils'
import * as postsController from '../posts'
import db from '../../utils/db'

// I'll give this one to you. You want the database to be fresh
// the initDb function will initialize the database with random users and posts
// you can rely on that fact in your tests if you want.
// (For example, getPosts should return all the posts in the DB)
beforeEach(() => initDb())

test('getPosts returns all posts in the database', async () => {
  // here you'll need to Arrange, Act, and Assert
  // Arrange: set up the req and res mock objects
  // Act: Call getPosts on the postsController with the req and res
  // Assert:
  //   - ensure that your mock object functions were called properly
  //   - BONUS: ensure that the posts returned are the ones in the database `await db.getPosts()`

  const req = {}
  const res = {
    json: jest.fn(),
  }

  await postsController.getPosts(req, res)
  expect(res.json).toHaveBeenCalledTimes(1)
  const allPosts = await db.getPosts()
    //check that our res.json function was called with the appropriate posts
  expect(res.json).toHaveBeenCalledWith({posts: allPosts})
})

Test Object Factories

One of the things that I've come across is needing some sort of setup to be done within my request objects for doing updates that require parameters to be passed down in the body of a request. For example, I could do something like this:

test('getUser returns the specific user', async() => {
    const testUser = await db.insertUser(generate.userData());
    const req = {
        params: {
            id: testUser.id
        }
    };
    const res = {
        json: jest.fn();
    };

    await usersController.getUser(req, res);
    //... different assertions thereafter
})

Additionally, say I want specific methods based on status codes, as my test grows, the requests setup will become more and more sizeable. When they’re almost the same in ALMOST every way, except for specific subtleties, I can use a Test Object Factory aka "Object Mother" but that’s weird so let’s say Test Object Factory.

So let’s see an example of that function which we can call setup():

function setup() {
    const req = {
        body: {}
    }
    const res = {}

    Object.assign(res, {
        status: jest.fn(
            function status() {
                return this
            }.bind(res)
        ),
        json: jest.fn(
            function json() {
                return this
            }.bind(res)
        ),
        send: jest.fn(
            function send() {
                return this
            }.bind(res)
        )
    })

    return {req, res}
}

This allows for flexibility when I need setup for tests, whatever is relevant for the tests, say for object init or state init. I can do it in a beforeEach() but according to Kent, it's not necessarily preferred since it can obscure what’s going on in my tests. Now, implementing this in the previous test would look like this:

test('getUser returns the specific user', async() => {
    const testUser = await db.insertUser(generate.userData());
    const {req, res} = setup();
    const req.params = {id: testUser.id}

    await usersController.getUser(req, res);
    //... different assertions thereafter
})
test('updateUser updates the user with the given changes', async () => {
  const testUser = await db.insertUser(generate.userData())
  const {req, res} = setup()
  req.user = {id: testUser.id}
  req.params = {id: testUser.id}
  const username = generate.username()
  req.body = {username}
  const updatedUser = {...testUser, username}

  await usersController.updateUser(req, res)

  expect(res.json).toHaveBeenCalledTimes(1)
  const firstCall = res.json.mock.calls[0]
  const firstArg = firstCall[0]
  const {user} = firstArg
  expect(user).toEqual(safeUser(updatedUser))
  const userFromDb = await db.getUser(user.id)
  expect(userFromDb).toEqual(updatedUser)
})

One thing to be aware of is that by using these factories, it can be easy to simply copy and paste the body of other tests for new test use cases, which can inadvertently include unnecessary cruft from other tests, ie: additional props on the req object that can create confusion for people in the future trying to maintain tests and thinking that those unnecessary props are needed.

💡
"The whole process of testing is trying to communicate to people who will be maintaining these tests, what is relevant so that when they start making changes they don’t mistakenly think there are things that will be changed from an isolated property." - KCD

One thing that hasn't been talked about with beforeEach() is test clean-up. So my test should be able to run in isolation from any other tests that might be running in parallel. When talking about DBs it might start getting complicated, so in some situations, I can either change tests with anything else writing to the DB at any given time and do clean-up ahead of each test rather than at the end of all tests can be very helpful.

This is where I can use the afterEach() at the end of each test, and it also allows me to see ”the state of the world” at the point tests fail because I can see what was happening at the DB level since each test resets before the next one runs.

Test Driven Development

There are several reasons why I could use Test Driven Development (TDD). However, before getting into that, the TDD framework can be described with the below Red, Green, Refactor cycle which in practice looks like this:

  • Writing some code in a test that fails, writing as little as possible to get something failing

  • Write some source code that passed the test, writing as little as possible to pass the test

  • Refactor source code or test that resembles something shippable.

  • Continue until I finish the feature I'm trying to build

This typically boils down to a workflow technique, a mechanism to enhance a workflow. This prevents having to write a bunch of code that potentially has bugs and won’t be able to find it until I run the code. At this point, I start finding other bugs or problems and end up having to write a bunch of other code before finally finding and addressing the bug (I know because this has happened to me and it’s the main reason I’m trying to force myself to use TDD on my next project).

Sometimes the tests I write might not give me a ton of confidence, and instead, they helped build out features. Those tests can be deleted or refactored to give me more confidence. However, it seems that TDD isn’t the best suited for testing out UI development where it’s a bit harder since it might not be exactly clear how I want something to look and behave— I agree. Kent C Dodds himself doesn’t even use TDD all that often and says we actually shouldn’t force ourselves to do it. So maybe my approach will change to force myself to use tests, but not TDD

💡
EDIT: His Testing-Library makes it easy to use TDD for UI. Regardless, seems like TDD is more of a nice to have and guiding light but might be more impractical than anything— however I won’t really know unless I try.

Now let’s put my test writing knowledge to the test by implementing a new feature using TDD by writing the test before I write the functionality. Let’s look at an example before I go ahead and try it myself. I’ll look at two files, one where I’ll implement my new feature and the next where my test will live and serve as my TDD file:

//feature file
import db from '../utils/db'

// Here's where you'll add your deletePost function!
// It should:
// 1. Attempt to get the post from the database (see updatePost for an example)
// 2. If a post doesn't exist it should send a 404 error
// 3. If the post authorId is not the same as the req.user.id, it should send a 403 error
// 4. Delete the post
// 5. Finally send the json for the deleted post
// Don't forget! It needs to be an async function, and you need to add it to the list of exports below.
async function deletePost(req, res) {
  const post = await db.getPost(req.params.id)
  if (!post) {
    return res.status(404).send()
  }
  if (!req.user || req.user.id !== post.authorId) {
    return res.status(403).send()
  }

  const deletedPost = await db.deletePost(post.id)
  return res.json({post: deletedPost})
}
export {authorize, getPosts, getPost, createPost, updatePost, deletePost}
//test file
// import db from '../../utils/db'
// eslint-disable-next-line no-unused-vars
import {initDb, generate} from 'til-server-test-utils'
import * as postsController from '../posts'
import {deletePost} from '../posts.todo'
import db from '../../utils/db'

// I'll give this one to you. You want the database to be fresh
// the initDb function will initialize the database with random users and posts
// you can rely on that fact in your tests if you want.
// (For example, getPosts should return all the posts in the DB)
beforeEach(() => initDb())

function setup() {
  const req = {
    params: {},
    body: {},
  }
  const res = {}
  Object.assign(res, {
    status: jest.fn(
      function status() {
        return this
      }.bind(res),
    ),
    json: jest.fn(
      function json() {
        return this
      }.bind(res),
    ),
    send: jest.fn(
      function send() {
        return this
      }.bind(res),
    ),
  })
  return {req, res}
}

// Here's where you'll add your new `deletePost` tests!
// - Think more about use cases than code coverage and use those use cases to title your tests
// - Write the code and tests iteratively as little as necessary at a time.
// - Create and use a `setup` test object(s) factory to keep your tests focused

test('deletePost deletes post', async () => {
  const testPost = await db.insertPost(generate.postData())
  const {req, res} = setup()
  req.params = {id: testPost.id}
  req.user = {id: testPost.authorId}

  await deletePost(req, res)
  expect(res.json).toHaveBeenCalledTimes(1)
  const firstCall = res.json.mock.calls[0]
  const firstArg = firstCall[0]
  const {post} = firstArg
  expect(post).toEqual(testPost)
  const postFromDb = await db.getPost(testPost.id)
  expect(postFromDb).not.toBeDefined()
})

test('deletePost will 404 if no post or user is found', async () => {
  const {req, res} = setup()
  req.params = {id: generate.id()}

  await deletePost(req, res)
  expect(res.json).not.toHaveBeenCalled()
  expect(res.status).toHaveBeenCalledTimes(1)
  expect(res.status).toHaveBeenCalledWith(404)
  expect(res.send).toHaveBeenCalledTimes(1)
})

test('deletePost will 403 if not made by the author', async () => {
  const testPost = await db.insertPost(generate.postData())
  const {req, res} = setup()
  const badAuthorId = generate.id()
  req.params = {id: testPost.id}
  req.user = {id: badAuthorId}

  await deletePost(req, res)
  expect(res.json).not.toHaveBeenCalled()
  expect(res.status).toHaveBeenCalledTimes(1)
  expect(res.status).toHaveBeenCalledWith(403)
})

Integration Tests


Basic Integration Test for a Node Server

💡
"The distinction between integration and unit tests can sometimes get a little fuzzy, but for unit test purists, if you consider a unit test to be a module, it will mock out every single one of its dependencies, leading you to only testing out that unit. An integration test will not mock out anything." - KCD

So for an example of an integration test in the Express server, let’s think about what I would want to be testing:

  • Before anything, I would need to start the server

  • Once the server is started I would want to send requests to that server to get responses

So that’s what my test will do— and where should that logic live? In the routes directory, so it would make sense to co-locate the tests within the routes directory, so let’s see what that would look like:

So if I were to go into the index.js file, I would see something like this:

import express from 'express'
import setupUserRoutes from './users'
import setupAuthRoutes from './auth'
import setupPostRoutes from './posts'

function setupRoutes(app) {
  const authRouter = express.Router()
  setupAuthRoutes(authRouter)
  app.use('/api/auth', authRouter)

  const userRouter = express.Router()
  setupUserRoutes(userRouter)
  app.use('/api/users', userRouter)

  const postRouter = express.Router()
  setupPostRoutes(postRouter)
  app.use('/api/posts', postRouter)
}

export default setupRoutes

So let’s break this code down:

  • I've got the setupRoutes function which is setting up the different sub-routes such as authRouter, userRouter, and postRouter

  • From there, each setup[X]Routes file is being imported to then mount the appropriate post, get, delete, etc. actions to each route.

For this example, let’s check out the setupUserRoutes:

import {authMiddleware} from '../utils/auth'
import * as userController from '../controllers/users'

function setupUserRoutes(router) {
  router.get('/', userController.getUsers)

  router.get('/:id', authMiddleware.optional, userController.getUser)

  router.put(
    '/:id',
    authMiddleware.required,
    userController.authorize,
    userController.updateUser,
  )

  router.delete(
    '/:id',
    authMiddleware.required,
    userController.authorize,
    userController.deleteUser,
  )
}

export default setupUserRoutes

Let’s also take a look at what’s going on with this code:

  • First I have the getUsers route

  • Then authMiddleware is used when I try to get a specific user, and if I'm authorized will get info

  • I can also updateUser and deleteUser

Now that I know what’s going on here, I'll be using this file to test out the following flow:

  • I want to register a new user

  • I want to get that user

  • Log in as that user

  • Update that user

  • Delete that user

I can do this in a bunch of different tests, and in some scenarios, it might make more sense, but if I can do it in one single test it would save a bunch of time.

So the first thing I need to do is start up the server, I could do it in a separate process, but if I can get away with it, Kent says that I should try to have the same process running my test, start my server. In a Node context, I can use Node APIs and then Jest can instrument all of that for me, and get better error messages.

So if I look at the entry of the server, it’s not doing a whole lot since all of the modules so far have been pure modules and so all this does it start the server:

import logger from 'loglevel'
import startServer from './start'

const notTest = process.env.NODE_ENV !== 'test'
const isProduction = process.env.NODE_ENV === 'production'
const logLevel = process.env.LOG_LEVEL || (notTest ? 'info' : 'warn')

logger.setLevel(logLevel)

startServer({port: isProduction ? process.env.PORT : undefined})

And looking deeper at the startServer function:

import express from 'express'
import bodyParser from 'body-parser'
import cors from 'cors'
import passport from 'passport'
import logger from 'loglevel'
import 'express-async-errors'
import detectPort from 'detect-port'
import {getLocalStrategy} from './utils/auth'
import setupRoutes from './routes'

async function startServer({port = process.env.SERVER_PORT} = {}) {
  port = port || (await detectPort(8888))
  const app = express()
  app.use(cors())
  app.use(bodyParser.json())
  app.use(passport.initialize())
  passport.use(getLocalStrategy())

  setupRoutes(app)

  return new Promise(resolve => {
    const server = app.listen(port, () => {
      logger.info(`Listening on port ${server.address().port}`)
      const originalClose = server.close.bind(server)
      server.close = () => {
        return new Promise(resolveClose => {
          originalClose(resolveClose)
        })
      }
      resolve(server)
    })
  })
}

export default startServer

So this will be the function I call in my tests to start the server up.

Let’s start with the tests for the users route:

import axios from 'axios'
import {omit} from 'lodash'
import {initDb, generate} from 'til-server-test-utils'
import db from '../../utils/db'
import startServer from '../../start'

jest.unmock('axios')

const getUser = res => res.data.user

let baseURL, api, server

beforeAll(async () => {
  server = await startServer({port: 8788})
  baseURL = `http://localhost:${server.address().port}/api`
  api = axios.create({baseURL})
})

afterAll(() => server.close())

beforeEach(() => initDb())

test('user CRUD', async () => {
  // create
  const registerData = generate.loginForm()
  const testUser = await api.post('auth/register', registerData).then(getUser)
  expect(testUser.username).toBe(registerData.username)

  // read (unauthenticated)
  const readUserUnauthenticated = await api
    .get(`users/${testUser.id}`)
    .then(getUser)
  expect(readUserUnauthenticated).toEqual(omit(testUser, ['token']))

  // get authenticated client
  const authAPI = axios.create({
    baseURL,
  })
  authAPI.defaults.headers.common.authorization = `Bearer ${testUser.token}`

  // read (authenticated)
  const readUserAuthenticated = await api
    .get(`users/${testUser.id}`)
    .then(getUser)
  expect(readUserAuthenticated).toEqual(testUser)

  // update
  const updates = {username: generate.username()}
  const updatedTestUser = await authAPI
    .put(`users/${testUser.id}`, updates)
    .then(getUser)
  expect(updatedTestUser).toMatchObject(updates)

  // delete
  const deletedTestUser = await authAPI
    .delete(`users/${testUser.id}`)
    .then(getUser)
  expect(deletedTestUser).toEqual(updatedTestUser)

  // read of deleted user
  const error = await api
    .get(`users/${updatedTestUser.id}`)
    .catch(e => e.response)
  expect(error.status).toBe(404)
})

test('get users', async () => {
  const {users} = await api.get('users').then(res => res.data)
  const actualUsers = await db.getUsers()
  expect(users).toEqual(actualUsers.map(u => omit(u, ['hash', 'salt'])))
})

Though all of these are “happy path” tests, the reason I want to have all of these at a minimum vs trying to get all edges cases are twofold:

  • Edge cases can be tested at a lower level test or unit test

  • If the “happy path” breaks it would affect the majority of our users vs an edge case would only affect a smaller subset of users.

Wrapping up


The Testing Trophy is Kent’s response to the traditional Testing Pyramid, which said that the tests on the bottom of the Pyramid were cheaper and faster than tests that required you to move up the pyramid, going from Unit -> Integration -> E2E. This reasoning seemed weird since then by logic, people would purely stay at the bottom of the pyramid to reap the cheaper + faster rewards of those tests, never bothering to move up.

However, something that was missing from the pyramid was also that the bottom of the pyramid was perfect for simple problems, but if you were building more complex software, those simple unit tests wouldn’t cut it— he calls it the confidence coefficient. Where, yes it’s true that it’s cheaper and faster to stay with unit tests, but then we’re missing the more complex bugs that could arise, like the checkout button not working, which would be way costlier than anything the simple tests would be covering. Therefore integration tests are truly the big sweet spot that would give us the most bang for our buck and where we should spend a good chunk of time in.

That's it! Thanks for following my progress through this course. Up next I'll be documenting my progress on building a VanillaJS Todo App utilizing Jest as my testing framework and applying these testing practices and principles!