Skip to content
SparkTime, home
AI From Zero to Productive
Lessons
1 · Understanding Modern AI
2 · Talking to AI Effectively
3 · AI for Learning
4 · AI for Work and Productivity
5 · AI for Developers
6 · AI for Career Growth
7 · Research, Verification and Responsible AI
8 · Automation and AI Agents
9 · Build Your Personal AI System
Lesson 20 of 35

Lesson 20 · AI for Developers

Testing, Reviewing and Refactoring AI-Generated Code

AI-written code often passes AI-written tests, because both come from the same assumptions. This article shows how to write tests that check what the code should do, how to use AI as a second reviewer without relying on it, what to look for in AI-generated diffs, and how to refactor safely.

On this page

Key takeaways

  • Write the expected behaviour first, from the requirement, not from the generated code. Tests derived from the code mostly confirm what the code already does.
  • Use AI to enumerate edge cases and draft tests, then check that each test fails when the code is broken on purpose.
  • Review AI-generated diffs for size, unrelated changes, removed checks, new dependencies, hard-coded values and comments that do not match the code.
  • An AI reviewer is a useful extra pair of eyes with a checklist, and never the only reviewer. Verify each claim it makes.
  • Refactor with tests in place, in small behaviour-preserving steps, and run the tests after each step.

A developer asks an assistant for a username validator, then asks it to write tests. The tests pass. Everything is green. The validator quietly accepts a username with a newline at the end, which later causes a mismatch in a database lookup.

The tests passed because they were written from the same assumptions as the code. If the model overlooked the case when writing the validator, it very likely overlooked it again when writing the tests. Green tests mean the code does what the tests expect. They do not mean the tests expect the right things.

What you will learn

#
  • Why tests must come from the requirement, not from the code.
  • A worked example in which a plausible validator has a subtle bug.
  • How to use AI to draft tests and edge cases, and check that the tests are strong.
  • How to review AI-generated changes.
  • How to refactor safely.

Tests are a specification

#

A good test states what should happen, independently of how the code does it. If you write the expected values by running the code and copying its output, you have created a test that can only ever agree with the code. That is called a characterisation test, and it has a legitimate use (pinning down current behaviour before a refactor), but it is not evidence that the behaviour is correct.

So the order matters:

  1. Write the requirement in plain words. "A valid username has 3 to 16 characters, using only lowercase letters, digits and underscores."
  2. Derive test cases from the requirement, including boundaries and nasty inputs.
  3. Then generate or write the code, or test the code you already have.

Ask the AI to help with step 2 without showing it the implementation:

Code
Requirement: a valid username has 3 to 16 characters, using only lowercaseletters a-z, digits 0-9 and underscores. List test cases as a table: input, expected result (valid or invalid), and thereason. Cover boundaries (length 2, 3, 16, 17), invalid characters, emptyinput, whitespace, a trailing newline, and non-ASCII letters.

Because the assistant has not seen the code, it cannot just mirror it. You then review the table: do you agree with each expected result?

A worked example: the newline that got through

#

Here is a plausible first draft of the validator, the kind of thing an assistant might write, alongside a stricter version. The program tests both on the same five inputs.

Python
# File: username_check.pyimport re# A plausible first draft from an assistant: 3 to 16 lowercase letters, digits or underscores.DRAFT = re.compile(r"^[a-z0-9_]{3,16}$")# A stricter version: fullmatch has no trailing-newline loophole.STRICT = re.compile(r"[a-z0-9_]{3,16}")def draft_is_valid(name):    return bool(DRAFT.match(name))def strict_is_valid(name):    return bool(STRICT.fullmatch(name))cases = [    ("priya_01", True),    ("ab", False),    ("Priya", False),    ("a" * 17, False),    ("priya_01\n", False),]print(f"{'input':<16}{'expected':<10}{'draft':<8}{'strict':<8}")for name, expected in cases:    label = repr(name) if len(name) < 12 else repr(name[:9] + "...")    print(f"{label:<16}{str(expected):<10}{str(draft_is_valid(name)):<8}{str(strict_is_valid(name)):<8}")
Code
input           expected  draft   strict'priya_01'      True      True    True'ab'            False     False   False'Priya'         False     False   False'aaaaaaaaa...'  False     False   False'priya_01\n'    False     True    False

