Skip to the content.

Value Object 테스트 가이드

목적: Record 기반 Value Object의 단위 테스트 패턴과 원칙


1️⃣ 테스트 전략

테스트 대상

Record 기반 VO는 Compact Constructor 검증비즈니스 메서드를 테스트합니다:

✅ 테스트 항목:
1. Compact Constructor 검증 로직 (null 체크, 도메인 규칙)
2. 정적 팩토리 메서드 (of, forNew)
3. 비즈니스 메서드 (연산, 비교, 상태 변화, 검증)
4. equals/hashCode (Record 자동 생성이지만 확인)

테스트 범위


2️⃣ 기본 템플릿

2-1) Simple VO 템플릿 (Money, Email, Price 등)

package com.ryuqq.domain.{bc}.vo;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.*;

/**
 * {VoName} Value Object 단위 테스트
 *
 * <p>테스트 전략:</p>
 * <ul>
 *   <li>Compact Constructor 검증 (도메인 규칙)</li>
 *   <li>정적 팩토리 메서드 (of)</li>
 *   <li>비즈니스 메서드 (연산, 비교)</li>
 *   <li>equals/hashCode (Record 자동 생성)</li>
 * </ul>
 *
 * @author development-team
 * @since 1.0.0
 */
@Tag("unit")
@Tag("domain")
@Tag("vo")
@DisplayName("{VoName} VO 단위 테스트")
class {VoName}Test {

    @Nested
    @DisplayName("정적 팩토리 메서드 테스트")
    class FactoryMethodTests {

        @Test
        @DisplayName("of() - 올바른 값으로 VO가 생성되어야 한다")
        void of_WithValidValue_ShouldCreateVO() {
            // Given
            Long amount = 1000L;

            // When
            Money money = Money.of(amount);

            // Then
            assertThat(money).isNotNull();
            assertThat(money.amount()).isEqualTo(amount);
        }

        @Test
        @DisplayName("of() - null 값이면 예외가 발생해야 한다")
        void of_WithNullValue_ShouldThrowException() {
            // When & Then
            assertThatThrownBy(() -> Money.of(null))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("null일 수 없습니다");
        }
    }

    @Nested
    @DisplayName("Compact Constructor 검증 테스트")
    class CompactConstructorTests {

        @Test
        @DisplayName("음수 값이면 예외가 발생해야 한다")
        void of_WithNegativeValue_ShouldThrowException() {
            // When & Then
            assertThatThrownBy(() -> Money.of(-1L))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("0 이상이어야 합니다");
        }

        @Test
        @DisplayName("0은 허용되어야 한다")
        void of_WithZeroValue_ShouldSucceed() {
            // When
            Money money = Money.of(0L);

            // Then
            assertThat(money.amount()).isEqualTo(0L);
        }
    }

    @Nested
    @DisplayName("비즈니스 메서드 테스트")
    class BusinessMethodTests {

        @Test
        @DisplayName("add() - 두 금액의 합이 반환되어야 한다")
        void add_ShouldReturnSumOfTwoAmounts() {
            // Given
            Money money1 = Money.of(1000L);
            Money money2 = Money.of(500L);

            // When
            Money result = money1.add(money2);

            // Then
            assertThat(result.amount()).isEqualTo(1500L);
        }

        @Test
        @DisplayName("subtract() - 두 금액의 차이가 반환되어야 한다")
        void subtract_ShouldReturnDifferenceOfTwoAmounts() {
            // Given
            Money money1 = Money.of(1000L);
            Money money2 = Money.of(500L);

            // When
            Money result = money1.subtract(money2);

            // Then
            assertThat(result.amount()).isEqualTo(500L);
        }

        @Test
        @DisplayName("isGreaterThan() - 큰 금액이면 true를 반환해야 한다")
        void isGreaterThan_WithGreaterAmount_ShouldReturnTrue() {
            // Given
            Money money1 = Money.of(1000L);
            Money money2 = Money.of(500L);

            // When & Then
            assertThat(money1.isGreaterThan(money2)).isTrue();
            assertThat(money2.isGreaterThan(money1)).isFalse();
        }
    }

    @Nested
    @DisplayName("equals/hashCode 테스트")
    class EqualsAndHashCodeTests {

        @Test
        @DisplayName("같은 값을 가진 VO는 equals()로 동등해야 한다")
        void equals_WithSameValue_ShouldBeEqual() {
            // Given
            Money money1 = Money.of(1000L);
            Money money2 = Money.of(1000L);

            // When & Then
            assertThat(money1).isEqualTo(money2);
            assertThat(money1.hashCode()).isEqualTo(money2.hashCode());
        }

        @Test
        @DisplayName("다른 값을 가진 VO는 equals()로 다르다")
        void equals_WithDifferentValue_ShouldNotBeEqual() {
            // Given
            Money money1 = Money.of(1000L);
            Money money2 = Money.of(2000L);

            // When & Then
            assertThat(money1).isNotEqualTo(money2);
        }
    }

    @Nested
    @DisplayName("상수 테스트")
    class ConstantTests {

        @Test
        @DisplayName("ZERO 상수는 금액이 0인 Money여야 한다")
        void zero_ShouldBeMoneyWithZeroAmount() {
            // When & Then
            assertThat(Money.ZERO.amount()).isEqualTo(0L);
        }
    }
}

2-2) Long ID VO 템플릿 (Auto Increment - OrderId 등)

특징: DB가 ID 생성 (Auto Increment) forNew(): null 반환 isNew(): 필수 (null 여부 확인)

package com.ryuqq.domain.{bc}.vo;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.*;

/**
 * {IdName} Long ID Value Object 단위 테스트
 *
 * <p>테스트 전략:</p>
 * <ul>
 *   <li>forNew() null 생성 (Auto Increment, DB가 ID 생성)</li>
 *   <li>isNew() 체크</li>
 *   <li>양수 검증</li>
 * </ul>
 *
 * @author development-team
 * @since 1.0.0
 */
