DEV Community

Alex Chen
Alex Chen

Posted on

Testing JavaScript: A Practical Guide to TDD with Jest (2026)

Testing JavaScript: A Practical Guide to TDD with Jest (2026)

Testing isn't optional anymore. Here's how to actually do it — with real examples you can copy.

Why Test?

Without tests:
→ You're scared to refactor (what if I break something?)
→ Bugs get reintroduced ("regressions")
→ Code reviews take forever (reviewers have to mentally trace logic)
→ Onboarding is slow (no tests document expected behavior)
→ Deployments are stressful (did I break production?)

With tests:
→ Refactor confidently (tests tell you if something broke)
→ Catches regressions immediately
→ Code reviews focus on design, not correctness
→ Tests ARE documentation (executable specs)
→ Deployment is boring (in a good way) — just another day
Enter fullscreen mode Exit fullscreen mode

Setup

# Install Jest
npm install --save-dev jest
# Or with TypeScript support:
npm install --save-dev jest ts-jest @types/jest

# Add to package.json:
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}

# Run tests:
npm test                    # Run once
npm run test:watch          # Watch mode (re-runs on file changes)
npm run test:coverage       # With coverage report
Enter fullscreen mode Exit fullscreen mode
// jest.config.js (or in package.json under "jest")
module.exports = {
  // Where to find tests
  testMatch: ['**/__tests__/**/*.js', '**/*.test.js', '**/*.spec.js'],

  // Transform files (TypeScript, JSX, etc.)
  transform: {
    '^.+\\.jsx?$': 'babel-jest',  // or 'ts-jest' for TypeScript
  },

  // Coverage settings
  collectCoverageFrom: [
    'src/**/*.{js,ts}',
    '!src/**/*.test.{js,ts}',
    '!src/index.ts',           // Entry point usually doesn't need coverage
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },

  // Test environment (jsdom for DOM tests, node for pure JS)
  testEnvironment: 'node',

  // Setup file (runs before each test)
  setupFilesAfterEnv: ['./tests/setup.js'],
};
Enter fullscreen mode Exit fullscreen mode

The Three Types of Tests You Need

Unit Tests — Test One Thing in Isolation

// utils/price.js
export function calculateTotal(items, taxRate = 0.1) {
  if (!items || items.length === 0) return 0;
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  return subtotal * (1 + taxRate);
}

export function formatPrice(cents) {
  const dollars = cents / 100;
  return `$${dollars.toFixed(2)}`;
}

export function applyDiscount(total, discountCode) {
  const discounts = { SAVE10: 0.1, SAVE20: 0.2, WELCOME: 0.25 };
  if (!discounts[discountCode]) return total; // Invalid code = no discount
  return total * (1 - discounts[discountCode]);
}
Enter fullscreen mode Exit fullscreen mode
// __tests__/utils/price.test.js
const { calculateTotal, formatPrice, applyDiscount } = require('../../utils/price');

describe('calculateTotal', () => {
  it('should return 0 for empty array', () => {
    expect(calculateTotal([])).toBe(0);
  });

  it('should return 0 for null/undefined', () => {
    expect(calculateTotal(null)).toBe(0);
    expect(calculateTotal(undefined)).toBe(0);
  });

  it('should calculate subtotal with default tax', () => {
    const items = [{ price: 100, quantity: 2 }];
    expect(calculateTotal(items)).toBe(220); // 200 + 10% tax
  });

  it('should handle multiple items correctly', () => {
    const items = [
      { price: 1000, quantity: 1 },
      { price: 500, quantity: 3 },
      { price: 50, quantity: 2 },
    ];
    // Subtotal: 1000 + 1500 + 100 = 2600
    // With 10% tax: 2860
    expect(calculateTotal(items)).toBe(2860);
  });

  it('should use custom tax rate when provided', () => {
    const items = [{ price: 100, quantity: 1 }];
    expect(calculateTotal(items, 0.2)).toBe(120); // 100 + 20% tax
  });
});

describe('formatPrice', () => {
  it('should format cents to dollars', () => {
    expect(formatPrice(1999)).toBe('$19.99');
    expect(formatPrice(100)).toBe('$1.00');
    expect(formatPrice(0)).toBe('$0.00');
  });

  it('should round to 2 decimal places', () => {
    expect(formatPrice(333)).toBe('$3.33'); // 3.33 not 3.333...
  });
});

