Jest Testing Framework

Understanding and implementing JavaScript testing with Jest

Introduction to Jest

Jest is a delightful JavaScript testing framework developed by Facebook. It's designed to ensure correctness of any JavaScript codebase with a focus on simplicity and developer experience. Think of Jest as your quality assurance partner, constantly checking that your code does exactly what you expect it to do.

mindmap root((Jest)) Zero Configuration Works out of the box Sensible defaults Built-in code coverage Fast & Parallel Sandbox isolation Parallelized tests Performance oriented Complete Solution Test runner Assertion library Mocking utilities Snapshot testing Developer Experience Watch mode Interactive mode Clear error messages

Real-world analogy: If software development were cooking, Jest would be like having a team of food critics constantly tasting your dishes to ensure they meet the expected flavor profile, texture, and presentation before they reach customers.

Getting Started with Jest

Installation

Setting up Jest in your project is straightforward:

// Initialize npm in your project (if not already done)
npm init -y

// Install Jest as a development dependency
npm install --save-dev jest

// Update package.json to use Jest for testing
// In "scripts" section add:
// "test": "jest"

Creating Your First Test

Let's create a simple function to test. Create a file called sum.js:

// sum.js
function sum(a, b) {
  return a + b;
}

module.exports = sum;

Now, create a test file called sum.test.js:

// sum.test.js
const sum = require('./sum');

test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3);
});

To run the test, simply execute:

npm test

You should see output indicating that your test passed!

graph TD A[Create source file with function] --> B[Create test file] B --> C[Write test assertions] C --> D[Run tests with npm test] D --> E{Tests pass?} E -->|Yes| F[Celebrate!] E -->|No| G[Fix code or tests] G --> D

Jest Test Structure

Jest tests follow a clear and intuitive structure:

// Basic test structure
test('description of what the test should verify', () => {
  // Setup - Prepare the data/environment
  
  // Exercise - Call the function being tested
  
  // Verify - Check that the result is what you expect
  
  // Teardown (optional) - Clean up any resources
});

For grouping related tests, Jest provides the describe block:

// Grouping related tests
describe('Math utility functions', () => {
  test('sum adds numbers correctly', () => {
    expect(sum(2, 3)).toBe(5);
  });
  
  test('multiply works properly', () => {
    expect(multiply(3, 4)).toBe(12);
  });
});

For setup and teardown operations, Jest provides special functions:

// Setup and teardown
describe('Database operations', () => {
  beforeAll(() => {
    // Run once before all tests in this describe block
    // Often used to set up database connections
  });
  
  afterAll(() => {
    // Run once after all tests in this describe block
    // Typically used to close connections or clean up resources
  });
  
  beforeEach(() => {
    // Run before each test in this describe block
    // Perfect for resetting to a known state
  });
  
  afterEach(() => {
    // Run after each test in this describe block
    // Good for cleaning up after each test
  });
  
  test('can save a record', () => {
    // Test implementation
  });
});

Matchers: Verifying Results

Jest provides a rich set of matchers to verify different types of values and conditions:

Common Matchers

// Exact equality
expect(2 + 2).toBe(4);  // Checks with ===

// Deep equality for objects and arrays
expect({ name: 'John' }).toEqual({ name: 'John' });

// Truthiness
expect(true).toBeTruthy();
expect(false).toBeFalsy();
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect('defined').toBeDefined();

Number Matchers

// Comparison
expect(10).toBeGreaterThan(9);
expect(10).toBeGreaterThanOrEqual(10);
expect(10).toBeLessThan(11);
expect(10).toBeLessThanOrEqual(10);

// Floating point
expect(0.1 + 0.2).toBeCloseTo(0.3);

String Matchers

// String matching
expect('hello world').toMatch(/world/);
expect('hello world').toContain('world');

Array Matchers

// Array contents
const shoppingList = ['milk', 'bread', 'eggs'];
expect(shoppingList).toContain('milk');
expect(shoppingList).toHaveLength(3);

