TDD with JavaScript and Jest: A Hands-On Tutorial

TDD with JavaScript and Jest: A Hands-On Tutorial

The best way to learn TDD is to do it, not read about it. This tutorial walks through a complete TDD session in JavaScript using Jest. We will build a password validator from scratch — no implementation code until the tests demand it. By the end, you will have a fully tested module and a clear picture of how the red-green-refactor loop feels in practice.

Setup

You need Node.js and npm. Create a new directory and initialize it:

mkdir password-validator && <span class="hljs-built_in">cd password-validator
npm init -y
npm install --save-dev jest

Add the test script to package.json:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch"
  }
}

Create two files:

touch password-validator.js
<span class="hljs-built_in">touch password-validator.test.js

Leave password-validator.js empty. We will only write in it when a test forces us to.

Round 1: Minimum Length

Start with the simplest possible requirement: passwords must be at least 8 characters.

Red — write the failing test:

// password-validator.test.js
const { validatePassword } = require('./password-validator');

describe('validatePassword', () => {
  describe('minimum length', () => {
    test('rejects passwords shorter than 8 characters', () => {
      const result = validatePassword('abc123');
      expect(result.valid).toBe(false);
      expect(result.errors).toContain('Password must be at least 8 characters');
    });

    test('accepts passwords of exactly 8 characters', () => {
      const result = validatePassword('abcd1234');
      expect(result.valid).toBe(true);
      expect(result.errors).toHaveLength(0);
    });
  });
});

Run npm test. You get:

FAIL password-validator.test.js
  ● validatePassword › minimum length › rejects passwords shorter than 8 characters
    TypeError: validatePassword is not a function

Good. You are in the red phase.

Green — minimum code to pass:

// password-validator.js
function validatePassword(password) {
  const errors = [];

  if (password.length < 8) {
    errors.push('Password must be at least 8 characters');
  }

  return {
    valid: errors.length === 0,
    errors,
  };
}

module.exports = { validatePassword };

Run npm test. Both tests pass. Green.

Refactor — nothing to clean up yet. The code is simple enough. Move on.

Round 2: Requiring Numbers

Red:

  describe('requires a number', () => {
    test('rejects passwords with no numbers', () => {
      const result = validatePassword('abcdefgh');
      expect(result.valid).toBe(false);
      expect(result.errors).toContain('Password must contain at least one number');
    });

    test('accepts passwords that contain a number', () => {
      const result = validatePassword('abcdefg1');
      expect(result.valid).toBe(true);
    });
  });

Run. The second test ("accepts passwords that contain a number") passes incidentally. The first test fails because we do not validate for numbers yet.

Green:

function validatePassword(password) {
  const errors = [];

  if (password.length < 8) {
    errors.push('Password must be at least 8 characters');
  }

  if (!/\d/.test(password)) {
    errors.push('Password must contain at least one number');
  }

  return {
    valid: errors.length === 0,
    errors,
  };
}

All 4 tests pass.

Refactor: The regex is not self-documenting. Extract it:

const HAS_NUMBER = /\d/;

function validatePassword(password) {
  const errors = [];

  if (password.length < 8) {
    errors.push('Password must be at least 8 characters');
  }

  if (!HAS_NUMBER.test(password)) {
    errors.push('Password must contain at least one number');
  }

  return {
    valid: errors.length === 0,
    errors,
  };
}

Still green. Good refactor.

Round 3: Requiring Uppercase Letters

Red:

  describe('requires an uppercase letter', () => {
    test('rejects passwords with no uppercase letters', () => {
      const result = validatePassword('abcdefg1');
      expect(result.valid).toBe(false);
      expect(result.errors).toContain('Password must contain at least one uppercase letter');
    });

    test('accepts passwords with an uppercase letter', () => {
      const result = validatePassword('Abcdefg1');
      expect(result.valid).toBe(true);
    });
  });

Note: 'abcdefg1' is 8 characters and has a number but no uppercase. It should be invalid now.

Green:

const HAS_NUMBER = /\d/;
const HAS_UPPERCASE = /[A-Z]/;

function validatePassword(password) {
  const errors = [];

  if (password.length < 8) {
    errors.push('Password must be at least 8 characters');
  }

  if (!HAS_NUMBER.test(password)) {
    errors.push('Password must contain at least one number');
  }

  if (!HAS_UPPERCASE.test(password)) {
    errors.push('Password must contain at least one uppercase letter');
  }

  return {
    valid: errors.length === 0,
    errors,
  };
}