describe('applyDiscount', () => {
  it('should apply SAVE10 discount', () => {
    expect(applyDiscount(100, 'SAVE10')).toBe(90);
  });

  it('should apply SAVE20 discount', () => {
    expect(applyDiscount(100, 'SAVE20')).toBe(80);
  });

  it('should apply WELCOME discount (best deal)', () => {
    expect(applyDiscount(100, 'WELCOME')).toBe(75);
  });

  it('should return original total for invalid code', () => {
    expect(applyDiscount(100, 'INVALID')).toBe(100);
    expect(applyDiscount(100, '')).toBe(100);
    expect(applyDiscount(100, null)).toBe(100);
  });
});
Enter fullscreen mode Exit fullscreen mode

Integration Tests — Test Components Working Together

// services/orderService.js
const db = require('./db');
const { calculateTotal, applyDiscount } = require('../utils/price');

async function createOrder(userId, items, discountCode) {
  const user = await db.users.findById(userId);
  if (!user) throw new Error('User not found');

  let total = calculateTotal(items);
  if (discountCode) {
    total = applyDiscount(total, discountCode);
  }

  const order = await db.orders.create({
    userId,
    items,
    total,
    status: 'pending',
  });

  return order;
}
Enter fullscreen mode Exit fullscreen mode
// __tests__/services/orderService.test.js
const { createOrder } = require('../../services/orderService');

// Mock the database layer
jest.mock('../../services/db');

describe('createOrder', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('should create order successfully', async () => {
    const mockUser = { id: 'user_1', name: 'Test User' };
    const mockOrder = { id: 'order_1', total: 110, status: 'pending' };

    require('../../services/db').users.findById.mockResolvedValue(mockUser);
    require('../../services/db').orders.create.mockResolvedValue(mockOrder);

    const result = await createOrder(
      'user_1',
      [{ price: 100, quantity: 1 }]
    );

    expect(result).toEqual(mockOrder);
    expect(require('../../services/db').orders.create).toHaveBeenCalledWith(
      expect.objectContaining({ total: 110, status: 'pending' })
    );
  });

  it('should throw error for non-existent user', async () => {
    require('../../services/db').users.findById.mockResolvedValue(null);

    await expect(createOrder('bad_id', []))
      .rejects.toThrow('User not found');
  });

  it('should apply discount code when provided', async () => {
    require('../../services/db').users.findById.mockResolvedValue({ id: 'u1' });
    require('../../services/db').orders.create.mockImplementation(o => Promise.resolve({...o, id: 'o1'}));

    const result = await createOrder(
      'u1',
      [{ price: 100, quantity: 1 }],
      'SAVE10'
    );

    expect(result.total).toBe(90); // $100 - 10%
  });
});
Enter fullscreen mode Exit fullscreen mode

E2E / API Tests — Test Real HTTP Endpoints

// __tests__/api/orders.e2e.test.js
const request = require('supertest');
const app = require('../../app');

describe('POST /api/orders', () => {
  it('should create an order and return 201', async () => {
    const res = await request(app)
      .post('/api/orders')
      .send({
        userId: 'user_1',
        items: [{ productId: 'prod_1', quantity: 2 }],
        discountCode: 'SAVE10',
      })
      .expect('Content-Type', /json/)
      .expect(201);

    expect(res.body).toMatchObject({
      status: 'pending',
      items: expect.arrayContaining([
        expect.objectContaining({ quantity: 2 }),
      ]),
    });
    expect(res.body.total).toBeGreaterThan(0);
    expect(res.body.id).toBeDefined();
  });

  it('should return 400 for invalid input', async () => {
    const res = await request(app)
      .post('/api/orders')
      .send({}) // Missing required fields
      .expect(400);

    expect(res.body.error).toBeDefined();
  });

  it('should return 404 for non-existent user', async () => {
    const res = await request(app)
      .post('/api/orders')
      .send({
        userId: 'nonexistent_user',
        items: [{ productId: 'p1', quantity: 1 }],
      })
      .expect(404);
  });
});
Enter fullscreen mode Exit fullscreen mode

TDD Workflow (Test-Driven Development)

RED → GREEN → REFACTOR (repeat)

