Home 테스트 + 테스트 작성 요령
Post
Cancel

테스트 + 테스트 작성 요령

테스트는 말 그대로 구현한 기능에 대해 잘 구현되었는 지 확인하는 것을 의미한다. 즉 의도된 대로 정확히 작동하는 지 검증하는 절차이다.

테스트의 종류

테스트는 테스트 대상 범위나 성격에 따라 크게 3가지로 구분된다.

테스트 피라미드

Unit Test (단위 테스트)

  • 소형 테스트에 속하는 테스트이다.
  • 클래스 범주 내 작은 단위(메서드)의 기능에 대한 유효성을 검증하는 테스트이다.
  • 단위 테스트는 매우 간단하고 명확하며 빠르게 실행되는 특징이 있다.
  • 하나의 함수에 대해 하나 이상의 테스트가 존재할 수 있고, 각각의 조건에 대한 유효성을 검증한다.
  • 이렇게 작성된 테스트가 많을수록 해당 로직에 대한 신뢰도가 높아진다.
  • 작게 쪼개진 단위 테스트를 통해 해당 로직이 어떤 역할을 하는지도 파악할 수 있다.

Integration Test (통합 테스트)

  • 중형 테스트에 속하는 테스트이다.
  • 서로 다른 모듈 혹은 클래스 간 상호작용의 유효성을 검사하는 테스트이다.
  • 통합 테스트가 필요한 이유는 각각의 단위 테스트가 검증되었다 하더라도, 모듈 간 인터페이스 및 데이터 흐름이 의도한 대로 작동하지 않는 경우가 있기 때문이다.
  • 통합 테스트는 각 모듈에 대한 설정 또는 테스트를 위한 사전 조건이 필요한 경우도 있어 단위 테스트에 비해 테스트 코드의 작성이 복잡하다.
  • 단위 테스트보다 더 넓은 범위의 종속성까지 테스트함으로써 단위 테스트보다 조금 더 유의미한 테스트가 되는 경우가 많다.

UI Test

  • 대형 테스트로 분류되는 테스트이다.
  • 실제 사용자들이 사용하는 화면에 대한 테스트를 하여 서비스의 기능이 정상적으로 작동하는 지 검증하는 테스트이다.
  • UI 테스트를 통해 실제 앱을 사용하는 사용자의 흐름에 대해 테스트하여 UI 변경 사항으로 발생할 수 있는 문제를 사전에 차단한다.
  • 화면과 직접 연관되어있어 실행시간도 오래걸리고 디자인이 변경될 때 마다 테스트 코드의 수정이 필요하다.
    • 유지보수 비용이 크다.

구글 공식 문서에서는 각 테스트 비율을 단위 테스트(70%), 통합 (20%), UI 테스트(10%) 정도로 구성하는 것을 추천한다.

테스트를 처음 도입한다면 비교적 작성의 비용이 적은 단위 테스트부터 시작하는 것을 권장한다.

테스트 코드 작성의 장점

  • 개발 과정 중 예상치 못한 문제를 미리 발견할 수 있다.
  • 작성한 코드가 의도한 대로 작동하는지 검증할 수 있다.
  • 코드 변경에 대한 사이드 이펙트를 줄이는 예방책이다.
  • 코드 수정이 필요한 상황에서 유연하고 안정적인 대응을 할 수 있게 해준다.
  • 코드의 모듈화를 한번 더 고민하게 해준다.
  • 코드 변경 시, 변경 부분으로 인한 영향도 쉽게 파악할 수 있게 해준다.
  • 코드 리팩토링 시 기능 구현이 동일하게 되었다는 판단을 내릴 수 있다.
  • 테스트 코드를 통해 동작하는 방식 및 결과 확인이 가능하다.

TDD (Test Driven Development)

실제 어플리케이션 코드 작성 전 테스트 코드를 먼저 작성하는 개발 방식이다.

테스트를 먼저 작성하고 테스트를 통과할만큼의 코드만 작성해 테스트를 하게 된다.

실패하는 테스트를 먼저 작성하고 그에 해당하는 기능을 만들어나가는 방식으로 이후 리팩토링을 통해 효율성을 향상시켜야 한다.

