Writing tests is the part of development most developers want to automate away. Not because they dislike testing—good engineers know tests matter—but because the mechanical parts are tedious. Wiring up Mockito stubs for a service with five dependencies. Writing a WireMock stub for an external API with a 40-field response. Enumerating edge cases for a method you just wrote and understand completely. That cognitive overhead adds up.
AI handles the mechanical parts well. After using it across a few projects, I’ve settled on specific workflows for different testing scenarios: unit tests with Mockito, integration tests with Testcontainers, external API mocking with WireMock, and coverage gap analysis. This is what actually works.
What AI Does Well (and Where It Falls Short)
The first thing I noticed when I started using AI for tests—around day 21 of a structured experiment—was that AI gets you to 60% very fast. Structure, imports, Mockito setup, the obvious happy path and one or two unhappy paths. That’s the bulk of the boilerplate that eats time.
What it doesn’t do well: understanding what your business logic is actually supposed to enforce. If your OrderService has a rule that backorders can’t be placed for discontinued products, AI doesn’t know that rule. It sees your code, not your requirements. The tests it generates verify that the code runs—not that the code is correct.
That distinction matters. An AI-generated test suite can pass at 100% while your business logic is completely wrong. The tests confirm that createOrder() calls orderRepository.save(), not that createOrder() correctly handles the discontinued product case.
So: use AI for the scaffolding and mechanical enumeration. You supply the business knowledge.
Unit Tests: JUnit 5 and Mockito
Here’s a service worth testing:
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentGatewayClient gatewayClient;
private final OrderRepository orderRepository;
private final NotificationService notificationService;
public PaymentResult processPayment(Long orderId, PaymentRequest request) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
if (order.getStatus() == OrderStatus.PAID) {
throw new IllegalStateException("Order " + orderId + " is already paid");
}
GatewayResponse response = gatewayClient.charge(request);
if (response.isSuccess()) {
order.setStatus(OrderStatus.PAID);
orderRepository.save(order);
notificationService.sendConfirmation(order);
}
return PaymentResult.from(response);
}
}
The prompt that produces a usable test class:
Here is my PaymentService class. Write a JUnit 5 test class using Mockito.
Requirements:
- Use @ExtendWith(MockitoExtension.class)
- Mock all three dependencies: PaymentGatewayClient, OrderRepository, NotificationService
- Test cases: successful payment, order not found (OrderNotFoundException), order already paid (IllegalStateException), gateway failure (response.isSuccess() returns false)
- Use when/thenReturn for stubbing, verify() for side-effect assertions
- Import statements included
[paste full PaymentService source]
The output you get (and what to fix):
@ExtendWith(MockitoExtension.class)
class PaymentServiceTest {
@Mock
private PaymentGatewayClient gatewayClient;
@Mock
private OrderRepository orderRepository;
@Mock
private NotificationService notificationService;
@InjectMocks
private PaymentService paymentService;
@Test
void processPayment_success_updatesOrderAndSendsNotification() {
Long orderId = 1L;
Order order = new Order(orderId, OrderStatus.PENDING);
PaymentRequest request = new PaymentRequest("tok_visa", 9999);
GatewayResponse response = GatewayResponse.success("ch_abc123");
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
when(gatewayClient.charge(request)).thenReturn(response);
PaymentResult result = paymentService.processPayment(orderId, request);
assertThat(result.isSuccess()).isTrue();
assertThat(order.getStatus()).isEqualTo(OrderStatus.PAID);
verify(orderRepository).save(order);
verify(notificationService).sendConfirmation(order);
}
@Test
void processPayment_orderNotFound_throwsOrderNotFoundException() {
when(orderRepository.findById(99L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> paymentService.processPayment(99L, new PaymentRequest()))
.isInstanceOf(OrderNotFoundException.class);
verifyNoInteractions(gatewayClient, notificationService);
}
@Test
void processPayment_alreadyPaid_throwsIllegalStateException() {
Order order = new Order(1L, OrderStatus.PAID);
when(orderRepository.findById(1L)).thenReturn(Optional.of(order));
assertThatThrownBy(() -> paymentService.processPayment(1L, new PaymentRequest()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("already paid");
verifyNoInteractions(gatewayClient);
}
@Test
void processPayment_gatewayFailure_doesNotUpdateOrder() {
Order order = new Order(1L, OrderStatus.PENDING);
GatewayResponse response = GatewayResponse.failure("card_declined");
when(orderRepository.findById(1L)).thenReturn(Optional.of(order));
when(gatewayClient.charge(any())).thenReturn(response);
PaymentResult result = paymentService.processPayment(1L, new PaymentRequest());
assertThat(result.isSuccess()).isFalse();
assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
verify(orderRepository, never()).save(any());
verify(notificationService, never()).sendConfirmation(any());
}
}
This is solid output. The verifyNoInteractions calls are a nice touch—they confirm that a gateway call never happens when an exception is thrown early. Review the assertions: confirm the expected values match your actual domain, not what AI assumed.
Adversarial Test Prompting
The most useful thing I found—around day 60—was shifting the prompt goal from “verify it works” to “find where it breaks.”
Standard prompt: “Write tests for my PaymentService.” Adversarial prompt: “Write tests designed to break my PaymentService. Focus on null inputs, boundary values, concurrent modification, empty collections, and exception paths my implementation might not handle correctly.”
The adversarial approach produces different tests:
@Test
void processPayment_nullRequest_throwsNullPointerOrValidationException() {
Order order = new Order(1L, OrderStatus.PENDING);
when(orderRepository.findById(1L)).thenReturn(Optional.of(order));
// AI flagged: no null check on PaymentRequest before passing to gateway
assertThatThrownBy(() -> paymentService.processPayment(1L, null))
.isInstanceOf(Exception.class); // Confirm which exception is intended
}
@Test
void processPayment_gatewayThrowsException_propagatesCorrectly() {
Order order = new Order(1L, OrderStatus.PENDING);
when(orderRepository.findById(1L)).thenReturn(Optional.of(order));
when(gatewayClient.charge(any())).thenThrow(new GatewayTimeoutException("timeout"));
// AI flagged: no try/catch around gateway call—timeout propagates to caller
assertThatThrownBy(() -> paymentService.processPayment(1L, new PaymentRequest()))
.isInstanceOf(GatewayTimeoutException.class);
verify(orderRepository, never()).save(any());
}
@Test
void processPayment_notificationFails_orderStillMarkedPaid() {
Order order = new Order(1L, OrderStatus.PENDING);
GatewayResponse response = GatewayResponse.success("ch_abc123");
when(orderRepository.findById(1L)).thenReturn(Optional.of(order));
when(gatewayClient.charge(any())).thenReturn(response);
doThrow(new RuntimeException("SMTP unavailable")).when(notificationService).sendConfirmation(any());
// AI flagged: notification failure would roll back order status if @Transactional
assertThatThrownBy(() -> paymentService.processPayment(1L, new PaymentRequest()))
.isInstanceOf(RuntimeException.class);
}
That last test found a real issue: if notificationService.sendConfirmation() throws and the method is @Transactional, the orderRepository.save() call rolls back—leaving a paid gateway charge but a PENDING order status. AI flagged the risk from the code structure alone. Whether that’s the intended behavior is your call, but the test forces you to decide.
Integration Tests with Testcontainers
For integration tests, AI is most useful for generating the boilerplate that @ServiceConnection and Testcontainers require:
Write a Spring Boot integration test using Testcontainers for PostgreSQL.
- Use @SpringBootTest and @ActiveProfiles("test")
- Use PostgreSQLContainer with @ServiceConnection (Spring Boot 3.1+)
- Static container shared across test methods
- Test: save an order, retrieve it, verify data persisted correctly
- Include the full class with imports and @DynamicPropertySource if needed
[paste your Order entity and OrderRepository interface]
Result:
@SpringBootTest
@ActiveProfiles("test")
@Testcontainers
class OrderRepositoryIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private OrderRepository orderRepository;
@Autowired
private TestEntityManager entityManager;
@Test
@Transactional
void saveAndRetrieveOrder_persistsCorrectly() {
Order order = new Order();
order.setCustomerId(42L);
order.setStatus(OrderStatus.PENDING);
order.setTotal(new BigDecimal("99.99"));
Order saved = orderRepository.save(order);
entityManager.flush();
entityManager.clear();
Order retrieved = orderRepository.findById(saved.getId()).orElseThrow();
assertThat(retrieved.getCustomerId()).isEqualTo(42L);
assertThat(retrieved.getStatus()).isEqualTo(OrderStatus.PENDING);
assertThat(retrieved.getTotal()).isEqualByComparingTo("99.99");
}
@Test
@Transactional
void findByCustomerId_returnsOnlyThatCustomersOrders() {
orderRepository.save(orderFor(1L));
orderRepository.save(orderFor(1L));
orderRepository.save(orderFor(2L));
entityManager.flush();
List<Order> found = orderRepository.findByCustomerId(1L);
assertThat(found).hasSize(2)
.allMatch(o -> o.getCustomerId().equals(1L));
}
private Order orderFor(Long customerId) {
Order o = new Order();
o.setCustomerId(customerId);
o.setStatus(OrderStatus.PENDING);
o.setTotal(BigDecimal.TEN);
return o;
}
}
The @ServiceConnection annotation is Spring Boot 3.1+ and wires the container’s JDBC URL, username, and password directly—no @DynamicPropertySource needed. AI gets this right if you specify the Spring Boot version in your prompt.
WireMock for External API Mocking
This is where AI saves the most time. Writing WireMock stubs by hand for complex JSON responses is tedious and error-prone. The prompt workflow:
- Paste the actual API response JSON you got from the external service
- Paste your HTTP client interface
- Ask for the WireMock stub and the test
Write a Spring Boot test using WireMock for this external payment gateway integration.
Here is the actual gateway success response JSON:
{
"id": "ch_abc123",
"status": "succeeded",
"amount": 9999,
"currency": "usd",
"payment_method": {
"type": "card",
"card": {
"brand": "visa",
"last4": "4242",
"exp_month": 12,
"exp_year": 2027
}
},
"metadata": {}
}
Here is my gateway client:
[paste PaymentGatewayClient source]
Requirements:
- Use @SpringBootTest with @AutoConfigureWireMock
- Stub a POST /v1/charges with this response body
- Verify the correct request body is sent
- Confirm the response is mapped to GatewayResponse correctly
The output:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWireMock(port = 0)
@ActiveProfiles("test")
class PaymentGatewayClientIntegrationTest {
@Autowired
private PaymentGatewayClient gatewayClient;
@Test
void charge_successResponse_mapsToGatewayResponse() {
stubFor(post(urlEqualTo("/v1/charges"))
.withHeader("Content-Type", containing("application/json"))
.withRequestBody(matchingJsonPath("$.amount", equalTo("9999")))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{
"id": "ch_abc123",
"status": "succeeded",
"amount": 9999,
"currency": "usd",
"payment_method": {
"type": "card",
"card": {
"brand": "visa",
"last4": "4242",
"exp_month": 12,
"exp_year": 2027
}
},
"metadata": {}
}
""")));
PaymentRequest request = new PaymentRequest("tok_visa", 9999);
GatewayResponse response = gatewayClient.charge(request);
assertThat(response.isSuccess()).isTrue();
assertThat(response.getChargeId()).isEqualTo("ch_abc123");
assertThat(response.getLast4()).isEqualTo("4242");
}
@Test
void charge_networkTimeout_throwsGatewayTimeoutException() {
stubFor(post(urlEqualTo("/v1/charges"))
.willReturn(aResponse()
.withFixedDelay(5000) // exceed client timeout
.withStatus(200)));
assertThatThrownBy(() -> gatewayClient.charge(new PaymentRequest("tok_visa", 100)))
.isInstanceOf(GatewayTimeoutException.class);
}
@Test
void charge_gatewayReturns402_throwsCardDeclinedException() {
stubFor(post(urlEqualTo("/v1/charges"))
.willReturn(aResponse()
.withStatus(402)
.withBody("""
{"error": {"code": "card_declined", "message": "Your card was declined."}}
""")));
assertThatThrownBy(() -> gatewayClient.charge(new PaymentRequest("tok_declined", 100)))
.isInstanceOf(CardDeclinedException.class)
.hasMessageContaining("card_declined");
}
}
The WireMock DSL for request matching (matchingJsonPath, containing) is where most developers spend time looking at docs. AI gets it right on the first try when you give it concrete request and response examples. Day 62 of my experiment: this cut the time to test a new external integration from about 2 hours to 30 minutes.
Coverage Gap Analysis
The day 65 workflow is simple but effective. Export your JaCoCo coverage report and paste the relevant XML alongside the class under test:
Here is my PaymentService class and the JaCoCo XML coverage report for it.
Identify which branches, conditions, and code paths are not covered by existing tests.
For each gap, suggest the test scenario that would cover it.
[PaymentService source]
[JaCoCo XML excerpt for PaymentService]
JaCoCo XML reports branch coverage at the bytecode level. AI parses it and maps it back to your source code, then tells you things like: “Line 47 has a conditional branch that’s only covered in the true path—the false path (when order.getTotal() is zero) has no test.”
That’s faster than staring at a coverage report in IntelliJ trying to remember which red lines matter.
Common Mistakes
Trusting AI tests without reading assertions. AI generates assertions that look correct but check the wrong thing. assertThat(result).isNotNull() passes even if result contains garbage data. Read every assert line and confirm it actually verifies what you intend.
AI hallucinating method names. If your codebase has non-standard method names, AI invents plausible-sounding ones that don’t exist. Always compile after generation, don’t just trust the test structure.
Missing business invariants. AI tests verify the code that exists, not the requirements the code is supposed to meet. A service that accepts a zero-amount payment when it shouldn’t will have perfectly passing AI-generated tests unless you explicitly ask AI to check business rules.
Skipping verification of verify() calls. AI often adds verify(mock).method() calls that technically pass but verify the wrong arguments. Use ArgumentCaptor when argument correctness matters, and check that AI’s verify() calls match your actual expectations.
Not telling AI the test framework version. JUnit 4 vs JUnit 5 annotations are completely different. Mockito 4+ has different behavior than Mockito 3. Include @ExtendWith(MockitoExtension.class) vs @RunWith(MockitoJUnitRunner.class) in your requirements or AI will guess.
Treating the AI output as reviewed. The value proposition is speed to a working draft. If you paste AI tests into your PR without reading them, you’ve automated the part that takes 20 minutes and skipped the review that takes 5. That’s a bad trade.