개요
테스트의 종류와 필요성에 대해서는 이전 글()에서 설명했다.
우리가 테스트를 작성하면 예상하는 결과에 알맞게 동작하는지 테스트를 자동화할 수 있을 것이다.
본격적인 테스트 자동화에 앞서서, 구체적으로 테스트가 무엇을 어떻게 검증해야하는지 살펴보자.
이 글을 읽기 위해 필요한 지식은 다음과 같습니다.
- ‘의존성’의 의미 ⇒ https://80000coding.oopy.io/68ee8d89-5d05-449d-87e2-5fba84d604ca
- ‘API’의 의미 ⇒ https://namu.wiki/w/API
설명을 위한 코드는 최대한 간단하게 작성하기 위해, 디테일적으로 고려되지 않은 부분이 많을 수 있습니다.
상태 검증과 행위 검증
테스트를 하면서 주되게 검증하는 두 가지는 ‘상태 검증’과 ‘행위 검증’이다. 이 두 개가 무엇인지 살펴보자.
상태 검증은 테스트를 통해 예상하는 상태로 결과가 나타나는지 확인하는 것이다.
예를 들어, 배고픈 사람이 음식을 먹으면 배가 불러지는 아래와 같은 코드를 작성했다고 하자.
class Person {
public boolean isHungry = true;
public void eatFood(Food food) {
food.eaten();
if (this.isHungry) {
this.setAsFull();
}
}
public void setAsFull() {
this.isHungry = false;
}
}
class Food {
public boolean isEaten = false;
public void eaten() {
this.isEaten = true;
}
}
Java
복사
그렇다면 상태 검증을 하는 테스트는 다음과 같이 작성해볼 수 있다.
// 배고플 때 사람이 음식을 먹으면 배부름 상태로 변하는지 확인한다.
// 이 테스트의 주된 관심은 '음식을 먹는다' 라는 행위가 시나리오에 따라 예상하는 상태가 되는지 확인하는 것이다.
// 전제 조건 - 시나리오(given)
// 1. 배고픈 사람이 있다.
Person person = new Person();
// 2. 음식이 있다.
Food food = new Food();
// 호출 - 행위(when)
// 3. 사람이 음식을 먹는다.
person.eatFood(food);
// 예상 결과 - 상태 검증(then)
// 4. 사람의 상태는 '배부름'이어야 한다.
테스트_라이브러리.참이니(person.isHungry == false);
Java
복사
한편, 행위 검증은 테스트를 통해 예상하는 행위(호출)가 이뤄지는지 확인하는 것이다.
예를 들어, 코드가 아래와 같이 작성되어 있다고 하자.
// 배고플 때 사람이 음식을 먹으면 배부름 상태로 변하는지 확인한다.
// 이 테스트의 주된 관심은 '음식을 먹는다'라는 행위가 시나리오에 따라 예상하는대로 메서드를 호출하는지이다.
// 전제 조건 - 시나리오(given)
// 1. 배고픈 사람이 있다.
Person person = new Person();
// 2. 음식이 있다.
Food food = new Food();
// 호출 - 행위(when)
// 3. 사람이 음식을 먹는다.
person.eatFood(food);
// 예상 결과 - 행위 검증(then)
// 4. 사람은 음식의 '먹음 처리'를 한 번 호출한다.
테스트_라이브러리.이거_호출했니(food.eaten());
// 5. 사람은 자신의 상태를 변경하는 행위를 한 번 한다.
테스트_라이브러리.이거_호출했니(person.setAsFull());
Java
복사
잘 살펴보면, 상태 검증은 결과에 대해서 관심이 있는 데에 반해, 행위 검증은 해당 객체의 로직(코드 흐름)에 관심이 더 많은 것을 알 수 있다. → 코드의 세세한 부분까지 더 알고 있다.
우리가 테스트를 작성하는 대부분의 이유는 특정 상황에서 어떠한 호출을 했을 때, 해당 호출이 어떠한 결과를 가져오는지 확인하고 싶어서일 것이다. ← 상태 검증
그렇다면 아래와 같은 의문점들이 떠오를 수 있다.
•
이렇게 작성하면 내부 코드 바뀌면 다 바꿔야 하는 거 아니야?
•
상태 검증만 하면 되는 것 아니야? 행위 검증으로 호출만 확인해서 좋을 게 뭐가 있는거야?
언제 상태를 검증하고 언제 행위를 검증하는가
실제로 틀린 말이 아니다. 행위 검증은 호출을 확인한다는 특성으로 인해 코드가 바뀌면 같이 바뀌어야할 것이고, 대부분은 상태 검증으로도 테스트가 충분하기 때문이다.
그럼에도 행위 검증을 한다면 우리가 원하는 대부분의 이유가 아닌, 특정한 이유일 때임을 생각해볼 수 있다.
언제 상태 검증을 하고, 언제 행위 검증을 하는지 알아보자.
상태 검증을 하는 때 - 대부분
상황(시나리오)이 주어진 상태에서, 특정 동작을 수행하고 예상하는 결과에 대해서 검증한다.
이전 글에서 설명했던 E2E 테스트, 통합 테스트, 유닛 테스트 전부 다 상태 검증 방식으로 테스트를 작성할 수 있다.
유닛 테스트 - 통합 테스트 - E2E 테스트 순으로 알아보자.
유닛 테스트(어플리케이션 환경)
유닛 테스트의 목적은 해당 객체가 의존하는 다른 객체들과 상관 없이 스스로가 해야할 일을 잘 했는지를 검증하는 것이다.
예를 들어, 시간을 계산하는 유틸 클래스 DateUtil이 있다고 하자. 이 클래스는 시간 계산만 잘하면 된다.
LocalDateTime이라는 신뢰할 수 있는 표준 라이브러리와 동작을 같이한다는 가정으로 객체를 작성했다고 가정한다면, 다음과 같이 DateUtil.날짜_더하기 메서드에 대한 유닛 테스트를 작성할 수 있다.
DateUtil dateUtil = new DateUtil();
void 날짜_더하기_성공) {
// given
LocalDateTime 현재 = LocalDateTime.now();
//when
LocalDateTime 이틀_뒤 = dateUtil.날짜_더하기_일수(현재, 2);
//then
테스트_라이브러리.일치하니(이틀_뒤, LocalDateTime.plusDays(now, 2));
}
Java
복사
통합 테스트 (어플리케이션 환경)
유닛 테스트의 목표는 객체를 하나의 단위로서 잘 동작하는지에 대한 테스트였다면, 통합 테스트의 주된 목표는 여러 객체(컴포넌트들)와 연관관계를 가지고 있는 큰 덩어리가 컴포넌트들과 함께 잘 동작하는지 확인하는 것이다.
TMI : 그래서 단위랑 통합 테스트가 뭐가 다른건데..?
예를 들어, 데이터베이스에 대여 기록을 저장하는 저장소 LentRepository와 필요한_이것_저것들 을 주입받은 사물함 대여 객체인 LentService가 LentRepository와 필요한_이것_저것들 과 같은 컴포넌트 들과 잘 협력해서 우리가 예상한 대로 작동하는지를 확인하는 것이다.
LentRepository가 대여에 대한 올바른 요청을 받았다면, (여기서는 보이지 않는 여러가지 중간 과정들을 거쳐) 데이터베이스에 대여 확정에 대한 데이터가 저장되어야 한다.
즉, LentService의 대여 메서드는 LentRepository라는 컴포넌트와 함께 잘 동작해야하는 것이고, 이를 확인하는 것이다.
LentRepository lentRepository = new LentRepository();
LentService lentService = new LentService(lentRepository, 필요한_이것_저것들);
void 통합_테스트_대여_성공() {
// given
유저 user = 대여_가능한_유저_생성();
사물함 cabinet = 대여_가능한_사물함_생성();
// when
대여_결과 result = lentService.대여해주기(user, cabinet);
// then
테스트_라이브러리.참이니(lentRepository.유저가_사물함에_대한_대여_결과가_있다(유저, 사물함));
}
Java
복사
E2E 테스트 (실제 사용하는 환경을 가정)
E2E 테스트는 시나리오를 작성하고 실제 데이터를 생성, 저장한다. 특히 서비스가 배포되어 작동하는 것과 유사한 환경을 구성하고, 실제 사용자가 서버에 요청을 날리는 것과 동일한 방법으로 테스트하여 잘 동작하는지를 확인한다.
해당 테스트에서는 대여 가능한 사용자가 대여 가능한 사물함 대여를 요청(HTTP)하면 사물함 대여가 되고, 올바른 응답(HTTP)을 반환해야 한다.
통합 테스트와 다른 점은 내부에서 작성한 객체를 이용해서 검증하는 것이 아닌, HTTP 요청을 위한 데이터를 집어넣고, 그 응답 결과를 확인한다는 점에서 다르다.
void E2E_테스트_대여_성공() {
// given
유저 user = 대여_가능한_유저_생성_및_저장();
사물함 cabinet = 대여_가능한_사물함_생성_및_저장();
// when
결과 result = 테스트_라이브러리.서버에_요청보내기(HTTP_대여_요청(유저, 사물함));
// then
테스트_라이브러리.HTTP_응답이_성공했니(result);
테스트_라이브러리.HTTP_응답이_다음과_일치하니(result, 예상하는_결과); // 성공 | 실패 여부와 응답
테스트_라이브러리.일치하니(실제_DB에_대여_기록이_생성되었는지(유저, 사물함)); // 누가 몇번 사물함을 대여했고..
}
Java
복사
행위 검증을 하는 때
여기까지 보면, 상태 검증으로 충분해보인다.
하지만 어플리케이션에서 외부 API를 사용한다면 어떨까?
위에서 말한 “행위 검증을 한다면 우리가 원하는 대부분의 이유가 아닌, 특정한 이유일 때임을 생각해볼 수 있다”의 특정한 이유는 바로 외부 API, 외부 의존성을 갖는 경우이다.
외부 API, 의존성?
외부 API는 어플리케이션 프로그래밍 인터페이스(API)의 일종으로, 서로 다른 소프트웨어 시스템이나 애플리케이션이 상호작용할 수 있게 해줍니다.
예를 들어, 소셜 미디어 로그인, 지도 서비스, 결제 시스템 등을 자신의 애플리케이션에 쉽게 추가할 수 있습니다.
- ChatGPT 4.0
우리가 작성하는 테스트는 엄밀히 얘기하면 ‘테스트 자동화’이다. 한 번 작성하고 해당하는 케이스를 자동적으로 확인하도록 하는 것이기 때문이다. 이 상태에서 아래와 같은 예시가 있다고 하자.
•
외부 API를 이용해 이미지를 업로드하고, 해당하는 URL을 반환받는다.
우리의 테스트는 수십, 수백번이고 반복될 수 있다. 이 때마다 실제로 이미지를 업로드한다면…
더해서 외부 API를 실제로 사용하게되면 정작 우리의 테스트가 잘 검증함에도 불구하고 외부 API의 상태에 의존하고 있기 때문에 통제할 수 없는 변수가 되어버린다. 예를 들어, 이미지를 업로드하는 서버가 정상 동작하는 동안에는 테스트가 잘 통과하다가, 해당 업로드 서버가 다운된다면 우리 테스트는 실패해버릴 것이다.
테스트는 멱등성(반복되어도 같은 결과를 반환함)이 보장되어야하기 때문에, 우리는 이 통제할 수 없는 변수인 외부 API를 통제해야한다.
이를 정리하자면 아래와 같다.
•
테스트의 멱등성은 보장되어야 한다.
→ 그러므로 통제할 수 없는 변수는 제거하거나 최소화해야 한다.
통제할 수 없는 변수를 어떻게 제거하거나 최소화 할 수 있을까..?
이 때, 혜성같이 등장하는 것이 테스트 대역(Test Double) 중 Mock이다.
TMI : 테스트 대역..?
Mock..?
모의 객체(Mock Object)란 주로 객체 지향 프로그래밍으로 개발한 프로그램을 테스트 할 경우 테스트를 수행할 모듈과 연결되는 외부의 다른 서비스나 모듈들을 실제 사용하는 모듈을 사용하지 않고 실제의 모듈을 "흉내"내는 "가짜" 모듈을 작성하여 테스트의 효용성을 높이는데 사용하는 객체이다. 사용자 인터페이스(UI)나 데이터베이스 테스트 등과 같이 자동화된 테스트를 수행하기 어려운 때 널리 사용된다.
- 위키백과
즉, 위의 예시에서 이미지 업로드를 하는 API(객체)를 Mocking하여, 원하는 결과가 항상 반환되도록 동작한다는 가정하에 테스트를 작성하는 것이다.
ImageUploadExternalAPI라는 외부 API 객체와, 이를 사용하는 우리가 직접 작성한 이미지 업로드 처리 객체인 ImageService가 있다고 해보자. mock을 이용한 테스트를 작성해본다면 아래와 같은 구조로 작성해볼 수 있다.
ImageUploadExternalAPI mockUploader = 모킹_라이브러리.mock(ImageUploadExternalAPI);
ImageService imageService = new ImageService(mockUploader);
void 이미지_업로드_하기() {
//given
ImageFile 유저_프로필_사진 = new ImageFile(대충_무슨_파일);
// mock한 객체에 대해 우리가 예상하는 "대충_업로드_된_이미지_url.jpg"를 반환하도록 지정한다.
다음과_같이_한다면(mockUploader.이미지_업로드(유저_프로필_사진)).다음을_반환한다("대충_업로드_된_이미지_url.jpg");
//when
ImageUrl 결과 = imageService.파일_업로드하기(유저_프로필_사진);
//then
테스트_라이브러리.객체가_호출됐니(mockUploader.uploadData(유저_프로필_사진).한번();
테스트_라이브러리.일치하니(결과, "대충_업로드_된_이미지_url.jpg");
}
Java
복사
즉, 기존에 ImageUploadExternalAPI 객체의 uploadData에 제대로 주어지는 매개변수인 유저_프로필_사진을 전달했고, 해당 결과를 잘 받아서 처리했는지가 이 테스트의 목적인데, 이 부분에서 업로드에 대한 행위가 잘 이뤄졌는지를 외부 API에 의존하지 않고 검증할 수 있게 된다.
위와 같은 방식을 통해서 우리는 매번 테스트를 돌려도, 실제로 이미지가 업로드 되는 일이 발생하지 않음과 동시에 통제할 수 없는 변수를 통제할 수 있게되고, 외부 API와 상관없이 우리가 작성한 로직이 잘 동작하는지 확인할 수 있게 된다.
정리
상태 검증과 행위 검증(mock)에 대해 알아보았다.
기본적으로 우리가 작성하는 테스트는 모두 테스트 자동화를 위한 것이고, 이 과정에서 우리는 대부분 상태 검증으로 테스트를 작성하게 된다.
한편 의존성 제거가 필요해지는 부분들(외부 API)에 대해서 mock등을 이용해 의존성을 제거할 필요가 생길 수 있다.
개인적으로는 다른 객체의 주입이나 의존성이 없는(모킹된) 상태에서 테스트를 작성한다면 단위 테스트, 그렇지 않고 다른 객체와 의존하는 경우에서 테스트를 작성한다면 통합 테스트라고 생각한다.
테스트 라이브러리를 잘 이용해서 mock이 필요한 경우와 그렇지 않은 경우를 잘 구분해서 작성한다면 잘 깨지지 않으면서도 필요한 것들을 잘 검증하는 자동화된 테스트를 작성할 수 있다!
이제 실제로 상태 검증과 행위 검증 테스트 코드를 직접 작성해보자!
다음 글 :