Test Driven Development With the Oclif Testing Library Part 2
Follow into
Save into
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:
1time-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.
1 1// PATH: test/commands/start-timer.test.js
2 2
3 3const {expect, test} = require('@oclif/test')
4 4const StartTimerCommand = require('../../src/commands/start-timer')
5 5const MemoryStorage = require('../../src/storage/memory')
6 6const {generateDb} = require('../test-helpers')
7 7
8 8const someDate = 1631943984467
9 9
1010describe('start timer', () => {
1111 test
1212 .stdout()
1313 .stub(StartTimerCommand, 'storage', new MemoryStorage(generateDb('project-one')))
1414 .stub(Date, 'now', () => someDate)
1515 .command(['start-timer', 'project-one'])
1616 .it('should start a timer for "project-one"', async ctx => {
1717 expect(await StartTimerCommand.storage.load()).to.eql({
1818 activeProject: 'project-one',
1919 projects: {
2020 'project-one': {
2121 activeEntry: 0,
2222 entries: [
2323 {
2424 startTime: new Date(someDate),
2525 endTime: null,
2626 },
2727 ],
2828 },
2929 },
3030 })
3131 expect(ctx.stdout).to.contain('Started a new time entry on "project-one"')
3232 })
3333})
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:
1Error: 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 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.
1 1const {Command, flags} = require('@oclif/command')
2 2const FilesystemStorage = require('../storage/filesystem')
3 3
4 4class StartTimerCommand extends Command {
5 5 async run() {
6 6 const {args} = this.parse(StartTimerCommand)
7 7 const db = await StartTimerCommand.storage.load()
8 8
9 9 await StartTimerCommand.storage.save(db)
1010 }
1111}
1212
1313StartTimerCommand.storage = new FilesystemStorage()
1414
1515StartTimerCommand.description = `Start a new timer for a project`
1616
1717StartTimerCommand.flags = {
1818 name: flags.string({char: 'n', description: 'name to print'}),
1919}
2020
2121module.exports = StartTimerCommand
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)
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.
1const {args} = this.parse(StartTimerCommand)
2const db = await StartTimerCommand.storage.load()
3
4if (db.projects && db.projects[args.projectName]) {
5 db.activeProject = args.projectName
6 // Set the active entry before we push so we can take advantage of the fact
7 // that the current length is the index of the next insert
8 db.projects[args.projectName].activeEntry = db.projects[args.projectName].entries.length
9 db.projects[args.projectName].entries.push({startTime: new Date(Date.now()), endTime: null})
10}
11
12this.log(`Started a new time entry on "${args.projectName}"`)
13
14await 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.
1 1test
2 2 .stdout()
3 3 .stub(StartTimerCommand, 'storage', new MemoryStorage(generateDb('project-one')))
4 4 .stub(Date, 'now', () => someDate)
5 5 .command(['start-timer', 'project-does-not-exist'])
6 6 .catch('Project "project-does-not-exist" does not exist')
7 7 .it('should return an error if the user attempts to start a timer on a project that doesn\'t exist', async _ => {
8 8 // Expect that the storage is unchanged
9 9 expect(await StartTimerCommand.storage.load()).to.eql({
1010 activeProject: null,
1111 projects: {
1212 'project-one': {
1313 activeEntry: null,
1414 entries: [],
1515 },
1616 },
1717 })
1818 })
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 run method, right after we load the db from storage.
1if (!db.projects?.[args.projectName]) {
2 this.error(`Project "${args.projectName}" does not exist`)
3}
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 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.
1 1test
2 2 .stdout()
3 3 .stub(StartTimerCommand, 'storage', new MemoryStorage({
4 4 activeProject: 'project-one',
5 5 projects: {
6 6 'project-one': {
7 7 activeEntry: 0,
8 8 entries: [
9 9 {
1010 startTime: new Date(someStartDate),
1111 endTime: null,
1212 },
1313 ],
1414 },
1515 'project-two': {
1616 activeEntry: null,
1717 entries: [],
1818 },
1919 },
2020 }))
2121 .stub(Date, 'now', () => someDate)
2222 .command(['start-timer', 'project-two'])
2323 .it('should end the running timer from another project before starting a timer on the requested one', async ctx => {
2424 // Expect that the storage is unchanged
2525 expect(await StartTimerCommand.storage.load()).to.eql({
2626 activeProject: 'project-two',
2727 projects: {
2828 'project-one': {
2929 activeEntry: null,
3030 entries: [
3131 {
3232 startTime: new Date(someStartDate),
3333 endTime: new Date(someDate),
3434 },
3535 ],
3636 },
3737 'project-two': {
3838 activeEntry: 0,
3939 entries: [
4040 {
4141 startTime: new Date(someDate),
4242 endTime: null,
4343 },
4444 ],
4545 },
4646 },
4747 })
4848
4949 expect(ctx.stdout).to.contain('Started a new time entry on "project-two"')
5050 })
This test requires another timestamp, which we call someStartDate. We add that near the top of our start-timer.test.js file:
1...
2const someStartDate = 1631936940178
3const someDate = 1631943984467
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)
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.
11// Check to see if there is a timer running on another project and end it
22if (db.activeProject && db.activeProject !== args.projectName) {
33 db.projects[db.activeProject].entries[db.projects[db.activeProject].activeEntry].endTime = new Date(Date.now())
44 db.projects[db.activeProject].activeEntry = null
55}
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 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!
Published To
- https://brennonloveless.medium.com/test-driven-development-with-the-oclif-testing-library-part-two-13698e694d16
- https://dev.to/bloveless/test-driven-development-with-the-oclif-testing-library-part-two-3aab
- https://dzone.com/articles/test-driven-development-with-the-oclif-testing-lib-1
- https://hackernoon.com/build-a-cli-app-with-oclif-and-nodejs-using-test-driven-development-part-2