JUnit 5 Tests with Claude Code: A Guide with Mockito Examples

  • Last Updated: May 19, 2026
  • By: javahandson
  • Series
img

JUnit 5 Tests with Claude Code: A Guide with Mockito Examples

A complete how-to guide on generating JUnit 5 tests with Claude Code, including Mockito mocking, parameterized tests, exception testing, and Spring Boot integration tests. Learn the prompts that produce maintainable tests, how to handle edge cases, when to use @MockBean vs @Mock, and how to review AI-generated tests so they actually catch bugs instead of just inflating coverage numbers.

 

Introduction

Most Java developers will agree on two things about unit tests. First, they are essential — without tests, refactoring is dangerous, debugging is slow, and confidence is low. Second, writing them is often the least enjoyable part of the day. Setting up mocks, naming test methods, building test data, and remembering the right Mockito syntax for the seventeenth time is real work that does not feel like progress.

This is exactly the kind of work that Claude Code is genuinely good at. JUnit 5 tests follow predictable patterns. Mockito follows predictable patterns. Spring Boot test slices follow predictable patterns. When patterns are stable and the volume is high, an AI coding assistant that can read your real code and write tests against it becomes a serious productivity multiplier.

In this article, we will walk through how to generate JUnit 5 and Mockito tests with Claude Code in a way that produces tests you would actually accept in a code review. We will keep the examples beginner-friendly, but the patterns we cover are the same ones senior Java developers use on production codebases.

📌 How to use this article: Read it in order. We start with the philosophy of what makes a good test, then move to writing your first generated test, then handle dependencies with Mockito, then look at Spring Boot integration tests, and finally cover the patterns that keep generated tests maintainable as your code evolves.

1. What Makes a Good Unit Test, And Why That Matters Here

Before we ask Claude Code to write any tests, it is worth being honest about what we actually want. A test is not just code that imports JUnit. A good unit test verifies one specific behaviour, fails for one specific reason, and reads like a sentence describing what the code is supposed to do. If your tests pass when the code is wrong, they are worse than no tests, because they create false confidence.

This matters when generating tests with AI because the model will happily write tests that exercise your code without actually testing anything meaningful. A test method that calls a service and then asserts that the result is not null is a test in name only. A test that calls a service and asserts the exact business outcome — the right entity was saved, the right event was published, the right exception was thrown for the right input — is a real test. The difference comes from the prompt.

The good news is that Claude Code is fully capable of writing strong tests when you ask for them. The skill is in the asking. Throughout this article, we will be very explicit about what to include in your prompts so the generated tests verify behaviour, not just execution.

Real-World Analogy: Imagine asking a junior developer to write tests for a method. If you say ‘just write some tests for it’, you will get tests that compile and run, but they may not catch bugs. If you say ‘write tests covering the happy path, the empty input case, the duplicate entry case, and what happens when the repository throws’, you will get a test class that genuinely protects the method. Claude Code responds to the same kind of guidance, with the same kind of results.

2. Setting the Stage: A Service Worth Testing

To make this concrete, let us use a small but realistic Java class as our running example. We will test a CustomerService for a Spring Boot application — the same kind of service most Java developers have written many times. It has a few clear behaviours, a few dependencies, and enough surface area to show what good tests look like.

// CustomerService.java
package com.example.shop.customer;
 
import org.springframework.stereotype.Service;
import org.springframework.context.ApplicationEventPublisher;
 
@Service
public class CustomerService {
 
    private final CustomerRepository customerRepository;
    private final ApplicationEventPublisher eventPublisher;
 
    public CustomerService(CustomerRepository customerRepository,
                           ApplicationEventPublisher eventPublisher) {
        this.customerRepository = customerRepository;
        this.eventPublisher = eventPublisher;
    }
 
    public Customer register(RegistrationRequest request) {
        if (customerRepository.existsByEmailIgnoreCase(request.email())) {
            throw new EmailAlreadyUsedException(request.email());
        }
        Customer toSave = new Customer(request.fullName(), request.email().toLowerCase());
        Customer saved = customerRepository.save(toSave);
        eventPublisher.publishEvent(new CustomerCreatedEvent(saved.getId()));
        return saved;
    }
}

This service has three observable behaviours that we want to verify. First, when the email is new, it saves a customer with a normalised lowercase email. Second, when the email already exists, it throws EmailAlreadyUsedException without saving anything. Third, on a successful save, it publishes a CustomerCreatedEvent with the saved customer’s ID. Three behaviours mean at least three tests, plus a few edge cases.

