TDD는 Test Driven Development의 약자를 의미하며 테스트 주도 개발을 의미합니다.
TDD에 대한 오해
매우 유명한 개발 기법으로 다양한 자료가 온라인에 존재하여, TDD에 대해 처음 접하였을때 제 기준으로 잘못 이해한 부분을 위주로 간단하게 설명하겠습니다.
1. 테스트 코드를 이용하면서 개발하면 TDD 를 사용한거다. (△)
일부는 맞고 일부는 틀린 내용입니다.
정확히는 테스트 코드를 주도로 하여 개발하는 것을 TDD라고 정의합니다.
이는 개발하는 과정에서 본인이 테스트에 얼마나 비중을 두고 개발하는가? 라는 질문과는 거리가 먼 정의입니다.
그럼 이게 정확히 어떤 의미인지는 아래에서 말씀드리겠습니다.
2. 작동하는 코드를 개발 이후 테스트 코드를 통해 작동하는걸 검증하면서 개발하는 걸 TDD라고 한다. (X)
작동하는 코드(로직 파트)를 개발 후 테스트 코드를 통해 검증하는건 TDD가 아닌 TLD(Test Last Development)라고 합니다.
이 부분이 제가 크게 혼동했던 부분이였습니다.
저는 개발할때 로직 코드를 먼저 개발하고 이후에 테스트를 진행할때 더 많은 시간과 노력을 할애하는 방법을 TDD라고 오해했습니다. 하지만 이는 TLD 기법이였고 TDD는 다른 기법이였습니다.
즉 TDD는 테스트 코드를 먼저 작성하고 이에 맞춰서 로직 코드를 개발하는 방법을 의미합니다.
TDD의 사이클 3단계
1. 테스트를 작성한다. [RED]
- 컴파일 조차 안되는 코드를 통해 테스트를 작성 후 실행 (※테스트의 뼈대를 만드는 과정)
2. 실행 가능하게 만든다. [GREEN]
- 함수, 메소드 등을 작성하여 컴파일 및 실행이 되도록 코드를 작성
3. 원하는 로직 코드로 개발한다.[REFACTOR]
- 테스트의 규격안에서 작동 가능하도록 코드를 추가 및 보안을 하며 개발
TDD를 사용하는 이유 및 실습
TDD를 사용하는 이유는 너무나 잘 알려져있습니다.
대표적으로 깔끔한 코드, 느슨한 결합, 빠른 오류 캐치 등이 있습니다.
그러면 TLD와 TDD를 코드로 비교하여 어떻게 다르고 어떤 특징이 있는지 살펴 보겠습니다.
예시로 Spring Boot와 JUnit을 이용하여 준회원의 등급을 정회원으로 승급시켜주는 관리자 기능을 개발해보겠습니다.
TLD
로직 코드를 작성 후 테스트 코드를 작성하여 코드가 잘 작동하는지 확인해보겠습니다.
개발할 기능
- 유저 정보를 받기
- 해당 유저의 티어를 정회원으로 변경하기
로직 작동 순서
- 외부로부터 Service가 유저 정보 받음
- Repository를 통해 DB로부터 유저 정보 찾기
- 유저 티어 변경
- 변경된 값 리턴
AdminService.class
@Service
public class AdminService {
@Autowired
private UserTierRepository userTierRepository;
public void advanceUserTier(UserTierInfo userTierInfo){
UserTierInfoEntity entity = userTierRepository.findById(userTierInfo.getId());
entity.changeTier("regular");
userTierInfo.setTier(entity.getTier());
}
}
UserTierInfo.class
@Getter
@Setter
@Builder
public class UserTierInfo {
private String id;
private String tier;
}
UserTierRepository
public interface UserTierRepository extends JpaRepository<UserTierInfoEntity, Long> {
UserTierInfoEntity findById(String id);
}
테스트 코드
@Test
public void advancementTestLater(){
AdminService adminService = new AdminService();
UserTierInfo userTierInfo = UserTierInfo.builder()
.id("hong1234")
.tier("associate")
.build();
adminService.advanceUserTier(userTierInfo);
String userTier = userTierInfo.getTier();
assertEquals("regular", userTier, "tier compare");
}
테스트 실행 결과
Repository가 null이라고 뜹니다. 이유는 단위테스트 할때 Autowired된 빈들은 스프링 컨텍스트를 로드하지 않기에 bean 등록이 안되어 있기 때문입니다.
테스트 코드 수정
@RunWith(SpringRunner.class)
public class AdminTests {
@Mock
UserTierRepository userTierRepository;
@InjectMocks
AdminService adminService;
@Test
public void advancementTestLater(){
UserTierInfo userTierInfo = UserTierInfo.builder()
.id("hong1234")
.tier("associate")
.build();
when(userTierRepository.findById("hong1234")).thenReturn(UserTierInfoEntity.builder()
.id("hong1234")
.tier("regular")
.build());
adminService.advanceUserTier(userTierInfo);
String userTier = userTierInfo.getTier();
assertEquals("regular", userTier, "tier compare");
}
}
테스트 실행 결과
TDD
단위 테스트를 기준으로 정하여 단위 테스트는 DB의 연결 없이 진행해보겠습니다.
1. 테스트 코드 작성[RED]
public class AdminTests {
@Test
public void advancementTest(){
// Given
AdminService adminService = new AdminService();
UserTierInfo userTierInfo = UserTierInfo.builder()
.id("hong1234")
.tier("associate")
.build();
// When
adminService.advanceUserTier(userTierInfo);
String userTier = userTierInfo.getTier();
// Then
assertEquals("tier compare", userTier, "regular");
}
}
AdminService를 만들고 User 티어 정보를 생성 후 이를 승급하게 한 후
회원 티어가 regular인지 확인하는 테스트 코드입니다.
문제가 없어보이는 해당 테스트 코드는 IDE에서 보면 사실 아래 사진과 같습니다.
이런 이유는 사실 AdminService, UserTierInfo라는 클래스도, advanceUserTier 라는 메소드도 존재하지 않기 때문입니다.
이제 실행 가능하도록 코드를 작성해보겠습니다.
2. 실행 가능하게 작성[GREEN]
우선 AdminService, UserTierInfo 클래스와 advanceUserTier 메소드를 아래와 같이 작성하여,
컴파일이 가능하고 원하는 로직이 작동하도록 하겠습니다.
AdminService.class
public class AdminService {
public void advanceUserTier(UserTierInfo userTierInfo){
userTierInfo.setTier("regular");
}
}
UserTierInfo.class
@Getter
@Setter
@Builder
public class UserTierInfo {
private String id;
private String tier;
}
이제 잘 작동하는지 테스트를 통해 확인해봅니다.
이제 컴파일도 정상적으로 동작하고, 테스트 결과도 정상으로 패스했습니다.
3. 원하는 로직으로 개발[REFACTOR]
하지만 아직 필요한 로직은 개발이 안되었습니다.
해당 서비스의 최종 목적은 DB에 있는 유저 티어 정보를 수정하는 것입니다.
이제 로직을 테스트 코드의 큰 틀에서 벗어나지 않는 선에서 구현해봅니다.
테스트 코드 수정
public class AdminTests {
@Mock
UserTierRepository repository = Mockito.mock(UserTierRepository.class);
@Test
public void advancementTest(){
// Given
AdminService adminService = new AdminService(repository);
UserTierInfo userTierInfo = UserTierInfo.builder()
.id("hong1234")
.tier("associate")
.build();
// When
Mockito.doAnswer(i -> UserTierInfoEntity.builder()
.id("hong1234")
.tier("regular")
.build()).when(repository).findById("hong1234");
adminService.advanceUserTier(userTierInfo);
String userTier = userTierInfo.getTier();
// Then
assertEquals("regular", userTier, "tier compare");
}
}
- UserTierRepository Mock 추가
- 실제 DB 연결 테스트를 제외하였기에 repository를 Mock 객체(대신 적용되는 임시 객체)로 대체하여 작동하도록 설정
- When 파트에 추가된 코드
- repository의 findById라는 함수를 실행했을때 어떤 값을 리턴하는지 지정.
- 여기서는 findById("hong1234")를 실행했을때 id에는 hong1234, tier에는 regular라는 데이터가 있는 entity를 리턴하도록 설정해주었습니다.
AdminService.class 수정
@Service
public class AdminService {
private final UserTierRepository userTierRepository;
@Autowired
public AdminService(UserTierRepository userTierRepository){
this.userTierRepository = userTierRepository;
}
public void advanceUserTier(UserTierInfo userTierInfo){
UserTierInfoEntity entity = userTierRepository.findById(userTierInfo.getId());
entity.changeTier("regular");
userTierInfo.setTier(entity.getTier());
}
}
- 생성자 주입을 통해 repository 주입
- 생성자 주입을 통하지 않고 바로 @Autowired를 적용시, Test코드에서 만든 Mock객체 주입이 불가하기에 이 방법 사용
- 테스트를 위해 사용한 이 방법을 통해 자연스레 repository와 service코드의 결합도를 낮춤
- DB 관련 로직 추가
- DB와 관련된 로직 및 그에 따른 작동 코드를 작성
Repository 코드 추가
public interface UserTierRepository extends JpaRepository<UserTierInfoEntity, Long> {
UserTierInfoEntity findById(String id);
}
Entity 코드 추가
@Getter
@Entity
@Table(name = "user_tier")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserTierInfoEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "info_id")
private long infoId;
@Column(name = "id")
private String id;
@Column(name = "tier")
private String tier;
@Builder
public UserTierInfoEntity(String id, String tier) {
this.id = id;
this.tier = tier;
}
public void changeTier(String tier){
this.tier = tier;
}
}
테스트 결과
정상 작동하는 모습을 볼 수 있습니다.
만약 DB 연결까지 필요한 테스트(ex : 통합 테스트)를 진행할 경우 Mock 객체가 아닌 진짜 Repository를 주입하면 됩니다.
후기
TLD, TDD를 통해 동일한 기능을 작성해보았습니다.
이렇게 볼 때, TLD 과정이 간단하고 직관적으로 보입니다.
제 경험에 따르면, 코드가 길어지고 기능이 복잡해질수록 테스트 코드를 이후에 작성하는 것이 어려웠습니다.
그 이유 중 하나는 테스트 코드를 작성할 때 상황이 달라지기 때문입니다.
테스트 코드 작성을 먼저 한 후에 개발을 시작하면, 데이터가 제공될때 어떤 리턴이 나오는지 우선으로 하여 코드를 작성하기에 옳고 틀린 결과를 캐치하는게 비교적 쉬웠습니다.
그리고 테스트에 필요한 데이터 혹은 목업을 넣어야 하기에 자연스레 결합도가 조금이라도 더 낮아지는 걸 경험했습니다.
하지만 테스트 코드를 이후에 작성하면, 이미 작성된 코드에 맞춰서 작성하려는 경향(머리속으로 생각중인 최상의 조건)이 있어 예상치 못한 부분을 놓치는 경우가 종종 있었습니다. 뿐만 아니라 기능 중점으로 개발을 하다보니 결합도에 대해 신경을 쓰지 못해 강한 결합을 가진 코드들도 많이 발생했습니다.
TDD가 만능인 방법은 아니지만 이 방법을 통해 추가적으로 테스트코드를 작성하거나, 잘못된 코드를 뒤늦게 발견하여 수정하는 스트레스는 확실하게 줄어들 수 있게 하는 좋은 방법인듯 합니다.