개발이군고구마

[냄새라도 TDD/OOP] 2-1. 단위 테스트 (도메인단) 본문

SERVER/Architecture

[냄새라도 TDD/OOP] 2-1. 단위 테스트 (도메인단)

김구황 2025. 6. 27. 08:38
728x90
[OOP 설계 프로세스]
1. 설계 원칙 확립 < 구현 1단계
2-1. TDD 구현 단위(도메인) < 구현 2단계
2-2. TDD 구현 단위(컨트롤러-검증) < 구현 2단계

3. TDD 구현 통합

1. 단위테스트 전 고려한 것들

1. Exception 처리

  • 필드에 대한 유효성 검사는 hanlder에서 핸들러에서 처리
  • 권한 검사는 시큐리티 필터에서 예외처리 
  • 파일에 대한 유효성 검사는 파일 컨텍스트에서 처리 

2. Entity 에 객체 생성 메서드 필요성

  • Entity 생성은 테스트 코드에서나 많이 사용되기 때문에 이너 메서드로 뽑아놓았고, Entity 생성은 DTO 에서 메서드로 구현할 예정
  • 따라서 Builder 만 사용(받아올 변수가 5개 이상) 하여 생성자 구현하였음
  • 그렇다면 단순히 상태값 변경 정도면 테스트로 구현함

3. 도메인 단위

  • 필드에 대한 유효성 검증은 ExceptionHandler에서 대부분 처리한다.
  • 권한에 대한 검증은 SpringSecurity Filter에서 공통 에러로 처리한다. (관리자 권한)

4. 리파지토리 단위

  • Request의 값이 제대로 들어갔는지 확인하거나 
  • 정보 조회 (전시, deleted 태그) / 정보 삭제 (deleted 태그) 확인
  • 하지만 이 부분도 JPA 단 외부 인프라 쓰는 것이기 때문에 문제 될 것이 없다고 봄

5. 서비스 단위

  • RequestDto에서 관리자 ID를 뽑는 부분도 사실상 시큐리티 의존 → 이미 Security context에서 테스트 진행하고 있음 (getMemberId..)
  • 서비스 단에서 복잡하게 값을 가공하는 로직은 없음 
  • 그렇다면 Response를 제대로 만들어내냐의 문제인데 → 사실상 이건 통합 테스트와 중복되는 것이기 때문에

6. ❇ 컨트롤러 슬라이스 (Request / Status)

  • 유효성 검사
    • InfoCategory Enum Request 의 값이 제대로 들어가는지 확인 (어노테이션 사용하기 때문에 Json 으로 값을 변경해줄 때 예외 터짐) 
  • 상태 값만 확인함

7. 서비스 통합 (Response / Pageable)

  • Response의 값이 제대로 반납되는지 확인
    • 카테고리 한글로
  • Container

2. 도메인 단위테스트 

🌐 객체 생성과 수정 

 

1) InfoEntity (테스트 하는 클래스) 를 기준으로 뼈대 잡기 

대략적인 필드 값과 생성자를 잡아둠 

@Getter
@Entity
@Table(name = "INFO")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
//@Where(clause = "deleted = false AND is_display = 'Y'") 통합테스트 추가 
public class InfoEntity extends BaseEntity {
	@Id
	@GeneratedValue(generator = "info-id-gen")
	@GenericGenerator(
		name = "info-id-gen",
		strategy = "com.example.green.domain.info.utils.PrefixSequenceIdGenerator"
	)
	@Column(name = "info_id", length = 7, nullable = false, updatable = false)
	private String id;

	@Enumerated(EnumType.STRING)
	@Column(nullable = false, columnDefinition = "VARCHAR(20)")
	private InfoCategory infoCategory;

	@Column(length = 30, nullable = false)
	private String title;

	@Column(length = 1000, nullable = false)
	private String content;

	@Column(nullable = false)
	private String imageUrl;

	/**
	 * 전시 여부
	 * - Y: 전시
	 * - N: 비전시
	 */
	@Column(nullable = false, columnDefinition = "CHAR(1)")
	private String isDisplay;
}

 

 

 

2) entity 생성 수정에 관한 테스트 생각해보기 무엇이 필요할까 

1) ID 커스텀 생성 (mock 생성)
2) 전시여부 Y/N 으로 잘 들어왔는지 
3) 생성/업데이트 여부 
4) VO 단 : Generator 로 @Id 생성 여부 
5) Enum 값 정상 매핑 여부 

 

✅ 트랜잭션 오류는 서비스 통합테스트에서 

✅ 도메인 단위테스트는 오직 도메인의 생성 여부 

 

2-1) 커스텀 ID 생성

실제 코드 (generator) -> 테스트 코드 작성으로 진행함 

 

🔴 실제코드 

public class PrefixSequenceIdGenerator implements IdentifierGenerator {