3. Generating Your First JUnit 5 + Mockito Test

Now we ask Claude Code to write the test. The prompt does most of the work. A weak prompt produces weak tests. A specific prompt produces tests that read like they were written by a careful human. We want behavior-driven names, AAA structure (Arrange-Act-Assert), specific assertions, and a clear mock setup.

3.1 The First Prompt

# Prompt for Claude Code
 
Read CustomerService.java, RegistrationRequest.java, Customer.java,
and EmailAlreadyUsedException.java.
 
Create CustomerServiceTest in src/test/java mirroring the package layout.
 
Requirements:
  - Use JUnit 5 (org.junit.jupiter.api) and Mockito (5.x).
  - Use @ExtendWith(MockitoExtension.class).
  - Mock CustomerRepository and ApplicationEventPublisher.
  - Use constructor injection of mocks via @InjectMocks or manual construction.
 
Cover these behaviours:
  1. register_savesCustomer_whenEmailIsNew
  2. register_normalisesEmailToLowercase_beforeSave
  3. register_throwsEmailAlreadyUsedException_whenEmailExists
  4. register_publishesCustomerCreatedEvent_onSuccess
  5. register_doesNotSaveOrPublish_whenEmailExists
 
Use AAA structure with blank lines between Arrange, Act, Assert.
Use ArgumentCaptor where you need to inspect what was saved or published.
Do not assert on toString or hashCode.

This prompt is worth studying. It tells Claude Code which files to read, which versions of which libraries to use, and what each test method must verify. Crucially, it names every test, so there is no ambiguity about what should be covered. It also bans low-value assertions, such as checking toString output, which is a common AI failure mode that leads to brittle tests.

3.2 What Claude Code Generates

With that prompt, Claude Code reads the source files, identifies the exact dependencies and exception types, and produces a test class that you would happily review on a pull request. Here is the kind of output you can expect — pay attention to how each test isolates a single behaviour.

@ExtendWith(MockitoExtension.class)
class CustomerServiceTest {
 
    @Mock CustomerRepository customerRepository;
    @Mock ApplicationEventPublisher eventPublisher;
    @InjectMocks CustomerService customerService;
 
    @Test
    void register_savesCustomer_whenEmailIsNew() {
        // Arrange
        RegistrationRequest req = new RegistrationRequest("Anita Patel", "Anita@Example.com");
        when(customerRepository.existsByEmailIgnoreCase("Anita@Example.com")).thenReturn(false);
        Customer saved = new Customer(1L, "Anita Patel", "anita@example.com");
        when(customerRepository.save(any(Customer.class))).thenReturn(saved);
 
        // Act
        Customer result = customerService.register(req);
 
        // Assert
        assertThat(result.getId()).isEqualTo(1L);
        assertThat(result.getEmail()).isEqualTo("anita@example.com");
    }
 
    @Test
    void register_normalisesEmailToLowercase_beforeSave() {
        RegistrationRequest req = new RegistrationRequest("Bob", "BOB@EXAMPLE.COM");
        when(customerRepository.existsByEmailIgnoreCase(anyString())).thenReturn(false);
        ArgumentCaptor<Customer> captor = ArgumentCaptor.forClass(Customer.class);
        when(customerRepository.save(captor.capture())).thenAnswer(inv -> inv.getArgument(0));
 
        customerService.register(req);
 
        assertThat(captor.getValue().getEmail()).isEqualTo("bob@example.com");
    }
 
    @Test
    void register_throwsEmailAlreadyUsedException_whenEmailExists() {
        RegistrationRequest req = new RegistrationRequest("Carla", "carla@example.com");
        when(customerRepository.existsByEmailIgnoreCase("carla@example.com")).thenReturn(true);
 
        assertThatThrownBy(() -> customerService.register(req))
            .isInstanceOf(EmailAlreadyUsedException.class);
    }
 
    @Test
    void register_doesNotSaveOrPublish_whenEmailExists() {
        RegistrationRequest req = new RegistrationRequest("Carla", "carla@example.com");
        when(customerRepository.existsByEmailIgnoreCase("carla@example.com")).thenReturn(true);
 
        try { customerService.register(req); } catch (EmailAlreadyUsedException ignored) {}
 
        verify(customerRepository, never()).save(any());
        verify(eventPublisher, never()).publishEvent(any());
    }
}

Look at what these tests do well. Each method name reads as a sentence describing the behaviour. Each test does exactly one thing. The arrange-act-assert structure is visible. The assertions check specific values, not just non-nullness. ArgumentCaptor is used precisely where it adds value — to inspect what was passed to save. And the negative tests verify that save and publish were never called, not just that an exception was thrown. That is a real test class.

