The prompts that produce useful unit tests split the job in two: first ask the model to list edge cases in plain language without writing code, review and extend that list yourself, then ask for test code that names your framework, your file and an existing test to copy the style from. A single “write unit tests for this” request mostly yields happy-path tests, because nothing in it tells the model which inputs are dangerous. The extra step costs a minute and is where most of the value comes from.
This guide gives the prompts for each step, a worked example on a small Python function, prompts for legacy code, mocks and property-based tests, and a checklist for reviewing what the model hands back.
Why a one-line request gives weak tests
A unit test is a small automated check that calls one function or class in isolation and asserts on the result. Good unit tests are mostly about choosing inputs: the empty string, the zero, the value one past the limit, the malformed record. A request with no guidance tends to produce tests for the inputs the code was obviously written for, and those are the inputs least likely to break.
Three other problems show up with short prompts:
- Tests that assert the current behavior, bugs included. If the model only sees the implementation, it derives the expected values from that implementation. A test generated this way can lock in a bug instead of catching it.
- Mismatched style. The model guesses at the framework, assertion style and mocking library, so the file does not look like the rest of your suite and may not even run.
- Coverage without checking. Tests that call every branch but assert little can push a coverage number up while verifying almost nothing.
Separating reasoning from code generation addresses the first and third problems; supplying context addresses the second. Anthropic’s own guide to writing unit tests with Claude recommends the same split: plan inputs, outputs and edge cases first, then generate the test files, then run them and iterate.
Step 1: Ask for edge cases, not code
The first prompt asks for a categorized list and explicitly forbids code. A list is quick to read and correct; a 200-line test file is not.
Here is a function and the spec or docstring that describes what it
should do. Do not write any test code yet.
List the edge cases worth testing, grouped under these headings:
1. Valid inputs at the boundaries (minimum, maximum, just inside)
2. Invalid or malformed input (wrong type, empty, None, bad format)
3. Error handling (what should raise, and which exception)
4. State and environment (time, locale, ordering, shared state)
For each case give: the input, the expected result according to the
SPEC (not according to the code), and one line on why it matters.
If the spec does not say what should happen for a case, mark it
"UNSPECIFIED" instead of guessing from the implementation.
<spec>
PASTE DOCSTRING, TICKET OR REQUIREMENTS
</spec>
<code>
PASTE FUNCTION
</code>
Why each part is there:
- The fixed categories steer the model away from listing ten variations of the happy path. Adjust them to your domain; a pricing function might add “rounding and currency”.
- “According to the spec, not the code” is the line that stops the model from turning existing bugs into expected values.
- “UNSPECIFIED” surfaces gaps in the requirements. Each one is a question for you or the product owner, and answering it is often more valuable than the test itself.
- The tags keep the spec and code separate, so the model can compare them rather than blending them.
Then read the list. Delete cases that do not matter, and add the ones only you know about: the customer whose IDs contain a hyphen, the upstream system that sends dates as empty strings. That review is the human part of the workflow, and it is short.
Step 2: Ask for the test code with real context
The second prompt names everything the model would otherwise guess. Include an existing test file from the same project as a style reference; it is the single most effective way to get tests that look like they belong.
Write unit tests for the function above covering the edge cases in
the approved list below.
Project context:
- Language and version: Python 3.12
- Test framework: pytest 8, plain assert statements
- Test file location: tests/test_durations.py
- Style reference (copy naming, fixtures and structure from this):
<example_test>
PASTE AN EXISTING TEST FILE OR PART OF ONE
</example_test>
Rules:
- One test per behavior; use pytest.mark.parametrize for cases that
differ only by input and expected value.
- Test names describe the behavior: test_<unit>_<condition>_<result>.
- Assert on specific values or specific exception types, never just
"does not raise".
- For cases marked UNSPECIFIED, write the test with
pytest.mark.skip(reason="UNSPECIFIED: <question>") so the gap is
visible in the test report.
- Do not modify the function under test.
<approved_cases>
PASTE THE EDITED LIST FROM STEP 1
</approved_cases>
The parametrize rule keeps the file short. pytest’s parametrize decorator runs one test function once per argument set, so twenty boundary cases become one function and a table of inputs instead of twenty near-identical functions. If you use Jest, JUnit or another framework, swap in its equivalent (test.each, @ParameterizedTest) and name it explicitly; models follow named constructs more reliably than general advice.
The skip rule is a small trick worth keeping: unresolved questions show up in every test run until someone answers them, instead of living in a chat window you will close.
Worked example: a duration parser
This example is illustrative. Suppose a codebase has a function parse_duration(text) whose docstring says it converts strings like "1h30m" or "45s" into a number of seconds, accepts units h, m and s in that order, and raises ValueError on anything else.
A one-line prompt typically returns tests for "1h", "30m", "45s" and "1h30m". All useful, all likely to pass, none likely to find a bug.
The step 1 prompt produces a list closer to this (abridged):
| Category | Input | Expected per spec | Why it matters |
|---|---|---|---|
| Boundary | "0s" | 0 | Zero is a classic off-by-one source |
| Boundary | "1h0m0s" | 3600 | Explicit zero components |
| Invalid | "" | ValueError | Empty input is common from forms |
| Invalid | "30m1h" | ValueError | Spec says units must be in order |
| Invalid | "1.5h" | UNSPECIFIED | Spec is silent on decimals |
| Invalid | "1H" | UNSPECIFIED | Spec is silent on case |
| Invalid | " 1h " | UNSPECIFIED | Whitespace from user input |
| Invalid | None | UNSPECIFIED | TypeError or ValueError? |
Three of the eight cases are questions about the requirements, not about the code. That is a typical and useful outcome. Once you decide (say: reject decimals, accept uppercase, strip whitespace, raise TypeError for None), step 2 produces something like:
import pytest
from app.durations import parse_duration
@pytest.mark.parametrize(
"text, expected",
[
("45s", 45),
("30m", 1800),
("1h30m", 5400),
("0s", 0),
("1h0m0s", 3600),
("1H", 3600),
(" 1h ", 3600),
],
)
def test_parse_duration_valid_input_returns_seconds(text, expected):
assert parse_duration(text) == expected
@pytest.mark.parametrize("text", ["", "30m1h", "1.5h", "abc", "1x"])
def test_parse_duration_invalid_format_raises_value_error(text):
with pytest.raises(ValueError):
parse_duration(text)
def test_parse_duration_none_raises_type_error():
with pytest.raises(TypeError):
parse_duration(None)
If some of these fail, that is the point. A failing "1H" test means the code does not match the decision you just made, which is exactly what the test exists to tell you. When a failure is confusing, the prompts in debugging Python code with AI help trace where the value goes wrong.
Prompts for harder situations
Legacy code you are about to refactor
When code has no tests and you want a safety net before changing it, the goal flips: you want tests that pin current behavior, bugs included, so a refactor does not change anything by accident. These are often called characterization tests. Say so explicitly.
I am about to refactor the code below and need characterization tests
that capture its CURRENT behavior, including behavior that looks wrong.
- Do not change any existing logic, even if you see a bug.
- For anything that looks like a bug, still assert the current output,
and add a comment "# SUSPECTED BUG: <reason>" above that test.
- After the tests, list the suspected bugs separately with line numbers.
The comment convention means the suspected bugs survive the refactor as visible notes rather than being silently fixed in the middle of it. For the refactor itself, see the prompts for refactoring legacy code.
Code with external dependencies
Models over-mock when left alone, replacing things that should run for real and asserting on internal calls that do not matter. Name the boundary and the kind of double you want. Martin Fowler’s article Mocks Aren’t Stubs gives the standard vocabulary: a stub returns canned answers, a fake is a working shortcut implementation such as an in-memory database, and a mock is set up with expectations about the calls it should receive.
Only replace these boundaries: the HTTP client in payments/gateway.py
and the system clock. Use a stub for the HTTP client that returns the
response bodies below, and freeze time at 2026-01-15T10:00:00Z.
Do not mock anything inside the payments package itself.
Assert on the function's return value and on the request body sent to
the gateway; do not assert on how many times internal helpers run.
Example gateway responses:
PASTE ONE SUCCESS AND ONE ERROR BODY
Realistic response bodies make a large difference. If you need many of them, the prompts for generating realistic test data and fixtures cover that separately.
Properties instead of examples
Example-based tests check specific inputs. Property-based tests state a rule that should hold for every input and let a library generate the inputs. In Python, Hypothesis is the standard library for this; it picks inputs across the range you describe, including awkward ones you would not think to write. Models are good at suggesting properties if you ask for them as a list first.
For the function above, list 3-5 properties that should hold for all
valid inputs (for example: round-trip, idempotence, ordering, bounds).
For each, say whether it is guaranteed by the spec or only by the
current implementation. Then write Hypothesis tests for the properties
guaranteed by the spec only.
For the duration parser, a natural property is a round trip: formatting a number of seconds as a duration string and parsing it back returns the original number.
Reviewing what the model gives you
Generated tests are a draft. Before committing them, check:
- Do the expected values come from the spec? Pick three tests and verify the expected value by hand.
- Would each test fail if the code were wrong? A quick check is to break the function on purpose (return 0, flip a comparison) and confirm tests go red. Mutation testing tools automate this idea.
- Are the assertions specific? Watch for
assert result is not Noneor a bare call with no assertion. - Is anything mocked that should not be? If the unit under test is itself mocked, the test checks nothing.
- Does it run in isolation and in any order? Shared state between tests is the most common cause of later flakiness.
- Does it fit the suite? Same naming, same fixtures, same folder, and it passes your linter.
High coverage is a side effect of good tests, not a goal to prompt for directly. If you ask for “90% coverage”, a model can satisfy that with shallow tests. Asking for specific edge cases, then checking coverage reports for branches nobody listed, gives better tests and usually decent coverage too.
Once tests are in place, a structured review pass with AI code review prompts, including its test-coverage gap prompt, is a useful second check on what is still missing.



