skies.dev

TDD and Unit Testing with Jest

4 min read

What Are Unit Tests?

Unit tests verify a small, isolated piece of behavior. They should tell you whether a function, class, or module does what its public API promises, without relying on the rest of the system.

Good unit tests are usually:

  • fast
  • deterministic
  • focused on one behavior at a time
  • written against observable output, not private internals

Let's use a simple banking example: an Account class. The product requirements are:

  • create an account with an initial balance greater than or equal to 0
  • read the current balance
  • deposit an amount greater than 0
  • withdraw an amount greater than 0 when enough funds are available

Start With The Boundaries

When you cannot test every input, test the edges of the domain. For this class, the first important boundary is the initial balance.

account.ts
export default class Account {
  private _balance: number;

  constructor(initialBalance = 0) {
    if (initialBalance < 0) {
      throw new Error('initial balance should be >= 0');
    }

    this._balance = initialBalance;
  }

  get balance(): number {
    return this._balance;
  }
}

Now write tests for the boundary conditions.

__tests__/account.test.ts
import Account from '../account';

describe('Account', () => {
  test('rejects a negative initial balance', () => {
    expect(() => new Account(-1)).toThrow('initial balance should be >= 0');
  });

  test('allows zero or positive initial balances', () => {
    expect(new Account().balance).toBe(0);
    expect(new Account(25).balance).toBe(25);
  });
});

Those tests describe the public behavior without caring how the class stores its state.

Add One Behavior At A Time

Next, add deposit(). The test should describe the contract from the caller's point of view.

__tests__/account.test.ts
import Account from '../account';

describe('Account', () => {
  test('rejects a negative initial balance', () => {
    expect(() => new Account(-1)).toThrow('initial balance should be >= 0');
  });

  test('allows zero or positive initial balances', () => {
    expect(new Account().balance).toBe(0);
    expect(new Account(25).balance).toBe(25);
  });

  test('deposits positive amounts', () => {
    const account = new Account(10);
    expect(account.deposit(5)).toBe(15);
    expect(account.balance).toBe(15);
  });

  test('rejects non-positive deposits', () => {
    const account = new Account();
    expect(() => account.deposit(0)).toThrow('deposit should be > 0');
    expect(() => account.deposit(-1)).toThrow('deposit should be > 0');
  });
});

The implementation stays small.

account.ts
export default class Account {
  private _balance: number;

  constructor(initialBalance = 0) {
    if (initialBalance < 0) {
      throw new Error('initial balance should be >= 0');
    }

    this._balance = initialBalance;
  }

  get balance(): number {
    return this._balance;
  }

  deposit(amount: number): number {
    if (amount <= 0) {
      throw new Error('deposit should be > 0');
    }

    this._balance += amount;
    return this._balance;
  }
}

Finish With Withdrawal

Withdrawal adds one more important rule: do not allow the balance to go below zero.

__tests__/account.test.ts
import Account from '../account';

describe('Account', () => {
  test('rejects a negative initial balance', () => {
    expect(() => new Account(-1)).toThrow('initial balance should be >= 0');
  });

  test('allows zero or positive initial balances', () => {
    expect(new Account().balance).toBe(0);
    expect(new Account(25).balance).toBe(25);
  });

  test('deposits positive amounts', () => {
    const account = new Account(10);
    expect(account.deposit(5)).toBe(15);
    expect(account.balance).toBe(15);
  });

  test('rejects non-positive deposits', () => {
    const account = new Account();
    expect(() => account.deposit(0)).toThrow('deposit should be > 0');
    expect(() => account.deposit(-1)).toThrow('deposit should be > 0');
  });

  test('withdraws when funds are available', () => {
    const account = new Account(20);
    expect(account.withdraw(5)).toBe(15);
    expect(account.balance).toBe(15);
  });

  test('rejects non-positive withdrawals', () => {
    const account = new Account(20);
    expect(() => account.withdraw(0)).toThrow('withdraw should be > 0');
    expect(() => account.withdraw(-1)).toThrow('withdraw should be > 0');
  });

  test('rejects withdrawals that exceed the balance', () => {
    const account = new Account(20);
    expect(() => account.withdraw(25)).toThrow('insufficient funds');
  });
});
account.ts
export default class Account {
  private _balance: number;

  constructor(initialBalance = 0) {
    if (initialBalance < 0) {
      throw new Error('initial balance should be >= 0');
    }

    this._balance = initialBalance;
  }

  get balance(): number {
    return this._balance;
  }

  deposit(amount: number): number {
    if (amount <= 0) {
      throw new Error('deposit should be > 0');
    }

    this._balance += amount;
    return this._balance;
  }

  withdraw(amount: number): number {
    if (amount <= 0) {
      throw new Error('withdraw should be > 0');
    }

    if (amount > this._balance) {
      throw new Error('insufficient funds');
    }

    this._balance -= amount;
    return this._balance;
  }
}

What To Remember

Unit tests are most useful when they describe the contract of your code. For a class like Account, that means testing:

  • valid and invalid inputs at the boundaries
  • return values that callers depend on
  • state changes that are visible through the public API

You do not need to test every line of implementation. You need to test the behavior that matters.

That is the part that makes unit tests maintainable instead of noisy.

Hey, you! 🫵

Did you know I created a YouTube channel? I'll be putting out a lot of new content on web development and software engineering so make sure to subscribe.

(clap if you liked the article)

You might also like