Testing Principles and Unit Testing with Jest

Understanding the foundations of software testing and implementing effective unit tests

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:

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:

graph TD A[Unit Tests] -->|Foundation| B[Integration Tests] B -->|Middle Layer| C[End-to-End Tests] style A fill:#cce5ff,stroke:#004085 style B fill:#d4edda,stroke:#155724 style C fill:#fff3cd,stroke:#856404

Today we focus on the foundation: unit testing.

Unit Testing Principles

What Makes a Good Unit Test?

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?

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.js suffix
  • Files with .spec.js suffix
  • 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:

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:

graph LR A[Write a Failing Test] -->|Red| B[Write Code to Pass] B -->|Green| C[Refactor Code] C -->|Repeat| A style A fill:#f8d7da,stroke:#721c24 style B fill:#d4edda,stroke:#155724 style C fill:#fff3cd,stroke:#856404
  1. Red: Write a failing test
  2. Green: Write the minimum amount of code to pass the test
  3. 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.

  1. First, we write tests for various valid and invalid card numbers
  2. Then, we implement the validation function to make the tests pass
  3. 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:

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:

Jest Mocking Features:

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.

  1. Write tests for successful API responses
  2. Write tests for API error scenarios
  3. Test edge cases like timeout handling

Further Reading