This tutorial covers:
- Cloning the sample project
- Adding unit and integration tests
- Automating end-to-end tests
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. An alternative to REST, GraphQL lets you describe your data in a complete and accurate way that provides enough flexibility for changing client commands. It provides a runtime for returning your data in response to client queries. GraphQL provides many helpful developer tools that help make maintaining APIs easier.
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:
- Node.js installed on your computer
- MySQL installed on your computer
- Nest CLI installed on your computer
- A GitHub account
- A CircleCI account
Our tutorials are platform-agnostic, but use CircleCI as an example. If you don’t have a CircleCI account, sign up for a free one here.
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);
});
});
There are 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 create() {
await createConnection();
},
async close() {
await getConnection().close();
},
async clear() {
const connection = getConnection();
const entities = connection.entityMetadatas;
const entityDeletionPromises = entities.map((entity) => async () => {
const repository = connection.getRepository(entity.name);
await repository.query(`DELETE FROM ${entity.tableName}`);
});
await Promise.all(entityDeletionPromises);
},
};
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 automating your tests, you first need to build a continuous integration pipeline that CircleCI will run. The pipeline will:
- Check out your 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 your tutorial project.
Click Let’s go when prompted. Your project will be connected to Circle CI, and your first build will start running.
Your build should run successfully.
Click the build link to review how the pipeline ran and confirm that the tests passed successfully.
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.