장점

  • 요구사항에 대한 분석 및 이해를 바탕으로 설계자의 관점에서 개발할 수 있다.
  • 모든 요구사항, 목표에 대해 점검할 수 있고 사용자 입장에서 코드를 작성할 수 있다.
  • 테스트 통과율이 높아진다.
  • 설계에 대한 피드백에 빠르다.
  • 오버 엔지니어링을 방지할 수 있다.

테스트 코드 작성 요령

두 개 이상을 검증하려 하지 마라.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Test
void registerAndSendMail() {
	userRegister.register("id", "pw", "email");

	//검증1. 회원 데이터가 올바르게 저장되었는가.
	User savedUser = fakeRepository.findById("id");
	assertEquals("id", savedUser.getId());
	assertEquals("email", savedUser.getEmail());

	//검증2. 이메일 발송을 요청했는 지 검증
	ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
	BDDMockito.then(mockEmailNotifier)
		.should().sendRegisterEmail(captor.caputre());

	String realEmail = captor.getValue();
	assertEquals("eamil@email.com", realEmail);
}

위 테스트는 2개의 검증을 하고 있다. 테스트가 잘못된 것은 아니다. 다만 한 테스트에서 검증하는 내용이 두 개 이상이라면 테스트 결과를 확인할 때 집중도가 떨어진다.

만약 첫 검증이 실패한다면 아래 검증은 성공인지, 실패인지 알 수 없다.

따라서 각 검증을 메서드를 통해 분리하도록 한다.

기댓값을 명확하게 표현해라. (변수나 필드를 사용하지 말자)

1
2
3
4
5
6
7
8
@Test
void dateFormat() {
	LocalDate date = LocalDate.of(1945,8,15);
	String dateStr = formatDate(date);
	assertEquals(date.getYear()+"년 "+
			date.getMonthValue()+"월 "+
			date.getDayOfMonth()+"일", dateStr);
}

위의 코드도 틀린 것은 아니다. 다만 위와 같이 어떤 필드를 사용해 기댓값을 표현하게 되면 실수가 있을 수 있다. get 메서드도 실수로 잘못입력할 수 있으며, 위의 코드에서는 문자열 연결에 있어 단순하게 공백을 실수할 수도 있다.

