Frank Wang on Unsplash

Testing your Neo4j & Nest.js Application

In this post, I will outline how you can test your Neo4j-based Nest.js application using unit tests and End-to-End testing

Adam Cowley
9 min readSep 22, 2020

--

This article is one of a series of blog posts that accompany the Livestream on the Neo4j Twitch Channel where I build an application on top of Neo4j with Nest.js.

The recordings are also all available in a YouTube Playlist.

This article assumes some prior knowledge of Neo4j and Nest.js. If you haven’t already done so, you can read the previous articles at:

  1. Building a Web Application with Neo4j and Nest.js
  2. Authentication in a Nest.js Application with Neo4j
  3. Authorising Requests in Nest.js with Neo4j
  4. Handling Neo4j Constraint Errors with Nest Interceptors

Which tests should I use?

So far, I’ve only really touched End-to-End testing. End-to-end (or E2E) tests are functional tests that test the application as a whole. In the streams so far I’ve used E2E tests to test the entire application stack and represent a user’s entire journey through the website, including:

For example, the tests that cover the POST /articles endpoint provide tests to ensure that the following elements are working correctly:

  • Validation Pipe are working correctly
  • The user is correctly authenticating
  • The database is up and the data is being correctly added to the database

These tests are all run through Jest using Supertest to mimic HTTP requests to the API. With a front end, it would also make sense to use a tool like Selenium or Cypress to automate the clicking of buttons and filling in of forms.

These are great and can be quick to run for small applications, but if you are following Test Driven Development on a large project, running hundreds of tests on each save could become time consuming. At the start of the project, a lot of those elements may not be in place, so it doesn’t make much sense to write a set of failing tests for middleware that aren’t on the roadmap for several months.

Test Pyramid

For this reason, most people start with unit tests. The goal of unit testing is to write more precise tests that verify that each single element that makes up the code base work under specific conditions. For example, what happens when a third-party API returns a particular response? What happens if the API goes down?

These can be hard to simulate in real-life, besides it could cost a lot of money to keep sending requests to some Google API to constantly test these conditions.

This is where mocking comes in handy. Instead of sending a request with each test, mocking the API class will mean that we can “spy” on a call to a method and return a specific result on a test-by-test basis.

In our case, we don’t want to rely on a Neo4j instance to unit test our code. We can instead mock the read and write methods on the Neo4jService and return certain results based on the test case.

Testing Nest.js with Jest

For this, we will use the tests that have already been auto-generated with the nest generate commands. These use the @nestjs/testing package and a testing framework called Jest.

As an example, we’ll take a look at testing the create() method on the ArticleService. The generated unit test file is in the same folder, called article.service.spec.ts.

At the top of the test file, you’ll see a beforeEach function that creates a new testing module. The idea of the testing module is that rather than registering the entire application, we only register the elements that we require to make the test pass — in the long run this will make the tests a lot quicker.

// article.service.spec.ts
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [ArticleService],
}).compile();
service = module.get<ArticleService>(ArticleService);
});

Running the test as it stands will cause a couple of knock-on problems:

ArticleService is marked as a scoped provider

Firstly, the following line ensures that the ArticleService is scoped to the request:

// article.service.ts
@Injectable({ scope: Scope.REQUEST })

By default Injectable classes are singletons — meaning that a single instance of the class is created for the entire application. Because this services is scoped to the request, a new instance will be instantiated with each request, allowing us to inject the Request.

Calling module.get to retrieve an instance of the module will return the following error:

ArticleService is marked as a scoped provider. Request and transient-scoped providers can't be used in combination with "get()" method. Please, use "resolve()" instead.

Because this is a scoped provider that is instantiated with the request, Nest can’t instantiate it outside of a request. It will instead need to resolve a new instance. Changing the call from .get to .resolve will solve this error:

service = await module.resolve<ArticleService>(ArticleService);

Nest can’t resolve dependencies of the ArticleService

Next, because we pass through an instance of the Neo4jService into the ArticleService constructor, we need to make sure that the Neo4jModule has been registered within the testing module.

As it stands, the testing module is not aware of the Neo4jService and will throw the following error:

Nest can't resolve dependencies of the ArticleService (REQUEST, ?). Please make sure that the argument Neo4jService at index [1] is available in the RootTestModule context.

To fix this, we’ll need to register the Neo4jModule with the application. In the main application, we use the ConfigService to pull the applicable config values from the .env file in the root. But as we’ll be mocking the interactions between the application and Neo4j we can put in any old information.

Just as we do in the AppModule, we can add the Neo4jModule to the imports key on instantiating the testing module, but instead using forRoot rather than forRootAsync:

// article.service.spec.ts
import { Neo4jModule} from 'nest-neo4j';
// ...
const module: TestingModule = await Test.createTestingModule({
imports: [
Neo4jModule.forRoot({
scheme: 'neo4j',
host: 'localhost',
port: 7687,
username: 'neo4j',
password: 'neox'
})
],
providers: [ArticleService],
}).compile();

The client is unauthorized due to authentication failure

Re-running the tests, you’ll an error message saying that authentication to Neo4j has failed:

// article.service.spec.ts
Neo4jError: The client is unauthorized due to authentication failure.

At the moment Neo4j is still trying to authenticate — to stop this from happening, we can instruct jest to mock the entire Neo4j Driver class:

jest.mock('neo4j-driver/lib/driver')

