5.09 테스트 전략

사용자 도메인의 테스트는 도메인 모델의 무결성과 비즈니스 규칙의 정확한 구현을 보장하기 위해 체계적으로 수행되어야 합니다. 여러 계층에 걸친 포괄적인 테스트 전략을 살펴보겠습니다.

값 객체 테스트

값 객체는 도메인의 기본적인 빌딩 블록이므로, 철저한 테스트가 필요합니다.

package net.badnom.library.user.domain.user;

@DisplayName("Email 값 객체 테스트")
class EmailTest {
    @Nested
    @DisplayName("이메일 생성 시")
    class Creation {
        @Test
        @DisplayName("올바른 형식의 이메일이면 성공한다")
        void shouldCreateValidEmail() {
            // Given
            String validAddress = "user@example.com";

            // When
            Result<Email> result = Email.of(validAddress);

            // Then
            assertThat(result.isSuccess()).isTrue();
            assertThat(result.getValue().toString()).isEqualTo(validAddress);
        }

        @ParameterizedTest
        @DisplayName("잘못된 형식의 이메일이면 실패한다")
        @ValueSource(strings = {
            "invalid-email",
            "@example.com",
            "user@",
            "user@.com",
            "user@example."
        })
        void shouldFailWithInvalidEmail(String invalidAddress) {
            // When
            Result<Email> result = Email.of(invalidAddress);

            // Then
            assertThat(result.isFailure()).isTrue();
            assertThat(result.getErrorCode())
                .isEqualTo(ErrorCodes.EMAIL_INVALID_PATTERN);
        }
    }

    @Nested
    @DisplayName("이메일 도메인 추출 시")
    class DomainExtraction {
        @Test
        @DisplayName("이메일에서 도메인 부분을 정확히 추출한다")
        void shouldExtractDomainCorrectly() {
            // Given
            Email email = Email.of("user@example.com").getValue();

            // When
            String domain = email.getDomain();

            // Then
            assertThat(domain).isEqualTo("example.com");
        }
    }
}

엔티티 테스트

엔티티 테스트는 비즈니스 규칙과 상태 변경을 검증합니다.

package net.badnom.library.user.domain.user;

@DisplayName("Member 엔티티 테스트")
class MemberTest {
    @Nested
    @DisplayName("회원 생성 시")
    class Creation {
        private Username username;
        private HashedPassword password;
        private Email email;
        private FullName fullName;
        private UniqueEmailSpecification uniqueEmailSpec;
        private UniqueUsernameSpecification uniqueUsernameSpec;

        @BeforeEach
        void setUp() {
            username = Username.of("testuser").getValue();
            password = HashedPassword.of("hashedpass").getValue();
            email = Email.of("test@example.com").getValue();
            fullName = FullName.of("Test User").getValue();
            uniqueEmailSpec = mock(UniqueEmailSpecification.class);
            uniqueUsernameSpec = mock(UniqueUsernameSpecification.class);
        }

        @Test
        @DisplayName("유효한 데이터로 생성하면 성공한다")
        void shouldCreateValidMember() {
            // Given
            when(uniqueEmailSpec.isSatisfiedBy(email))
                .thenReturn(Result.success(true));
            when(uniqueUsernameSpec.isSatisfiedBy(username))
                .thenReturn(Result.success(true));

            // When
            Result<Member> result = Member.create(
                username, password, email, 
                uniqueEmailSpec, uniqueUsernameSpec, fullName);

            // Then
            assertThat(result.isSuccess()).isTrue();
            Member member = result.getValue();
            assertSoftly(softly -> {
                softly.assertThat(member.getUsername()).isEqualTo(username);
                softly.assertThat(member.getEmail()).isEqualTo(email);
                softly.assertThat(member.getFullName()).isEqualTo(fullName);
                softly.assertThat(member.getCurrentTier())
                    .isEqualTo(MembershipTier.BASIC);
                softly.assertThat(member.getStatus())
                    .isEqualTo(UserStatus.PENDING);
            });
        }
    }

    @Nested
    @DisplayName("등급 변경 시")
    class TierChange {
        private Member member;

        @BeforeEach
        void setUp() {
            member = TestMemberBuilder.aDefaultMember().build();
        }

        @Test
        @DisplayName("상위 등급으로 변경하면 성공한다")
        void shouldUpgradeTier() {
            // When
            Result<Void> result = member.changeTier(MembershipTier.GOLD);

            // Then
            assertThat(result.isSuccess()).isTrue();
            assertThat(member.getCurrentTier()).isEqualTo(MembershipTier.GOLD);

            // 이벤트 검증
            List<DomainEvent> events = member.getDomainEvents();
            assertThat(events)
                .hasSize(1)
                .first()
                .isInstanceOf(MemberTierChangedEvent.class);
        }
    }
}

명세(Specification) 테스트

명세의 검증 로직을 테스트합니다.

package net.badnom.library.user.domain.user.specification;

@DisplayName("UniqueEmailSpecification 테스트")
class UniqueEmailSpecificationTest {
    private UserRepository userRepository;
    private UniqueEmailSpecification specification;

    @BeforeEach
    void setUp() {
        userRepository = mock(UserRepository.class);
        specification = new UniqueEmailSpecification(userRepository);
    }

