Learn Jest in 10 DaysDay 10: CI/CD and Best Practices
books.chapter 10Learn Jest in 10 Days

Day 10: CI/CD and Best Practices

What You'll Learn Today

  • Running Jest in CI environments (GitHub Actions)
  • Pre-commit hooks with Husky + lint-staged
  • Test strategy: what to test, what not to test
  • Writing maintainable tests
  • Test organization patterns
  • Dealing with flaky tests
  • Performance optimization
  • Testing legacy code
  • Jest ecosystem: useful plugins and tools

Running Jest in CI Environments

Continuous Integration (CI) ensures your tests run automatically on every push, maintaining code quality throughout the project lifecycle.

flowchart LR
    subgraph Dev["Development"]
        PUSH["git push"]
    end
    subgraph CI["CI/CD Pipeline"]
        BUILD["Build"]
        TEST["Run Tests"]
        LINT["Lint"]
        DEPLOY["Deploy"]
    end
    PUSH --> BUILD --> TEST --> LINT --> DEPLOY
    style Dev fill:#3b82f6,color:#fff
    style CI fill:#22c55e,color:#fff

GitHub Actions Configuration

# .github/workflows/test.yml
name: Test

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [18, 20, 22]

    steps:
      - uses: actions/checkout@v4

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test -- --ci --coverage --maxWorkers=2

      - name: Upload coverage
        if: matrix.node-version == 20
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage/lcov.info

CI-Specific Jest Configuration

