Better tests with "Testing Hooks" pattern
Discover how to simplify and reuse logic in your tests with "Testing Hooks" pattern.
Tests are part of any project, they ensure both non-regressions and code stability. On the other hand, they are often painful to write and are often the least well-off with regard to factoring and the beauty of the code. This article describes a solution to make your tests clearer. It is Jest which is used in the examples, but the technique can be adapted to all libraries.
Write tests
Most modern frameworks (Jest, Jasmine) offer two syntaxes for writing tests: the TDD syntax for "Test Driven Development" and the BDD syntax for "Behavior Driven Development".
TDD syntax
The TDD syntax consists in expressing the tests in a linear way. We express what must happen and write the corresponding test:
test('returns user when the user exists', () => {// ...})test('returns null when the user is not found', () => {// ...})
BDD syntax
BDD syntax, on the other hand, consists of expressing the tests in the form of a scenario with actions nested one inside the other.
describe('#getUser', () => {describe('when the user exists', () => {beforeEach(() => {// ...})it('returns user', () => {// ...})})describe('when the user does not exist', () => {it('returns null', () => {// ...})})})
Choose between TDD & BDD
BDD syntax allows you to organize tests more precisely. The behavior of the user and all the cases that arise are described.
The TDD syntax is lighter, it is very suitable for simple cases, but on complex cases we can quickly find ourselves repeating code and forgetting cases.
Personally I prefer the BDD syntax. However I recently read an article by Kent C. Dodds where he recommends using the TDD syntax for reasons of clarity. and code organization.
I agree with this article, I myself have faced these organizational problems. But I managed to organize the code while retaining the advantages of the BDD syntax. I'll explain how.
Problems
To fully understand the difficulties generated by the BDD syntax, it is necessary to start from a concrete case. In my example, I will test a getRandomUser
function which gets a random user from the database. The database
object will represent a database with classic functions for interacting with it.
Here is the test of my function written in BDD syntax:
import database from './database'import { getRandomUser } from './api'describe('#getRandomUser', () => {let dbbeforeEach(async () => {db = await database.connect()})afterEach(async () => {await db.reset()await db.close()})describe('when the user exists', () => {let userbeforeEach(async () => {user = await db.users.insert({ name: 'Greg' })})it('returns user', async () => {const userFromAPI = await getRandomUser()expect(user).toEqual(userFromAPI)})})describe('when the user does not exist', () => {it('returns null', async () => {const userFromAPI = await getRandomUser()expect(userFromAPI).toBe(null)})})})
Several things make this code complex:
- Variables are defined with
let
instead ofconst
- The scope of variables is invisible; in other words we do not know very well what belongs to the test or the context
How to simplify it?
Solving the problem of let
is at first glance impossible, because the variable must be accessible in the scope of the describe
function but it must be initialized in the beforeEach
.
As for the concern of knowing the origin, we could go through a convention using names like userFromAPI
but this remains quite complex.
So let's take a step back and think: how to have a single scope?
In JavaScript, there are two ways to assign a value: either you declare a variable, or you assign a property to an object.
The variable declaration contributes to the complexity, so let's start with the assignment of property to an object.
I define an object ctx
which will contain all the variables which are part of the context of my test. This ctx
object will be reset at each test to guarantee the isolation of the tests:
import database from './database'import { getUser } from './api'// Create a global ctx objectlet ctx// Reinitialize it for each testbeforeEach(() => {ctx = {}})describe('#getUser', () => {// Add value in ctxbeforeEach(async () => {ctx.db = await database.connect()})afterEach(async () => {await ctx.db.reset()await ctx.db.close()})describe('when the user exists', () => {beforeEach(async () => {ctx.user = await ctx.db.users.insert({ name: 'Greg' })})it('returns user', async () => {const user = await getUser()expect(ctx.user).toEqual(user)})})describe('when the user does not exist', () => {it('returns null', async () => {const user = await getUser()expect(user).toBe(null)})})})
It's already clearer! We can identify at a glance what is part of our context and what is not: ctx.user
vsuser
. In addition we have more than one let
, the one that allows to definectx
.
Reuse logic
Now that we have found a way to no longer have variables in all directions, we must think about the factorization side.
As a React developer for more than 5 years now, the Hooks have been a real renewal for me and an excellent source of inspiration for factoring my code. So I said to myself, why not do the same in the tests? The "Testing Hooks" were born!
Let's first take the time to define what is a "Testing Hook". A "Testing Hook" is a function that takes one or more parameters as input, returns one or more parameters and uses native Hooks such as: beforeEach
andafterEach
.
To properly identify them, it is good to impose a convention. React requires that all Hooks be prefixed with "use", to make the distinction in the tests, we will therefore use "with".
A "Testing Hook" is therefore a function that begins with "with" with an input, an output and possible calls to beforeEach
,afterEach
or other Hooks.
So I suggest you write our first "Testing Hook"! The one that will allow us to access the context: cornerstone of our Hooks system.
function withContext() {const ctx = {}beforeEach(() => {for (const member in ctx) {delete ctx[member]}})return ctx}
It is indeed a function which begins with "with", which calls beforeEach
and which returns a value, we are in the nails!
You will notice that ctx
is a constant and that we will delete its properties in a beforeEach
. Indeed, we retrieve the context only once but we must ensure that it is emptied with each new execution.
We can now use it in our test:
import { withContext } from './hooks/context'import database from './database'import { getUser } from './api'const ctx = withContext()describe('#getUser', () => {// Add value in ctxbeforeEach(async () => {ctx.db = await database.connect()})afterEach(async () => {await ctx.db.reset()await ctx.db.close()})describe('when the user exists', () => {beforeEach(async () => {ctx.user = await ctx.db.users.insert({ name: 'Greg' })})it('returns user', async () => {const user = await getUser()expect(ctx.user).toEqual(user)})})describe('when the user does not exist', () => {it('returns null', async () => {const user = await getUser()expect(user).toBe(null)})})})
Create a "Testing Hook"
The Hook withContext
the cornerstone of our system, the context will serve as transport for all the other "Testing Hooks".
The connection to the database will have to be reused in many tests. We will therefore define a Hook which allows you to connect to it and clean up the connection:
export function withDatabase(ctx) {beforeEach(async () => {ctx.db = ctx.db || (await database.connect())})afterEach(async () => {await ctx.db.reset()await ctx.db.close()})}
Again we have a function that starts with with
which takes the context as an argument, so it is indeed a Hook. You will notice that it does not return any value. This is the context that serves as our bus, a bit like the ref
in React.
Thanks to this Hook, we can further reduce the code:
import { withContext } from './hooks/context'import { withDatabase } from './hooks/database'import database from './database'import { getUser } from './api'const ctx = withContext()describe('#getUser', () => {describe('when the user exists', () => {withDatabase(ctx)beforeEach(async () => {ctx.user = await ctx.db.users.insert({ name: 'Greg' })})it('returns user', async () => {const user = await getUser()expect(ctx.user).toEqual(user)})})describe('when the user does not exist', () => {it('returns null', async () => {const user = await getUser()expect(user).toBe(null)})})})
Compose Hooks
We have one more thing to factor, the creation of the user. We will use this case to illustrate the composition of the Hooks:
export function withUser(ctx, data) {withDatabase(ctx)beforeEach(async () => {ctx.user = await ctx.db.users.insert(data)})}
You will notice that withDatabase
is called inside withUser
, this allows us to compose the Hooks.
Here is now the final result factored using "Testing Hooks":
import { withContext } from './hooks/context'import { withUser } from './hooks/database'import database from './database'import { getUser } from './api'const ctx = withContext()describe('#getUser', () => {describe('when the user exists', () => {withUser(ctx, { name: 'Greg' })it('returns user', async () => {const user = await getUser()expect(ctx.user).toEqual(user)})})describe('when the user does not exist', () => {it('returns null', async () => {const user = await getUser()expect(user).toBe(null)})})})
As illustrated by the example above, the test is now very concise and above all we can reuse the logic in other tests.
Personally I use something much more generic. A Hook withContextValues
which allows you to simply define asynchronous values in the context.
function withContextValues(getValues) {beforeEach(async () => {const values = await getValues()Object.assign(ctx, values)})}// UsagewithContextValues(async () => ({user: await createUser({ name: 'Greg' }),}))
Feedback
I use the principle of "Testing Hooks" daily for 6 months now, with this hindsight I was able to determine what are the advantages and limits.
Advantages
Clarity of code
The code is much clearer, the fact of systematically specifying ctx
allows you to know at a glance what is the context and what is not.
Share logic
This system makes it easy to factorize all the logic for creating mocks necessary for testing and also all the services you use on a daily basis (database, GraphQL, etc.).
Limits
I prefer to speak of limit rather than weak points because these limits could probably be lifted by the creation of a more complex library.
Non-obvious code
Adding properties to the context is opaque, it is invisible when reading code. This is both a strength and a weakness. The code is reduced, but someone who has never read the code may find themselves lost.
const ctx = withContext()// ctx.db is magically added by `withDatabase`withDatabase()
Non-universal
While writing this article, I realized that the order of execution of beforeEach
can vary from one library to another. On CodeSandbox they are evaluated in reverse order, but this system requires a linear execution to work.
What's next?
I deliberately did not want to create a library because the code is quite simple, it is rather a principle. I share this article because I think it could easily be taken further and become a standard in writing tests. And if it can prevent the syntax "BDD" from being forgotten for the wrong reasons, then that's always the point!