그럼 테스트 코드를 유지보수하기 점점 짜증나지고, 테스트를 안하게 되고, 그럼 결함율이 높아지고…
어쨌든 원인은 테스트 코드는 막 짜도 된다고 생각한 것이다.
그렇다면 테스트 코드 역시 실제 코드처럼 깨끗하게 짜면 된다. 테스트 코드는 실제 코드 못지 않게 깨끗하게 짜야 한다.
테스트가 우리에게 주는 것
테스트 케이스가 있으면 변경이 두렵지 않다. 안심하고 아키텍처와 설계를 개선할 수 있다. 그러므로 테스트는 유연성, 유지보수성, 재사용성을 제공해준다.
테스트 코드가 지저분하면 곧 실제 코드도 지저분해진다.
깨끗한 테스트 코드 만들기
가장 중요한 건 가독성이다. 어쩌면 가독성은 실제 코드보다 테스트 코드에서 더 중요하다.
Build Operate Check 패턴
이 패턴을 적용해서 이해하기 쉽게 만들 수 있다. 이 패턴에서 테스트는 세 부분으로 나눠진다.
테스트 자료 만들기
테스트 자료 조작하기
조작한 결과가 올바른지 확인
읽는 사람을 생각하자. 필요없거나, 너무 세부적인 내용 같은 코드는 없애자. 그런 내용이 꼭 필요하다면 더 추상적인 함수를 만들어보자. (→ 도메인에 특화된 테스트 언어)
Given When Then 관례
상황, 실행, 결과 확인의 세 가지 요소로 테스트 구성 가능
상황이 없을 때도 있고… 상황-실행-결과 확인 구조에 너무 집착하지는 말자.
테스트 코드를 작성하는 데 도움이 되는 것은 맞지만 꼭 모든 테스트 메서드를 이 구조로 만들 필요는 없다. 테스트 코드를 보고 테스트 내용을 이해할 수 있으면 된다.
도메인에 특화된 테스트 언어
DSL?
가장 익숙한 DSL은 SQL, 정규식
둘다 특정 작업에 유리.
장점
DSL이 범용 프로그래밍 언어와 달리 더 선언적(declarative)라는 것이 중요
범용 프로그래밍 언어는 보통 명령적(imperative). 따라서 어떤 연산을 위한 각 단계를 세세하게 정확히 기술. 하지만 DSL은, SQL을 예로 들면 어떤 걸 SELECT, 조건을 WHERE를 통해 명시할 뿐 어떤 방식으로 가져올 지 세부 실행은 언어를 해석하는 엔진에 맡김. 또한 전체적으로 실행엔진이 한꺼번에 최적화하면서 더 효율적일 수 있음
단점
범용 언어로 만든 호스트 애플리케이션과 조합하기 어려움. 예를 들어 SQL을 코틀린 프로그램에서 쓸려면 “SELECT…” 와 같이 문자열 리터럴로 저장해야 함. 그럼 DSL에서의 문법 검사 등도 힘들다
→ 이를 해결하면서 장점은 가져가기 위해 내부 internal DSL이라는 개념이 유명해짐
내부 DSL
독립적인 문법 구조를 가진 외부 DSL과 달리 범용 언어로 작성된 프로그램의 일부. 따라서 완전히 다른 언어가 아닌 DSL의 장점을 유지하면서 주 언어를 특별한 방법으로 사용하는 것 DSL을 통해 특정한 과업을 달성하려 하지만 구현은 범용 언어(코틀린)으로 이뤄짐
도메인에 특화된 테스트 언어라고 해서 말이 어렵지만.. 이 테스트를 위해 쓰인 특수 메서드를 만들라는 말과 비슷하다.
예를 들어 UserService 관련 테스트를 한다고 생각해보자. 테스트 메서드에서 매번 아래와 같은 상황 설정이 필요할 수 있다.
이런 상황 설정을 위한 API가 여러개 필요할 수 있다. 그럼 Helper 클래스를 만들어서 사용할 수도 있다.
public class UserGivenHelper {
private UserRepository userRepository;
public UserGivenHelper(UserRepository userRepository) {
this.userRepository = userRepository;
}
public void givenUser(String id, String pw, String email) {
userRepository.save(new User(id, pw, email));
}
// 기타 필요한 메서드들...
}
public class UserServiceTestWithHelper {
private UserRepository userRepository;
private UserGivenHelper given;
@BeforeEach
void setUp() {
userRepository = new MemoryUserRepository();
given = new UserGivenHelper(userRepository);
}
@Test
void 중복된_id있으면_가입불가() {
given.givenUser("id", "pw", "email");
userService.join(new User("id", "pw", "email"));
}
@Test
void 존재하는_User_탈퇴가능() {
given.givenUser("id", "pw", "email");
//...
}
}
처음부터 필요한 상황 설정별로 메서드를 다 만들고.. 클래스를 만들고.. 하는 것이 아니라
하나씩 구현하다가 어떤 상황 설정이 계속 반복되고, 필요할 것 같다 싶을 때 만들어보자.
이중 표준
어쨌든 테스트 코드는 실제 코드와 다르다. 실제 코드만큼 효율적일 필요가 없다.
효율적이지 않거나, 실제 환경에선 사용할 수 없는 코드라도 가독성을 위해서라면 테스트 환경에선 가능할 수 있다.
테스트 당 assert 하나
assert 문이 테스트당 반드시 하나라면 코드를 이해하기 쉬워질 수도 있다.
하지만 필요하면 assert 문을 여러개 넣자. 어쨌든 최대한 줄일 수 있다면 줄여보자.
저자는 중복을 제거하기 위해서라면 @Before 부분에 given/when 을 모두 넣는 식으로도 중복을 제거할 수 있다고 했다. 하지만 이렇게 중복을 제거하는 건 조금 고려해보는 편이 좋을 것 같다.
몇개월 뒤에 본인이, 혹은 다른 개발자가 그 테스트 코드를 본다고 생각해보자. 테스트 코드를 이해하기 위해 @Before 부분을 계속 확인해야 한다. 또 @Before 에 있는 부분과 충돌이 나서 예상치 못하게 테스트 코드가 실패해서 문제점을 파악하기 어려워질 수도 있다.
테스트 함수는 스스로 상황을 설명할 수 있는 편이 좋다.
테스트 당 개념 하나
테스트 함수 당 assert 하나보단 개념 하나만 테스트하라고 하는 편이 낫다.
여러 개념을 한 메서드에 넣으면, 이해하기 힘들 뿐만 아니라 어떤 곳에서 테스트가 실패하면, 나머지 테스트 코드는 실패할지 성공할지 여부를 알 수 없는 문제도 있다.
// Mock
@DisplayName("회원 가입시 암호 검사 수행함 - mock")
@Disabled
@Test
void checkPassword() {
// given
// when
userRegister.register("id", "pw", "email");
// then
BDDMockito.then(mockPasswordChecked)
.should()
// 이렇게 하지 말것
// .checkPasswordWeak("pw");
.checkPasswordWeak(BDDMockito.anyString());
}
“pw”와 같은 특정 값으로 호출될것을 확인하기 모다는 역시 anyString을 이용하자.
모의 객체는 가능한 범용적인 값을 사용해서 기술해야 한다.
한정된 값에 일치하도록 모의 객체를 사용하면 약간의 코드 수정만으로도 테스트는 실패하게 된다. 그럼 테스트 코드의 일부 값을 수정 시 모의 객체 관련 코드도 함께 수정해야 한다.
테스트의 의도를 해치지 않는 선에서 “pw”같은 특정 값보다는 anyString()같이 범용적인 값을 사용하자.
과도하게 구현 검증하지 않기
테스트 대상의 내부 구현을 검증하지 않도록 주의하자. 모의 객체를 처음 사용할 때 특히 이런 유혹에 빠지기 쉽다.
필자의 경우 모의 객체를 처음 접했을 때 모의 객체를 이용해서 내부 구현을 검증하는 코드를 많이 작성했는데 이것이 결과적으로 테스트 코드 유지 보수에 도움이 되지 않았다.
@DisplayName("회원 가입시 암호 검사 수행함 - mock")
@Disabled
@Test
void checkPassword() {
// given
// when
userRegister.register("id", "pw", "email");
// then
BDDMockito.then(mockPasswordChecked)
.should()
// ch10 - 이렇게 하지 말것
// .checkPasswordWeak("pw");
.checkPasswordWeak(BDDMockito.anyString());
}
원래 위와 같은 메소드가 있었다면….
// 테스트 대상의 내부 구현을 검증하는 코드 - 이렇게 하지 말자!
@DisplayName("회원 가입시 암호 검사 수행함 : 내부 구현 검사 - mock")
@Disabled
@Test
void checkPassword_Implementation() {
// given
userRegister = new UserRegister(mockPasswordChecker, mockUserRepository, mockEmailNotifier);
// when
userRegister.register("id", "pw", "email");
// then
BDDMockito.then(mockPasswordChecker)
.should() // 호출 되어야하고
.checkPasswordWeak(Mockito.anyString());
BDDMockito.then(mockUserRepository)
.should(Mockito.never()) // 호출돼선 안된다
.findById(Mockito.any());
}
테스트 대상인 UserRegister#register() 메서드가 Checker의 check 메서드를 호출하는지, 또 UserRepository#findById를 호출하지 않는 지도 검증한다.
결국 테스트 대상인 register 메서드의 내부 구현을 검증하는 것이다.
내부 구현을 검증하는 것이 나쁜 것은 아니지만 한 가지 단점이 있다.
구현을 조금만 변경해도 테스트가 깨질 가능성이 커진다!
내부 구현은 언제든지 바뀔 수 있기 때문에 테스트 코드는 내부 구현보다 실행 결과를 검증해야 한다.
언제든지 변하는 구현 말고 변하지 않는 인터페이스에 의존하자 !!!
예제 코드의 경우 PasswordChecker가 호출되는 지가 중요한 게 아니고 약한 암호일 때 register가 실패하는 지, register()가 올바른지 검사하는 것이 더 맞다.
이미 존재하는 코드에 단위 테스트를 추가할 경우
어쩔 수 없이 내부 구현을 검증해야 할 때도 있다. 레거시 코드에서 DAO는 다양한 update, select 메서드를 정의하고 있을 때가 있기 때문에 메모리를 이용한 가짜 구현으로 대체하기가 쉽지 않다.그래서 레거시 코드에 대한 테스트는 모의 객체를 많이 활용한다.
거꾸로 만약 내부 구현을 테스트해야하는 상황이라면 메소드를 나눠야 하는 경우가 아닌지 생각해볼 수도 있다
기능이 정상적으로 동작하는지 확인할 수단이 구현 검증밖에 없다면 일단 모의 객체를 사용해서 테스트 코드를 작성하더라도 작성 후에는 점진적으로 코드를 리팩토링해서 구현이 아닌 결과를 검증할 수 있도록 시도해야 한다.
실행 시점이 다르다고 실패하지 않기
예를 들어 회원의 만료 기능을 테스트한다고 해보자.
회원은 필드에 저장된 expiryDate 를 만료 검사하는 메서드인 isExpired를 실행할 때의 LocalDate.now() 와 비교하여 만료 여부를 판단한다.
public class Member {
private LocalDateTime expiryDate;
public Member(LocalDateTime expiryDate) {
this.expiryDate = expiryDate;
}
// bad
public boolean isExpired() {
return expiryDate.isBefore(LocalDateTime.now());
}
}
class MemberTest {
@Test
void 만료된_회원인지_확인() {
// given
// when
Member member = new Member(LocalDateTime.of(1999, 11, 17, 10, 10));
// then
assertThat(member.isExpired()).isFalse();
}
}
만약 테스트에서 회원의 만료일을 설정한 코드가 있을 때, 그 코드는 언젠가 설정한 만료일이 지나면 테스트가 깨질 것이다.
물론 아주 오랜 미래를 설정하면 몇십년간 깨지지 않게 할 수 있지만,
이보다는 테스트 코드에서 시간을 명시적으로 제어할 수 있는 방법을 선택하는 것이 좋다.
예를 들어 위에서 isExipired 보다 passedExpiryDate(LocalDateTime time) 으로 날짜를 받아서 판단하게 할 수 있다.
// better
public boolean passedExpiryDate(LocalDateTime time) {
return expiryDate.isBefore(time);
}
@Test
void 만료된_회원인지_확인시_인자로_시간지정() {
// given
// when
Member member = new Member(LocalDateTime.of(1999, 11, 17, 10, 10));
// then
assertThat(member.passedExpiryDate(LocalDateTime.of(1999, 11, 18, 00, 00))).isTrue();
}
혹은 별도의 시간 클래스를 작성한다.
public class BizClock {
private static BizClock DEFAULT = new BizClock();
private static BizClock instance = DEFAULT;
public static void reset() {
System.out.println("Biz Clock reset!");
instance = DEFAULT;
}
public static LocalDateTime now() {
return instance.timeNow();
}
public LocalDateTime timeNow() {
return LocalDateTime.now();
}
public static void setInstance(BizClock instance) {
BizClock.instance = instance;
}
}
// better
public boolean isExpiredWithCustomTime() {
return expiryDate.isBefore(BizClock.now());
}
BizClock 클래스의 now는 instance의 timeNow를 통해 현재 시간 값을 리턴한다. 이때 setInstance 를 통해 now가 원하는 시간을 제공하도록 만들 수 있다.
예를 들어 BizClock 클래스를 확장해서 아래와 같이 만들 수 있다.
public class TestBizClock extends BizClock {
private LocalDateTime now;
public TestBizClock() {
setInstance(this);
}
public void setNow(LocalDateTime now) {
this.now = now;
}
@Override
public LocalDateTime timeNow() {
return now != null ? now : BizClock.now();
}
}
그리고 아래와 같이 테스트할 수 있다.
TestBizClock testClock = new TestBizClock();
@AfterEach
void resetClock() {
// TestBizClock이 BizClock을 상속하므로
// BizClock.reset() 과 같다
// testClock.reset();
BizClock.reset();
}
@Test
void 만료된_회원인지_확인시_커스텀시간클래스_이용해서_시간지정() {
// given
testClock.setNow(LocalDateTime.of(1999, 11, 10, 00, 00));
// when
Member member = new Member(LocalDateTime.of(1999, 11, 17, 10, 10));
// then
assertThat(member.isExpiredWithCustomTime()).isFalse();
}
now()를 instance.timeNow()로 얻기 때문에 다른 timeNow를 응답하도록 오버라이딩하고 생성자에서 setInstance로 인스턴스를 바꿔준다!
이렇게 했을 때 장점은 단순히 깨지지 않는다는 것만이 아니라, 경계 조건도 쉽게 테스트할 수 있다는 것이다.
아래와 같이 ‘1 나노초’ 가 지났는지 테스트하는 것도 가능하다.
@Test
void 만료된_회원인지_확인시_인자로_시간지정() {
// given
// when
Member member = new Member(LocalDateTime.of(1999, 11, 17, 10, 10));
// then
assertThat(member.passedExpiryDate(LocalDateTime.of(1999, 11, 17, 10, 10,0,1))).isTrue();
}