Continuous integration for NestJS GraphQL projects
Software Engineer
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:
- Node.js installed on your computer
- MySQL installed on your computer
- Nest CLI installed on your computer
- A GitHub account
- A CircleCI account
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.
Click on “Let’s go” and your project will be connected to CircleCI. Your first build will start running immediately.
Now click into the build process to confirm that the tests passed successfully.
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.