@Tag("unit")
@Tag("domain")
@Tag("vo")
@DisplayName("{IdName} Long ID VO 단위 테스트")
class {IdName}Test {

    @Nested
    @DisplayName("정적 팩토리 메서드 테스트")
    class FactoryMethodTests {

        @Test
        @DisplayName("forNew() - null 값을 가진 ID VO가 생성되어야 한다")
        void forNew_ShouldCreateVOWithNullValue() {
            // When
            OrderId orderId = OrderId.forNew();

            // Then
            assertThat(orderId).isNotNull();
            assertThat(orderId.value()).isNull();
            assertThat(orderId.isNew()).isTrue();
        }

        @Test
        @DisplayName("of() - 올바른 값을 가진 ID VO가 생성되어야 한다")
        void of_WithValidValue_ShouldCreateVO() {
            // Given
            Long value = 100L;

            // When
            OrderId orderId = OrderId.of(value);

            // Then
            assertThat(orderId).isNotNull();
            assertThat(orderId.value()).isEqualTo(value);
            assertThat(orderId.isNew()).isFalse();
        }

        @Test
        @DisplayName("of() - null 값이면 예외가 발생해야 한다")
        void of_WithNullValue_ShouldThrowException() {
            // When & Then
            assertThatThrownBy(() -> OrderId.of(null))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("null일 수 없습니다");
        }
    }

    @Nested
    @DisplayName("Compact Constructor 검증 테스트")
    class CompactConstructorTests {

        @Test
        @DisplayName("0 이하 값이면 예외가 발생해야 한다")
        void of_WithNonPositiveValue_ShouldThrowException() {
            // When & Then
            assertThatThrownBy(() -> OrderId.of(0L))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("양수여야 합니다");

            assertThatThrownBy(() -> OrderId.of(-1L))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("양수여야 합니다");
        }
    }

    @Nested
    @DisplayName("isNew() 메서드 테스트")
    class IsNewMethodTests {

        @Test
        @DisplayName("forNew()로 생성한 ID는 isNew()가 true")
        void isNew_WithNullValue_ShouldReturnTrue() {
            // Given
            OrderId orderId = OrderId.forNew();

            // When & Then
            assertThat(orderId.isNew()).isTrue();
        }

        @Test
        @DisplayName("of()로 생성한 ID는 isNew()가 false")
        void isNew_WithValue_ShouldReturnFalse() {
            // Given
            OrderId orderId = OrderId.of(100L);

            // When & Then
            assertThat(orderId.isNew()).isFalse();
        }
    }

    @Nested
    @DisplayName("equals/hashCode 테스트")
    class EqualsAndHashCodeTests {

        @Test
        @DisplayName("같은 값을 가진 ID VO는 equals()로 동등해야 한다")
        void equals_WithSameValue_ShouldBeEqual() {
            // Given
            OrderId orderId1 = OrderId.of(100L);
            OrderId orderId2 = OrderId.of(100L);

            // When & Then
            assertThat(orderId1).isEqualTo(orderId2);
            assertThat(orderId1.hashCode()).isEqualTo(orderId2.hashCode());
        }

        @Test
        @DisplayName("forNew()로 생성한 ID는 null 기준으로 동등해야 한다")
        void equals_WithBothNull_ShouldBeEqual() {
            // Given
            OrderId orderId1 = OrderId.forNew();
            OrderId orderId2 = OrderId.forNew();

            // When & Then
            assertThat(orderId1).isEqualTo(orderId2);
        }
    }
}

2-3) UUID ID VO 템플릿 (Application 생성 - UserId 등)

특징: Application이 ID 생성 (UUIDv7) forNew(): UUID 반환 (항상 값 존재) isNew(): 불필요 (항상 값 존재)

package com.ryuqq.domain.{bc}.vo;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.*;

/**
 * {IdName} UUID ID Value Object 단위 테스트
 *
 * <p>테스트 전략:</p>
 * <ul>
 *   <li>forNew() UUIDv7 생성 (Application이 ID 생성)</li>
 *   <li>UUID 형식 검증</li>
 *   <li>null 금지 (항상 값 존재)</li>
 * </ul>
 *
 * <p>Note: UUID ID는 isNew() 메서드가 없습니다 (항상 값 존재)</p>
 *
 * @author development-team
 * @since 1.0.0
 */
@Tag("unit")
@Tag("domain")
@Tag("vo")
@DisplayName("{IdName} UUID ID VO 단위 테스트")
class {IdName}Test {

    @Nested
    @DisplayName("정적 팩토리 메서드 테스트")
    class FactoryMethodTests {

        @Test
        @DisplayName("forNew() - UUIDv7 값을 가진 ID VO가 생성되어야 한다")
        void forNew_ShouldCreateVOWithUUIDValue() {
            // When
            UserId userId = UserId.forNew();

            // Then
            assertThat(userId).isNotNull();
            assertThat(userId.value()).isNotNull();
            assertThat(userId.value()).isNotBlank();
            // UUID 형식 검증 (36자: 8-4-4-4-12)
            assertThat(userId.value()).matches(
                "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
            );
        }

        @Test
        @DisplayName("forNew() - 매 호출마다 다른 UUID가 생성되어야 한다")
        void forNew_ShouldCreateDifferentUUIDsEachTime() {
            // When
            UserId userId1 = UserId.forNew();
            UserId userId2 = UserId.forNew();

            // Then
            assertThat(userId1.value()).isNotEqualTo(userId2.value());
        }

        @Test
        @DisplayName("of() - 올바른 UUID 값을 가진 ID VO가 생성되어야 한다")
        void of_WithValidValue_ShouldCreateVO() {
            // Given
            String value = "01234567-89ab-cdef-0123-456789abcdef";

            // When
            UserId userId = UserId.of(value);

            // Then
            assertThat(userId).isNotNull();
            assertThat(userId.value()).isEqualTo(value);
        }

