During my life as a full-stack developer, I tried out lots of ORM, ODM, and query builders. Some were fantastic, while others still give me nightmares (we see you Sequelize).
Current state
Among the fantastic tools, there is Prisma. It works perfectly, has a great DX, documentation, and much more. I encountered a single issue when working with it, unit testing.
While they have a documentation page on unit testing, with a great introduction, their method of mocking is unsatisfactory at best.
At best, that allows you to mock, one by one, the responses for the requests you believe will be called. I think this is inefficient for a few reasons:
It requires too much setup, on every single test.
There is a very low level of trust in the mocks you define, as you are not sure Prisma will return the same data.
By defining these mocks yourself, you get further away from testing your app behavior, and closer to testing implementation details.
With new versions of Prisma, you are at risk of seeing your tests being obsolete.
Lets look at what application we want to test, which tests to write, and then define how to write unit tests.
Application structure
My backend applications are usually split into a few layers. If we take an example with a basic ExpressJS app, it has at least 3 layers: module, controller, and service.
Each set of functionality is separated into a dedicated module , which handles routing, validation, and passes the request to the controller.
The controller is where the business logic is found, and often makes use of services.
A service provides high-level functions that make requests to the database. This is where I use ORM such as Prisma.
What should be tested
I usually have two primary types of tests when working on a backend application (and many more depending on the context!). They are unit and end-to-end tests.
I try to avoid writing e2e tests when possible, as they have limitations like being slow, expensive, and giving late feedback. I usually write unit tests for low-level functions such as middlewares.
On the other hand, I dont test my services or controllers in complete isolation. In those cases, I feel like its not giving me enough value for the time it takes to write tests. Instead, I want to test my endpoint behavior as a whole.
To do it, I write my tests using a test version of my backend app. Outside dependencies, like Prisma, are mocked (in an efficient way) which allows me to simulate queries in isolation using tools like SuperTest.
In this context, I write unit tests for most use-cases. It includes validation (such as parameters), authorization (ensure youre connected with the right access), but I also verify I receive the right response, using the mocked version of Prisma. Depending on my app, there is even more use-case.
I also end up writing e2e tests, to ensure my successful response and database-dependent errors, are the expected ones, in a real environment.
Those tests may partially overlap with my unit tests, but this time using a real database. It gives me the quick and early feedback of unit tests while having the high confidence of e2e tests.
How to write those tests
In the following examples, I expect you to understand the basics of testing. That includes Jest, which is used as part of the examples.
Middleware
Middlewares dont need a special environment to be tested in, they can be considered just like any other function.
Only thing is, they have a defined format. For Express, they must return a function that takes a request , response , and next function. Lets have a look at the following snippet:
import { Request, Response, NextFunction } from 'express';
type Validator = (req: Request, res: Response) => boolean;
export function validator(validate: Validator) {
return (req: Request, _res: Response, next: NextFunction) => {
const valid = validate(req);
if (!valid) next(new Error());
else next();
};
}
Validator takes a function that determines if a request is considered valid, by returning a boolean based on the received request. It returns a middleware, which will throw an error if the received request is determined as invalid.
With Jest, this middleware can be tested quite easily by calling it with different arguments, and verifying next has been called with the right element.
import validator from '../validator.middleware';
describe('validator', () => {
const req = { params: { mock: 'true' } };
const res = {};
it('Should call next with an error if request is invalid', () => {
const mockNext = jest.fn();
validator((req) => req.params.mock === 'false')(req, res, mockNext);
expect(mockNext).toHaveBeenCalledWith(new Error());
});
it('Should call next otherwise', () => {
const mockNext = jest.fn();
validator((req) => req.params.mock === 'true')(req, res, mockNext);
expect(mockNext).toHaveBeenCalled();
});
});
Endpoints (Unit)
I already explained I use SuperTest for my endpoints. I also talked about using a test version of my backend app. To be more precise, I have a few helper functions, dedicated to bootstraping my backend during tests and managing mocked data.
The following snippet is a good example of a unit test. We demonstrate how we can test an endpoint dedicated to creating an article.
We use helpers to bootstrap our app, generate the tokens needed for our use case, and send the request with SuperTest.
import { Express } from 'express';
import supertest from 'supertest';
import { ArticleFixture, ServerMock, UserMock } from '../../../../testing';
describe('POST /article', () => {
let app: Express;
const tokens = UserMock.generateTokens();
beforeAll(async () => {
app = await ServerMock.createApp();
});
test("Should return error if user isn't authenticated", () => {
return supertest(app).post('/article').send(ArticleFixture.articles.newArticle).expect(401);
});
test("Should return error if user doesn't have ADMIN role", () => {
return supertest(app)
.post('/article')
.set('Authorization', `Bearer ${tokens.user}`)
.send(ArticleFixture.articles.newArticle)
.expect(401);
});
it('Should create article', () => {
return supertest(app)
.post('/article')
.set('Authorization', `Bearer ${tokens.admin}`)
.send(ArticleFixture.articles.newArticle)
.expect(201);
});
});
Mocking with Prisma
But we still didnt solve the issue that comes with Prisma. In the above snippet, it looks like nothing is mocked.
There is a single solution if we want to write tests with no dependence to a database or heavy mocking: using an in-memory implementation of Prisma.
Introducing prismock. Disclaimer: I am indeed its creator.
As there was no satisfying solution to efficiently write unit tests with Prisma, I decided to write my own solution.
It actually reads your schema.prisma
and generates models based on it. It perfectly simulates Prismas API and store everything in-memory for fast, isolated, and retry-able unit tests.
Remember how I use a helper to build a test version of my backend app?
In production I build my app, using a genuine PrismaClient , which is then bootstrapped. During my test, I replace PrismaClient using dependency injection.
In the above snippet, its done as part of ServerMock.createApp()
, which makes it virtually invisible when I write my tests.
Endpoints (E2E)
In a context where our article endpoint and authorization process are already tested, we could argue that its not mandatory to test authentication on every single endpoint during e2e tests.
For example, we could end up with the following test:
import supertest from 'supertest';
import { ArticleFixture } from '../../fixtures';
import { ArticleMock } from '../../mocks';
import E2EUtils from '../EndToEndUtils';
describe('POST /article', () => {
let tokens: { admin: string };
beforeAll(async () => {
tokens = await E2EUtils.generateTokens();
});
it('Should return created article', async () => {
return supertest('http://localhost')
.post('/article')
.set('Authorization', `Bearer ${tokens.admin}`)
.send(ArticleFixture.articles.newArticle)
.expect(201)
.then((response) => {
expect(response.body).toEqual({
title: ArticleMock.articles.newArticle.title,
content: ArticleMock.articles.newArticle.content,
slug: ArticleMock.articles.newArticle.slug,
});
});
});
});
This test must be written in a different environment, where we have access to a seeded database, and our endpoint has been built and runs in a near-production environment.
Conclusion
In this context, we should cover the entirety of our codebase with unit tests, giving us fast and early feedback, with a strong trust in our test results.
Using an in-memory implementation of Prisma instead of manual mocks increases our confidence even more. Together with our testing strategy (defined in what should be tested ), we also end up with amazing productivity.
Finally, we write E2E tests exclusively for use-cases that make requests to our database. It does overlap with some unit tests, is slower, and more expensive with late feedback, but it eliminates the remaining grey areas.
We are then able to answer:
Are you confident in shipping your app to production?
Photo by Roman Mager on Unsplash
Top comments (0)