While writing a CLI tool can be a lot of fun, the initial setup and boilerplate—parsing arguments and flags, validation, subcommands—is generally the same for every CLI, and it’s a drag. That’s where the saves the day. The boilerplate for writing a single-command or multi-command CLI melts away, and you can quickly get into the code that you want to write. oclif framework actually But wait—there’s more! Oclif also has a testing framework that lets you execute your CLI the same way a user would, capturing standard output and errors so that you can test expectations. In this article, I'll show you how to write and test an Oclif CLI application with ease. What Are We Going to Build? We’re all tired of working on the typical TODO application. Instead, let’s build something different but simple. We’ll use a test-driven development (TDD) approach to build a time-tracking application. Our CLI will let us do the following: Add projects Start and end timers on those projects View the total spend on a project View the time spent on each entry for a given project Here’s what a sample interaction with the CLI looks like: time-tracker ~ time-tracker add-project project-one Created new project "project-one" ~ time-tracker start-timer project-one Started a new time entry on "project-one" ~ time-tracker start-timer project-two > Error: Project "project-two" does not exist ~ time-tracker add-project project-two Created new project "project-two" ~ time-tracker start-timer project-two Started a new time entry on "project-two" ~ time-tracker end-timer project-two Ended time entry for "project-two" ~ time-tracker list-projects project-one (0h 0m 13.20s) - 2021-09-20T13:13:09.192Z - 2021-09-20T13:13:22.394Z (0h 0m 13.20s) project-two (0h 0m 7.79s) - 2021-09-20T13:13:22.394Z - 2021-09-20T13:13:30.189Z (0h 0m 7.79s) We’ll manage all of the data about added projects and active timers in a “database” (a simple JSON data file). The source code for our time tracking application project can be found . here Since we’re doing this the TDD way, let’s dive in… tests first! Our Time-Tracker: Features and Tests As we describe our application’s features, we should be thinking about tests we can write to assert the expectations we have for those features. Here is a list of our application’s features: Create a new project : The new project is created, and its record is stored in the underlying database. The user receives a confirmation message. Happy path : If the project already exists, then an error message will appear to the user. The underlying database will be unaltered. Sad path Start a timer on a project : The requested project already exists, so we can start a new time entry, setting the startTime to the current date/time. The user will receive a notification when the timer begins. Happy path : If the timer is already running on another project, then that timer will stop and a new timer will begin on the requested project. The user will receive a notification when the timer begins. Happy path : If the project doesn’t exist, then an error message will appear to the user. The underlying database will be unaltered. Sad path End a timer on a project : A timer is active on the requested project, so we can end that timer and notify the user. Happy path : If the project doesn’t exist, then an error message will appear to the user. The underlying database will be unaltered. Sad path : If the project exists without an active timer, then the user will be notified. The underlying database will be unaltered. Sad path List project : All the projects, total times, entries, and entry times are displayed to the user. Happy path Database existence (for all commands) : If the file doesn’t exist in the current directory, then an error message appears to the user. Sad path time.json For data storage—our “database”—we’ll store our time entries on disk as JSON, in a file called . Below is an example of how this file may look: time.json { "activeProject": "project-two", "projects": { "project-one": { "activeEntry":null, "entries": [ { "startTime": "2021-09-18T06:25:55.874Z", "endTime": "2021-09-18T06:26:03.021Z" }, { "startTime": "2021-09-18T06:26:09.883Z", "endTime": "2021-09-18T06:26:47.585Z" } ] }, "project-two": { "activeEntry": 1, "entries": [ { "startTime": "2021-09-18T06:26:47.585Z", "endTime": "2021-09-18T06:27:13.776Z" }, { "startTime": "2021-09-18T06:52:54.791Z", "endTime": null } ] } } } Design Decisions Finally, let’s cover some of the design decisions for our overall application. First, we’ll store an at the top level of our JSON data. We can use this to quickly check which project is active. Second, we’ll store an field in , which stores the index of the entry that is currently being worked on. activeProject activeEntry each project With these two pieces of information, we can navigate directly to the active project and its active entry in order to end the timer. We can also determine instantly if the project has any active entries or if there are any active projects. Project Setup Now that we’ve laid all the groundwork, let’s create a new project and start digging in. npx oclif multi time-tracker This command creates a new . With a multi-command CLI, we can run commands like and . In these examples, both and are commands, each stored in its own source file in the project, but they all fall under the umbrella CLI. multi-command oclif application time-tracker add-project project-one time-tracker start-timer project-one add-project start-timer separate time-tracker A Word About Stubs We want to take advantage of the test helpers provided by . For testing particular application, we’ll need to write a simple stub. Here’s why: @oclif/test our Our application writes to a file on the filesystem. Imagine if we were running our tests in parallel and had 10 tests that were all writing to the same file at the same time. That would get messy and produce unpredictable results. timer.json A better approach would be to make each test write to its own file, test against those files, and clean up after ourselves. Better yet, each test could write to an object in memory instead of a file, and we can assert our expectations on that object. The best practice when writing unit tests is to replace the driver with something else. In our case, we will stub out the default driver with a driver. FilesystemStorage MemoryStorage is a simple wrapper around that adds some functionality around testing CLI commands. We’re going to use the in to replace the storage driver in our command for testing. @oclif/test @oclif/fancy-test stub functionality @oclif/fancy-test Our First Command: Add Project Now, let's talk about the “add project” command and the important parts related to mocking out the filesystem. Every new oclif project starts with a file in . We’ve renamed it to file and filled it in with the bare minimum. hello.js src/commands add-project.js // PATH: src/commands/add-project.js const {Command} = require('@oclif/command') const FilesystemStorage = require('../storage/filesystem') class AddProjectCommand extends Command { async run() {} } // This is the important line! AddProjectCommand.storage = new FilesystemStorage() AddProjectCommand.description = 'Add a new project to the time tracking database' AddProjectCommand.args = [] module.exports = AddProjectCommand Swappable Storage for Tests Notice how I statically assign a instance to . This allows me—in my tests—to swap out the filesystem storage with an in-memory storage implementation. Let’s look at the and classes below. FilesystemStorage AddProjectCommand.storage FilesystemStorage MemoryStorage // PATH: src/storage/filesystem.js const fs = require('fs/promises') class FilesystemStorage { constructor(initialData = {}) { this.data = initialData } load() { return fs.readFile('./time.json').then(file => { return JSON.parse(file.toString('utf-8')) }).catch(() => { // If reading the file results in an error then assume that the file didn't exist and return an empty object return Promise.resolve(this.data) }) } save(data) { return fs.writeFile('./time.json', JSON.stringify(data)) } } module.exports = FilesystemStorage // PATH: src/storage/memory.js class MemoryStorage { constructor(initialData = {}) { this.data = initialData } load() { return Promise.resolve(this.data) } save(data) { this.data = data return Promise.resolve() } } module.exports = MemoryStorage and have the same interface, so we can swap one out for the other in our tests. FilesystemStorage MemoryStorage The First Test for the Add Project Command In , we renamed to , and we’ve written our first test: test/commands hello.test.js add-project.test.js // PATH: test/commands/add-project.test.js const { expect, test } = require('@oclif/test') const AddProjectCommand = require('../../src/commands/add-project') const MemoryStorage = require('../../src/storage/memory') describe('add project', () => { test .stdout() .stub(AddProjectCommand, 'storage', new MemoryStorage({})) .command(['add-project', 'project-one']) .it('should add a new project', async ctx => { expect(await AddProjectCommand.storage.load()).to.eql({ activeProject: null, projects: { 'project-one': { activeEntry: null, entries: [], }, }, }) expect(ctx.stdout).to.contain('Created new project "project-one"') }) }) The magic happens in the call. We swap out the with (with an empty object for initial data). Then, we assert expectations on the storage contents. stub FilesystemStorage MemoryStorage Unpacking the Command from @oclif/test test Before we implement our command, let’s make sure we understand our test file. Our block calls , which is the entry point to (re-exported from ). describe test @oclif/fancy-test @oclif/test Next, the method captures the output from the command, letting you assert expectations on it by using . There is also a method, but we'll see later that there is another more preferred method for handling errors in .stdout() ctx.stdout .stderr() @oclif/fancy-test. For most applications, you wouldn’t normally make assertions against what’s being written to standard out. However, in the case of a CLI, this is one of your major interfaces with the user, so testing against standard out makes sense. Keep in mind that there is a major gotcha here! If you use to debug while you are developing, then Unless you are asserting against , you'll probably never see that output. console.log .stdout() will capture that output as well. ctx.stdout .stub(AddProjectCommand, 'storage', new MemoryStorage({})) We've talked about the method a bit already, but what we’re doing here is replacing the static property on our command with instead of the default . .stub MemoryStorage FilesystemStorage .command(['add-project', 'project-one']) The method is where things get really cool with . This line calls your CLI just like you would from the command line. You can pass in flags and their values or a list of arguments like I'm doing here. will do the work of calling your command the exact same way as it would be called by an end-user at the command line. .command @oclif/test @oclif/test .it('test description', () => [...]) You might be familiar with blocks. This is where you normally do all the work to set up your test and run assertions against the results. Things are pretty similar here, but you've probably already done the hard work of setting up your test with the other helpers from and , and the block needs only to assert against the output of the command. it @oclif/test @oclif/fancy-test it Finally, now that we understand a bit more about what the test does, we can run our tests with . Since we haven’t written any implementation code, we would expect our test to fail. npm test 1) add project should add a new project: Error: Unexpected argument: project-one See more help with --help at validateArgs (node_modules/@oclif/parser/lib/validate.js:10:19) at Object.validate (node_modules/@oclif/parser/lib/validate.js:55:5) at Object.parse (node_modules/@oclif/parser/lib/index.js:28:7) at AddProjectCommand.parse (node_modules/@oclif/command/lib/command.js:86:41) at AddProjectCommand.run (src/commands/add-project.js:1:1576) at AddProjectCommand._run (node_modules/@oclif/command/lib/command.js:43:31) Perfect! A failed test, just as we expected. Let’s write the code to get to green. Getting to Green: Implementing Our Command Now, we just have to follow the errors to write our command. First, we need to update the class to be aware of the arguments we want to pass in. In this case, we are only passing in a project name. Let’s make that change. AddProjectCommand class AddProjectCommand extends Command { ... } AddProjectCommand.storage = new FilesystemStorage() AddProjectCommand.description = 'Add a new project to the time tracking database' // This is the update AddProjectCommand.args = [ {name: 'projectName', required: true}, ] We need to tell oclif about our command’s expected arguments and their properties. In our case, there is only one argument, , and it is required. You can learn more about oclif arguments , and oclif flags . projectName here here Now, we run the test again. 1) add project should add a new project: AssertionError: expected {} to deeply equal { Object (activeProject, projects) } + expected - actual -{} +{ + "activeProject": [null] + "projects": { + "project-one": { + "activeEntry": [null] + "entries": [] + } + } +} at Context.<anonymous> (test/commands/add-project.test.js:11:55) at async Object.run (node_modules/fancy-test/lib/base.js:44:29) at async Context.run (node_modules/fancy-test/lib/base.js:68:25) Wonderful! We are now seeing that, while we had expected “project-one” to be created, there was no change made to the underlying data structure. Let's update the command with the minimum amount of code necessary to make this test pass. For brevity, we’ll only display the method in . run() src/commands/add-project.js async run() { const {args} = this.parse(AddProjectCommand) const db = await AddProjectCommand.storage.load() db.activeProject = db.activeProject || null db.projects = db.projects || {} db.projects[args.projectName] = { activeEntry: null, entries: [], } await AddProjectCommand.storage.save(db) } By default, if no file exists, then we will receive an empty object when loading from storage. This code creates any default properties and their values if they didn't exist (for example, and ), then it creates a new project with the default structure—an empty array and set to . activeProject projects entries activeEntry null Running the test again, we see the next error. 1) add project should add a new project: AssertionError: expected '' to include 'Created new project "project-one"' at Context.<anonymous> (test/commands/add-project.test.js:20:27) at async Object.run (node_modules/fancy-test/lib/base.js:44:29) at async Context.run (node_modules/fancy-test/lib/base.js:68:25) This is where the function comes into play. We expected our CLI to tell the user that we created their new project, but it didn't say anything. This one is easy to fix. We can add the following line right before we call . .stdout() storage.save() this.log(`Created new project "${args.projectName}"`) Voila! Our first happy path test is passing. Now we’re cruising! add project ✓ should add a new project (43ms) 1 passing (44ms) One More Test We've got one more test for . We need to make sure that the user cannot add another project with the same name as the current project. For these tests, we’ll repeatedly need to generate a database for a single project. Let’s create a helper for this. AddProjectCommand In add the following: test/test-helpers.js module.exports = { generateDb: project => { return { activeProject: null, projects: { [project]: { activeEntry: null, entries: [], }, }, } }, } Now, we can add the next test in : add-project.test.js test .stdout() .stub(AddProjectCommand, 'storage', new MemoryStorage(generateDb('project-one'))) .command(['add-project', 'project-one']) .catch('Project "project-one" already exists') .it('should return an error if the project already exists', async _ => { // Expect that the storage is unchanged expect(await AddProjectCommand.storage.load()).to.eql(generateDb('project-one')) }) There is a new method in this test: .catch('Project "project-one" already exists') I mentioned earlier that we don't need to mock to assert against it. That’s because we can use this method to assert against any errors that happened during the run. In this case, we are expecting that an error will occur and that the underlying storage is unchanged. stderr catch After running our test again, we see the following: 1) add project should return an error if the project already exists: Error: expected error to be thrown at Object.run (node_modules/fancy-test/lib/catch.js:8:19) at Context.run (node_modules/fancy-test/lib/base.js:68:36) Right after we load from storage, we need to check and see if the project already exists and throw an error if it does. db const db = await AddProjectCommand.storage.load() // New code if (db.projects?.[args.projectName]) { this.error(`Project "${args.projectName}" already exists`) } Now, when we run our tests, they all pass! We've done it! We can now add as many projects as we'd like to track our time. add project ✓ should add a new project (46ms) ✓ should return an error if the project already exists (76ms) Conclusion In this article— we’ve talked about oclif, its testing framework, why stubs are useful, and how to use them. Then, we began writing tests and implementation for our CLI. time-tracker This is a great start. In the , we’ll continue building out our CLI with more commands while covering important testing concepts like data store testing and initialization. next part of our series First Published here
Share Your Thoughts