NEXTSTEP에서 진행하는 교육과정 ATDD와 함께 클린 API로 가는 길 4기다. (글을 작성하는 시점에서는 과정명이 ATDD, 클린 코드 with Spring으로 변경되었다.)
🚀 1단계 - 구간 추가 기능 변경
요구사항
- 변경된 스펙 - 구간 추가 제약사항 변경
- 변경된 스펙 - 예외 케이스
- 추가된 기능에 대한 인수 테스트 작성
- 인수 테스트 이후 TDD
2주차는 인수테스트 안에서 TDD를 하는 걸 중점으로 진행된다.
첫걸음은 테스트 관점에 따라 이미 구현된 기능에 통합 테스트 코드를 2번씩 작성하는데
실객체를 이용하는 LineServiceTest, 가짜객체를 이용하는 LineServiceMockTest로 구분된다.
실객체는 말 그대로 테스트 대상 객체의 의존성을 실제 객체를 생성해서 테스트를 작성하고
가짜객체는 Mockito를 활용하여 테스트 대상 객체의 의존성을 모두 가짜객체로 작성한다.
기능은 제공받기 때문에 곧바로 성공해야한다.
그 다음부터 요구사항대로 기능 변경 및 추가, 리팩토링을 진행한다.
요구사항을 확인해보면 다양한 방법으로 노선 추가하는 요구가 나온다.
노선 중간에 추가하거나 맨 앞에 추가하거나 단순히 추가만 하는 게 아니라 변경에 따라 구간 길이도 같이 변경해야한다.
복잡한 요구사항을 잘 정리한다면 어느 정도 무난하게 진행이 가능하다.
이번 단계는 테스트에서 여러 문제를 겪었다.
예외처리를 작성하다가 실객체 테스트에서 JPA 쿼리 실행이 되지 않아서(더티 체킹이 동작안함) 테스트 실패가 발생했다.
@Service
@Transactional
public class LineService {
private LineRepository lineRepository;
private StationService stationService;
...
public void updateLine(Long id, LineRequest lineRequest) {
Line line = findLineById(id);
line.updateLine(lineRequest.getName(), lineRequest.getColor());
}
private Line findLineById(Long id) {
return lineRepository.findById(id)
.orElseThrow(IllegalArgumentException::new);
}
}
@DisplayName("노선 서비스 관리 - 실객체")
@SpringBootTest
@Transactional
public class LineServiceTest {
...
@DisplayName("수정할 노선 이름이 중복이면 예외 발생")
@Test
void updateLineDuplicationNameException() {
// given
LineResponse 일호선 = lineService.saveLine(createLineRequest(FIRST_LINE_NAME));
LineResponse 이호선 = lineService.saveLine(createLineRequest(SECOND_LINE_NAME));
LineRequest 중복_이호선 = createLineRequest(SECOND_LINE_NAME);
// when, then
assertThatThrownBy(() -> {
lineService.updateLine(일호선.getId(), 중복_이호선);
}).isInstanceOf(DataIntegrityViolationException.class);
}
}
테스트를 성공하기 위해 updateLine 마지막에 직접 save 구문을 실행해야 할까? 고민을 하다가
문제없이 잘 작동하는 기능인데 테스트 성공을 위해서 변경해야 하는 건 아닌 거 같았다.
단순하게 테스트 코드에 조회 호출을 추가하면서 테스트를 성공시켰는데 지금도 이렇게 해야했나 의문스럽다. (뒤늦게 찾았지만 원인은 Transaction Rollback 때문이다.)
public class LineServiceTest {
...
@DisplayName("수정할 노선 이름이 중복이면 예외 발생")
@Test
void updateLineDuplicationNameException() {
...
assertThatThrownBy(() -> {
lineService.updateLine(일호선.getId(), 중복_이호선);
// 중복에러를 위한 조회처리.
lineService.showLines();
}).isInstanceOf(DataIntegrityViolationException.class);
}
}
Mockito로 Mock 객체를 만들면 verify를 활용해서 메서드 호출 여부를 검증할 수 있는데
verify(lineRepository, times(1)).save(any())
verify(stationService, times(2)).findById(any())
문제는 검증에 관계없는 부분인 협력 객체를 준비하는 setUp() 과정까지 메서드 호출 결과가 통합되어버렸다.
이에 관련된 질문을 했더니, 테스트와 관련 없는 부분이 검증에 포함되었다면서 잘못된 거라고 답변을 받았다.
그 외에도 몇가지 질문이 있었지만 생략하고 넘어간다.
🚀 2단계 - 구간 제거 기능 변경
요구사항
- 변경된 스펙 - 구간 삭제에 대한 제약 사항 변경 구현
- 추가된 기능에 대한 인수 테스트 작성
- 인수 테스트 이후 TDD
이전 단계를 잘 넘어갔다면 무난하게 할 수 있는 단계다.
요구사항은 지난번과 반대로 제거 기능을 구현이 목표다.
테스트 코드 작성 방법은 지난번과 동일하게 실객체와 가짜객체 2가지로 진행한다.
무난하게 진행되지만 프로덕션 코드보다 많아지는 테스트 코드를 보면서 의문이 생기게 된다.
인수테스트와 통합테스트를 비교해보면 겹치는게 많은데 중복이 아닐까?
당시에 인수테스트와 TDD를 비슷하게 보는 관점으로 생긴 의문이였지만
인수테스트는 고객의 입장에서 작성되기 때문에 통합테스트, 단위테스트와 목적이 전혀 달라서 중복으로 보면 안되는 거였다.
🚀 3단계 - 경로 조회
요구사항
- 최단 경로 조회 인수 조건 도출
- 최단 경로 조회 인수 테스트 만들기
- 최단 경로 조회 기능 구현하기
2주차 마지막 단계인 경로 조회다. 제공해주는 다익스트라 알고리즘 라이브러리를 통해서 출발지와 도착지의 최단경로를 구한다. (이전 기수 통틀어서 직접 구현한 사람이 한명있다고 한다.)
학습 테스트를 통해 사용방법은 금방 익혀진다.
@Test
public void getDijkstraShortestPath() {
WeightedMultigraph<String, DefaultWeightedEdge> graph
= new WeightedMultigraph(DefaultWeightedEdge.class);
graph.addVertex("v1");
graph.addVertex("v2");
graph.addVertex("v3");
graph.setEdgeWeight(graph.addEdge("v1", "v2"), 2);
graph.setEdgeWeight(graph.addEdge("v2", "v3"), 2);
graph.setEdgeWeight(graph.addEdge("v1", "v3"), 100);
DijkstraShortestPath dijkstraShortestPath
= new DijkstraShortestPath(graph);
List<String> shortestPath
= dijkstraShortestPath.getPath("v3", "v1").getVertexList();
assertThat(shortestPath.size()).isEqualTo(3);
}
학습 테스트대로 노선과 구간을 추가해서 최단 경로를 찾으면 되는데, 연결이 안되어있거나 존재하지 않는 역에 대해서 예외처리를 해야한다.
외부 라이브러리는 구현을 건들 수가 없어서 단위 테스트를 하지 않고, 이를 사용하는 로직 검증을 진행하면 된다.
2주차 - ATDD + TDD 후기
2주차 ATDD 내에서 TDD 진행이 메인 주제다.
TDD와 관련된 단위 테스트와 도구에 대한 설명을 진행하는데 추후 포스팅으로 정리하고.
가장 기억에 남는 건 TDD 접근 방식이 2가지로 Outside In / Inside Out 구분되는데
간단하게 Inside out은 도메인 단위테스트부터 시작하는 걸로 TDD 첫걸음을 할 때 하는 행위로 이해하면 된다. 도메인 이해도가 높을 때 진행하기 편하다는 장점이 있다.
Outside In은 반대인데 컨트롤러 -> 서비스 -> 도메인 순서대로 진행하는 것을 의미한다. 도메인 이해도가 낮아도 진행이 가능하다는 장점이 있지만, 협력 객체가 필요한 경우 이를 가짜객체로 대체해야한다는 단점이 존재한다.
무엇이 더 좋을까? 정답은 없다.
사실은 상향식, 하향식 둘 다 TDD의 프로세스를 효과적으로 설명해 줄 수 없다.
만약 어떤 방향성을 가질 필요가 있다면 '아는 것에서 모르는 것으로(known-to-unknown)' 방향이 유용할 것이다. - kent beck
그리고 다음으로 기억나는 건 테스트를 바라보는 관점인 Classicist vs Mockist 다.
간단하게 Classist는 테스트 대상의 의존성이 있으면 실제 객체를 이용한다.
Mockist는 테스트 대상을 협력객체로 부터 격리하기 위해 모든 의존성을 가짜 객체로 이용한다.
그 외에 ATDD와 TDD는 별개로 봐야한다는 주제도 있었고
2주차 마지막에는 그간 질문했던 피드백이 담겨있기도 했다.
정리할 내역
- TDD 접근 방식 2가지, Inside out & Outside In 정리
- 테스트를 바라보는 관점 Classicist & Mockist 정리
- 단위 테스트와 도구 정리
- 테스트 코드에서 JPA 더티체킹이 안되는 이유 (Transaction Rollback) 정리
'교육 및 인강 > ATDD, 클린 코드 with Spring' 카테고리의 다른 글
ATDD 5주차 - 마무리 (0) | 2022.07.25 |
---|---|
ATDD 4주차 - 테스트 기반 문서화 (0) | 2022.07.25 |
ATDD 3주차 - ATDD 기반 리팩토링 (0) | 2022.07.09 |
ATDD 1주차 - 인수테스트 후기📖 (0) | 2022.06.21 |
ATDD 시작하면서... (0) | 2022.06.09 |