Last time when I spoke about testing, I went straight to the meat about TDD. Now I want to take a step back and talk about basics, from the purpose of testing, through setting up the environment, to writing basic tests, and, eventually in the future, creating full suites.
This is aimed at people who want to learn how to test JavaScript applications, or have some questions regarding the matter, or simply need a refresher.
In today’s development, writing tests is a crucial skill. But, before we dive into “how”, let’s talk about the “why”.
Why should I test my code?
When you create something, anything, like a calculator (foreshadowing), or a TODO app, you need to see if it works. You can do it quickly by running it and clicking around. But doing this every few seconds when you change something is really tedious. And, once you’re done working on a single functionality, you’ll move further, right? And how to make sure that what you’re doing won’t break anything somewhere else?
You can manually test every change, but it’s boring, time- and energy-consuming. You can, instead, write a test to check it for you.
Tests are usually divided into three groups:
- unit tests: the smallest ones, easiest to write and run, these check one functionality of a single component, for example “if a button is clickable”;
- integration tests: these are bigger, and they test how components integrate with each other, for example “if clicking this button triggers that change”;
- end-to-end (e2e): these are the biggest and also the most time-consuming tests. They emulate user environment (such as browser) to manually go through your application.
There are tons of testing pyramids out there, but the gist is: first, the unit tests, then the integration ones, then e2e. It goes from the cheapest to the most expensive.
Cheap, expensive? What does it mean?
We live (mostly) in capitalism. Everything’s bottom line has assigned a monetary cost: your time, your computer’s power consumption, your remote environment (like CI) working time.
We can say that the test is cheap, if writing and running it takes little time. If it can be written in a few minutes and running it takes around 1 millisecond, it’s a cheap test. For example, this:
it("should add 2 and 2", () => {
expect(sum(2, 2)).toBe(4);
});
It’s quick to write and quick to run.
Of course a lot of this relies on the code we are testing. If our sum
function
has to perform an API call and run some LLM to get you your 4
, it won’t be
that cheap. But, if it would require that much, it wouldn’t be a unit test.
When tests require more work around it, like making sure that API calls are reproduced and all the vendors are mocked (don’t worry about this for now), we say that this test is expensive. It requires a lot of time to write and prepare everything (data, environment, mocks) around it, and more than a few milliseconds to run. Like this one:
it("should fetch the data", async () => {
const { data } = await getArticles();
expect(data).toMatchObject(mockData);
});
Notice the mockData
variable. It’s something we need to prepare beforehand,
and to make our application think it’s the real deal. We would integrate more
than one thing into our tests, hence making it an integration one.
How to prepare my project?
Theory out of the way, we can dive into practice. We won’t build a large project, as I want this to be as easy to comprehend as possible. We will make a simple “sum” function, so that, after submitting two numbers, will return their sum. Nothing fancy.
Let’s start by initializing an empty project using Vite.
npm create vite@latest -- --template vanilla
Note that I am using the vanilla
template, so it’s only JavaScript. Now, let’s
add Vitest:
npm i -D vitest;
And, that’s that. The initial setup is done.
The anatomy of a test
I believe the best way to learn is by doing. So let’s try to write a function that will add two numbers together. For example:
// ./src/sum/sum.js
function sum(a, b) {
return a + b;
}
This will take two numbers and sum them. Okay, so how can we test this? The best
tests reflects the usage, not the implementation. So we should just test if
it actually works! To write a test, create a new file in the same directory and
name it [filename].test.js
. So, for our sum.js
it will be sum.test.js
.
Every test consists of three elements: preparation, execution and expectation:
- preparation is doing everything “around” the test, like importing, preparing data etc.;
- execution is what you run, so how you execute your code;
- expectation is what do you expect your code to do.
This can also be described as “given” – “when” – “then”.
Given X function with Y parameters, when I execute is, then I should get Z.
All right, so let’s try to write one!
First, the preparation. Our sum
function is not exposed anywhere, so it won’t
be available for our tests. We need to export
it:
// ./src/sum/sum.js
export function sum(a, b) {
return a + b;
}
Now, we can import it in our test.
// ./src/sum/sum.test.js
import { sum } from "./sum.js";
You might’ve heard about “describe” and “it” statements, and these are perfectly valid, but for writing suites. For now, let’s just write a simple test.
How should we test this function? What do we execute and expect? Well, if my
math is correct, 2+2
should give 4
. So let’s try to write a test that will
execute 2+2
and we will expect it to yield 4
.
// ./src/sum/sum.test.js
import { sum } from "./sum.js";
test("add 2 and 2", () => {
// execution
const result = sum(2, 2);
// expectation
expect(result).toEqual(4);
});
All right, so we have our test. Okay, so how do we run it? By calling vitest
,
just like you would call npm
.
~ vitest
FAIL src/sum/sum.test.js [ src/sum/sum.test.js ]
ReferenceError: test is not defined
❯ src/sum/sum.test.js:5:1
3| import { sum } from "./sum.js";
4|
5| test("add 2 and 2", () => {
| ^
6| // execution
7| const result = sum(2, 2);
Oh, great. Vitest isn’t currently configured to be in our global space, so we need to import the missing files.
// ./src/sum/sum.test.js
import { test, expect } from "vitest";
import { sum } from "./sum.js";
test("add 2 and 2", () => {
// execution
const result = sum(2, 2);
// expectation
expect(result).toEqual(4);
});
Let’s try again!
~ vitest
RERUN src/sum/sum.test.js x1
✓ src/sum/sum.test.js (1)
✓ add 2 and 2
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 09:51:36
Duration 7ms
PASS Waiting for file changes...
press h to show help, press q to quit
Works! You might’ve noticed that the command didn’t finish and is awaiting
something. Waiting for file changes
means it’s running in watch mode, so
every change you’ll make will be automatically reflected. If we add another
test, adding 3 and 3:
test("add 3 and 3", () => {
// execution
const result = sum(3, 3);
// expectation
expect(result).toEqual(6);
});
…it will re-run:
✓ src/sum/sum.test.js (2)
✓ add 2 and 2
✓ add 3 and 3
As you see, this wasn’t that hard! But you know what’s really cool?
Refactoring the code without touching tests
There’s a rule for writing tests saying that they shouldn’t need maintenance. This means, if you change your code, the test should still work. Let’s see if our tests are rock-solid like this.
The way to do this, is to change the body of our sum
function without
changing its signature.
Function signature is describing what function accepts and what does it return.
Our sum
signature is (number, number) => number
. This means, it accepts
two parameters, both numbers, and returns a number.
Okay. so let’s rewrite this. Since we have a full function (declared as
function sum(a, b) { ... }
) rather than a lambda (which is declared as
sum = (a, b) => a+b;
), we have the access to arguments
variable. It is an
object that houses all the parameters we pass to our function. In our case, that
would be:
{
"0": 2,
"1": 2,
}
But this is an object, and we only need values from it. The easiest would be to
throw Object.values(arguments)
and carry on. And how do we sum all that’s in
the array? With a loop. We can use reduce
or for
or whatever construct you
prefer. To make it simple and easy, I’ll just throw for
:
export function sum(a, b) {
const args = Object.values(arguments);
let sum = 0;
for (let i = 0; i < args.length; i++) {
sum += args[i];
}
return sum;
}
And what do you know, it works! The test runs just fine without any changes, even though we’ve rewritten our function entirely. This is a good test: it checks the functionality, but not the implementation.
—
I hope you found this helpful. This is just an introduction to our longer testing course, and I will continue with testing more complex code, mocking API responses and testing the entire app in the next parts!
Thanks for watching and happy testing!