CSE 341: Web Services

W06 Learning Activity: Automated Testing with Vitest

Overview

When you write software, you make decisions about how it should behave. A function should return a certain value. An API should return a certain status code. Invalid input should produce a helpful error. Testing is the process of checking whether the software actually behaves that way.

You have already tested code manually. You may have opened a browser, sent a request through Swagger, or added a temporary console.log(). Manual testing is useful because it lets you explore the application and see what a user sees. However, it takes time to repeat the same manual steps after every change. It is also easy to forget a step.

Why Automated Testing Matters

An automated test is code that runs other code and checks the result. Once the test has been written, a developer can run it again in a few seconds. A team can also run the same tests on every computer and before every pull request is merged.

Automated tests provide several benefits:

Tests increase confidence

Passing tests do not prove that an application has no bugs. Tests only check the situations that developers included. A useful test suite increases confidence by checking the application's most important behavior and common error cases.

The Testing Process

Most tests follow three simple stages. Developers sometimes call these stages Arrange, Act, and Assert.

  1. Arrange: Prepare the input and any data the code needs.
  2. Act: Run the function or behavior being tested.
  3. Assert: Check that the actual result matches the expected result.
test('adds two numbers', () => {
  // Arrange
  const firstNumber = 7;
  const secondNumber = 5;

  // Act
  const result = firstNumber + secondNumber;

  // Assert
  expect(result).toBe(12);
});

Comments are included here to make the three stages easy to see. You do not need to add these comments to every test. The important idea is that the test prepares a situation, performs an action, and checks the result.

Introducing Vitest

Vitest is a test runner for JavaScript. A test runner finds test files, runs the tests inside them, and reports which tests passed or failed. Vitest also provides functions for organizing tests and checking results.

The course project will already include the testing packages and configuration. The important scripts in package.json will look similar to this:

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest"
  }
}

Run all tests once with this command:

npm test

During development, you may use watch mode:

npm run test:watch

Watch mode stays open. When a related file changes, Vitest runs the affected tests again. Press q to leave watch mode.

How Vitest Finds Tests

Vitest normally looks for filenames that include .test. or .spec.. For example, all of these are common test filenames:

A project often keeps tests in a tests directory, but tests can also be stored next to the files they test. For your project, you will put the tests in a tests directory.

Your First Test File

Suppose the application contains a small function that joins a first name and a last name:

// src/utils/format-name.js
export function formatName(firstName, lastName) {
  return `${firstName} ${lastName}`;
}

A test for this function could look like this:

// tests/format-name.test.js
import { describe, expect, test } from 'vitest';
import { formatName } from '../src/utils/format-name.js';

describe('formatName', () => {
  test('joins the first and last names with a space', () => {
    const result = formatName('Ada', 'Lovelace');

    expect(result).toBe('Ada Lovelace');
  });
});

This example introduces three important Vitest functions:

Testing More Than One Situation

A single function can behave differently with different input. Each test should focus on one clear behavior. Small tests make failures easier to understand.

import { describe, expect, test } from 'vitest';
import { formatName } from '../src/utils/format-name.js';

describe('formatName', () => {
  test('joins the first and last names with a space', () => {
    expect(formatName('Ada', 'Lovelace')).toBe('Ada Lovelace');
  });

  test('keeps hyphens that are part of a name', () => {
    expect(formatName('Mary-Jane', 'Watson')).toBe('Mary-Jane Watson');
  });

  test('returns a string', () => {
    expect(typeof formatName('Grace', 'Hopper')).toBe('string');
  });
});

If the second test fails, Vitest reports its name. The developer immediately knows which situation did not work. This is one reason descriptive test names are important.

Matchers and Expected Results

A matcher tells Vitest how to compare the actual value with the expected value. Vitest includes many matchers. You only need a small number to begin.

Matcher What It Checks Example
toBe() An exact primitive value, such as a number, string, or Boolean expect(total).toBe(12)
toEqual() The contents of an object or array expect(names).toEqual(['Ana', 'Bo'])
toContain() A string or array contains a value expect(message).toContain('required')
toHaveProperty() An object contains a named property expect(user).toHaveProperty('email')
toBeGreaterThan() A number is larger than another number expect(price).toBeGreaterThan(0)
toBeTruthy() A value is treated as true by JavaScript expect(savedRecord).toBeTruthy()

