JS Tests

JavaScript Testing

Unit Testing with Jest

CSCI 4513

Week 7, Lecture 13

Today's Learning Objectives

Norse Mythology Connection

Brokk and Eitri Forging Mjolnir

Constant testing prevented flaws during forging, just as continuous testing ensures code quality throughout development.

The Tale:

Brothers Brokk and Eitri wagered they could forge treasures rivaling any others. Eitri forged while Brokk worked the bellowsβ€”maintaining exact temperature without stopping, even for a moment, or the enchantments would fail.

Loki, desperate to disrupt them, transformed into a fly and bit Brokk repeatedly. Brokk didn't flinch through two treasures. But during Mjolnir's forging, Loki bit his eyelid. For just a moment, Brokk brushed blood from his eyes, briefly stopping the bellows. That tiny lapse caused Mjolnir's only flawβ€”a slightly short handle. Despite this, Mjolnir became the mightiest weapon in all realms.

Constant Vigilance

// Brokk's constant testing
describe('Mjolnir functionality', () => {
    test('hammer returns when thrown', () => {
        const mjolnir = new Hammer();
        mjolnir.throw();
        expect(mjolnir.inHand).toBe(true);
    });

    test('only Thor can lift it', () => {
        expect(() => mjolnir.liftBy('Loki')).toThrow();
    });
});

// Loki's interference - skipping tests
test.skip('performance test', () => {
    // "I'll test this later" - causes flaws
});

Reflection Questions

Click each card to reveal thoughts!

❓ Question 1

Brokk maintained steady work despite distractions. How do you maintain test discipline when deadlines pressure you?

❓ Question 2

One brief lapse created Mjolnir's only flaw. What flaws have you seen from skipping "just one test"?

❓ Question 3

Mjolnir was still mighty despite the flaw. When is "good enough with tests" better than "perfect but untested"?

What is Test-Driven Development?

TDD: Write tests BEFORE writing the code they test.

The TDD Cycle (Red-Green-Refactor):

  1. Red: Write a failing test
  2. Green: Write minimal code to make it pass
  3. Refactor: Improve the code while keeping tests passing
πŸ’‘ Key Insight: Tests define what your code should do before you write it!

Benefits of TDD

Remember: It's not about the syntax, it's about the TDD philosophy and mindset!

Introducing Jest

Jest is a delightful JavaScript testing framework with a focus on simplicity.

Why Jest?

# Install Jest
npm install --save-dev jest

Your First Jest Test

The Code

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

The Test

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

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

Run it:

npx jest

Common Jest Matchers

// Exact equality
expect(value).toBe(4);

// Object/array equality
expect(data).toEqual({name: 'Thor'});

// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();

// Numbers
expect(value).toBeGreaterThan(3);
expect(value).toBeLessThan(5);

// Strings
expect('team').toMatch(/tea/);

// Arrays
expect(list).toContain('milk');

// Exceptions
expect(() => throwError()).toThrow();

Organizing Tests

Use describe blocks to group related tests:

describe('Calculator', () => {
  describe('add', () => {
    test('adds positive numbers', () => {
      expect(add(2, 3)).toBe(5);
    });

    test('adds negative numbers', () => {
      expect(add(-2, -3)).toBe(-5);
    });
  });

  describe('subtract', () => {
    test('subtracts numbers', () => {
      expect(subtract(5, 3)).toBe(2);
    });
  });
});

Pure Functions Make Testing Easy

A pure function always returns the same output for the same input and has no side effects.

βœ… Pure Function

function add(a, b) {
  return a + b;
}

// Easy to test!
expect(add(2, 3)).toBe(5);

❌ Impure Function

let total = 0;
function addToTotal(num) {
  total += num;
  return total;
}

// Hard to test!

The Problem: Tightly Coupled Code

// Hard to test!
function guessingGame() {
  const magicNumber = 22;
  const guess = prompt('guess a number between 1 and 100!');
  if (guess > magicNumber) {
    alert('YOUR GUESS IS TOO BIG');
  } else if (guess < magicNumber) {
    alert('YOUR GUESS IS TOO SMALL');
  } else if (guess == magicNumber) {
    alert('YOU DID IT! πŸŽ‰');
  } else {
    return 'INVALID INPUT';
  }
}
⚠️ Problems: Mixes DOM interaction with logic. Can't test without a browser!

The Solution: Decouple!

