DEV Community

Cover image for ABC... Tests ⚙️
Damien Chazoule
Damien Chazoule

Posted on • Edited on

ABC... Tests ⚙️

Unit testing for Web development

NB: This post follows a talk on the same topic; here is the support of this presentation: https://slides.com/damienchazoule/abc-tests


Once Upon A Time...

Unit testing are the job of all developers, whether they are frontend or backend experts (or even fullstack).

In 1994, the first unit tests were created for the SmallTalk language: SUnit.

3 years later, Kent Beck (xUnit's creator) meets Erich Gamma and brings SUnit to the Java language, called JUnit!

At the beginning of the 2000s (more exactly 2001), the xUnit family grows with the arrival of a new one: JSUnit.

The goal of unit tests is to verify the proper functioning of all or part of an application / a development. In the same way, these tests can indicate possible post-delivery issues of a project (during the maintenance phase).

Unit tests are part of Agility, especially with the e*Xtreme **Programming (XP* for friends) method which includes the TDD pattern during the iterations of a project. Test Driven Development means writing unit tests even before the development of functionalities / technical components, as well as comments in the code and / or technical documentation.

Different Kinds Of Tests

There are several "degrees" / levels of unit testing, but each works the same way:

  • Setting up the test environment
  • Executing the test / unit test suite
  • Validation / control of test results
  • Finally, resetting the test environment

The 1st tests to perform ("simple" / "basic" tests) are to test the functions, the algorithms, the business logic in the code.

NB: This kind of test helped my son (6 years old at the time) to understand that it's possible to add 2 numbers and get a negative result. Even a sum() function can be fun to test 😉

The 2nd degree of unit testing concerns component-oriented frameworks (Angular, React, Vue, etc...). These tests aim to validate the behavior of a single component / or a set of components (in a complex context, the router for example). Here, we'll get closer to the BDD* pattern.

Finally, there are end-to-end (E2E) tests to verify a user journey / business use case. For example, if you need to check the correct operation of a form, in order to obtain an estimate, or something else... These kinds of tests are there for that!

Simple / Basic Testing

Make your choice....

There are many unit testing solutions in JavaScript. Promoted by the AngularJS (R.I.P) ecosystem, in the 2010s, the Karma test engine and its assertion library Jasmine had their heyday. Note that Jasmine is neither more nor less than the evolution of JSUnit!

At the same time, the Mocha testing framework started to be talked about, an alternative solution to Karma which interfaces with any assertion library. Some time later, after the AngularJS fork (led by Evan You), Vue will embed the Mocha x Chai pair as the default testing solution for its component-oriented framework.

With React, Facebook has redesigned a new unit testing solution that is much simpler in terms of configuration. Jest, by the way, is far from being a "joke", since it carries both the test engine and the assertion library. It is "THE" all-in-one solution for those who want to test their application / code, because of its simplicity (but also its speed).

At this point, I would have already told you to choose Jest without hesitation. But in 2023, the new generation of JavaScript libraries is here! of course, I want to talk about Vite, but especially Vitest. It's all in the name 🚀 This last one interfaces perfectly with the Vite bundler, and takes the main lines of its predecessor Jest.

How To Test ?

Let's get practical, by creating a new JavaScript project (with the next-gen bundler):

npm create vite@latest abc-tests -- --template react-swc
npm install --save-dev vitest
Enter fullscreen mode Exit fullscreen mode

NB: Above, I scaffold the project with the React framework + Speedy Web Compiler for a faster compilation than with Babel!

Let's start slowly by creating a 1st utils.js file, then by writing a simple function:

/**
 * Format date to 'dd/mm/yyyy' pattern
 *
 * @param {Date} dateToFormat
 * @returns {string} Formatted date
 */
export const formatDate = (dateToFormat) => {
  if (dateToFormat instanceof Date) {
    const dd = dateToFormat.getDate();
    const mm = dateToFormat.getMonth() + 1;
    const yyyy = dateToFormat.getFullYear();

    return `${dd}/${mm}/${yyyy}`;
  }

  throw new Error('Not a Date');
};
Enter fullscreen mode Exit fullscreen mode
utils.js

Now, let's test this 1st formatting function from the code below, and the following terminal instruction: npx vitest

import { describe, it, expect } from 'vitest';
import { formatDate } from './utils';

describe('formatDate', () => {
  it('should returns formatted date', () => {
    const mayTheFourth = new Date('2023-05-04');
    expect(formatDate(mayTheFourth)).toEqual('21/5/2023');
  });

  it('should throws an error', () => {
    expect(formatDate('2023-05-04')).toThrowError('Not a Date');
  });
});
Enter fullscreen mode Exit fullscreen mode
utils.test.js

And that's it, it's relatively simple after all! No ? Here, both test cases are covered, i.e. on success, but also on error when the date isn't of type Date. Here are some other examples:

import { suite, test, expect } from 'vitest';
import { toCapitalize } from './utils';

suite("concat or evaluate, that's the question", () => {
  test('it should concat 2 values', () => {
    expect('11' + 1).toEqual('111');
  });

  test('it should evaluate 2 values', () => {
    expect('11' - 1).toEqual(10);
  });
});
Enter fullscreen mode Exit fullscreen mode

NB: There are some keywords to remember when it comes to frontend-oriented unit testing. The describe and suite keywords allow you to declare a test suite, usually associated with a feature. The keywords it and test are relatively self-explanatory, it's simply the test. Finally, expect, the main one / the assertion! Note that it is more used than test because of the following word association "it should..."; it works less well with "test" 😋

It is obviously possible to test asynchronous functions, especially in the context of API testing. Here is a simulation of a service function:

/**
 * Get next value from current
 *
 * @param {number} currentValue
 * @returns {Promise} { nextValue }
 * @throws {Error} NaN
 */
export const getNextValue = (currentValue) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (typeof currentValue === 'number') {
        const nextValue = currentValue + 1;
        resolve({ nextValue });
      } else {
        reject(new Error('Not an Number'));
      }
    }, 1000);
  });
};
Enter fullscreen mode Exit fullscreen mode
counterService.js