1. RED:   Write a failing test for the behavior you want
2. GREEN: Write the MINIMUM code to make the test pass
3. REFACTOR: Improve the code while keeping tests green
4. Repeat for next feature
Enter fullscreen mode Exit fullscreen mode
// Example: Building a password validator via TDD

// Step 1: RED — Write failing test
describe('PasswordValidator', () => {
  it('should reject passwords shorter than 8 characters', () => {
    expect(validatePassword('abc123')).toBe(false);
  });

  it('should accept valid 8+ character passwords', () => {
    expect(validatePassword('Abc123!@')).toBe(true);
  });
});
// Run: FAILS (validatePassword doesn't exist yet)

// Step 2: GREEN — Minimum implementation
function validatePassword(password) {
  return password.length >= 8;
}
// Run: PASSES ✅

// Step 3: Add more tests (back to RED)
it('should require at least one uppercase letter', () => {
  expect(validatePassword('abcdefg1!')).toBe(false);
});

it('should require at least one number', () => {
  expect(validatePassword('Abcdefg!')).toBe(false);
});
// Run: FAILS ❌

// Step 4: GREEN again
function validatePassword(password) {
  if (password.length < 8) return false;
  if (!/[A-Z]/.test(password)) return false;
  if (!/[0-9]/.test(password)) return false;
  return true;
}
// Run: PASSES ✅

// Step 5: REFACTOR (make cleaner without breaking tests)
function validatePassword(password) {
  const rules = [
    { test: p => p.length >= 8, error: 'min 8 chars' },
    { test: p => /[A-Z]/.test(p), error: 'uppercase required' },
    { test: p => /[0-9]/.test(p), error: 'number required' },
  ];
  return rules.every(rule => rule.test(password));
}
// Run: Still PASSES ✅ (refactoring didn't break anything!)
Enter fullscreen mode Exit fullscreen mode

Common Patterns & Matchers

// Object shape matching (great for API responses)
expect(response).toMatchObject({
  status: 'success',
  data: expect.any(Array),
  pagination: expect.objectContaining({ page: 1 }),
});

// Async testing
it('fetches data asynchronously', async () => {
  const data = await fetchData('/api/users');
  expect(data).toHaveLength(5);
});

// Error handling
it('throws with specific message', () => {
  expect(() => riskyOperation()).toThrow('specific error text');
});

await expect(asyncFunction()).rejects.toThrow(ErrorType);

// Callback-style
it('calls callback with data', (done) => {
  asyncWithCallback((err, data) => {
    expect(err).toBeNull();
    expect(data).toBeDefined();
    done(); // Must call done()!
  });
});

// Timers
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());

it('debounces function calls', () => {
  const fn = jest.fn();
  const debouncedFn = debounce(fn, 500);

  debouncedFn();     // Call 1
  debouncedFn();     // Call 2
  debouncedFn();     // Call 3

  expect(fn).not.toHaveBeenCalled(); // Not called yet!

  jest.advanceTimersByTime(500);
  expect(fn).toHaveBeenCalledTimes(1); // Called once after delay!
});

// Spies
it('should call logger on error', () => {
  const loggerSpy = jest.spyOn(console, 'error');
  processWithError();
  expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('failed'));
  loggerSpy.mockRestore(); // Always restore!
});
Enter fullscreen mode Exit fullscreen mode

Testing Best Practices

 DO:
- Test behavior, not implementation (refactor-friendly)
- Use descriptive test names (reads like a sentence)
- One assertion per concept (use describe blocks)
- Test edge cases: null, empty, boundary values
- Keep tests fast (< 100ms per test ideally)
- Use meaningful test data (not always "foo", "bar")
- Setup/tear down in beforeEach/afterEach

 DON'T:
- Test private methods (test public behavior instead)
- Make tests depend on each other (isolation!)
- Ignore flaky tests (fix or delete them)
- Put logic in tests (if/else in tests = smell)
- Mock everything (integration tests need real connections)
- Skip writing tests because "it's simple"
- Use random data in tests (non-deterministic = flaky)

📏 Structure each test file:
describe('Module/Component', () => {
  describe('functionName()', () => {
    it('should do X when Y', () => {});
    it('should throw when Z', () => {});
  });
});
Enter fullscreen mode Exit fullscreen mode

What's your testing philosophy? TDD purist or test-after?

Follow @armorbreak for more practical developer guides.

Top comments (0)