Thu May 06 2021

Making a release toolkit - Part 2: Testing

I started working on the changelog command in my previous post, and while I was cleaning it up I ran into a couple of bugs. I don't love running a command over and over to make sure it works every time I make a change, that's what automated tests are for. Let's do that now.

This is an interesting package to test, because much of it is interacting with the file system or accepting input from the user via command line. The technique I usually reach for in cases like this is to push side effects to the edges of your code. To put it another way: create a thin layer that's purely responsible for the interacting with the outside world, so you can keep your business logic isolated. In most apps this would be a data-fetching layer, but it varies depending on the context. In game dev it could be a rendering layer that just draws to the screen 1 .

In this case, I made a separate layer for file system interactions, creating an interface around the fs and inquirer modules. Now we can test it with jest either by using jest.mock to mock the module, or using dependency injection (DI) to pass functions as arguments that can be overridden in tests. That way we don't actually write files or wait for user input during tests.

Let me just add jest and start writing these tests... ooh! Here we go, our first ES module snag. jest isn't yet fully compatible with native ES modules 2 , but there's an active issue tracking progress. Relevant here is that module mocking isn't implemented yet at all! Dependency injection it shall be.

Let's make the changelog function take arguments for each function we want to mock, and default them to the actual functions. This way we can override each function with a mock but when it runs for real we won't need to pass those in. Another option is wrap the functions in a utils object, but I prefer to keep my argument structure flat.

The function definition

The test

The real usage


Okie dokie, but we also want to make sure those utils are doing what we want - the "edge" layer that's handling real-world interaction. Normally I would definitely mock the module here as DI starts to get a little clunky, but c'est la vie et la ES-module-incompatibilité. Do we inject fs into each individual function, or wrap them so the mock fs is available to all? My gut says wrap them, so away we go:

Then we can make sure getChangelog and updateChangelog are using fs in the right way:

At this point our code is tested. There's no need to test fs because it already has tests. The one caveat here is that if the fs API makes breaking changes, these tests will continue to pass. That's a risk I'm willing to take on this project. I don't expect fs to make breaking changes often, and I'm mostly using the tests so I can make changes confidently. If I wanted more protection I could add assertions around the parts of the fs API I'm using.

This is in the spirit of one of my favorite Kent Beck quotes:

...my philosophy is to test as little as possible to reach a given level of confidence

I used to write tests on tests on tests, but at some point got comfortable recognizing the places where I was getting diminishing returns.

OK! Now that we're exporting createUtils instead of utils we need to update our changelog.js:

Et voila!

Learnings:

  • You can use jest with ES modules by running node --experimental-vm-modules node_modules/.bin/jest
  • jest currently has limited ES module compatibility and jest.mock isn't available yet

Takeaways:

  • Push side effects to the edges
  • Without module mocking, we can use DI for testing
  • Add enough tests to be confident you can make changes without breaking existing behavior

Next I'll be making version, verify and publish scripts, and wiring them all up in a CI pipeline. But before that we'll take a closer look at ES modules and why jest mocking doesn't work yet. See you then!


1: This series of posts on TDD game dev was my first exposure to this idea. It got me excited about it enough that I used TDD to made a simple platformer. Old code is always a bit scary to look at, but that was an eye-opening project.

2: Native ES modules are different from Babel, which enables import/export syntax but still ultimately transpiles to CommonJS