Use toBe() for exact values such as 4 or 'hello'. Use toEqual() when you want to compare the values inside an object or array.

test('creates an array of doubled numbers', () => {
  const result = [1, 2, 3].map((number) => number * 2);

  expect(result).toEqual([2, 4, 6]);
});

You can place .not before a matcher to check that something is not true:

expect(total).not.toBe(0);
expect(message).not.toContain('password');

What Happens When a Test Fails?

A test passes when every expectation in that test is satisfied. A test fails when an expectation is not satisfied or when the tested code throws an unexpected error.

For example, this test will fail:

test('multiplies 6 by 4', () => {
  const result = 6 * 4;

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

Vitest will report that the test expected 20 but received 24. A failing test is useful information. Read the test name, expected value, actual value, and stack trace before changing the code.

Do not change a correct expectation just to make a test pass

First decide whether the application or the test is wrong. The expectation should describe the required behavior. If the requirement says that six times four is twenty-four, changing the expectation to twenty would hide the problem instead of fixing it.

Testing Asynchronous Code

Web service code often waits for a database or another service. An asynchronous test uses async and await, just like other asynchronous JavaScript code.

async function calculateLater(firstNumber, secondNumber) {
  return firstNumber + secondNumber;
}

test('waits for an asynchronous result', async () => {
  const result = await calculateLater(8, 3);

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

Remember to use await. If the test finishes before the Promise finishes, the test may not check the result you intended to check.

Setup and Cleanup

Tests sometimes need preparation before they run. They may also need cleanup after they finish. Vitest provides lifecycle hooks for this work.

import {
  afterAll,
  afterEach,
  beforeAll,
  beforeEach,
  describe,
  expect,
  test
} from 'vitest';

let numbers;

beforeAll(() => {
  console.log('The tests in this file are starting.');
});

beforeEach(() => {
  numbers = [2, 4, 6];
});

afterEach(() => {
  numbers = [];
});

afterAll(() => {
  console.log('The tests in this file are finished.');
});

describe('number calculations', () => {
  test('adds all numbers', () => {
    const total = numbers.reduce((sum, number) => sum + number, 0);

    expect(total).toBe(12);
  });

  test('finds the number of items', () => {
    expect(numbers.length).toBe(3);
  });
});

The beforeEach() hook creates a fresh array before each test. One test cannot accidentally leave changed array data for the next test. Tests are easier to trust when each one starts from a known state.

A hook follows the scope where it is declared. A hook inside a describe() block applies only to that group. A hook at the top of a test file applies to the tests in that file.

Setup for the Entire Test Run

Some resources should start once before any test file runs and stop once after all test files finish. Vitest calls this global setup. The course project uses global setup to start a temporary MongoDB server. You will study that process in the next learning activity.

A global setup module has this general shape:

export default async function setup() {
  // Start a shared resource before the test run.

  return async () => {
    // Stop the shared resource after the test run.
  };
}

You will not need to create this global setup configuration for the team project. However, understanding the timing is helpful: global setup surrounds the complete test run, while beforeEach() and afterEach() surround every individual test.

Write Independent Tests

Each test should be able to pass when it runs by itself. A test should not depend on another test running first. For example, one test should not create a value that a later test expects to find.

Independent tests are important because test runners may run files in a different order. Developers may also choose to run only one test while investigating a problem.

Choose Useful Test Cases

Writing many tests is not automatically better than writing a few thoughtful tests. Begin with behavior that matters:

Activity Instructions

Check your understanding

  1. What are the Arrange, Act, and Assert stages of a test?
    Click to expand the example answer

    Arrange prepares the input and starting state. Act runs the behavior being tested. Assert compares the actual result with the expected result.

  2. What is the difference between test() and expect()?
    Click to expand the example answer

    test() defines one behavior to check. expect() receives an actual value so a matcher can compare it with the expected value.

  3. When should you use toEqual() instead of toBe()?
    Click to expand the example answer

    Use toEqual() to compare the contents of objects and arrays. Use toBe() for exact primitive values such as numbers, strings, and Booleans.

  4. How is beforeAll() different from beforeEach()?
    Click to expand the example answer

    beforeAll() runs once before a group of tests. beforeEach() runs again before every individual test in the group.

  5. Why should one test not depend on another test?
    Click to expand the example answer

    Tests may run separately or in a different order. Each test should create the state it needs so its result stays predictable.

Other Links: