아래는 최범균님의 유튜브를 보고 느낀 점을 나름대로 정리한 것이다.
테스트 불가능한 문제
아래의 코드가 테스트가 불가능한 문제가 있었다고 한다.
ResultBuilder builder = ...;
InputPeriod realPeriod = mapper.selectPeriod(param); // 1. DB에서 읽음
if (realPeriod == null) { // 2. 없으면 다른 값 읽음
InputPeriod expectedPeriod = mapper.selectExpectedPeriod(otherParam);
builder.period(expectedPeriod).type(EXPECTED);
} else if (realPeriod.getStart() == null || realPeriod.getEnd() == null) { // 3. 데이터는 있는데 실제 값이 없으면
builder.period(null).type(EXPECTED);
} else {
// 4. 데이터의 값이 있고 아래 조건을 충족하면
if((today.isEqual(real.getStart()) || today.isEqual(real.getEnd())) ||
(today.isAfter(real.getStart()) && today.isBefore(real.getEnd()))
) {
builder.period(realPeriod).type(SCHEDULED);
} else { // 5. (4) 조건이 아니면
builder.period(realPeriod).type(EXPECTED);
}
}
realPeriod, expectedPeriod, today 값에 따라 사용할 period와 type이 달라지기 때문
단위 테스트
단위 테스트를 방해하는 요소는 아래와 같다.
- 중간에 섞여 있는 DB 연동
- ResultBuilder를 만드는 과정
그래서 아래의 로직으로 분리
- DB에서 InputPeriod를 읽어오는 코드
- realPeriod, expectedPeriod, today로 사용할 period와 type 구하기
- 코드 분리를 위한 타입 추가
데이터 타입 추가
public class InputPeriods {
private InputPeriod real;
private InputPeriod expected;
}
DB에서 읽어오는 코드 분리
DB에서 읽어오는 부분을 아래와 같이 분리했다.
private InputPeriods selectPeriod(Param param, OtherParam otherParam) {
InputPeriod realPeriod = mapper.selectPeriod(param);
if(realPeriod != null) return new InputPeriods(realPeriod, null);
InputPeriod expectedPeriod = mapper.selectExpectedPeriod(otherParam);
return new InputPeriods(null, expectedPeriod);
}
결과적으로 코드가 아래와 같이 변경되었다.
ResultBuilder builder = ...;
// InputPeriod realPeriod = mapper.selectPeriod(param);
InputPeriods periods = selectPeriod(param, otherParam);
if (realPeriod == null) {
// InputPeriod expectedPeriod = mapper.selectExpectedPeriod(otherParam);
// builder.period(expectedPeriod).type(EXPECTED);
builder.period(periods.getExpected()).type(EXPECTED);
} else if (realPeriod.getStart() == null || realPeriod.getEnd() == null) {
builder.period(null).type(EXPECTED);
} else {
if((today.isEqual(real.getStart()) || today.isEqual(real.getEnd())) ||
(today.isAfter(real.getStart()) && today.isBefore(real.getEnd()))
) {
builder.period(realPeriod).type(SCHEDULED);
} else {
builder.period(realPeriod).type(EXPECTED);
}
}
period, type 구하는 로직을 InputPeriods 클래스로 이동
period, type을 build하는 과정을 삭제하기 위해 로직을 Data 객체에 위임했다.
public class InputPeriods {
private InputPeriod real;
private InputPeriod expected;
public InputPeriod getPeriod() {
if(real == null) return expected;
if(real.getStart() == null || real.getEnd() == null) return null;
return expected;
}
public PeriodType getType(LocalDate today) {
if(real == null) return EXPECTED;
if(realPeriod.getStart() == null || realPeriod.getEnd() == null) return EXPECTED;
if((today.isEqual(real.getStart()) || today.isEqual(real.getEnd())) ||
(today.isAfter(real.getStart()) && today.isBefore(real.getEnd()))
) return SCHEDULED;
return EXPECTED;
}
}
여기서 LocalDate로 today라는 파라미터를 내부에서 수행하지 않는 이유는 제어가 가능해야 하기 때문이다.
변경 후
결과적으로 아래와 같이 깔끔한 코드가 되었다.
ResultBuilder builder = ...;
InputPeriods periods = selectPeriod(param, otherParam);
builder.period(periods.getPeriod()).type(periods.getType(today));
결과
- 코드가 단순해졌음
- 로직에 대한 단위 테스트가 쉬워짐
- DB 연동 필요 없음 (Mocking 필요 X)
- 실제 테스트하고 싶은 대상에만 초점
- 로직 변경/기능/추가가 쉬워짐
- 관련 로직이 한 곳에 모임 -> 변경할 곳도 한 곳으로 모임
- 데이터가 한 클래스에 있음 -> 데이터(expectedPeriod, realPeriod)와 관련된 기능 추가는 해당 클래스
- 코드가 흩어지는 문제를 방지
마무리
나도 개발하면서 영속성 계층에서 (저 만큼 복잡하지는 않았지만) 로직을 수행해서 가져와야 하는 경우가 일부 있었다.
- ALTER(Utf8mb4 적용)를 할 수 없어서 임시 테이블에 데이터가 존재 시 해당 테이블에서 데이터를 꺼내와야 했던 문제
나도 JPA Entity에서 로직을 구현해서 테스트를 했었다. 내가 한건 처음 코드를 작성한 것이었고 기존의 코드를 리팩토링하는 것은 조금 더 어려운 것 같다.
로직이 수행이 많아서 단위 테스트를 작성하기가 어렵다면 수행할 로직에서 테스트할 대상이 정말 1가지가 맞는 지 확인하고, 2가지 이상이라면 테스트할 대상을 분리해보자.
- SQL을 실행하는 부분에서는 가능한 로직을 담지 말고 저장소의 역할만 해야 한다.