---
title: 'Writing Test Cases'
source: 'https://academia.sh/en/courses/testing-fundamentals/writing-test-scenarios'
course: 'Quality and Testing Fundamentals'
language: en
updated: '2026-08-23T14:25:18+00:00'
license: 'CC BY-SA 4.0'
---

# Writing Test Cases

A test case's identifier, precondition, steps, and expected-result parts, the criteria for a well-written test case, and the move from a written test case to an executable test.

The techniques settled which inputs to try. Being able to describe what was tried to
someone else is a separate task: what state does it start from, which steps are
followed, what is observed? An automated check carries this information inside code and
speaks only to whoever reads the code. A form is needed that a librarian, a reviewer, or
the analyst who wrote the rule can read.

This lesson builds that form and shows the move from the form to an executable test.

## Parts of a Test Case

A **test case** consists of six parts.

**Identifier** gives the test case a fixed number; a defect report and a regression set
refer to this number. **Title** states what the test case checks in a single sentence
and includes the expected outcome — whoever reads the title understands the test's
intent.

**Traceability link** shows which rule the test case comes from. The process-quality
measurement built in the first lesson depends exactly on this link: which rule has a
test case, and which does not?

**Precondition** states what state the system must be in for the test case to start.
Without a precondition a test case is not repeatable; running it twice gives two
different results.

**Steps** give the work to be done in order. Because a test case has a single purpose,
the step count is small; a long list of steps is usually several test cases stuck
together.

**Expected result** states what will be observed. It must be observable: something on
screen, a returned value, a saved row. "The system behaves correctly" is not an
observable result.

A **postcondition** can be added: the state the system is left in once the test case
finishes. When it is written, test cases do not depend on the state another one leaves
behind.

## Criteria for a Good Test Case

Two ways of writing the same rule as a test case can be compared. The weak version reads:
"A member in debt tries to borrow a book, the system gives the appropriate response."
This sentence has three flaws. How much debt is not stated, so the precondition is
ambiguous. Whether it is under or over the limit is unknown, so which equivalence class
it represents is ambiguous. "Appropriate response" is not an observable result, so
whether the test case passes or fails is left to the reader.

The strong version asks the same rule this way: precondition "a member whose debt is
exactly 20 units, with one active loan," step "the member wants to borrow a book sitting
on the shelf," expected result "the request is rejected and the debt limit is given as
the reason."

The criteria fall under four headings. **Single purpose**: one test case checks one
rule; two rules make two test cases, because it must be clear which one broke when it
fails. **Determinism**: it gives the same result with the same precondition; it does not
depend on today's date, a random value, or ordering. **Independence**: it does not rely
on the state left behind by another test case. **Observability**: it is something a
decision can be made about by looking at the result.

## Test Cases as Data

If a test case's structure is regular, it can be represented as data. This representation
delivers two gains: the test cases stay readable, and the same list produces both a
report and an executable test.

```js
// decision.mjs — loan decision with the boundary defect fixed
export const LOAN_LIMIT = { student: 10, member: 5 };
export const DEBT_LIMIT = 20;

export function loanDecision({ memberType, activeLoans, debt, reservedForAnother }) {
  if (!(memberType in LOAN_LIMIT)) throw new Error(`unknown member type: ${memberType}`);
  if (reservedForAnother) return 'reserved';
  if (debt >= DEBT_LIMIT) return 'debt';
  if (activeLoans >= LOAN_LIMIT[memberType]) return 'limit';
  return 'granted';
}
```

```js
// test-cases.mjs — written test cases represented as data
export const RULES = ['K3', 'K4', 'K13', 'K14', 'K15'];

export const TEST_CASES = [
  {
    id: 'TS-01', rule: 'K4',
    title: 'A member whose loan limit is full is not given a new book',
    precondition: { memberType: 'member', activeLoans: 5, debt: 0, reservedForAnother: false },
    step: 'The member wants to borrow a book sitting on the shelf',
    expected: 'limit',
  },
  {
    id: 'TS-02', rule: 'K13',
    title: 'A member whose debt has reached the limit is not given a book',
    precondition: { memberType: 'member', activeLoans: 1, debt: 20, reservedForAnother: false },
    step: 'The member wants to borrow a book sitting on the shelf',
    expected: 'debt',
  },
  {
    id: 'TS-03', rule: 'K14',
    title: 'A book reserved for someone else is not given out',
    precondition: { memberType: 'student', activeLoans: 0, debt: 0, reservedForAnother: true },
    step: 'The student wants to borrow the book on the reservation list',
    expected: 'reserved',
  },
  {
    id: 'TS-04', rule: 'K3',
    title: 'A student whose limit is not full is given the book',
    precondition: { memberType: 'student', activeLoans: 9, debt: 0, reservedForAnother: false },
    step: 'The student wants to borrow a book sitting on the shelf',
    expected: 'granted',
  },
  {
    id: 'TS-05', rule: 'K3',
    title: 'A student whose loan limit is full is not given a new book',
    precondition: { memberType: 'student', activeLoans: 10, debt: 0, reservedForAnother: false },
    step: 'The student wants to borrow a book sitting on the shelf',
    expected: 'limit',
  },
];
```

