TDD를 어떻게 프로젝트에 적용해볼까?
최근에 켄트백님의 Test Development by Example 을 읽으며 테스트 주도(Test-Driven) 개발에 대해서 책을 따라가며 감을 익혀보았다. 하지만, 실제 프로젝트에 바로 적용하기에는 선뜻 책을 1번밖에 안읽어서인지 바로 어떻게 해야겠다고 다가오지는 않았다. 그래서 ChatGPT와 함께 테스트 주도 개발을 프로젝트에 적용하기 위해서 필요한 것들을 토론해보았다. 당장 바로 사용할려면 어떻게해야할까? 를 고민했을때 첫번째로 나는 테스트 케이스를 어떻게 떠올려야 할까? 큰 기능을 어떻게 작은 기능 단위로 나누어야 할까? 와 같은 고민들이 생겼고 그 고민에 대한 연장선으로 토론해보았다.
문제
- 기능에 대한 테스트 케이스가 떠오르지 않는다.
- 실패하는 테스트를 작성하는 것에서 시작하는데, 큰 기능단위를 어떻게 작은 기능단위로 나눌 수 있는가? 작은 기능이 되어야 실패하는 테스트를 작성하는데 쉬운것 같다.
- 기능 구현을 목표로 하는 개발은 테스트 주고 개발을 어떻게 가져가야 할까?
- 큰 기능만 바라보고 있으니, 좀처럼 todolist가 지워지지 않는다. todo를 통해서 기능 구현을 목표로 어떻게 작은 todo들로 도달하기 위해서는 todo를 어떻게 작성해야할까? 팁이 필요함
1. 기능에 대한 테스트 케이스가 떠오르지 않는다.
원인
- 기능을 너무 '크게' 바라봐서 테스트 조건이 막연할 수 있음
- 요구사항 자체가 불분명하거나, 구체적인 입출력 시나리오가 정리되지 않았을 수 있음
팁
- 사용자 입장에서 생각해보기 -> 내가 이 기능을 쓴다면, 어떤 입력을 주고 어떤 결과를 기대하지?
- 경계 조건을 생각하기 -> ex) 빈 리스트, 0, 음수, 이상한 문자열 등
- 에제 중심 사고법 적용 :
이런 입력이면 -> 이런 결과가 나와야 한다
식으로 써보기
예 : '할일 목록에 새로운 아이템을 추가한다'
- 리스트가 비어있을 때 추가하면, 아이템 하나가 생겨야 한다
- 이미 2개가 있을 때 추가하면, 총 3개가 되어야 한다
- 같은 내용의 아이템도 별도로 추가되어야 한다 (중복 허용 여부 체크)
2. 큰 기능 단위를 어떻게 작은 단위로 나눌 수 있는가?
원칙
- TDD는 '작은 설계 단위' 에 적합한데, 큰 기능을 작은 단계로 '해체' 하지 않으면 실패하는 테스트부터 시작하기 어려움
팁
- 시나리오 기반으로 쪼개기 : 기능을 '사용자의 행동 시나리오 ' 기준으로 나누어보기.
기능 : 게시글 작성
1. 사용자가 제목과 내용을 입력하면
2. 게시글이 저장되고
3. 고유한 ID가 부여되고
4. DB에 저장되며
5. 사용자에게 저장 결과를 알려준다
- 위처럼 쪼개면 각 단계에 대해 테스트를 걸 수 있습니다.
3. 기능 구현이 목표일 때, TDD는 어떻게 적용해야 할까?
TDD는 속도가 느릴 수 있다. 기능 구현이 주된 목표라면, 다음 방법을 활용해보기.
현실적인 TDD 적용 방식
- 아주 작은 단위만 TDD로 : 예를 들어 핵심 도메인 로직이나, 버그 가능성이 높은 로직에만 집중.
- Outside-In 방식 활용: 먼저 외부에서 사용할 인터페이스(컨트롤러, 서비스의 공개 메서드 등) 를 생각하고, 그걸 사용하려고 할 때 필요한 내부 로직을 테스트 주도 개발로 파고들기.
4. Todo를 작성해도 잘게 쪼개지지 않는다. To-do를 쪼개는 팁이 궁금하다.
팁 1 : UI/입출력과 로직 분리해서 생각하기
UI나 입출력은 테스트하기 어렵기 때문에, 로직 중심으로 Todo를 작성하기.
X : '게시글 작성 폼 만든다' (막연함)
O : '제목/내용 검증 로직 추가한다'
O : '입력값 없으면 예외 던지는 테스트 작성'
팁 2 : 함수 수준의 동작 단위로 쪼개기
Todo는 '테스트 가능한 함수 단위' 정도로 쪼개기.
예 : 게시글 작성 기능
- [ ] 제목과 내용이 빈 문자열일 때 예외 발생 테스트
- [ ] 게시글 저장 시 고유 ID 생성 확인 테스트
- [ ] 게시글 저장 후 저장소에 있는지 확인 테스트
팁 3 : 명사보다 동사 중심으로 쓰기
- ❌ "게시글 저장"
- ✅ "게시글 저장 시 ID가 생성된다"
- ✅ "중복된 제목은 허용된다"
팁 4 : 시간 10분 단위로 목표를 쪼개기
- '10분 안에 해결할 수 없는 Todo는 더 쪼개야 한다'는 식으로 작업의 크기를 제한해보기.
-
💡3. 기능 구현이 목표일 때, TDD는 어떻게 적용해야 할까? TDD는 속도가 느릴 수 있습니다. 기능 구현이 주된 목표라면, 다음 방법을 활용해보세요: 현실적인 TDD 적용 방식 아주 작은 단위만 TDD로: 예를 들어 핵심 도메인 로직이나, 버그 가능성이 높은 로직에만 집중. Outside-In 방식 활용: 먼저 외부에서 사용할 인터페이스(컨트롤러, 서비스의 공개 메서드 등)를 생각하고, 그걸 사용하려고 할 때 필요한 내부 로직을 테스트 주도 개발로 파고듭니다. 위 답변에 대해서 질문이 있어 - 외부에서 사용할 인터페이스를 사용하려고 할때 필요한 내부 로직을 테스트로 파고들려면 또 그걸 작은 단위로 쪼개야 할텐데, 컨트롤러 로직이 있다고 했을때 그 컨트롤러에 대한 요청 테스트를 진행하면 내부 로직도 확인할 수 잇는거 아냐 ?
-
-> 컨트롤러 테스트는 내부 로직이 '작동했는지'는 검증하지만, '정확하게 어떻게 작동했는지'는 확인하지 못합니다.
-
컨트롤러 테스트는 주로 '행위'와 '반응'을 검증하는데, 어떤 요청을 보냈더니 200 OK가 오고, 응답 JSON에는 어떤 값이 있을거다. 이런식의 테스트는 엔드 투 엔드(End-to-End) 혹은 통합 테스트에 가깝다 내부로직이 어떤 방식으로 작동했는지에 대한 세부 구현 확인은 어렵다 외부 기대 결과만 확인하는 것이고, 내부의 동작은 암묵적으로 검증되는 것인셈이다.
-
내부 로직이 복잡하거나 중요한 판단을 포함한다면, 단위 테스트가 필요하다. 컨트롤러가
PostService.createPost(dto)
를 호출하면, 그 안에서 제목 중복 검사, DB 저장, 이벤트 발행 등 여러 단계의 로직이 존재한다. 이걸 단순히 응답으로 만 테스트하게 되면 잘못 동작하거나 빠져있어도 모를 수 있고 경계 조건(제목이 공백일 때 예외) 이런 버그들을 놓치기 쉽다.
Outside-In 방식의 의미
순서대로 말하면, Outside 는 컨트롤러에서 시작해서 실패하는 통합테스트를 작성해둔다. 서비스 메서드는 어떤게 필요한지 정의하고 도메인 메서드도 정의한다. 실제로 내부 로직을 구현하지말고 호출만 가능하게 만든다. 내부 로직은 TDD로 구현하기 시작한다. 여기서 부터 작은 단위로 잘게 쪼개서 테스트-구현-리팩터를 반복한다. 최종적으로 테스트가 통과되면 전체 기능이 완성된다.
ex) 게시글 작성 기능
1. [Outside 테스트] 컨트롤러 테슽 ㅡ작성
- POST /posts 호출 -> 게시글 생성되었는지 응답 확인
- 실패 : 아직 PostService.createPost() 미구현
2. PostService.createPost(PostDto) 정의만 하고 내부는 비워둠
3. [Inside 테스트] PostService 단위 테스트 작성
- 제목이 비어있으면 예외 발생
- 제목/내용이 유효하면 Repository에 저장된다
- 저장된 게시글 ID를 반환한다
-> 테스트 주도 구현
3. [Outside 테스트 통과] 최종적으로 컨트롤러 테스트까지 통과
요약
컨트롤러 테스트로 내부 로직까지 테스트 되는거 아냐? | 아니요. 겉으로 보이는 결과만 검증할 뿐, 내부 로직의 정확한 동작은 보장하지 못합니다. |
---|---|
왜 작은 단위로 또 쪼개야 해? | 복잡한 조건, 예외처리, 상태 변화 등을 정확히 검증하기 위해서입니다. |
그럼 Outside-In은 어떤 순서로? | 통합 테스트 -> 서비스 설계 -> 내부 TDD로 구현 -> 통합 테스트 통과 순으로 진행됩니다. |
마무리
위와 정리해본대로, 내가 현재 진행하던 프로젝트인 수강후기 공유시스템, 외주 개발에 한번 적용해볼 예정이다. 곧바로 테스트 영역과 테스트 케이스들을 깔끔히 잡아내기에는 미흡하겠지만, 계속해서 반복 적용을 해보며 익숙해져볼려고 한다.