1
2
3
4
5
@Test
void dateFormat() {
	LocalDate date = LocalDate.of(1945,8,15);
	String dateStr = formatDate(date);
	assertEquals("1945년 8월 15일", dateStr);

위와 같이 간결하게 바꿀 수 있다. 기대하는 값을 명확하게 표현하자. 만약 테스트가 실패한다면 formatDate() 메서드만 체크하면 되는 것이다.

로컬 변수와 필드 사용 문제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
public void saveAnswerSuccessfully() {
	Survey survey = SurveyFactory.createApprovedSurvey(1L);
	surveyRepository.save(survey);

	SurveyAnswerRequest surveyAnswer = SurveyAnswerRequest.builder()
					.surveyId(survey.getId())
					.respondentId(respondentId)
					.answers(answers)
					.build();

	SurveyAnswer savedAnswer = memoryRepository.findBySurveyId(1L);

	assertAll(
		() -> assertEquals(respondentId, savedAnswer.getRespondentId()),
		() -> assertEquals(answers.size(), savedAnswer.getAnswers().size()),
		...
}

이렇게 변수로 인자를 넘겨주는 것은 실패가 발생했을 때 개발자가 값을 이해하는 과정이 추가로 필요하게 된다.

1
2
3
4
assertAll(
	() -> assertEquals(100L, savedAnswer.getRespondentId()),
	() -> assertEquals(4, savedAnswer.getAnswers().size(),
	...

이런식으로 값을 명확하게 직접 입력해 테스트 코드를 작성한다면 테스트 코드와 코드 구현부를 오가며 확인하지 않아도 된다.

정확하게 일치하는 값으로 모의 객체를 설정하지 말자.

1
2
3
4
5
6
7
8
@Test
void weakPassword() {
	BDDMockito.given(mockPasswordChecker.checkPasswordWeak("pw")).willReturn(true);
	
	assertThrows(WeakPasswordException.class,
		() -> userRegister.register("id", "pw", "email");
	});
}

위 코드를 간략히 설명하면 mock 모의 객체가 주어지고 해당 객체는 password가 “pw”일 때만 true를 리턴하도록 지정되었다.

즉 아래 테스트에서 “pw” 대신 “pw1” 로만 지정해도 false가 지정되어 테스트에 실패하게 된다.

1
BDDMockito.given(mockPasswordChecker.checkPasswordWeak(Mockito.anyString()));

대신 임의의 String 값에 일치하도록 하는 것이다.

물론 이러한 부분은 테스트의 의도에 따라 다르겠지만 테스트에 의도를 해치지 않는 범위에서 특정한 값보다는 범용적인 값을 사용해야 한다.

이렇게 해야 약간의 코드 수정에도 테스트가 실패하는 것을 방지할 수 있다.

과도하게 내부 구현을 검증하지 마라.

모의 객체를 처음 사용할 때 이러한 유혹에 빠지기 쉽다.

1
2
3
4
5
6
7
8
9
10
11
12
@Test
void checkPassword() {
	userRegister.register("id", "pw", "email");
	
	BDDMockito.then(mockPasswordChecker)
			.should()
			.checkPasswordWeak(Mockito.anyString());

	BDDMockito.then(mockRepository)
			.should(Mockito.never())
			.findById(Mockito.anyString());
}

위 코드를 설명하면 userRegister.register() 메서드를 호출할 때 내부적으로 checkPasswordWeak와 findById가 호출되는지를 검사하는 테스트이다.

내부 구현을 검증하는 것이 나쁜 것은 아니지만 이렇게 세세하게 내부 구현을 검증하게 되면 내부 구현을 조금만 변경해도 테스트가 실패해 테스트가 깨지게 된다.

내부 구현은 언제든지 바뀔 수 있기 때문에 테스트 코드는 내부 구현보다 실행 결과를 검증하는데 집중하는 것이 좋다.

즉 예를 들면 약한 암호일 때 예상한 결과 값이 올바른지 검증해야 하는 것이다. 이렇게 하면 내부 구현의 일부가 바뀌어도 테스트는 깨지지 않는다.

셋업을 이용해 중복된 상황을 설정하지 않는다.

테스트 코드를 작성하다보면 각 테스트 코드에서 동일한 상황이 주어져야하는 경우가 있다. 이런 경우 @BeforeEach를 이용해 상황을 구성할 수 있다.

그런데 여기에 함정이 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@BeforeEach
void setting() {
	changeService = new ChangeUserService(memoryRepository);
	memoryRepository.save(new User("id", "pw", new Address("서울", "북부")));
}

@Test
void noUser() {
	assertThrows(UserNotFoundException.class,
		() -> changeService.changeAddress("id2", new Address("서울", "남부")));
}

@Test
void changeAddress() {
	changeService.changeAddress("id", new Address("서울", "남부"));

	User user = memoryRepository.findById("id");
	assertEquals("서울", user.getAddress().getCity());
}

각 테스트 메서드에는 @BeforeEach로 세팅한 객체들이 사용되고 있다. 이렇게 중복을 제거하고 코드 길이가 짧아져 코드 품질이 좋아졌다고 생각할 수 있다.

그러나 테스트 코드는 다르다.

예를 들면 테스트 메서드가 실패해 몇 달만에 테스트 코드를 다시 보는 경우가 있다고 가정하자.

테스트를 실패한 이유를 알기 위해 어떤 상황인지 파악해야 하고, 몇 달만에 다시 코드를 확인하면 기억이 잘 나지 않기 때문에 셋업 메서드를 확인해야 한다.

즉, 코드를 위아래로 이동하며 실패한 원인을 분석해야 하는 것이다.

여기에 더해 또 다른 문제는 테스트가 깨지기 쉬운 구조가 된다.

셋업 메서드에서 사용한 “pw”를 “password”로 바꿔버린다면 패스워드를 검증하는 메서드에서 문제가 발생할 수 있는 것이다.

상황의 설정을 각 테스트 메서드에 하나하나 작성하면 처음엔 번거롭고 코드가 길어진다고 생각할 수 있지만, 테스트에 실패해도 실패한 테스트 메서드 위주로 확인하면 되고 추후 수정이 용이해진다.

실행 환경이 다르다고 실패하는 테스트 코드가 되면 안된다.

같은 테스트 메서드가 실행 환경에 따라 성공하거나 실패하게 만들면 안된다.

로컬 개발 환경에서는 성공하는데 빌드 서버에서는 실패한다거나 윈도우에서는 성공하는데 맥 OS에서는 실패하는 등의 경우이다.

전형적인 예가 바로 파일 경로이다.

1
2
3
4
5
6
7
8
private String filePath = "D:\\mywork\temp\file.txt";

@Test
void load() {
	FileLoader loader = new FileLoader();
	loader.load(filePath);
	/...
}

위 파일 경로는 D드라이브를 포함한다. D드라이브가 없는 맥OS에서는 이 테스트가 항상 실패하게 된다.

이러한 파일 경로 문제라면 테스트에서 사용하는 파일을 프로젝트 폴더를 기준으로 상대 경로를 사용해 해결할 수 있다.

1
private String filePath = "src/test/resources/file.txt";

실행 시점이 다르다고 실패하면 안된다.

1
2
3
4
5
6
7
public class Member {
	private LocalDateTime expireDate;

	public boolean isExpired() {
		return expireDate.isBefore(LocalDateTime.now());
	}
}

위 클래스는 회원의 만료 여부를 확인한다. 이 메서드를 검증하는 테스트 코드를 아래와 같이 작성했다고 가정하자.

1
2
3
4
5
6
7
8
@Test
void notExpired() {
	//테스트 코드의 작성 시점은 2019년 1월 1일
	LocalDateTime expire = LocalDateTime.of(2019,12,31,0,0,0);
	Member memeber = Member.builder().expireDate(expire).build();
	
	assertFalse(member.isExpired());
}

테스트 코드의 작성 시점에는 테스트가 당연히 성공한다. 실제로 만료일을 초과하지 않았기 때문이다. 그러나 2019년 12월 31일 이후 테스트를 실행한다면 테스트가 깨지게 되는 것이다.

이런 문제를 해결하는 방법은 테스트 코드에서 시간을 명시적으로 제어할 수 있는 방법을 선택하면 된다. 대표적으로는 값을 파라미터로 넘기는 방식이 있다.

1
2
3
4
5
6
7
public class Member {
	private LocalDateTime expireDate;

	public boolean isExpired(LocalDateTime time) {
		return expireDate.isBefore(time);
	}
}

Member 클래스에서 만료여부를 정할 때 만료일자를 매개변수로 받는 것이다.

1
2
3
4
5
6
7
8
@Test
void notExpired() {
	//테스트 코드의 작성 시점은 2019년 1월 1일
	LocalDateTime expire = LocalDateTime.of(2019,12,31,0,0,0);
	Member memeber = Member.builder().expireDate(expire).build();
	
	assertFalse(member.isExpired(LocalDateTime.of(2019,12,30,0,0,0));
}

위 테스트 코드는 실행 시점에 상관없이 항상 통과한다. 또한 시간을 직접 전달하여 경계 조건도 쉽게 테스트가 가능하다.

필요하지 않은 값은 설정하지 않는다.

1
2
3
4
5
6
7
8
9
@Test
void duplicateIdTest() {
	//동일한 ID가 존재하는 상황을 테스트한다.
	memoryRepository.save(User.builder().id("dupid")
										.name("이름")
										.email("abc@aaa.com")
										.build());
	/...
}

단순히 중복된 ID만 검증하면 되는 것인데 ID가 담겨있는 User의 모든 정보를 설정하고 있다.

ID를 검증하는 테스트 메서드라면 ID만 넘기자.

1
memoryRepository.save(User.builder().id("dupid").build());

이렇게 필요한 값만 설정하면 필요하지 않은 값을 설정하는데 고민할 필요가 없고 테스트 코드의 가독성도 늘어 한눈에 파악이 가능하다.

조건부로 검증하지 않는다.

테스트는 성공하거나 실패해야 한다. 테스트가 성공하거나 실패하려면 반드시 단언을 실행해야 한다.

만약 조건에 따라 단언을 하지 않으면 그 테스트는 성공하지도, 실패하지도 않은 테스트가 된다.

예시를 보자.

1
2
3
4
5
6
7
8
@Test
void canTranslateBasicWord() {
	Translator tr = new Translator();

	if (tr.contains("cat")) {
		assertEquals("고양이", tr.translate("cat"));
	}
}

위 코드는 tr.contains(“cat”)이 true가 아니라면 assertEquals를 실행하지 않는다.

만약 위와 같은 의도로 테스트 코드를 작성하고 싶다면

1
2
assertTrue(tr.contains("cat");
assertEquals("고양이", tr.translate("cat"));

이와 같이 조건에 대한 단언을 추가하면 된다.

This post is licensed under CC BY 4.0 by the author.