DevPlainly
AI Tools

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
  1. The problem with "write tests for this"
  2. Step 1: List behaviors before any code
  3. Step 2: Generate tests from the agreed list
  4. Step 3: Run them and read every failure
  5. Step 4: Check that the tests can fail
  6. Step 5: Review like any other code
  7. Prompts for specific situations
  8. Legacy code with no tests: characterization tests
  9. Reproduce a bug first
  10. Property-based tests
  11. Testing code with dependencies
  12. What AI-generated tests are good and bad at
  13. FAQ
  14. Should I let AI write tests and code at the same time?
  15. Is 100% coverage a good target?
  16. Which assistant is best for writing tests?
  17. 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_price fails 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:

  1. Break the code on purpose. Change 1 - percent / 100 to 1 + percent / 100 and rerun. Several tests should fail. If they all pass, they are not testing the logic.
  2. 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, not test_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 atWeak at
Boilerplate, fixtures and parametrized casesKnowing your business rules
Listing edge cases you forgotDeciding what correct behavior is
Converting a bug report into a failing testJudging which tests actually matter
Translating tests between frameworksSpotting when a test is tautological
Fast first drafts for untested codeIntegration 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