Now for each test in this suite, the Neo4j driver module will be mocked, so none of the code from that file will actually be executed, and instead we can check that the functions have been called and if necessary return our own responses.

More information on mocking entire modules

The Neo4j driver itself is down-stream of any of the code we will be testing.

Testing the create() method

In order to test the create method, we’ll first need to create a group to hold the test.

describe('::create()', () => {
it('should create a new article', async () => {
// Test will go here...
})
})

Because this is a service scoped to each request, many methods including the create method expect the request to be injected into the service and for it also to contain a User.

The request property on the method is a private method, so we’ll have to use Object.defineProperty to an object that contains an instance of the User entity class. In order to fake this, we can import Node class from the Neo4j driverneo4j-driver/lib/graph-types.js and create a new instance.

// article.service.spec.ts 
import { User } from '../user/entity/user.entity'
import { Node } from 'neo4j-driver/lib/graph-types'
import { int } from 'neo4j-driver'
// Create User
const userNode = new Node(int(9999), ['User'], { id: 'test-user' })
const user = new User(userNode)

In order to ensure that this is found in our class, we can use Object.defineProperty to set the value of service.request to an object that contains the User.

Object.defineProperty(service, 'request', { value: { user } })

Next, we’ll want to mock a response from Neo4j. We can assume from the E2E tests that the actual Cypher query is fine, but we should check that the values returned by Neo4j are correctly processed by the service.

Taking a look at the RETURN portion of the query you can see that the service expects the driver to return a user node, article node, array of nodes to represent tags, a boolean to indicate whether the user has favorited the node and a number to represent the total number of :FAVORITED relationships.

RETURN u, // User Node
a, // Article Node
[ (a)-[:HAS_TAG]->(t) | t ] AS tagList, // Array of tag nodes
exists((a)<-[:FAVORITED]-(u)) AS favorited, // Boolean
size((a)<-[:FAVORITED]-()) AS favoritesCount // Number

We can listen for calls to the write method on the Neo4jService using the jest.spyOn method. We can retrieve the instance of the Neo4jService that will be injected into the service by calling module.get.

// Get the type of result to returned by the mocked method
import { Result } from 'neo4j-driver/lib/result'
// Get the instance of the Neo4jService created by the TestModule
const neo4jService: Neo4jService = await module.get(Neo4jService)
// Add some test data to pass to the call
const data = {
title: 'Title',
description: 'Description',
body: 'Body',
tagList: ['tag1', 'tag2'],
}
// Listen to calls on neo4jService.write()
const write = jest.spyOn(neo4jService, 'write')
// Each time the
.mockResolvedValue(
// Return a mocked value to mimic a <Result>
)

The resolved value from the write method should be a QueryResult. In the ArticleService we only use the records array which contains an array of Record objects. Each record has a get method that is used to pull the individual items from the return.

We can mock what the driver would return by adding a case statement.

.mockResolvedValue(<Result> {
records: [
{
get: key => {
switch (key) {
case 'a':
// If requesting 'a', return a `Node` with the data
// passed to the `create` method
const { tagList, ...properties } = data
return new Node( int(100), ['Article'], { ...properties, id: 'test-article-1' })
case 'tagList':
// If 'tagList' return an array of Nodes with a
// property to represent the name
return data.tagList.map((name, index) => new Node ( int(200 + index), 'Tag', { name }))
case 'favoritesCount':
// If favouritesCount then return a random number
return 100;
case 'favorited':
// If favorited, return a boolean
return false;
}
// Otherwise return null
return null
}
}
]
})

As we only expect a single node, that is as complicated as we need to get.

Then in order to rest the hydration we can run the method and then call toJson on the article that has been returned.

const article = await service.create(data.title, data.description, data.body, data.tagList)const json = article.toJson()

If all has gone well, we should get a JSON object with the original information passed to the create method with some additional information including the article ID. The author object should all properties passed to the mock User entity, and the values returned from the “Driver” should match.

expect(json).toEqual({
...data,
author: user.toJson(),
id: 'test-article-1',
favorited: false,
favoritesCount: 100,
})

TL;DR: I don’t want boilerplate

I’ve added a set of methods to the nest-neo4j package so you don’t need to scour the neo4j-driver repository in order to find the methods.

  • mockNode(labels: string | string[], properties: object) — Return a node with the label(s) and properties supplied.
  • mockRelationship(type: string, properties: object, start?: Node, end?: Node) — Return a relationship object with the type and properties defined. You can either pass node instances to represent the start and end nodes, or a random one will be generated
  • mockResult(rows: object[]) — this method will return a mocked version of the Result class with a records key. Each record has a keys array and a get method for retrieving a single value.

For example, the code above could be replaced with the following:

// Import methods
import { mockNode, mockResult } from 'nest-neo4j/dist/test'
// Mock Result
const write = jest.spyOn(neo4jService, 'write')
.mockResolvedValue(
mockResult([
{
u: user,
a: mockNode('Article', { ...data, id: 'test-article-1' }),
tagList: data.tagList.map(name => mockNode('Tag', { name })),
favoritesCount,
favorited,
},
])
)

As the weeks progress, I will be adding more test cases and examples — for example what happens when Neo4j throws a Constraint Error? Or what happens if the service is unavailable?

Star or Watch the nest-neo4j repository to be notified of any commits.

Until next week!

~ Adam

--

--