And here are the associated tests, in case of success, as well as in case of error:

import { describe, it, expect } from 'vitest';
import * as CounterService from './counterService';

describe('CounterService', () => {
  it('should returns next value', async () => {
    const { nextValue } = await CounterService.getNextValue(41);
    expect(nextValue).toEqual(42);
  });

  it('should throws an error', async () => {
    expect(await CounterService.getNextValue('41')).toThrowError('Not an Number');
  });
});
Enter fullscreen mode Exit fullscreen mode
counterService.test.js

Some tests may seem trivial or even obvious at first glance, but this is rarely the case. In the context of mapping functions (between backend and frontend), the interest is great, since it makes it possible to quickly diagnose regressions / changes at the API signature level. I therefore strongly advise you to split your code as much as possible in order to facilitate testing.

Component Testing

Make your choice (again)...

This is where the Behavior Driven Development* pattern makes sense.

Just as for "classic" unit tests, there are several solutions for testing the components of your application.

Historically, AirBnB comes first with Enzyme! Equipped with an API close to jQuery (to manipulate components), Enzyme allows to mount / render a React component in a virtual DOM (the famous JSDOM). This unit testing framework has a shallow mode allowing to mount a single component (without children), so in a "unitary" way; and a mount mode to render the parent component and the child components associated with it, and thus technically to reach the componentDidMount / componentDidUpdate functions of the React component lifecycle.

Enzyme's direct competitor is none other than Testing Library. Arrival later than this last one. Testing Lib(rary) has a more explicit (and simpler: queryBy* / getBy* / findBy*) modern API for rendering a component and its children through a single function. This library aims to provide better testing practices for component-oriented frameworks. In fact, Testing Lib is agnostic and can be used with Angular, React, Vue, Svelte, Cypress, etc...

How To Test ?

Once again, let's get down to practice by adding the required dependencies and setting up the test environment:

npm i -D @testing-library/jest-dom @testing-library/react jsdom
Enter fullscreen mode Exit fullscreen mode
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';

// https://vitejs.dev/config/
export default defineConfig({
  test: { environment: 'jsdom' }, // 👈 TODO
  plugins: [react()]
});
Enter fullscreen mode Exit fullscreen mode
vite.config.js

