Using AI to Write Unit Tests: A Practical Workflow That Works
A step-by-step workflow for generating useful unit tests with AI assistants: list behaviors first, generate, run, mutate and review — with pytest examples.
· 6 min read
Contents
- The problem with "write tests for this"
- Step 1: List behaviors before any code
- Step 2: Generate tests from the agreed list
- Step 3: Run them and read every failure
- Step 4: Check that the tests can fail
- Step 5: Review like any other code
- Prompts for specific situations
- Legacy code with no tests: characterization tests
- Reproduce a bug first
- Property-based tests
- Testing code with dependencies
- What AI-generated tests are good and bad at
- FAQ
- Should I let AI write tests and code at the same time?
- Is 100% coverage a good target?
- Which assistant is best for writing tests?
- Further reading
Writing tests is one of the tasks AI coding assistants handle best: the code under test gives the model concrete context, the output is easy to run, and failures are obvious. But "write tests for this file" usually produces a pile of shallow tests that pass without protecting much.
This workflow fixes that. It treats the AI as a fast pair programmer while you stay in charge of what gets tested.
The problem with "write tests for this"
A one-line request typically gives you tests that:
- Mirror the implementation line by line, so they break on every refactor but never catch a logic error.
- Only cover the happy path.
- Assert that the code does what it currently does — including its bugs.
- Mock so much that nothing real is tested.
High coverage numbers from tests like these are misleading. The goal is tests that fail when behavior breaks.
Step 1: List behaviors before any code
Start by asking for a list of behaviors and edge cases, not tests:
Here is a function. Before writing any tests, list the behaviors it should have,
including edge cases and error cases. Group them as: normal cases, boundaries,
invalid input, and anything that looks like a bug in the current implementation.
Don't write code yet.
Example function:
def apply_discount(price: float, percent: float) -> float:
"""Return the price after a percentage discount, rounded to 2 decimals."""
if percent < 0 or percent > 100:
raise ValueError("percent must be between 0 and 100")
return round(price * (1 - percent / 100), 2)
A good behavior list includes things like: 0% returns the original price; 100% returns 0; values are rounded to cents; negative and over-100 percentages raise; and a question worth flagging — what about a negative price? The function silently accepts it.
This is the most valuable step. Edit the list: remove what does not matter, add business rules the model cannot know, and decide what the correct behavior is for open questions.
Step 2: Generate tests from the agreed list
Write pytest tests for exactly these behaviors: <paste your edited list>.
Rules:
- Test behavior through the public function, not internal details.
- Use pytest.mark.parametrize for similar cases.
- One clear assertion per behavior; descriptive test names.
- No mocks unless a dependency does I/O.
Typical result:
import pytest
from pricing import apply_discount
@pytest.mark.parametrize(
"price, percent, expected",
[
(100.0, 0, 100.0),
(100.0, 25, 75.0),
(100.0, 100, 0.0),
(19.99, 15, 16.99), # rounding to cents
],
)
def test_apply_discount_returns_discounted_price(price, percent, expected):
assert apply_discount(price, percent) == expected
@pytest.mark.parametrize("percent", [-1, 100.01, 150])
def test_apply_discount_rejects_out_of_range_percent(percent):
with pytest.raises(ValueError):
apply_discount(100.0, percent)
def test_apply_discount_rejects_negative_price():
with pytest.raises(ValueError):
apply_discount(-5.0, 10)
Step 3: Run them and read every failure
pytest -q
Failures fall into two groups:
- The test is wrong (a miscalculated expected value, a wrong import). Fix the test.
- The code is wrong — the most valuable outcome. Here,
test_apply_discount_rejects_negative_pricefails because the function accepts negative prices. That is a real decision for you: add validation, or delete the test if negative prices are intentional.
Never ask the AI to "make the tests pass" without checking which side is wrong. It will happily change the assertion to match the bug.
Step 4: Check that the tests can fail
A test that cannot fail is useless. Two quick checks:
- Break the code on purpose. Change
1 - percent / 100to1 + percent / 100and rerun. Several tests should fail. If they all pass, they are not testing the logic. - Use mutation testing for important modules. Tools like
mutmut(Python) or Stryker (JavaScript/TypeScript) make many small changes to your code automatically and report which ones your tests failed to catch.
pip install mutmut
mutmut run
Surviving mutants point exactly to missing assertions — paste them back to the assistant and ask for tests that kill them.
Step 5: Review like any other code
Before committing, check:
- Test names describe behavior (
test_rejects_negative_price, nottest_apply_discount_3). - No assertions on private details or exact log messages unless they matter.
- Mocks only at real boundaries (network, filesystem, clock, randomness).
- No duplicated tests checking the same thing.
- Expected values were verified by you, not just accepted.
Prompts for specific situations
Legacy code with no tests: characterization tests
This module has no tests and we need to refactor it. Write characterization tests
that capture its CURRENT behavior for typical inputs, including outputs that look
wrong. Mark any suspicious behavior with a comment "# CHECK: …".
These lock in current behavior so a refactor cannot change it by accident. Resolve the # CHECK comments separately.
Reproduce a bug first
Here is a bug report: <paste the report, including the input and the wrong output>.
Write a failing test that reproduces it. Don't fix the code yet.
A failing test first proves you understand the bug and prevents it from returning.
Property-based tests
Write Hypothesis property-based tests for apply_discount: the result is never
greater than the price, never negative for valid input, and 0% always returns
the price rounded to 2 decimals.
Property tests generate hundreds of inputs and often find edge cases that example tests miss.
Testing code with dependencies
This function calls an HTTP API through the `client` argument. Write tests using
a simple fake client class (not unittest.mock) that returns canned responses,
covering success, a 404, and a timeout.
Passing dependencies in as arguments makes code easier to test — a design improvement the AI can suggest too.
What AI-generated tests are good and bad at
| Good at | Weak at |
|---|---|
| Boilerplate, fixtures and parametrized cases | Knowing your business rules |
| Listing edge cases you forgot | Deciding what correct behavior is |
| Converting a bug report into a failing test | Judging which tests actually matter |
| Translating tests between frameworks | Spotting when a test is tautological |
| Fast first drafts for untested code | Integration and end-to-end strategy |
FAQ
Should I let AI write tests and code at the same time?
Better to separate them: agree on the tests first (or write them yourself), then generate or write the implementation. If the same prompt produces both, they tend to share the same misunderstanding.
Is 100% coverage a good target?
No. Coverage shows which lines ran, not whether behavior was checked. Aim for meaningful tests of important behavior and use mutation testing to measure their strength.
Which assistant is best for writing tests?
Assistants that can read your repository and run the test suite themselves are most effective, because they can iterate on failures. The workflow above works with any of them.
Further reading
Keep reading
How to Write Better Prompts for AI Coding Assistants
A practical framework for prompting AI coding assistants: give context, set constraints, ask for a plan first and verify the output. With before-and-after examples.
AI Code Review: What Works, What Doesn't, and How to Set It Up
How to use AI for code review effectively: what it catches, where it fails, prompts that reduce noise, CI integration and how to keep humans in the loop.
How to Run a Local LLM on Your Own PC (Beginner's Guide)
Run large language models on your own computer: hardware requirements, quantization explained, setup with Ollama, using the local API from code, and realistic expectations.