DEV Community

Cover image for Real-World TDD with Claude AI: Building a User Registration System in Java
Jacky
Jacky

Posted on

Real-World TDD with Claude AI: Building a User Registration System in Java

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    });
}
Enter fullscreen mode Exit fullscreen mode

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)