The draft gets four of the five cases right, and a quick glance at the first four rows might make you stop. The fifth row reveals the bug: the draft accepts 'priya_01\n'. In Python's regular expressions, $ matches at the end of the string and also just before a trailing newline. So the pattern considers a name with a newline valid. The stricter version, using fullmatch, requires the whole string to match.

Two lessons:

  1. The bug lives in the edge, not the middle. Tests drawn only from "normal" examples (the first four rows) pass. A requirement-driven list that includes "trailing newline" catches it.
  2. Ask the assistant to explain why its code handles each case. If you ask "how does this treat a trailing newline?" with the code visible, a good answer would notice $, and a weak one would not. Test it.

(The details of regex behaviour vary between languages. Always check the documentation for the one you use.)

Edge-case checklist

#

Run through these for any function, and ask the assistant to propose more for your specific one.

Category

Examples

Size

Empty, one item, many items, very large

Boundaries

Exactly at the limit, one below, one above

Types and shapes

Wrong type, null or None, missing fields, extra fields

Text

Whitespace, newlines, case, Unicode, very long strings, special characters

Numbers

Zero, negative, very large, decimals, rounding

Time

Time zones, daylight saving, leap days, month ends

Duplicates and order

Repeated values, already sorted, reversed

Failure

Network down, file missing, permission denied, timeout

Concurrency

Two requests at once, retries

Are the tests any good? Break the code on purpose

#

A test suite that cannot fail is useless. A cheap check, a manual form of what is known as mutation testing: after the tests pass, change the code to be wrong and see whether any test fails. Remove a boundary check, flip a comparison, delete a branch. If everything stays green, your tests do not cover that behaviour.

Do this especially for AI-generated tests, which often have these weaknesses:

  • Tautologies: the test recomputes the answer using the same logic as the code.
  • Over-mocking: everything is replaced by fake objects, so the test checks the mocks, not the behaviour.
  • Weak assertions: assert result is not None when the exact value matters.
  • Happy path only: no empty, invalid or extreme input.
  • Copied outputs: expected values pasted from the code's own output.
  • Passing by coincidence: a test that would pass for several wrong implementations.

Coverage numbers do not fix this. Code can be fully covered and badly tested, because coverage counts lines that ran, not behaviours that were checked.

Reviewing AI-generated changes

#

AI changes can be large and plausible-looking, and reviewers tend to skim what looks fluent. Slow down and use a checklist.

Check

Questions

Scope

Is the diff bigger than the request? Did it change unrelated files, formatting or names?

Correctness

Does it do what the requirement says, including the edge cases above?

Removed code

Did it delete a check, a log line, a validation or a test? Why?

Error handling

Are errors handled deliberately, or swallowed?

Security

Input validation, authorisation, secrets, injection (article 21)

Dependencies

Does it add a package? Does that package exist, is it maintained, and is it the one you meant? Check its name and source.

Hard-coded values

Magic numbers, test data, URLs, credentials

Comments and docs

Do they describe what the code actually does?

Performance

Any obviously expensive loops or queries on realistic data?

Consistency

Does it fit the project's style and structure?

Understanding

Can you explain every line?

A tip for large diffs: ask the assistant to produce a change summary (what changed and why), then check whether the summary matches the diff. If the summary mentions something the diff does not contain, or the diff contains something the summary does not mention, investigate.

Using AI as a second reviewer

#

An assistant can be a useful extra reviewer, especially for catching things humans tire of: inconsistent naming, missing error handling, forgotten edge cases. Make it useful:

Code
Review the diff below as a careful senior engineer. Focus on: correctness foredge cases, error handling, security, and anything that is changed but notmentioned in the description. For each concern, quote the line, explain therisk and say how confident you are. If you find nothing in a category, say"no issues found in this category", and do not invent concerns.[paste diff]

Treat the result as leads to check:

  • Some points will be wrong (false positives). Verify before acting.
  • It will miss things. A clean report is not a clearance.
  • It may praise what is merely familiar. Keep your own review.
  • Do not let "the AI approved it" replace a human sign-off where your team requires one.

Refactoring safely

#