4. Mocking Dependencies the Right Way

Mockito is one of those libraries where the syntax is easy, but the discipline is hard. The wrong mock setup produces tests that pass for the wrong reason. The right mock setup produces tests that fail loudly the moment something behaves unexpectedly. When generating tests with Claude Code, being explicit about how to mock dependencies pays off enormously.

4.1 Stub Only What the Test Needs

A common mistake — by humans and AI alike — is over-stubbing. If the test only exercises one path, only the methods on that path should be stubbed. Stubbing everything ‘just in case’ creates noise and hides bugs. With Claude Code, you can specifically ask for minimal stubbing: only set up mocks for the methods the test actually invokes, and let any unused stubs be flagged as errors via Mockito’s strict stubbing.

# Mocking guidance you can include in your prompt
 
Use Mockito strict stubbing (the default with MockitoExtension).
Only stub methods that the test under test actually calls.
Do not stub methods that are unused — they should fail with UnnecessaryStubbingException.
Prefer when().thenReturn() for simple values.
Prefer when().thenThrow() to simulate failure paths.
Use doThrow().when() only for void methods.

4.2 Verify Both Outcomes and Interactions

There are two things worth verifying in a unit test. The outcome — what the method returned, what state it left behind. And the interactions, which collaborators called, with what arguments, and how many times. Many AI-generated tests focus on outcomes and overlook interactions, or vice versa. Strong tests cover both.

In our CustomerService example, the outcome is the returned Customer. The interactions are: existsByEmailIgnoreCase was called once with the right email, save was called once with the normalized email, and publishEvent was called once with a CustomerCreatedEvent containing the right ID. Asking Claude Code to explicitly verify both in the prompt produces tests that fail for the right reasons when the code regresses.

Interview Insight: Interviewers love asking ‘what is the difference between when().thenReturn() and verify()?’ The clean answer: when().thenReturn() configures how a mock should respond when called. verify() asserts that a mock was actually called in a specific way during the test. Stubbing controls input to the system under test; verification asserts on output behaviour. A test that stubs but never verifies might pass for the wrong reason.

5. Generating Tests for Spring Boot Components

Spring Boot adds a few extra layers worth testing — controllers, repositories, security configuration. Each layer has its own test slice annotation that loads only the parts of the Spring context the test actually needs. Claude Code can generate test slice tests just as easily as plain unit tests, provided you tell it which slice you want.

5.1 @WebMvcTest for Controllers

@WebMvcTest is the right choice for testing the HTTP layer in isolation. It loads only the MVC infrastructure, lets you inject a MockMvc, and lets you mock service-layer beans with @MockBean. The result is a test that exercises real Jackson serialization, real validation annotations, and real Spring routing without spinning up the entire application.

# Prompt for a controller test
 
Generate CustomerControllerTest using @WebMvcTest(CustomerController.class).
 
Inject MockMvc.
Use @MockBean to mock CustomerService.
 
Cover:
  - POST /api/customers/register returns 201 Created with the new customer JSON
  - POST /api/customers/register returns 409 Conflict when EmailAlreadyUsedException is thrown
  - POST /api/customers/register returns 400 Bad Request when the request body fails validation
 
Use ObjectMapper for JSON. Assert HTTP status, content-type, and the relevant JSON fields.

With that prompt, Claude Code will read your CustomerController, DTOs, validation annotations, and exception-handling configuration. It will produce a test that exercises real serialization, real validation, and real status-code mapping. This is much closer to what end users actually experience than a plain unit test of the controller class.

5.2 @DataJpaTest for Repositories

Repository tests are a different beast. You almost never want to mock a repository — you want to test it against a real database, ideally an embedded one. @DataJpaTest sets up an embedded H2 database, applies your JPA configuration, and gives you a TestEntityManager so you can seed data. Asking Claude Code to generate these tests is straightforward when you specify the slice.

# Prompt for a repository test
 
Generate CustomerRepositoryTest using @DataJpaTest.
 
Use TestEntityManager to persist test data.
Test these query methods:
  - existsByEmailIgnoreCase returns true for a matching email regardless of case
  - existsByEmailIgnoreCase returns false when no customer matches
  - findByFullNameContainingIgnoreCase returns customers whose name contains the substring
 
Use AssertJ for collection assertions (size, exact contents, ordering if applicable).
Do not test JpaRepository's built-in methods (save, findById) — they are framework code.

