Why this matters: this is the question I started from. We have a large product, we keep shipping, and the honest answer to “is this tested” is usually “someone clicked through it once”. This note explains how that happened and how to make it structurally impossible from here on.
Nobody decided to skip testing
That is the part worth understanding. There was never a meeting where we chose to ship untested features. What happened is that testing sat outside the definition of finished, so under pressure it was the thing that quietly did not happen, two hundred separate times.
The mechanism, every time:
flowchart TD
A["Requirement is vague"]
B["No acceptance criteria written"]
C["Nothing concrete to write test cases from"]
D["Testing becomes "click around and see if it looks right""]
E["Sprint is tight, the clicking gets shorter"]
F["Feature ships"]
G["Six months later: what is this supposed to do? nobody knows"]
A --> B
B --> C
C --> D
D --> E
E --> F
F --> G
Every arrow in that chain is fixable, but the first one causes all the rest. This is the same root cause described in Why We Miss Deadlines, seen from the quality side.
The chain that fixes it
flowchart LR
A["Story"]
B["Acceptance Criteria"]
C["Test Cases"]
D["Automated regression"]
E["Living documentation"]
A --> B
B --> C
C --> D
D --> E
Read that left to right and notice that the expensive thinking happens in box two, during refinement, before any code exists. By the time you reach test cases, you are not inventing anything, you are transcribing decisions you already made.
That is the entire trick. Test cases feel expensive when you write them from a finished screen, reverse engineering intent. They are nearly free when they fall out of Acceptance Criteria that were written during refinement.
A Story with six acceptance criteria generates at least six test cases, and they existed in draft form before the first line of code.
Make it impossible to skip
Two structural changes, and the point of both is that they remove the option rather than relying on discipline.
One: criteria are required to enter the sprint. No acceptance criteria, no sprint. That is Definition of Ready.
Two: test cases are required to leave the sprint. “Test cases written and linked to the Story” goes into Definition of Done. A Story without them is not finished, regardless of what the code does.
Once both are in place, a tight sprint can no longer produce untested features. It produces fewer features, which is visible, honest, and a decision a Product Owner can actually make. You have converted an invisible quality trade into a visible scope trade, and visible trades are the only kind you can manage.
What a test case actually looks like
Not “test the invoice screen”. That is a wish. A test case has a starting state, an action, and an expected result specific enough that two different people would agree on pass or fail.
TC-INV-014 Invoice cannot be saved when stock would go negative
Precondition: Item SKU-100 has 5 units in stock, negative stock not allowed
Steps: 1. Create an invoice for customer A
2. Add line item SKU-100, quantity 6
3. Click Save
Expected: Warning "Insufficient stock for SKU-100 (available: 5)" is shown.
The invoice is not saved. Stock remains 5.
Traces to: Story #1423, acceptance criterion 1
That last line matters more than it looks. Traceability is what lets you answer, at any time: which acceptance criterion does this test prove, and which Story introduced this behaviour. Without it, in a year you have a pile of test cases and no idea which are still relevant. Azure DevOps Test Plans link test cases directly to work items, which is covered in Azure DevOps Setup.
Where tests should live
Not everything should be a manual test case, and not everything should be an end to end automated test. The usual shape, and the reason for it:
Unit tests cover the logic: tax calculation, stock arithmetic, date handling, validation rules. Fast, cheap, and they should be the majority. Anything involving money, rates or rounding belongs here and belongs here thoroughly, because these are the bugs that are silently wrong rather than loudly broken.
Integration tests cover the pieces working together: the API endpoint plus the database plus the permission check. Slower, fewer, and this is where most real bugs actually live, because most real bugs are in the seams between components rather than inside them.
End to end tests cover the critical user journeys through the real UI: log in, create an invoice, receive a payment. Slow and brittle, so keep the set small and only for journeys where failure is genuinely serious. Ten reliable end to end tests beat two hundred flaky ones, and a flaky suite is worse than no suite because people learn to ignore red.
Manual and exploratory testing covers what automation cannot: does this feel right, is the error message comprehensible, what happens if I do something strange. Do not try to automate exploration. A skilled tester poking at a new feature for thirty minutes finds things no written case would have caught, and those findings should then become written cases.
The edge case checklist
The complaint “nobody knows the corner cases” has a boring practical fix: use a checklist, because corner cases are surprisingly repetitive. Run any new Story through these questions during refinement:
Data boundaries. Empty, one, many, maximum. Zero, negative, very large numbers. Long strings, unicode, emoji, quotes and apostrophes in names.
State. What if it is already done, already deleted, already cancelled, in draft, locked, or being edited by someone else at the same time?
Time. Timezones. Month end and year end. Backdated entries. Leap years. Financial year boundaries. Records created before a feature existed.
Permissions. What does a user without rights see? Can they reach it by URL directly? What about a user from another organisation, or a customer’s data leaking across a tenant boundary?
Money, if you touch money. Rounding, currency conversion, tax edge cases, refunds, partial payments, reversals. Money bugs do not crash, they just quietly produce the wrong number, and that is far worse.
Failure. Third party API times out, returns an error, returns success but with garbage, or returns success twice. Network drops halfway. User double clicks Submit. Browser back button after a submit.
Volume. Works with 10 rows, does it work with 100,000? Does the screen still load, does the export still complete?
Migration. What happens to data that existed before this change? This is the one that is forgotten most often and hurts the most, because it only shows up in production where the old data lives.
Fifteen minutes with this list during refinement will find cases that would otherwise arrive as production bugs, and each one you find becomes an acceptance criterion, which becomes a test case. This is the practical answer to “why does nobody know the corner cases”: nobody was systematically asking.
Catching up on features that already have no tests
You cannot retrofit test cases for everything at once, and trying will fail. Prioritise by risk instead, which is the standard risk based testing approach:
risk = how likely is this to break × how bad is it if it does
Sort every existing feature into four buckets:
- High risk, changes often: automate first. This is where your regression suite earns its money.
- High risk, stable: write the test cases, automate when convenient. Payments, permissions, ledger postings and anything involving customer money belong here at minimum.
- Low risk, changes often: test cases written, automate if cheap.
- Low risk, stable: document the expected behaviour and move on. Honestly, you may never automate these, and that is a legitimate decision as long as it is a decision.
Then give it a fixed budget: one feature per sprint gets its behaviour written down and its critical path automated. Not a testing project, not a testing sprint, just a permanent small slice. Twenty sprints later you have covered twenty features, which is roughly your whole product, and nobody ever had to ask for a three month quality initiative that would never be approved.
Pick the first ones by looking at where bugs actually come from. Your bug history is a free map of where the risk is, and it is almost always concentrated in three or four areas.
The regression suite is the real asset
A test case that runs once is a checklist item. A test case that runs on every build is an asset that keeps paying, because it converts “I hope we did not break anything” into a fact you can read in two minutes.
That is what actually buys back development speed as the product grows. Without it, every release needs days of manual regression, so releases get bigger and rarer, so each one is riskier, so you need even more manual regression. With it, you release small changes often, and the DORA research is fairly clear that this is the pattern that correlates with both speed and stability. They are not a trade off.
Writing the behaviour down is half the value
Even when a test is not automated, the written case has value that outlives the sprint, because it is the only honest record of what the feature is supposed to do.
Code tells you what it does, including the bugs. A written test case tells you what it was supposed to do. That difference is the whole reason a new engineer can join and be productive, and it is the subject of Feature Inventory and Ownership.
This becomes much more important when AI is generating most of the code, because AI can produce a working implementation from a vague prompt without anybody ever articulating the expected behaviour. The code exists, and the specification never did. What that changes is in Building with AI.
A shorter version
- Missing test cases are a symptom of missing acceptance criteria, not of lazy testers.
- Write criteria at refinement, in Acceptance Criteria, and the test cases are nearly free.
- Put test cases in Definition of Done so they cannot be quietly skipped.
- Use the edge case checklist during refinement, because corner cases repeat.
- Attack existing untested features by risk, one per sprint, forever.
- Automate the critical paths so that speed later is possible at all.