DEV Community

Laura
Laura

Posted on

Lectures of JS Unit Testing - The Practical Guide

This is a summary of the "JavaScript Unit Testing - The Practical Guide" course lectured by Maximilian Schwarzmüller.


Introduction

Diving into automated tests can be a great idea because compared to manual testing, it's way more predictable and consistent.

Let's consider a scenario in which you add a new feature or change some part of your code.

You won't necessarily know or be aware of each part or all of the parts of the code that were truly affected.

With manual testing, we would have to test (or try) the entire application, or at least, things that may be affected by the change. Why? Because we would need to see if everything is still working, and that tiny change or new feature didn't break the code somewhere.

So, as we can imagine, a lot of work.

Also, it can't be guaranteed that everything that needs to be tested will be tested, and everytime something changes, it can't be assured that things will be tested the same way.

With automated testing, we have an initial effort, but it brings a lot of benefits later.

Of the variety of automated tests we have nowadays, here we are going to talk about unit and integration testing, but mainly about unit tests.

Let's think about a unit as the smallest part of our code, for example, functions or classes. So, in unit tests, as the name suggests, we will test each unit of our application. So, if we have, for example, 50 functions, we will create tests for all of them or the majority. That's because we want to guarantee that each part, each unit, is working as it should, as it was first intended.

Integration tests, on the other hand, will be concerned about testing these units together, or, better said, how they work together and if they work well together. That's because even if we test the units alone, that is not a guarantee that they are working together or working as they should.

Test-Driven Development (TDD)

We should also be aware of TDD, the short form of Test-Driven Development.

TDD is a framework/philosophy that leads us to think about writing a failing test first and then implementing the code which will make the test succeed. And then refactor, as a cyclical thing.

A reminder

Keep your tests simple!

When someone else needs to read your code or even you in a future scenario, it's important that it doesn't take too long to understand. It needs to be an easy task.

Vitest

For this guidance, we are going to work with Vitest, a tool based on the famous Jest app for testing and more. Here, it is not intended to go deep into Vitest syntax or all the functionalities, but as a tool for understading the core of testing.

If you want to learn or see everything Vitest can help us with, go to the documentation at the following link: Vitest documentation

Hint

Vitest can work similar as the Webpack tool for bundling, so when using the ES Module notation, we don't actually need to explicitly inform the extension of the file we're importing. For example:

Import math from './math/Math'

Which is the contrary would be:

Import math from './math/Math.js'


Good practices

Here's a little guide to lead you into good practices in your writing tests routine.

Writing good tests

Unit and integration tests can be very useful, but only if written well. And for that, we can follow a series of "good practices" that will be explored below:

Only test your code

When we talk about testing our own code, it means that third-party code is not a responsibility for us to test. Well, it's intended for who wrote the third-party code to ensure it's working fine and well, so it's their responsibility to test it.

Also because you won't be able to change it, so it's no useful to test it.

Know how to differentiate tests

You are not going to test your server-side code implicitly via your client-side code.

And you will test your client-side reaction to different responses and errors.

So separate your testing, tests for your frontend development and tests for your backend development.

AAA

  • Arrange: define the testing environment and values
  • Act: run the actual code/function that should be tested
  • Assert: evaluate the produced value/result and compare it to the expected value/result.

Here there's a Vitest code example:

import { expect, it } from 'vitest';
import { toSum } from './math';

it('should sum two numbers', () => {
    //Arrange
    const num1 = 1;
    const num2 = 2;

    //Act
    const result = toSum(num1, num2);

    //Assert
    const expectedResult = num1 + num2;

    expect(result).toBe(expectedResult);
});
Enter fullscreen mode Exit fullscreen mode

Just to clarify what the code is doing, in case you are not aware of it.

First of all, we import "expect" and "it" from vitest, which are functionalities we need, and the "toSum" function, which is a function built for the example but is in another file.

The "it" works as the scope for our test; it receives a string that behaves as the identifier and a function that will run the test code. Here is very simple; we are saying that it should sum two numbers, that's our expectation for the function and for the test.

