TutorialsLast Updated Feb 20, 20245 min read

Continuous integration for NestJS GraphQL projects

Godwin Ekuma

Software Engineer

Developer RP sits at a desk working on an intermediate-level project.

NestJS is fast becoming the de facto framework for NodeJS projects. Unlike older frameworks, it was built with TypeScript, which has become commonplace in the JavaScript community. Frameworks like NestJS are also naturally 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 using continuous integration (CI) with CircleCI.

Prerequisites

To follow along with this post, there are a couple of things you will need to have set up:

Cloning a sample project

This tutorial will use an existing NestJS application. It is a small invoicing application that demonstrates how to build a GraphQL API using NestJS. If you are interested in learning how the app was built be sure to check out how to build GraphQL API with NestJs.

To begin, clone the project. It already contains some tests that I will explain individually. I will then guide you through automating 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 that will be explained here will focus on the customer module.

Unit testing for NestJS GraphQL APIs

Unit testing for this project consists of:

  • Testing the customer service
  • Testing the customer resolver

Testing the customer service

The customer service has 3 methods: create, findAll, and findOne. You will add a test for each of the methods. The customer.service.spec.ts file already includes a test boilerplate that you can use to test the customer service. Before adding each of the tests, though, you need to configure the testing module by providing 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.entity';
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);
  });

    });

The CustomerService and the CustomerRepository have been added as providers. The CustomerRepository uses a mock value because this is a unit test. 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.entity';
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({ where: { id: customer.id } });
    });
  });
});

Testing the customer resolver

The customer.resolver.spec.ts also has boilerplate code for testing the resolver. The test for the resolver also mocks out dependencies like the CustomerService and the InvoiceService.

    // 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',
      });
    });
  });
});

End-to-end testing for NestJS GraphQL APIs

For end-to-end (E2E) testing, nothing will be mocked. The goal is to test each of the components (model, resolver, and service) as they work together.

The TypeORM configuration for the test database is a little bit different than the config for the main database. You will create a config, add a config.database.ts, and export the TypeORM configuration based on the environment (test or development). You also need a data-source/ormconfig.ts file for creating a new data source.

// database.config.ts
    import dotenv from 'dotenv'

dotenv.config();
const database = {
    development:   {
      type: "postgres",
      host: "localhost",
      port: 5432,
      username: "postgres",
      password: "",
      database: "invoiceapp",
      entities: [
        "dist/**/*.entity.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/**/*.entity.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 previous runs. After each test runs, it is good practice to delete all data for every single entity registered in your connection.

Create a connection.ts file under 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. close is called after all the tests have been executed to close the connection to the database. clear is called before each test to remove all the data before another test runs.

Now add the tests:

    // /test/customer.e2e-spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import connection from './connection';
import { getConnection } from 'typeorm';
import { CustomerModel } from '../src/customer/customer.entity';

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])
      })
    })
  })
});

Great work!

Adding CircleCI configuration to the project

To begin automating your tests, you need to build a continuous integration pipeline that CircleCI will run.

The pipeline will do the following:

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

Create a folder named .circleci at the root of your project and 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:21.6.1
          - image: cimg/postgres:16.1.0
            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 Nodejs and Postgres. Postgres is needed for running the E2E tests.

Now commit your code and push it to your repo. Next, set up the project to run on CircleCI whenever you push code changes.

Automating NestJS GraphQL tests with CircleCI

Go to CircleCI’s project dashboard. If you signed up to CircleCI with GitHub or Bitbucket, all your repositories will be listed there. Click Setup up Project for the project you want to work on.

CircleCI project dashboard

Click on “Let’s go” and your project will be connected to CircleCI. Your first build will start running immediately.

Select CircleCI config file

Project Pipeline overview

Now click into the build process to confirm that the tests passed successfully.

Project pipeline detail

Excellent! You have a green build. Now any time you make a change to your code, your tests will automatically execute, ensuring your application remains stable and functional with each update.

Conclusion

In this post I focused on testing just the customer module to show you how to perform both unit and end-to-end testing in a NestJS GraphQL API. You can use the same approach for any NestJS application. Engage with your team to experiment with automating testing for your NestJS and GraphQL applications.

Copy to clipboard