| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- SEQUENCE
- model
- JDBC
- AOP
- 설정세팅
- JPA
- 커뮤니티서버
- 클린아키텍처
- DDD
- 공통규약
- MVC
- 단위테스트
- SpringLegacy
- 전전긍긍
- 이벤트핸들러등록
- 공통기술
- OOP
- string
- index
- Transaction
- 사면초가
- 냄새라도
- MVC요청플로우
- TDD
- springsecurity
- 로그백
- 이벤트핸들러this
- 디스크i/o
- Ajax
- 문제선택근거
- Today
- Total
개발이군고구마
[냄새라도 TDD/OOP] 2-1. 단위 테스트 (도메인단) 본문
[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);
}
'SERVER > Architecture' 카테고리의 다른 글
| [냄새라도 TDD/OOP] 2-2. 단위 테스트 (컨트롤러) (0) | 2025.07.04 |
|---|---|
| [냄새라도 TDD/OOP] 3. 통합테스트 (서비스) (0) | 2025.06.27 |
| [냄새라도 TDD/OOP] 1. 구현 원칙 확립 (0) | 2025.06.27 |
| [사면초가 TDD/OOP] 3. 통합테스트 (config mock의 주입) (0) | 2025.06.08 |
| [사면초가 TDD/OOP] 2. 단위 테스트 (테스트 하고 있는 메서드만 생각한다) (0) | 2025.05.30 |