AI is writing frontend code faster than most teams can review it. But speed without strategy is just technical debt in disguise.
When you write a service or repository yourself, there's a direct line between what you intended and what the code does. You know why that null check is there. You know why you chose to throw a specific exception type. Testing is basically about verifying that your intentions are correctly expressed in the code. With AI-generated code, that line gets blurry. You have an intention, the AI interprets that intention, and then you get code. You're now testing two things: whether your prompt was precise enough, and whether the AI's interpretation matches what you actually need. Those are different problems. And honestly, conflating them is where most people get into trouble. The practical result is that you can't give AI-generated code the same trust you'd give your own. Not because it's bad, but because it's opaque. You didn't write it, so your intuition about what edge cases are handled is unreliable. I treat it roughly the same way I'd treat code from a third-party library I'm evaluating: assume nothing, read it carefully, test the contracts explicitly. Start With a Service Contract Before writing a single test, I now define what I'm calling a "service contract". Nothing formal, just a short spec: what inputs does this method accept, what does it return, what exceptions can it throw, and what side effects does it produce? For a user registration service, it might look like: Accepts a RegisterUserRequest with a non-null email and password Returns a UserResponse with the created user's ID and email Throws DuplicateEmailException if the email already exists Throws IllegalArgumentException if the email format is invalid Persists exactly one User entity to the database Publishes a UserRegisteredEvent to the application event bus That's the contract. My tests verify the contract, not the implementation. I don't care how the AI chose to validate the email format. I care that invalid emails are rejected with the right exception. This matters a lot because AI tools make it very easy to regenerate or refactor a method, and if your tests are tightly coupled to internal implementation details, you'll be rewriting them constantly. Test the contract and they survive regeneration. Here's roughly what those tests look like in JUnit 5 with Mockito: import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class UserRegistrationServiceTest { @Mock private UserRepository userRepository; @Mock private ApplicationEventPublisher eventPublisher; @InjectMocks private UserRegistrationService service; @Test void registersUserAndReturnsResponse() { var request = new RegisterUserRequest("alice@example.com", "s3cr3t"); when(userRepository.existsByEmail("alice@example.com")).thenReturn(false); when(userRepository.save(any(User.class))) .thenAnswer(inv -> { User u = inv.getArgument(0); u.setId(42L); return u; }); UserResponse response = service.register(request); assertThat(response.id()).isEqualTo(42L); assertThat(response.email()).isEqualTo("alice@example.com"); verify(eventPublisher).publishEvent(any(UserRegisteredEvent.class)); } @Test void throwsDuplicateEmailExceptionWhenEmailAlreadyExists() { when(userRepository.existsByEmail("alice@example.com")).thenReturn(true); assertThatThrownBy(() -> service.register(new RegisterUserRequest("alice@example.com", "s3cr3t")) ).isInstanceOf(DuplicateEmailException.class); verify(userRepository, never()).save(any()); verify(eventPublisher, never()).publishEvent(any()); } @Test void throwsIllegalArgumentExceptionForInvalidEmail() { assertThatThrownBy(() -> service.register(new RegisterUserRequest("not-an-email", "s3cr3t")) ).isInstanceOf(IllegalArgumentException.class); } } Clean, readable, and totally indifferent to how the AI chose to implement the internals. That's the goal. Integration Tests Are Where AI Code Really Gets Exposed Unit tests with mocks are a good start, but they only validate the logic in isolation. AI-generated code tends to fall apart at the boundaries: how it talks to the database, how it handles transaction rollbacks, how it behaves when upstream services return unexpected responses. I use Spring Boot's @SpringBootTest with Testcontainers for integration tests. Real Postgres instance, real schema, real queries. The setup cost is a bit higher, but it's caught more bugs in AI-generated code than anything else in my workflow. import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.testcontainers.junit.jupiter.Testcontainers; import static org.assertj.core.api.Assertions.*; @SpringBootTest @Testcontainers class UserRegistrationIntegrationTest extends AbstractIntegrationTest { @Autowired private UserRegistrationService service; @Autowired private UserRepository userRepository; @Test void persistsUserToDatabase() { var request = new RegisterUserRequest("bob@example.com", "s3cr3t"); UserResponse response = service.register(request); assertThat(userRepository.findById(response.id())).isPresent(); } @Test void rollsBackTransactionOnEventPublishFailure() { // If the event publisher throws, the user should NOT be persisted // This is exactly the kind of transactional edge case AI code gets wrong assertThatThrownBy(() -> service.register(new RegisterUserRequest("fail@example.com", "s3cr3t")) ); assertThat(userRepository.findByEmail("fail@example.com")).isEmpty(); } } That second test, the transaction rollback one, caught a real bug in AI-generated code on the rewrite project. The AI had put @Transactional on the service class but the event publish call happened after the commit boundary. The unit test passed because we'd mocked the publisher. The integration test failed because the actual behavior was wrong. That's the gap you need to close. Treat AI-Generated Exception Handling With Extra Suspicion This is the thing that's bitten me more than anything else. AI-generated Java code tends to handle exceptions in ways that look fine but are subtly wrong. Common patterns I've found: Catching Exception instead of a specific type, which silently swallows things you wanted to propagate Logging and rethrowing, but losing the original stack trace by not passing the cause to the new exception Checked exceptions converted to unchecked ones without any documentation or wrapper type Optional.get() called without isPresent() checks (AI loves generating this) I now write explicit tests for exception paths, not just happy paths. And I check the exception type and message , not just that something was thrown. @Test void preservesOriginalCauseWhenWrappingException() { // AI often does: throw new ServiceException(e.getMessage()) // which loses the cause. This test enforces the correct behavior. var dbException = new DataAccessException("connection timeout") {}; when(userRepository.save(any())).thenThrow(dbException); assertThatThrownBy(() -> service.register(new RegisterUserRequest("test@example.com", "s3cr3t")) ) .isInstanceOf(ServiceException.class) .hasCause(dbException); // cause must be preserved } Took me a while to start writing tests like this consistently. But AI code makes it necessary. Parameterized Tests for the Edge Cases AI Misses AI-generated code tends to handle the o...