서론
테스트 필요성은 레거시가 더 높다.
우리가 생각하는 레거시 코드의 여러 특징들에는 "테스트 없음" 또는 테스트하기 어려운 구조 와 같은 부분들이 있습니다.
실제로 레거시 코드에 테스트 추가는 레거시 코드를 다룰 때 부딪치는 큰 도전 중 하나입니다.
레거시 코드를 단위 테스트하는 것은 확실히 어려운 과제입니다.
하지만 실제로 테스트 필요성이 높은 분야 역시 레거시 코드입니다.
프로그램이 예상치 못한 방식으로 작동할 가능성이 높기 때문입니다.
높은 확률로 코드 작성자는 현재 작업자와 컨벤션, 배경지식, 코딩스타일 등에 대한 이해도를 맞추지 않았을 것이니까요.
실제로 현재 저희 팀에서는 (저희가 레거시라 정의한) 레거시 코드에 대한 변경 작업시 충분한 양의 테스트의 통합을 선행 작업으로 진행하고 난 이후 실제 변경 작업을 수행해야 한다는 공통적 이해가 존재합니다.
단위 테스트 for 거대한 클래스
우리가 “레거시 코드는 테스트하기 어렵다”라고 생각을 할 때 많은 경우 거대한 클래스를 테스트하는 것을 떠올립니다.
거대한 UserService 처럼 말이지요.
그런데 사실 거대한 클래스에 대한 단위테스트도 잘하면 크게 복잡하지 않습니다.
이 글에서는 Pass-Null 기법을 통해 거대한 클래스를 단위테스트하는 방법을 소개드리려 합니다.
우리 마음의 고향이자 우리가 사랑하고 가꾸는 UserService
클래스를 예로 들어, 이 기법을 실제로 어떻게
적용하는지 보여드리겠습니다.
본문
Pass-Null 기법 소개
테스트를 작성할 때, 구성하기 어려운 파라미터가 필요한 객체에는 단순히 null을 전달하는 것을 고려해보세요.
테스트 실행 과정에서 해당 파라미터가 사용되면, 코드는 예외를 발생시키고 테스트 하네스가 이를 포착합니다.
실제 객체가 필요한 동작을 필요로 한다면, 그 시점에 객체를 구성하여 파라미터로 전달할 수 있습니다.
`Pass Null`은 Java와 C#과 같은 언어에서 유용합니다.
이들 언어에서는 null 참조가 런타임에 사용될 때 예외를 던집니다.
반면, C와 C++에서는 이 방법이 좋지 않을 수 있습니다.
런타임이 null 포인터 오류를 감지하지 못한다면, 테스트가 미스터리하게 충돌하거나, 더 나쁘게는 메모리를 손상시키면서 잘못된 방향으로 진행될 수 있습니다.
이러한 방식으로, `Pass Null` 기법은 거대한 클래스의 단위 테스트를 단순화하고 효율적으로 만들 수 있는 강력한 도구가 됩니다. 이 기법을 사용함으로써, 레거시 코드에 대한 테스트 접근 방식을 재고하고, 보다 안전하고 효과적인 코드 수정을 위한 길을 열 수 있습니다.
- 참고: 마이클 C. 페더스의 “레거시 코드 활용 전략”
예시: 거대한 UserService
자바 클래스 테스트하기
우리 마음의 고향, 모두의 서비스 UserService 를 예시로 들겠습니다. 여기서 UserService 는 13개의 의존 컴포넌트를 가지고 사용자 관련 모든 기능을 수행합니다. Pass Null 기법을 활용해서
단위 테스트를 작성해보겠습니다.
step 1. UserService
클래스와 의존 컴포넌트
- 시작으로 테스트 대상
UserService
클래스와 그 의존성을 소개합니다. - UserService 에는 13개 정도의 의존 컴포넌트가 있습니다.
- 이번에 테스트 하는 메소드는 getUserInfo(Long) 메소드로 userId 기준으로 검색이 안되거나 비활성화(deactivated) 상태인 경우 에러를 발생시킵니다.
- **예시가 좀 비현실 적일 수도 있는 점 양해 부탁드립니다.
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepositoryCustom userRepositoryCustom;
private final UserRepository userRepository;
private final DeactivatedUserService deactivatedUserService;
private final UserInfoService userInfoService;
private final UserDetailsService userDetailsService;
private final UserProfileService userProfileService;
private final MaliciousUserActionService maliciousUserActionService;
private final IdentityVerifyClient identityVerifyClient;
private final PasswordEncoder passwordEncoder;
private final AccountManagementRepository accountManagementRepository;
private final LoginService loginService;
private final UserActionLoggingService userActionLoggingService;
private final FindFriendsService findFriendsService;
// all args constructor 코드는 Lombok 이 생성
public UserService(파라미터들...) {
// 파라미터들을 필드에 할당
}
// 테스트 대상 메소드
public UserInfo getUserInfo(Long userId) {
UserInfo result = userRepository.findId(userId); // id 기준 조회 시도
if (result == null) {
throw new UserNotFoundException(userId); // 못 찾으면 에러
}
if (deactivatedUserService.isDeactivated(userId)) { // 비활성화 여부 체크
throw new CannotAccessDeactivatedUserException(userId); // 비활성화 상태면 에러
}
return result; // 응답
}
// .... 나머지 메소드들
}
step 2. JUnit을 사용한 단위 테스트 초기화
- 이 지점부터 Pass-Null 기법을 즉시 적용합니다.
- 무작정 UserService를 초기화하고 테스트를 작성합니다 --예제에서는 Junit 을 활용해서 단위테스트를 작성했습니다.
- 전부 null 값 전달해주면 즉시 초기화가 가능합니다.
public class UserServiceTest {
private UserService userService;
@Before
public void setUp() {
userService = new UserService(null, null, null, null, null, null, null, null, null, null, null, null, null);
}
@Test
public void testGetUserInfo() {
// Given
// 일단 아무것도 적지 않기
// Then
UserInfo actual = userService.getUserInfo(1L);
// Then
UserInfo expected = new UserInfo(1L, "김ㅈㅎ", "01001010101", .... );
assertEquals(expected, actual);
}
}
step 3. 1차 테스트 실행
- 테스트 실행시 아래와 같이
NullPointerException
에러가 발생합니다. userRepository
가null
이기 때문이죠.NullPointerException
를 레버리징해서 빠르게 1차 피드백을 받았으니 1차 피드백을 반영하러 갑니다.
java. Lang. NullPointerException: Cannot invoke "userRepository.findId(Long)" because "this.userRepository" is null
step 4. 점진적 테스트 개선 1 : UserRepository 초기화 및 목킹하기
UserRepository
를 초기화합니다.findById(Long)
메소드를 목킹해서UserInfo expected
객체를 리턴하게 합니다.- 1차적인
NullPointerException
을 해소해줍니다. - 참고로 본 블로그에서는 (상태기반 테스트 vs 행위기반 테스트 / classicist vs mockist) 등의 내용을 배제하고 있어서 감안하고 봐주시면 될 것 같습니다.
public class UserServiceTest {
private UserService userService;
private UserRepository userRepository;
@Before
public void setUp() {
// Mockito 테스트 라이브러리로 초기화
userRepository = Mockito.mock(UserRepository.class);
userService = new UserService(null, userRepository, null, null, null, null, null, null, null, null, null, null, null);
}
@Test
public void testGetUserInfo() {
// Given
// 데이터 셋업
UserInfo expected = new UserInfo(1L, "김ㅈㅎ", "01001010101", .... );
// 의존 컴포넌트 목킹
when(userRepository.findById(anyLong()).thenReturn(expected);
// Then
UserInfo actual = userService.getUserInfo(1L);
// Then
assertEquals(expected, actual); // UserInfo 클래스의 equals 메소드 오버라이딩 전제
}
}
step 5. 2차 테스트 실행
- 테스트 실행시 아래와 같이
NullPointerException
에러가 발생합니다. - 이번에는
DeactivatedUserService
가null
이기 때문이죠. NullPointerException
를 레버리징해서 빠르게 2차 피드백을 받았으니 2차 피드백을 반영하러 가보겠습니다.
java. Lang. NullPointerException: Cannot invoke "DeactivatedUserService.isDeactivated(Long)" because "this.deactivatedUserService" is null
step 6. 점진적 테스트 개선 2 : DeactivatedUserService
초기화 및 목킹하기
DeactivatedUserService
를 초기화합니다.isDeactivated(Long)
메소드를true
값을 리턴하게 목킹합니다.NullPointerException
을 해소해줍니다.
public class UserServiceTest {
private UserService userService;
private UserRepository userRepository;
private DeactivatedUserService deactivatedUserService;
@Before
public void setUp() {
// Mockito 테스트 라이브러리로 초기화
userRepository = Mockito.mock(UserRepository.class);
userService = new UserService(null, userRepository, deactivatedUserService, null, null, null, null, null, null, null, null, null, null);
}
@Test
public void testGetUserInfo() {
// Given
UserInfo expected = new UserInfo(1L, "김ㅈㅎ", "01001010101", .... ); // 데이터 셋업
// 의존 컴포넌트 목킹
when(userRepository.findById(anyLong())
.thenReturn(expected);
when(deactivatedUserService.isDeactivated(anyLong())
.thenReturn(false);
// When
UserInfo actual = userService.getUserInfo(1L);
// Then
assertEquals(expected, actual); // UserInfo 클래스의 equals 메소드 오버라이딩 전제
}
}
step 7. 마지막 테스트 실행
- 테스트 실행시 아래 (퍼온) 사진과 같이 통과합니다.
- 이제
getUserInfo
는 저희 제어권 내부로 흡수되었습니다. - 이제
getUserInfo
를 자유롭게 테스트 할 수 있습니다.
결론
본문의 예제에서 Pass Null 기법을 활용했고 이를 통해 UserService
클래스의 단위테스트를 단계적으로 완성시켜나갔습니다.
Pass Null 기법은 쉽고 빨라서 테스트 코드 작성을 몸에 익히기에 매우 유용합니다.
저 또한 이 기법으로 많은 경험을 쌓았습니다.
빠르게 테스트 작성하다보면 자신감도 생기고 테스트가 몸에 익숙해지는 데 도움이 되며, 더 많은 테스트를 작성하는 선순환이 발생합니다.
특히 Java와 같은 컴파일 언어에서는 컴파일러를 활용해 테스트 가이드를 얻을 수 있고, 단위테스트는 대게 실행속도가 빨라서 피드백도 빠릅니다.
이렇게 우리는 거대한 클래스를 단위 테스트하는 방법 한 가지를 더 알게 되었습니다.
단위 테스트는 레거시 코드를 안정적으로 프로젝트를 개선할 가능성을 열어주기 때문에 적극 활용해보시기 바랍니다.
읽어주셔서 감사합니다!
PS : 질문은 제 링크드인으로 연락 주시면 빠른 답변 드릴 수 있습니다.
'Test' 카테고리의 다른 글
[Kotlin] 랜덤 한국 이름(세자리)와 랜덤 주민등록번호 생성 테스트 유틸 (0) | 2023.10.17 |
---|