Testing Typescript GraphQL Server using Jest with Docker

While working on a backend built using typescript, graphql, and mongoDB I reached a point where I needed some code coverage to continue development. Documenting test cases in graphql playground wasn't scaling anymore and I'm strictly against using Google Docs/Sheets for documenting test cases and running them manually after every code change. So, I decided to go with Jest for executing and documenting the test cases.

Overview

This article assumes that you have an existing backend codebase that uses typescript, graphql, and mongodb and is executed using docker. So, the basic infrastructure looks something like this, a docker-compose file runs the backend and database docker containers where the backend service can connect to the database container on a specific port. The backend service runs graphql and listens for connections on a specific port on the local machine and runs graphql playground for manually testing the queries & mutations.

Also, I set up my backend service to read the database connection details (url & name) from the environment variables, which comes from .env file. Make sure to add this file to gitignore to ensure this doesn't get pushed to the remote and other environments (eg. production).

Setting up Jest

Assuming that you already have everything from the overview section running as expected and you're able to use graphql playground to execute queries and mututaions, we'll get started with setting up Jest in this section.

Since we're using Typescript, I will be working with ts-jest.

Installation

Run the following commands in your terminal.

# Install pre-requisites
npm i -D jest typescript
# Install ts-jest
npm i -D ts-jest @types/jest
# Initiallize
npx ts-jest config:init

Once we've installed ts-jest, let's add a test script to our package.json which will run jest on our codebase and execute all the *.test.js tests files.

"scripts": {
  /* ... */
  "test": "jest"
},

Connecting to Database

In order to connect jest to our database, we would need to read the connection string from the environment variable. There are multiple ways to access environment variables in jest, so we'll cover the easiest method here.

We can tell jest when it gets executed to provide access to the environment variables inside test files by passing an argument to the jest call in package.json.

"scripts": {
  /* ... */
  "test": "jest --setupFiles dotenv/config"
},

The --setupFiles dotenv/config argument allows us to access environment variable within test files.

Now that we have access to the environment variables, let's write our first test which will connect to the database. src/first.test.ts

import mongoose, { Mongoose } from "mongoose";

let conn: Mongoose;

beforeAll(async () => {
  conn = await mongoose.connect(
    `${process.env.DATABASE_URL}/${process.env.DATABASE_NAME}`,
    {
      useNewUrlParser: true,
      useUnifiedTopology: true,
      useCreateIndex: true,
      useFindAndModify: false,
    }
  );
});

afterAll(() => {
  conn.disconnect();
});

Note that we don't have a test cases yet, but we're just setting up the test to connect to the database before starting and close the connection after completing all the tests.

Connecting to Graph for Testing

Now let's set up our test file to connect to our graph and call the queries and mutations from the schema. For this, we'll create a new graph that takes our schema and executes our queries & mutations on it.

Let's create a helper file that will set up the graph and help execute queries and mutations from test files. Let's call this file graphqlHelper.ts:

import { graphql } from "graphql";
import { makeExecutableSchema } from "@graphql-tools/schema";
import { typeDefs } from "../typeDefs/typeDefs";
import { resolvers } from "../resolvers/resolvers";
import { User, IUser } from "../entity";
import { UserService } from "../services";

// Create the schema from typeDefs and resolvers
const schema = makeExecutableSchema({
  typeDefs: typeDefs,
  resolvers,
});

// Create the helper method that returns the graph
export const graphqlHelper = async (
  query: any,
  variables?: any,
  user?: IUser,
) => {
  // Create the graph
  return graphql(
    schema,
    query, // Query or Mutations will be passed when calling this helper
    undefined,
    {
      req: {
        user, // add the user object to the req object
        userId: user?._id, // add the userId object to the req object
      },
      res: {
        cookie: () => {},
        clearCookie: () => {}
      },
      // Add user & userId to context to simulate logged in state
      user: user, // add the user object to the context
      userId: user?._id, // add the userId object to the context

      // Add the entities to the context
      User,

      // Add the services to the context 
      UserService,
    },
    variables // variables that gets passed when graphqlHelper method is called
  );
};

Now that we have the graphqlHelper method, we will import it in our test file first.test.js

import mongoose, { Mongoose } from "mongoose";
import { graphqlHelper } from "./graphqlHelper";

let conn: Mongoose;

beforeAll(async () => {
  conn = await mongoose.connect(
    `${process.env.DATABASE_URL}/${process.env.DATABASE_NAME}`,
    {
      useNewUrlParser: true,
      useUnifiedTopology: true,
      useCreateIndex: true,
      useFindAndModify: false,
    }
  );
});

afterAll(() => {
  conn.disconnect();
});

Writing tests

Now that we have everything set up, let's write some test cases in our first.test.js file. For the purpose of this article I will be writing a simple register, login and me test case where we will test creating a new user using the register mutation, logging in with that user using login mutation and then fetching the logged in user's information using the me query.

import mongoose, { Mongoose } from "mongoose";
import { graphqlHelper } from "./graphqlHelper";
import { User } from "../entity";

let conn: Mongoose;

