CS 312 Software Development

CS 312 - Practical Two

Goals

  • Create your first npm package
  • Implement unit tests
  • Use a linter to write more consistent, more maintainable, higher quality, code

npm is the default package manager for Node.js. It provides a command line tool for installing packages and an associated package registry. Today you will create your first npm package and Node.js module that integrates unit testing with Jest and the ESLint linter.

*There is a lot here, but I've done a lot of the heavy lifting for you. Treat this as an educational tutorial that you are trying to learn from, rather than an assignment you are trying to speed through. Take your time, read it and ask questions if you have them.

Prerequisite

  1. Create the git repository for your practical by accepting the assignment from GitHub Classroom. This will create an empty repository -- don't do anything else with it at this point.

  2. Go to our repl.it team page and click the "Start project" link to start working on Practical 2.

  3. Switch to the Version Control pane and click "Create a git repo".

  4. In the Shell, enter the directions from GitHub on pushing an existing repository.

    1. git remote add origin repo-name where repo-name is the name of your repository (e.g., https://github.com/csci312-s21/practical02-ChristopherPAndrews.git)
    2. git branch -m main
    3. git push -u origin main

Creating the module

This is another point where we will side-step the Repl.it interface a little. Repl.it will allow you to install packages directly through one of the tabs on the left, which will, in essence, create the module for you, but we are going to do it manually so you know the process.

Go to the Shell, and run npm init. The npm init command will create the package.json file by asking you a series of questions. Hit Enter to accept the default for each question (though you should enter your own name as the author).

The package.json file will now appear in the file browser. It is just a normal text file, thought Repl.it lists it under "Packager files".

Open it in the editor.

This may be your first view of a JSON file, so take a moment to look through it -- it has become one of the most common file formats for moving data around the internet.

JSON stands for JavaScript Object Notation. It looks like a JavaScript object with a couple of notable exceptions.

  • We put quotes around all property names
  • The only values that are allowed are numbers, Booleans, strings, objects, and arrays
  • There can be no dangling commas (this catches me regularly)

JSON parsers tend to be quite strict about these rules, so if you have issues with your package.json file, look for these problems.

Once you have looked over the file, add the "private": true property like shown below to prevent accidentally publishing this package to npm. After your manual editing your initial package.json file should look something like the following (though obviously not exactly like it).

Make sure to save package.json before moving on. Installing will try to update package.json for you, and if you haven't saved, you will create edit conflicts. Repl.it saves everything you type automatically, but be careful of this if you are working in other settings.

{
  "name": "practical-2-profAndrews",
  "version": "1.0.0",
  "private": true,
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/csci312-s21/practical02-ChristopherPAndrews.git"
  },
  "author": "Christopher Andrews <candrews@middlebury.edu>",
  "license": "ISC",
  "bugs": {
    "url": "https://github.com/csci312-s21/practical02-ChristopherPAndrews/issues"
  },
  "homepage": "https://github.com/csci312-s21/practical02-ChristopherPAndrews#readme"
}

Setting up unit testing

We want to add automated unit tests for our functions. Unit testing typically requires 1) a test runner to automatically run all the tests and report the results, and 2) an assertion library for implementing expectations about the behavior of the code under test. We will use the Jest unit testing package, which provides both. Jest is one of many possible unit testing libraries; it is not necessarily the best (a matter of opinion) or the most frequently used, but it is fairly high profile and integrated into a number of tools.

Install Jest by running npm install --save-dev jest in the shell. This will install the Jest package and any dependencies into a special directory called node_modules. The node_modules won't show up in the Repl.it file browser, but you can see it if you use ls in the shell. This directory is one of the things that we list in our .gitignore file as we never want to include it in the git repository (it can balloon up to be quite large, and it can always be recreated using the data in package.json).

The --save-dev option specifies that you want to update package.json with this dependency, and that it is a "development" dependency. You only need Jest when developing this module (when you would run the tests) and not in production.

Notice that the package.json file now specifies this new dependency (your version for this package and others may be slightly different):