// Easy to test!
function evaluateGuess(magicNumber, guess) {
  if (guess > magicNumber) {
    return 'YOUR GUESS IS TOO BIG';
  } else if (guess < magicNumber) {
    return 'YOUR GUESS IS TOO SMALL';
  } else if (guess == magicNumber) {
    return 'YOU DID IT! πŸŽ‰';
  } else {
    return 'INVALID INPUT';
  }
}

// DOM interaction separated
function guessingGame() {
  const magicNumber = 22;
  const guess = prompt('guess a number between 1 and 100!');
  const message = evaluateGuess(magicNumber, guess);
  alert(message);
}
πŸ’‘ Now we can test evaluateGuess without touching the DOM!

Mocking: Faking Dependencies

Mocking creates fake versions of functions that always behave exactly how you want for testing.

When to Mock:

Understanding Mocking

Watch this explanation of mocking in testing:

Mock Function Example

// Create a mock function
const mockCallback = jest.fn(x => x + 1);

// Use it
[1, 2, 3].forEach(mockCallback);

// Test it was called
expect(mockCallback).toHaveBeenCalledTimes(3);

// Test it was called with specific arguments
expect(mockCallback).toHaveBeenCalledWith(1);
expect(mockCallback).toHaveBeenCalledWith(2);
expect(mockCallback).toHaveBeenCalledWith(3);

// Test the results
expect(mockCallback.mock.results[0].value).toBe(2);
expect(mockCallback.mock.results[1].value).toBe(3);
expect(mockCallback.mock.results[2].value).toBe(4);

Testing Best Practices

Project: Testing Practice

Your Assignment:

Write tests for these functions, then make the tests pass!

  1. capitalize(string) - Returns string with first character capitalized
  2. reverseString(string) - Returns reversed string
  3. calculator - Object with add, subtract, divide, multiply
  4. caesarCipher(string, shift) - Caesar cipher with wrapping, case preservation, punctuation
  5. analyzeArray(array) - Returns object with average, min, max, length

Walk-Through: capitalize β€” Red

Write the test first in tests/capitalize.test.js. These will fail β€” that's the point!

const capitalize = require('../src/capitalize');

describe('capitalize', () => {
  test('capitalizes the first character of a string', () => {
    expect(capitalize('hello')).toBe('Hello');
  });

  test('does not change already-capitalized strings', () => {
    expect(capitalize('World')).toBe('World');
  });

  test('leaves a non-letter first character unchanged', () => {
    expect(capitalize('123abc')).toBe('123abc');
  });

  test('handles an empty string', () => {
    expect(capitalize('')).toBe('');
  });
});
πŸ’‘ Run npm test β€” you should see failures. Red is good at this stage!

Walk-Through: capitalize β€” Green

Now write src/capitalize.js to make the tests pass:

function capitalize(string) {
  if (string.length === 0) return string;
  return string[0].toUpperCase() + string.slice(1);
}

module.exports = capitalize;

Edge case first β€” Return early if empty to avoid string[0] being undefined.

string[0].toUpperCase() β€” Uppercases only the first character.

string.slice(1) β€” Returns all characters from index 1 onward, unchanged.

πŸ’‘ Run npm test β€” all green!

Walk-Through: reverseString β€” Red

Write the test in tests/reverseString.test.js:

const reverseString = require('../src/reverseString');

describe('reverseString', () => {
  test('reverses a simple string', () => {
    expect(reverseString('hello')).toBe('olleh');
  });

  test('reverses a string with spaces', () => {
    expect(reverseString('hello world')).toBe('dlrow olleh');
  });

  test('returns an empty string unchanged', () => {
    expect(reverseString('')).toBe('');
  });

  test('a single character is unchanged', () => {
    expect(reverseString('a')).toBe('a');
  });
});
πŸ’‘ Run npm test β€” watch it fail. Red first!

Walk-Through: reverseString β€” Green

Now write src/reverseString.js to make the tests pass:

function reverseString(string) {
  return string.split('').reverse().join('');
}

module.exports = reverseString;

.split('') β€” Splits the string into an array of individual characters.

.reverse() β€” Reverses the array in place.

.join('') β€” Joins the array back into a string with no separator.

πŸ’‘ Run npm test β€” all green!

Project: Battleship

Build the classic Battleship game using TDD from the start!

Step-by-Step TDD Approach:

  1. Create Ship class with hit(index) and isSunk()
  2. Create Gameboard to place ships and receive shots
  3. Create Player class for real and computer players
  4. Implement game logic with event listeners
  5. Add UI to display gameboards
  6. Implement ship placement system