beforeAll(async () => {
  conn = await mongoose.connect(
    `${process.env.DATABASE_URL}/${process.env.DATABASE_NAME}`,
    {
      useNewUrlParser: true,
      useUnifiedTopology: true,
      useCreateIndex: true,
      useFindAndModify: false,
    }
  );
});

afterAll(async () => {
  // Cleanup users created as part of test
  await User.deleteMany({ email: /.*@example-jest-user-test.com/ });
  conn.disconnect();
});


const registerMutation = `
  mutation RegisterMutation($email: String!, $password: String!, $firstName: String!, $lastName: String!) {
    register(email: $email, password: $password, firstName: $firstName, lastName: $lastName)
  }
`;

const loginMutation = `
  mutation LoginMutation($email: String!, $password: String!) {
    login(email: $email, password: $password) {
      _id
      email
    }
  }
`;

const meQuery = `
  query MeQuery {
    me {
      _id
      email
    }
  }
`;

describe("resolvers", () => {
  it("register, login, and me", async () => {
    const randomNumber = Math.floor(1000 * Math.random());
    const testUser = { email: `jestuser+${randomNumber}@example-jest-user-test.com`, password: "testpass", firstName: "Jest", lastName: "User" };

    const registerResponse = await graphqlHelper(registerMutation, {
      email: testUser.email,
      password: testUser.password,
      firstName: testUser.firstName,
      lastName: testUser.lastName,
    });

    expect(registerResponse).toEqual({ data: { register: true } });

    const dbUser = await User.findOne({ email: testUser.email });

    expect(dbUser).toBeDefined();

    const loginResponse = await graphqlHelper(loginMutation, {
      email: testUser.email,
      password: testUser.password,
    });

    expect(loginResponse).toEqual({
      data: {
        login: {
          _id: `${dbUser!._id}`,
          email: dbUser!.email,
        }
      }
    });

    const meResponse = await graphqlHelper(meQuery, {}, dbUser || undefined);

    expect(meResponse).toEqual({
      data: {
        me: {
          _id: `${dbUser!._id}`,
          email: dbUser!.email,
        }
      }
    });
  });
});

Executing

We have the tests ready and now we just need to execute them. However, there's one problem. When you run npm run test in your local (host) terminal, the test fails as the database connection times out. This is happening because the database is running inside docker and the container is only accessible from within the docker environment.

So, to execute, let's open the terminal of our backend docker container. To do this, we'll ssh into the docker container. We first need the ID of the backend container. So let's run this command in our terminal:

docker ps

And you should see an output like this:

CONTAINER ID   IMAGE                       COMMAND                  CREATED      STATUS      PORTS                                            NAMES
8491cf18587f   backend_server   "docker-entrypoint.s…"   2 days ago   Up 2 days   0.0.0.0:4000->4000/tcp, 0.0.0.0:9229->9229/tcp   backend-server
0f15447c0ae2   mongo                       "docker-entrypoint.s…"   2 days ago   Up 2 days   0.0.0.0:27017->27017/tcp                         db

From here, copy the CONTAINER ID of the backend server and then run the following command:

docker exec -it 8491cf18587f /bin/bash

where 8491cf18587f is the CONTAINER ID of the backend docker container.

Once you have access to the container's terminal, navigate to the folder which contains the app files (including package.json). This might look something like

cd /app/

depending on your docker configuration.

Once you're in that folder, run npm run test to execute the tests within the docker container. This time you should see the tests get executed and an output that looks something like this

root@8491cf18587f:/app# npm run test

> server@0.0.1 test /app
> jest --setupFiles dotenv/config

 PASS  dist/first.test.js
 PASS  src/first.test.ts

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        4.257 s
Ran all test suites.

root@8491cf18587f:/app#

Voila! You have succesfully executed your Jest tests from within your docker container. Now the only thing left to do it is to write a docker-compose file that will allow you to execute the tests from your local (host) terminal.

Docker-Compose

Let's write a docker-compose file that can execute our test cases from our terminal without having to connect to the container's terminal using SSH. Here is what we need to do to in this docker compose file, run the mongoDB image and then run the backend image, but in the backend image instead of running the command npm run dev (or whatever is your execution script), we'll run npm run test.

This is what the docker-compose.test.yml file should look like:

version: "3.7"

services:
  test-server:
    build:
      context: .
      dockerfile: Dockerfile
      target: base
    volumes:
      - ./src:/app/src
    container_name: test-server
    expose:
      - 4000
    ports:
      - "4000:4000"
      - "9229:9229"
    command: npm run test
    links:
      - db

  db:
    container_name: db
    image: mongo
    ports:
      - 27017:27017
    volumes:
      - data:/data/db

volumes:
  data:

Now let's run this docker-compose file from the terminal:

docker-compose -f docker-compose.test.yml up

This will run the two containers and then execute the jest test scripts and print a similar output as before in the console. However, you'll see that running this will result in console showing the logs from the db container as well and if you want your console to look clean and only have the output of npm run test then launch the docker compose using this command:

docker-compose -f docker-compose.test.yml run test-server

You should now see the output of the test case execution in your terminal.

Once you have executed the test cases, run the docker-compose down command to kill the containers.

docker-compose -f docker-compose.test.yml down

And that's it, you've successfully configured jest to execute test cases on your graphql typescript server inside docker!