In of this series on the oclif testing library, we used a test-driven development approach to building our CLI. We talked about the , which helps developers dispense with the setup and boilerplate so that they can get to writing the meat of their CLI applications. Part One time-tracker oclif framework We also talked about and , which take care of the repetitive setup and teardown so that developers can focus on writing their Mocha tests. @oclif/test @oclif/fancy-test Our application is a . We’ve already written tests and implemented our first command for adding a new project to our tracker. Next, we’re going to write tests and implement our “start timer” command. time-tracker multi-command CLI Just as a reminder, the final application is posted on as a reference in case you hit a roadblock. GitHub First Test for the Start Timer Command Now that we can add a new project to our time tracker, we need to be able to start the timer for that project. The command usage would look like this: time-tracker start-timer project-one Since we’re taking a TDD approach, we’ll start by writing the test. For our happy path test, "project-one" already exists, and we can simply start the timer for it. // PATH: test/commands/start-timer.test.js const {expect, test} = require('@oclif/test') const StartTimerCommand = require('../../src/commands/start-timer') const MemoryStorage = require('../../src/storage/memory') const {generateDb} = require('../test-helpers') const someDate = 1631943984467 describe('start timer', () => { test .stdout() .stub(StartTimerCommand, 'storage', new MemoryStorage(generateDb('project-one'))) .stub(Date, 'now', () => someDate) .command(['start-timer', 'project-one']) .it('should start a timer for "project-one"', async ctx => { expect(await StartTimerCommand.storage.load()).to.eql({ activeProject: 'project-one', projects: { 'project-one': { activeEntry: 0, entries: [ { startTime: new Date(someDate), endTime: null, }, ], }, }, }) expect(ctx.stdout).to.contain('Started a new time entry on "project-one"') }) }) There is a lot of similarity between this test and the first test of our “add project” command. One difference, however, is the additional call. Since we will start the timer with , our test code will preemptively stub out to return . Though we don’t care what the value of is, what’s important is that it is fixed. stub() new Date(Date.now()) Date.now() someDate someDate When we run our test, we get the following error: Error: Cannot find module '../../src/commands/start-timer' It’s time to write some implementation code! Beginning to Implement the Start Time Command We need to create a file for our command. We duplicate the file and rename it as . We clear out most of the method, and we rename the command class to . start-timer add-project.js start-timer.js run StartTimerCommand const {Command, flags} = require('@oclif/command') const FilesystemStorage = require('../storage/filesystem') class StartTimerCommand extends Command { async run() { const {args} = this.parse(StartTimerCommand) const db = await StartTimerCommand.storage.load() await StartTimerCommand.storage.save(db) } } StartTimerCommand.storage = new FilesystemStorage() StartTimerCommand.description = `Start a new timer for a project` StartTimerCommand.flags = { name: flags.string({char: 'n', description: 'name to print'}), } module.exports = StartTimerCommand Now, when we run the test again, we see that the has not been updated as we had expected. db 1) start timer should start a timer for "project-one": AssertionError: expected { Object (activeProject, projects) } to deeply equal { Object (activeProject, projects) } + expected - actual { - "activeProject": [null] + "activeProject": "project-one" "projects": { "project-one": { - "activeEntry": [null] - "entries": [] + "activeEntry": 0 + "entries": [ + { + "endTime": [null] + "startTime": [Date: 2021-09-18T05:46:24.467Z] + } + ] } } } at Context.<anonymous> (test/commands/start-timer.test.js:16: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) While we’re at it, we also know that we should be logging something to tell the user what just happened. So let's update the run method with code to do that. const {args} = this.parse(StartTimerCommand) const db = await StartTimerCommand.storage.load() if (db.projects && db.projects[args.projectName]) { db.activeProject = args.projectName // Set the active entry before we push so we can take advantage of the fact // that the current length is the index of the next insert db.projects[args.projectName].activeEntry = db.projects[args.projectName].entries.length db.projects[args.projectName].entries.push({startTime: new Date(Date.now()), endTime: null}) } this.log(`Started a new time entry on "${args.projectName}"`) await StartTimerCommand.storage.save(db) Running the test again, we see that our tests are all passing! add project ✓ should add a new project ✓ should return an error if the project already exists (59ms) start timer ✓ should start a timer for "project-one" Sad Path: Starting a Timer on a Non-Existent Project Next, we should notify the user if they attempt to start a timer on a project that doesn't exist. Let's start by writing a test for this. test .stdout() .stub(StartTimerCommand, 'storage', new MemoryStorage(generateDb('project-one'))) .stub(Date, 'now', () => someDate) .command(['start-timer', 'project-does-not-exist']) .catch('Project "project-does-not-exist" does not exist') .it('should return an error if the user attempts to start a timer on a project that doesn\'t exist', async _ => { // Expect that the storage is unchanged expect(await StartTimerCommand.storage.load()).to.eql({ activeProject: null, projects: { 'project-one': { activeEntry: null, entries: [], }, }, }) }) And, we are failing again. 1 failing 1) start timer should return an error if the user attempts to start a timer on a project that doesn't exist: 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) Let's write some code to fix that error. We add the following snippet of code to the beginning of the method, right after we load the from storage. run db if (!db.projects?.[args.projectName]) { this.error(`Project "${args.projectName}" does not exist`) } We run the tests again. add project ✓ should add a new project (47ms) ✓ should return an error if the project already exists (75ms) start timer ✓ should start a timer for "project-one" ✓ should return an error if the user attempts to start a timer on a project that doesn't exist Nailed it! Of course, there is one more thing that this command should do. Let's imagine that we've already started a timer on and we want to quickly switch the timer to . We'd expect that the running timer on will stop and a new timer on will begin. project-one project-two project-one project-two Stop One Timer, Start Another We repeat our TDD red-green cycle by first writing a test to represent the missing functionality. test .stdout() .stub(StartTimerCommand, 'storage', new MemoryStorage({ activeProject: 'project-one', projects: { 'project-one': { activeEntry: 0, entries: [ { startTime: new Date(someStartDate), endTime: null, }, ], }, 'project-two': { activeEntry: null, entries: [], }, }, })) .stub(Date, 'now', () => someDate) .command(['start-timer', 'project-two']) .it('should end the running timer from another project before starting a timer on the requested one', async ctx => { // Expect that the storage is unchanged expect(await StartTimerCommand.storage.load()).to.eql({ activeProject: 'project-two', projects: { 'project-one': { activeEntry: null, entries: [ { startTime: new Date(someStartDate), endTime: new Date(someDate), }, ], }, 'project-two': { activeEntry: 0, entries: [ { startTime: new Date(someDate), endTime: null, }, ], }, }, }) expect(ctx.stdout).to.contain('Started a new time entry on "project-two"') }) This test requires another timestamp, which we call . We add that near the top of our file: someStartDate start-timer.test.js ... const someStartDate = 1631936940178 const someDate = 1631943984467 This test is longer than the other tests, but that’s because we needed a very specific initialized within MemoryStorage to represent this test case. You can see that, initially, we have an entry with a and no in . In the assertion, you'll notice that the in is populated, and there is a new active entry in with a and no . db startTime endTime project-one endTime project-one project-two startTime endTime When we run our test suite, we see the following error: 1) start timer should end the running timer from another project before starting a timer on the requested one: AssertionError: expected { Object (activeProject, projects) } to deeply equal { Object (activeProject, projects) } + expected - actual { "activeProject": "project-two" "projects": { "project-one": { - "activeEntry": 0 + "activeEntry": [null] "entries": [ { - "endTime": [null] + "endTime": [Date: 2021-09-18T05:46:24.467Z] "startTime": [Date: 2021-09-18T03:49:00.178Z] } ] } at Context.<anonymous> (test/commands/start-timer.test.js:76: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) This error tells us that our CLI correctly created a new entry in , but it didn't first end the timer on . Our application also didn't change the from to in as we expected. project-two project-one activeEntry 0 null project-one Let's fix up the code to solve this issue. Right after we check that the requested project exists, we can add this block of code which will end a running timer on another project and unset the in that project, and it does that all before we create a new timer on the requested project. activeEntry // Check to see if there is a timer running on another project and end it if (db.activeProject && db.activeProject !== args.projectName) { db.projects[db.activeProject].entries[db.projects[db.activeProject].activeEntry].endTime = new Date(Date.now()) db.projects[db.activeProject].activeEntry = null } And there we have it! All our tests are passing once again! add project ✓ should add a new project (47ms) ✓ should return an error if the project already exists (72ms) start timer ✓ should start a timer for "project-one" ✓ should return an error if the user attempts to start a timer on a project that doesn't exist ✓ should end the running timer from another project before starting a timer on the requested one Conclusion If you’ve been tracking with our CLI development over Part One and Part Two of this oclif testing series, you’ll see that we’ve covered the and commands. We’ve been demonstrating how easy it is to use TDD to build these commands with and . add-project start-timer oclif @oclif/test Because the and commands are so similar to what we’ve already walked through, we’ll leave their development using TDD as an exercise for the reader. The has those commands implemented as well as the tests used to validate the implementation. end-timer list-projects project repository In summary, we laid out plans for using TDD to build a CLI application using the oclif framework. We spent some time getting to know the package and some of the helpers provided by that library. Specifically, we talked about: @oclif/test Using the method for calling our command and passing it arguments command Methods provided by for stubbing parts of our application, catching errors, mocking stdout and stderr, and asserting on those results @oclif/fancy-test Using TDD to build out a large portion of a CLI using a red-green cycle by writing tests first and then writing the minimal amount of code to get our tests to pass Just like that… you've got another tool in your dev belt—this time, for writing and testing your own CLIs! Also published . here