Let's Build It Together

Battleship β€” TDD from the Ground Up

Three Classes to Build:

  1. Ship β€” name, position, hits, isSunk
  2. Gameboard β€” 100-cell array, place ships, receive shots
  3. Player β€” fire shots, prevent duplicates

Coordinate System: A flat 0–99 index for the 10Γ—10 grid. Formula: index = row Γ— 10 + col. So row 2, col 1 β†’ index 21. Moving right = +1, moving down = +10.

Our cycle: Write test β†’ Watch it fail β†’ Write code β†’ Watch it pass

File Structure:

battleship/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ Ship.js
β”‚   β”œβ”€β”€ Gameboard.js
β”‚   └── Player.js
β”œβ”€β”€ tests/
β”‚   β”œβ”€β”€ Ship.test.js
β”‚   β”œβ”€β”€ Gameboard.test.js
β”‚   └── Player.test.js
└── package.json

Step 1: Project Setup

Run these commands to get started:

mkdir battleship && cd battleship
npm init -y
npm install --save-dev jest
mkdir src tests

Then update the "scripts" section of package.json:

{
  "scripts": {
    "test": "jest"
  }
}
πŸ’‘ Run npm test now β€” no tests found yet, and that's okay!

Step 2: Ship Tests β€” Red

Write this in tests/Ship.test.js. These will all fail right now!

const Ship = require('../src/Ship');

describe('Ship', () => {
  test('has the correct name and position', () => {
    const ship = new Ship('destroyer', [20, 21, 22]); // row 2, cols 0–2
    expect(ship.name).toBe('destroyer');
    expect(ship.position).toEqual([20, 21, 22]);
  });

  test('starts with no hits', () => {
    const ship = new Ship('destroyer', [20, 21, 22]);
    expect(ship.hits).toEqual([]);
  });

  test('records a hit at a specific index', () => {
    const ship = new Ship('destroyer', [20, 21, 22]);
    ship.hit(21);
    expect(ship.hits).toContain(21);
  });

  test('is not sunk when partially hit', () => {
    const ship = new Ship('destroyer', [20, 21, 22]);
    ship.hit(20);
    expect(ship.isSunk()).toBe(false);
  });

  test('is sunk when all positions are hit', () => {
    const ship = new Ship('patrol boat', [30, 31]);
    ship.hit(30); ship.hit(31);
    expect(ship.isSunk()).toBe(true);
  });
});

Step 3: Ship Implementation β€” Green

Write this in src/Ship.js to make your tests pass:

class Ship {
  constructor(name, position) {
    this.name = name;
    this.position = position;
    this.hits = [];
  }

  hit(index) {
    this.hits.push(index);
  }

  isSunk() {
    return this.hits.length >= this.position.length;
  }
}

module.exports = Ship;

position β€” Array of 0–99 indices the ship occupies. Its length is the ship's size.

hit(index) β€” Stores the exact index hit, not just a count. Useful for knowing which cells were struck.

isSunk() β€” Compares array lengths: sunk when the hits count matches the position count.

Run npm test β€” all 5 tests should pass! βœ…

Step 4: Gameboard Tests β€” Red

Write this in tests/Gameboard.test.js:

const Gameboard = require('../src/Gameboard');
const Ship = require('../src/Ship');

describe('Gameboard', () => {
  test('initializes with 100 empty cells', () => {
    const board = new Gameboard();
    expect(board.board.length).toBe(100);
    expect(board.board[0]).toEqual({ hasShip: null, isShot: false });
  });

  test('places a ship at its position indices', () => {
    const board = new Gameboard();
    const ship = new Ship('patrol boat', [0, 1]);
    board.placeShip(ship);
    expect(board.board[0].hasShip).toBe(ship);
  });

  test('records a hit when shot lands on a ship', () => {
    const board = new Gameboard();
    const ship = new Ship('patrol boat', [5, 6]);
    board.placeShip(ship);
    board.receiveShot(5);
    expect(ship.hits).toContain(5);
  });

  test('marks missed shots on the opponent board', () => {
    const board = new Gameboard();
    board.receiveShot(42);
    expect(board.opponentBoard()[42]).toBe('miss');
  });

  test('knows when all ships are sunk', () => {
    const board = new Gameboard();
    const ship = new Ship('patrol boat', [0, 1]);
    board.placeShip(ship);
    board.receiveShot(0); board.receiveShot(1);
    expect(board.allSunk()).toBe(true);
  });
});

