How to write a great test case
The anatomy of a test case (ID, preconditions, steps, test data, expected vs actual result), with real worked examples and what separates a good test case from a useless one.
A test case is a recipe. Hand it to someone who has never seen your app, and they should be able to follow it exactly and reach the same result you would. No guessing, no "you kind of click around here". A recipe that only works when the original chef is in the kitchen is a bad recipe, and a test case that only you can run is a bad test case.
In Foundations you learned that testing is asking software good questions. A test case is one of those questions, written down precisely enough that anyone can ask it and recognise a wrong answer. Let's take it apart piece by piece.
The anatomy of a test case
Most test cases share the same skeleton. You don't always fill in every field, but knowing the full list keeps you honest:
| Field | What it holds | Example |
|---|---|---|
| Test case ID | A short unique label so you can refer to it later. | TC-LOGIN-007 |
| Title | A one-line summary of what this checks. | Login fails with a wrong password |
| Preconditions | What must already be true before you start. | A registered account exists; you are on the login page. |
| Test steps | The exact actions, in order. | 1. Type a valid email. 2. Type a wrong password. 3. Click Log in. |
| Test data | The specific values you'll use. | Email: amy@test.com · Password: wrongpass123 |
| Expected result | What should happen if the software is correct. | An error "Email or password is incorrect" appears; you stay logged out. |
| Actual result | What did happen when you ran it. | (filled in at run time) |
| Status | Pass or Fail, decided by comparing the two above. | Pass / Fail |
The two fields that matter most are expected result and actual result. A test passes or fails purely by comparing them. If you can't state the expected result before you run the test, you don't have a test: you have a wander.
Worked example: a login screen
Let's write two real cases for a login form. The first is a positive case (we feed it good data and expect success). The second is a negative case (we feed it bad data and expect a graceful failure). You need both: software that only handles the happy path is software that will embarrass you.
| ID | Title | Steps | Test data | Expected result |
|---|---|---|---|---|
| TC-LOGIN-001 | Login succeeds with valid credentials | 1. Open login page. 2. Enter email. 3. Enter password. 4. Click Log in. | amy@test.com / Correct123! | User lands on the dashboard; their name shows in the header. |
| TC-LOGIN-007 | Login fails with a wrong password | 1. Open login page. 2. Enter valid email. 3. Enter a wrong password. 4. Click Log in. | amy@test.com / wrongpass | An inline error "Email or password is incorrect" appears; user stays on the login page. |
Notice TC-LOGIN-007 expects a vague-on-purpose message ("Email or password is incorrect") rather than "wrong password". That's a real security habit: telling an attacker which half they got right is a gift. A sharp tester checks not just that login fails, but that it fails the right way.
Worked example: an e-commerce coupon
Coupons are a goldmine of bugs because money is involved and the rules are fiddly. Imagine a code SAVE20 that gives 20% off orders over $50, expires at the end of the month, and can be used once per customer. Here's a small suite:
| ID | Title | Test data | Expected result |
|---|---|---|---|
| TC-COUPON-001 | Valid coupon applies on a qualifying order | Cart total $80 · code SAVE20 | Total drops to $64; a "20% off applied" note shows. |
| TC-COUPON-002 | Coupon rejected below the minimum spend | Cart total $40 · code SAVE20 | Code is refused with "Spend $50 to use this code"; total unchanged. |
| TC-COUPON-003 | Expired coupon is refused | Cart total $80 · code SAVE20 · system date next month | Code refused with "This code has expired"; total unchanged. |
| TC-COUPON-004 | Same coupon cannot be reused by one customer | Same account, second order, code SAVE20 again | Code refused with "You've already used this code"; total unchanged. |
Four small cases, and already you're covering the discount maths, the minimum-spend rule, the expiry, and the one-use limit. This is what people mean by coverage: not testing everything, but testing each rule that could plausibly break.
What separates a good test case from a bad one
- Clear. Anyone on the team can read it and run it the same way. No private context left in your head.
- Independent. It doesn't secretly depend on another test running first. If TC-3 only passes when TC-2 ran moments before, you'll get phantom failures forever.
- Reproducible. Same steps and data give the same result every time. If it passes on Tuesdays and fails on Fridays, the test data or setup is leaking.
- Specific. "Check the cart works" is a wish, not a test. "Adding a second unit updates the total to $128" is a test.
- Atomic. One case checks one thing. Bundle five checks together and a failure tells you almost nothing about which part broke.
Writing the expected result after running the app, copying down whatever it did and calling it "expected". That's not testing, that's note-taking. If the app already had a bug, you just enshrined the bug as correct. Always decide what should happen before you look.
- A test case is a recipe precise enough that anyone can follow it and reach the same result.
- The full skeleton: ID, title, preconditions, steps, test data, expected result, actual result, status.
- Expected vs actual is the heart of it: write the expected result before you run the test.
- Write both positive cases (good data, expect success) and negative cases (bad data, expect a graceful failure).
- Good test cases are clear, independent, reproducible, specific, and atomic: one check each.