Refactoring means changing the structure of code without changing its behaviour. AI is good at proposing restructurings, and the risk is that it changes behaviour too, in subtle ways.

  1. Have tests first. If there are none, write characterisation tests that pin down the current behaviour, including its oddities.
  2. Ask for a plan, not the finished result. "List the refactoring steps you would take, smallest first, and what behaviour each preserves."
  3. Take one step at a time, and run the tests after each.
  4. Commit after each green step, so you can undo.
  5. Compare before and after for performance if it matters, using a measurement.
  6. Review the diff for behaviour changes, such as altered error messages, ordering, rounding or default values.
  7. Stop at "good enough." Endless AI-suggested rewrites increase risk and review burden.

Common mistakes

#
  • Generating tests from the code, then treating them as proof.
  • Never trying to break the code, so you never learn whether the tests can fail.
  • Skimming big diffs because they look tidy.
  • Accepting a new dependency without checking it.
  • Treating an AI review as a clearance.
  • Refactoring without a safety net.
  • Chasing coverage numbers instead of behaviours.

Exercises

#

Exercise 1

Write requirement-first tests

#

For the requirement "a function is_even_length(text) returns true if the text has an even number of characters", list six test cases, including edge cases, that you would write before looking at any implementation.

Exercise 2: Break it on purpose

#

You have a function discount(price, percent) and a test suite that passes. Describe three deliberate changes (mutations) to the function and what each should do to your tests if they are good.

Exercise 3: Review the diff

#

An AI-generated change meant to "fix a typo in an error message" modifies five files, removes an if user is None check and adds a new package to requirements.txt. List what you would do before accepting it.

Exercise 4: Plan the refactor

#

You want to split a 200-line function into smaller functions with an assistant's help. Describe your plan, including the safety net and the review.

Show solutionHide solution

Examples: (1) "" returns true, because zero is even, which is an edge case worth confirming with the requirement owner. (2) "a" returns false. (3) "ab" returns true. (4) "abc" returns false. (5) "héllo!" has 6 characters if counted as characters and possibly more if counted as bytes, so it tests the definition of "character". (6) A very long string of 10,001 characters returns false. You might add whitespace-only strings and strings with a newline.

The point: some cases (empty, Unicode) force a decision about what the requirement means, which is the real value of writing cases before the code.

Exercise 2

Show solutionHide solution

Examples: (1) Change price * (1 - percent / 100) to price * (1 + percent / 100). A good suite fails, since the discounted price would be higher than the original. (2) Remove a check that rejects negative percentages. If no test fails, you have no test for negative or invalid input. (3) Change > to >= in a boundary condition (for example a free-shipping threshold). A good suite has a test exactly at the boundary and fails.

If any mutation leaves all tests passing, add a test for the behaviour that mutation changed, then check that the new test fails on the mutated code.

Exercise 3

Show solutionHide solution

Do not accept it as is. Steps: (1) Ask why five files changed for a typo fix, and revert unrelated changes so the diff contains only the message change. (2) Find out why the if user is None check was removed. It is probably unrelated and may reintroduce a crash or a security hole. Restore it unless there is a clear reason. (3) Check the new package: does it exist under that exact name, who maintains it, is it needed at all, what licence does it have and is it allowed by your policy? A typo fix should not need a dependency. (4) Run the tests, and add or confirm a test for the message. (5) Ask the assistant to produce only the minimal change.

Exercise 4

Show solutionHide solution
  1. Safety net: write characterisation tests that capture the current behaviour on typical and edge inputs, and confirm they pass on the existing code.
  2. Plan: ask the assistant for a list of steps, smallest first, each identified as behaviour-preserving, such as "extract the input validation into its own function".
  3. Do step 1 only, run the tests, commit.
  4. Review the diff for accidental behaviour changes (error messages, order of operations, default values).
  5. Repeat for each step.
  6. At the end, compare performance if relevant, read the final code for clarity, and make sure you can explain the new structure.

If a test fails at any step, undo that step, find out whether the test or the change is at fault, and continue.

Where to go next

#
  • Guide · Technology

    Writing Better AI Prompts: A Practical Guide

  • Guide · Technology

    Using AI Tools at Work Without Getting Into Trouble

  • Guide · Career

    A LinkedIn Profile That Gets Recruiter Messages