    @Test
    @DisplayName("존재하지 않는 이메일이면 성공한다")
    void shouldPassForUnusedEmail() {
        // Given
        Email email = Email.of("unused@example.com").getValue();
        when(userRepository.existsByEmail(email)).thenReturn(false);

        // When
        Result<Boolean> result = specification.isSatisfiedBy(email);

        // Then
        assertThat(result.isSuccess()).isTrue();
        assertThat(result.getValue()).isTrue();
    }
}

애플리케이션 서비스 테스트

명령 핸들러의 동작을 테스트합니다.

package net.badnom.library.user.application.member.command;

@DisplayName("RegisterMemberCommandHandler 테스트")
class RegisterMemberCommandHandlerTest {
    private UserRepository userRepository;
    private PasswordHasher passwordHasher;
    private RegisterMemberCommandHandler handler;

    @BeforeEach
    void setUp() {
        userRepository = mock(UserRepository.class);
        passwordHasher = mock(PasswordHasher.class);
        handler = new RegisterMemberCommandHandler(
            userRepository, passwordHasher);
    }

    @Test
    @DisplayName("유효한 명령을 처리하여 회원을 생성한다")
    void shouldHandleValidCommand() {
        // Given
        RegisterMemberCommand command = new RegisterMemberCommand(
            Username.of("newuser").getValue(),
            Password.of("Pass123!").getValue(),
            Email.of("new@example.com").getValue(),
            FullName.of("New User").getValue()
        );

        HashedPassword hashedPassword = 
            HashedPassword.of("hashed").getValue();
        when(passwordHasher.hash(command.password()))
            .thenReturn(hashedPassword);
        when(userRepository.existsByEmail(command.email()))
            .thenReturn(false);

        // When
        Result<UserId> result = handler.handle(command);

        // Then
        assertThat(result.isSuccess()).isTrue();
        verify(userRepository).create(any(Member.class));
    }
}

통합 테스트

실제 컴포넌트들과의 상호작용을 테스트합니다.

package net.badnom.library.user.integration;

@SpringBootTest
class UserIntegrationTest {
    @Autowired
    private RegisterMemberCommandHandler registerHandler;

    @Autowired
    private AuthenticateUserCommandHandler authHandler;

    @Autowired
    private UserRepository userRepository;

    @Test
    @DisplayName("회원 가입 후 로그인이 가능하다")
    void shouldRegisterAndAuthenticate() {
        // Given
        String username = "integrationtest";
        String password = "Test123!";
        String email = "integration@example.com";

        RegisterMemberCommand registerCommand = new RegisterMemberCommand(
            Username.of(username).getValue(),
            Password.of(password).getValue(),
            Email.of(email).getValue(),
            FullName.of("Integration Test").getValue()
        );

        // When
        Result<UserId> registerResult = registerHandler.handle(registerCommand);
        assertThat(registerResult.isSuccess()).isTrue();

        AuthenticateUserCommand authCommand = 
            new AuthenticateUserCommand(username, password);
        Result<UserId> authResult = authHandler.handle(authCommand);

        // Then
        assertThat(authResult.isSuccess()).isTrue();
        assertThat(authResult.getValue())
            .isEqualTo(registerResult.getValue());
    }
}

테스트 지원 클래스

테스트 데이터 생성을 돕는 빌더 클래스를 구현합니다.

package net.badnom.library.user.domain.user;

public class TestMemberBuilder {
    private Username username = Username.of("testuser").getValue();
    private HashedPassword password = 
        HashedPassword.of("hashedpass").getValue();
    private Email email = Email.of("test@example.com").getValue();
    private FullName fullName = FullName.of("Test User").getValue();
    private MembershipTier tier = MembershipTier.BASIC;

    public static TestMemberBuilder aDefaultMember() {
        return new TestMemberBuilder();
    }

    public TestMemberBuilder withUsername(String username) {
        this.username = Username.of(username).getValue();
        return this;
    }

    public TestMemberBuilder withEmail(String email) {
        this.email = Email.of(email).getValue();
        return this;
    }

    public TestMemberBuilder withTier(MembershipTier tier) {
        this.tier = tier;
        return this;
    }

    public Member build() {
        return new Member(
            UserId.newId(),
            username,
            password,
            email,
            fullName,
            tier
        );
    }
}

테스트 전략의 핵심 원칙:

  1. 계층별 테스트

    • 값 객체, 엔티티, 명세, 서비스 등 각 계층에 맞는 테스트 접근을 구현합니다
    • 적절한 격리 수준을 유지합니다
    • 테스트 가능한 설계를 지향합니다
  2. 명확한 테스트 구조

    • Given-When-Then 패턴을 사용합니다
    • 명확한 테스트 이름과 설명을 제공합니다
    • 중첩 클래스를 통한 테스트를 그룹화합니다
  3. 테스트 데이터 관리

    • 테스트 데이터 빌더를 활용합니다
    • 의미 있는 테스트 데이터를 사용합니다
    • 경계 조건을 고려합니다
  4. 통합 테스트 전략

    • 실제 환경과 유사한 설정을 구성합니다
    • 주요 시나리오를 검증합니다
    • 외부 의존성을 적절히 처리합니다

이러한 체계적인 테스트 전략을 통해 도메인 모델의 정확성을 보장하고, 변경에 대한 안정성을 확보할 수 있습니다.