JavaScript · Syntax · Advanced
Testing in JavaScript
Write and structure unit tests using Jest/Vitest. Covers describe/it/expect, mocking, async tests, and fake timers.
Quick topic start and explanations before exercises (exercises below):
jest.fn(), spyOn, module mocking, fake timers, test.each
#AAA pattern, testable code, isolation, common mistakes, CLI reference
#Exercises:
describe / it / expect: basic test structure
#Write a `sum(a, b)` function and a full test suite for it. Use `describe` to group tests, `it` (or `test`) for each case, and `expect(...).toBe(...)` for assertions. Cover: positive numbers, zero, negative numbers.
// sum.js
function sum(a, b) {
return a + b;
}
// sum.test.js
describe('sum', () => {
it('adds two positive numbers', () => {
// write expect
});
it('returns the number when adding zero', () => {
// write expect
});
it('handles negative numbers', () => {
// write expect
});
});
Solution
function sum(a, b) {
return a + b;
}
describe('sum', () => {
it('adds two positive numbers', () => {
expect(sum(2, 3)).toBe(5);
});
it('returns the number when adding zero', () => {
expect(sum(5, 0)).toBe(5);
expect(sum(0, 5)).toBe(5);
});
it('handles negative numbers', () => {
expect(sum(-1, -2)).toBe(-3);
expect(sum(-1, 1)).toBe(0);
});
});
expect matchers: toBe, toEqual, toContain, toThrow
#Write tests demonstrating four matchers: `toBe` (primitive equality), `toEqual` (deep object equality), `toContain` (array/string inclusion), `toThrow` (function throws). Use a `getUser()` function that returns an object and a `divide(a, b)` that throws on zero.
function getUser() {
return { name: 'Alice', age: 30, tags: ['admin', 'user'] };
}
function divide(a, b) {
if (b === 0) throw new Error('Division by zero');
return a / b;
}
describe('matchers', () => {
it('toBe: strict equality', () => { /* */ });
it('toEqual: deep object equality', () => { /* */ });
it('toContain: array inclusion', () => { /* */ });
it('toThrow: function throws', () => { /* */ });
});
Solution
function getUser() {
return { name: 'Alice', age: 30, tags: ['admin', 'user'] };
}
function divide(a, b) {
if (b === 0) throw new Error('Division by zero');
return a / b;
}
describe('matchers', () => {
it('toBe: strict equality', () => {
expect(2 + 2).toBe(4);
expect('hello').toBe('hello');
});
it('toEqual: deep object equality', () => {
expect(getUser()).toEqual({ name: 'Alice', age: 30, tags: ['admin', 'user'] });
});
it('toContain: array inclusion', () => {
expect(getUser().tags).toContain('admin');
expect('hello world').toContain('world');
});
it('toThrow: function throws', () => {
expect(() => divide(10, 0)).toThrow('Division by zero');
});
});
jest.fn(): mock functions
#Create a `UserService` class with a `notify(user)` method. In the test, mock the `notify` method with `jest.fn()`. Call `notify` twice and assert: `toHaveBeenCalled`, `toHaveBeenCalledTimes(2)`, `toHaveBeenCalledWith` on specific args.
class UserService {
notify(user) {
// sends an email (side-effect we want to mock)
console.log(`Notifying ${user.name}`);
}
register(user) {
this.notify(user);
return { ...user, registered: true };
}
}
describe('UserService', () => {
it('calls notify when registering a user', () => {
const service = new UserService();
service.notify = jest.fn();
service.register({ name: 'Alice' });
service.register({ name: 'Bob' });
// assert: notify was called
// assert: notify was called twice
// assert: first call had { name: 'Alice' }
});
});
Solution
class UserService {
notify(user) { console.log(`Notifying ${user.name}`); }
register(user) {
this.notify(user);
return { ...user, registered: true };
}
}
describe('UserService', () => {
it('calls notify when registering a user', () => {
const service = new UserService();
service.notify = jest.fn();
service.register({ name: 'Alice' });
service.register({ name: 'Bob' });
expect(service.notify).toHaveBeenCalled();
expect(service.notify).toHaveBeenCalledTimes(2);
expect(service.notify).toHaveBeenCalledWith({ name: 'Alice' });
expect(service.notify).toHaveBeenNthCalledWith(2, { name: 'Bob' });
});
});
Mock return values: mockReturnValue and mockResolvedValue
#Write a `fetchPrice(productId)` async function that calls an `api.getProduct` method. In the test, replace `api.getProduct` with a `jest.fn()` that returns a resolved promise. Assert the function returns the correct price from the mocked response.
// price.js
async function fetchPrice(api, productId) {
const product = await api.getProduct(productId);
return product.price;
}
// price.test.js
describe('fetchPrice', () => {
it('returns the price from the API response', async () => {
const mockApi = {
getProduct: jest.fn().mockResolvedValue({ id: 1, price: 9.99 }),
};
const price = await fetchPrice(mockApi, 1);
// assert price
// assert getProduct was called with 1
});
});
Solution
async function fetchPrice(api, productId) {
const product = await api.getProduct(productId);
return product.price;
}
describe('fetchPrice', () => {
it('returns the price from the API response', async () => {
const mockApi = {
getProduct: jest.fn().mockResolvedValue({ id: 1, price: 9.99 }),
};
const price = await fetchPrice(mockApi, 1);
expect(price).toBe(9.99);
expect(mockApi.getProduct).toHaveBeenCalledWith(1);
});
});
Async tests: testing async functions
#Write an async function `loadUser(id)` that fetches from a mock API. Write an async test (using `async/await`) that mocks the fetch, calls `loadUser`, and asserts the result. Also write a test that asserts it throws on a bad ID.
async function loadUser(id, fetch) {
const res = await fetch(`/users/${id}`);
if (!res.ok) throw new Error('User not found');
return res.json();
}
describe('loadUser', () => {
it('returns user data on success', async () => {
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: 'Alice' }),
});
// call loadUser, assert result
});
it('throws when response is not ok', async () => {
const mockFetch = jest.fn().mockResolvedValue({ ok: false });
// assert it rejects/throws
});
});
Solution
async function loadUser(id, fetch) {
const res = await fetch(`/users/${id}`);
if (!res.ok) throw new Error('User not found');
return res.json();
}
describe('loadUser', () => {
it('returns user data on success', async () => {
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: 'Alice' }),
});
const user = await loadUser(1, mockFetch);
expect(user).toEqual({ id: 1, name: 'Alice' });
});
it('throws when response is not ok', async () => {
const mockFetch = jest.fn().mockResolvedValue({ ok: false });
await expect(loadUser(999, mockFetch)).rejects.toThrow('User not found');
});
});
Fake timers: test setTimeout/setInterval
#Write a `delay(ms)` function that returns a Promise resolving after `ms` milliseconds. Write a test using `jest.useFakeTimers()` and `jest.runAllTimers()` that resolves the delay without actually waiting.
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
describe('delay', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it('resolves after the given delay', async () => {
let resolved = false;
delay(1000).then(() => { resolved = true; });
expect(resolved).toBe(false);
// advance timers
// now resolved should be true
});
});
Solution
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
describe('delay', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it('resolves after the given delay', async () => {
let resolved = false;
const p = delay(1000).then(() => { resolved = true; });
expect(resolved).toBe(false);
jest.runAllTimers(); // fast-forward all pending timers
await p; // wait for microtasks to flush
expect(resolved).toBe(true);
});
});
beforeEach / afterEach: test setup and teardown
#Write a `Counter` class with `increment()`, `decrement()`, and `value` getter. Use `beforeEach` to create a fresh counter before each test. Write three tests: increment, decrement, and reset. Show that tests are isolated — each starts from zero.
class Counter {
constructor() { this._value = 0; }
increment() { this._value++; }
decrement() { this._value--; }
reset() { this._value = 0; }
get value() { return this._value; }
}
describe('Counter', () => {
let counter;
beforeEach(() => {
// create fresh counter
});
it('increments', () => { /* */ });
it('decrements', () => { /* */ });
it('resets', () => { /* */ });
});
Solution
class Counter {
constructor() { this._value = 0; }
increment() { this._value++; }
decrement() { this._value--; }
reset() { this._value = 0; }
get value() { return this._value; }
}
describe('Counter', () => {
let counter;
beforeEach(() => {
counter = new Counter();
});
it('increments', () => {
counter.increment();
counter.increment();
expect(counter.value).toBe(2);
});
it('decrements', () => {
counter.increment();
counter.decrement();
expect(counter.value).toBe(0);
});
it('resets to zero', () => {
counter.increment();
counter.increment();
counter.reset();
expect(counter.value).toBe(0);
});
});
jest.spyOn: spy without replacing
#Write a `Logger` class with a `log(msg)` method that also calls `console.log`. Use `jest.spyOn(console, 'log')` to spy on `console.log` without replacing it. Assert it was called with the right message. Restore the original in `afterEach`.
class Logger {
log(msg) {
console.log(`[LOG] ${msg}`);
}
}
describe('Logger', () => {
let spy;
beforeEach(() => {
spy = jest.spyOn(console, 'log');
});
afterEach(() => {
spy.mockRestore();
});
it('logs with the [LOG] prefix', () => {
const logger = new Logger();
logger.log('hello');
// assert console.log was called with '[LOG] hello'
});
});
Solution
class Logger {
log(msg) { console.log(`[LOG] ${msg}`); }
}
describe('Logger', () => {
let spy;
beforeEach(() => {
spy = jest.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
spy.mockRestore();
});
it('logs with the [LOG] prefix', () => {
const logger = new Logger();
logger.log('hello');
expect(spy).toHaveBeenCalledWith('[LOG] hello');
});
});
test.each: parametrised tests
#Write a `isPalindrome(str)` function. Use `test.each` to run the same assertion for multiple inputs: `'racecar'`, `'hello'`, `'level'`, `'world'`, `'noon'`. The test table should include the input string and expected boolean result.
function isPalindrome(str) {
const clean = str.toLowerCase().replace(/[^a-z0-9]/g, '');
return clean === clean.split('').reverse().join('');
}
describe('isPalindrome', () => {
test.each([
// [input, expected]
])('%s => %s', (input, expected) => {
expect(isPalindrome(input)).toBe(expected);
});
});
Solution
function isPalindrome(str) {
const clean = str.toLowerCase().replace(/[^a-z0-9]/g, '');
return clean === clean.split('').reverse().join('');
}
describe('isPalindrome', () => {
test.each([
['racecar', true],
['hello', false],
['level', true],
['world', false],
['noon', true],
])('%s => %s', (input, expected) => {
expect(isPalindrome(input)).toBe(expected);
});
});
Dependency injection: testable code architecture
#Refactor a `sendEmail(to, subject)` function to be testable WITHOUT mocking globals. Instead of calling `nodemailer.sendMail` directly, accept a `mailer` dependency. Write a test that passes a mock mailer and asserts it was called correctly. This is the key pattern that makes code testable.
// hard to test — calls global nodemailer directly:
// async function sendEmail(to, subject) {
// await nodemailer.sendMail({ to, subject });
// }
// testable version: accept mailer as dependency
async function sendEmail(mailer, to, subject) {
await mailer.sendMail({ to, subject });
}
describe('sendEmail', () => {
it('calls sendMail with correct params', async () => {
const mockMailer = { sendMail: jest.fn().mockResolvedValue(undefined) };
await sendEmail(mockMailer, '[email protected]', 'Hello');
// assert mockMailer.sendMail was called with the right object
});
});
Solution
async function sendEmail(mailer, to, subject) {
await mailer.sendMail({ to, subject });
}
describe('sendEmail', () => {
it('calls sendMail with correct params', async () => {
const mockMailer = { sendMail: jest.fn().mockResolvedValue(undefined) };
await sendEmail(mockMailer, '[email protected]', 'Hello');
expect(mockMailer.sendMail).toHaveBeenCalledWith({
to: '[email protected]',
subject: 'Hello',
});
});
});