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 runningnode --experimental-vm-modules node_modules/.bin/jest
jest
currently has limited ES module compatibility andjest.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