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:
- Normal data — typical, valid values from the middle of the allowed range.
The everyday case the program is meant for. (e.g. an exam score of 55.)
- Boundary data — values sitting right on the edges of what is
allowed: the smallest and largest valid values, and the values just outside those
edges. This is where mistakes hide. (e.g. scores of 0 and 100, and −1 and 101.)
- Erroneous data — values that are clearly invalid and should be
rejected: out of range, the wrong type, or missing entirely. (e.g. −5, 1000, or
"banana".)
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:
- Validating input — check data is present, the right type, and in range
before you use it, and reject it clearly if not.
- Sensible defaults — when something is missing, fall back to a safe,
predictable value rather than crashing.
- Clear structure — small, well-named functions with obvious jobs, so bugs
have fewer places to hide and other people can read your code.
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.