All 6 tests pass.

Round 4: Requiring Special Characters

Red:

  describe('requires a special character', () => {
    test('rejects passwords with no special characters', () => {
      const result = validatePassword('Abcdefg1');
      expect(result.valid).toBe(false);
      expect(result.errors).toContain('Password must contain at least one special character');
    });

    test('accepts passwords with a special character', () => {
      const result = validatePassword('Abcdefg1!');
      expect(result.valid).toBe(true);
    });
  });

Green:

const HAS_NUMBER = /\d/;
const HAS_UPPERCASE = /[A-Z]/;
const HAS_SPECIAL = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/;

function validatePassword(password) {
  const errors = [];

  if (password.length < 8) {
    errors.push('Password must be at least 8 characters');
  }

  if (!HAS_NUMBER.test(password)) {
    errors.push('Password must contain at least one number');
  }

  if (!HAS_UPPERCASE.test(password)) {
    errors.push('Password must contain at least one uppercase letter');
  }

  if (!HAS_SPECIAL.test(password)) {
    errors.push('Password must contain at least one special character');
  }

  return {
    valid: errors.length === 0,
    errors,
  };
}

All 8 tests pass.

Refactor — the function is getting repetitive. Each validation follows the same pattern: a regex, a push to errors. Extract a rules array:

const RULES = [
  {
    test: (p) => p.length >= 8,
    error: 'Password must be at least 8 characters',
  },
  {
    test: (p) => /\d/.test(p),
    error: 'Password must contain at least one number',
  },
  {
    test: (p) => /[A-Z]/.test(p),
    error: 'Password must contain at least one uppercase letter',
  },
  {
    test: (p) => /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(p),
    error: 'Password must contain at least one special character',
  },
];

function validatePassword(password) {
  const errors = RULES
    .filter((rule) => !rule.test(password))
    .map((rule) => rule.error);

  return {
    valid: errors.length === 0,
    errors,
  };
}

module.exports = { validatePassword };

Run npm test. Still 8 tests, all green. The refactor completely changed the structure of the code, but the tests prove the behavior is identical.

Round 5: Edge Cases

Always ask yourself: what did we miss? Add tests for edge cases:

  describe('edge cases', () => {
    test('returns all errors for a completely invalid password', () => {
      const result = validatePassword('abc');
      expect(result.errors).toHaveLength(4);
    });

    test('handles empty string', () => {
      const result = validatePassword('');
      expect(result.valid).toBe(false);
      expect(result.errors).toContain('Password must be at least 8 characters');
    });

    test('handles very long valid passwords', () => {
      const result = validatePassword('Abcdefghijklmnop1!');
      expect(result.valid).toBe(true);
    });
  });

Run. All pass without any code changes — because the rules-based implementation handles these cases naturally. This is a sign of good design: edge cases that work without special handling.

The Final Test Suite

Your complete test file now documents the password validator completely:

  • Rejects passwords under 8 characters
  • Accepts passwords of exactly 8 characters
  • Rejects passwords with no numbers
  • Accepts passwords with a number
  • Rejects passwords with no uppercase
  • Accepts passwords with uppercase
  • Rejects passwords with no special characters
  • Accepts passwords with a special character
  • Returns all errors simultaneously
  • Handles empty strings
  • Handles very long passwords

That is 11 behaviors, all documented as executable tests, all written before the code that implements them. The final implementation is clean, extensible, and trivially easy to modify — you add a rule to the RULES array and write a test for it. No other changes required.

Running in Watch Mode

During active TDD sessions, run npm run test:watch. Jest re-runs your tests on every file save, giving you a continuous red/green indicator. The goal is to spend as little time in the red phase as possible — usually under 5 minutes per cycle.

What This Gets You

The password validator you built is not just tested — it was designed through testing. The rules-based architecture emerged from the refactor phase because the tests gave you the freedom to restructure without fear. Without those tests, the refactor would be risky. With them, it takes 2 minutes and gives you complete confidence.

This is the real payoff of TDD: not just fewer bugs, but better design. Tests push you toward code that is modular, focused, and easy to change. That matters more than test coverage metrics.

Read more