In the arrange part we create the variables which will be passed to the "toSum" function. Then, in the act part, we create a constant that will receive the result of the function. Finally, in the assert, we will store the expected result, which would be the sum of the two numbers and use "expect" from Vitest to make the assertion. Basically, we are saying that we expect the result to be the same as the expected result.

There are many assertion possibilities, so please do check the documentation for further study.

Note: "it" is an alias for "test"

Also, it's very important the following line:

const expectedResult = num1 + num2;

Imagine if we've done it like this:

const expectedResult = 3;

It's okay for the current test because we are indeed expecting 3 as the result.

But, imagine in the future, someone changes "num1" or "num2", and forgets to change the "expectedResult"; it would not work if the result of the sum was not 3.

Essence of what is being tested

If you, for example, created a function that is going to receive an array of numbers as an argument and you need to test if it actually received an array of numbers in your test file, you just need to reference an array, for example:
const items = [1, 2];

You don't need to create a bigger array, for example:
const items = [1, 2, 3, 4, 5, 6, 7, 8];

It's unnecessary and redundant. Keep it short, simple and concise, so you will make sure that you are only testing what needs to be tested or what is important for the function.

Test one thing

You can think of one thing as one feature or one behavior. For example, if you have a function that will sum two numbers (the same example above) and you need to make sure that is summing two numbers (because indeed that's the main functionality) but also that the output is of type number, then, you can separate it into two assertions, for example:

import { describe, expect, it } from 'vitest';
import { toSum } from './math';

describe('toSum()', () => {
 it('should sum two numbers', () => {
    const num1 = 1;
    const num2 = 2;

    const result = toSum(num1, num2);

    const expectedResult = num1 + num2;

    expect(result).toBe(expectedResult);
 });

 it('should output a result of type number', () => {
    const num1 = 1;
    const num2 = 2;

    const result = toSum(num1, num2);

    const expectedResult = num1 + num2;

    expect(result).toBe(expectedResult);
 });

})
Enter fullscreen mode Exit fullscreen mode

If you're wondering what describe does, it help us to create suites. As many suites as we want, like dividing blocks of tests. It keeps our code organized, clear, and easier to understand the outputting.

Here's an example using the toSum function:

Image description

As you can see in the image above, it will show us the file path, and after that the "describe" name, and then the "it" name. It's a good idea to keep the describer name short and referencing the function to what the tests are about.

And you could have describers inside describers to organize even further, it's up to you.

SO,

when we create our tests following good practices, we are creating tests that will actually help us on what's needed to be tested. And also, testing forces us to write better code. For that, we can write good functions that will hold only the logic of that function so it'll be easier to test what's need to be tested.

Code coverage

It's important to understand also that coverage doesn't mean good testing or testing that is useful and meaningful for the application. Well, you could cover 100% of your code with meaningless tests after all, or, missing important tests that you didn't think of.

Don't see a high amount of code coverage as the ultimate goal!

You will want to try and test cover the majority of the units (functions or classes) in your application, because that's what unit testing is about, but, there is some code that doesn't need to be tested.

Vitest comes with a built-in functionality to help us measure the code coverage; you can access in the following link: Vitest coverage tool


Callbacks and Async Functions

As callbacks and async functions exhibit specific behavior in Vitest, this section is dedicated to exploring them superficially.

When testing for a callback, keep in mind that Vitest does not wait for the response or for the callback to be executed. Therefore, we need to use the "done" argument.

Consider the following test as an example:

import { expect, it } from 'vitest';
import { generateToken } from './async-example';

it('should generate a token value', (done) => {
   const email = 'test@mail.com'; 

   generateToken(email, (err, token) => {
       expect(token).toBeDefined();
       done()
   })

})
Enter fullscreen mode Exit fullscreen mode

Now, we are working with a callback function. Notice that there's a parameter being passed. The "done".

Vitest now needs to wait until the done function is called.

What would happen if we didn't use the "done" argument? The "expect" wouldn't be executed.

Try and catch

Still in that function, imagine if we changed toBeDefined to toBe, as in the image below:

import { expect, it } from 'vitest';
import { generateToken } from './async-example';

it('should generate a token value', (done) => {
   const email = 'test@mail.com'; 

   generateToken(email, (err, token) => {
       expect(token).toBe(2);
       done();
   });
})
Enter fullscreen mode Exit fullscreen mode

By default, in Vitest, the "toBe" function throws an error each time something doesn't go as expected, in this case, if the token returned wasn't 2.

However, as we are working with a callback, we will need to add an exception handling syntax, such as "try and catch", because if we don't do so, the test will timeout.

import { expect, it } from 'vitest';
import { generateToken } from './async-example';

it('should generate a token value', (done) => {
   const email = 'test@mail.com'; 

   try {
     generateToken(email, (err, token) => {
         expect(token).toBe(2);
     });
   } catch (error) {
       done(error);
   }
})
Enter fullscreen mode Exit fullscreen mode

Since we are dealing with an error, we also need to pass this error to the "done" function.

Promises

Now, when working with promises, it's a bit easier, or should we say, simpler.

import { expect, it } from 'vitest';
import { generateTokenPromise } from './async-example';

it('should generate a token value', () => {
  const email = 'test@mail.com';

  return expect(generateTokenPromise(email)).resolves.toBeDefined();

  //or

  return expect(generateTokenPromise(email)).rejects.toBe();
});
Enter fullscreen mode Exit fullscreen mode

Here we have two possibilities: resolves and rejects

The "return" statement guarantees Vitest waits for the promise to be resolved.

Alternatively, we have:

import { expect, it } from 'vitest';
import { generateTokenPromise } from './async-example';

it('should generate a token value', async () => {
  const email = 'test@mail.com';

  const token = await generateTokenPromise(email);

  expect(token).resolves.toBeDefined();

  // or

  expect(token).rejects.toBe();
})
Enter fullscreen mode Exit fullscreen mode

Here we don't need to use "return" because "async/await" is being used (since a function annotated with "async" returns a promise implicitly).


Hooks, spies and mocks

Here, we are going to explore a little bit of these important functionalities that Vitest provides to us.

Hooks

Imagine working with a bunch of tests that use the same variable, and you don't want to initialize it every single time in every single test because it's the same.

Hooks can help us in this case because you can use functions provided by it that allow us to reuse this variable.

Functions available: "beforeAll", "beforeEach", "afterEach", "afterAll".

Here goes a simple example just to show how it works:

import { beforeEach } from 'vitest';

let myVariable;

beforeEach(() => {
  myVariable = "";
});

it('sentence', () => {
  myVariable = "Hello";
});

it('another sentence', () => {
  myVariable += 2;
});
Enter fullscreen mode Exit fullscreen mode

Now, imagine the same but without the hook:

let myVariable;

it('sentence', () => {
  myVariable = "Hello";
});

it('another sentence', () => {
  myVariable += 2;
});
Enter fullscreen mode Exit fullscreen mode

As we can see, when using "beforeEach" and a global variable, before each test starts to execute, the variable will be "cleaned". This allows the tests to use the variable as if it were fresh.

But, without using the hook and using a global variable, in some cases, things would be tricky. In the example of the test "another sentence," if we didn't clean the variable, it would be holding "Hello" because the "sentence" test is run first. And that's not what we want.

Mocks and Spies

Mocks and spies are mainly to handle side effects and external dependencies.

We could say that spies help us deal with side effects in our functions, and mocks help us deal with side effects of external dependencies.

For that, you will have to import "vi" from vitest.

To build a spy, you can use "vi.fn()" and for a mock "vi.mock()". Inside each function, you will pass the name to the other function (your or external).

So, spies and mocks kind of replace the actual functions with other functions or empty functions.

Mocks will be available only for tests of the file you called them and Vitest, behind the scenes, puts them at the start of the file.


Conclusion

In summary, you need to consider what the unit should or should not do. To achieve this, you can utilize the "it" syntax provided by Vitest, which takes a string describing your expectations and a function that will test the given expectations.

The name of the test should be short, simple and easy to understand.

The testing magic lies in thinking about aspects that were not initially considered, leading to code improvement. This process helps prevent errors and promotes a clearer understanding of what the function should do and its expected behaviors.


If you've read this far, thank you. 🙂

Top comments (0)