Effortless testing in JavaScript with functional programming
Testing code written with functional programming and @7urtle/lambda requires the same testing tools that you are used to. Nothing new to learn. But functional programming makes the practice much easier. In the code examples bellow, we will be using Jest library but you can use any other that you like.
Testing pure functions
Functional programming leads you to the creation of simple pure functions that are stringed together by function composition. Pure functions are simply dependent only on their input without any side effects. You can read more about them in Functional programming basics. Common imperative functions often look like this:
1let count = 0;23const badFunction = function () {4++count;5};
badFunction
doesn't take any input and it just does things to change external variables. In functional programming that means that it is causing a side effect. That makes the function difficult to test. You always have to mock external dependencies and the function will have different effect depending on the current state of such dependencies. It should be obvious why large code base built this way would be hard to debug. Functional alternative would look like this:
1const goodFunction = count => ++count;
goodFunction
is a simple pure function that increases the value of its input. It will always return the same value for the same input which makes the test also very simple:
1test('goodFunction increases its input without side effects.', () => {2let count = 1;3expect(goodFunction(count)).toBe(2);4expect(count).toBe(1);5});
Some side effects are of course unavoidable. In the end you want your code to change something whether it is a DOM element or a database record. For managing these we use monads.
Testing with monads
Monads represent simple wrappers around "dangerous" values and functions and provide safe way of mapping. To understand it more, consider this imperative function:
1const getHeight = function (element) {2return document.querySelector(element).clientHeight;3};45getHeight('#myelement');
Coding with DOM in JavaScript is an example of working with side effects. querySelector
may not find any element and return undefined which will result in the above code throwing an error. To handle this we need to add conditions and/or try catch statements.
In functional programming, just use Maybe monad as an argument for your function:
1import {Maybe, map} from '@7urtle/lambda';23const selectDOMMaybe = selector => Maybe.of(document.querySelector(selector));4const getHeightMaybe = DOMMaybe => map(a => a.clientHeight)(DOMMaybe);56getHeightMaybe(selectDOMMaybe('#myelement')));
selectDOMMaybe
and getHeightMaybe
now work with our Maybe monad. You need to test selectDOMMaybe
with DOM mock but any following function in the composition like getHeightMaybe
can be tested independently now and very easily. These two functions also nicely demonstrate the benefit of separation of concerns that is discussed in detail in a moment.
getHeightMaybe
can be tested without mocking DOM:
1test('getHeightMaybe returns height if DOM element exists.', () => {2const mockJust = Maybe.of({clientHeight: 30});3const resultJust = getHeightMaybe(mockJust);4expect(resultJust.isJust()).toBe(true);5expect(resultJust.value).toBe(30);6});78test('getHeightMaybe is Nothing if DOM element is undefined.', () => {9const mockNothing = Maybe.of(undefined);10const resultNothing = getHeightMaybe(mockNothing);11expect(resultNothing.isNothing()).toBe(true);12});
Separation of concerns is another important programming principle that we can apply with ease.
Separation of concerns
Separation of concerns principle simply tells us to separate parts of code that deal with different things. In functional programming when you start learning the basics of pure functions you still need to learn how too deal with side effects.
Side effects are inevitable when you are integrating your code with the external world. That usually happens twice. At the beginning you need to get the input for your program and at the end yoo need to publish the result. By this logic we can separate our code into two concerns. Integration concern deals with reading and publishing changes and execution concern then deals with transformation of the input into the output.
In practice your code then should be divided into modules based on separation of concerns as well. Integrations should be tested by integration tests and execution can be covered by regular unit tests. In a code it could be expressed like this:
1// ./FileReader.js //////2import {fs} from 'fs';3import {AsyncEffect} from '@7urtle/lambda';45// readFile :: string -> AsyncEffect6export const readFile => input =>7AsyncEffect8.of(reject => resolve => _ =>9fs.readFile(input, (err, data) =>10err ? reject(err) : resolve(data)11)12);1314// ./DataTransform.js //////15import {UpperCaseOf, trim, compose, map} from '@7urtle/lambda';1617// transformer :: string -> string18export const transformer = compose(upperCaseOf, trim);1920// transformData :: AsyncEffect -> AsyncEffect21export const transformData = map(transformer);2223// ./FileWriter.js //////24import {fs} from 'fs';25import {AsyncEffect, flatMap} from '@7urtle/lambda';2627// writeFile :: string -> AsyncEffect -> AsyncEffect28export const writeFile = output =>29flatMap(data =>30AsyncEffect31.of(reject => resolve => _ =>32fs.writeFile(output, data, error =>33error ? reject(error) : resolve('success')34)35)36);3738// ./index.js //////39import {log, compose} from '@7urtle/lambda';40import {readFile} from './FileReader';41import {transformData} from './DataTransform';42import {writeFile} from './FileWriter';4344// execute :: string -> string -> AsyncEffect45const execute = input => output => compose(writeFile(output), transformData, readFile)(input);4647execute('input.txt')('output.txt').trigger(log)(log)();
We end up with relatively short code that is divided into four files (modules). Everything is brought together in a final functional composition const execute = input => output => compose(writeFile(output), transformData, readFile)(input);
.
Reading and writing into files is separated into individual modules that handle these side effects and the rest of the code (here simplified into transformData
) is then pure and easy to test without any mocking like this:
1import {AsyncEffect} from '@7urtle/lambda';2import {transformer, transformData} from './DataTransform';34test('transformer input goes through trim and upperCaseOf.', () => {5expect(transformer('input')).toBe('INPUT');6expect(transformer(' input ')).toBe('INPUT');7});89test('transformData maps transformer to AsyncEffect.', done => {10const input = AsyncEffect.of(reject => resolve => value => resolve(' input '));1112transformData(input)13.trigger14(a => a)15(result => {16expect(result).toBe('INPUT');17done();18})19();20});
Notice that in the test we are happy to verify just the happy path of the AsyncEffect in the final function. We don't need to worry about error states because that is abstracted by the monads.
It is also important to mention that performance of execution can then be effortlessly optimized by caching memoization.
The documentation page on this website is generated out of JSDoc directly from the source code of @7urtle/lambda and the program uses the separation of concerns principle as it is described here when working with input files and the final output.
Ask For Help
If you have any questions or need any help, please leave a message on @7urtle/lambda GitHub Issues to get quick assistance. If you find any errors in @7urtle/lambda or on the website please reach out.