Free QA template
Test plan
Scope, risks, environments and entry/exit criteria as data, with checks that every risk has tests.
It opens with this template loaded and runs in your browser. You don't need an account to use it; a free account saves your work and exports it.
The job it does
At the start of a release or a feature: agree what will be tested, how, and when testing is done. It comes with its own checks, so after you change it you can see at a glance that it still does the job.
What's inside
This is the template exactly as the QA Lab opens it, read-only. Open it to run it, change it and export it.
- 3 files
- Runs from main.js
- JavaScript
At the start of a release or a feature, agree what will be tested, how, and when testing is done. The plan is data in plan.js: scope in and out, environments, risks rated by likelihood and impact, the test cases that cover each risk, and entry and exit criteria.
The tests keep the plan honest (every risk has cases, no orphan cases, every case has steps and an expected result), and Run prints it as Markdown for the wiki. Replace the feature, risks and cases with yours.
- All my tests pass
- Output contains “## Exit criteria”
- No uncaught errors
import { PLAN, CASES } from './plan';
const md = [
'# Test plan: ' + PLAN.feature + ' (' + PLAN.release + ')',
'## In scope', ...PLAN.scope.in.map((s) => '- ' + s),
'## Out of scope', ...PLAN.scope.out.map((s) => '- ' + s),
'## Risks', ...PLAN.risks.map((r) => '- ' + r.id + ' ' + r.risk + ' (' + r.likelihood + ' / ' + r.impact + '): ' + r.cases.join(', ')),
'## Cases', ...CASES.map((c) => '- ' + c.id + ' ' + c.title + ': ' + c.expected),
'## Entry criteria', ...PLAN.entry.map((s) => '- [ ] ' + s),
'## Exit criteria', ...PLAN.exit.map((s) => '- [ ] ' + s),
];
console.log(md.join('\n'));
// Test plan as data: what is tested, what is not, the risks, and when testing is done.
// Review it like code; the tests below check it is complete.
export const PLAN = {
feature: 'Click & collect: book a pickup slot at checkout',
release: '2026.03',
scope: {
in: ['slot picker on web and app', 'booking and cancelling', 'store pick list', 'slot capacity'],
out: ['delivery orders (unchanged)', 'payment provider (contract tested by the payments team)'],
},
environments: ['staging with the March price list', 'Android 10 on a low-end phone', 'iPhone, latest iOS', 'Chrome and Safari'],
risks: [
{ id: 'R1', risk: 'Two customers book the last place in a slot', likelihood: 'medium', impact: 'high', cases: ['TC1', 'TC2'] },
{ id: 'R2', risk: 'A cancelled booking does not free its place', likelihood: 'medium', impact: 'medium', cases: ['TC3'] },
{ id: 'R3', risk: 'Slots show in the wrong time zone', likelihood: 'low', impact: 'high', cases: ['TC4'] },
{ id: 'R4', risk: 'The store pick list misses late bookings', likelihood: 'medium', impact: 'high', cases: ['TC5'] },
],
entry: ['feature complete on staging', 'unit tests green in CI', 'test data loaded'],
exit: ['every high-impact risk has passing cases', 'no open severity 1 or 2 bugs', 'regression suite green twice in a row'],
};
export const CASES = [
{ id: 'TC1', title: 'Last place: two bookings at the same time', steps: ['Open a slot with 1 place on two phones', 'Book on both at once'], expected: 'One booking succeeds, the other is told the slot is full' },
{ id: 'TC2', title: 'Full slot is not offered', steps: ['Fill a slot', 'Open the slot picker'], expected: 'The slot shows as full and cannot be picked' },
{ id: 'TC3', title: 'Cancel frees the place', steps: ['Book the last place', 'Cancel it', 'Open the slot picker'], expected: 'The slot has 1 place again' },
{ id: 'TC4', title: 'Slot times in the store time zone', steps: ['Set the phone to another time zone', 'Open the slot picker'], expected: 'Times are shown in the store time zone, labelled' },
{ id: 'TC5', title: 'Late booking reaches the pick list', steps: ['Book 5 minutes before the cut-off', 'Open the store pick list'], expected: 'The order is on the list for its slot' },
];
import { PLAN, CASES } from './plan';
const ids = new Set(CASES.map((c) => c.id));
test('every risk has at least one test case', () => {
expect(PLAN.risks.filter((r) => !r.cases.length).map((r) => r.id)).toEqual([]);
});
test('every case a risk names exists', () => {
expect(PLAN.risks.flatMap((r) => r.cases).filter((id) => !ids.has(id))).toEqual([]);
});
test('every case has steps and an expected result', () => {
expect(CASES.filter((c) => !c.steps.length || !c.expected).map((c) => c.id)).toEqual([]);
});
test('no case is orphaned (each covers a risk)', () => {
const used = new Set(PLAN.risks.flatMap((r) => r.cases));
expect(CASES.filter((c) => !used.has(c.id)).map((c) => c.id)).toEqual([]);
});
test('scope, environments, entry and exit criteria are written down', () => {
expect(PLAN.scope.in.length > 0 && PLAN.scope.out.length > 0 && PLAN.environments.length > 0 && PLAN.entry.length > 0 && PLAN.exit.length > 0).toBe(true);
});
The lesson behind it
Learn why it works, with a podcast and a quiz for each lesson.
More QA templates
Regression suite
Tests grouped by feature with a smoke tag, a fixture builder and a test for every bug fixed before.
Bug-report template
A bug report as data (steps, expected, actual, severity) with a failing test that reproduces it.