NestJS is fast becoming the de facto framework for NodeJS projects. Unlike older frameworks, NestJS was built with TypeScript, which has become commonplace in the JavaScript community. Frameworks like NestJS seem to be preferred by teams that adopt TypeScript.

NestJS supports building APIs in REST and GraphQL. The goal of this tutorial is to show how you can add unit and integration tests to a NestJS GraphQL project and automate the testing process with CircleCI.

Prerequisites

To follow along with this tutorial, you will need to have a few things in place:

Cloning a sample project

This tutorial uses an existing NestJS application. It is a small invoicing application that demonstrates how to build a GraphQL API using NestJS.

To begin, clone the project. It already contains some tests that I will explain. Then we will automate the process of running the tests using CircleCI. The project has two modules: customer and invoice. Each module contains a service and a resolver file. The example tests will focus on the customer module.

Unit testing the customer service

The customer service has 3 methods: create, findAll, and findOne. We will add a test for each of the methods. The customer.service.spec.ts file already includes a test boilerplate that we can use to test the customer service. Before adding each test, you need to configure the testing module by providing it with all required dependencies. The test configuration is added in a beforeEach block. Here is the configuration for the customer service:

    // src/customer/customer.service.spec.ts

    import { Test, TestingModule } from '@nestjs/testing';
    import { getRepositoryToken } from '@nestjs/typeorm';
    import { Repository } from 'typeorm';
    import { CustomerModel } from './customer.model';
    import { CustomerService } from './customer.service';
    type MockType<T> = {
      [P in keyof T]?: jest.Mock<{}>;
    };
    describe('CustomerService', () => {
      let service: CustomerService;
      const customerRepositoryMock: MockType<Repository<CustomerModel>> = {
        save: jest.fn(),
        findOne: jest.fn(),
        find: jest.fn()
      }
      beforeEach(async () => {
        const module: TestingModule = await Test.createTestingModule({
          providers: [
            CustomerService,
            {
               provide: getRepositoryToken(CustomerModel), useValue: customerRepositoryMock              }
          ]
        }).compile();
        service = module.get<CustomerService>(CustomerService);
      });
    
    });

We have added two providers: CustomerService and CustomerRepository. Because this is a unit test, we are using a mock value for CustomerRepository. Here is a complete test for each of the methods:

    // src/customer/customer.service.spec.ts

    import { Test, TestingModule } from '@nestjs/testing';
    import { getRepositoryToken } from '@nestjs/typeorm';
    import { Repository } from 'typeorm';
    import { CustomerModel } from './customer.model';
    import { CustomerService } from './customer.service';
    type MockType<T> = {
      [P in keyof T]?: jest.Mock<{}>;
    };
    describe('CustomerService', () => {
      let service: CustomerService;
      const customerRepositoryMock: MockType<Repository<CustomerModel>> = {
        save: jest.fn(),
        findOne: jest.fn(),
        find: jest.fn()
      }
      beforeEach(async () => {
        const module: TestingModule = await Test.createTestingModule({
          providers: [
            CustomerService,
            {
               provide: getRepositoryToken(CustomerModel), useValue: customerRepositoryMock ,
         
            }
          ]
        }).compile();
        service = module.get<CustomerService>(CustomerService);
      });
      it('should be defined', () => {
        expect(service).toBeDefined();
      });
      describe('create', () => {
        it('should create a new customer', async () => {
          const customerDTO = {
            name: 'John Doe',
            email:'john.doe@email.com',
            phone: '3134045867',
            address: '123 Road, Springfied, MO'
          }
          customerRepositoryMock.save.mockReturnValue(customerDTO);
          const newCustomer = await service.create(customerDTO) 
          expect(newCustomer).toMatchObject(customerDTO)
          expect(customerRepositoryMock.save).toHaveBeenCalledWith(customerDTO)
        })
      })
      describe('findAll', () => {
        it('should find all customers', async () => {
          const customers =  [
             {
              id: '1234',
              name: 'John Doe',
              email:'john.doe@email.com',
              phone: '3134045867',
              address: '123 Road, Springfied, MO'
            },
            
            {
              id: '5678',
              name: 'John Ford',
              email:'john.ford@email.com',
              phone: '3134045867',
              address: '456 Road, Springfied, MO'
            }
          ]
          customerRepositoryMock.find.mockReturnValue(customers);
          const foundCustomers = await service.findAll() 
          expect(foundCustomers).toContainEqual({
            id: '1234',
            name: 'John Doe',
            email:'john.doe@email.com',
            phone: '3134045867',
            address: '123 Road, Springfied, MO'
          })
          expect(customerRepositoryMock.find).toHaveBeenCalled()
        })
      })
      describe('findOne', () => {
        it('should find a customer', async () => {
          const customer = {
            id: '1234',
            name: 'John Doe',
            email:'john.doe@email.com',
            phone: '3134045867',
            address: '123 Road, Springfied, MO'
          }
          customerRepositoryMock.findOne.mockReturnValue(customer);
          const foundCustomer = await service.findOne(customer.id) 
          expect(foundCustomer).toMatchObject(customer)
          expect(customerRepositoryMock.findOne).toHaveBeenCalledWith(customer.id)
        })
      })
    });
    

