Testing & Debugging with AI
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Java examples on this page use the JUnit Jupiter annotation API (org.junit.jupiter.api, @Test,
assertEquals, and the rest) that this sub-section calls "JUnit 5" throughout; as of 2026-09-28 the JUnit
project’s own site names JUnit 6 the current generation, bundling that same Jupiter module alongside JUnit
Platform and Vintage, so treat "JUnit 5" here as naming the Jupiter API this scenario’s tests already use rather
than a still-current major version — see JUnit — Project homepage for the current
generation. Property-based examples target jqwik 1.10.1, the current release as of 2026-09-28 per
jqwik’s user guide; Python examples target pytest plus
Hypothesis, both current as of the same date per
Hypothesis’s quickstart. GitHub Copilot Chat and
Claude Code examples are checked against the official docs at that date, as on the rest of this sub-section. The
running example is again DocsAssistant API: its Java sources live under
src/main/java/com/example/docsassistant/, its JUnit tests under
src/test/java/com/example/docsassistant/DocsControllerTest.java, and its Python ingestion script at
ingest/build_index.py, the same GET /api/pages/{id}/related feature (a PathVariable on DocsController,
returning a List<RelatedPage> sorted by descending score, backed by the embeddings ingest/build_index.py
builds) that
Prompting Coding Assistants introduces.
Generating tests
Unit tests
A unit test targets one method in isolation. Copilot Chat’s /tests slash command generates a test suite for
the file open in the editor, and reading an existing test file in an adjacent tab helps it match the project’s
conventions; Claude Code takes the same request as an ordinary prompt with an @ reference.
/tests #DocsController.java
Add JUnit 5 cases for getPage: a known id returns 200 with the matching PageDto, and an
unknown id returns 404. Follow the @SpringBootTest style already used in DocsControllerTest.java.
$ claude "write JUnit 5 tests for getPage in @src/main/java/com/example/docsassistant/DocsController.java,
covering a known id (200, matching PageDto) and an unknown id (404). Add them to
@src/test/java/com/example/docsassistant/DocsControllerTest.java, matching its existing @SpringBootTest style."
See GitHub — Writing tests with GitHub Copilot and Claude Code — Best practices, whose specificity table makes the same "name the file, the case, the convention to match" point.
Integration tests
An integration test exercises more of the stack together — here, the full Spring context, the repository and the
ranking logic behind /api/pages/{id}/related. Breaking the request into the specific cases to cover, rather
than asking for "integration tests" generically, is what keeps the assistant’s output matched to what the
endpoint actually does:
Write a JUnit 5 + @SpringBootTest integration test in DocsControllerTest.java for
GET /api/pages/{id}/related: a known page id returns 5 results ordered by descending
score; an unknown id returns 404; a page with no embedding yet returns an empty list
rather than an error. Use MockMvc, matching the file's existing setup.
This is the same three-case list Prompting
Coding Assistants's "Tests" phase already specifies for the related endpoint; repeating the exact cases here
keeps this page’s example directly runnable against the same test class.
Edge-case tests
Edge cases are the inputs a hand-written happy-path test tends to skip: a limit of 0 or a negative number, an
id that isn’t a valid Long (/api/pages/abc/related), and a candidate list that is empty before ranking
starts. Naming them explicitly, instead of asking for "more tests", is what actually produces them:
Add edge-case tests to DocsControllerTest.java for GET /api/pages/{id}/related: limit=0 returns
an empty list, a negative limit is rejected with 400, a non-numeric id returns 400 (not 500),
and a page with zero candidate embeddings returns an empty list rather than throwing.
Property-based tests
Where a unit test checks one input/output pair, a property-based test states an invariant that must hold for
every generated input and lets the framework search for a counterexample. jqwik (net.jqwik:jqwik, the
current test engine for the JUnit 5 platform) generates the inputs for a @Property method’s @ForAll
parameters; a @Provide-annotated method can build a custom Arbitrary when the parameter type is a project
class rather than a plain number or string. The property below states two invariants of the ranking helper
behind DocsController.related — that it never returns more than the requested limit, and that its output stays
sorted by descending score:
package com.example.docsassistant;
import net.jqwik.api.*;
import net.jqwik.api.constraints.IntRange;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class RelatedPageRankerProperties {
@Provide
Arbitrary<List<ScoredPage>> candidateLists() {
Arbitrary<Long> ids = Arbitraries.longs().between(1, 1000);
Arbitrary<Double> scores = Arbitraries.doubles().between(0.0, 1.0);
Arbitrary<ScoredPage> pages = Combinators.combine(ids, scores).as(ScoredPage::new);
return pages.list().ofMaxSize(50);
}
@Property
void rankNeverExceedsTheRequestedLimit(
@ForAll("candidateLists") List<ScoredPage> candidates,
@ForAll @IntRange(min = 0, max = 20) int limit) {
List<ScoredPage> ranked = RelatedPageRanker.rank(candidates, limit);
assertThat(ranked.size()).isLessThanOrEqualTo(Math.min(limit, candidates.size()));
}
@Property
void rankIsSortedByDescendingScore(@ForAll("candidateLists") List<ScoredPage> candidates) {
List<ScoredPage> ranked = RelatedPageRanker.rank(candidates, candidates.size());
for (int i = 1; i < ranked.size(); i++) {
assertThat(ranked.get(i - 1).score()).isGreaterThanOrEqualTo(ranked.get(i).score());
}
}
}
A prompt to generate this asks the assistant for the invariant, not the test code directly, since the invariant is the part a human still has to get right:
$ claude "write jqwik @Property tests for @src/main/java/com/example/docsassistant/RelatedPageRanker.java
that check: rank() never returns more elements than the requested limit, and its output is sorted by
descending score for any list of ScoredPage. Generate a @Provide arbitrary for ScoredPage."
Hypothesis is jqwik’s Python counterpart, used here on the ranking logic inside ingest/build_index.py. Its
@given decorator plays the same role as jqwik’s @ForAll, drawing from strategies (imported as st):
from hypothesis import given, strategies as st
scored_pages = st.builds(
ScoredPage,
page_id=st.integers(min_value=1, max_value=1000),
score=st.floats(min_value=0.0, max_value=1.0, allow_nan=False),
)
@given(st.lists(scored_pages, max_size=50), st.integers(min_value=0, max_value=20))
def test_rank_never_exceeds_the_requested_limit(candidates, limit):
ranked = rank_related(candidates, limit)
assert len(ranked) <= min(limit, len(candidates))
@given(st.lists(scored_pages, max_size=50))
def test_rank_is_sorted_by_descending_score(candidates):
ranked = rank_related(candidates, len(candidates))
scores = [page.score for page in ranked]
assert scores == sorted(scores, reverse=True)
Fuzz tests
Fuzz testing pushes the same generate-and-check idea toward large volumes of adversarial or malformed input,
specifically looking for crashes rather than checking a business invariant. Both frameworks above double as fuzz
tools once the generator targets input shapes an attacker or a malformed request might send instead of only
well-formed domain objects — an oversized or negative limit, a scores list with NaN or infinite values, or
a raw string in place of a numeric id:
Turn the property tests on RelatedPageRanker into a fuzz test: generate scores that include NaN,
Infinity and negative values, and raw strings where an id is expected on the related endpoint's
MockMvc call. Run many iterations and report the first case that throws instead of returning
400/404 cleanly.
When a generated case fails, both frameworks shrink it automatically to the smallest input that still reproduces the failure, which is what makes the reported counterexample directly usable when feeding it back to the assistant, as in "Test-driven debugging" below, rather than a large hard-to-read fixture.
Critiquing existing tests
Asking an assistant to review a test file it did not just write catches gaps a human skimming the same file tends to miss — an untested branch, a missing negative case, an assertion that would still pass if the implementation were wrong:
#DocsControllerTest.java #DocsController.java
What test cases are missing in DocsControllerTest.java for the related endpoint,
based on the branches DocsController.related actually has?
$ claude "review @src/test/java/com/example/docsassistant/DocsControllerTest.java against
@src/main/java/com/example/docsassistant/DocsController.java. List branches with no test,
assertions that would pass even if the implementation were wrong, and any test that doesn't
actually exercise what its name claims."
See GitHub — Writing tests with GitHub Copilot, which documents this same "what additional tests should be included" pattern.
Test-driven debugging
Test-driven debugging means feeding the assistant the failing test’s name and its actual output — the stack trace or assertion diff, verbatim — instead of describing the bug in your own words, so it starts from the same evidence you have rather than a paraphrase of it:
#DocsControllerTest.java
relatedPages_unknownId_returns404 is failing:
expected: 404 NOT_FOUND
but was: 500 INTERNAL_SERVER_ERROR
java.lang.NullPointerException: Cannot invoke "Embedding.vector()" because the return value of "PageRepository.findEmbeddingById(Long)" is null
at com.example.docsassistant.DocsController.related(DocsController.java:41)
Find the root cause in DocsController.related and fix it there, not by catching the
exception at the controller boundary.
$ claude "@src/test/java/com/example/docsassistant/DocsControllerTest.java
DocsControllerTest#relatedPages_unknownId_returns404 is failing with a 500 instead of a 404:
[paste the stack trace here]
Find where DocsController.related handles a missing embedding and fix the root cause,
not by catching the exception at the controller layer. Re-run mvn test -Dtest=DocsControllerTest
and confirm it passes."
Pasting the exact exception type and line, rather than "it throws an error", is what lets the assistant jump
straight to DocsController.related instead of re-deriving the failure by reading the whole class; this mirrors
the "describe the symptom" pattern from
Claude Code — Best practices's own before/after table.
Verification loops: tests as the agent’s success criterion
A failing test is a check the assistant can run and read the result of on its own, which turns a session you have to watch into one that closes its own loop: the assistant edits code, re-runs the test, reads whether it passed, and repeats until it does, or until it runs out of ideas and reports back. Without that check, "looks done" is the only signal available, and a human has to supply the verification step every time instead. This is why the earlier prompts above all end with "re-run the test" rather than stopping at the code change.
For an unattended run — one nobody is watching turn by turn — the same idea can be enforced rather than merely
asked for: Claude Code’s Stop hook runs a script every time the assistant is about to finish responding and can
block that stop, forcing another turn, until the script it runs (typically the project’s test command) exits
successfully. A minimal version in the project’s .claude/settings.json points the Stop event at a script:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/run-tests.sh"
}
]
}
]
}
}
The script runs mvn -q test -Dtest=DocsControllerTest and exits 0 when it passes, or exits 2 with the failure
on stderr when it doesn’t. Exit code 2 blocks the stop and hands that stderr text to Claude as the reason to keep
working. The hook input’s stop_hook_active field is true when Claude is already continuing because of a stop
hook, and as of 2026-09-28 Claude Code overrides the block after eight consecutive continuations, so a test that
can never pass doesn’t loop forever. See Claude Code — Hooks
reference (Stop) and Claude Code — Best practices ("Give Claude
a way to verify its work"). Authoring hooks in depth is covered in
Hooks; this page only shows the mechanism as the deterministic
version of "tests as the success criterion" above.
Reproducing bugs
Before fixing a reported bug, ask the assistant to turn the report into a failing test first — proving the bug exists and giving both of you a concrete check for "fixed" that doesn’t depend on re-reading the original report:
Users report that GET /api/pages/{id}/related sometimes returns pages already returned by a
previous call for the same id, in a different order each time. Write a failing JUnit 5 test in
DocsControllerTest.java that reproduces this by calling the endpoint twice for the same id and
asserting the two responses are identical. Then find and fix the root cause.
This is the same "write a failing test that reproduces the issue, then fix it" pattern Claude Code — Best practices recommends for describing a symptom, applied here specifically to reproduction rather than to fixing a build error.
Hallucinated APIs in generated tests
Generated test code is exactly the kind of fluent, plausible-looking output where a hallucinated method,
assertion class or mocking API is easy to miss until the build fails on a name that was never real — assertThrowsExactly misspelled into a method that doesn’t exist, or a jqwik or Hypothesis strategy invented
from a plausible-sounding name rather than the actual API. Compiling and running generated tests immediately,
rather than only reading them, is the fastest way to catch this;
Hallucinations & Limitations covers why models
produce fabricated names with such confidence and the grounding and verification techniques that reduce it.