DEV Community

Brennon Loveless
Brennon Loveless

Posted on

Test-Driven Development With The oclif Testing Library: Part Two

In Part One of this series on the oclif testing library, we used a test-driven development approach to building our time-tracker CLI. We talked about the oclif framework, which helps developers dispense with the setup and boilerplate so that they can get to writing the meat of their CLI applications. We also talked about @oclif/test and @oclif/fancy-test, which take care of the repetitive setup and teardown so that developers can focus on writing their Mocha tests.

Our time-tracker application is a multi-command CLI. 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.

Just as a reminder, the final application is posted on GitHub as a reference in case you hit a roadblock.

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
Enter fullscreen mode Exit fullscreen mode

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"')
  })
})
Enter fullscreen mode Exit fullscreen mode

There is a lot of similarity between this test and the first test of our “add project” command. One difference, however, is the additional stub() call. Since we will start the timer with new Date(Date.now()), our test code will preemptively stub out Date.now() to return someDate. Though we don’t care what the value of someDate is, what’s important is that it is fixed.

When we run our test, we get the following error:

Error: Cannot find module '../../src/commands/start-timer'
Enter fullscreen mode Exit fullscreen mode

It’s time to write some implementation code!

Beginning to Implement the Start Time Command

We need to create a file for our start-timer command. We duplicate the add-project.js file and rename it as start-timer.js. We clear out most of the run method, and we rename the command class to 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
Enter fullscreen mode Exit fullscreen mode

Now, when we run the test again, we see that the db has not been updated as we had expected.

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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: [],
        },
      },
    })
  })
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Let's write some code to fix that error. We add the following snippet of code to the beginning of the run method, right after we load the db from storage.

if (!db.projects?.[args.projectName]) {
    this.error(`Project "${args.projectName}" does not exist`)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 project-one and we want to quickly switch the timer to project-two. We'd expect that the running timer on project-one will stop and a new timer on project-two will begin.

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"')
  })
Enter fullscreen mode Exit fullscreen mode

This test requires another timestamp, which we call someStartDate. We add that near the top of our start-timer.test.js file:

...
const someStartDate = 1631936940178
const someDate = 1631943984467
Enter fullscreen mode Exit fullscreen mode

This test is longer than the other tests, but that’s because we needed a very specific db initialized within MemoryStorage to represent this test case. You can see that, initially, we have an entry with a startTime and no endTime in project-one. In the assertion, you'll notice that the endTime in project-one is populated, and there is a new active entry in project-two with a startTime and no 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)
Enter fullscreen mode Exit fullscreen mode

This error tells us that our CLI correctly created a new entry in project-two, but it didn't first end the timer on project-one. Our application also didn't change the activeEntry from 0 to null in project-one as we expected.

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 activeEntry in that project, and it does that all before we create a new timer on the requested project.

// 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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 add-project and start-timer commands. We’ve been demonstrating how easy it is to use TDD to build these commands with oclif and @oclif/test.

Because the end-timer and list-projects 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 project repository has those commands implemented as well as the tests used to validate the implementation.

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 @oclif/test package and some of the helpers provided by that library. Specifically, we talked about:

  • Using the command method for calling our command and passing it arguments
  • Methods provided by @oclif/fancy-test for stubbing parts of our application, catching errors, mocking stdout and stderr, and asserting on those results
  • 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!

Oldest comments (0)