개요
최근 참여한 테스트 코드 스터디에서 테스트과 관련된 좋은 인사이트를 많이 얻었다. 평소 테스트가 어려웠는데, 스터디 이후 조금은 재밌다는 생각이 들었다. 스터디에서 배운 점들을 정리하고 복습하고자 포스팅을 작성해본다 ✅
인프런의 Java/Spring 테스트를 추가하고 싶은 개발자들의 오답노트를 학습하며 정리한 포스팅입니다!
왜 테스트 코드를 짜야 하는가?
✅ 인수 테스트의 한계
우리는 보통 내가 만든 코드가 의도대로 잘 돌아가는지 검증하는 목표로 테스트 코드를 짠다. 그러다보니 흔히 이런 생각을 하곤 한다.
굳이 테스트 코드를 안짜도, 직접 실행해서 이것저것 눌러보면 충분히 테스트 한 게 아닐까?
예를 들어 REST API를 만든다고 가정할 때, 굳이 테스트 코드를 짜지 않고도 Postman이나 Swagger 등을 활용해 API를 call하고 정상 동작하는지 확인하는 것 만으로도 충분히 테스트가 완료된다고 생각하는 거다. 하지만 과연 이게 효율적인 방법인지 고민해봐야 한다.
아주 단적인 예로, 회귀 버그 (Regression bug) 문제가 있다.
회귀 버그란?
✓ 기존 코드를 수정하거나 or 신규 기능을 배포한 이후 잘 돌아가던 기능에 갑자기 생긴 버그
✓ 즉, 새로 만든 기능이 아니라, 그 기능으로 인해 영향을 받은 다른 컴포넌트의 버그
기능이 많아질 수록 시스템에 존재하는 모든 기능들을 직접 하나하나 돌려보는 것은 어려워진다. 각 기능에서 확인해야 할 엣지 케이스가 많다면 더더욱 힘든 작업이 될 것이다. 검증이 어려우니 테스트 신뢰도가 낮아질 것이고, 자연스레 신규 기능의 배포 비용이 커진다.
하지만 테스트 코드를 이용해 테스트를 자동화한다면? 테스트 버튼 하나를 누르는 것 만으로 시스템의 모든 기능들을 점검하고, 충돌 여부를 확인할 수 있다. 사람이 테스트하기 힘든 동작 (복잡한 연산, 확률 기반 시스템, 외부 의존성 etc)을 보다 쉽게 검증할 수 있는 건 물론이다.
✅ 유연한 설계 만들기
이런 장점에도 불구하고 우리는 테스트에 쉽사리 손을 대지 못한다. 테스트 코드를 짠다는 것 자체가 할 일은 2배로 만들기 때문이기도 하지만, 내 경우에는 어떻게 테스트해야 할 지 모르겠어서 고민한 경험이 많다. 그럼 이제 고민해보자. 왜 테스트를 짜기가 어려웠을까?
상황에 따라 여러 이유가 있겠으나, 테스트하기 어려운 구조였을 가능성이 크다.
테스트하기 어려운 구조 예시
✓ 외부 시스템에 크게 의존하고 있는 경우
(e.g., 이미지 업로드 기능을 확인하기 위해 S3에 직접 접근해야 한다.)
✓ 통제할 수 없는 코드가 있는 경우
(e.g., LocalDateTime.now()는 실행 순간의 시간을 기록하므로 값 비교가 어렵다.)
✓ 무엇을 테스트해야 하는지 알 수 없는 경우
(e.g., 메서드의 책임이 너무 많거나, JpaRepository를 테스트한다거나)
내가 테스트하려고 구조까지 수정해야해?! 라고 생각할 수 있겠으나, 반대로 생각해보자. 애초에 시스템 구조가 좋지 못했던 건 아닐까? 우리는 좋은 코드에 대해 논할 때 객체지향의 SOLID 원칙을 자주 언급하곤 한다. 테스트와 SOLID의 상관 관계에 대해 고민해보자.
원칙 | 테스트에서의 관계 |
S (단일 책임 원칙) | 테스트는 여기서 뭘 확인하려는 것인지 잘 보이도록, 최대한 명료하고 간단해야 한다. |
O (개방 폐쇄 원칙) | 테스트 컴포넌트와 프로덕션 컴포넌트를 분리해 작업하고, 필요에 따라 컴포넌트를 탈부착 하자. |
L (리스코프 치환 원칙) | 모든 클래스를 테스트했다면, 서브 클래스에 대한 치환 여부를 테스트로 검증할 수 있다. |
I (인터페이스 분리 원칙) | 테스트는 그 자체로 인터페이스를 사용해볼 수 있는 환경으로, 불필요한 의존성을 확인할 수 있는 샌드박스이다. |
D (의존성 역전 원칙) | 가짜 객체(Fake)를 이용해 테스트를 작성하려면 의존성이 역전되어야 할 필요가 있다. |
개인적으로 SOLID는 무슨 일이 있어도 지켜야만 하는 규칙이라기보단, 이 원칙을 지키지 않았을 때 발생하는 부수효과에 대해 경각심을 가지는 용도로 사용되는 도구라고 생각한다. 그런 관점에서 테스트와 SOLID를 묶어서 바라보면, 테스트를 통해 내 설계의 개선 방향을 판단할 수 있을 것이다.
의존성과 Testability
위에서 의존성이라는 단어가 몇 번 나왔다. 의존성이란 무엇일까? 의존성 관련 개념들을 빠르게 알아보자.
- 의존성 (Dependency) : 결합 (coupling)와 동일한 개념. 다른 객체의 함수 (또는 객체)를 사용하는 상태.
- 의존성 주입 (Dependency Injection) : 필요한 값을 직접 인스턴스화 하지 않고, 외부에서 넣어주는 방법. 의존성 약화 테크닉
- 의존성 역전 (Dependency Inversion Principal) : 세부 구현은 추상화에 의존해야 하며, 고수준 정책은 저수준 세부사항을 알아선 안된다. 변동성이 큰 구체적인 요소에 의존하지 말자.
여기서 의존성 역전에 주목해야 한다. 이 포스팅의 제목! 테스트 하기 편한 구조를 만들어주는 일등 공신! 코드로 바로 살펴보자.
❌ 의존성이 숨겨진 코드
import java.time.Clock;
class User {
private long lastLoginTimestamp;
public void login() {
// ...
this.lastLoginTimestamp = Clock.systemUTC().millis()
}
}
위 코드에서 User.login()은 마지막 로그인 시간을 기록하기 위해 Clock에 의존하고 있다. 아무 문제가 없어보이지만, 테스트 코드를 짜려고 보면 곤란해진다.
class UserTest {
@Test
public void login_테스트() {
// given
User user = new User();
// when
user.login();
// then
assertThat(user.getLastLoginTimestamp()).isEqualTo(????);
}
}
assertThat의 isEqualTo에 무슨 값을 넣어야 할까? login() 내부에서 Clock을 이용해 현재 시간을 동적으로 생성하므로, 외부에서 값을 검증할 수 없다. 이와 같이 테스트 하다가 테스트..쫌 빡센데? 라는 생각이 든다면 구조 개선을 고민해 볼 필요가 있다. (설령 라이브러리 등을 이용해 테스트할 수 있더라도, 라이브러리가 있어야만 검증할 수 있는 기능이라는 점에서 경각심을 가져야 한다)
🔺 의존성을 드러낸 코드 (feat. 의존성 주입)
class User {
private long lastLoginTimestamp;
public void login(Clock clock) {
// ...
this.lastLoginTimestamp = clock.millis();
}
}
테스트가 어려운 이유가 lastLoginTimestamp가 무엇을 이용해 생성되는지 알 수 없기 때문이라고 판단, login 외부에서 Clock을 넘겨주어 결과를 예상 가능하게 만들었다. Clock을 객체 외부에서 주입해주었으므로, 의존성 주입이 되었다고 볼 수 있다.
class UserTest {
@Test
public void login_테스트() {
// given
User user = new User();
Clock clock = Clock.fixed(Instant.parse("2000-01-01T00:00:00.00Z"));
// when
user.login();
// then
assertThat(user.getLastLoginTimestamp()).isEqualTo(9272878791839819L);
}
}
이제 우리는 Clock 변수를 만들어 외부에서도 login의 동작 결과를 테스트할 수 있다. 언뜻 보면 모든 문제가 해결된 것 같지만, 과연 그럴까? 아래 코드를 보자.
class UserService {
public void login(User user) {
// ...
user.login(Clock.systemUTC());
}
}
User의 login을 또 다른 메서드에서 호출하고 있다면? 그래서 그 메서드를 테스트해야 한다면? 의존성이 숨겨져있던 맨 처음 상황과 똑같은 문제가 발생한다.
여기서 우리는 의존성 주입은 어딘가로 책임을 넘겼을 뿐임을 알 수 있다. 결국 어딘가에선 떠넘겨진 책임을 받아, 직접 인스턴스를 생성하고 주입해주어야 한다. 이런 케이스에서 우리는 의존성 역전을 이용해 문제를 해결할 수 있다.
⭕️ 의존성 주입 + 의존성 역전을 적용한 코드
interface ClockHolder {
long getMillis();
}
@Component
class SystemClockHolder implements ClockHolder {
@Override
public long getMillis() {
return Clock.systemUTC().millis();
}
}
@AllArgsConstructor
class TestClockHolder implements ClockHolder {
private Clock clock;
@Override
public long getMillis() {
return clock.millis();
}
}
먼저 문제가 되는 Clock을 사용하는 코드를 인터페이스로 분리해준다. 현재 시간의 밀리초를 측정해야 하므로, 해당 역할을 인터페이스에 추상 메서드로 추가해준다.
그리고 실제 기능에 사용될 프로덕션용 컴포넌트와, 테스트에 사용될 테스트용 컴포넌트를 각각 인터페이스를 구현하여 생성해준다. 여기서 집중할 부분은 테스트용 컴포넌트! 외부에서도 내부의 동작을 짐작할 수 있게끔, 구현체 내부에서 인스턴스를 생성하는게 아니라, 외부에서 구현체로 인스턴스를 넘겨 주도록 구성해주자. (의존성 주입)
@Getter
class User {
private long lastLoginTimestamp;
public void login(ClockHolder clockHolder) {
// ...
this.lastLoginTimestamp = clockHolder.getMillis();
}
}
@Service
@RequiredArgsConstructor
class UserService {
private final ClockHolder clockHolder;
public void login(User user) {
// ...
user.login(clockHolder);
}
}
User와 UserService도 마저 수정해주자. 구현체가 아니라, 인터페이스를 이용하도록 수정해주면 된다. 인터페이스로 갈아껴줬을 뿐인데 뭐가 달라져요? 코드를 작성할 때 원하는 구현체를 끼워넣을 수 있다는 점이 달라진다.
class UserServiceTest {
@Test
public void login_테스트() {
// given
Clock clock = Clock.fixed(Instant.parse("2000-01-01T00:00:00:00Z"), ZoneId.of("UTC"));
User user = new User();
UserService userService = new UserService(new TestClockHolder(clock));
// when
userService.login(user);
// then
assertThat(user.getLastLoginTimestamp()).isEqualTo(9738389400000L);
}
}
UserService userService = new UserService(new TestClockHolder(clock));
여기가 핵심이다. 외부에서 Clock을 주입해줄 수 있는 TestClockHolder 구현체를 이용해 UserService를 생성해주었다. 이제 우리가 정한 Clock만 이용하는 일관적이고, 결과를 예상할 수 있는 테스트가 되었다.
최종 구조를 도식화하면 아래와 같다.
실제 프로덕션 환경에서도 TestClockHolder가 사용되면 어떡하지? 라고 고민할 수 있다. @Component 등을 활용해 빈으로 등록해주면 된다. 스프링의 IoC/DI는 어플리케이션이 처음 실행될 때 스프링 빈들을 스캔하여 필요에 맞게 자동으로 의존성을 주입해준다.
이때 주입 대상은 스프링 빈이라는 점에 주목하자. TestClockHolder을 빈으로 등록해주지 않으면 스프링 컨테이너의 DI 대상이 아니다. 따라서 프로덕션 환경에선 @Component을 이용해 빈으로 등록된 SystemClockHolder가 자동으로 주입될 것이다.
♻️ Testability
위와 같은 과정을 거쳐, 테스트하기 쉬운 코드로 리팩토링 하는 것에 성공했다. 이를 테스트 가능성이 높아진다고 표현한다.
Software Testability
✓ 주어진 상황에서 테스트를 지원하는 정도
✓ 소프트웨어 테스트 가능성이 높을 수록, 테스트를 통해 시스템에서 결함을 찾는 것이 쉬워진다.
✓ 반대로 테스트 가능성이 낮으면 테스트 비용이 증가한다.
즉, 얼마나 쉽게 input을 변경하고, 얼마나 쉽게 output을 검증할 수 있는가? 를 나타내는 지표라고 볼 수 있다. 대부분의 소프트웨어는 의존성 주입과 의존성 역전을 활용하여 테스트 가능성을 높일 수 있다.
레퍼런스
https://en.wikipedia.org/wiki/Software_testability
'Develop > Java' 카테고리의 다른 글
[Java] equals()와 hashCode()를 털어보자 (feat. 동일성 vs 동등성) (1) | 2023.12.23 |
---|---|
[Java] 참조 타입에 ==을 쓰면 안되는 이유 (feat. String Pool, Integer cache) (0) | 2023.11.25 |
[Java] 맥북에서 자바 버전 여러 개 돌려쓰기 (1) | 2023.05.01 |