Step 5: Gameboard Implementation β€” Green

Write this in src/Gameboard.js:

class Gameboard {
  constructor() {
    this.board = Array.from({ length: 100 }, () => ({
      hasShip: null, isShot: false
    }));
    this.ships = [];
  }

  placeShip(ship) {
    ship.position.forEach(index => {
      this.board[index].hasShip = ship;
    });
    this.ships.push(ship);
  }

  receiveShot(index) {
    const cell = this.board[index];
    cell.isShot = true;
    if (cell.hasShip) cell.hasShip.hit(index);
  }

  opponentBoard() {
    return this.board.map(cell => {
      if (cell.isShot && cell.hasShip) return 'hit';
      if (cell.isShot) return 'miss';
      return 'empty';
    });
  }

  allSunk() {
    return this.ships.every(ship => ship.isSunk());
  }
}
module.exports = Gameboard;

Array.from + factory β€” Creates 100 independent objects. Using .fill({}) would give every cell the same object reference β€” mutating one would mutate all!

placeShip β€” The ship already knows where it goes via position. We just mark those cells.

receiveShot β€” Sets isShot = true on the cell, then tells the ship if it was hit.

opponentBoard β€” Hides ship locations. Returns only what's been revealed. This is what the AI reads!

Step 6: Player Tests β€” Red

Write this in tests/Player.test.js:

const Player = require('../src/Player');
const Gameboard = require('../src/Gameboard');
const Ship = require('../src/Ship');

describe('Player', () => {
  test('can fire a shot at an enemy board', () => {
    const player = new Player('Alice');
    const enemy = new Gameboard();
    const ship = new Ship('patrol boat', [0, 1]);
    enemy.placeShip(ship);
    player.fireShot(0, enemy);
    expect(ship.hits).toContain(0);
  });

  test('cannot fire at the same cell twice', () => {
    const player = new Player('Alice');
    const enemy = new Gameboard();
    player.fireShot(5, enemy);
    const result = player.fireShot(5, enemy);
    expect(result).toBe(false);
  });

  test('computer fires at a random empty cell', () => {
    const computer = new Player('CPU', true);
    const enemy = new Gameboard();
    computer.randomShot(enemy);
    const shots = enemy.opponentBoard().filter(c => c !== 'empty');
    expect(shots.length).toBe(1);
  });
});

Step 7: Player Implementation β€” Green

Write this in src/Player.js:

const Gameboard = require('./Gameboard');

class Player {
  constructor(name, isComputer = false) {
    this.name = name;
    this.isComputer = isComputer;
    this.gameboard = new Gameboard();
  }

  fireShot(index, enemyBoard) {
    const visible = enemyBoard.opponentBoard();
    if (visible[index] === 'empty') {
      enemyBoard.receiveShot(index);
      return true;
    }
    return false;
  }

  randomShot(enemyBoard) {
    const visible = enemyBoard.opponentBoard();
    const available = visible
      .map((cell, i) => cell === 'empty' ? i : null)
      .filter(i => i !== null);
    const index = available[
      Math.floor(Math.random() * available.length)
    ];
    return this.fireShot(index, enemyBoard);
  }
}

module.exports = Player;

Default parameter β€” isComputer = false means human by default. Pass true for the AI.

fireShot β€” Reads opponentBoard() first. Returns false if already shot β€” the rules enforce themselves!

randomShot β€” Builds a list of available indices, picks one at random. Can't repeat because fireShot rejects non-empty cells.

Run npm test β€” all tests green! Core is complete! βœ…

Battleship Project Tips

⚠️ Do NOT test the DOM! Separate application logic from DOM manipulation.

Remember:

πŸ’‘ Extra Credit: Drag & drop ships, 2-player mode, smarter AI!

Knowledge Check

Click each card to reveal the answer!

❓ Question 1

What are the benefits of TDD?

❓ Question 2

What are some common Jest matchers?

Knowledge Check (continued)

❓ Question 3

What is tightly coupled code?

❓ Question 4

What are the two requirements for a function to be pure?

❓ Question 5

What is mocking and when would you use it?

Today's Takeaways

You Can Now:

πŸ“… Next Class: Midterm Review

πŸ“ Homework: Testing Practice project & Battleship (TDD!)