	private static final String PREFIX = "P";
	private static final int NUMBER_LENGTH = 6;

	@Override
	public synchronized Serializable generate(SharedSessionContractImplementor session, Object object) {
		try {
			String sql = "SELECT info_id FROM INFO ORDER BY info_id DESC LIMIT 1";
			Object result = session.createNativeQuery(sql).uniqueResult();

			int nextNumber = 1;
			if (result != null) {
				String lastId = result.toString();
				String numberPart = lastId.substring(PREFIX.length());
				nextNumber = Integer.parseInt(numberPart) + 1;
			}

			String format = String.format("%%0%dd", NUMBER_LENGTH);
			return PREFIX + String.format(format, nextNumber);

		} catch (Exception e) {
			throw new InfoException(InfoExceptionMessage.CANNOT_MAKE_INFO_ID);
		}
	}
}

 

🔵 테스트 코드 

  • SQL 실제 데이터베이스 조회는 테스트하지 않음 
  • session과 session에서 비롯한 NativeQuery의 값을 mock 처리 -> stub
  • PrefixSequesnceIdGenerator 클래스는 인스턴스화 (new)하여 **사용** 처리 

☢ 엄밀히 말하면 Entity 클래스에 있어도 되는지 잘 모르겠다. 어쨋든 Entity를 생성할 때 필요한 로직이기 때문에 도메인 테스트에서 함께 테스트. 

@ExtendWith(MockitoExtension.class)
class InfoEntityTest {
	@Mock
	SharedSessionContractImplementor session;
	@Mock
	NativeQueryImplementor<String> query;
	
    @Test
	void 정보커스텀ID_첫_생성_케이스_테스트() {
		when(session.createNativeQuery(anyString())).thenReturn(query);
		when(query.uniqueResult()).thenReturn(null);

		PrefixSequenceIdGenerator generator = new PrefixSequenceIdGenerator();

		Serializable id = generator.generate(session, new Object());
		assertEquals("P000001", id);
	}

	@Test
	void 정보커스텀ID_기존_값_있을_때_증가_테스트() {
		when(session.createNativeQuery(anyString())).thenReturn(query);
		when(query.uniqueResult()).thenReturn("P000042");

		PrefixSequenceIdGenerator generator = new PrefixSequenceIdGenerator();

		Serializable id = generator.generate(session, new Object());
		assertEquals("P000043", id);
	}

	@Test
	void 정보커스텀ID_생성시_오류가_발생하면_예외를_던진다() {
		when(session.createNativeQuery(anyString())).thenReturn(query);
		when(query.uniqueResult()).thenThrow(new RuntimeException("DB Error"));

		PrefixSequenceIdGenerator generator = new PrefixSequenceIdGenerator();

		assertThatThrownBy(() -> generator.generate(session, new Object()))
			.isInstanceOf(InfoException.class)
			.hasMessageContaining("정보 ID 생성 중 오류가 발생했습니다.");
	}
}

 

 

 

2-2) 전시여부 Y/N 으로 들어왔는지 

🔵 테스트 코드 

@ParameterizedTest
@ValueSource(strings = {"notValid", "n"})
void 전시여부가_YN또는yn가_아니면_예외를_던진다(String value) {
    //given
    String isDisplay = value;

    //when & then
    if (value.equals("notValid")) {
        assertThatThrownBy(() -> createInfo(isDisplay))
            .isInstanceOf(BusinessException.class)
            .hasMessageContaining("경로 변수 또는 쿼리 파라미터의 타입이 잘못되었습니다.");
    } else {
        assertThat(createInfo(isDisplay).getIsDisplay()).isEqualTo("N");
    }
}

 

🔴 실제코드 

@Builder
private InfoEntity(
    final String title,
    final String content,
    final InfoCategory infoCategory,
    final String isDisplay,
    final String imageUrl
) {
    this.title = title;
    this.content = content;
    this.infoCategory = infoCategory;
    this.imageUrl = imageUrl;
    this.isDisplay = validateAndFormatIsDisplay(isDisplay); // 추가 
}

// private 메서드로 만들어서 도메인단 보호 
private String validateAndFormatIsDisplay(String isDisplay) {
    if ("y".equalsIgnoreCase(isDisplay)) {
        isDisplay = "Y";
    } else if ("n".equalsIgnoreCase(isDisplay)) {
        isDisplay = "N";
    } else {
        throw new BusinessException(GlobalExceptionMessage.ARGUMENT_TYPE_MISMATCH_MESSAGE);
    }
    return isDisplay;
}

 

 

 

2-3) 생성 수정

🔵 테스트 코드 

@Test
@Test
void 정보_도메인을_생성한다() {
    //given & when
    InfoEntity infoEntity = createInfo("Y");

    //then
    assertThat(infoEntity.getTitle()).isEqualTo("title");
    assertThat(infoEntity.getInfoCategory().getDescription()).isEqualTo("참여형");
}

