Testable Javascript: Pure Functions
My Testing Journey
The first program I wrote was on a TI-83 in high school. I wasn’t trying to write clean code. I was just trying to get it to work. I’ve since learned the value of testing. It has made my life so much easier. However, as I became more and more disciplined about writing tests, I then had the problem that I had a LOT of tests that were hard to maintain. In the last two years I’ve been on a journey to write high quality tests that are lighter weight and easier to maintain. Today I’ll share how pure functions help to make your tests more clean.
Pure Functions
A pure function is one whose return value is based solely on the input arguments. Additionally, it doesn’t perform any side effects (usually I/O). Common operations that can’t occur in a pure function are reading from a database, writing to the console or even reading a global variable.
These functions are easy to reason about. They are also easy to test because they don’t require any test doubles. Let’s look at a simple example.
function increment(number) {
return number + 1;
}
And the simple test:
test('increment should add one to the number', () => {
expect(increment(1)).toEqual(2)
})
Let’s look at a function that is not pure.
async function increment() {
let number = await repo.getNumberFromDatabase();
number += 1;
await repo.setNumberInDatabase(number);
return number;
}
This is not pure because it fetches data from a database. The result will vary based on external state. To unit test this we have to spy on our database functions and pre-program their behavior.
describe('increment', () => {
test('when successful, should add one to the number in the database', () => {
const mockRepo = mock(repo)
mockRepo.when('getNumberFromDatabase').returns(1);
mockRepo.when('setNumberInDatabase').returns();
await increment();
expect(mockRepo.setNumberInDatabase.calls[0][0]).toEqual(2);
})
test('when fetching the value from the database fails', () => {
// ...
})
})
Push I/O To The Edge Of Your Application
Your application will surely have I/O and there’s nothing wrong with writing integration tests or using test doubles. The problem is when we make functions impure that don’t need to be. Let’s look at an example.
function getAverageTransactionAmountForAccount(accountId) {
const sql = 'SELECT * FROM transactions WHERE account_id = $1';
const result = await db.query(sql, [accountId]);
const amounts = result.rows.map(row => row.amount);
const sum = amounts.reduce((amount, sum) => amount + sum, 0);
return sum / amounts.length;
}
We are really doing three things here:
- Building a query (could be pure)
- Executing the query (I/O, impure)
- Transforming the result (could be pure)
You should test your application as well, either with a unit test with test doubles or an end-to-end test. I suggest the latter. Since all the permutations are covered by your unit tests, you need not cover them all again with your end-to-end test. I usually keep it minimal.
Let’s break apart this code into these three sections.
// ----------------------
// application.js
// ----------------------
function getAverageTransactionAmountForAccount(accountId) {
const query = buildSelectionTransactionsQuery(accountId);
const result = await db.query(query);
return getAverageTransactionAmount(result.rows);
}
// ----------------------
// transforms.js
// ----------------------
function buildSelectionTransactionsQuery(accountId) {
return {
sql: 'SELECT * FROM transactions WHERE account_id = $1',
params: [accountId]
};
}
function getAverageTransactionAmount(transactionRows) {
const amounts = transactionRows.map(row => row.amount);
const sum = amounts.reduce((amount, sum) => amount + sum, 0);
return sum / amounts.length;
}
- Building the query: This is now contained in
buildSelectionTransactionsQuery
and is pure. Even though it is pure, you might want to write an integration test for it to make sure the query does what you expect. - Executing the query (I/O): This is contained in
db.query
. - Transforming the result: This is now contained in
getAverageTransactionAmount
. It contains our main calculation and is now much easier to test.
This becomes especially helpful with more complicated computations that have more permutations.
Summary
There are many practices that will help you write testable Javascript code. Favoring pure functions by pushing I/O to the edge has been an easy win for me. Thinking this way is also a nice first step into functional programming.