Skip to content

Unit Testing

Alex Stojcic edited this page Apr 7, 2025 · 1 revision

Unit Testing

Unit testing is the practice of testing individual units or components of code in isolation from the rest of the system. A "unit" is typically a function, method, or class that performs a specific task. Unit tests verify that these units behave as expected when provided with different inputs.

Key Characteristics of Unit Tests

  • Isolation: Units are tested in isolation from external dependencies
  • Speed: Execute very quickly (milliseconds)
  • Deterministic: Always produce the same result for the same input
  • Automated: Can be run automatically as part of a build process
  • Focused: Each test verifies a single behavior or aspect

Benefits of Unit Testing

  • Early bug detection: Find issues before they propagate through the system
  • Code quality: Encourages cleaner, more modular design
  • Documentation: Tests serve as examples of how to use the code
  • Confidence: Provides confidence when making changes or refactoring
  • Developer productivity: Saves time by catching issues early

Unit Testing Best Practices

1. Follow the AAA Pattern

Structure tests using the Arrange-Act-Assert pattern:

test('calculateDiscount applies percentage correctly', () => {
  // Arrange - Set up the test data
  const price = 100;
  const discountPercentage = 20;
  
  // Act - Call the function being tested
  const discountedPrice = calculateDiscount(price, discountPercentage);
  
  // Assert - Verify the result
  expect(discountedPrice).toBe(80);
});

2. Test One Thing Per Test

Each test should verify a single behavior:

// Good: Separate tests for different behaviors
test('isValidEmail returns true for valid email', () => {
  expect(isValidEmail('user@example.com')).toBe(true);
});

test('isValidEmail returns false for invalid email', () => {
  expect(isValidEmail('not-an-email')).toBe(false);
});

// Avoid: Testing multiple behaviors in one test
test('isValidEmail validates emails correctly', () => {
  expect(isValidEmail('user@example.com')).toBe(true);
  expect(isValidEmail('not-an-email')).toBe(false);
  expect(isValidEmail('')).toBe(false);
  expect(isValidEmail('user@example')).toBe(false);
});

3. Use Descriptive Test Names

Name tests to clearly indicate what they're testing:

// Good: Clear what's being tested
test('should calculate correct tax for standard rate', () => {...});
test('should throw error for negative amounts', () => {...});

// Avoid: Vague test names
test('tax calculation works', () => {...});
test('test errors', () => {...});

4. Mock External Dependencies

Use mocks or stubs to isolate units from external dependencies:

// Example with Jest mocks
test('fetchUserProfile calls the API and transforms the result', async () => {
  // Mock the API call
  apiClient.get = jest.fn().mockResolvedValue({
    id: '123',
    firstName: 'John',
    lastName: 'Doe',
    email: 'john@example.com'
  });
  
  const profile = await userService.fetchUserProfile('123');
  
  // Verify API was called correctly
  expect(apiClient.get).toHaveBeenCalledWith('/users/123');
  
  // Verify the result was transformed correctly
  expect(profile).toEqual({
    id: '123',
    name: 'John Doe',
    email: 'john@example.com'
  });
});

5. Test Edge Cases

Include tests for boundary conditions and error cases:

// Testing a function that processes age
test('processAge handles minimum age (18)', () => {...});
test('processAge handles maximum age (120)', () => {...});
test('processAge rejects negative age', () => {...});
test('processAge rejects non-numeric input', () => {...});

6. Keep Tests Fast

Unit tests should execute quickly:

  • Avoid unnecessary computation
  • Minimize setup and teardown
  • Use mocks instead of real external services
  • Prefer in-memory data structures over file/network operations

7. Make Tests Independent

Tests should not depend on other tests or run order:

// Bad: Tests depend on shared state
let sharedUser;

test('createUser creates a new user', () => {
  sharedUser = createUser('Test User');
  expect(sharedUser.name).toBe('Test User');
});

test('updateUser updates an existing user', () => {
  updateUser(sharedUser, { age: 30 });
  expect(sharedUser.age).toBe(30);
});

// Good: Tests are independent
test('createUser creates a new user', () => {
  const user = createUser('Test User');
  expect(user.name).toBe('Test User');
});

test('updateUser updates an existing user', () => {
  const user = createUser('Original Name');
  updateUser(user, { age: 30 });
  expect(user.age).toBe(30);
});

Popular Unit Testing Frameworks

JavaScript

  • Jest: Full-featured testing framework with built-in mocking and assertions
  • Mocha: Flexible testing framework, often paired with Chai for assertions
  • Jasmine: Behavior-driven development framework with built-in assertions

Python

  • pytest: Feature-rich, extensible testing framework
  • unittest: Standard library testing framework
  • nose2: Extended unittest framework

Java

  • JUnit: Standard testing framework for Java
  • TestNG: Advanced testing framework with more features than JUnit
  • Mockito: Popular mocking framework for Java

C#

  • NUnit: Widely used testing framework for .NET
  • xUnit.net: Modern testing tool for .NET
  • MSTest: Microsoft's testing framework

Common Unit Testing Patterns

1. Test Doubles

Different types of test replacements:

  • Dummy: Objects passed around but never used
  • Stub: Provides predefined answers to calls
  • Spy: Records calls made to it for later verification
  • Mock: Pre-programmed with expectations and can fail tests if expectations are not met
  • Fake: Simplified working implementation of a dependency

2. Parameterized Tests

Running the same test with different inputs:

// Jest example
test.each([
  [1, 1, 2],
  [2, 2, 4],
  [3, 3, 6]
])('add(%i, %i) => %i', (a, b, expected) => {
  expect(add(a, b)).toBe(expected);
});
# pytest example
@pytest.mark.parametrize("a,b,expected", [
    (1, 1, 2),
    (2, 2, 4),
    (3, 3, 6)
])
def test_add(a, b, expected):
    assert add(a, b) == expected

3. Setup and Teardown

Many frameworks provide hooks for test setup and cleanup:

// Jest example
beforeEach(() => {
  // Setup before each test
  mockDatabase = createMockDatabase();
});

afterEach(() => {
  // Cleanup after each test
  mockDatabase.reset();
});
# pytest example
@pytest.fixture
def database():
    # Setup
    db = create_test_database()
    yield db
    # Teardown
    db.close()

Code Coverage

Code coverage measures how much of your code is exercised by your tests.

Coverage Types

  • Statement coverage: Percentage of statements executed
  • Branch coverage: Percentage of branches (if/else paths) executed
  • Function coverage: Percentage of functions called
  • Line coverage: Percentage of code lines executed

Coverage Tools

  • JavaScript: Jest (built-in), Istanbul
  • Python: Coverage.py, pytest-cov
  • Java: JaCoCo, Cobertura
  • C#: Coverlet, NCover

Coverage Best Practices

  • Focus on quality over quantity
  • Aim for high coverage on critical components
  • Don't use coverage as the only quality metric
  • Uncovered code often indicates design issues

Examples from the Repository

Our repository includes several unit testing examples:

Further Reading

  • "Test Driven Development: By Example" by Kent Beck
  • "Working Effectively with Unit Tests" by Jay Fields
  • "The Art of Unit Testing" by Roy Osherove

Related Wiki Pages