Writing and Testing CLI Tools

Vinson Chuong
Scripting Bits
Published in
2 min readSep 7, 2020

My last few bits of code have all been CLI tools. CLI tools usually consist of imperative code that is hard to abstract and hard to test. Because they tend to perform many side-effects, test cases have to take extra steps to achieve good isolation.

My test cases generally go through the same steps:

  • Create a temporary directory and make sure it gets cleaned up.
  • Install a copy of the CLI tool to that directory.
  • Provide an IO resource, like an open port.
  • Run the CLI tool and wait for some kind of output.
  • Perform the actual task under test.
  • Kill the CLI tool and free the IO resource.

Every test case has to go through all of these steps because the public interface of the tool is through the command line.

I always want to write fewer lines of test code but I’m never able to find the right seams in the implementation to build abstractions.

It would be nice though to not have to run yarn add in my test cases as it’s easily the most time intensive step. The purpose of running yarn add is to:

  • Download the code
  • Run any post-install hooks
  • Create symlinks for CLI tools and put them in node_modules/.bin/.

But, it takes about 20 seconds to run. I’m guessing that all of that time is spent ensuring that the resulting dependency tree is correct.

Experimenting a bit, it looks like by faking the package installation process as follows, I save the entire 20 seconds:

import fs from 'fs-extra'async function installPackage(packagePath, targetPath) {
const {name: packageName, bin = {}} = await fs.readJson(
path.resolve(packagePath, 'package.json')
)
await fs.ensureSymlink(
packagePath,
path.resolve(targetPath, 'node_modules', packageName)
)
for (const [binName, binPath] of Object.entries(bin)) {
await fs.ensureSymlink(
path.resolve(packagePath, binPath),
path.resolve(targetPath, 'node_modules', '.bin', binName)
)
}
}

I decided to publish this code as its own package:

I ultimately decided to just extract the bits of test code that were most common across my codebases into their own package:

The overall effect has been to reduce the amount of code in a lot of my test cases. So, even though it’s hard to find meaningful abstractions, at least I can cut down on code duplicated across projects.

--

--