Exception Matchers

// Testing that a function throws an error
function throwError() {
  throw new Error('This is an error');
}

expect(() => throwError()).toThrow();
expect(() => throwError()).toThrow('This is an error');

Real-world example: Think of matchers like quality inspectors in a factory. Different inspectors (matchers) specialize in checking different aspects of the product—dimensions, weight, appearance, functionality, etc.

Practical Example: Testing a Shopping Cart

Let's implement tests for a simple shopping cart module that allows adding items, removing items, and calculating the total.

First, our implementation (shoppingCart.js):

// shoppingCart.js
class ShoppingCart {
  constructor() {
    this.items = [];
  }
  
  addItem(item) {
    if (!item.name || !item.price || item.price <= 0) {
      throw new Error('Invalid item');
    }
    
    // Check if item already exists
    const existingItemIndex = this.items.findIndex(i => i.name === item.name);
    
    if (existingItemIndex >= 0) {
      // Increment quantity if item exists
      this.items[existingItemIndex].quantity += item.quantity || 1;
    } else {
      // Add new item with default quantity of 1 if not specified
      this.items.push({
        ...item,
        quantity: item.quantity || 1
      });
    }
    
    return this.items;
  }
  
  removeItem(itemName) {
    const initialLength = this.items.length;
    this.items = this.items.filter(item => item.name !== itemName);
    
    // Return true if an item was removed
    return this.items.length < initialLength;
  }
  
  getTotal() {
    return this.items.reduce((total, item) => {
      return total + (item.price * item.quantity);
    }, 0);
  }
  
  clear() {
    this.items = [];
  }
}

module.exports = ShoppingCart;

Now, let's write tests for this cart (shoppingCart.test.js):

// shoppingCart.test.js
const ShoppingCart = require('./shoppingCart');

describe('Shopping Cart', () => {
  let cart;
  
  beforeEach(() => {
    // Reset the cart before each test
    cart = new ShoppingCart();
  });
  
  test('should start with an empty cart', () => {
    expect(cart.items).toEqual([]);
    expect(cart.getTotal()).toBe(0);
  });
  
  test('should add an item to the cart', () => {
    const item = { name: 'Keyboard', price: 50 };
    cart.addItem(item);
    
    expect(cart.items).toHaveLength(1);
    expect(cart.items[0].name).toBe('Keyboard');
    expect(cart.items[0].price).toBe(50);
    expect(cart.items[0].quantity).toBe(1);
  });
  
  test('should increase quantity when adding the same item', () => {
    const item = { name: 'Mouse', price: 25 };
    
    cart.addItem(item);
    cart.addItem(item);
    
    expect(cart.items).toHaveLength(1);
    expect(cart.items[0].quantity).toBe(2);
  });
  
  test('should allow adding an item with a specific quantity', () => {
    const item = { name: 'Headphones', price: 100, quantity: 2 };
    
    cart.addItem(item);
    
    expect(cart.items[0].quantity).toBe(2);
  });
  
  test('should throw error when adding invalid item', () => {
    const invalidItem1 = { price: 50 }; // Missing name
    const invalidItem2 = { name: 'Keyboard' }; // Missing price
    const invalidItem3 = { name: 'Keyboard', price: -10 }; // Negative price
    
    expect(() => cart.addItem(invalidItem1)).toThrow();
    expect(() => cart.addItem(invalidItem2)).toThrow();
    expect(() => cart.addItem(invalidItem3)).toThrow();
  });
  
  test('should remove an item from the cart', () => {
    cart.addItem({ name: 'Keyboard', price: 50 });
    
    const result = cart.removeItem('Keyboard');
    
    expect(result).toBe(true);
    expect(cart.items).toHaveLength(0);
  });
  
  test('should return false when removing an item that does not exist', () => {
    const result = cart.removeItem('NonExistent');
    
    expect(result).toBe(false);
  });
  
  test('should calculate the correct total', () => {
    cart.addItem({ name: 'Keyboard', price: 50 });
    cart.addItem({ name: 'Mouse', price: 25 });
    cart.addItem({ name: 'Headphones', price: 100, quantity: 2 });
    
    expect(cart.getTotal()).toBe(275); // 50 + 25 + (100 * 2)
  });
  
  test('should clear all items', () => {
    cart.addItem({ name: 'Keyboard', price: 50 });
    cart.addItem({ name: 'Mouse', price: 25 });
    
    cart.clear();
    
    expect(cart.items).toHaveLength(0);
    expect(cart.getTotal()).toBe(0);
  });
});

