CODEX

Unit test your implementation of third party libraries

“Do use functions as callbacks.” — Charles Stover

Jake’s argument against third-party library functions as callbacks boils down to the following, valid example:

A third party library may publish a function, e.g. toReadableNumber.

function toReadableNumber(n) {
return new Intl.NumberFormat('en-US').format(n);
}

This works great for converting 1000 (number) to '1,000' (string), so I may decide to use it on my list of numbers:

import { toReadableNumber } from 'some-library';
const list = [1000, 2000, 3000];
console.log(list.map(toReadableNumber));

In this code sample, my list of three numbers have become a list of three, human-readable strings: ['1,000', '2,000', '3,000'].

This is all well and good… until some-library publishes an update to toReadableNumber, e.g. an optional second parameter that converts the base of the number first.

This is interesting because, from the perspective of some-library, this is not a breaking change. The library’s contract with customers is that toReadableNumber(x) will produce a human-readable string. Since the second parameter to convert base is optional, this contract is not broken. It’s merely extended. toReadableNumber(x, base) is an additional feature.

Unfortunately for your application, Array.prototype.map passes a second (and third) parameter to its callback, causing your list to be converted to base 0, 1, and 2 respectively: toReadableNumber(1000, 0), toReadableNumber(2000, 1), and toReadableNumber(3000, 2). While these second parameters were simply ignored and unused in the original version of some-library, they are now used, causing a breaking change to your application. Something as simple as updating your dependencies can cause your application to go offline or crash at runtime.

This is an infamous JavaScript issue that is most frequently seen with parseInt, which has this exact base-converting second parameter. ['1', '7', '11'].map(parseInt) does not return [1, 7, 11], but [1, NaN, 3].

Jake’s argument is to not use functions that are not explicitly created for being callbacks to such prototype methods as map or filter. This is a valid way to circumvent this issue.

I would argue this alternative is bloated and wastes developer energy. All callbacks would change from list.map(toReadableNumber) to list.map(n => toReadableNumber(n)).

I do not believe the solution to this issue is to write new callbacks for every utility function imported by third parties. I am also not in favor of unit testing third party modules. While there is value in unit testing your dependencies, a cut-line needs to be drawn somewhere in favor of developer sanity. I would argue developers should only test code they write and maintain.

The solution to this conundrum is to unit test your code. Do not deploy code to customers that is not tested. Do not unit test third party code. Unit test your code. Does it do what your say it does? Unit tests do more than prove the accuracy of your code. You can also think of them as regression testing, which is to prevent accidentally changes in behavior. Regression testing is particularly useful when refactoring — did my rewrite lose or alter any fundamental functionality? This is a primary benefit of unit tests. Here, we see an additional benefit. Did my updates in dependencies lose or alter any fundamental functionality?

I want to stress the distinction here that I am not testing if my updates in dependencies lost or altered any fundamental functionality of my dependencies, but whether or not updating my dependencies lost or altered any fundamental functionality of my own application.

As an example, let’s use a contrived and basic application: a list of 10,000 random numbers, printed in human-readable format.

function render() {
const numbers = [];
for (let i = 0; i < 10000; i++) {
numbers.push(Math.floor(Math.random() * 999999));
}
console.log(numbers.map(toReadableNumber).join('\n'));
}

In this example application, I output 10,000 random numbers between 0 and 999,999. This works great until my dependency (toReadableNumber) updates to take a second, base-converting parameter. Now my application is filled with erroneous numbers (and NaNs!).

I should have tested my application code before deploying to customers!

In this contrived example, what does a passing test look like? Off the top of my head, “no number should be 4 digits in a row” and “all values should be digits, commas, or periods.”

const MOCK_CONSOLE_LOG = jest.fn();
console.log = MOCK_CONSOLE_LOG;
render();
// Assert
const loggedString = MOCK_CONSOLE_LOG.mock.calls[0][0];
const loggedValues = loggedString.split('\n');
for (const loggedValue of loggedValues) {
// It should not log 4 digits in a row.
expect(loggedValue).not.toMatch(/\d{4}/);
// It should only log digits, commas, and periods.
expect(loggedValue).toMatch(/^[\d,.]+$/);
}

I have now enforced a contract with my customers — I will always log only digits, commas, and periods (no NaN or hexadecimal!), and there will never be more than 3 digits in a row.

We can improve on this contract with more impressive regular expressions or unit tests, but that is not the point of this contrived example. Unit testing our application prevents bugs from shipping to customers. When we upgrade our dependencies, when toReadableNumber introduces a breaking change to our application, this unit test will prevent that breaking change, that runtime failure, from affecting customers.

I want to stress that I did not unit test toReadableNumber. The application’s maintainers can swap toReadableNumber or even the entire some-library library with any other implementation, and the unit test will still be valid and the application will continue to function flawlessly, with no noticeable change for customers.

Jake’s article makes a very valid point, and I applaud him for raising awareness and understanding. This is a behavior of JavaScript that can impact you, your application, and your customers. Jake’s conclusion may not be the best: it trades developer energy for safety, but does it need to? This is a burden developers do not need to carry.

Conclusion 🔚

If you have any questions or feedback, please leave them in the comments below.

To read more of my columns, you may follow me on LinkedIn and Twitter, or check out my portfolio on CharlesStover.com.

Senior front end engineer / charlesstover.com

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store