Unit Testing Fundamentals
Unit testing is the practice of testing the smallest testable parts of an application, called units, in isolation from the rest of the system. A unit is typically a single function, method, or class. Unit tests form the foundation of your test suite and are the most numerous tests you'll write.
What is a Unit?
A "unit" is the smallest piece of code that can be logically isolated and tested independently. The definition can vary:
- Function/Method Level: Testing individual functions or methods
- Class Level: Testing a class with all its methods
- Module Level: Testing a small module or component
Key Principle: A unit test should test one unit of functionality in isolation. If your test requires a database, API calls, or file system operations, it's likely an integration test, not a unit test.
Characteristics of Good Unit Tests
Effective unit tests share these characteristics:
- Fast: Execute in milliseconds, allowing frequent execution
- Isolated: Test one thing at a time without external dependencies
- Repeatable: Same input always produces same output
- Self-Validating: Pass or fail automatically, no manual checking
- Thorough: Cover normal cases, edge cases, and error conditions
- Readable: Clear test names and structure
The AAA Pattern
The AAA (Arrange-Act-Assert) pattern is the most popular structure for writing unit tests. It divides tests into three clear sections:
1. Arrange (Setup)
Set up the test conditions and initialize objects needed for the test.
2. Act (Execute)
Execute the code being tested—call the function or method.
3. Assert (Verify)
Verify that the code behaved as expected.
<script>
// JavaScript example using AAA pattern
test('calculateTotal should sum prices correctly', () => {
// Arrange: Setup test data
const items = [
{ price: 10.00, quantity: 2 },
{ price: 5.50, quantity: 3 }
];
// Act: Execute the function
const total = calculateTotal(items);
// Assert: Verify the result
expect(total).toBe(36.50);
});
</script>
<?php
// PHP example using AAA pattern
public function test_user_full_name_concatenation()
{
// Arrange
$user = new User();
$user->first_name = 'John';
$user->last_name = 'Doe';
// Act
$fullName = $user->getFullName();
// Assert
$this->assertEquals('John Doe', $fullName);
}
?>
Pro Tip: Adding blank lines between the three sections makes tests more readable and easier to understand at a glance.
Anatomy of a Unit Test
Let's break down the structure of a well-written unit test:
Test Name
The test name should clearly describe what is being tested and the expected outcome.
<script>
// Good: Descriptive and clear
test('login returns token when credentials are valid', () => { });
// Bad: Vague and unclear
test('test1', () => { });
test('it works', () => { });
</script>
Test Body
Follow the AAA pattern to structure the test logic clearly.
Assertions
Use appropriate assertions that make test failures easy to understand.
Writing Your First Unit Test
Let's write a complete unit test for a simple calculator function:
<script>
// calculator.js - The code we want to test
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
function multiply(a, b) {
return a * b;
}
function divide(a, b) {
if (b === 0) {
throw new Error('Division by zero');
}
return a / b;
}
module.exports = { add, subtract, multiply, divide };
</script>
<script>
// calculator.test.js - Our unit tests
const { add, subtract, multiply, divide } = require('./calculator');
describe('Calculator', () => {
describe('add', () => {
test('should add two positive numbers', () => {
// Arrange
const a = 5;
const b = 3;
// Act
const result = add(a, b);
// Assert
expect(result).toBe(8);
});
test('should add negative numbers', () => {
expect(add(-5, -3)).toBe(-8);
});
test('should add zero', () => {
expect(add(5, 0)).toBe(5);
});
});
describe('divide', () => {
test('should divide numbers correctly', () => {
expect(divide(10, 2)).toBe(5);
});
test('should throw error when dividing by zero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero');
});
});
});
</script>
Test Organization
Organize tests logically to make them easy to navigate and understand:
describe Blocks
Group related tests together using describe blocks.
<script>
describe('User', () => {
describe('authentication', () => {
test('should login with valid credentials', () => { });
test('should reject invalid password', () => { });
});
describe('profile', () => {
test('should update profile information', () => { });
test('should validate email format', () => { });
});
});
</script>
Testing Different Scenarios
Comprehensive unit tests cover multiple scenarios:
Happy Path (Normal Case)
Test the expected behavior with valid inputs.
<script>
test('should create user with valid data', () => {
const user = createUser('John', 'john@example.com');
expect(user.name).toBe('John');
expect(user.email).toBe('john@example.com');
});
</script>
Edge Cases
Test boundary conditions and unusual but valid inputs.
<script>
test('should handle empty string', () => {
expect(trimString('')).toBe('');
});
test('should handle very long strings', () => {
const longString = 'a'.repeat(10000);
expect(processString(longString).length).toBe(10000);
});
</script>
Error Cases
Test how your code handles invalid inputs and error conditions.
<script>
test('should throw error for null input', () => {
expect(() => processUser(null)).toThrow();
});
test('should return error message for invalid email', () => {
const result = validateEmail('invalid-email');
expect(result.isValid).toBe(false);
expect(result.error).toBe('Invalid email format');
});
</script>
Common Mistake: Testing only the happy path. Always test edge cases and error conditions—that's where bugs often hide!
Test Isolation
Unit tests must be isolated from external dependencies and from each other:
No External Dependencies
Don't rely on databases, APIs, file systems, or network calls.
<script>
// BAD: This is not a unit test—it depends on a database
test('should save user to database', async () => {
await database.connect();
const user = await User.create({ name: 'John' });
expect(user.id).toBeDefined();
});
// GOOD: This is a unit test—it tests logic in isolation
test('should validate user data before saving', () => {
const user = new User({ name: 'John' });
expect(user.isValid()).toBe(true);
});
</script>
Independent Tests
Each test should run independently—they shouldn't affect each other.
<script>
// BAD: Tests depend on shared state
let counter = 0;
test('first test', () => {
counter++;
expect(counter).toBe(1);
});
test('second test', () => {
counter++; // Depends on first test!
expect(counter).toBe(2);
});
// GOOD: Each test is independent
test('first test', () => {
let counter = 0;
counter++;
expect(counter).toBe(1);
});
test('second test', () => {
let counter = 0;
counter++;
expect(counter).toBe(1);
});
</script>
Setup and Teardown
Use setup and teardown hooks to prepare and clean up test environments:
<script>
describe('ShoppingCart', () => {
let cart;
// Runs before each test
beforeEach(() => {
cart = new ShoppingCart();
});
// Runs after each test
afterEach(() => {
cart = null;
});
test('should start empty', () => {
expect(cart.items).toHaveLength(0);
});
test('should add item', () => {
cart.addItem({ id: 1, name: 'Product' });
expect(cart.items).toHaveLength(1);
});
});
</script>
Available Hooks:
beforeAll() - Runs once before all tests in the describe block
beforeEach() - Runs before each test
afterEach() - Runs after each test
afterAll() - Runs once after all tests in the describe block
Testing Pure vs Impure Functions
Pure Functions (Easy to Test)
Pure functions always return the same output for the same input and have no side effects.
<script>
// Pure function - easy to test
function calculateDiscount(price, percentage) {
return price * (percentage / 100);
}
test('should calculate discount correctly', () => {
expect(calculateDiscount(100, 10)).toBe(10);
expect(calculateDiscount(100, 10)).toBe(10); // Always same result
});
</script>
Impure Functions (Require Mocking)
Impure functions have side effects or depend on external state—they need special handling.
<script>
// Impure function - harder to test
function saveUser(user) {
const timestamp = new Date(); // External dependency
user.createdAt = timestamp;
database.save(user); // Side effect
return user;
}
// Test requires mocking
test('should set createdAt timestamp', () => {
const mockDate = new Date('2024-01-01');
jest.spyOn(global, 'Date').mockImplementation(() => mockDate);
const user = saveUser({ name: 'John' });
expect(user.createdAt).toEqual(mockDate);
});
</script>
What NOT to Test
Not everything needs a unit test. Avoid testing:
- Third-Party Code: Don't test libraries or frameworks—trust they're already tested
- Trivial Code: Simple getters/setters don't need tests
- Private Methods: Test public interfaces; private methods are implementation details
- Framework Magic: Don't test framework features like routing or ORM behavior
Rule of Thumb: If your test is just repeating the implementation code, it's probably not adding value. Test behavior, not implementation.
Common Unit Testing Patterns
Testing Return Values
<script>
test('should return uppercase string', () => {
expect(toUpperCase('hello')).toBe('HELLO');
});
</script>
Testing Exceptions
<script>
test('should throw error for invalid input', () => {
expect(() => parseJSON('invalid')).toThrow(SyntaxError);
});
</script>
Testing State Changes
<script>
test('should update counter state', () => {
const counter = new Counter();
expect(counter.value).toBe(0);
counter.increment();
expect(counter.value).toBe(1);
});
</script>
Testing Array/Object Properties
<script>
test('should return user with correct properties', () => {
const user = createUser('John', 'john@example.com');
expect(user).toHaveProperty('name', 'John');
expect(user).toHaveProperty('email', 'john@example.com');
expect(user.roles).toContain('user');
});
</script>
Practice Exercise
Write unit tests for this validation function:
<script>
function validatePassword(password) {
if (!password) {
return { valid: false, error: 'Password is required' };
}
if (password.length < 8) {
return { valid: false, error: 'Password must be at least 8 characters' };
}
if (!/[A-Z]/.test(password)) {
return { valid: false, error: 'Password must contain uppercase letter' };
}
if (!/[0-9]/.test(password)) {
return { valid: false, error: 'Password must contain a number' };
}
return { valid: true };
}
</script>
Write tests for:
- Empty password
- Password too short
- Password without uppercase letter
- Password without number
- Valid password
Summary
Unit testing is the foundation of a solid test suite. By testing small units of code in isolation using the AAA pattern, you can catch bugs early and build confidence in your code. Remember: good unit tests are fast, isolated, repeatable, and readable. In the next lesson, we'll explore Test-Driven Development (TDD), a methodology that makes unit testing even more powerful.
Key Takeaways:
- Unit tests verify small, isolated pieces of code
- Use the AAA pattern: Arrange, Act, Assert
- Test happy paths, edge cases, and error conditions
- Keep tests isolated from external dependencies
- Each test should be independent and repeatable
- Write descriptive test names that explain what is being tested