Summary
Unit testing validates individual components in isolation, forming the foundation of reliable software. Master test structure (AAA pattern), FIRST principles, mocking strategies, TDD practices, and framework-specific features to write fast, maintainable tests that catch bugs early and enable confident refactoring.
Fundamentals
What is Unit Testing?
- Definition: Testing individual units/components in isolation
- Unit: Smallest testable part (function, method, class)
- Goal: Verify each unit works correctly independently
Why Unit Testing?
- Early Bug Detection: Catch issues during development
- Documentation: Tests serve as living documentation
- Refactoring Safety: Confidence when changing code
- Design Improvement: Forces modular, testable code
- Regression Prevention: Ensure new changes don't break existing functionality
Key Concepts
Test Structure (AAA Pattern)
test('should calculate total price', () => {
// Arrange
const items = [{ price: 10 }, { price: 20 }];
// Act
const total = calculateTotal(items);
// Assert
expect(total).toBe(30);
});
F.I.R.S.T Principles
- Fast: Tests run quickly
- Independent: No dependencies between tests
- Repeatable: Same results every time
- Self-validating: Pass/fail without manual check
- Timely: Written just before production code
Test Coverage
- Line Coverage: % of code lines executed
- Branch Coverage: % of decision branches tested
- Function Coverage: % of functions called
- Statement Coverage: % of statements executed
Note: 100% coverage ≠ bug-free code
Types of Assertions
// Equality
expect(result).toBe(5); // Strict equality
expect(result).toEqual({a: 1}); // Deep equality
// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
// Numbers
expect(value).toBeGreaterThan(3);
expect(value).toBeCloseTo(0.3);
// Strings
expect(text).toMatch(/pattern/);
// Arrays/Iterables
expect(array).toContain(item);
expect(array).toHaveLength(3);
// Exceptions
expect(() => throwError()).toThrow();
Testing Patterns
Test Doubles
- Dummy: Passed but never used
- Stub: Provides preset answers
- Spy: Records information about calls
- Mock: Pre-programmed with expectations
- Fake: Working implementation, simplified
Example: Mock vs Stub
// Stub - returns canned response
const stub = jest.fn().mockReturnValue(42);
// Mock - verifies behavior
const mock = jest.fn();
callFunction(mock);
expect(mock).toHaveBeenCalledWith('arg');
Mocking & Stubbing
Basic Mocking
// Mock a module
jest.mock('./api');
// Mock implementation
const mockFn = jest.fn();
mockFn.mockImplementation((x) => x * 2);
// Mock return values
mockFn.mockReturnValue(10);
mockFn.mockReturnValueOnce(5);
// Mock promises
mockFn.mockResolvedValue(data);
mockFn.mockRejectedValue(error);
Spying
// Spy on existing method
const spy = jest.spyOn(obj, 'method');
// Verify calls
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenCalledWith(arg1, arg2);
// Restore original
spy.mockRestore();
Test-Driven Development (TDD)
Red-Green-Refactor Cycle
- Red: Write failing test
- Green: Write minimal code to pass
- Refactor: Improve code, keep tests green
Example TDD Flow
// 1. Red - Write test first
test('should validate email', () => {
expect(isValidEmail('test@example.com')).toBe(true);
expect(isValidEmail('invalid')).toBe(false);
});
// 2. Green - Implement minimal solution
function isValidEmail(email) {
return email.includes('@');
}
// 3. Refactor - Improve implementation
function isValidEmail(email) {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
}
Best Practices
1. Write Descriptive Test Names
// ❌ Bad
test('test1', () => {});
// ✅ Good
test('should return user name when valid ID provided', () => {});
2. One Assertion Per Test (When Possible)
// ❌ Bad - Multiple unrelated assertions
test('user operations', () => {
expect(createUser(data)).toBeTruthy();
expect(deleteUser(id)).toBe(true);
expect(updateUser(id, data)).toEqual(data);
});
// ✅ Good - Focused tests
test('should create user successfully', () => {
expect(createUser(data)).toBeTruthy();
});
3. Use beforeEach/afterEach for Setup/Teardown
describe('UserService', () => {
let service;
beforeEach(() => {
service = new UserService();
});
afterEach(() => {
jest.clearAllMocks();
});
test('should fetch user', () => {
// Test implementation
});
});
4. Test Edge Cases
describe('divide function', () => {
test('should divide positive numbers', () => {
expect(divide(10, 2)).toBe(5);
});
test('should handle division by zero', () => {
expect(() => divide(10, 0)).toThrow();
});
test('should handle negative numbers', () => {
expect(divide(-10, 2)).toBe(-5);
});
});
5. Keep Tests DRY with Helper Functions
// Helper function
function createTestUser(overrides = {}) {
return {
id: 1,
name: 'Test User',
email: 'test@example.com',
...overrides
};
}
// Usage in tests
test('should update user email', () => {
const user = createTestUser({ email: 'new@example.com' });
// Test implementation
});
Common Anti-Patterns
1. Testing Implementation Details
// ❌ Bad - Tests internal implementation
test('should call internal method', () => {
const spy = jest.spyOn(obj, '_privateMethod');
obj.publicMethod();
expect(spy).toHaveBeenCalled();
});
// ✅ Good - Tests behavior/output
test('should return processed data', () => {
const result = obj.publicMethod();
expect(result).toEqual(expectedOutput);
});
2. Excessive Mocking
// ❌ Bad - Mocking everything
jest.mock('./userService');
jest.mock('./emailService');
jest.mock('./logger');
jest.mock('./validator');
// ✅ Good - Mock only external dependencies
jest.mock('./api'); // External API only
3. Non-Deterministic Tests
// ❌ Bad - Depends on current time
test('should create timestamp', () => {
const result = createTimestamp();
expect(result).toBe(Date.now()); // Flaky!
});
// ✅ Good - Control time
test('should create timestamp', () => {
const now = 1234567890;
jest.spyOn(Date, 'now').mockReturnValue(now);
expect(createTimestamp()).toBe(now);
});
Testing Frameworks
JavaScript/Node.js
- Jest: Most popular, zero config
- Mocha: Flexible, requires assertion library
- Vitest: Fast, Vite-native
- Jasmine: Behavior-driven development
Other Languages
- Java: JUnit, TestNG
- Python: pytest, unittest
- C#: NUnit, xUnit, MSTest
- Ruby: RSpec, Minitest
- Go: Built-in testing package
Advanced Topics
Parameterized Tests
// Jest
test.each([
[1, 1, 2],
[1, 2, 3],
[2, 2, 4],
])('add(%i, %i) returns %i', (a, b, expected) => {
expect(add(a, b)).toBe(expected);
});
Async Testing
// Promises
test('should fetch data', async () => {
const data = await fetchData();
expect(data).toEqual({ id: 1 });
});
// Callbacks
test('should call callback', (done) => {
fetchData((data) => {
expect(data).toEqual({ id: 1 });
done();
});
});
Snapshot Testing
test('should render correctly', () => {
const component = render(<Button />);
expect(component).toMatchSnapshot();
});
Code Coverage Configuration
// jest.config.js
{
"collectCoverage": true,
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
Critical Interview Topics
Test Level Distinctions
- Unit Tests: Test single functions/methods in isolation, mock dependencies, milliseconds execution
- Integration Tests: Test component interactions, use real dependencies where possible, seconds execution
- E2E Tests: Test complete user workflows, full stack involved, minutes execution
- Test Pyramid: 70% unit, 20% integration, 10% E2E for optimal balance
Mocking Strategy Decisions
When to Mock
- External Services: APIs, databases, file systems
- Slow Operations: Network calls, heavy computations
- Non-Deterministic: Random values, timestamps, external state
- Complex Setup: Multi-step initialization, expensive resources
When NOT to Mock
- Simple Value Objects: DTOs, models, POJOs
- Pure Functions: Mathematical operations, utilities
- Core Business Logic: The actual code under test
- Fast Operations: In-memory calculations, simple transformations
Testing Private Methods
- Best Practice: Test through public API that uses private methods
- Refactoring Signal: Complex private methods may need extraction
- Design Principle: If it's worth testing, it's worth making public
- Alternative: Move to separate testable utility class
Characteristics of Quality Tests
- Fast Execution: Milliseconds per test
- Isolated: No shared state, order independence
- Repeatable: Same result every run
- Self-Validating: Clear pass/fail, no manual verification
- Timely: Written with or before production code
- Single Concept: One logical assertion per test
- Clear Failure: Obvious what broke from test name/message
Common Testing Scenarios
- How would you test a function that makes API calls?
// Mock the API module
jest.mock('./api');
test('should fetch user data', async () => {
api.getUser.mockResolvedValue({ id: 1, name: 'John' });
const user = await fetchUserData(1);
expect(user.name).toBe('John');
});
- How do you test error handling?
test('should handle API errors', async () => {
api.getUser.mockRejectedValue(new Error('Network error'));
await expect(fetchUserData(1)).rejects.toThrow('Network error');
});
- How would you test time-dependent code?
beforeEach(() => {
jest.useFakeTimers();
});
test('should timeout after 5 seconds', () => {
const callback = jest.fn();
startTimer(callback, 5000);
jest.advanceTimersByTime(4999);
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(1);
expect(callback).toHaveBeenCalled();
});
Quick Reference
Essential Jest Matchers
.toBe() // Strict equality (===)
.toEqual() // Deep equality
.toBeNull() // Checks for null
.toBeUndefined() // Checks for undefined
.toBeDefined() // Opposite of undefined
.toBeTruthy() // Truthy values
.toBeFalsy() // Falsy values
.toContain() // Array/string contains
.toThrow() // Function throws error
.toHaveBeenCalled() // Mock function called
Test Organization
describe('Calculator', () => {
describe('add method', () => {
it('should add positive numbers', () => {});
it('should add negative numbers', () => {});
});
describe('subtract method', () => {
it('should subtract numbers', () => {});
});
});
Mock Utilities
jest.fn() // Create mock function
jest.mock() // Mock entire module
jest.spyOn() // Spy on method
.mockReturnValue() // Set return value
.mockImplementation() // Custom implementation
.mockResolvedValue() // Async success
.mockRejectedValue() // Async failure
Interview Success Strategies
Technical Demonstration
- Start Simple: Begin with happy path, then add edge cases
- Edge Case Coverage: Null, undefined, empty arrays, zero, negative numbers, boundaries
- Error Scenarios: Invalid inputs, exceptions, timeout handling
- Code Organization: Clear test structure, descriptive names, helper functions
Strategic Thinking
- Coverage Balance: Explain why 100% coverage isn't always optimal
- Maintenance Trade-offs: Simple tests vs DRY principle
- Test Speed: Fast feedback loop importance
- Documentation Value: Tests as living documentation
Best Practices to Showcase
- AAA Pattern: Clear Arrange-Act-Assert structure
- Descriptive Naming: Test names describe behavior, not implementation
- Single Responsibility: One concept per test
- Test Independence: No shared state or order dependencies
- Deterministic: No random values or external dependencies
Communication Points
- TDD Benefits: Better design, built-in regression suite, confidence in refactoring
- Mock vs Stub vs Spy: Understand distinctions and appropriate usage
- Testing Philosophy: Behavior over implementation
- Team Practices: Code review includes tests, continuous integration, pair programming
Red Flags to Avoid
- Testing implementation details instead of behavior
- Overly complex test setup
- Ignored or commented-out tests
- Tests that sometimes pass, sometimes fail
- No assertions in test methods
Found this useful? Pass it on.