Writing unit tests is an important step to be an intermediate developer from a beginner. The developers who can’t write unit tests are beginners even if they have worked as a programmer for more than 10 years. If there is such a developer, I wanna call him/her an expert of a beginner.
There might be some (or many?) cases that we don’t try to write unit tests because the existing code is legacy, messy, and too hard to understand the class relationships. Yes, it’s true. However, even if we are in such a project we should learn how to write unit tests. We can’t write unit tests if we don’t know how to write.
Let’s try to write the first unit tests in this article. It’s better to write them before learning how to write good unit tests. We can learn them later but first, we should start anyway. By learning how to start unit testing, our code will be cleaner because we need to consider the class relationships carefully.
You are not alone. I will guide you with examples. You can download the complete code from my repository.
Install the necessary packages
We have to install all the necessary packages from npm. There are many packages for unit testing. We will use “mocha” and “chai” because I personally use them and familiar with them. Mocha is a test framework that can perform tests on Node.js. Chai is an assertion library that compares actual data with expected data. We can install them with the following command.
npm install -D mocha chai @types/mocha @types/chai
It is one line but it installs 4 packages as dev dependencies. “-D” option put those packages into “devDependencies” property in package.json file. It means that the packages are used only for developers but not for end-users. They are not packaged when we publish the application in production mode.
Make sure that you install “@types” packages as well. IDE can detect the definitions and helps our coding.
Let’s install the useful tool “ts-node” as well. This tool allows us to execute TypeScript files without compiling them. TypeScript needs to be complied before executing but ts-node does it for us. We can reduce one step with the tool.
npm install -D typescript ts-node
After the two commands, package.json file looks like this below.
{
"name": "unit-testing",
"version": "1.0.0",
...
"devDependencies": {
"@types/chai": "^4.3.0",
"@types/mocha": "^9.1.0",
"chai": "^4.3.6",
"mocha": "^9.2.2",
"ts-node": "^10.7.0",
"typescript": "^4.6.2"
}
}
Those packages are added into “devDependencies“. All packages have a version number with a caret which means it can be updated if a new version is published without any breaking change. Precisely, it’s not updated when the first number increases.
Code for the first test
The first code that we want to test is the following. Let’s assume that we develop a game application and need to store the results and show them. We have to check if it works as expected. This is a simple class that is suitable for the first unit tests.
export interface GameResult {
name: string;
score: number;
}
export class RankingHolder {
private results: GameResult[] = [];
public get average(): number | null {
if (this.results.length === 0) {
return null;
}
return this.results.reduce((pre, cur) => pre + cur.score, 0) / this.results.length;
}
public get highestScore(): number | null {
if (this.results.length === 0) {
return null;
}
return Math.max(...this.results.map(x => x.score));
}
public add(result: GameResult): void {
this.results.push(result);
}
}
We want to check if “average
“ and “highestScore
“ returns a correct value. We don’t write any tests against add
function because it has neither logic nor returns a value. We can actually test it when testing a different function.
How to organize unit tests with mocha
Firstly, we need to import the whole package.
import "mocha";
There are several ways to organize our unit tests. Mocha supports BDD, TDD, Exports, QUnit, and Require-style interfaces. If we are familiar with one of those, we can easily get used to mocha. You should go to official web site for the details. I like BDD style, so I will explain with BDD style.
The BDD style looks as follows.
describe("RankingHolder", () => {
describe("average", () => {
context("when having multiple results", () => {
it("should be something", () =>{
// write first test here
});
it("should be something2", () =>{
// write second test here
});
});
});
});
There are some functions. describe
is used for grouping tests. context
is just an alias for describe
, so we can replace it with describe
. it
is used for a test. A test logic is written in this callback function.
There is no strict rule but I recommend following the structure above.
- The top level
describe
contains a class name - The second level
describe
contains a function name context
contains a explanation for the test groupit
contains a test title and test logic
In my work project, we don’t use “context” because we didn’t know it at the beginning. We don’t have to introduce it to keep the code consistent.
How to compare actual value with expected value in chai
Firstly, let’s import chai package. We basically use expect
function.
import { expect } from "chai";
For the first test, importing expect is enough.
We can write the validation logic in a grammatical way. We can chain words with dots.
expect(variable).to.be.null;
expect(variable).to.be.false;
expect(variable).to.be.true;
expect(variable).to.equal(50);
expect(variable).to.be.true;
expect(instance.average).to.be.instanceOf(Something);
It is very easy to use, isn’t it? Chai offers a lot of functions but let’s stop here for the first test.
Writing actual test cases
We know how to organize tests and how to compare values. Let’s write the unit tests. The test file name is basically xxxx_spec.ts
where xxxx is the file name of the production code. Test file should be in test folder.
// src/test/RankingHolder_spec.ts
import "mocha";
import { expect } from "chai";
import { RankingHolder } from "../RankingHolder";
describe("RankingHolder", () => {
describe("average", () => {
context('when having no result', () => {
it("should return null", () => {
const instance = new RankingHolder();
expect(instance.average).to.be.null;
});
});
context('when having multiple results', () => {
it("should return 50 if a result with score 50 is added", () => {
const instance = new RankingHolder();
instance.add({ name: "Yuto", score: 50 });
expect(instance.average).to.equal(50);
});
it("should return 55 when adding 40, 50, 60 and 70", () => {
const instance = new RankingHolder();
instance.add({ name: "Yuto", score: 40 });
instance.add({ name: "Yuto2", score: 50 });
instance.add({ name: "Yuto3", score: 60 });
instance.add({ name: "Yuto4", score: 70 });
expect(instance.average).to.equal(55);
});
it("should return -2 when adding -6, and 2", () => {
const instance = new RankingHolder();
instance.add({ name: "Yuto", score: -6 });
instance.add({ name: "Yuto2", score: 2 });
expect(instance.average).to.equal(-2);
});
});
});
});
It’s basically the same as production code. Import the necessary packages and then use them. A new instance is created in each test. You might think it is not efficient but this is actually good practice. Each unit test should be independent and should not influence another test.
However, we don’t want to write the same code many times. In this case, we can use beforeEach
function that is called before each test. We can remove the class instantiation statement from each test.
describe("RankingHolder", () => {
describe("average", () => {
let instance: RankingHolder;
beforeEach(() => {
instance = new RankingHolder();
});
context('when having no result', () => {
it("should return null", () => {
expect(instance.average).to.be.null;
});
});
context('when having multiple results', () => {
it("should return 50 if a result with score 50 is added", () => {
instance.add({ name: "Yuto", score: 50 });
expect(instance.average).to.equal(50);
});
it("should return 55 when adding 40, 50, 60 and 70", () => {
instance.add({ name: "Yuto", score: 40 });
instance.add({ name: "Yuto2", score: 50 });
instance.add({ name: "Yuto3", score: 60 });
instance.add({ name: "Yuto4", score: 70 });
expect(instance.average).to.equal(55);
});
it("should return -2 when adding -6, and 2", () => {
instance.add({ name: "Yuto", score: -6 });
instance.add({ name: "Yuto2", score: 2 });
expect(instance.average).to.equal(-2);
});
});
});
});
Mocha offers likewise before
, after
, afterEach
functions.
We might think the score is a positive value but it can be a negative value. We don’t know the specification, so we should also add the case.
We don’t have to add context
to the hierarchy. If it doesn’t have it, the explanation should be included in the test title in it
function.
describe("RankingHolder", () => {
// test case for average here
describe("highestScore", () => {
it("should return null if no result is added", () => {
const instance = new RankingHolder();
expect(instance.highestScore).to.be.null;
});
it("should return 50 if a result with score 50 is added", () => {
const instance = new RankingHolder();
instance.add({ name: "Yuto", score: 50 });
expect(instance.highestScore).to.equal(50);
});
it("should return 70 when adding 40, 50, 60 and 70", () => {
const instance = new RankingHolder();
instance.add({ name: "Yuto", score: 40 });
instance.add({ name: "Yuto2", score: 50 });
instance.add({ name: "Yuto3", score: 60 });
instance.add({ name: "Yuto4", score: 70 });
expect(instance.highestScore).to.equal(70);
});
});
});
How to execute unit tests with mocha
We wrote the first unit tests. The last step that we must do is to execute them. Let’s define the following test script in package.json file.
"scripts": {
"test": "mocha --require ts-node/register src/test/**/*_spec.ts"
},
If ts-node
is not installed, the file must be compiled first. In this case, it looks like the following.
"scripts": {
"test": "tsc && mocha dist/test/**/*_spec.js"
},
After defining the script, we can execute our first tests by the following command.
$ npm run test
> unit-testing@1.0.0 test C:\something\unit-testing
> mocha --require ts-node/register src/test/**/*_spec.ts
RankingHolder
average
when having no result
✔ should return null
when having multiple results
✔ should return 50 if a result with score 50 is added
✔ should return 55 when adding 40, 50, 60 and 70
✔ should return -2 when adding -6, and 2
highestScore
✔ should return null if no result is added
✔ should return 50 if a result with score 50 is added
✔ should return 70 when adding 40, 50, 60 and 70
7 passing (41ms)
The all tests succeeded above.
If one of tests fails, it shows which test fails and the actual value and expected value.
$ npm run test
> unit-testing@1.0.0 test C:\something\unit-testing
> mocha --require ts-node/register src/test/**/*_spec.ts
RankingHolder
average
when having no result
✔ should return null
when having multiple results
✔ should return 50 if a result with score 50 is added
1) should return 55 when adding 40, 50, 60 and 70
✔ should return -2 when adding -6, and 2
highestScore
✔ should return null if no result is added
✔ should return 50 if a result with score 50 is added
✔ should return 70 when adding 40, 50, 60 and 70
6 passing (91ms)
1 failing
1) RankingHolder
average
when having multiple results
should return 55 when adding 40, 50, 60 and 70:
AssertionError: expected 55 to equal 56
+ expected - actual
-55
+56
at Context.<anonymous> (src\test\RankingHolder_spec.ts:30:45)
at processImmediate (internal/timers.js:461:21)
npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! unit-testing@1.0.0 test: `mocha --require ts-node/register src/test/**/*_spec.ts`
npm ERR! Exit status 1
npm ERR!
npm ERR! Failed at the unit-testing@1.0.0 test script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.
npm ERR! A complete log of this run can be found in:
npm ERR! C:\Users\user-name\AppData\Roaming\npm-cache\_logs\2022-03-22T19_26_45_133Z-debug.log
End
That’s it! You are done. If this is your first unit test, let’s try to introduce unit tests into your own project. I hope this article helps beginners.
You can leave comments if you have any questions. Enjoy your coding.
Comments