Author - Ishaan Khurana, LinkedIn
This tutorial explains mocking and it's utility in writing unit tests. We'll be using FluffySpoon's Substitute to mock an interface and use it to instantitate a service class.
- You're required to complete Unit Test Basics tutorial before starting this one.
- Prior experience with javascript, typescript and npm is needed in order to succeed in this tutorial.
Mocking allows us to create a fake version of a real internal or external service that can be used as a substitute to the actual class. It's widely used in software testing to test a service in isolation without having to create its dependencies which are mocked.
In this tutorial, we'll be testing the LinkedInService class that exists in the directory with the same name. LinkedInService's constructor requires an object of UsersRepository that implements the interface IUsersRepository and is responsible for connecting to the database and performing database-related operations. For our purposes, we won't concern ourselves with its implementation. The LinkedInService implements three methods:
- getConnectionDegree(userId: number) - this method returns the connection degree with the user with id 1 and the user with the supplied connection id.
- getConnections(userId: number) - this methods returns a list of users that are connected to the given user
- connectTwoPeople(id1: number, id2: number) - this method connects two users with the given ids
Since we're testing this class and are focusing on the mocking tool Substitute, we won't be looking at the actual implementation of this class here.
LinkedInService.test.ts
import { IUsersRepository } from "../ConnectionsRepository/IUsersRepository"
import { Substitute, Arg, SubstituteOf } from '@fluffy-spoon/substitute'
import { IUser } from "../ConnectionsRepository/IUser"
import { faker } from "@faker-js/faker"
import { LinkedInService } from "./LinkedInService"
interface IUserJoins {
id: number
connectionId: number
}
let testUsers: IUser[] = []
let testUserJoins: IUserJoins[] = []
let _mockUsersRepository: SubstituteOf<IUsersRepository>
let _testLinkedInService: LinkedInService
describe('LinkedIn Service', () => {
beforeAll(() => {
_mockUsersRepository = Substitute.for<IUsersRepository>()
_mockUsersRepository.getImmediateConnections(Arg.any('number'))
.mimicks(id => {
const connectionIds = testUserJoins.filter(u => u.id === id)
return Promise.resolve(testUsers.filter(u => connectionIds.find(c => c.connectionId === u.id)))
})
_mockUsersRepository.findUser(Arg.any('number'))
.mimicks(id => Promise.resolve(testUsers.find(u => u.id === id)))
_mockUsersRepository.insertIntoJoinsTable(Arg.any('number'), Arg.any('number'))
.mimicks((id1: number, id2: number) => {
if (!testUserJoins.find(r => r.id === id1 && r.connectionId === id2)) {
testUserJoins.push({
id: id1,
connectionId: id2
})
}
if (!testUserJoins.find(r => r.id === id2 && r.connectionId === id1)) {
testUserJoins.push({
id: id2,
connectionId: id1
})
}
return Promise.resolve()
})
_testLinkedInService = new LinkedInService(_mockUsersRepository)
})
describe('getConnections(userId: number) returns the list of people that are connected to the user with the supplied id', () => {
beforeEach(() => {
testUserJoins = []
testUsers = []
for (let i=0; i<20; ++i) {
testUsers.push({
id: i,
name: faker.name.findName()
})
}
})
it('returns an empty list for all the users since they are not connected to each other', async () => {
for (let i=0; i<20; ++i) {
const connections = await _testLinkedInService.getConnections(i)
expect(0).toEqual(connections.length)
}
})
it('returns a list consisting of users 2 and 3 since they are connected to users 1', async () => {
testUserJoins.push({id: 1, connectionId: 2}, {id: 1, connectionId: 3})
const connections = await _testLinkedInService.getConnections(1)
expect(2).toEqual(connections.length)
})
})
describe('getConnectionDegree', () => {
describe('returns the connection degree between two users', () => {
beforeEach(() => {
testUserJoins = []
testUsers = []
for (let i=0; i<20; ++i) {
testUsers.push({
id: i,
name: faker.name.findName()
})
}
})
it('returns 0 since no connections exist yet', async () => {
const actualDegree = await _testLinkedInService.getConnectionDegree(6)
expect(0).toEqual(actualDegree)
})
it('make users 1 and 3 a second degree connection', async () => {
await _testLinkedInService.connectTwoPeople(1, 2)
await _testLinkedInService.connectTwoPeople(2, 3)
const actualDegree = await _testLinkedInService.getConnectionDegree(3)
expect(2).toEqual(actualDegree)
})
})
})
})
Before starting writing our tests, we're defining two arrays to store our users and the joins between two users respsectively. They act as makeshift database tables here. In the beforeAll block, we mock our repository by calling the for method of the Substitute class with our repository's interface passed in as the generic type - _mockUsersRepository = Substitute.for<IUsersRepository>()
. In order to mock the interface's methods, we call them and replace the actual arguments with generic Args.any('<type>')
. We chain this call with a call to the mimick function that accepts a lambda function, which serves as the implementation of the method. For example,
_mockUsersRepository.getImmediateConnections(Arg.any('number'))
.mimicks(id => {
const connectionIds = testUserJoins.filter(u => u.id === id)
return Promise.resolve(testUsers.filter(u => connectionIds.find(c => c.connectionId === u.id)))
})
After mimicking all the repository methods, we define describe blocks for each service method. Note the following code snippet at the beginning of every describe block:
beforeEach(() => {
testUserJoins = []
testUsers = []
for (let i=0; i<20; ++i) {
testUsers.push({
id: i,
name: faker.name.findName()
})
}
})
This code snippet ensures that all the data is wiped out from the joins and the users arrays, and that 20 random users are pushed to users array, before every test.
The unit tests themselves are wrapped inside of it blocks and are pretty straightforward. In you inspect the code in LinkedInService.ts, you'd note that the starting user id for the DFS algorithm is hard-coded to 1 as opposed to being extracted from an identity service.
getConnectionDegree = async (connectionId: number): Promise<number> => {
const userId = 1
// DFS business logic
const visitedIds = new Set<number>()
let depth = 0
const store = new Array<number>();
store.push(userId)
In a real application, the job to get the id of the logged in user is usually outsourced to an external dependency. This task is intentionally left as an exercise for the readers. Try creating a simple class called IdentityService.ts that perhaps accepts a JWT token, validates it, and returns a user id. Then, in order to test LinkedInService.ts, mock IdentityService.ts and supply the mocked class as an argument to the constructor of LinkedInService.ts while instantiating it.
- Clone the repository and cd into the project directory.
- Run
npm install
to install all the related dependencies and thennpm test
to run the unit tests. You should get a similar output:
Additionally, the coverage report can be accessed by opening the coverage/lcov-report/index.html
in a browser. You should see a similar output:
index.html
LinkedInService.ts.html
LinkedInService.ts.html
After completing this tutorial, you should able to mock dependencies and write tests for services in isolation.