This comprehensive test suite ensures that each feature of our shopping cart works as expected. By running these tests, we can confidently make changes to our implementation knowing that we'll catch any regressions.

Advanced Jest Features

Watch Mode

Jest can watch for file changes and automatically run tests:

npm test -- --watch

Test Coverage

Jest includes built-in code coverage reporting:

npm test -- --coverage

This generates a comprehensive report showing which parts of your code are covered by tests and which aren't.

pie title Code Coverage "Statements" : 87 "Branches" : 75 "Functions" : 92 "Lines" : 88

Test Filtering

You can run specific tests by name pattern:

// Run only tests with "total" in the name
npm test -- -t "total"

Snapshot Testing

Snapshot testing is great for testing UI components or data structures that you expect to remain consistent:

test('product card renders correctly', () => {
  const product = {
    id: 1,
    name: 'Mechanical Keyboard',
    price: 79.99,
    imageUrl: '/images/keyboard.png'
  };
  
  const card = renderProductCard(product);
  
  // This creates/verifies a snapshot of the rendered output
  expect(card).toMatchSnapshot();
});

When you run this test the first time, Jest creates a snapshot file. On subsequent runs, it compares the output with the saved snapshot to ensure consistency.

Best Practices for Jest Testing

File Organization

Testing Strategy

Avoiding Common Pitfalls

Real-world example: At Airbnb, they organize their Jest tests alongside the components they test. They've found that this proximity makes developers more likely to update tests when they change the component code, leading to fewer broken tests and better test coverage.

When to Use Jest

Jest is particularly well-suited for:

Real-world applications: Companies like Facebook, Twitter, Airbnb, Instagram, and Spotify use Jest for their JavaScript testing needs. For example, Spotify rebuilt their web player with React and uses Jest for testing to ensure consistent playback behavior across browsers and devices.

Practical Exercise

Exercise: Testing a Form Validator

Let's practice by writing tests for a form validation utility. The utility should validate:

  1. Email addresses (must be valid format)
  2. Passwords (minimum 8 characters, at least one uppercase, one lowercase, one number)
  3. Username (alphanumeric, 3-20 characters)

Use this skeleton:

// formValidator.js
const formValidator = {
  validateEmail(email) {
    // Implement email validation
  },
  
  validatePassword(password) {
    // Implement password validation  
  },
  
  validateUsername(username) {
    // Implement username validation
  }
};

module.exports = formValidator;

// formValidator.test.js
const validator = require('./formValidator');

describe('Form Validator', () => {
  // Write your tests here
});

Summary

Remember: The goal of testing is not just to identify bugs but to give you confidence in your code and enable fearless refactoring.

Assignment

Create a complete Jest test suite for a URL shortener utility:

  1. Create a urlShortener.js module with the following functions:
    • shortenUrl(url) - Generate a short code for a URL
    • expandUrl(code) - Retrieve the original URL for a short code
    • validateUrl(url) - Check if a URL is valid
    • getStats(code) - Get usage statistics for a short code
  2. Write a comprehensive test suite in urlShortener.test.js
  3. Include edge cases like invalid URLs, non-existent codes, etc.
  4. Generate a code coverage report and aim for at least 90% coverage
  5. Include a brief explanation of your testing strategy

Bonus challenge: Implement mock persistence (using Jest mocks) to simulate storing URLs in a database.