        @Test
        @DisplayName("of() - null 값이면 예외가 발생해야 한다")
        void of_WithNullValue_ShouldThrowException() {
            // When & Then
            assertThatThrownBy(() -> UserId.of(null))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("null일 수 없습니다");
        }

        @Test
        @DisplayName("of() - 빈 문자열이면 예외가 발생해야 한다")
        void of_WithBlankValue_ShouldThrowException() {
            // When & Then
            assertThatThrownBy(() -> UserId.of(""))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("빈 문자열일 수 없습니다");

            assertThatThrownBy(() -> UserId.of("  "))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("빈 문자열일 수 없습니다");
        }
    }

    @Nested
    @DisplayName("Compact Constructor 검증 테스트")
    class CompactConstructorTests {

        @Test
        @DisplayName("UUID 형식이 아니면 예외가 발생해야 한다")
        void of_WithInvalidUUIDFormat_ShouldThrowException() {
            // When & Then
            assertThatThrownBy(() -> UserId.of("invalid-uuid"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("UUID 형식이어야 합니다");

            assertThatThrownBy(() -> UserId.of("12345"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("UUID 형식이어야 합니다");
        }
    }

    @Nested
    @DisplayName("equals/hashCode 테스트")
    class EqualsAndHashCodeTests {

        @Test
        @DisplayName("같은 값을 가진 UUID ID VO는 equals()로 동등해야 한다")
        void equals_WithSameValue_ShouldBeEqual() {
            // Given
            String uuidValue = "01234567-89ab-cdef-0123-456789abcdef";
            UserId userId1 = UserId.of(uuidValue);
            UserId userId2 = UserId.of(uuidValue);

            // When & Then
            assertThat(userId1).isEqualTo(userId2);
            assertThat(userId1.hashCode()).isEqualTo(userId2.hashCode());
        }

        @Test
        @DisplayName("forNew()로 생성한 UUID ID는 서로 다르다")
        void equals_WithDifferentForNew_ShouldNotBeEqual() {
            // Given
            UserId userId1 = UserId.forNew();
            UserId userId2 = UserId.forNew();

            // When & Then
            assertThat(userId1).isNotEqualTo(userId2);
        }
    }
}

Long ID vs UUID ID 비교: | 항목 | Long ID (Auto Increment) | UUID ID (UUIDv7) | |——|————————–|——————| | 필드 타입 | Long | String | | forNew() 반환 | null | UUID 문자열 | | isNew() 메서드 | 필수 (null 여부 확인) | 없음 (항상 값 존재) | | ID 생성 주체 | DB (Auto Increment) | Application (UUIDv7) | | 외부 노출 | 순차 증가 (보안 취약) | 랜덤 (보안 강함) | | MySQL 저장 | BIGINT | BINARY(16) |


2-4) Enum VO 템플릿 (OrderStatus 등)

특징: Enum 기반 상태/타입 표현 displayName(): 필수 (화면 표시용) Record가 아님: of() 메서드 불필요

package com.ryuqq.domain.{bc}.vo;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.*;

/**
 * OrderStatus Enum Value Object 단위 테스트
 *
 * <p>테스트 전략:</p>
 * <ul>
 *   <li>displayName() 화면 표시명 검증</li>
 *   <li>상태 체크 메서드 검증 (isActive, isFinal 등)</li>
 *   <li>모든 Enum 값 검증 (누락 방지)</li>
 * </ul>
 *
 * @author development-team
 * @since 1.0.0
 */
@Tag("unit")
@Tag("domain")
@Tag("vo")
@DisplayName("OrderStatus Enum VO 단위 테스트")
class OrderStatusTest {

    @Nested
    @DisplayName("displayName() 테스트")
    class DisplayNameTests {

        @Test
        @DisplayName("PENDING은 '주문 대기'를 반환해야 한다")
        void pending_ShouldReturnCorrectDisplayName() {
            assertThat(OrderStatus.PENDING.displayName()).isEqualTo("주문 대기");
        }

        @Test
        @DisplayName("CONFIRMED는 '주문 확정'을 반환해야 한다")
        void confirmed_ShouldReturnCorrectDisplayName() {
            assertThat(OrderStatus.CONFIRMED.displayName()).isEqualTo("주문 확정");
        }

        @Test
        @DisplayName("SHIPPED는 '배송 중'을 반환해야 한다")
        void shipped_ShouldReturnCorrectDisplayName() {
            assertThat(OrderStatus.SHIPPED.displayName()).isEqualTo("배송 중");
        }

        @Test
        @DisplayName("DELIVERED는 '배송 완료'를 반환해야 한다")
        void delivered_ShouldReturnCorrectDisplayName() {
            assertThat(OrderStatus.DELIVERED.displayName()).isEqualTo("배송 완료");
        }

        @Test
        @DisplayName("CANCELLED는 '주문 취소'를 반환해야 한다")
        void cancelled_ShouldReturnCorrectDisplayName() {
            assertThat(OrderStatus.CANCELLED.displayName()).isEqualTo("주문 취소");
        }

        @Test
        @DisplayName("모든 상태는 displayName()이 null이 아니어야 한다")
        void allStatuses_ShouldHaveNonNullDisplayName() {
            for (OrderStatus status : OrderStatus.values()) {
                assertThat(status.displayName())
                    .as("Status %s should have non-null displayName", status)
                    .isNotNull()
                    .isNotBlank();
            }
        }
    }

    @Nested
    @DisplayName("상태 체크 메서드 테스트")
    class StatusCheckTests {

        @Test
        @DisplayName("isActive() - 활성 상태인 경우 true를 반환해야 한다")
        void isActive_WithActiveStatus_ShouldReturnTrue() {
            assertThat(OrderStatus.PENDING.isActive()).isTrue();
            assertThat(OrderStatus.CONFIRMED.isActive()).isTrue();
            assertThat(OrderStatus.SHIPPED.isActive()).isTrue();
        }

        @Test
        @DisplayName("isActive() - 종료 상태인 경우 false를 반환해야 한다")
        void isActive_WithFinalStatus_ShouldReturnFalse() {
            assertThat(OrderStatus.DELIVERED.isActive()).isFalse();
            assertThat(OrderStatus.CANCELLED.isActive()).isFalse();
        }

        @Test
        @DisplayName("isFinal() - 종료 상태인 경우 true를 반환해야 한다")
        void isFinal_WithFinalStatus_ShouldReturnTrue() {
            assertThat(OrderStatus.DELIVERED.isFinal()).isTrue();
            assertThat(OrderStatus.CANCELLED.isFinal()).isTrue();
        }

        @Test
        @DisplayName("isFinal() - 진행 중 상태인 경우 false를 반환해야 한다")
        void isFinal_WithActiveStatus_ShouldReturnFalse() {
            assertThat(OrderStatus.PENDING.isFinal()).isFalse();
            assertThat(OrderStatus.CONFIRMED.isFinal()).isFalse();
            assertThat(OrderStatus.SHIPPED.isFinal()).isFalse();
        }

        @Test
        @DisplayName("isCancellable() - 취소 가능 상태인 경우 true를 반환해야 한다")
        void isCancellable_WithCancellableStatus_ShouldReturnTrue() {
            assertThat(OrderStatus.PENDING.isCancellable()).isTrue();
            assertThat(OrderStatus.CONFIRMED.isCancellable()).isTrue();
        }

        @Test
        @DisplayName("isCancellable() - 취소 불가 상태인 경우 false를 반환해야 한다")
        void isCancellable_WithNonCancellableStatus_ShouldReturnFalse() {
            assertThat(OrderStatus.SHIPPED.isCancellable()).isFalse();
            assertThat(OrderStatus.DELIVERED.isCancellable()).isFalse();
            assertThat(OrderStatus.CANCELLED.isCancellable()).isFalse();
        }
    }

    @Nested
    @DisplayName("Enum 값 검증 테스트")
    class EnumValuesTests {

        @Test
        @DisplayName("OrderStatus는 5개의 상태를 가져야 한다")
        void orderStatus_ShouldHaveFiveValues() {
            assertThat(OrderStatus.values()).hasSize(5);
        }

        @Test
        @DisplayName("모든 상태 값이 존재해야 한다")
        void allStatuses_ShouldExist() {
            assertThat(OrderStatus.valueOf("PENDING")).isNotNull();
            assertThat(OrderStatus.valueOf("CONFIRMED")).isNotNull();
            assertThat(OrderStatus.valueOf("SHIPPED")).isNotNull();
            assertThat(OrderStatus.valueOf("DELIVERED")).isNotNull();
            assertThat(OrderStatus.valueOf("CANCELLED")).isNotNull();
        }
    }
}

Enum VO vs Record VO 차이점: | 항목 | Enum VO | Record VO | |——|———|———–| | 기반 | enum | record | | of() 메서드 | 불필요 (Enum.valueOf 사용) | 필수 | | displayName() | 필수 | 선택적 | | 인스턴스 수 | 고정 (열거 값 개수) | 무제한 | | equals/hashCode | 자동 (싱글톤) | 자동 (값 기반) |


2-5) Multi-field VO 템플릿 (Address 등)

package com.ryuqq.domain.{bc}.vo;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.*;

/**
 * Address Multi-field Value Object 단위 테스트
 *
 * <p>테스트 전략:</p>
 * <ul>
 *   <li>각 필드별 도메인 규칙 검증</li>
 *   <li>조합 메서드 (getFullAddress)</li>
 * </ul>
 *
 * @author development-team
 * @since 1.0.0
 */
@Tag("unit")
@Tag("domain")
@Tag("vo")
@DisplayName("Address Multi-field VO 단위 테스트")
class AddressTest {