The driver that runs the list also produces the traceability report.

```js
// test-case-runner.mjs — running the test cases and the traceability report
import { loanDecision } from './decision.mjs';
import { TEST_CASES, RULES } from './test-cases.mjs';

let matched = 0;
for (const c of TEST_CASES) {
  const observed = loanDecision(c.precondition);
  const result = observed === c.expected ? 'matched' : `DEVIATION (observed ${observed})`;
  console.log(`${c.id} [${c.rule}] ${result}: ${c.title}`);
  if (observed === c.expected) matched += 1;
}

const covered = new Set(TEST_CASES.map((c) => c.rule));
const uncovered = RULES.filter((r) => !covered.has(r));
console.log(`result: ${matched}/${TEST_CASES.length} test cases matched`);
console.log(`traceability: ${covered.size} of ${RULES.length} rules have a test case`);
console.log(`rules without a test case: ${uncovered.join(', ')}`);
```

```
TS-01 [K4] matched: A member whose loan limit is full is not given a new book
TS-02 [K13] matched: A member whose debt has reached the limit is not given a book
TS-03 [K14] matched: A book reserved for someone else is not given out
TS-04 [K3] matched: A student whose limit is not full is given the book
TS-05 [K3] matched: A student whose loan limit is full is not given a new book
result: 5/5 test cases matched
traceability: 4 of 5 rules have a test case
rules without a test case: K15
```

All five of the five test cases matched, but the last line repeats the warning from the
first lesson: the off-branch return rule has no test case at all. Because the test case
list is written down, this gap has turned into a countable size.

## From a Test Case to an Executable Test

The same list can also produce the tests to hand to a runner. The block below walks the
test case list and defines one test per test case, then runs it with Node's built-in
runner. Because the duration fields in the output depend on the machine, only the count
lines are filtered through.

```bash
cat > test-cases.test.mjs <<'EOF'
import test from 'node:test';
import assert from 'node:assert/strict';
import { loanDecision } from './decision.mjs';
import { TEST_CASES } from './test-cases.mjs';

for (const c of TEST_CASES) {
  test(`${c.id} ${c.title}`, () => {
    assert.equal(loanDecision(c.precondition), c.expected);
  });
}
EOF
node --test test-cases.test.mjs 2>&1 | grep -E '^ℹ (tests|pass|fail) '
```

```
ℹ tests 5
ℹ pass 5
ℹ fail 0
```

The move is nothing more than three matchings: precondition to setup data, step to the
call, expected result to the assertion. If a test case does not carry these three parts,
it cannot be turned into an executable test — and if it cannot be turned into one, it
carries the same ambiguity when run by hand.

That the test name starts with the test case identifier is deliberate. The first thing
read when a test fails is which test case broke; the identifier keeps the path from
defect report to test case, and from test case to rule, open.

## Where a Manually Run Test Case Belongs

Not every test case turns into an automated test. Whether the librarian finds the screen
layout understandable, whether a warning message is clear enough — these are checked
with a written test case, but they require human judgment.

There is one more kind: **exploratory testing**. Here no test case is written in
advance; the person testing chooses the next try based on the result of the previous
one. Exploratory testing does not replace a written test case, it fills its gap — the
question the fourth lesson mentioned as often forgotten is usually found this way. Once
found, it is turned into a written test case and joins the regression set.

## Summary

- A test case consists of identifier, title, traceability link, precondition, steps, and
  expected result; a postcondition keeps test cases independent of one another.
- The criteria for a good test case are single purpose, determinism, independence, and
  an observable result; "the system gives the appropriate response" is not an observable
  result.
- When test cases are represented as data, the same list produces both a traceability
  report and an executable test.
- The move from test case to test is three matchings: precondition to setup, step to
  call, expected result to assertion.
- In the example all five test cases matched, but it emerged in a countable way that one
  of five rules had no test case at all.
- Exploratory testing does not replace a written test case; what it finds is turned into
  a written test case and joins the regression set.

## Next Step

A test case's most critical field was passed over quietly: where does the expected
result come from? In the five test cases above the values were read from the
specification, because the rule was written down. If the rule is not written down, if
the computation is too complex to check by hand, or if no one knows the correct result,
how is the expected result determined? The next lesson takes up this question and
builds the source of the expected result with three separate methods, then compares
them.
