Practical Two
Deliverable | Due Date |
---|---|
Practical 2 | September 23, 2025 at 02:15 PM |
Practical 2 (revision) | October 23, 2025 at 11:59 PM |
Goals
- Implement unit tests
- Use a linter to write more consistent, more maintainable, higher quality, code
In the last practical, you created your first npm package. Today, we will actually use the module we installed (Vitest) to do some unit testing and we will add a linter called biome to do a stylistic check of your code as well.
There is a lot here, but I’ve done a lot of the heavy lifting for you. Treat this (and all other practicals) as a tutorial that you are trying to learn from, rather than an assignment you are trying to speed through. Take your time, read it thoroughly and don’t hesitate to ask questions.
Prerequisite
Create the git repository for your practical by accepting the assignment from GitHub Classroom. This will create a new repository for you with a bare bones npm package already set up for you.
Clone the repository to you computer with
git clone
(get the name of the repository from GitHub).Open up the package.json file and add your name as the author of the package and the URL of your git repository.
Setting up unit testing
We want to add automated unit tests for our functions. Unit testing JavaScript typically requires two pieces
- a test runner to automatically run all the tests and report the results
- an assertion library for implementing expectations about the behavior of the code under test
We will use the Vitest unit testing package, which provides both. Vitest 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 growing in popularity due to its more modern architecture which makes it quick and easy to configure.
Install Vitest by running pnpm add -D vitest
in the shell.
All of the shell commands listed in this file should be executed inside of the project directory (i.e., the same directory as package.json).
As a reminder, the -D
option specifies that you want to update package.json with this dependency, and that it is a “development” dependency. You only need Vitest 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": {
"vitest": "^3.2.4"
},
Now that you have a testing library, you want to update the “test” script specified in the package.json file to run Vitest. To do so, edit your package.json file to include:
"scripts": {
"test": "vitest"
},
You can now run Vitest with pnpm test
or pnpm run test
. However, since you don’t have any tests yet, you will get an error. By default, Vitest goes into “watcher” mode when it is activated. In this mode, Vitest will watch your files for changes and rerun your tests when a change is detected. Despite the error, Vitest is probably still in watcher mode. Type ‘q’ in the terminal to quit watcher mode.
package.json, as the extension suggests, is a JSON file and must be parse-able as such. package.json is effectively defining a dictionary (associative array) where the keys are "devDependencies"
, "scripts"
, etc. As a dictionary the order of those keys does not matter (although people generally follow the order show in the documentation). However, when adding entries, like above, we need to be careful about commas. Each key-value entry in JSON must be separated by a comma and no trailing comma is permitted after the last key-value pair.
Example: validSong()
We are going to write a 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;
}
export default 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 (Vitest will automatically run any files ending in test.js), and add the following code, which will create a new test suite and a test.
import { describe, expect, test } from "vitest";
import validSong from "./index";
describe("Testing validSong()", ()=>{
test("validSong: accepts valid notes", ()=>{
;
})
; })
Vitest 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 setup and tear down functionality. The “setup” and “tear down” is code that must run before the test and after the test is complete (e.g., to make the tests repeatable and independent).
The test
function should contain one or more assertions, i.e., 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, 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 body of your test:
const validNotes = ["A","B","C","D","E","F","G"];
.forEach((note)=>{
validNotesexpect(validSong(note)).toBeTruthy();
; })
Iteration in Vitest tests
Using a forEach
to iterate over test cases should not be your first solution (we do so here for simplicity). Instead we can use Vitest’s built-in support for “parameterized” tests. Below we parameterize the test by note
. This is particularly effective when we have multiple instances of the same (complex) test with different inputs.
.for(["A", "B", "C", "D", "E", "F", "G"])(
test"validSong: accepts %s",
=> {
(note) expect(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 pnpm 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
.
If had quit the vitest watcher, 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.
The tests should now 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 inputs that we expect to pass together as any single failure will cause the assertion to fail (though we lose some feedback back where the error might have happened), but we can’t group inputs we expect to return false
as they will mask each other (i.e., the first false
means the others are never checked).
The first of these tests should fail, but the test for the special cases will pass. Why is that? We haven’t introduced special characters to our code yet, so any notes with sharps or flats will fail. However, we need the test because once we add in support for the sharps and flats, there is a potential that will not have considered the special cases properly.
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 && (note[1] === "#" || note[1] === "b");
valid
// make sure it isn't a bad note
= valid && ! badNotes.includes(note);
valid
}
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;
}
At this point, all of your tests should be passing.
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.
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
Vitest can also check how well our tests cover our code (coverage docs). However, we will need to install a new module first.
Execute the following in your terminal
pnpm add -D @vitest/coverage-v8
Now, we can add a new script to package.json.
{
"scripts": {
"test": "vitest",
"coverage": "vitest run --coverage"
}
}
Now execute pnpm coverage
to get a report on the coverage of the tests. Your one function should be 100% covered! But as I discussed in lecture, 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.
Linting and formatting
[Linters]1 help us identify “programming errors, bugs, stylistic errors, and suspicious constructs”. For this practical we will use Biome, which can both lint and format our code. Biome provides a collection of fairly opinionated rules, and you and I may not wholly agree with all of them. However, they are a good starting point. It is okay for us to deviate from from their recommendations, but we should do so as a considered decision.
Install Biome as a development dependency by executing
pnpm add -D @biomejs/biome
The next step is to ask Biome to generate a configuration file
pnpm exec biome init
This will generate a file called <biome.json>, which allows us to configure the operations of the tool. We are going to make a small change to make sure that Biome is only looking at the files we care about. Update the “files” sections to look like this:
"files": {
"ignoreUnknown": false,
"includes": ["**/*", "!coverage"]
},
This tells Biome to look at all files and to recurse into sub-folders, but to ignore (“!”) the coverage folder, which is full of generated files that were created when we looked at our test coverage. Biome will have some definite views about the quality of the files it finds in there, but we don’t care – it isn’t our code.
Just as we did for testing, you want to add a script entry point to run the linter. Add
"lint" : "biome lint"
to the scripts section of your package.json file, i.e. it should now look like:
"scripts": {
"test": "vitest",
"lint" : "biome lint"
},
Running the linter
Run the linter with pnpm lint
(which is equivalent to pnpm exec biome lint
). You may have some errors! Biome can fix many errors for you automatically by running pnpm lint --write
. This won’t fix all errors, however. Biome will only make changes to your files if it can be absolutely certain that it knows how to make the change safely. Other errors will require you to manually refactor your code. To learn about a particular error (e.g., “noConsole”), search through the JavaScript rules2. 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.
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 suppress certain rules. You can do so in a variety of ways, including globally (in biome.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.
/* biome-ignore-all lint/suspicious/noConsole: I am still debugging and don't want to remove the log lines yet */
Three things to note here: - we use biome-ignore-all
to ignore all violations of the rule - we are using the full path the error including its category. - after the colon, we have to write the reason we are disabling the rule
Alternately, if we just want to switch off a rule for a single line, we can add a comment immediate before it with biome-ignore
// biome-ignore-all lint/suspicious/noConsole: This code it meant to print something out
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…
Code formatting
Individuality is great, but not so hot when you are working on a team. While the linter goes a long way towards making sure your team is writing consist code, a formatter can help as well. A formatter isn’t looking at the content of your code – it just looks at the formatting (primarily how white spaces and line breaks are used). Under the hood it tokenizes your code and then outputs a new version with its formatting.
These are frequently two separate tools, but Biome actually includes a formatter as well. To run the formatter, execute this in the terminal
pnpm exec biome format
It is likely that the formatter is going to complain about a lot of your files. Fortunately, formatting changes tend to be very safe to fix, so running pnpm exec biome format --write
should correct them all automatically.
Automate linting and formatting
The linter and the formatter are great tools, but they only work if you use them. VSCode can be set up to use these and give you feedback as you work. If you install the Biome extension and enable it, you will see that VSCode is already using it to show you lint errors live.
For formatting, I configure VSCode to use Biome for formatting, but I turn off the Format on Save and Format on Paste options because I like to use white space when I am working to visually break up sections I am actively working on. Instead, I tend to use the keyboard shortcuts3 to format the file.
- On Mac Shift + Option + F
- On Windows Shift + Alt + F
- On Linux Ctrl + Shift + I
To find the options relevant to formatting you can open the VSCode preferences, and type ‘format’ into the search box at the top.
If you go this manual route, however, then you can accidentally add unformatted or un-linted code to a team repository. So, we are going to set up some additional tooling so that both checks are run automatically when you commit your code to git.
To do so, we will install a new tool called husky.
pnpm add -D husky lint-staged
Husky will add “hooks” to the git process, and then it will call the other package you installed (lint-staged) when the time comes.
To initialize Husky, execute pnpm exec husky init
in the terminal. This command will initialize Husky by creating a .husky directory with example hooks and add a “prepare” script to your package.json file to setup Husky when your package is installed elsewhere in the future.
Then add the following to package.json at the top level of the object. This configuration specifies that we would like to run Biome on every JavaScript and JSON file. Note that we are running biome check
. This is a Biome option that runs both the formatter and the linter.
"lint-staged": {
"*.{js,json}": "pnpm exec biome check --write"
},
The last piece here is to actually add the hook to husky. Run the following:
echo "pnpm exec lint-staged" > .husky/pre-commit
That tells husky that we would like the lint-staged
command that we just configured to be run before anything is committed to git. Note that if the linter finds an error and fails, the commit will fail and you will have to fix it before you can proceed.
Go ahead and commit your work to git now. You should see the lint-staged
tasks run at the start of the commit.
It is worth emphasizing the fact that linter errors that cannot be automatically fixed will abort the commit. While this is generally a good thing, sometimes we want to commit partial work to share with a teammate (or the instructor) to get help. In that context we want to commit despite the errors. To do so, add the --no-verify
option to your commit command to bypass the checks (i.e., 💻 git commit --no-verify -m "My pithy commit messages"
).
For most people, husky will just work. But if you are using NVM and the class version of Node is not the default Node version, the husky hooks might not use the correct Node version (e.g., when run by VSCode, etc.). If you use NVM and run into errors related to the Node version when running hooks via husky, create a file ~/.config/husky/init.sh (the ~ indicates your home directory) with:
{% include codeHeader.html %}
# This loads nvm.sh and sets the correct PATH before running any hooks
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
nvm use
This will initialize NVM and select the version specified for this project. Note, this approach assumes you have a .nvmrc file in your project directory (or parent) that specifies the correct Node version. Doing so is good practice. I have created such a file in the parent directory that contains my CS312 assignments so it is applied to all.
Finishing up
Commit any changes you may have made sine the commit at the end of the last section and then push your changes to GitHub.
You should then submit your repository to Gradescope, using the directions here.
Requirements
- Create npm package
- Implement
validSong
- Implement tests with 100% coverage
- Pass all tests
- Pass all linting checks
Recall that the Practical exercises are evaluated as “Satisfactory/Not yet satisfactory”. Your submission will need to implement all of the required functionality (i.e., pass all the tests) to be Satisfactory (2 points).