6. Edge Cases, Negative Tests, and Boundaries

Most bugs do not live on the happy path. They live in the corners — empty strings, null inputs, unexpected exceptions from collaborators, off-by-one boundaries, concurrent modifications. A test suite that only covers the happy path provides false confidence. When you generate tests with Claude Code, asking explicitly for negative and boundary tests is what separates a useful suite from a checkbox suite.

6.1 The Edge-Case Checklist

There is a small mental checklist that helps. For every public method, ask: what happens with null input? What happens with empty input? What happens with the largest realistic input? What happens when a collaborator throws? What happens when a collaborator returns null? What happens at boundary values like zero, one, and the maximum allowed value? Each of these can be a test.

# Add this to your test generation prompt for stronger coverage
 
For every public method, also generate edge-case tests covering:
  - Null arguments where allowed/disallowed
  - Empty strings and empty collections
  - Maximum allowed length or count if applicable
  - The repository or other collaborator throwing a runtime exception
  - The collaborator returning null where the code might assume non-null
 
For each edge case, the test name must describe the case explicitly.
Example: register_throwsIllegalArgumentException_whenEmailIsNull

6.2 Negative Tests Are Not Optional

A negative test verifies that something does NOT happen. It is the test that would have caught the bug where the code accidentally saves a customer even after detecting a duplicate. Negative tests are worth asking for explicitly because AI assistants — like junior developers — sometimes default to writing only positive tests. The verify(repository, never()).save(any()) line in our earlier example is a negative test, and it is one of the most valuable lines in the entire test class.

7. Parameterized Tests: Coverage Without Repetition

JUnit 5 has excellent support for parameterized tests, and they are perfect for cases where the same logic should be verified across many inputs. Email validation, password rules, status transitions, currency conversion, and date parsing — all of these benefit from parameterized tests. Asking Claude Code to use parameterized tests where appropriate keeps the suite compact without losing coverage.

@ParameterizedTest
@ValueSource(strings = {"", " ", "no-at-sign", "@nodomain", "user@", "user@@example.com"})
void register_throwsValidationException_forInvalidEmail(String invalidEmail) {
    RegistrationRequest req = new RegistrationRequest("Test", invalidEmail);
 
    assertThatThrownBy(() -> customerService.register(req))
        .isInstanceOf(InvalidEmailException.class);
}
 
@ParameterizedTest
@CsvSource({
    "Anita Patel, anita@example.com, anita@example.com",
    "Bob,         BOB@EXAMPLE.COM,   bob@example.com",
    "Carla,       Carla@Example.Com, carla@example.com"
})
void register_normalisesEmailToLowercase(String name, String input, String expected) {
    RegistrationRequest req = new RegistrationRequest(name, input);
    when(customerRepository.existsByEmailIgnoreCase(anyString())).thenReturn(false);
    ArgumentCaptor<Customer> captor = ArgumentCaptor.forClass(Customer.class);
    when(customerRepository.save(captor.capture())).thenAnswer(inv -> inv.getArgument(0));
 
    customerService.register(req);
 
    assertThat(captor.getValue().getEmail()).isEqualTo(expected);
}

Six invalid email tests in five lines. Three normalization tests in seven lines. The signal-to-noise ratio of parameterized tests is unbeatable for this kind of coverage, and Claude Code is happy to produce them when you ask for them by name.

8. Tests as Specifications: Naming and Documentation

Test names matter more than most developers admit. A well-named test reads like a specification. When a test fails in a CI run, the failing test name should tell you which behavior broke, without you having to open the file. This is one of the reasons we name tests like methodUnderTest_expectedOutcome_whenCondition rather than testRegister1, testRegister2, testRegister3.

There is a useful pattern called BDD-style naming: ‘given X, when Y, then Z’. JUnit 5 supports this directly with @DisplayName, which lets you write a human-readable name without giving up the method-name discipline. When generating tests, you can ask Claude Code to add @DisplayName to every test method so the failure messages are immediately readable by a non-Java reader — for example, a product manager looking at a CI dashboard.

Approach Why It Matters
Method names that read as sentences Failure output indicates which behavior failed without opening the file.
@DisplayName for human-readable labels CI dashboards and reports are readable by non-developers.
AAA layout with blank lines Reviewers can see at a glance what the test sets up, runs, and checks.
One behaviour per test When a test fails, the cause is usually obvious from the name alone.
Specific assertions, not just non-null Tests fail when the value is wrong, not just when the method blew up.