"devDependencies": {
  "jest": "^26.6.3"
}

Now that you have a testing library, you want to update the "test" script specified in the package.json file to run Jest. To do so, edit your package.json file to include:

"scripts": {
  "test": "jest"
},

You can now run Jest with npm test or npm run test. However, since you don't have any tests yet, you will get an error.

Example: validSong()

We are going to write a silly little function to check if a string contains a song (or at the very least, a valid sequence of notes). Our song will be defined as a string containing notes separated by spaces. The notes will be a single upper case letter in the set [A-G]. They can optionally be modified by a sharp ('#') or a flat ('b'). For reasons we will not get into, there is no 'B#', 'Cb', 'E#' or 'Fb'.

> validSong("C C G G A A G")
true
> validSong("Ab Bb C C# Db D  G")
true
> validSong("Ab Cb")
false
> validSong("Ab Z")
false

Writing tests

We are going to practice test driven development to create this function.

In index.js, put in the function declaration, with no body other than a simple return statement.


const validSong = (song) => {

  return false;
}

module.exports = {
  validSong
}

Now we are going to pick one feature of this function, and write a test to test it. We will start by testing that it accepts valid, unmodified notes.

Create a new file called index.test.js (jest will automatically run any files ending in test.js).

In index.test.js, create a new test suite and a test.

Jest provides the test(string, fn) function. This is a basic test comprising a string description that will be printed when the test is run and a function that will provide the body of the test. We have wrapped that test in the describe function, which helps group tests that share common setup or teardown (described more below).

The test function should contain one or more assertions – tests of state or values in your code. The expect(value) function takes as an argument a value generated by your code in some way and returns an "expectation object". To turn this into a test, you apply a matcher to test that value. There are a number of different matchers, and the one above works exactly as its name suggests. Jest will run all of your tests for you and keep track of how many tests pass and how many fail.

You can have multiple assertions within a single test function. All of the assertions should contribute in some way to the test.

Jest provides another function named describe, which allows us to wrap multiple tests together into a "suite". These tests can be loosely coupled. Perhaps they all test the same component or approach testing a function from different directions. Often the tests in a single describe all share common setup and tear down functionality, that is they all need the same work to be performed before the test is run and after the test is complete.

With all of that in mind, you can paste the following into index.test.js to get started.


const p2 = require('./index');


describe("Testing validSong()", ()=>{
  test("validSong: accepts valid notes", ()=>{

  });

});

Now we need an assertion, which has expect and a matcher. Jest has a lot of matchers, but we can stick with toBeTruthy and toBeFalsy since our function returns a Boolean value.

Since we have a fixed number of valid notes, we can check them all. Add this to the test:


const validNotes = ['A','B','C','D','E','F','G'];

validNotes.forEach((note)=>{
  expect(p2.validSong(note)).toBeTruthy();
});

These assertions only test if the function accepts single notes, we also need to make sure that it can handle strings with multiple notes.

Add a second test and name it "validSong: accepts compound strings". Test the function on the string "A B C D E F G".

Run the tests with npm test. They should fail.

Satisfying tests

Now, we need to write the minimal amount to make sure that our tests pass. In this case, that is pretty easy -- change the return value of the function to true.

Run the tests again. They should now pass.

Iterate

Clearly these were insufficient tests of the behavior we were targeting. When we specified that the function accepted a certain set of letters as valid notes, we really mean "uniquely". So, we need to test the "sad path" as well -- what happens when we give the function invalid input.

We could test all other letters (or symbols!), but that is on the verge of overkill. At a certain point, we need to acknowledge that we are probably not learning more and we are just wasting time (remember that tests should be Fast). So, we want to look for boundary cases -- places near the valid cases. In this case, we can think of a couple of candidates:

  • 'H' -- the letter immediately after the last valid note is a good candidate
  • 'Z' -- this is a belt+suspenders test. It probably isn't necessary, but it is a boundary on the alphabet AND a random additional letter
  • '0' -- this is a representative number, and also a boundary
  • 'a' -- this would be a valid note if it was uppercase, by testing this, we would be adding a firm requirement that only uppercase letters are accepted (test as specification)

