Arrange-Act-Assert (AAA) Pattern
The Arrange-Act-Assert (AAA) pattern is a structured approach to writing unit tests that enhances readability and maintainability. It divides each test into three distinct sections:
- Arrange: Set up the conditions and inputs required for the test.
- Act: Execute the code or function being tested.
- Assert: Verify that the outcome matches the expected result.
Why It’s a Best Practice
- Clarity: Clearly separating setup, execution, and verification makes tests easier to read and understand.
- Consistency: A uniform structure across tests simplifies writing and maintaining them.
- Maintainability: Well-organized tests are easier to update as the codebase evolves.
- Debugging Efficiency: Identifying where a test fails becomes straightforward.
- Single Responsibility: Encourages testing one functionality at a time, leading to more precise tests.
Examples in Practice
Example 1: Testing a Simple Function
Consider a function that calculates the factorial of a number:
function factorial(n) {
if (n < 0) throw new Error('Negative input not allowed');
return n === 0 ? 1 : n * factorial(n - 1);
}
Test Using AAA Pattern:
test('calculates factorial of a positive integer', () => {
// Arrange
const input = 5;
const expectedOutput = 120;
// Act
const result = factorial(input);
// Assert
expect(result).toBe(expectedOutput);
});
Explanation:
- Arrange: Set up the input value and expected output.
- Act: Call the
factorial
function with the input. - Assert: Check that the result matches the expected output.
Example 2: Testing Error Handling
Testing how a function handles invalid input:
function divide(a, b) {
if (b === 0) throw new Error('Cannot divide by zero');
return a / b;
}
Test Using AAA Pattern:
test('throws an error when dividing by zero', () => {
// Arrange
const numerator = 10;
const denominator = 0;
// Act and Assert
expect(() => {
divide(numerator, denominator);
}).toThrow('Cannot divide by zero');
});
Explanation:
- Arrange: Initialize the numerator and set the denominator to zero.
- Act and Assert: Execute the function inside an assertion to check for the expected error.
Example 3: Testing Asynchronous Code
Suppose there’s an asynchronous function that fetches data from an API:
async function fetchData(url) {
const response = await fetch(url);
return response.json();
}
Test Using AAA Pattern:
test('fetches data successfully from an API', async () => {
// Arrange
const url = 'https://api.example.com/data';
const mockData = { id: 1, name: 'Test Data' };
global.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve(mockData),
}),
);
// Act
const data = await fetchData(url);
// Assert
expect(data).toEqual(mockData);
expect(global.fetch).toHaveBeenCalledWith(url);
// Cleanup
global.fetch.mockClear();
delete global.fetch;
});
Explanation:
- Arrange: Mock the
fetch
function to return predefined data. - Act: Call the
fetchData
function. - Assert: Verify that the returned data matches the mock data and that
fetch
was called with the correct URL.
Example 4: Testing a Class Method
Consider a simple Calculator
class:
class Calculator {
add(a, b) {
return a + b;
}
}
Test Using AAA Pattern:
test('adds two numbers correctly using Calculator class', () => {
// Arrange
const calculator = new Calculator();
const num1 = 7;
const num2 = 3;
const expected = 10;
// Act
const result = calculator.add(num1, num2);
// Assert
expect(result).toBe(expected);
});
Explanation:
- Arrange: Create an instance of
Calculator
and set up the numbers. - Act: Invoke the
add
method with the numbers. - Assert: Check that the result equals the expected sum.
Example 5: Testing a Function with Side Effects
Suppose a function logs a message to the console:
function logMessage(message) {
console.log(message);
}
Test Using AAA Pattern:
test('logs the correct message to the console', () => {
// Arrange
const consoleSpy = vi.spyOn(console, 'log');
const message = 'Hello, World!';
// Act
logMessage(message);
// Assert
expect(consoleSpy).toHaveBeenCalledWith(message);
// Cleanup
consoleSpy.mockRestore();
});
Explanation:
- Arrange: Spy on the
console.log
method. - Act: Call
logMessage
with a test message. - Assert: Verify that
console.log
was called with the correct message. - Cleanup: Restore the original
console.log
method.
Example 6: Testing Edge Cases
Testing a function that processes an array:
function getFirstElement(array) {
if (!Array.isArray(array)) throw new Error('Input must be an array');
return array[0];
}
Test Using AAA Pattern:
test('returns undefined for an empty array', () => {
// Arrange
const inputArray = [];
const expectedOutput = undefined;
// Act
const result = getFirstElement(inputArray);
// Assert
expect(result).toBe(expectedOutput);
});
Explanation:
- Arrange: Prepare an empty array.
- Act: Call
getFirstElement
with the empty array. - Assert: Verify that the result is
undefined
.
Example 7: Testing with Mock Service Worker (MSW)
Testing a function that makes an API call, using MSW to mock the network request:
// Function to fetch user data
async function getUser(id) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
Test Using AAA Pattern with MSW:
import { rest } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
rest.get('/api/users/:id', (req, res, ctx) => {
const { id } = req.params;
return res(ctx.json({ id, name: 'John Doe' }));
}),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('fetches user data successfully', async () => {
// Arrange
const userId = '123';
// Act
const user = await getUser(userId);
// Assert
expect(user).toEqual({ id: '123', name: 'John Doe' });
});
Explanation:
- Arrange: Set up MSW to intercept network requests and return mock data.
- Act: Call the
getUser
function with a test user ID. - Assert: Verify that the returned user data matches the mock data.
Key Takeaways
With all of that said, here’s the gist:
- Separation of Concerns: The AAA pattern enforces a clear separation between setup, execution, and verification.
- Readability: Tests become self-explanatory, serving as documentation for the code’s expected behavior.
- Consistency: Following a standard pattern reduces cognitive load when switching between different tests or projects.
- Maintainability: Easier to update tests when changes occur in the codebase.
- Debugging Efficiency: Simplifies identifying where a test might be failing.