CSCI 4513
Week 7, Lecture 13
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.
// 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
});
Click each card to reveal thoughts!
Brokk maintained steady work despite distractions. How do you maintain test discipline when deadlines pressure you?
Consider: Tests save time in the long run. Bugs found early are exponentially cheaper to fix. Skipping tests under pressure often creates more pressure later when bugs emerge in production.
One brief lapse created Mjolnir's only flaw. What flaws have you seen from skipping "just one test"?
Consider: The parts you skip testing are often where bugs hide. Edge cases, error handling, and "simple" functions often cause production issues when untested. One untested function can break an entire feature.
Mjolnir was still mighty despite the flaw. When is "good enough with tests" better than "perfect but untested"?
Consider: 80% test coverage with working code beats 0% coverage with "perfect" code that never ships. Start with critical paths tested, iterate to improve coverage. Shipping tested code creates value; pursuing perfection creates delay.
TDD: Write tests BEFORE writing the code they test.
Remember: It's not about the syntax, it's about the TDD philosophy and mindset!
Jest is a delightful JavaScript testing framework with a focus on simplicity.
# Install Jest
npm install --save-dev jest
// sum.js
function sum(a, b) {
return a + b;
}
module.exports = sum;
// sum.test.js
const sum = require('./sum');
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3);
});
npx jest
// 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();
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);
});
});
});
A pure function always returns the same output for the same input and has no side effects.
function add(a, b) {
return a + b;
}
// Easy to test!
expect(add(2, 3)).toBe(5);
let total = 0;
function addToTotal(num) {
total += num;
return total;
}
// Hard to test!
// 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';
}
}
// 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);
}
evaluateGuess without touching the DOM!
Mocking creates fake versions of functions that always behave exactly how you want for testing.
Watch this explanation of mocking in testing:
// 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);
Write tests for these functions, then make the tests pass!
capitalize(string) - Returns string with first character capitalizedreverseString(string) - Returns reversed stringcalculator - Object with add, subtract, divide, multiplycaesarCipher(string, shift) - Caesar cipher with wrapping, case preservation, punctuationanalyzeArray(array) - Returns object with average, min, max, lengthcapitalize β RedWrite 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('');
});
});
npm test β you should see failures. Red is good at this stage!
capitalize β GreenNow 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.
npm test β all green!
reverseString β RedWrite 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');
});
});
npm test β watch it fail. Red first!
reverseString β GreenNow 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.
npm test β all green!
Build the classic Battleship game using TDD from the start!
Ship class with hit(index) and isSunk()Gameboard to place ships and receive shotsPlayer class for real and computer playersCoordinate 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.
battleship/
βββ src/
β βββ Ship.js
β βββ Gameboard.js
β βββ Player.js
βββ tests/
β βββ Ship.test.js
β βββ Gameboard.test.js
β βββ Player.test.js
βββ package.json
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"
}
}
npm test now β no tests found yet, and that's okay!
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);
});
});
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.
npm test β all 5 tests should pass! β
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);
});
});
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!
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);
});
});
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.
npm test β all tests green! Core is complete! β
Click each card to reveal the answer!
What are the benefits of TDD?
Answer: Catches bugs early, encourages better architecture, provides living documentation, gives confidence to refactor, forces thinking about requirements first, and enables faster debugging.
What are some common Jest matchers?
Answer: toBe(), toEqual(), toBeTruthy(), toBeFalsy(), toBeGreaterThan(), toMatch(), toContain(), toThrow()
What is tightly coupled code?
Answer: Code where functions depend heavily on other functions, DOM methods, or external systems. It's hard to test because you can't isolate the function being tested.
What are the two requirements for a function to be pure?
Answer: 1) Always returns the same output for the same input, 2) Has no side effects (doesn't modify external state or interact with outside world).
What is mocking and when would you use it?
Answer: Mocking creates fake versions of functions that always behave how you want. Use it for external dependencies like API calls, database queries, DOM interactions, or time-dependent functions that you can't easily test otherwise.
π Next Class: Midterm Review
π Homework: Testing Practice project & Battleship (TDD!)