Let’s walk through a complete Test-Driven Development cycle using Claude AI assistance, from initial requirements to fully tested code. We’ll build a user registration system that demonstrates how AI can accelerate and improve your TDD workflow.
The Requirements
Our product manager provides these requirements for a user registration feature:
“We need a user registration system that accepts email and password. Email must be valid format and unique in our system. Password must be at least 8 characters with one uppercase, one lowercase, and one number. We should hash passwords before storing and return appropriate error messages for validation failures.”
Step 1: Translating Requirements to Test Cases with Claude
First, I asked Claude: “Based on these requirements, what test cases should I write for a UserRegistrationService?”
Claude suggested these test scenarios:
Valid registration with proper email and password
Invalid email formats (missing @, invalid domain, empty)
Duplicate email registration attempts
Password validation (too short, missing uppercase/lowercase/number)
Password hashing verification
Proper error message returns
Step 2: Setting Up the Test Structure
Let’s start with our test class structure:
package com.example.registration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
public class UserRegistrationServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private PasswordHasher passwordHasher;
private UserRegistrationService registrationService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
registrationService = new UserRegistrationService(userRepository, passwordHasher);
}
}
Step 3: Writing Failing Tests (Red Phase)
Claude helped me write comprehensive failing tests:
@Test
void shouldRegisterUserWithValidEmailAndPassword() {
// Arrange
String email = "[email protected]";
String password = "SecurePass123";
String hashedPassword = "hashed_password_here";
when(userRepository.existsByEmail(email)).thenReturn(false);
when(passwordHasher.hash(password)).thenReturn(hashedPassword);
// Act
RegistrationResult result = registrationService.registerUser(email, password);
// Assert
assertTrue(result.isSuccess());
assertNull(result.getErrorMessage());
verify(userRepository).save(any(User.class));
}
@Test
void shouldRejectInvalidEmailFormat() {
// Arrange
String invalidEmail = "not-an-email";
String password = "ValidPass123";
// Act
RegistrationResult result = registrationService.registerUser(invalidEmail, password);
// Assert
assertFalse(result.isSuccess());
assertEquals("Invalid email format", result.getErrorMessage());
verify(userRepository, never()).save(any(User.class));
}
@Test
void shouldRejectDuplicateEmail() {
// Arrange
String existingEmail = "[email protected]";
String password = "ValidPass123";
when(userRepository.existsByEmail(existingEmail)).thenReturn(true);
// Act
RegistrationResult result = registrationService.registerUser(existingEmail, password);
// Assert
assertFalse(result.isSuccess());
assertEquals("Email already registered", result.getErrorMessage());
verify(userRepository, never()).save(any(User.class));
}
@Test
void shouldRejectWeakPassword() {
// Arrange
String email = "[email protected]";
String weakPassword = "weak";
when(userRepository.existsByEmail(email)).thenReturn(false);
// Act
RegistrationResult result = registrationService.registerUser(email, weakPassword);
// Assert
assertFalse(result.isSuccess());
assertEquals("Password must be at least 8 characters with uppercase, lowercase, and number",
result.getErrorMessage());
verify(userRepository, never()).save(any(User.class));
}
@Test
void shouldHashPasswordBeforeStoring() {
// Arrange
String email = "[email protected]";
String password = "MySecurePass123";
String hashedPassword = "hashed_version";
when(userRepository.existsByEmail(email)).thenReturn(false);
when(passwordHasher.hash(password)).thenReturn(hashedPassword);
// Act
registrationService.registerUser(email, password);
// Assert
ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
verify(userRepository).save(userCaptor.capture());
User savedUser = userCaptor.getValue();
assertEquals(email, savedUser.getEmail());
assertEquals(hashedPassword, savedUser.getPasswordHash());
assertNotEquals(password, savedUser.getPasswordHash()); // Ensure it's hashed
}
Step 4: Creating Supporting Classes
Claude helped me identify the needed supporting classes:
// RegistrationResult.java
public class RegistrationResult {
private final boolean success;
private final String errorMessage;
private RegistrationResult(boolean success, String errorMessage) {
this.success = success;
this.errorMessage = errorMessage;
}
public static RegistrationResult success() {
return new RegistrationResult(true, null);
}
public static RegistrationResult failure(String errorMessage) {
return new RegistrationResult(false, errorMessage);
}
public boolean isSuccess() { return success; }
public String getErrorMessage() { return errorMessage; }
}
// User.java
public class User {
private String email;
private String passwordHash;
public User(String email, String passwordHash) {
this.email = email;
this.passwordHash = passwordHash;
}
// Getters
public String getEmail() { return email; }
public String getPasswordHash() { return passwordHash; }
}
// UserRepository.java (interface)
public interface UserRepository {
boolean existsByEmail(String email);
void save(User user);
}
// PasswordHasher.java (interface)
public interface PasswordHasher {
String hash(String password);
}
Step 5: Implementing the Service (Green Phase)
Now let’s make the tests pass with the minimal implementation:
package com.example.registration;
import java.util.regex.Pattern;
public class UserRegistrationService {
private static final Pattern EMAIL_PATTERN =
Pattern.compile("^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})$");
private static final Pattern PASSWORD_PATTERN =
Pattern.compile("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).{8,}$");
private final UserRepository userRepository;
private final PasswordHasher passwordHasher;
public UserRegistrationService(UserRepository userRepository,
PasswordHasher passwordHasher) {
this.userRepository = userRepository;
this.passwordHasher = passwordHasher;
}
public RegistrationResult registerUser(String email, String password) {
// Validate email format
if (!isValidEmail(email)) {
return RegistrationResult.failure("Invalid email format");
}
// Check for duplicate email
if (userRepository.existsByEmail(email)) {
return RegistrationResult.failure("Email already registered");
}
// Validate password strength
if (!isValidPassword(password)) {
return RegistrationResult.failure(
"Password must be at least 8 characters with uppercase, lowercase, and number");
}
// Hash password and save user
String hashedPassword = passwordHasher.hash(password);
User user = new User(email, hashedPassword);
userRepository.save(user);
return RegistrationResult.success();
}
private boolean isValidEmail(String email) {
return email != null && EMAIL_PATTERN.matcher(email).matches();
}
private boolean isValidPassword(String password) {
return password != null && PASSWORD_PATTERN.matcher(password).matches();
}
}
Step 6: Refactoring with Claude’s Help
Claude suggested several improvements for the refactor phase:
Extract validation logic into separate validator classes
Add more specific password validation messages
Create custom exceptions for different error types
Here’s the refactored version:
// EmailValidator.java
public class EmailValidator {
private static final Pattern EMAIL_PATTERN =
Pattern.compile("^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})$");
public ValidationResult validate(String email) {
if (email == null || email.trim().isEmpty()) {
return ValidationResult.invalid("Email is required");
}
if (!EMAIL_PATTERN.matcher(email).matches()) {
return ValidationResult.invalid("Invalid email format");
}
return ValidationResult.valid();
}
}
// PasswordValidator.java
public class PasswordValidator {
public ValidationResult validate(String password) {
if (password == null || password.length() < 8) {
return ValidationResult.invalid("Password must be at least 8 characters");
}
if (!password.matches(".*[a-z].*")) {
return ValidationResult.invalid("Password must contain lowercase letter");
}
if (!password.matches(".*[A-Z].*")) {
return ValidationResult.invalid("Password must contain uppercase letter");
}
if (!password.matches(".*\\d.*")) {
return ValidationResult.invalid("Password must contain a number");
}
return ValidationResult.valid();
}
}
// Updated UserRegistrationService
public class UserRegistrationService {
private final UserRepository userRepository;
private final PasswordHasher passwordHasher;
private final EmailValidator emailValidator;
private final PasswordValidator passwordValidator;
public UserRegistrationService(UserRepository userRepository,
PasswordHasher passwordHasher,
EmailValidator emailValidator,
PasswordValidator passwordValidator) {
this.userRepository = userRepository;
this.passwordHasher = passwordHasher;
this.emailValidator = emailValidator;
this.passwordValidator = passwordValidator;
}
public RegistrationResult registerUser(String email, String password) {
ValidationResult emailValidation = emailValidator.validate(email);
if (!emailValidation.isValid()) {
return RegistrationResult.failure(emailValidation.getErrorMessage());
}
if (userRepository.existsByEmail(email)) {
return RegistrationResult.failure("Email already registered");
}
ValidationResult passwordValidation = passwordValidator.validate(password);
if (!passwordValidation.isValid()) {
return RegistrationResult.failure(passwordValidation.getErrorMessage());
}
String hashedPassword = passwordHasher.hash(password);
User user = new User(email, hashedPassword);
userRepository.save(user);
return RegistrationResult.success();
}
}
Step 7: Additional Test Cases with Claude
Claude suggested edge cases I hadn’t considered:
@Test
void shouldRejectNullEmail() {
RegistrationResult result = registrationService.registerUser(null, "ValidPass123");
assertFalse(result.isSuccess());
assertEquals("Email is required", result.getErrorMessage());
}
@Test
void shouldRejectEmptyPassword() {
RegistrationResult result = registrationService.registerUser("[email protected]", "");
assertFalse(result.isSuccess());
assertEquals("Password must be at least 8 characters", result.getErrorMessage());
}
@Test
void shouldHandleRepositoryExceptions() {
String email = "[email protected]";
String password = "ValidPass123";
when(userRepository.existsByEmail(email)).thenReturn(false);
when(passwordHasher.hash(password)).thenReturn("hashed");
doThrow(new RuntimeException("Database error")).when(userRepository).save(any(User.class));
assertThrows(RuntimeException.class, () -> {
registrationService.registerUser(email, password);
});
}
Key Benefits of AI-Assisted TDD
Through this real-world example, Claude AI provided several advantages:
Comprehensive Test Coverage: Claude identified edge cases like null inputs and database exceptions that I might have missed initially.
Better Code Structure: The AI suggested extracting validators into separate classes, improving maintainability and single responsibility principle adherence.
Realistic Mock Scenarios: Claude helped create meaningful mock interactions that truly test the service behavior.
Progressive Refinement: As requirements evolved, Claude helped adapt tests and suggest improvements without breaking existing functionality.
Conclusion
This practical example demonstrates how Claude AI transforms TDD from a sometimes tedious process into an efficient, comprehensive development approach. By leveraging AI assistance for test generation, edge case identification, and code structure suggestions, developers can focus on business logic while ensuring robust test coverage.
The key is treating Claude as a knowledgeable pair programming partner who helps you think through scenarios, suggests improvements, and catches potential issues early in the development cycle. The result is higher-quality, well-tested code delivered more efficiently than traditional manual TDD approaches.
Top comments (0)