Why Testing Matters
Software testing is like having a safety net when walking a tightrope. Without it, you're one misstep away from disaster. Here's why testing is crucial:
- Quality Assurance: Tests help ensure your code works as expected
- Regression Prevention: Prevent new changes from breaking existing functionality
- Documentation: Tests serve as living documentation of how your code should behave
- Design Feedback: Difficult-to-test code often indicates design problems
- Confidence: Tests give you confidence to refactor and add new features
Real-World Impact
In 2015, a single untested line of code at Knight Capital caused a loss of $440 million in just 45 minutes. The trading system deployed code that mistakenly bought high and sold low at high frequency - the reverse of what was intended.
Testing Pyramid
Think of your testing strategy as a pyramid, with different types of tests serving different purposes:
- Unit Tests: Test individual functions or components in isolation (fast, many)
- Integration Tests: Test how components work together (medium speed, medium quantity)
- End-to-End Tests: Test entire application flows (slow, fewer)
Today we focus on the foundation: unit testing.
Unit Testing Principles
What Makes a Good Unit Test?
- Fast: Unit tests should execute quickly
- Isolated: Tests shouldn't depend on external systems or other tests
- Repeatable: Same inputs should produce same results every time
- Self-checking: Tests should automatically determine pass/fail
- Timely: Ideally written before or alongside code (TDD)
FIRST Principles
Fast: Tests should run quickly
Isolated: Tests should be independent
Repeatable: Tests should yield same results each run
Self-validating: Tests should have boolean output
Timely: Tests should be written at appropriate time
Introduction to Jest
Jest is a delightful JavaScript testing framework developed by Facebook. It's designed to ensure correctness of JavaScript code with an emphasis on simplicity.
Why Jest?
- Zero configuration for most JavaScript projects
- Built-in test runner, assertion library, and mocking capabilities
- Snapshot testing for UI components
- Parallel test execution for speed
- Great documentation and community support
Installing Jest
npm install --save-dev jest
Configuring package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch"
}
}
Writing Your First Jest Test
Let's say we have a simple utility function to add numbers:
// math.js
function sum(a, b) {
return a + b;
}
module.exports = { sum };
Our test file would be:
// math.test.js
const { sum } = require('./math');
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3);
});
Run with npm test and you should see:
PASS ./math.test.js
✓ adds 1 + 2 to equal 3 (5ms)
File Naming Conventions
Jest looks for test files with these patterns:
- Files with
.test.jssuffix - Files with
.spec.jssuffix - Files inside a
__tests__directory
Jest Matchers
Matchers are methods that let you test values in different ways. Here are some common ones:
Equality
expect(value).toBe(expectedValue); // Exact equality (===)
expect(value).toEqual(expectedObject); // Deep equality for objects
expect(value).not.toBe(differentValue); // Negation
Truthiness
expect(value).toBeTruthy(); // Checks if value is truthy
expect(value).toBeFalsy(); // Checks if value is falsy
expect(value).toBeNull(); // Checks if value is null
expect(value).toBeUndefined(); // Checks if value is undefined
expect(value).toBeDefined(); // Opposite of toBeUndefined
Numbers
expect(value).toBeGreaterThan(3);
expect(value).toBeGreaterThanOrEqual(3.5);
expect(value).toBeLessThan(5);
expect(value).toBeLessThanOrEqual(4.5);
expect(value).toBeCloseTo(0.3); // For floating point equality
Strings
expect('team').toMatch(/tea/); // Regex matching
Arrays and Iterables
expect(shoppingList).toContain('milk');
expect(new Set(shoppingList)).toContain('milk');
Exceptions
expect(() => {
functionThatThrows();
}).toThrow();
expect(() => {
functionThatThrows();
}).toThrow(Error);
expect(() => {
functionThatThrows();
}).toThrow('specific error message');
A Real-World Example: Testing a User Service
Let's create and test a simple user validation service:
The Code to Test:
// userService.js
class UserService {
validateUsername(username) {
// Username must be between 4-20 characters
if (!username || username.length < 4 || username.length > 20) {
return false;
}
// Only allow letters, numbers, and underscores
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
return false;
}
return true;
}
validateEmail(email) {
// Simple email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
validatePassword(password) {
// Password must be at least 8 characters
if (!password || password.length < 8) {
return false;
}
// Password must contain at least one uppercase letter, one lowercase letter, and one number
if (!/[A-Z]/.test(password) || !/[a-z]/.test(password) || !/[0-9]/.test(password)) {
return false;
}
return true;
}
}
module.exports = UserService;
The Test File:
// userService.test.js
const UserService = require('./userService');
describe('UserService', () => {
let userService;
beforeEach(() => {
userService = new UserService();
});
describe('validateUsername', () => {
test('should return true for valid usernames', () => {
expect(userService.validateUsername('john_doe')).toBe(true);
expect(userService.validateUsername('dev123')).toBe(true);
expect(userService.validateUsername('user_name_123')).toBe(true);
});
test('should return false for usernames shorter than 4 characters', () => {
expect(userService.validateUsername('abc')).toBe(false);
expect(userService.validateUsername('a')).toBe(false);
});
test('should return false for usernames longer than 20 characters', () => {
expect(userService.validateUsername('abcdefghijklmnopqrstuvwxyz')).toBe(false);
});
test('should return false for usernames with special characters', () => {
expect(userService.validateUsername('user@name')).toBe(false);
expect(userService.validateUsername('user-name')).toBe(false);
expect(userService.validateUsername('user.name')).toBe(false);
});
test('should return false for empty or null username', () => {
expect(userService.validateUsername('')).toBe(false);
expect(userService.validateUsername(null)).toBe(false);
expect(userService.validateUsername(undefined)).toBe(false);
});
});
describe('validateEmail', () => {
test('should return true for valid emails', () => {
expect(userService.validateEmail('user@example.com')).toBe(true);
expect(userService.validateEmail('name.surname@domain.co.uk')).toBe(true);
});
test('should return false for invalid emails', () => {
expect(userService.validateEmail('user@')).toBe(false);
expect(userService.validateEmail('user@domain')).toBe(false);
expect(userService.validateEmail('@domain.com')).toBe(false);
expect(userService.validateEmail('user domain.com')).toBe(false);
});
});
describe('validatePassword', () => {
test('should return true for valid passwords', () => {
expect(userService.validatePassword('Password123')).toBe(true);
expect(userService.validatePassword('Secure789Password')).toBe(true);
});
test('should return false for passwords shorter than 8 characters', () => {
expect(userService.validatePassword('Pass1')).toBe(false);
});
test('should return false for passwords without uppercase letters', () => {
expect(userService.validatePassword('password123')).toBe(false);
});
test('should return false for passwords without lowercase letters', () => {
expect(userService.validatePassword('PASSWORD123')).toBe(false);
});
test('should return false for passwords without numbers', () => {
expect(userService.validatePassword('PasswordOnly')).toBe(false);
});
});
});
Test Organization with describe() and beforeEach()
In the example above, we used some important Jest functions for organizing tests:
describe(): Groups related tests togetherbeforeEach(): Runs before each test in the describe blockafterEach(): Runs after each test (for cleanup)beforeAll(): Runs once before all testsafterAll(): Runs once after all tests
These help with test organization and setup/teardown of test environments.
Test-Driven Development (TDD)
TDD is a development process where you write tests before writing the actual code. The cycle is:
- Red: Write a failing test
- Green: Write the minimum amount of code to pass the test
- Refactor: Clean up the code while keeping tests passing
TDD in Practice
Let's say we need to implement a function to validate a credit card number using the Luhn algorithm.
- First, we write tests for various valid and invalid card numbers
- Then, we implement the validation function to make the tests pass
- Finally, we refactor our implementation for better performance or readability
TDD is like having a GPS for your development journey – it tells you when you've reached your destination.
Testing Edge Cases
Edge cases are scenarios that occur at the extremes of what your code is designed to handle. Testing these is crucial for robust code.
Common Edge Cases to Test:
- Empty inputs (strings, arrays, objects)
- Null or undefined values
- Boundary values (min/max allowed values)
- Extremely large values
- Invalid formats
- Unexpected types (passing a string where a number is expected)
Example: Testing a Number Formatter Function
If you have a function to format currency, edge cases might include:
- Negative numbers
- Zero
- Very large numbers
- Numbers with many decimal places
- Non-number inputs
Mocking with Jest
Mocking is replacing real implementations with controlled substitutes for testing purposes. It's like using a stunt double for dangerous movie scenes.
When to Use Mocks:
- When testing code that calls external services (APIs, databases)
- For testing error scenarios that are hard to produce naturally
- When you want to verify specific functions were called with certain arguments
- To simplify testing of complex dependencies
Jest Mocking Features:
jest.fn()- Mock functionsjest.mock()- Mock modulesjest.spyOn()- Spy on function calls- Mock timers for testing setTimeout/setInterval
Example: Mocking an API Call
// userApi.js
const axios = require('axios');
class UserApi {
async fetchUser(id) {
const response = await axios.get(`https://api.example.com/users/${id}`);
return response.data;
}
}
module.exports = UserApi;
// userApi.test.js
const axios = require('axios');
const UserApi = require('./userApi');
// Mock the axios module
jest.mock('axios');
describe('UserApi', () => {
let userApi;
beforeEach(() => {
userApi = new UserApi();
});
test('fetchUser returns user data', async () => {
// Set up the mock response
const mockUser = { id: 1, name: 'John Doe' };
axios.get.mockResolvedValue({ data: mockUser });
// Call the method
const user = await userApi.fetchUser(1);
// Assert the results
expect(axios.get).toHaveBeenCalledWith('https://api.example.com/users/1');
expect(user).toEqual(mockUser);
});
test('fetchUser handles errors', async () => {
// Set up the mock to throw an error
const errorMessage = 'Network Error';
axios.get.mockRejectedValue(new Error(errorMessage));
// Call the method and expect it to throw
await expect(userApi.fetchUser(1)).rejects.toThrow(errorMessage);
});
});
Practice Activities
Activity 1: Write Tests for a String Utility Library
Create a utility library with functions for common string operations:
- Capitalize the first letter of a string
- Truncate a string to a specified length with an ellipsis
- Count the occurrences of a substring within a string
- Convert a string to camelCase
Write comprehensive tests for these functions, including edge cases.
Activity 2: TDD Exercise - Shopping Cart
Using the TDD approach, implement a shopping cart class with the following features:
- Add items to the cart
- Remove items from the cart
- Update item quantities
- Calculate the total price
- Apply a discount code
Write the tests first, then implement the functionality to make the tests pass.
Activity 3: Mock an External Service
Create a weather service class that fetches weather data from an external API. Then write tests for this service by mocking the API responses.
- Write tests for successful API responses
- Write tests for API error scenarios
- Test edge cases like timeout handling