Consider the (default) <App /> component below:

import { useState } from 'react';
import * as CounterService from './counterService';
import styles from './app.module.css';

export default function App({ defaultValue = 0, ...props }) {
  const [count, setCount] = useState(defaultValue);

  const handleClick = async (currentValue) => {
    if (props.onClick) props.onClick({ prevValue: currentValue });

    try {
      const { nextValue } = await CounterService.getNextValue(currentValue);
      setCount(nextValue);
    } catch (err) {
      // eslint-disable-next-line
      console.log(err.message);
    }
  };

  return (
    <div className={styles.app}>
      <h1>Vite + React</h1>
      <div className={styles.card}>
        <button onClick={() => handleClick(count)}>Increment</button>
        <p>Count: {count}</p>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
App.jsx

Next, let's run some "behavior" oriented tests:

import { expect, describe, afterEach, it, vi } from 'vitest';
import { cleanup, render, screen, fireEvent } from '@testing-library/react';
import matchers from '@testing-library/jest-dom/matchers';
import App from './App';

expect.extend(matchers);

describe('<App />', () => {
  afterEach(cleanup);

  it('should renders', () => {
    render(<App />);
    expect(screen.getByText('Vite + React')).toBeInTheDocument();
  });

  it('should match snapshot', () => {
    const container = render(<App />);
    expect(container).toMatchSnapshot();
  });

  it('should increment the count value', async () => {
    render(<App defaultValue={41} />);
    expect(screen.getByText('Count: 41')).toBeInTheDocument();

    const btn = screen.getByRole('button', { name: 'Increment' });
    fireEvent.click(btn);

    expect(await screen.findByText('Count: 42')).toBeInTheDocument();
  });

  it('should triggers the click event', () => {
    const onClickMock = vi.fn();
    render(<App defaultValue={41} onClick={onClickMock} />);

    const btn = screen.getByRole('button', { name: 'Increment' });
    fireEvent.click(btn);

    expect(onClickMock).toHaveBeenCalled();
    expect(onClickMock).toHaveBeenCalledWith({ lastCount: 41 });
  });
});
Enter fullscreen mode Exit fullscreen mode
App.test.jsx

Above, several test cases:

  • The first should renders simply renders a component and checks its content;
  • The second should match snapshot creates an image of your component and allows you to detect any differences when your component evolves. In this specific case, you will have to update your snapshot;
  • The 3rd test case checks the value of the count variable before and after clicking on the button;
  • Finally, in the 4th test case, we inject a "mocked" function to our component, we click on the button, then we check that this function has been called with the right arguments;

fireEvent does a lot of things related to events in the DOM, such as filling the fields of a form. For simple cases, why not... For more complex cases, better leave that to end-to-end testing...

Mock Service Worker (MSW)

It's impossible to talk about API unit testing without talking about Mocker Service Worker. MSW acts as a middleware between your application and APIs, intercepting requests at the network level. So, it is no longer necessary to mock Axios or Fetch to control the algorithms related to API calls and returns.

MSW supports REST(ful) requests and GraphQL queries, and offers two modes: worker mode (ideal for developing and debugging your application), and server mode for backend and unit testing.

Straight from the documentation, here's how to install and initialize the worker:

npm i axios && npm i -D msw
npx msw init public --save
Enter fullscreen mode Exit fullscreen mode

Let's enhance the "fake" service previously created by adding a function calling the "real" service:

import axios from 'axios';

/**
 * Post current value to get next
 *
 * @param {number} currentValue
 * @returns {Promise} { nextValue }
 * @throws {Error} 40x or 50x
 */
export const postCurrentValue = async currentValue => {
  try {
    const response = await axios.post('/api/counter', { currentValue });
    return response.data; // { nextValue }
  } catch {
    throw new Error('Not an 20x');
  }
};
Enter fullscreen mode Exit fullscreen mode
counterService.js

NB: Below, here is the code to execute at the entry point (index.js) of your project to mock the API /api/counter return, when running the application.

import { rest, setupWorker } from 'msw';

const handlers = [
  rest.post('/api/counter', async (req, res, ctx) => {
    const { currentValue } = await req.json();

    if (typeof currentValue === 'number') {
      const nextValue = currentValue + 1;
      return res(ctx.delay(1000), ctx.json({ nextValue }));
    }

    return res(ctx.status(422, 'Unprocessable Entity'));
  })
];

const worker = setupWorker(...handlers);
worker.start();
Enter fullscreen mode Exit fullscreen mode

And now, here is how the server mode (alternative to the worker mode) works in the context of unit tests (always in the case of success, but also of failure):

import { describe, beforeAll, afterEach, afterAll, it, expect } from 'vitest';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
import * as CounterService from './counterService';

describe('postCurrentValue', () => {
  const server = setupServer();

  beforeAll(() => server.listen());
  afterEach(() => server.resetHandlers());
  afterAll(() => server.close());

  it('should returns next value', async () => {
    server.use(
      rest.post('/api/counter', (req, res, ctx) => {
        return res(ctx.json({ nextValue: 42 }));
      })
    );

    const result = await CounterService.postCurrentValue(41);
    expect(result.nextValue).toBeDefined();
  });

  it('should throws an error', async () => {
    server.use(
      rest.post('/api/counter', (req, res, ctx) => {
        return res(ctx.status(400, 'Bad Request'));
      })
    );

    const err = await CounterService.postCurrentValue('41').catch(err => err);
    expect(err.message).toEqual('Not an 20x');
  });
});
Enter fullscreen mode Exit fullscreen mode
counterService.test.js

End-To-End Testing

Make your choice (again and again)...

For more complex tests, especially business use cases, there is end-to-end testing. The interest of these tests is to prove the proper execution of a functional course, and to play your application in real cases / close to the final use.

To do this, Cypress has become a key player in end-to-end testing. With a detailed documentation and an active community, this framework makes it possible to emulate a user journey, directly in a browser (Chrome, Firefox or Edge) and to follow its progress visually. With the arrival of Testing Library, Cypress has enhanced its API, and now has getBy* functions (getByText, getByRole, etc...) to manipulate DOM elements.

As an alternative to Cypress, there is Playwright. Supported by Microsoft, it offers a simple and fast configuration for end-to-end testing. Unlike Cypress, Playwright plays business use cases from one or more "headless" browsers (Chromium, Firefox, Webkit) in parallel. It is also available in several languages: JavaScript, Java, Python and C# (unlike its counterpart). Finally, this framework benefits from an API similar to Testing Lib since October 2022, to the delight of developers... 🙏

How To Test ?

What could be more concrete than showing you Playwright in action. To do so, let me demonstrate the power of codegen mode:

npm install --save-dev @playwright/test
npx playwright install
npx playwright codegen --viewport-size=800,600 http://localhost:5173
Enter fullscreen mode Exit fullscreen mode

NB: npx playwright install allows you to install the "headless" browsers on which you will run your tests, namely Chromium, Firefox and WebKit.

CodeGen

NB: The DIY Redux application is a development made as part of work around State Management (i.e. React Vs. Vue).

Playwright's codegen mode is magic! It allows to simulate the functional test case, while recording each of your actions. Then just adapt the selectors, add assertions according to your needs, and run Playwright again (playwright test) to have a complete end-to-end test case.

import { test, expect } from '@playwright/test';

test.use({
  viewport: {
    height: 600,
    width: 800
  }
});

test('it should fill form and move to next (then to next)', async ({ page }) => {
  await page.goto('http://localhost:5173/');

  await expect(page.getByRole('heading', { name: 'Identity' })).toBeVisible();

  await page.getByPlaceholder('Abramov').click();
  await page.getByPlaceholder('Abramov').fill('Chazoule');

  await page.getByPlaceholder('Dan').click();
  await page.getByPlaceholder('Dan').fill('Damien');

  await page.getByRole('button', { name: 'Next' }).click();

  await expect(page.getByRole('heading', { name: 'Professional' })).toBeVisible();

  const firstComboBox = await page.getByRole('combobox').nth(0);
  firstComboBox.selectOption('Front');

  const secondComboBox = await page.getByRole('combobox').nth(1);
  secondComboBox.selectOption('JavaScript');

  await page.getByPlaceholder('Go, Rust...').click();
  await page.getByPlaceholder('Go, Rust...').fill('TypeScript');

  await page.getByRole('button', { name: 'Next' }).click();

  await expect(page.getByRole('heading', { name: 'Fantasy' })).toBeVisible();

  await page.getByPlaceholder('Cliff').click();
  await page.getByPlaceholder('Cliff').fill('Ragnar');

  await page.getByRole('button', { name: 'Previous' }).click();
  await expect(page.getByPlaceholder('Go, Rust...')).toHaveValue('TypeScript');

  await page.getByRole('button', { name: 'Next' }).click();
  await expect(page.getByPlaceholder('Cliff')).toHaveValue('Ragnar');
});
Enter fullscreen mode Exit fullscreen mode

Code Coverage

What about code coverage in all this? 🤔 Code coverage is a metric that determines the code rate performed. This indicator highlights the percentage of code statements, covered branches, executed functions, as well as lines of code not covered (explicitly).

Be careful! It is important to differentiate between tests and the coverage rate! Indeed, the code coverage rate is calculated according to the unit tests executed, but a successful test does not necessarily mean that the code is entirely covered... In the same way, we must also ask ourselves about the interest of an optimal coverage; 100% coverage is sometimes utopian or pointless if it does not bring any added value to the project (security, scalability, maintainability).

With Vitest, the code coverage tool is not included by default, unlike Jest, which relies on Babel to provide this functionality, without installing any additional dependencies.

However, it is possible to obtain code coverage using the C8 library:

npm i -D @vitest/coverage-c8
npx vitest --coverage
Enter fullscreen mode Exit fullscreen mode
-------------------|---------|----------|---------|---------|---------------
File               | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line
-------------------|---------|----------|---------|---------|---------------
All files          |   96.73 |    93.33 |     100 |   96.73 |
 App.jsx           |    92.1 |    83.33 |     100 |    92.1 | 16-18
 counterService.js |     100 |      100 |     100 |     100 |
 utils.js          |     100 |      100 |     100 |     100 |
-------------------|---------|----------|---------|---------|---------------
Enter fullscreen mode Exit fullscreen mode

So, the result above highlights the fact that most of the lines are covered, except for lines 16 to 18 of the App.jsx file (according to previous tests). Looking closer, we see that it is a console.log(). What's the point of going through here? Is there any code missing!? Or is it normal...

The Final Word

Very often, unit tests are synonymous with extra costs for the project (especially for frontend projects because they do not guarantee data integrity, unlike the backend). This is both true and false... Unit testing is a habit to be acquired. The more developers get used to testing their code, the less it will cost the project. In the same way, the flex of the unit test, makes it possible to better split its algorithms, to better organize its technical projects, and so to guarantee a better maintainability of the source code.

The mistake not to make on the frontend is to leave a project "abandoned" without regular maintenance (more than 6 months) and especially without testing (nor comments)... The Web evolves quickly, frontend projects must be updated at the same frequency. In the same way, unit tests guarantee a level of quality and understanding of the code! So the durability of a Web project depends in part on its unit tests 👌

To the question "is it doubtful to test?", I answer that I don't doubt anymore! I test my developments because it's natural (and fun) 🙂

To Go Further...

In order to provide good quality code for your frontend projects, it is possible to interface the SonarQube quality measurement tool with Vitest. In fact, Vitest is able to provide two types of reports: coverage reports and test reports. By combining these two tools with your CI/CD (GitHub Actions, GitLab CI, etc...) you will be able to guarantee some level of quality, by defining a coverage rate to be reached (although controversial) but above all by ensuring that the unit tests are working properly and automatically 👍

NB: SonarQube is a relatively visual tool, and one that allows project actors (other than developers) to quickly identify the robustness of the generated code (red / green indicators).

Finally, we have already seen the "classic" unit tests, component-oriented tests, but also end-to-end tests; there is a last category of tests: the Mutation Testing! The goal of these kinds of tests is to perform a multitude of "faulty" use cases to know if our application is robust. At the moment, I have only found one tool to do that. The JavaScript ecosystem will continue to evolve in the next few years, so we can expect to see this kind of test become more common. To be continued...

Top comments (0)