// jest.config.js
module.exports = {
  ci: process.env.CI === 'true',
  collectCoverage: process.env.CI === 'true',
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

TypeScript version:

// jest.config.ts
import type { Config } from 'jest';

const config: Config = {
  collectCoverage: process.env.CI === 'true',
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

export default config;

Tip: The --ci flag disables automatic snapshot updates and fails the test if a new snapshot is found. This prevents unintentional snapshot changes from being committed.


Pre-commit Hooks (Husky + lint-staged)

Pre-commit hooks prevent low-quality code from entering your repository by running lint and tests before each commit.

Setup

# Install Husky and lint-staged
npm install --save-dev husky lint-staged

# Initialize Husky
npx husky init

Configuration

// package.json
{
  "lint-staged": {
    "*.{js,ts,jsx,tsx}": [
      "eslint --fix",
      "jest --bail --findRelatedTests"
    ]
  }
}
# .husky/pre-commit
npx lint-staged
flowchart TB
    subgraph Hook["Pre-commit Hook"]
        COMMIT["git commit"]
        STAGED["Detect changed files"]
        LINT["Run ESLint"]
        TEST["Run related tests"]
        PASS["Commit succeeds"]
        FAIL["Commit aborted"]
    end
    COMMIT --> STAGED --> LINT --> TEST
    TEST -->|Pass| PASS
    TEST -->|Fail| FAIL
    style Hook fill:#8b5cf6,color:#fff

Important: --findRelatedTests only runs tests related to the changed files, minimizing commit-time wait.


Test Strategy: What to Test, What Not to Test

What You Should Test

Category Examples
Business logic Calculations, data transformations, validation
Edge cases Empty arrays, null, boundary values
Error handling Exception paths, fallback behavior
Public API Function inputs/outputs, rendered component output
Integration points API calls, database operations

What You Should Not Test

Category Reason
Implementation details Breaks on refactoring
Third-party libraries Already tested by maintainers
Constant values They don't change
Pure CSS styling Use visual testing instead
Private methods Test through public API
flowchart TB
    subgraph Do["Should Test"]
        BL["Business Logic"]
        EDGE["Edge Cases"]
        ERR["Error Handling"]
        API["Public API"]
    end
    subgraph Dont["Should Not Test"]
        IMPL["Implementation Details"]
        LIB["Third-party Libraries"]
        CONST["Constants"]
        PRIV["Private Methods"]
    end
    style Do fill:#22c55e,color:#fff
    style Dont fill:#ef4444,color:#fff

The Testing Pyramid

flowchart TB
    E2E["E2E Tests\n(Few, Slow, Expensive)"]
    INT["Integration Tests\n(Moderate)"]
    UNIT["Unit Tests\n(Many, Fast, Cheap)"]
    E2E --- INT --- UNIT
    style E2E fill:#ef4444,color:#fff
    style INT fill:#f59e0b,color:#fff
    style UNIT fill:#22c55e,color:#fff

Writing Maintainable Tests

Anti-pattern: Testing Implementation Details

// Bad: testing internal state
test('adds item to cart', () => {
  const cart = new ShoppingCart();
  cart.addItem({ id: 1, name: 'Book', price: 1500 });

  // Implementation detail: testing internal array
  expect(cart._items).toHaveLength(1);
  expect(cart._items[0].id).toBe(1);
});

// Good: testing public behavior
test('adds item to cart', () => {
  const cart = new ShoppingCart();
  cart.addItem({ id: 1, name: 'Book', price: 1500 });

  expect(cart.getItemCount()).toBe(1);
  expect(cart.getTotalPrice()).toBe(1500);
  expect(cart.hasItem(1)).toBe(true);
});

TypeScript version:

interface CartItem {
  id: number;
  name: string;
  price: number;
}

// Good: testing public behavior
test('adds item to cart', () => {
  const cart = new ShoppingCart();
  const item: CartItem = { id: 1, name: 'Book', price: 1500 };
  cart.addItem(item);

  expect(cart.getItemCount()).toBe(1);
  expect(cart.getTotalPrice()).toBe(1500);
  expect(cart.hasItem(1)).toBe(true);
});

Best Practice: AAA Pattern

test('applies discount to total price', () => {
  // Arrange: set up test data
  const cart = new ShoppingCart();
  cart.addItem({ id: 1, name: 'Book', price: 2000 });
  cart.addItem({ id: 2, name: 'Pen', price: 500 });

  // Act: perform the action
  cart.applyDiscount(0.1); // 10% off

  // Assert: verify the result
  expect(cart.getTotalPrice()).toBe(2250);
});

Test Naming Conventions

// Good: describes behavior
describe('ShoppingCart', () => {
  describe('applyDiscount', () => {
    test('reduces total price by the given percentage', () => {});
    test('throws error when discount is negative', () => {});
    test('does not apply discount when cart is empty', () => {});
  });
});

// Bad: vague names
describe('ShoppingCart', () => {
  test('discount works', () => {});
  test('test error', () => {});
});

Test Organization Patterns

Pattern 1: By Feature (Recommended)

src/
β”œβ”€β”€ features/
β”‚   β”œβ”€β”€ auth/
β”‚   β”‚   β”œβ”€β”€ login.ts
β”‚   β”‚   β”œβ”€β”€ login.test.ts
β”‚   β”‚   β”œβ”€β”€ register.ts
β”‚   β”‚   └── register.test.ts
β”‚   └── cart/
β”‚       β”œβ”€β”€ cart.ts
β”‚       β”œβ”€β”€ cart.test.ts
β”‚       β”œβ”€β”€ checkout.ts
β”‚       └── checkout.test.ts

Pattern 2: By Type

src/
β”œβ”€β”€ components/
β”‚   β”œβ”€β”€ Button.tsx
β”‚   └── Header.tsx
β”œβ”€β”€ __tests__/
β”‚   β”œβ”€β”€ unit/
β”‚   β”‚   β”œβ”€β”€ Button.test.tsx
β”‚   β”‚   └── Header.test.tsx
β”‚   β”œβ”€β”€ integration/
β”‚   β”‚   └── checkout.test.ts
β”‚   └── e2e/
β”‚       └── purchase-flow.test.ts
Pattern Pros Cons
By feature (colocation) Tests close to code, easy to find when making changes Directories can grow large
By type Clear separation of test types Tests are far from source code

Recommendation: For small to medium projects, colocation (by feature) is the most maintainable approach. Place test files next to the code they test.


Dealing with Flaky Tests

Flaky tests are tests that randomly pass or fail for the same code. They erode trust in your test suite.

Common Causes and Solutions

Cause Solution
Shared state between tests Reset state in beforeEach/afterEach
Timing dependencies Use waitFor, fake timers
External service dependencies Replace with mocks
Random data Use fixed seed values
Test order dependencies Detect with --randomize
// Bad: timing-dependent
test('shows notification after delay', () => {
  showNotification('Hello');
  expect(screen.getByText('Hello')).toBeInTheDocument(); // may fail
});

// Good: use fake timers
test('shows notification after delay', () => {
  jest.useFakeTimers();
  showNotification('Hello');
  jest.advanceTimersByTime(1000);
  expect(screen.getByText('Hello')).toBeInTheDocument();
  jest.useRealTimers();
});
// Bad: shared state
let counter = 0;

test('increments counter', () => {
  counter++;
  expect(counter).toBe(1);
});

test('counter is still 1', () => {
  expect(counter).toBe(1); // depends on execution order
});

// Good: isolated state
describe('counter tests', () => {
  let counter;

  beforeEach(() => {
    counter = 0;
  });

  test('increments counter', () => {
    counter++;
    expect(counter).toBe(1);
  });

  test('starts fresh', () => {
    expect(counter).toBe(0);
  });
});

Detecting Flaky Tests

# Run tests in random order
npx jest --randomize

# Run a test multiple times to check flakiness
for i in {1..10}; do npx jest --testPathPattern="suspect.test" || break; done

Performance Optimization

As projects grow, test execution speed becomes critical for developer productivity.

Key Flags

Flag Description Use Case
--maxWorkers=N Limit worker count Control resources in CI
--runInBand Serial execution (no workers) Memory-constrained environments
--shard=i/n Split tests into N shards, run shard i Parallel CI
--onlyChanged Only run tests related to changed files Local development
--bail Stop on first failure Fast feedback

Sharding in CI

# .github/workflows/test.yml
jobs:
  test:
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npx jest --shard=${{ matrix.shard }}/4
flowchart LR
    subgraph Sharding["Test Sharding"]
        ALL["All Tests\n(400 tests)"]
        S1["Shard 1\n100 tests"]
        S2["Shard 2\n100 tests"]
        S3["Shard 3\n100 tests"]
        S4["Shard 4\n100 tests"]
    end
    ALL --> S1
    ALL --> S2
    ALL --> S3
    ALL --> S4
    style Sharding fill:#3b82f6,color:#fff

Additional Optimization Techniques

// jest.config.js
module.exports = {
  // Transform cache to speed up subsequent runs
  cacheDirectory: '/tmp/jest-cache',

  // Only collect coverage for source files
  collectCoverageFrom: [
    'src/**/*.{js,ts,jsx,tsx}',
    '!src/**/*.d.ts',
    '!src/**/index.ts',
  ],

  // Skip expensive transforms for node_modules
  transformIgnorePatterns: ['/node_modules/'],
};

Testing Legacy Code

An incremental approach for adding tests to untested legacy code.

Step 1: Characterization Tests

First, document the current behavior.

// Legacy function with no tests
function calculateShipping(weight, destination) {
  if (destination === 'domestic') {
    if (weight < 1) return 500;
    if (weight < 5) return 800;
    return 1200;
  }
  if (weight < 1) return 2000;
  if (weight < 5) return 3500;
  return 5000;
}

// Characterization test: document current behavior
describe('calculateShipping (characterization)', () => {
  test('domestic shipping rates', () => {
    expect(calculateShipping(0.5, 'domestic')).toBe(500);
    expect(calculateShipping(3, 'domestic')).toBe(800);
    expect(calculateShipping(10, 'domestic')).toBe(1200);
  });

  test('international shipping rates', () => {
    expect(calculateShipping(0.5, 'international')).toBe(2000);
    expect(calculateShipping(3, 'international')).toBe(3500);
    expect(calculateShipping(10, 'international')).toBe(5000);
  });
});

Step 2: Refactor Safely

With tests in place, you can refactor with confidence.

// Refactored version
interface ShippingRate {
  maxWeight: number;
  price: number;
}

const DOMESTIC_RATES: ShippingRate[] = [
  { maxWeight: 1, price: 500 },
  { maxWeight: 5, price: 800 },
  { maxWeight: Infinity, price: 1200 },
];

const INTERNATIONAL_RATES: ShippingRate[] = [
  { maxWeight: 1, price: 2000 },
  { maxWeight: 5, price: 3500 },
  { maxWeight: Infinity, price: 5000 },
];

function calculateShipping(weight: number, destination: string): number {
  const rates = destination === 'domestic' ? DOMESTIC_RATES : INTERNATIONAL_RATES;
  const rate = rates.find(r => weight < r.maxWeight);
  return rate?.price ?? rates[rates.length - 1].price;
}

Step 3: Gradually Increase Coverage

flowchart LR
    subgraph Strategy["Incremental Testing Strategy"]
        S1["1. Characterization\nDocument behavior"]
        S2["2. Refactor\nProtected by tests"]
        S3["3. New Code\nAlways test new code"]
        S4["4. Coverage\nPrioritize critical paths"]
    end
    S1 --> S2 --> S3 --> S4
    style Strategy fill:#8b5cf6,color:#fff

Rule: Always write tests for new code. Add tests to legacy code when you modify it.


Jest Ecosystem: Useful Plugins and Tools

jest-extended

A collection of additional matchers that extend Jest's built-in set.

npm install --save-dev jest-extended
// jest.config.js
module.exports = {
  setupFilesAfterSetup: ['jest-extended/all'],
};
// Useful additional matchers
test('jest-extended matchers', () => {
  expect([1, 2, 3]).toBeArray();
  expect([1, 2, 3]).toIncludeAllMembers([1, 3]);
  expect('hello').toStartWith('he');
  expect('hello').toEndWith('lo');
  expect(5).toBeWithin(1, 10);
  expect({ a: 1 }).toContainKey('a');
  expect(() => {}).toBeFunction();
  expect(new Date()).toBeDate();
  expect('').toBeEmpty();
  expect(42).toBePositive();
  expect(-1).toBeNegative();
});

@testing-library/jest-dom

Custom matchers for testing DOM elements.

npm install --save-dev @testing-library/jest-dom
import '@testing-library/jest-dom';

test('DOM matchers', () => {
  document.body.innerHTML = `
    <button disabled class="primary">Submit</button>
    <input type="text" value="hello" />
    <div style="display: none">Hidden</div>
  `;

  const button = document.querySelector('button');
  const input = document.querySelector('input');
  const hidden = document.querySelector('div');

  expect(button).toBeDisabled();
  expect(button).toHaveClass('primary');
  expect(button).toHaveTextContent('Submit');
  expect(input).toHaveValue('hello');
  expect(hidden).not.toBeVisible();
});

Other Useful Tools

Tool Description
jest-extended Additional matchers (toBeArray, toStartWith, etc.)
@testing-library/jest-dom DOM-specific matchers
jest-watch-typeahead Filter by filename/test name in watch mode
jest-html-reporters Generate HTML test reports
jest-image-snapshot Visual regression testing
jest-when Conditional mock return values based on arguments

Summary

Concept Description
CI/CD Automatically run tests to ensure code quality
Pre-commit hooks Run lint and tests before each commit
Test strategy Focus on business logic, avoid implementation details
AAA pattern Structure tests as Arrange, Act, Assert
Colocation Place test files next to source code
Flaky tests Fix with state isolation and timing control
Sharding Split tests across parallel CI jobs
Characterization tests Document legacy code behavior before refactoring
jest-extended Plugin that adds additional matchers

Key Takeaways

  1. Use --ci and --maxWorkers to control resources in CI environments
  2. Use --findRelatedTests in pre-commit hooks for speed
  3. Test public behavior, not implementation details
  4. Identify and fix the root cause of flaky tests
  5. Start with characterization tests when dealing with legacy code

Exercises

Exercise 1: Basic

Fill in the blanks in the following GitHub Actions workflow.

name: Test
on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ______
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx jest --__ --coverage --maxWorkers=__

Exercise 2: Applied

The following test suite has a flaky test issue. Identify the problem and fix it.

let database = [];

describe('UserRepository', () => {
  test('adds a user', () => {
    database.push({ id: 1, name: 'Alice' });
    expect(database).toHaveLength(1);
  });

  test('finds a user by id', () => {
    const user = database.find(u => u.id === 1);
    expect(user).toBeDefined();
    expect(user.name).toBe('Alice');
  });

  test('database starts empty', () => {
    expect(database).toHaveLength(0); // This fails!
  });
});

Challenge

Create a jest.config.js that meets these requirements:

  • Collect coverage only in CI environments
  • Coverage threshold of 80% minimum
  • Cache directory at /tmp/jest-cache
  • Exclude node_modules and .d.ts files from coverage
  • Limit to 2 workers in CI environments

References


Reflecting on Your 10-Day Journey

Congratulations! You have completed the entire 10-day Jest learning journey. Let's look back at everything you've learned.

Day Topic What You Learned
Day 1 Welcome to Jest Setting up Jest, writing your first test
Day 2 Test Structure and Basic Patterns describe/test, beforeEach/afterEach
Day 3 Mastering Matchers toBe, toEqual, various matchers
Day 4 Mocks, Stubs, and Spies jest.fn(), jest.mock(), jest.spyOn()
Day 5 Testing Async Code async/await, Promises, timers
Day 6 Test Coverage and Debugging Coverage reports, debugging techniques
Day 7 React Component Testing Testing Library, user events
Day 8 Advanced Mock Patterns Module mocks, manual mocks
Day 9 Snapshot Testing toMatchSnapshot, toMatchInlineSnapshot
Day 10 CI/CD and Best Practices CI setup, test strategy, performance
flowchart TB
    subgraph Journey["Your 10-Day Jest Journey"]
        D1["Day 1-3\nFoundations"]
        D2["Day 4-6\nIntermediate"]
        D3["Day 7-9\nAdvanced"]
        D4["Day 10\nReal-World"]
    end
    D1 --> D2 --> D3 --> D4
    style D1 fill:#3b82f6,color:#fff
    style D2 fill:#8b5cf6,color:#fff
    style D3 fill:#f59e0b,color:#fff
    style D4 fill:#22c55e,color:#fff

Next Steps

From here, take your Jest skills into real-world projects.

  1. Add tests to an existing project -- Start with characterization tests
  2. Set up a CI/CD pipeline -- Use the GitHub Actions configuration from today
  3. Set a coverage goal -- Aim for 80% as your first target
  4. Dive deeper into Testing Library -- Essential for React projects
  5. Explore E2E testing (Playwright/Cypress) -- For scenarios Jest alone cannot cover

Writing tests is not just about code quality -- it builds confidence as a developer. Keep writing great tests!