How to write decent tests for helpers

TL;DR: You’ll probably write a decent amount of helper function in your life. So learning a good workflow to write and test functions is a good investment. In this post, I want to walk through how I approach writing helpers.

Recently my colleague and I worked on a feature that required working with empty objects. They are a little annoying to work with since I can’t use !{} to see if they are empty. So we wrote a tiny function that helped us deal with this problem.

We made a PR, merged it, and lived happily ever after... Or did we?

After we merged our PR, we realized that we weren’t the only ones who dealt with empty objects in our codebase. So we looked for occurrences of Object.keys( and found several other places doing the same thing as us. That was the moment we decided to write a helper function.

Generally speaking, helpers reduce the amount of code you have to write by reusing the same code all over the place. However, all the code we write is a liability and has a cost since potentially all lines of code that you write could break. This way, we can focus on the logic of the program.

Let’s see how our helper will change by the end of the journey. You can find the end result and test cases on CodeSandbox.

// First implementation
function isEmptyObject(obj) {
  return Object.keys(obj).length === 0
}

When writing helpers, I start with writing tests. Helpers must be reliably robust since they will be potentially reused in dozens of files in the codebase. The tests for our helper looked like this:

// I usually use Jest for testing, so the syntax will depend the
// framework you use.
describe('The `isEmptyObject` helper', () => {
  // Very basic tests related to objects.
  it('should return `true` for empty objects', () => {})
  it('should return `false` for non-empty objects', () => {})

  // Tests that makes sure the function doesn't crash, when no arguments are
  // provided
  it('should return `false` when no arguments are supplied to the function', () => {})

  // All other cases when the supplied argument is not an object
  it('should return `false` when argument is `null`', () => {})
  it('should return `false` when argument is an array', () => {})
  it('should return `false` when argument is an empty array', () => {})
  it('should return `false` when argument is a string', () => {})
  it('should return `false` when argument is an empty string', () => {})
  it('should return `false` when argument is `false`', () => {})
  it('should return `false` when argument is `true`', () => {})
})

After seeing these test cases, you might ask: why do we bother writing tests for cases the helper is not designed to handle? The answer is straightforward: life is unpredictable. For example, you fetch some data, and in the ideal case, the data returned is an object. But what if the request fails and we receive null? What if, by mistake, the response changes, and we end up receiving an array instead of an object? These might sound like edge cases, but according to Murphy's law, everything that can go wrong probably will at some point.

When we started adding the tests, we soon noticed that the third case crashed our code.

it('should return `false` when no arguments are supplied to the function', () => {
  const actual = isObject()
  const expected = false
  expect(actual).toBe(expected)
})

// Running the test results in:
// Uncaught TypeError: Cannot convert undefined or null to object

We can deal with the error in two ways. We can either fall back to some value when no argument is supplied or handle falsy values. In our cases, we just returned early for falsy values by adding this line:

if (!obj) return false

Let's focus on funky cases when the supplied argument is a non-empty string or an array. Our test cases yield false-positive results. We get false as a result, not because our function does what it’s supposed to do, but because we are lucky. Check out the following code:

// We can use `Object.keys` on arrays and strings, but the result may be
// unexpected
Object.keys(['EVIL ARRAY']) // returns ['0']
Object.keys('string') // returns ['0', '1', '2', '3', '4', '5']

Since we’re decent developers that don’t like to rely on luck, we should add a case that handles these cases.

// Handle non-objects
if (typeof obj !== 'object') return false

// Handle arrays
if (Array.isArray(obj)) return false

So, after all tests and fixes, we extended the helper by a few lines. The final result:

function isEmptyObject(obj) {
  // Handle falsy values
  if (!obj) return false

  // Handle types that aren't objects
  if (typeof obj !== 'object') return false

  // Handle arrays
  if (Array.isArray(obj)) return false

  return Object.keys(obj).length === 0
}

In this post, I walked you through how I approach writing helpers. I’m sure there’s probably another JS quirk we didn’t catch with these tests, but this helper will be able to handle most common cases. Although this approach might seem a little pedantic, it makes me feel better. I can merge this into main with a clear conscience, and that’s worth a lot.