Test-driven development with the Oakley Testing Library: Part Two

In the first part of this series on Oakley Testing Library, we have our own time-tracker CLI We talked about the Ocliffe Framework, which helps developers with the setup and distribution of boiler plates so that they can write the flesh of their CLI applications. We also talked about @ oclif / test and @ oclif / fancy-test, which take care of frequent setups and teardowns so that developers can focus on writing their own mocha tests.

our time-tracker The application is a multi-command CLI. We have already written the test and executed our first command to add a new project to our tracker. Next, we’re going to write a test and execute our “Start Timer” command.

Just as a reminder, the final request is posted on GitHub as a reference if you hit a roadblock.

First test for start timer command

Now that we can add a new project to our time tracker, we should be able to start the timer for that project. The use of the command will look like this:

time-tracker start-timer project-one

Since we are adopting the TDD method, we will start by writing the test. For our Happath Path test, “Project-One” already exists, and we can just 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 are many similarities between this test and the first test of our “ad project” command. One difference, however, is additional stub() Call since we will start with the timer. new Date(Date.now()), Our test code will already expire. Date.now() to return someDate. Although we do not care what the cost is. someDate Yes, the important thing is that it is fixed.

When we run our test we get the following error:

Error: Cannot find module '../../src/commands/start-timer'

It’s time to write some execution code!

Start implementing start time command

We need to create a file for ourselves. start-timer Command. We copy. add-project.js File and rename it. start-timer.js. We clean most of it. run Method, and we rename the command class. 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 db Not updated as we 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)

While we’re at it, we also know that we need to log in to let the user know what has just happened. So let’s update the run method with the code to do this.

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)

Let’s run the test again, we see that all our tests are 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"

Tragic way: starting a timer on a non-existent project

Next, we should notify the user if they are trying to start a timer on a project that does not 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, once again, we are failing.

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 this error. We include the following pieces of code at the beginning. run Method, immediately after our loading db From storage.

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

We run the test 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

Nail it! However, there is another thing that must be done. Let’s say we have already turned on the timer. project-one And we want to change the timer faster. project-two. We expect the running timer to be on. project-one Will stop and a new timer will be on. project-two Will begin.

Pause one timer, start another.

We repeat our TDD red-green cycle by writing the first test to represent lost function.

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 time stamp, which we call. someStartDate. We add it near our top. start-timer.test.js File:

const someStartDate = 1631936940178
const someDate = 1631943984467

This test is longer than other tests, but the reason is that we needed a very specific one. db Launched inside MemoryStorage to represent this test case. You can see that, initially, we have an entry with a. startTime And no endTime I project-one. In the claim, you will see that endTime I project-one Is settled, and has a new active entry. project-two with 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)

This error tells us that our CLI correctly created a new entry. project-two, But it did not turn on the first timer. project-one. Our request has not changed either. activeEntry From 0 To null I project-one As we expected.

Let’s fix the code to solve this problem. Immediately after we check that the requested project exists, we can add a block of code that will remove the timer running on another project and unset it. activeEntry In this project, and it does all this 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
}

And there we have it! All our exams 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

Result

If you are aware of our CLI development on Part One and Part Two of this oclif testing series, you will see that we add-project And start-timer Commands We are showing how easy it is to use TDD to create these commands. oclif And @oclif/test.

Because end-timer And list-projects The commands are similar to what we have already gone through, we will leave their development using TDD as an exercise for the reader. The project repository applies those commands as well as the tests used to validate the implementation.

In summary, we plan to use TDD to create a CLI application using the Ocliffe Framework. We spent some time learning. @oclif/test Package and some help provided by this library. Specifically, we talked about:

  • Using command How to call our command and pass arguments to it
  • Methods provided by @oclif/fancy-test To block parts of our application, catch bugs, make fun of stdout and stderr, and emphasize these results
  • Using TDD to generate a large portion of the CLI using the red-green cycle by first writing the test and then writing the minimum code to pass our test

Just like that; you’ve got another tool in your Dave Belt; this time, to write and test your own CLIs!

.

Tags:

Add a Comment

Your email address will not be published. Required fields are marked *