W06 Learning Activity: Automated Testing with Vitest
Overview
- Purpose: Learn why automated tests are useful and how to write and run tests with Vitest.
- Task: Read the lesson, study the examples, and check your understanding at the end.
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:
- Repeatability: The same checks run the same way each time.
- Fast feedback: Developers learn quickly when a change breaks expected behavior.
- Safer changes: Existing tests give developers more confidence when they improve or change the structure of code.
- Shared expectations: Tests show teammates how the code is expected to behave.
- Fewer repeated steps: A command can perform checks that would take much longer to repeat by hand.
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.
- Arrange: Prepare the input and any data the code needs.
- Act: Run the function or behavior being tested.
- 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:
math.test.jsformat-name.test.jsorders.spec.js
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:
describe()groups related tests. Its name should identify the feature or function being tested.test()defines one expected behavior. Its name should complete a sentence such as, “formatName joins the first and last names with a space.”expect()receives the actual result. A matcher such astoBe()describes the expected result.
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.
beforeAll()runs once before the tests in its group.afterAll()runs once after the tests in its group.beforeEach()runs before every individual test in its group.afterEach()runs after every individual test in its group.
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:
- A normal input produces the expected result.
- A boundary value is handled correctly, such as zero or an empty string.
- Invalid input produces the expected error.
- A bug that was fixed does not return later.
Activity Instructions
Check your understanding
- 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.
- What is the difference between
test()andexpect()?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. - When should you use
toEqual()instead oftoBe()?Click to expand the example answer
Use
toEqual()to compare the contents of objects and arrays. UsetoBe()for exact primitive values such as numbers, strings, and Booleans. - How is
beforeAll()different frombeforeEach()?Click to expand the example answer
beforeAll()runs once before a group of tests.beforeEach()runs again before every individual test in the group. - 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:
- Return to: Week Overview | Course Home