Are you a beginner at unit testing? Are you not confident enough to choose the test cases? You are not alone. Let’s learn how to determine the test cases to be more confident.
The code written here is TypeScript.
Why unit testing is necessary
Why is unit testing necessary in the first place. It’s actually not necessary if we don’t have to maintain the application in the future. If it is a private project, it’s up to you whether to write unit tests or not.
Normally, an application gradually gets bigger and bigger. It might be clear what to do at the beginning to add a new feature or change the behavior. However, we forget what we did before and why we implemented it in that way. There might be a reason why the code is so complicated but we don’t remember the detail. It is not easy to modify the code without knowing those things.
Even if we determine that we refactor the code, we need to run the application and test all the features again whenever we make changes to the code if we don’t have any unit tests. It is time-consuming and we can’t guarantee that the application works as before. We have to do manual tests but some of them might not be done because the test is done by humans. Even though we have a test list, it might not be clear enough for a new tester to perform the same tests.
Unit testing doesn’t cover all tests because it tests against “unit”. However, it definitely reduces the burden of the manual test. Since the unit tests are written in a programming language, there is no human interaction and thus the results are basically the same for each execution. The tests are executed by a command. We need to run the command manually to execute the existing tests during our development but all tests are executed once we run the command. It’s automated. We don’t need any preparation for the tests and the steps are exactly the same even though a new developer runs it.
We can refactor the code confidently if unit tests are written correctly because it guarantees that the feature works exactly the same way if the tests pass. It means that the refactoring cycle will be faster and the code gets better than before at a decent speed.
A developer should know how to write unit tests. Let’s say, the developers who don’t know how to write unit tests are beginners even if they have 10 years of experience. Production code without test code is called “Legacy code”. We should not write “legacy code”.
Confirm if the test code is correct
A developer, at least I, tends to think “the test code that I wrote works as expected”. If the test becomes a green state, we consider the test succeeded. This is not always true. Test code is also written by humans. It can contain mistakes.
If the tests contain mistakes and thus it always returns a green state, it is hard to find the error because we tend to think the unit test is correct. Even if we luckily find the error, it can take a while to find it. The test was written a long time ago and it must be checked if it worked as expected. If the unit tests are not reliable, it doesn’t make sense to have them. Even worse, it gets us confused.
Check if the test really fails when changing either a test value or an expected value. It might show a green state even if changing one of the values.
You might think I don’t write such a test but unfortunately, it happens if the target code is a bit tangled…
Some bad unit tests load a test data file and contain conditional clauses to determine which test to execute. The test is not performed if the data doesn’t match any conditions but the test result doesn’t show the truth. We should confirm if the added tests are really performed.
What do we test against
We know how a function behaves for specific input. If we give input X, the output must be Y. We should basically test all conditional cases because we know how it works. The test values should be boundary values.
One condition
Let’s consider the easiest case. The following function returns true if the input is 6 or bigger.
function isBiggerThanFive(value: number) {
return value > 5;
}
The boundary values are 5 and 6. We don’t have to input 1,2,3,4,5,6,7 because the function returns the same result for some of them. In this case, 5 and 6 are enough.
Test value: 5, 6
Two conditions
Let’s take a look at this second example that has two conditions. There are two boundaries in this case.
function isBiggerThan5AndLessThan10(value: number) {
return value > 5 && value < 10;
}
The range is short enough, so we can put test values like 3,4,5,6,7,8,9,10,11 but it is not nice. The boundary values are 5 and 6 for the small boundary, 9 and 10 for the big boundary.
Test value: 5, 6, 9, 10
JavaScript function that requires an unknown argument
Was it easy?
Let’s go to the next function. This is JavaScript code that returns the average of the received array.
function average(array){
let sum = 0;
for(let i = 0; i < array.length; i++) {
sum += array[i];
}
return sum/array.length;
}
You probably come up with the following data or similar input.
console.log(average([1,2,3,4,5])); // 3
Did you come up with the following data too?
console.log(average(["1","2","3","4","5"])); // 2469
console.log(average([])); // NaN
console.log(average([1, 2, "3", 4])); // 83.5 -> 334/4
Some of you might not come up with those cases. However, it can happen in reality because it doesn’t have any information about the data type. If it’s production code, it might have assertion code or comment for the type but we don’t know the received argument without that information.
The function works as expected for normal cases. This is needless to say important and we must take care of them but we should also take care of error cases or unexpected input. Our application will be robust by considering error cases.
It’s easy for everyone to find normal use cases. A better programmer finds further error values and fixes the bugs.
Let’s try to find error cases as many as possible.
Function requires an object that has mixed data types
Once we complete practices to learn how to code, we create a function that is more complicated. I created a function that requires an object that has 3 properties. The definition of the arguments is as follows.
export interface ScoreInput {
original: string;
userInput: string;
time: number;
}
I’ve developed a simple typing game running on a console. After an example sentence is shown, a user types the same sentence. After that, the application calculates the score by checking the two sentences and the input time. The specification is something like this.
- The max score is 10000
- The min score is 0
- The score is multiplied by the correct rate
- The result is divided by the squared elapsed time (unit is second)
- If the time is less than 1, set 1 to the time
You can run the console application on your machine if you clone my repository from here.
The implementation is the following.
export function calculateScore(args: ScoreInput): number {
let diffCount = 0;
for (let i = 0; i < args.original.length; i++) {
if (args.userInput[i] === undefined || args.userInput[i] === null) {
diffCount++;
continue;
}
if (args.original[i] !== args.userInput[i]) {
diffCount++;
}
}
if (args.userInput.length > args.original.length) {
diffCount += args.userInput.length - args.original.length;
}
const calculateCorrectRate = () => {
if (diffCount === 0) {
return 1;
} else if (args.original.length === 0 || args.original.length < diffCount) {
return 0;
} else {
return (args.original.length - diffCount) / args.original.length;
}
};
const correctRate = calculateCorrectRate();
const time = args.time <= 1 ? 1 : Math.sqrt(args.time);
return 10000 * correctRate / time;
}
What should we test for this function? We can have the following 8 cases for the string input.
No | original | userInput | Remark |
---|---|---|---|
1 | empty | empty | |
2 | empty | abcd | |
3 | abcd | empty | |
4 | abcd | abcd | Exact Match |
5 | abcd | abxx | Half match but the same length |
6 | abcd | ab | Half-length |
7 | abcd | abcdef | Excessive input |
8 | abcd | xxxxyyyy | The incorrect count is bigger than the original length |
For the time value, I think 0.5, 1, 4
are enough. When time is 4, it will eventually be 2, so it’s easy to calculate. The actual test implementation becomes as follows.
import "mocha";
import { expect } from "chai";
import { calculateScore } from "../../lib/typing-game/ScoreCalculator";
describe("ScoreCalculator", () => {
describe("calculateScore", () => {
context('original is empty', () => {
it("should return 10000 when time is 1 and user input is empty", () => {
const result = calculateScore({
original: "",
userInput: "",
time: 1,
});
expect(result).to.equal(10000);
});
it("should return 0 when time is 1 and user input is not empty", () => {
const result = calculateScore({
original: "",
userInput: "1",
time: 1,
});
expect(result).to.equal(0);
});
});
context('when user input matches original', () => {
[0.5, 1].forEach((time) => {
it(`should return 10000 when time is ${time}`, () => {
const result = calculateScore({
original: "input",
userInput: "input",
time,
});
expect(result).to.equal(10000);
});
});
it("should return 5000 when time is 4", () => {
const result = calculateScore({
original: "input",
userInput: "input",
time: 4,
});
expect(result).to.equal(5000);
});
});
context('when user input does not match original', () => {
context('when time is 1', () => {
it("should return 5000 when half correct and the same length", () => {
const result = calculateScore({
original: "abcd",
userInput: "abdc",
time: 1,
});
expect(result).to.equal(5000);
});
it("should return 5000 when userInput is half length", () => {
const result = calculateScore({
original: "abcd",
userInput: "ab",
time: 1,
});
expect(result).to.equal(5000);
});
it("should return 0 when userInput is empty", () => {
const result = calculateScore({
original: "abcd",
userInput: "",
time: 1,
});
expect(result).to.equal(0);
});
it("should return 0 when incorrect count is bigger than the original length", () => {
const result = calculateScore({
original: "abcd",
userInput: "xxxxyyyy",
time: 1,
});
expect(result).to.equal(0);
});
it("should return 5000 when userInput has half length extra input", () => {
const result = calculateScore({
original: "abcd",
userInput: "abcd11",
time: 1,
});
expect(result).to.equal(5000);
});
});
});
});
});
When I wrote the function above, I didn’t prepare the specification. I found some bugs while writing this article and fixed them. Even if I had the specification in advance, I think I couldn’t implement it without the bugs.
For the first implementation, I didn’t consider the excessive input that the number of incorrect letters exceeds the original string length. In this case, the result becomes a negative value.
In addition to that, I didn’t consider that a user finishes within 1 second. I think it doesn’t cause a real problem because it’s too hard to input everything within a second. But we should consider the case too.
Those cases are often overlooked during the implementation. Doubt yourself to find new test cases.
Comments