Testing the customer resolver

There is also boilerplate code for testing the resolver: customer.resolver.spec.ts. The test for the resolver is similar to that of the service test. We will provide mock values for the CustomerService and InvoiceService dependencies.

    // src/customer/customer.resolver.spec.ts

    import { Test, TestingModule } from '@nestjs/testing';
    import { InvoiceService } from '../invoice/invoice.service';
    import { CustomerDTO } from './customer.dto';
    import { CustomerResolver } from './customer.resolver';
    import { CustomerService } from './customer.service';
    const invoice = {
      id: '1234',
      invoiceNo: "INV-01",
      description: "GSVBS Website Project",
      customer: {},
      paymentStatus: "Paid",
      currency: "NGN",
      taxRate: 5,
      taxAmount: 8000,
      subTotal: 160000,
      total: 168000,
      amountPaid: "0",
      outstandingBalance: 168000,
      issueDate: "2017-06-06",
      dueDate: "2017-06-20",
      note: "Thank you for your patronage.",
      createdAt: "2017-06-06 11:11:07",
      updatedAt: "2017-06-06 11:11:07"
    }
    describe('CustomerResolver', () => {
      let resolver: CustomerResolver;
      beforeEach(async () => {
        const module: TestingModule = await Test.createTestingModule({
          providers: [
            CustomerResolver,
            {
              provide: CustomerService,
              useFactory: () => ({
                create: jest.fn((customer: CustomerDTO) => ({
                  id: '1234',
                  ...customer
                })),
                findAll: jest.fn(() => (
                  [
                    {
                     id: '1234',
                     name: 'John Doe',
                     email:'john.doe@email.com',
                     phone: '3134045867',
                     address: '123 Road, Springfied, MO',
                   },
                   
                   {
                     id: '5678',
                     name: 'John Ford',
                     email:'john.ford@email.com',
                     phone: '3134045867',
                     address: '456 Road, Springfied, MO'
                   }
                 ]
                )),
                findOne: jest.fn((id: string) => ({
                  id: id,
                  name: 'John Doe',
                  email:'john.doe@email.com',
                  phone: '3134045867',
                  address: '123 Road, Springfied, MO'
                }))
              })
            },
            {
              provide: InvoiceService,
              useFactory: () => ({
                findByCustomer: jest.fn((id: string) => (invoice))
              })
            }
          ],
        }).compile();
        resolver = module.get<CustomerResolver>(CustomerResolver);
      });
      it('should be defined', () => {
        expect(resolver).toBeDefined();
      });
      describe('customer', () => {
        it('should find and return a customer', async () => {
          const customer = await resolver.customer('1234')
          expect(customer).toEqual(
            {
              id: '1234',
              name: 'John Doe',
              email: 'john.doe@email.com',
              phone: '3134045867',
              address: '123 Road, Springfied, MO'
            }
          )
        })
      })
      describe('customers', () => {
        it('should find and return a list of customers', async () => {
          const customers = await resolver.customers()
          expect(customers).toContainEqual(
            {
              id: '1234',
              name: 'John Doe',
              email: 'john.doe@email.com',
              phone: '3134045867',
              address: '123 Road, Springfied, MO'
            }
          )
        })
      })
      describe('invoices', () => {
        it('should find and return a customer invoice', async () => {
          const customer = await resolver.invoices({id: '1234'})
          expect(customer).toEqual(invoice)
        })
      })
      describe('createCustomer', () => {
        it('should find and return a customer invoice', async () => {
          const customer = await resolver.createCustomer(
            'John Doe',
            'john.doe@email.com',
            '3134045867',
            '123 Road, Springfied, MO'
            )
          expect(customer).toEqual(
            {
              id: '1234',
              name: 'John Doe',
              email:'john.doe@email.com',
              phone: '3134045867',
              address: '123 Road, Springfied, MO'
            }
          )
        })
      })
    });
    