9. Keeping Generated Tests Healthy Over Time

Generating tests once is easy. Keeping them healthy across months of feature work is the harder problem. As production code evolves, tests that were once accurate can become stale, brittle, or — worst of all — silently broken. There are a few habits that keep generated tests as useful in month six as they were on day one.

9.1 Regenerate Tests When Behavior Changes, Not When Internals Change

This is the single most important principle. If you change a method’s public behavior, the test must change. If you only change the internal implementation, the test must NOT change — that is the whole point of a unit test. Asking Claude Code to update tests due to a refactor is a code smell. Asking it to update tests because the requirements changed is correct.

9.2 Treat Test Files as First-Class Code

Tests deserve the same review, the same naming standards, and the same refactoring care as production code. When Claude Code generates tests, do not skim the diff. Read it. If you would not accept this code in src/main/java, do not accept it in src/test/java either. The model will happily fix anything you point out — duplicated setup, weak assertions, unclear names — but only if you actually ask.

9.3 Use Coverage as a Diagnostic, Not a Goal

Code coverage tools like JaCoCo are useful for finding code that is not exercised by any test. They are not useful for proving that your tests are good — 100% line coverage with assertions that never check anything is worse than 60% coverage with strong assertions. When using Claude Code to fill coverage gaps, always insist on real assertions, not just test methods that call the code.

# Prompt for filling coverage gaps responsibly
 
JaCoCo reports that the following branches are not covered:
  - CustomerService.register: the branch where customerRepository.save throws
 
Add a test that exercises this branch.
Use Mockito to make save() throw a DataAccessException.
Verify that:
  1. The exception propagates up unchanged.
  2. eventPublisher.publishEvent is never called when save fails.
 
Do not add any test that just exercises the line without asserting behaviour.

That last sentence is the key. It is the prompt-level enforcement of the principle that tests must verify behavior, not just touch lines. Without it, coverage-driven test generation can produce trivially passing tests that offer no protection.

10. A Realistic Workflow: From Zero Tests to a Healthy Suite

Suppose you have inherited a Java service with poor test coverage. The class you are about to refactor — let us call it OrderService — has 400 lines, no tests, and you do not trust yourself to refactor it safely. This is the perfect situation for a structured test-generation pass with Claude Code before you touch a single line of production code.

Here is the workflow that works in practice. Each step is a small, scoped prompt that builds on the previous one.

# Step 1 — Map the public surface
Read OrderService.java.
List every public method, what it takes, what it returns, and what
exceptions it can throw. Do not write any code yet.
 
# Step 2 — Characterisation tests for the happy path
For each public method, write a JUnit 5 + Mockito test verifying its
happy-path behaviour. Use AAA structure, behaviour-driven names, and
specific assertions. These tests describe what the code currently does.
 
# Step 3 — Edge-case tests
For each public method, add tests for null inputs, empty inputs,
collaborator failures, and any boundary values you can identify
from reading the code. Same naming and structure conventions.
 
# Step 4 — Verify
Run: mvn -q -pl :order-service test
All tests must pass against the current code. If any fail, the test
is wrong, not the code — fix the test to match current behaviour.
 
# Step 5 — Refactor with confidence
Now refactor the production code. Re-run the test suite after every
change. Tests must continue to pass — that is what makes the refactor safe.

This workflow is known in the refactoring literature as “characterization tests”. The idea is to write tests that describe what the code currently does, even if some of that behavior is buggy, so that any change is visible. Refactoring then becomes safe because tests catch unintended changes immediately. Claude Code makes characterization testing fast enough to actually do, rather than just talk about it.

11. Conclusion

Generating JUnit 5 and Mockito tests with Claude Code is one of the highest-value things a Java developer can do with an AI coding assistant. The patterns are stable, the volume is high, the code is mechanical, and a real test suite is the foundation of every other improvement you might want to make to a codebase.

In this article, we covered what makes a good unit test, how to set up a service worth testing, how to write a strong first prompt, how to mock dependencies the right way, how to generate Spring Boot test-slice tests with @WebMvcTest and @DataJpaTest, how to use parameterised tests for compact coverage, how to name tests so failures explain themselves, and how to keep generated tests healthy over time.

The most useful thing you can do after reading this is to pick one undertested class in your project and walk through the workflow from Section 10. Generate the public-surface map. Generate the happy-path tests. Generate the edge-case tests. Run mvn verify. The first time you watch the bar go green on a class that had zero tests an hour ago is the moment this workflow earns its place in your daily routine.

Leave a Comment