    @Nested
    @DisplayName("정적 팩토리 메서드 테스트")
    class FactoryMethodTests {

        @Test
        @DisplayName("of() - 올바른 주소가 생성되어야 한다")
        void of_WithValidAddress_ShouldCreateAddress() {
            // Given
            String zipCode = "12345";
            String street = "123 Main St";
            String detail = "Apt 101";

            // When
            Address address = Address.of(zipCode, street, detail);

            // Then
            assertThat(address).isNotNull();
            assertThat(address.zipCode()).isEqualTo(zipCode);
            assertThat(address.street()).isEqualTo(street);
            assertThat(address.detail()).isEqualTo(detail);
        }
    }

    @Nested
    @DisplayName("Compact Constructor 검증 테스트 - 우편번호")
    class ZipCodeValidationTests {

        @Test
        @DisplayName("우편번호가 5자리가 아니면 예외가 발생해야 한다")
        void of_WithInvalidZipCode_ShouldThrowException() {
            // When & Then
            assertThatThrownBy(() -> Address.of("1234", "123 Main St", "Apt 101"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("5자리 숫자여야 합니다");

            assertThatThrownBy(() -> Address.of("123456", "123 Main St", "Apt 101"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("5자리 숫자여야 합니다");
        }
    }

    @Nested
    @DisplayName("Compact Constructor 검증 테스트 - 주소")
    class StreetValidationTests {

        @Test
        @DisplayName("주소가 null이거나 빈 문자열이면 예외가 발생해야 한다")
        void of_WithNullOrBlankStreet_ShouldThrowException() {
            // When & Then
            assertThatThrownBy(() -> Address.of("12345", null, "Apt 101"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("null이거나 빈 문자열일 수 없습니다");

            assertThatThrownBy(() -> Address.of("12345", "", "Apt 101"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("null이거나 빈 문자열일 수 없습니다");
        }

        @Test
        @DisplayName("주소가 100자를 초과하면 예외가 발생해야 한다")
        void of_WithTooLongStreet_ShouldThrowException() {
            // Given
            String longStreet = "a".repeat(101);

            // When & Then
            assertThatThrownBy(() -> Address.of("12345", longStreet, "Apt 101"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("100자를 초과할 수 없습니다");
        }
    }

    @Nested
    @DisplayName("비즈니스 메서드 테스트")
    class BusinessMethodTests {

        @Test
        @DisplayName("getFullAddress() - 전체 주소 문자열이 반환되어야 한다")
        void getFullAddress_ShouldReturnFormattedAddress() {
            // Given
            Address address = Address.of("12345", "123 Main St", "Apt 101");

            // When
            String fullAddress = address.getFullAddress();

            // Then
            assertThat(fullAddress).isEqualTo("[12345] 123 Main St Apt 101");
        }
    }

    @Nested
    @DisplayName("equals/hashCode 테스트")
    class EqualsAndHashCodeTests {

        @Test
        @DisplayName("같은 주소를 가진 Address는 equals()로 동등해야 한다")
        void equals_WithSameAddress_ShouldBeEqual() {
            // Given
            Address address1 = Address.of("12345", "123 Main St", "Apt 101");
            Address address2 = Address.of("12345", "123 Main St", "Apt 101");

            // When & Then
            assertThat(address1).isEqualTo(address2);
            assertThat(address1.hashCode()).isEqualTo(address2.hashCode());
        }
    }
}

2-6) Composite VO 템플릿 (FullAddress 등)

package com.ryuqq.domain.{bc}.vo;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.*;

/**
 * FullAddress Composite Value Object 단위 테스트
 *
 * <p>테스트 전략:</p>
 * <ul>
 *   <li>각 VO 개별 테스트 먼저 작성 (ZipCode, Street, City)</li>
 *   <li>Composite는 null 체크만 (각 VO가 이미 검증)</li>
 *   <li>조합 로직 테스트</li>
 * </ul>
 *
 * @author development-team
 * @since 1.0.0
 */
@Tag("unit")
@Tag("domain")
@Tag("vo")
@DisplayName("FullAddress Composite VO 단위 테스트")
class FullAddressTest {

    @Nested
    @DisplayName("정적 팩토리 메서드 테스트")
    class FactoryMethodTests {

        @Test
        @DisplayName("of() - 올바른 VO들로 FullAddress가 생성되어야 한다")
        void of_WithValidVOs_ShouldCreateFullAddress() {
            // Given
            ZipCode zipCode = ZipCode.of("12345");
            Street street = Street.of("123 Main St");
            City city = City.of("Seoul");

            // When
            FullAddress address = FullAddress.of(zipCode, street, city);

            // Then
            assertThat(address).isNotNull();
            assertThat(address.zipCode()).isEqualTo(zipCode);
            assertThat(address.street()).isEqualTo(street);
            assertThat(address.city()).isEqualTo(city);
        }
    }

    @Nested
    @DisplayName("Compact Constructor 검증 테스트 - null 체크만")
    class CompactConstructorTests {

        @Test
        @DisplayName("ZipCode가 null이면 예외가 발생해야 한다")
        void of_WithNullZipCode_ShouldThrowException() {
            // Given
            Street street = Street.of("123 Main St");
            City city = City.of("Seoul");

            // When & Then
            assertThatThrownBy(() -> FullAddress.of(null, street, city))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("우편번호는 null일 수 없습니다");
        }

        @Test
        @DisplayName("각 VO의 검증은 이미 완료되므로 Composite는 null 체크만 수행한다")
        void compositeVO_OnlyChecksNullNotValidation() {
            // Given: 각 VO는 자체 검증을 거친다
            assertThatThrownBy(() -> ZipCode.of("1234"))  // 5자리 아님
                .isInstanceOf(IllegalArgumentException.class);

            // When: 올바른 VO들로 Composite 생성 시
            ZipCode zipCode = ZipCode.of("12345");
            Street street = Street.of("123 Main St");
            City city = City.of("Seoul");

            // Then: 추가 검증 없이 생성 성공
            FullAddress address = FullAddress.of(zipCode, street, city);
            assertThat(address).isNotNull();
        }
    }

    @Nested
    @DisplayName("비즈니스 메서드 테스트")
    class BusinessMethodTests {

        @Test
        @DisplayName("getFullAddress() - 모든 VO를 조합한 주소가 반환되어야 한다")
        void getFullAddress_ShouldReturnCombinedAddress() {
            // Given
            ZipCode zipCode = ZipCode.of("12345");
            Street street = Street.of("123 Main St");
            City city = City.of("Seoul");
            FullAddress address = FullAddress.of(zipCode, street, city);

            // When
            String fullAddress = address.getFullAddress();

            // Then
            assertThat(fullAddress).isEqualTo("[12345] Seoul 123 Main St");
        }
    }

    @Nested
    @DisplayName("equals/hashCode 테스트")
    class EqualsAndHashCodeTests {

        @Test
        @DisplayName("같은 VO들을 가진 FullAddress는 equals()로 동등해야 한다")
        void equals_WithSameVOs_ShouldBeEqual() {
            // Given
            FullAddress address1 = FullAddress.of(
                ZipCode.of("12345"),
                Street.of("123 Main St"),
                City.of("Seoul")
            );

            FullAddress address2 = FullAddress.of(
                ZipCode.of("12345"),
                Street.of("123 Main St"),
                City.of("Seoul")
            );

            // When & Then
            assertThat(address1).isEqualTo(address2);
            assertThat(address1.hashCode()).isEqualTo(address2.hashCode());
        }
    }
}

3️⃣ 테스트 패턴

패턴 1: Compact Constructor 검증 테스트

목적: Record의 Compact Constructor에서 도메인 규칙을 검증합니다.

원칙:

테스트 케이스:

// 1) null 체크
@Test
@DisplayName("필수 필드가 null이면 예외가 발생해야 한다")
void of_WithNullValue_ShouldThrowException() {
    // 모든 필수 필드에 대해 null 체크
}

// 2) 도메인 규칙 검증
@Test
@DisplayName("도메인 규칙을 위반하면 예외가 발생해야 한다")
void of_WithInvalidValue_ShouldThrowException() {
    // 범위: 음수, 0 이하, 최대값 초과
    // 포맷: 정규식 불일치, 잘못된 형식
    // 길이: 최소/최대 길이 위반
    // 상태: 만료일 과거, 유효하지 않은 상태
}

// 3) 정규화 처리
@Test
@DisplayName("입력값이 정규화되어야 한다")
void of_WithUnnormalizedValue_ShouldNormalize() {
    // trim 처리
    // 대소문자 변환
    // null → 기본값 변환
}

다양한 타입 예시:

// BigDecimal (Price)
Price.of(new BigDecimal("-10.00"))  // ❌ 음수
Price.of(new BigDecimal("0"))       // ✅ 0 허용 (할인가)
Price.of(new BigDecimal("1000000")) // ✅ 정상

// LocalDate (ExpiryDate)
ExpiryDate.of(LocalDate.now().minusDays(1))  // ❌ 과거 날짜
ExpiryDate.of(LocalDate.now())               // ✅ 오늘
ExpiryDate.of(LocalDate.now().plusYears(10)) // ✅ 미래

// Enum (OrderStatus)
OrderStatus.of(null)    // ❌ null
OrderStatus.of("DRAFT") // ✅ 유효한 상태
OrderStatus.of("INVALID") // ❌ 유효하지 않은 상태

// Long (ID)
OrderId.of(null)  // ❌ null (forNew() 사용해야 함)
OrderId.of(0L)    // ❌ 0 이하
OrderId.of(-1L)   // ❌ 음수
OrderId.of(100L)  // ✅ 양수

// String (Email)
Email.of(null)               // ❌ null
Email.of("")                 // ❌ 빈 문자열
Email.of("invalid-email")    // ❌ 포맷 위반
Email.of("a".repeat(256))    // ❌ 길이 초과
Email.of("user@example.com") // ✅ 정상

패턴 2: 정적 팩토리 메서드 테스트

목적: of(), forNew() 같은 정적 팩토리 메서드의 생성 로직을 검증합니다.

원칙:

테스트 케이스:

// 1) of() 정상 생성
@Test
@DisplayName("of() 호출 시 올바른 값으로 VO가 생성되어야 한다")
void of_WithValidValue_ShouldCreateVO() {
    // Given: 유효한 값
    // When: of() 호출
    // Then: VO 생성 확인, 필드값 검증
}

// 2) forNew() null 생성 (ID VO)
@Test
@DisplayName("forNew() 호출 시 null 값을 가진 ID VO가 생성되어야 한다")
void forNew_ShouldCreateVOWithNullValue() {
    // When: forNew() 호출
    // Then: value() == null, isNew() == true
}

// 3) isNew() 체크 (ID VO)
@Test
@DisplayName("isNew() 호출 시 null 여부를 반환해야 한다")
void isNew_ShouldReturnTrueForNullValue() {
    // Given: forNew()로 생성
    // When: isNew() 호출
    // Then: true 반환
}

다양한 타입 예시:

// ID VO (Long)
OrderId orderId = OrderId.forNew();         // null
assertThat(orderId.isNew()).isTrue();

OrderId orderId = OrderId.of(100L);         // 100
assertThat(orderId.isNew()).isFalse();

// Simple VO (BigDecimal)
Price price = Price.of(new BigDecimal("1000.00"));
assertThat(price.value()).isEqualByComparingTo("1000.00");

// Simple VO (LocalDate)
ExpiryDate expiryDate = ExpiryDate.of(LocalDate.now().plusDays(30));
assertThat(expiryDate.value()).isAfter(LocalDate.now());

// Multi-field VO (여러 원시 타입)
Address address = Address.of("12345", "123 Main St", "Apt 101");
assertThat(address.zipCode()).isEqualTo("12345");
assertThat(address.street()).isEqualTo("123 Main St");

// Composite VO (VO 안에 VO)
FullAddress fullAddress = FullAddress.of(
    ZipCode.of("12345"),
    Street.of("123 Main St"),
    City.of("Seoul")
);
assertThat(fullAddress.zipCode()).isEqualTo(ZipCode.of("12345"));

패턴 3: 비즈니스 메서드 테스트

목적: VO의 도메인 로직(연산, 비교, 변환 등)을 검증합니다.

원칙:

테스트 케이스:

// 1) 연산 메서드
@Test
@DisplayName("연산 메서드는 올바른 결과를 반환해야 한다")
void operation_ShouldReturnCorrectResult() {
    // Given: 두 VO
    // When: add, subtract, multiply 등
    // Then: 결과값 검증
}

// 2) 비교 메서드
@Test
@DisplayName("비교 메서드는 올바른 boolean을 반환해야 한다")
void comparison_ShouldReturnCorrectBoolean() {
    // Given: 두 VO
    // When: isGreaterThan, isLessThan 등
    // Then: true/false 검증
}

// 3) 검증 메서드
@Test
@DisplayName("검증 메서드는 도메인 규칙을 확인해야 한다")
void validation_ShouldCheckDomainRules() {
    // Given: VO
    // When: isValid, canTransition 등
    // Then: true/false 검증
}

// 4) 변환 메서드
@Test
@DisplayName("변환 메서드는 올바른 형식으로 변환해야 한다")
void conversion_ShouldReturnFormattedValue() {
    // Given: VO
    // When: toDisplayString, toCurrency 등
    // Then: 변환된 값 검증
}

다양한 타입 예시:

// BigDecimal (Price) - 연산
Price price1 = Price.of(new BigDecimal("1000.00"));
Price price2 = Price.of(new BigDecimal("500.00"));
Price result = price1.add(price2);
assertThat(result.value()).isEqualByComparingTo("1500.00");

// BigDecimal (Price) - 비교
assertThat(price1.isGreaterThan(price2)).isTrue();
assertThat(price1.isBetween(Price.of("400"), Price.of("1500"))).isTrue();

// LocalDate (ExpiryDate) - 검증
ExpiryDate expiryDate = ExpiryDate.of(LocalDate.now().plusDays(30));
assertThat(expiryDate.isExpired()).isFalse();
assertThat(expiryDate.isExpiringSoon(7)).isFalse(); // 7일 이내 만료?
assertThat(expiryDate.daysUntilExpiry()).isEqualTo(30);

// Enum (OrderStatus) - 상태 전이
OrderStatus status = OrderStatus.PENDING;
assertThat(status.canTransitionTo(OrderStatus.CONFIRMED)).isTrue();
assertThat(status.canTransitionTo(OrderStatus.SHIPPED)).isFalse();

// Multi-field VO (Address) - 변환
Address address = Address.of("12345", "123 Main St", "Apt 101");
assertThat(address.getFullAddress()).isEqualTo("[12345] 123 Main St Apt 101");

// Composite VO (FullAddress) - 조합
FullAddress fullAddress = FullAddress.of(...);
assertThat(fullAddress.getFullAddress()).contains("Seoul");

패턴 4: 상태 변화 및 검증 로직 테스트

목적: VO의 상태 의존적 로직과 복잡한 검증 규칙을 테스트합니다.

원칙:

테스트 케이스:

// 1) 상태 전이 검증 (Enum VO)
@Test
@DisplayName("상태 전이가 도메인 규칙을 따라야 한다")
void stateTransition_ShouldFollowDomainRules() {
    // Given: 현재 상태
    // When: 다음 상태로 전이 가능 여부 확인
    // Then: canTransitionTo() 검증
}

// 2) 만료 검증 (날짜 VO)
@Test
@DisplayName("만료 여부를 올바르게 판단해야 한다")
void expiry_ShouldBeCheckedCorrectly() {
    // Given: 만료일이 과거/현재/미래인 VO
    // When: isExpired() 호출
    // Then: true/false 검증
}

// 3) 권한 검증 (역할 VO)
@Test
@DisplayName("권한 여부를 올바르게 판단해야 한다")
void permission_ShouldBeCheckedCorrectly() {
    // Given: 역할 VO
    // When: hasPermission(Action) 호출
    // Then: true/false 검증
}

// 4) 복합 조건 검증
@Test
@DisplayName("복합 조건을 만족하는지 검증해야 한다")
void complexValidation_ShouldCheckMultipleConditions() {
    // Given: Multi-field VO
    // When: isValid() 호출 (여러 필드 조합 검증)
    // Then: true/false 검증
}

다양한 타입 예시:

// Enum (OrderStatus) - 상태 전이
OrderStatus pending = OrderStatus.PENDING;
assertThat(pending.canTransitionTo(OrderStatus.CONFIRMED)).isTrue();
assertThat(pending.canTransitionTo(OrderStatus.SHIPPED)).isFalse();
assertThat(pending.canTransitionTo(OrderStatus.CANCELLED)).isTrue();

OrderStatus shipped = OrderStatus.SHIPPED;
assertThat(shipped.canTransitionTo(OrderStatus.CANCELLED)).isFalse(); // 배송 후 취소 불가

// LocalDate (ExpiryDate) - 만료 검증
ExpiryDate expired = ExpiryDate.of(LocalDate.now().minusDays(1));
assertThat(expired.isExpired()).isTrue();

ExpiryDate expiringSoon = ExpiryDate.of(LocalDate.now().plusDays(3));
assertThat(expiringSoon.isExpiringSoon(7)).isTrue();  // 7일 이내
assertThat(expiringSoon.daysUntilExpiry()).isEqualTo(3);

// Enum (UserRole) - 권한 검증
UserRole admin = UserRole.ADMIN;
assertThat(admin.hasPermission(Action.DELETE_USER)).isTrue();
assertThat(admin.hasPermission(Action.VIEW_ONLY)).isTrue();

UserRole viewer = UserRole.VIEWER;
assertThat(viewer.hasPermission(Action.DELETE_USER)).isFalse();
assertThat(viewer.hasPermission(Action.VIEW_ONLY)).isTrue();

// Multi-field VO (PhoneNumber) - 복합 조건
PhoneNumber phone = PhoneNumber.of("010", "1234", "5678");
assertThat(phone.isValid()).isTrue();  // 형식 + 길이 + 번호 유효성
assertThat(phone.isMobile()).isTrue(); // 010으로 시작

PhoneNumber landline = PhoneNumber.of("02", "1234", "5678");
assertThat(landline.isMobile()).isFalse();
assertThat(landline.isLandline()).isTrue();

패턴 5: equals/hashCode 테스트

목적: Record의 자동 생성된 equals/hashCode가 올바르게 동작하는지 확인합니다.

원칙:

테스트 케이스:

// 1) 같은 값 equals
@Test
@DisplayName("같은 값을 가진 VO는 equals()로 동등해야 한다")
void equals_WithSameValue_ShouldBeEqual() {
    // Given: 같은 값으로 생성한 두 VO
    // When: equals() 호출
    // Then: true, hashCode() 동일
}

// 2) 다른 값 equals
@Test
@DisplayName("다른 값을 가진 VO는 equals()로 다르다")
void equals_WithDifferentValue_ShouldNotBeEqual() {
    // Given: 다른 값으로 생성한 두 VO
    // When: equals() 호출
    // Then: false
}

// 3) null equals (ID VO)
@Test
@DisplayName("forNew()로 생성한 ID VO는 null 기준으로 동등해야 한다")
void equals_WithBothNull_ShouldBeEqual() {
    // Given: forNew()로 생성한 두 ID VO
    // When: equals() 호출
    // Then: true (둘 다 null)
}

다양한 타입 예시:

// BigDecimal (Price)
Price price1 = Price.of(new BigDecimal("1000.00"));
Price price2 = Price.of(new BigDecimal("1000.00"));
assertThat(price1).isEqualTo(price2);
assertThat(price1.hashCode()).isEqualTo(price2.hashCode());

// LocalDate (ExpiryDate)
LocalDate date = LocalDate.of(2025, 12, 31);
ExpiryDate expiry1 = ExpiryDate.of(date);
ExpiryDate expiry2 = ExpiryDate.of(date);
assertThat(expiry1).isEqualTo(expiry2);

// Enum (OrderStatus)
OrderStatus status1 = OrderStatus.PENDING;
OrderStatus status2 = OrderStatus.PENDING;
assertThat(status1).isEqualTo(status2); // Enum은 싱글톤

// Multi-field VO (Address)
Address addr1 = Address.of("12345", "123 Main St", "Apt 101");
Address addr2 = Address.of("12345", "123 Main St", "Apt 101");
assertThat(addr1).isEqualTo(addr2);
assertThat(addr1.hashCode()).isEqualTo(addr2.hashCode());

// Composite VO (FullAddress)
FullAddress full1 = FullAddress.of(
    ZipCode.of("12345"),
    Street.of("123 Main St"),
    City.of("Seoul")
);
FullAddress full2 = FullAddress.of(
    ZipCode.of("12345"),
    Street.of("123 Main St"),
    City.of("Seoul")
);
assertThat(full1).isEqualTo(full2);

4️⃣ VO 유형별 테스트 전략

각 VO 유형은 위의 5가지 패턴을 조합하여 테스트합니다. 강조할 패턴만 표시합니다.

VO 유형 @Nested 클래스 구성 특징
Long ID VO FactoryMethodTests, CompactConstructorTests, IsNewMethodTests, EqualsAndHashCodeTests forNew()→null, isNew() 필수
UUID ID VO FactoryMethodTests, CompactConstructorTests, EqualsAndHashCodeTests forNew()→UUID, isNew() 없음
Enum VO DisplayNameTests, StatusCheckTests, EnumValuesTests displayName() 필수, of() 없음
Simple VO FactoryMethodTests, CompactConstructorTests, BusinessMethodTests, EqualsAndHashCodeTests, ConstantTests 연산/비교 메서드 중심
Multi-field VO FactoryMethodTests, (필드별) ValidationTests, BusinessMethodTests, EqualsAndHashCodeTests 각 필드별 @Nested 분리
Composite VO FactoryMethodTests, CompactConstructorTests, BusinessMethodTests, EqualsAndHashCodeTests null 체크만, 조합 로직

Long ID VO (OrderId - Auto Increment)

UUID ID VO (UserId - UUIDv7)

Enum VO (OrderStatus)

Simple VO (Money, Email, Price, ExpiryDate)

Multi-field VO (Address, PhoneNumber)

Composite VO (FullAddress)


5️⃣ TestFixture 패턴 (선택적)

Record는 생성이 간단하므로 Fixture가 필수는 아니지만, 일관성을 위해 사용 가능합니다.

/**
 * OrderId TestFixture
 */
public class OrderIdFixture {
    public static OrderId forNew() {
        return OrderId.forNew();
    }

    public static OrderId defaultId() {
        return OrderId.of(100L);
    }

    public static OrderId of(Long value) {
        return OrderId.of(value);
    }

    private OrderIdFixture() {
        throw new AssertionError("Fixture 클래스는 인스턴스화할 수 없습니다.");
    }
}

6️⃣ Do / Don’t

❌ Bad Examples

// ❌ @Tag 누락
@DisplayName("Money VO 테스트")
class MoneyTest {  // ❌ @Tag("unit"), @Tag("domain"), @Tag("vo") 없음
}

// ❌ @Nested 없이 평면 구조
@Tag("unit")
@Tag("domain")
@Tag("vo")
@DisplayName("Money VO 테스트")
class MoneyTest {
    @Test
    void testOf() { }  // ❌ 관심사 분리 없음

    @Test
    void testAdd() { }

    @Test
    void testEquals() { }
}

// ❌ Record 검증 로직을 우회
@Test
void badTest() {
    Money money = new Money(-100L);  // ❌ 직접 생성 (검증 우회)
}

// ❌ Composite VO의 각 VO를 검증하지 않음
@Test
void badTest() {
    // ZipCode, Street, City 테스트 없음 ❌
    FullAddress address = FullAddress.of(...);
}

✅ Good Examples

// ✅ @Tag 필수 3종 세트
@Tag("unit")
@Tag("domain")
@Tag("vo")
@DisplayName("Money VO 단위 테스트")
class MoneyTest {
    // ...
}

// ✅ @Nested로 관심사 명확히 분리 (2-5개 권장)
@Tag("unit")
@Tag("domain")
@Tag("vo")
@DisplayName("Money VO 단위 테스트")
class MoneyTest {

    @Nested
    @DisplayName("정적 팩토리 메서드 테스트")
    class FactoryMethodTests {
        // of(), forNew() 테스트
    }

    @Nested
    @DisplayName("Compact Constructor 검증 테스트")
    class CompactConstructorTests {
        // 도메인 규칙 검증 테스트
    }

    @Nested
    @DisplayName("비즈니스 메서드 테스트")
    class BusinessMethodTests {
        // add, subtract 등
    }

    @Nested
    @DisplayName("equals/hashCode 테스트")
    class EqualsAndHashCodeTests {
        // equals/hashCode
    }
}

// ✅ 정적 팩토리 메서드 사용
@Test
void goodTest() {
    Money money = Money.of(1000L);  // ✅ of() 사용
}

// ✅ Composite VO는 각 VO를 먼저 테스트
@Test
void goodTest() {
    // 1. 각 VO 개별 테스트 ✅
    ZipCode zipCode = ZipCode.of("12345");
    Street street = Street.of("123 Main St");
    City city = City.of("Seoul");

    // 2. Composite VO 테스트 ✅
    FullAddress address = FullAddress.of(zipCode, street, city);
}

7️⃣ 체크리스트

Value Object 테스트 작성 후 다음을 확인:

기본 테스트 (모든 VO)

Long ID VO 추가 체크 (Auto Increment)

UUID ID VO 추가 체크 (UUIDv7)

Enum VO 추가 체크

Simple VO 추가 체크

Multi-field VO 추가 체크

Composite VO 추가 체크


✅ Value Object 테스트는 @Tag 필수, @Nested로 관심사 분리, 패턴 중심 접근이 핵심입니다!