Automating end-to-end testing

End-to-end tests do not use mocked values because the goal is to test each of the components (model, resolver, and service) and make sure they are working together properly.

The TypeORM configuration for the test database is a little bit different from the config for the main database. I created a config and added a config.database.ts to export the TypeORM configuration based on the environment (test or development).

    import dotenv from 'dotenv'
    dotenv.config();
    const database = {
        development:   {
          type: "postgres",
          host: "localhost",
          port: 5432,
          username: "godwinekuma",
          password: "",
          database: "invoiceapp",
          entities: [
            "dist/**/*.model.js"
          ],
          synchronize: false,
          uuidExtension: 'pgcrypto'
        },
        test:  {
          type: "postgres",
          host: "localhost",
          port: 5432,
          username: process.env.POSTGRES_USER,
          password: "",
          database: process.env.POSTGRES_DB,
          entities: [
            "src/**/*.model.ts"
          ],
          synchronize: true,
          dropSchema: true,
          migrationsRun: false,
          migrations: [
            "src/database/migrations/*.ts"
          ],
          cli: {
            migrationsDir: "src/database/migrations"
        },
        keepConnectionAlive: true,
        uuidExtension: 'pgcrypto'
        }
      }
      const DatabaseConfig = () => ({
        ...database[process.env.NODE_ENV]
      })
      export = DatabaseConfig;

Notice that dropSchema is set to true; this will delete your data after the tests to isolate them from previous tests and runs. After each test runs, it is good practice to delete all data for every single entity registered in the connection. Create a connection.ts file in the test folder and add this code:


    // /test/connection.ts
    import {createConnection, getConnection} from 'typeorm';
    
    const connection = {
    
      async close(){
        await getConnection().close(); 
      },
    
      async clear(){
        const connection = getConnection();
        const entities = connection.entityMetadatas;
    
        entities.forEach(async (entity) => {
          const repository = connection.getRepository(entity.name);
          await repository.query(`DELETE FROM ${entity.tableName}`);
        });
      },
    };
    export default connection;

connection exports two methods close and clear. To close the connection to the database, close is called after all the tests have been executed. Before each test, clear is called to remove all the data before another test runs.

Add the tests:

    // /test/customer.e2e-spec.ts
    
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request = require('supertest');
import { AppModule } from '../src/app.module';
import connection from './connection';
import { getConnection } from 'typeorm';
import { CustomerModel } from '../src/customer/customer.model';

