Testing and defensive design

Imagine you have written a program that works out a student's grade from their exam score. You try it once with a score of 72, it prints "Merit", and you happily hand it in. But what happens when someone types 100? Or 0? Or -5? Or the word "banana"? If you never checked, you simply don't know — and the first person to find out will be a user, at the worst possible moment.

Testing is the discipline of trying your program out on purpose, with carefully chosen data, so that you catch the errors before your users do. Defensive design is the flip side: writing the program so that when bad or surprising data arrives, it copes sensibly instead of crashing or giving nonsense. Good programmers assume things will go wrong — and build for it.

Choosing good test data: normal, boundary, erroneous

You can't try every possible input — there are far too many. The trick is to pick a small set of clever inputs that between them cover all the interesting cases. For any rule with a range of valid values, there are three kinds of test data you should always include:

A test isn't just an input, though — it's an input plus the answer you expect. You decide what should happen before you run it, then compare that expected result with the actual result the program gives. If they match, the test passes; if they differ, you've found a bug.

Testing in action: a table of test cases

Here is a grade(score) function. The rule is: a score from 0 to 100 earns a grade, and anything outside that range is invalid. Rather than test it by hand one score at a time, we run a whole table of test cases — normal, boundary and erroneous — and print each one as input → expected vs actual, with a tick or cross. Press Run.

function grade(score: number): string { if (score < 0 || score > 100) return "INVALID"; if (score >= 70) return "Distinction"; if (score >= 50) return "Merit"; if (score >= 40) return "Pass"; return "Fail"; } // Each test case: an input, and the result we EXPECT before running. const tests = [ { kind: "normal", score: 55, expected: "Merit" }, { kind: "normal", score: 82, expected: "Distinction" }, { kind: "boundary", score: 0, expected: "Fail" }, { kind: "boundary", score: 100, expected: "Distinction" }, { kind: "boundary", score: 70, expected: "Distinction" }, { kind: "boundary", score: 40, expected: "Pass" }, { kind: "erroneous", score: -5, expected: "INVALID" }, { kind: "erroneous", score: 101, expected: "INVALID" }, ]; let passed = 0; for (const t of tests) { const actual = grade(t.score); const ok = actual === t.expected; if (ok) passed++; const tick = ok ? "PASS" : "FAIL"; console.log( `[${tick}] ${t.kind.padEnd(9)} score=${String(t.score).padStart(4)} ` + `expected=${t.expected.padEnd(12)} actual=${actual}` ); } console.log(`\n${passed} / ${tests.length} tests passed.`);

Every case passes here — but notice how the table forces you to think. Writing the boundary rows made you ask "what should a score of exactly 70 give?" That single question is worth more than a hundred casual runs with random numbers.

Defensive design: expecting the unexpected

Testing finds problems; defensive design stops them happening in the first place. The core idea is simple: never trust the input. Assume a user will type something you didn't expect — because eventually one will. Defensive habits include:

Compare a naive and a defensive version of an age check. The naive one assumes it always gets a sensible number; the defensive one validates first. Run it and see how they behave on the same awkward inputs:

// Naive: trusts the input completely. function isAdultNaive(age: number): boolean { return age >= 18; } // Defensive: validates BEFORE deciding. Rejects nonsense instead of guessing. function isAdult(age: number): boolean { if (!Number.isInteger(age)) return false; // not a whole number? reject if (age < 0 || age > 120) return false; // impossible age? reject return age >= 18; } const inputs = [25, 17, 18, -4, 999, 3.5]; for (const a of inputs) { console.log(`age=${String(a).padStart(4)} naive=${isAdultNaive(a)} defensive=${isAdult(a)}`); }

The naive version cheerfully calls an age of 999 an adult and a negative age a child — it answers a question that never made sense. The defensive version refuses the impossible inputs. That refusal is the correct behaviour: a program that says "no, that's not valid" is far safer than one that quietly makes something up.

Suppose a settings function should use a font size the user gives, but fall back to 12 if they leave it blank. A defensive default handles the missing case in one clear line:

function fontSize(chosen?: number): number { // If nothing sensible was given, fall back to a safe default. if (chosen === undefined || chosen <= 0) return 12; return chosen; } console.log(fontSize(18)); // user chose 18 console.log(fontSize(undefined)); // nothing chosen → default console.log(fontSize(-3)); // silly value → default

The program never ends up with a broken font size of 0 or nothing at all — it always has something reasonable to work with.

Boundary values are where bugs breed. The commonest mistakes in all of programming are "off-by-one" errors: writing > when you meant >=, or letting a range start at the wrong end. These almost never show up with normal middle-of-the-range data — they only bite exactly at the edges. So for every boundary you must test three values: the edge itself, one step inside it, and one step outside it. For a valid range of 0 to 100, that means testing 0 and 100 (the edges), plus −1 and 101 (just outside), plus 1 and 99 (just inside).

And beware the opposite trap: passing a few tests does not prove your program is correct. Testing can show that bugs are present, but it can never prove they're absent — you only ever tried a handful of the infinitely many possible inputs. Green ticks mean "I haven't found a problem yet", not "there are no problems". Choosing thorough test data, especially at the boundaries, is what turns "seems to work" into real confidence.