@Test
void 정보_도메인을_수정한다() {
    //given
    InfoEntity infoEntity = createInfo("Y");

    //when
    InfoCategory updateInfoCategory = mock(InfoCategory.class);
    when(updateInfoCategory.getDescription()).thenReturn("커뮤니티");

    infoEntity.update(
        "updateTitle",
        "updateContent",
        updateInfoCategory,
        "updateImageUrl",
        "N"
    );

    // then
    assertThat(infoEntity.getTitle()).isEqualTo("updateTitle");
    assertThat(infoEntity.getContent()).isEqualTo("updateContent");
    assertThat(infoEntity.getInfoCategory().getDescription()).isEqualTo("커뮤니티");
}

 

 

🔴 실제코드 

// 변수 직접 받아 필드 변경 -> 도미엔단 외부 영향도 최소화
public void update(
    final String updateTitle,
    final String updateContent,
    final InfoCategory updateInfoCategory,
    final String updateImageUrl,
    final String updateIsDisplay
) {
    this.title = updateTitle;
    this.content = updateContent;
    this.infoCategory = updateInfoCategory;
    this.imageUrl = updateImageUrl;
    this.isDisplay = updateIsDisplay;
}

 

 

2-4) Generator Id 생성 

🔴 실제코드 

- 실제 코드 부터 작성하고 진행함

public class PrefixSequenceIdGenerator implements IdentifierGenerator {

    private static final String PREFIX = "P";
    private static final int NUMBER_LENGTH = 6;

    @Override
    public synchronized Serializable generate(SharedSessionContractImplementor session, Object object) {
       try {
          String sql = "SELECT info_id FROM INFO ORDER BY info_id DESC LIMIT 1";
          Object result = session.createNativeQuery(sql).uniqueResult();

          int nextNumber = 1;
          if (result != null) {
             String lastId = result.toString();
             String numberPart = lastId.substring(PREFIX.length());
             nextNumber = Integer.parseInt(numberPart) + 1;
          }

          String format = String.format("%%0%dd", NUMBER_LENGTH);
          return PREFIX + String.format(format, nextNumber);

       } catch (Exception e) {
          throw new InfoException(InfoExceptionMessage.CANNOT_MAKE_INFO_ID);
       }
    }
}

 

 

🔵 테스트 코드 

@Mock
SharedSessionContractImplementor session;
@Mock
NativeQueryImplementor<String> query;

@Test
void 정보커스텀ID_첫_생성_케이스_테스트() {
	// 굳이 필요 없는 부분은 mocking 
    when(session.createNativeQuery(anyString())).thenReturn(query);
    when(query.uniqueResult()).thenReturn(null);
    
    PrefixSequenceIdGenerator generator = new PrefixSequenceIdGenerator();

    Serializable id = generator.generate(session, new Object());
    assertEquals("P000001", id);
}

 

✅ PrefixGenerator만 실제 구현체를 가져오면 되기 때문에 new 인스턴스로 생성하였음

query 로 조회하여 값을 가져오는 부분은 필요하지 않기 때문에 mock으로 주입 받고 stubbing 진행 

 

 

2-5) Enum 값 검증 

🔵 테스트 코드 

  • json -> enum으로 컨버팅 기능을 보고 싶었음 
  • Controller 단에서는 할 수 없었던 이유 : Enum에는 @Valid 어노테이션이 적용되지 않았음
  • 순수 Jackson의 변환에서 발생하는 오류 InvalidFormatException이 발생하며, 스프링 단에서는 Http로 감싸서 최종 에러가 발생함 

💥 Entity 도메인에 Request 가 있다는 점 은 아쉬운 부분임

 

@ParameterizedTest
@ValueSource(strings = {"CONTENTS", "EVENT", "ETC"})
void 정보_카테고리ENUM에_등록된_값이_들어왔을_때_정상적으로_변환된다(String validCode) throws JsonProcessingException {
    // given
    ObjectMapper objectMapper = new ObjectMapper();
    String json = String.format("{\"infoCategory\":\"%s\"}", validCode);
    InfoRequest req = objectMapper.readValue(json, InfoRequest.class);

    // when & then
    assertThat(req.infoCategory().toString()).isEqualTo(validCode);
}

 

@ParameterizedTest
@ValueSource(strings = {"", "NOT_EXISTING"})
void 정보_카테고리ENUM에_등록되지_않은_값이_들어왔을_때_예외를_던진다(String invalidCode) {
    // given
    ObjectMapper objectMapper = new ObjectMapper();
    String json = String.format("{\"infoCategory\":\"%s\"}", invalidCode);

    // when & then
    // Jackson이 파싱 중에 InvalidFormatException 던짐
    assertThatThrownBy(() -> objectMapper.readValue(json, InfoRequest.class))
       .isInstanceOf(InvalidFormatException.class);
}