describe('CustomerResolver (e2e)', () => {
  let app: INestApplication;

  beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await connection.clear();
    await app.init();
  });

  afterAll(async () => {
    await connection.close();
    await app.close();
  });

  const gql = '/graphql';

  describe('createCustomer', () => {
    it('should create a new customer', () => {
      return (
        request(app.getHttpServer())
          .post(gql)
          .send({
            query:
              'mutation {createCustomer(name: "John Doe", email: "john.doe@example.com", phone: "145677312965", address: "123 Road, Springfied, MO") {address name phone email}}',
          })
          .expect(200)
          .expect((res) => {
            expect(res.body.data.createCustomer).toEqual({
              name: 'John Doe',
              email: "john.doe@example.com",
              phone: "145677312965",
              address: "123 Road, Springfied, MO"
            });
          })
      )
    })

    it('should get a single customer by id', () => {
      let customer
      return (
        request(app.getHttpServer())
          .post(gql)
          .send({
            query:
              'mutation {createCustomer(name: "John Doe", email: "john.doe@example.com", phone: "145677312965", address: "123 Road, Springfied, MO") {address name id phone email}}',
          })
          .expect(200)
          .expect((res) => {
            customer = res.body.data.createCustomer;
          })
          .then(() =>
            request(app.getHttpServer())
              .post(gql)
              .send({
                query:
                  `{customer(id: "${customer.id}") {address name id phone email}}`,
              })
              .expect(200)
              .expect((res) => {
                expect(res.body.data.customer).toEqual({
                  id: customer.id,
                  address: customer.address, name: customer.name, phone: customer.phone, email: customer.email
                });
              })
          )
      )
    })

    it('should retrieve all customer data', async () => {
      const data = [
        {
          name: 'John Doe',
          email: "john.doe@example.com",
          phone: "145677312965",
          address: "123 Road, Springfied, MO"
        },
        {
          name: 'Jane Doe',
          email: "jane.doe@example.com",
          phone: "145677312900",
          address: "456 Road, Springfied, MO"
        }
      ]
      const connection = await getConnection()
      data.map(async (item) => {
        await connection.createQueryBuilder().insert().into(CustomerModel).values(item).execute()
      })

      request(app.getHttpServer())
      .post(gql)
      .send({
        query:
          `{customers() {address name phone email}}`,
      })
      .expect(200)
      .expect((res) => {
        expect(res.body.data.customers.length).toEqual(data.length)
        expect(res.body.data.customers[0]).toEqual(data[0])
      })
    })
  })
});
    

Adding CircleCI configuration to the project

To begin our test automation, we need to build a continuous integration pipeline that CircleCI will run. The pipeline will:

  • Check out our repository
  • Restore existing cache
  • Install the project dependencies
  • Save the cache
  • Run unit tests
  • Run end-to-end tests

To begin, create a folder named .circleci at the root of your project. Create a file named config.yml inside it. Now paste this code inside:

    version: 2.1
    workflows:
      build:
        jobs:
          - test
    jobs:
      test:
        docker:
          - image: cimg/node:10.23
          - image: cimg/postgres:10.17
            environment:
              POSTGRES_USER: circleci
              POSTGRES_DB: circleci
              POSTGRES_HOST_AUTH_METHOD: "trust"
        environment:
          NODE_ENV: test
          POSTGRES_USER: circleci
          POSTGRES_DB: circleci
          POSTGRES_HOST_AUTH_METHOD: "trust"
        steps:
          - run:
              name: Waiting for Postgres to be ready
              command: dockerize -wait tcp://localhost:5432 -timeout 1m
          - checkout
          - restore_cache:
              key: v1-deps-{{ checksum "package.json" }}
          - run: yarn install
          - save_cache:
              paths:
                - node_modules
              key: v1-deps-{{ checksum "package.json" }}
          - run: yarn test
          - run: yarn test:e2e

This script specifies Docker as the execution environment. It will import two CircleCI Docker images, one for Nodejs and another for Postgres. Postgres is needed for running the end-to-end tests.

Commit your code and push it to your repo.

Next, set up the project to run on CircleCI when you push code changes.

Connecting the project to CircleCI

Go to the CircleCI project dashboard. If you signed up to CircleCI with GitHub or Bitbucket, all your repositories will be listed there. Click the Setup up Project button for our tutorial project.

CircleCI project dashboard

Click Let’s go when prompted. Your project will be connected to Circle CI, and your first build will start running.

Select CircleCI confiq file

Your build should run successfully.

Project Pipeline overview

Click the build link to review how the pipeline ran and confirm that the tests passed successfully.

Project pipeline detail

Conclusion

In this tutorial, we used the customer module to show you how to perform unit and end-to-end testing in a NestJS GraphQL API. The same approach used here can be applied to the other module in the sample application, or any NestJS application. Engage with your team to experiment with automating testing for your NestJS and GraphQL applications.

If you are interested in learning how the app was built, refer to this post: How to build GraphQL API with NestJS.