Testing | Part 1 — Introduction to Unit Testing

Why Choose Vitest?

When it comes to selecting a testing framework, Vitest stands out for several compelling reasons:

  1. Compatibility with Jest: Vitest is more than just a new testing framework; it offers almost perfect compatibility with Jest. This makes transitioning from Jest to Vitest (or vice versa) smooth and hassle-free. Since Jest is the de facto standard in testing, this compatibility makes Vitest a much better choice than other newer competitors to Jest.

  2. Simplicity with ES6 Modules: Unlike Jest, where the setup for ES6 modules can be cumbersome, Vitest embraces the modern era by using native ES6 modules by default. This leads to a more streamlined and efficient development process.

  3. Ease of Use with TypeScript and JSX: If you're working with TypeScript or JSX, Vitest offers a more straightforward approach. While Jest can handle these languages, the setup tends to be overly complex, leading to unnecessary headaches.

  4. All-In-One Solution: Vitest is not just a test runner; it's also an assertion library. This dual functionality provides a complete testing solution, saving you time and effort in selecting and configuring additional tools.

1. Setup

2. Triple A pattern

The AAA pattern is a common in software testing pattern that stands for Arrange, Act, Assert:

  • Arrange: Set up the testing environment.
  • Act: Perform the action that you want to test.
  • Assert: Check that the action produced the expected result.

This pattern can be applied to Jest, Vitest, or any other testing framework to organize code in a clear and understandable way.

Here's an example of how you might use the AAA pattern in a vitest test:

import { test } from 'vitest'

test('testing addition function', () => {
  // Arrange
  const a = 5
  const b = 10

  // Act
  const result = a + b

  // Assert
  if (result !== 15) {
    throw new Error('Addition function failed')
  }
})

3. Test invalid inputs

It's essential to test not just the expected outcomes when provided with valid inputs, but also to explore how the system behaves with invalid inputs. This comprehensive approach ensures that we're not only verifying that things work correctly under ideal conditions but also preparing for unexpected scenarios that may arise.

Let's write add add function to explicitly handle the case when at least one of the elements of the input array is a non-number value.

const add = (numbers) => {
  
  let sum = 0;
  for (let i = 0; i < numbers.length; ++i) {
    const number = numbers[i];

    if (typeof number !== 'number') {
      sum = NaN;
      break;
    }
  
    sum += number;
  }

  return sum;
};

Our tests should look something like this.

// Test valid inputs

it('should pass', () => {

  // Arrange:
  const numbers = [1, 2];

  // Act:
  const result = add(numbers);

  // Assert:
  const expected = 1 + 2;
  expect(result).toBe(expected);
});

// ==============================================

// Test invalid input

test('should yield NaN if at least one invalid number is provided', () => {

  // Arrange:
  const numbers = [1, null];

  // Act:
  const result = add(numbers);

  // Assert:
  expect(result).toBeNaN();
})

4. K.I.S.S.: Keep It Simple, Stupid

In software development simplicity is often the key to overall project success. When it comes to testing, this principle is no different. Keeping our tests simple allows us to quickly identify issues, understand what each test covers, and make changes with confidence. Overly complex tests can become a burden, leading to confusion and slowing down the development process. Simple tests promote readability and maintainability, making it easier for team members to collaborate and ensuring that tests remain robust and effective over time. By focusing on clarity and purpose in our testing approach, we can create a streamlined process that supports our goals without becoming an obstacle.

5. Test edge cases

it('input: []  =>  output: 0', () => {

  // Arrange:
  const numbers = [];

  // Act:
  const result = add(numbers);

  // Assert:
  const expected = 0;
  expect(result).toBe(expected);
});

6. Test errors

Next, we'll craft a test specifically aimed at ensuring that the add function behaves as expected when no value is passed into it. The focus here is to verify whether an error is thrown if the input is undefined. In the Arrange phase, even though the setup is commented out, it's where we would typically define the conditions to be tested. During the Act phase, we create a function named resultFn that calls add without any parameters, simulating the condition where no value is provided. Lastly, in the Assert phase, the test employs an expectation that resultFn will indeed throw an error, affirming that the function properly manages this specific situation.

it(`
  should throw an error if no value is passed into the function
  input: undefined  =>  throw Error
`, () => {

  // Arrange:
  // const numbers = undefined;

  // Act:
  // const result = add(numbers);
  // const result = add();
  const resultFn = () => {
    add();
  }

  // Assert:
  expect(resultFn).toThrow();
});

Let's now modify the test to check that the add function should NOT throw an error when valid inputs are passed into it. With the input defined as an array of numbers [0, 1], the expectation is set to confirm that no error is thrown. This emphasizes the function's ability to properly handle valid inputs, complementing the earlier test that focused on an undefined input.

it(`
  should NOT throw an error if valid inputs are passed into the function
  input: [0, 1]  =>  do NOT throw Error
`, () => {

  // Arrange:
  const numbers = [0, 1];

  // Act:
  const resultFn = () => add(numbers);

  // Assert:
  expect(resultFn).not.toThrow();
})

We can test specific error messages by passing in a regular expression containing the desired error message as an argument to the .toThrow() method.

const add = (numbers) => {

  if (!Array.isArray(numbers)) throw new Error('a non-array value was passed into the function');
  
  let sum = 0;
  for (let i = 0; i < numbers.length; ++i) {
    const number = numbers[i];

    if (typeof number !== 'number') {
      sum = NaN;
      break;
    }
  
    sum += number;
  }

  return sum;
};

// ==============================================

it(`
  should throw an error if non array values are passed into the function
  input: (0, 1)  =>  throw Error
`, () => {

  // Arrange:
  const x1 = 0;
  const x2 = 1;

  // Act:
  const resultFn = () => {
    add(x1, x2);
  }

  // Assert:
  expect(resultFn).toThrow(/non-array value was passed/);
});

Conclusion

In this blog post, we introduced unit testing by examining a simple example of an add function. Everything we discussed here is applicable to both frontend projects and backend Node.js projects. In the next post, we'll learn how to interact with the DOM in our unit tests.