Write a test called "validSong: rejects invalid characters". Note that each one of those cases needs to be in a separate assertion, otherwise the first bad character would mask all of the others.

In addition to these, we should test 'AB', which would codify the requirement that notes are separated by spaces. Write a fourth test called "validSong: notes must be separated by spaces". Note that this time we must use two valid notes so we are only testing the spacing.

Run the tests with npm test. They should fail again, which means that we need to work a little harder.

Here is a basic implementation that checks if each note in the song is valid. Note that we are using another higher-order function in there (every).

const validSong = (song) => {
  const validNotes = ['A', 'B', 'C', 'D', 'E', 'F', 'G'];
  // helper to test individual notes
  const validNote = (note)=>{
    return validNotes.includes(note);
  }

  // convert the string to an array of notes  
  const songList = song.split(' '); 

  // every returns true if the passed in function returns true for every value
  const valid = songList.every(validNote);

  return valid;
}

Iterate again

Now we need to add in the sharps and flats. Because of the earlier tests, we only need to test if sharps and flats work with valid notes. We also don't need to test all possible combinations.

Tests we should perform:

  • Are "A#" and "Ab" valid (we will assume that if it works for A, it will work for the others)
  • Are our special cases invalid ("B#", "Cb", "E#", and "Fb")

Write two more tests: "validSong: sharps and flats are accepted" and "validSong: special cases are rejected". Remember that we can group multiple tests that can pass together as any single failure will cause the assertion to fail, but we can't group tests we expect to fail as they will mask each other.

Run npm test again to make sure it fails (though the special character will not yet).

Let's update our function to satisfy these tests.

const validSong = (song) => {
  const validNotes = ['A', 'B', 'C', 'D', 'E', 'F', 'G'];
  const badNotes = ['B#', 'Cb', 'E#', 'Fb'];
  // helper to test individual notes
  const validNote = (note)=>{
    // make sure the first character is valid
    let valid = validNotes.includes(note[0]);
    
    // check the second character
    if (note.length === 2){
      // make sure the second character is '#' or 'b'
      valid = valid && (note[1] === '#' || note[1] === 'b');

      // make sure it isn't a bad note
      valid = valid && ! badNotes.includes(note);
    }

    return valid;
  }

  // convert the string to an array of notes  
  const songList = song.split(' '); 

  // every returns true if the passed in function returns true for every value
  const valid = songList.every(validNote);

  return valid;
}

Run npm test again. Everything should pass.

Fixing bugs

One of our users is using the function, forgets to put spaces in, and is surprised when our function accepts it.

> validSong('A#BB')
true

You can (and should) try this too. Click the 'Run' button to load the function into the REPL, and try some examples.

Add a test that tests this case and demonstrate that it does fail.

Then fix the function (you should be able to do this with a simple else if in the note validator).

Check the coverage

You can evaluate how comprehensive your test suites are with Jest's built-in coverage reports. Run npx jest --coverage. Your one function should be 100% covered! But as we discussed in class, coverage alone is limited measure of test quality. A high quality test suite will have high coverage but a high coverage test suite does not guarantee high quality.

Running a linter

Linters help us identify "programming errors, bugs, stylistic errors, and suspicious constructs". For this practical we will use ESLint and the AirBnB ESLint configuration. You and I may not agree with all of AirBnB's (opinionated) settings, but they provide a good starting point. It is OK for us to deviate from their recommendations, but we should do so as a considered decision.

Install ESLint and the AirBnB configuration as a development dependency by running the following command in the root directory of your package (the directory that contains the package.json file):

npx install-peerdeps --dev eslint-config-airbnb-base

Notice the different approach to installing these packages. npx executes local binaries within the package or as one-off invocations without local installation. Here, you are using it in the latter configuration. You are using the install-peerdeps package to install the peer dependencies for the AirBnB ESLint configuration (that is to install ESLint). The equivalent npm command would be:

$ npx install-peerdeps --dev --dry-run eslint-config-airbnb-base
npx: installed 82 in 6.613s
install-peerdeps v1.10.2
This command would have been run to install eslint-config-airbnb-base@latest:
npm install eslint-config-airbnb-base@13.1.0 eslint@5.3.0 eslint-plugin-import@^2.14.0 --save-dev

To configure ESLint you need to create a new file named .eslintrc.json in the root directory of your package with the following contents. Note that the file name is important as ESLint will look for a file with that exact name.

{
  "extends": "airbnb-base",
  "env": {
    "node": true,
    "jest": true
  }
}

This configuration specifies that you want to use the AirBnB base configuration and that the Node.js and Jest global variables should be predefined.

To prevent ESLint from trying to analyze the files you created as part of the coverage analysis you will want to also create a file named .eslintignore file with the following list of directories (or files) to be ignored. As with .eslintrc.json, this file should be created in the root directory of your package.

# testing
/coverage

Just as we did for testing, you want to add a script entry point to run the linter. Add

"lint" : "eslint ."

to the scripts section of your package.json file, i.e. it should now look like:

"scripts": {
  "test": "jest",
  "lint": "eslint ."
},

Running the linter

Run the linter with npm run lint (which is equivalent to npx eslint .). I suspect you may have some errors! ESLint can fix many of the formatting errors automatically by running npm run lint -- --fix. Other errors will require you to manually refactor your code. To learn more about a particular error, Google the rule name, e.g. no-console. As pedantic as the formatting requirements may seem, enforcing a consistent style is very helpful in a team context. It is much easier to read your teammate's code when everyone uses the same style.

You will probably be able to eliminate all of the errors using --fix. However, sometimes there will be linting errors that we can't eliminate. For example, most linting rule require console.log() calls be removed from production code, but you may be writing something that requires it. If, after very careful consideration you decide that you the rule shouldn't apply, there are ways to disable certain rules. You can do so in a variety of ways, including globally (in .eslintrc.json), for an entire file (with a comment at the top) and for a single line (with an inline comment). For example to turn off the warnings about the console add the following comment to the top of your index.test.js file.

/* eslint-disable no-console */

Alternately you can add // eslint-disable-line to the offending line to disable ESLint on that line.

**Use this power as little as possible and in the most targeted way possible. On most assignments a requirement is that your code passes the linter without errors or warnings -- the intention is that this isn't just because you disabled the linter...

Tuning git behavior

As we learned earlier, by selectively staging individual files, we can control what gets committed to the repository. However, it is frequently tempting to hit the button in Repl.it or do a git add . and commit all changes in the directory and all of its sub-directories. Once you do, it can be a big pain to remove any extra files that got checked in.

In general, we don't want big binary files, library files that aren't ours, OS specific files (like .DS_Store files, which the MacOS puts in every folder to store preferences for how you want to view the folder) or things that can be easily regenerated. In our case, our installing and testing has added two new directories: node_modules (where the installed packages and their dependencies live) and coverage (used by the jest coverage function). The node_modules in particular can balloon rapidly (in the range of 1/2 GB for a medium size project) and will cause all kinds of problems if you check it into the repository.

We can control this with a .gitignore file. This is a listing of files and directories that we want it git to ignore so we don't commit them by accident.

In the shell, type git status. This will show you all of the files and directories with changes. The node_modules directory is already being ignored, but you will see the coverage directory in there.

Create a .gitignore file and add the following lines to it:

# dependencies
/node_modules

# testing
/coverage

# misc
.DS_Store

Now check git status again -- coverage should be missing.

Go ahead and commit your changes. You are welcome to use the Replit interface to "commit & push". This should push the changes out to GitHub and you can follow the directions to submit your repository to Gradescope as described here.

Requirements

  • Create npm package
  • Implement validSong
  • Implement tests with 100% coverage
  • Pass all tests
  • Pass all ESLint checks

Note that I do not currently have a way to automatically test your tests, so I will be looking at those during my review process.


Last updated 03/10/2021