-
# 전반적인 책의 내용
-
1) 가치 있는 테스트만을 진행해야 한다.
-
2) 테스트는 "동작 단위"의 코드 조각을 검증해야 한다.
-
3) 구현 세부사항보단 구현 결과를 검증, 즉 출력을 확인하는 테스트를 해야 한다.
-
4) 목은 시스템 외부, 어플리케이션 경계를 넘나드는 "식별할 수 있는 동작"을 확인하기 위해서만 사용되어야 한다.
-
5) 코드가 중요하거나 복잡할수록 협력자가 적어야 한다.
-
6) 이해하기 쉬운 테스트 코드를 작성해야한다.
-
1. 좋은 예 : 테스트 클래스 내 팩토리 메소드 사용
-
2. 애매한 예 : DB 테스트에 Stub 사용
-
3. 좋은 예 : 구현 사항이 아닌 동작 결과를 검증
-
4. 좋은 예 : 식별할 수 있는 외부 모듈 호출에 목 사용
-
5. 안좋은 예 : 내부 클래스간 메세지 전달에 목 사용
-
6. 나쁘지 않은 예 : 필요한 비즈니스 로직을 검증
"단위 테스트"라는 책을 읽었습니다.
단위 테스트 - 예스24
소프트웨어 개발에 있어 단위 테스트는 이제 선택이 아니라 필수가 됐다. 단위 테스트에 대한 오해를 바로잡고, 올바른 단위 테스트에 대한 원칙, 테스트를 작성하는 스타일과 효과적인 테스트
www.yes24.com
의미 있는 테스트란 무엇인지, 그것을 위해 어떻게 코드를 작성해야하며 그를 위한 방법은 무엇인지 전반적인 방법론과 철학을 설명한 책입니다. 현재 "레시피토리" 프로젝트는 어플리케이션 코드에 사용되는 로직에 단위 테스트 코드를 작성했는데, 책의 내용과 대조해보면서 그동안 작성했던 테스트 코드에 문제가 있는지, 혹은 잘 작성되었는지 확인해보도록 합시다.
# 전반적인 책의 내용
책에 있는 모든 내용은 아니더라도, 테스트 코드를 살펴보기 위한 기준점이 될만한 중요한 내용을 몇가지 정리하겠습니다.
1) 가치 있는 테스트만을 진행해야 한다.
테스트 코드에 대한 작성, 그리고 테스트를 실행하는데 드는 컴퓨팅 리소스도 모두 자원이기 때문에 의미있고 효율적인 테스트만을 수행해야합니다. 의미 있고 효율적인 테스트란, 회귀 방지(동작의 오류를 잘 잡아냄)와 리팩토링 내성(테스트가 구현 사항에 강결합되지 않아 세부 구현이 바뀌었다고 해서 동작 결과에 영향을 미치지 않음), 빠른 피드백(테스트가 빨리 수행되는 것)을 만족하는 코드입니다.
또한 테스트를 진행하면서 특정 커버리지 지표를 목표로 하는 것은 좋지 않습니다. 의미없는 테스트만 많아질 뿐이고, 커버리지가 높으면 테스트 코드가 구현 코드에 강결합되어 리팩토링 내성을 낮추는 원인이 될 수도 있습니다.
2) 테스트는 "동작 단위"의 코드 조각을 검증해야 한다.
테스트할 대상 클래스를 정해두고 대상 클래스의 메소드의 동작 하나하나를 검증하는게 아니라, 특정 기능을 기준으로 클래스가 수행하는 동작을 검증해야합니다. 예를 들어, (1+1)+3을 테스트 할 때, 1+1, 2+3이 잘 수행되는지 테스트하는 것이 아니라 저 연산의 결과가 5인것을 검증해야 한다는 뜻입니다.
3) 구현 세부사항보단 구현 결과를 검증, 즉 출력을 확인하는 테스트를 해야 한다.
2번과 이어지는 내용입니다. 추상화된 것을 테스트하는 것으로, 세부 동작을 검증해야하는 특별한 때가 아니면 일반적으로 블랙박스 테스트가 선호됩니다. 테스트코드 복잡도를 낮추기 위해 테스트할 것만을 공개하고 나머지는 캡슐화를 시키는 것이 좋습니다. 객체지향 원칙이 테스트 코드에도 적용된 것이라고 볼 수 있습니다. 무엇을 "어떻게"하는지가 아닌 "무엇을"하는지를 검증하므로 세부 구현사항에 테스트는 약하게 결합하고, 이는 거짓 양성 정도를 줄여 의미있는 테스트가 만들어지도록 합니다.
4) 목은 시스템 외부, 어플리케이션 경계를 넘나드는 "식별할 수 있는 동작"을 확인하기 위해서만 사용되어야 한다.
시스템 내부에서 단위 동작을 수행하기 위해 메소드를 호출하는 것에 목을 사용할 이유가 없습니다. 의미 없는 회귀 테스트가 되며, 이는 어쩌면 테스트할 가치가 없는 기능일지도 모릅니다. 하지만 외부 시스템이나 API를 이용하는 코드의 경우, 어플리케이션 경계를 넘어가는 데이터가 존재하고 이는 사이드 이펙트를 발생시킴으로써 식별할 수 있는 동작이기 때문에 목을 사용해도 됩니다.
그러나 모든 외부의존성이 목으로 대체되어야하는 것은 아닙니다. DB와 같은 의존성은 어플리케이션 내부에서만 접근할 수 있는 내부 의존성입니다. 이는 어플리케이션이 완전히 제어권을 가진 모듈이고, 클라이언트의 기준에서 식별할 수 없는 동작으로 분류됩니다.
5) 코드가 중요하거나 복잡할수록 협력자가 적어야 한다.
컨트롤러 코드의 경우, 여러가지 협력 클래스(의존성)를 구성으로 두고 클래스 메소드를 수행하는 역할을 합니다. 협력자가 많지만 복잡도가 낮은 이러한 컨트롤러 코드는 통합 테스트를 통해 간단히 테스트해야합니다. 반대로 복잡한 비즈니스 로직이나 중요한 도메인 로직을 수행하는 클래스의 경우, 최소한의 협력자만을 두고 해당 클래스가 수행하는 단위 동작에 대한 검증을 진행해야 합니다.
때문에 가치 있는 단위 테스트를 위한 코드 작성은,
- 협력자가 많고 로직이 단순한 컨트롤러 코드
- 협력자가 적고 로직이 복잡한 도메인 모델 및 알고리즘 클래스
두 가지 종류의 클래스를 작성하고, 전자는 통합 테스트를, 후자는 단위 테스트를 진행하는 것입니다.
6) 이해하기 쉬운 테스트 코드를 작성해야한다.
테스트도 결국 유지보수의 대상입니다. 다른 개발자들에게 읽혀야하는 코드이므로 가독성에 집중하며 읽기 쉽고 간단하게 작성해야 합니다.
이제 프로젝트에서 작성했던 몇 가지 단위 테스트코드를 살펴보면서 위의 기준을 잘 만족했는지, 만족하거나 그렇지 않았다면 왜 그런건지 훑어보도록 합시다.
1. 좋은 예 : 테스트 클래스 내 팩토리 메소드 사용
유저가 프로젝트의 메인 컨텐츠인 레시피에 "북마크"를 하는 상황입니다. 자기 자신의 레시피는 북마크 할 수 없다는 비즈니스 로직을 단위 테스트 하려고 합니다.
테스트 코드 내부에 @BeforeEach 메소드를 선언해서 user1, user2가 각각 recipe1, recipe2를 작성하는 상황을 가정할 수 있도록 했습니다.
public class BookMarkServiceTest {
private BookMarkService bookMarkService;
private UserService userService;
private RecipeService recipeService;
User user1, user2;
Recipe recipe1, recipe2;
@BeforeEach
public void setUp() {
BookMarkRepository testBookMarkRepository = new TestBookMarkRepository();
bookMarkService = new BookMarkService(testBookMarkRepository,userService,recipeService);
// user1이 recipe1
// user2가 recipe2 작성
user1 = User.builder().id(1L).email("user1@test.com")
.name("user1").role(Role.USER).build();
user2 = User.builder().email("user2@test.com")
.id(2L).name("user2").role(Role.USER).build();
recipe1 = Recipe.builder().author(user1)
.id(1L).title("recipe1").recipeStatistics(new RecipeStatistics()).build();
recipe2 = Recipe.builder().author(user2)
.id(2L).title("recipe2").recipeStatistics(new RecipeStatistics()).build();
}
}
테스트 코드는 읽기 간단해야하고, 로직과 크게 연관되지 않은 이러한 준비 코드는 비공개 팩토리 메소드로 따로 뽑아내 재사용하는 것이 좋습니다. 테스트 유저와 테스트 유저가 작성한 각 레시피를 팩토리 메소드로 구성함으로써 테스트 메소드의 준비 구절을 줄일 수 있습니다.
2. 애매한 예 : DB 테스트에 Stub 사용
책에서는 DB 테스트에 대해 다음과 같은 지침을 안내합니다.
- 하나의 단위 동작을 수행하기 위한 DB 트랜잭션을 하나로 유지하도록 하고, 하나의 트랜잭션을 재사용하지 않도록 한다.
- DB는 애플리케이션 내부에서만 접근 가능한 의존성으로, 바깥에서 식별할 수 없는 동작이기 때문에 동작 검증을 위해 목을 사용하는 것은 의미가 별로 없다.
- 테스트 전용 인메모리 DB를 사용하지 말라. 실제 DB 환경과 다르게 동작할 수 있다.
- 개발자마다 로컬에서 실행 가능한 DB 인스턴스를 별도로 두어 서로의 테스트 결과가 영향을 미치지 않도록 하라.
요컨데 단위 작업을 하나의 트랜잭션으로 묶고, 실환경과 최대한 비슷한 테스트 전용 DB 인스턴스를 사용할 수 있도록 하라는 것입니다.
1번의 setUp() 예시를 다시 보면, 저는 Repository에 대해 스텁을 사용했습니다(TestBookMarkRepository). 엔티티 저장의 역할을 하는 save()와 필드 값으로 엔티티를 찾는 findByBookMarker()를 구현한 스텁 코드의 일부분을 가져와봤습니다. ID로 사용할 AtomicLong 객체와 엔티티 데이터를 저장할 bookMarks를 클래스 데이터로 두고, domain 패키지에 있는 BookMarkRepository 인터페이스를 구현하는 리포지토리 구현체입니다.
public class TestBookMarkRepository implements BookMarkRepository {
private final List<BookMark> bookMarks = new ArrayList<>();
private final AtomicLong atomicLong = new AtomicLong(1L);
@Override
public BookMark save(BookMark bookMark) {
BookMark saved = BookMark.builder().id(atomicLong.getAndIncrement())
.bookMarker(bookMark.getBookMarker()).recipe(bookMark.getRecipe())
.build();
bookMarks.add(saved);
return saved;
}
@Override
public List<BookMark> findByBookMarker(User bookMarker) {
return bookMarks.stream().filter(bookMark ->
bookMark.getBookMarker().equals(bookMarker))
.toList();
}
}
엔티티를 저장할 collection 필드를 스트림으로 돌며 findXXX 메소드를 구현하는 형태입니다. 진짜로 DB 로직만을 수행하는 스텁으로 동작하도록 구성했습니다. 테스트 전용으로 DB 인스턴스를 따로 띄우지 않고, 테스트 환경에서 이 클래스를 사용하는 것으로 빠른 단위 테스트를 하려고 한 것입니다.
"빠른 피드백"의 측면에서 위 코드는 훌륭하다고 할 수 있겠습니다. DB 인스턴스와의 연결 과정이 필요 없고, DB엔진을 띄우기 위한 구동 작업이 필요 없이 어플리케이션 내에서 간단하게 클래스 하나만 생성하면 되기 때문입니다. 하지만 실제 구현체로써 사용하고 있는 MySQL의 동작과 위 stub repository의 동작은 상당히 다릅니다.
- 어플리케이션 전반적으로 직접 JDBC를 이용한 data source를 관리하는 것이 아닌 JPA로 추상화된 DB 접근을 이용한다는 점
- 해당 테스트는 DB의 동작이 아닌 서비스 코드의 로직을 테스트한다는 점
- 세부 구현사항은 다소 다르더라도 repository method가 하는 일 자체는 동일하다는 점
때문에 테스트 전용 Repository 구현체를 만들어 사용했습니다만, unit testing에서 말하는 이상적인 데이터베이스 테스트에는 완벽히 미치지 못했기 때문에 "애매한 예"로 두었습니다.
3. 좋은 예 : 구현 사항이 아닌 동작 결과를 검증
"레시피"와 "레시피 재료"는 다대다 관계를 맺고 있습니다. 레시피도 여러개, 레시피 재료도 여러개인 상황에 서로가 서로에 포함되는 것입니다. 이렇게 만든 이유는 레시피 재료로 레시피를 찾는 기능을 만들고 싶어서입니다. 예를들어 "오징어"가 들어간 레시피를 찾는다, 처럼 말이죠.
레시피 생성 과정에서 같은 이름을 가진 재료가 DB에 존재하지 않으면 레시피 엔티티를 추가하고, 그렇지 않으면 연관 관계만 추가하는 로직을 수행하는 동작을 검증하려고 합니다. 아래 테스트 코드가 그것입니다.
@Test
@DisplayName("존재하지 않는 재료는 새로 생성된다.")
void notExistIngredient() {
// given : "신재료" 이름을 가진 new ingredient는 존재하지 않는다.
String newIngredientName = "신재료";
assertTrue(ingredientRepository.findByName(newIngredientName).isEmpty());
// when : newIngredientName을 saveOrFind() 하면,
ingredientService.saveOrFind(newIngredientName);
// then : 새로운 객체가 생성된다.
assertTrue(ingredientRepository.findByName(newIngredientName).isPresent());
}
saveOrFind()의 내부 동작을 몰라도, 기존에 존재하지 않던 "신재료"이름을 가진 엔티티가 isEmpty() -> isPresent()로 바뀌었습니다. 뭐 내부적으로는 재료를 저장한 DB에서 이름으로 검색하고, 존재하지 않는다면 save()를 진행하는 식으로 구현이 되어있겠죠. 하지만 이걸 몰라도, 우리가 원하는 동작만 수행한다면 위 코드는 리팩토링 내성과 회귀 방지의 측면에서 좋은 효과를 가진 테스트라고 할 수 있겠습니다.
만약 위 테스트코드가 saveOrFind()호출을 위해 재료 repository의 특정 메소드를 수행하는지 검증하고, 그때 전달되는 인자가 "신재료"임을 확인하는 등 세부 구현사항을 모조리 확인한다면 어떨까요? saveOrFind()에 조금의 로직 변화만 있어도 위 테스트는 깨지게 될 것입니다. 이것은 리팩토링 내성을 가지지 않는 테스트이며, 확실히 좋지 않은 테스트 코드라고 말할 수 있겠습니다.
4. 좋은 예 : 식별할 수 있는 외부 모듈 호출에 목 사용
현재 프로젝트는 kafka를 메세지 큐로 사용하는 구조를 띄고 있습니다.

유저의 특정 post 요청이 일어났을 때, WAS -> kafka -> WAS로 메세지가 전달되는지 확인하는 단위 테스트를 진행하고자 합니다.
해당 요청은 식별 가능한 사이드 이펙트를 발생시킵니다. WAS가 카프카에 메세지를 발행하고, 이는 카프카 로그에 쌓이고, 이를 또다시 구독하는 WAS가 소비하기 때문입니다.
카프카 메세지 발행을 확인하기 위한 테스트 코드는 아래과 같습니다. 메세지 발행 자체가 비동기로 이뤄지기 때문에 awaitility 모듈을 사용하여 verify를 진행했습니다. 외부 카프카 모듈을 사용하는 notificationMessageSender가 목 처리 되어있습니다.
@MockBean
private NotificationMessageSender notificationMessageSender;
// ...
// then : sender가 author인 알림이 발송된다.
ArgumentCaptor<NotificationMessage> argumentCaptor =
ArgumentCaptor.forClass(NotificationMessage.class);
await().atMost(3, TimeUnit.SECONDS).untilAsserted(() -> {
// Awaitility를 이용한 비동기 테스트
verify(notificationMessageSender)
.sendNotificationMessage(argumentCaptor.capture());
});
NotificationMessage sent = argumentCaptor.getValue();
assertEquals(NotificationType.NEW_RECIPE, sent.getNotificationType());
assertEquals(author.getId(), sent.getSenderId());
카프카 모듈을 사용하기 위한 파라미터로써 전달되는 메세지가 잘 전달 되는지 mock을 통해 확인하는 부분을 봅시다. ArgumentCaptor를 통해 @MockBean인 KafkaMessageSender의 동작을 확인하는 것을 알 수 있습니다.
이제 메세지 소비를 살펴봅시다. 실제로 카프카의 메세지를 주고받는게 아니라 이렇게 발행/소비를 나눠 단위 테스트를 하는 것입니다. 아래 코드는 MQ에 발행된 알림 메세지를 소비하는 listener 로직을 테스트하는 코드입니다.
public class NotificationListenerTest {
private KafkaNotificationListener notificationListener;
NotificationRepository notificationRepository = new TestNotificationRepository();
UserRepository userRepository = new TestUserRepository();
FollowRepository followRepository = new TestFollowRepository();
@BeforeEach
public void setUp() {
notificationListener = new KafkaNotificationListener(
notificationRepository, userRepository, followRepository);
}
@Test
@DisplayName("도착한 알림 메세지에 맞게 알림을 저장한다.")
public void notificationListenerTest() {
// given : sender, receiver 유저가 설정된 NotificationMessage가 발행된다.
User sender = userRepository.save(
User.builder().name("sender").email("sender@email.com").role(Role.USER).build());
User receiver = userRepository.save(
User.builder().name("receiver").email("receiver@email.com").role(Role.USER).build());
NotificationMessage message = NotificationMessage.builder()
.senderId(sender.getId()).receiverId(receiver.getId())
.notificationType(NotificationType.FOLLOW).build();
// when : 알림을 저장한다.
notificationListener.saveNotification(message);
// then : 메세지로 전송된 것과 같은 알림 entity가 조회된다.
List<Notification> found = notificationRepository.findByReceiver(receiver);
assertAll(() -> assertEquals(1,found.size()),
() -> assertEquals(sender.getId(), found.get(0).getSender().getId()),
() -> assertEquals(message.getNotificationType(), found.get(0).getNotificationType()));
}
}
repository 자체가 stub이기 때문에 따지고보면 모킹은 아니지만(모킹은 out을, stub은 in을 흉내) 어쨌든 실제 외부 시스템을 호출하는 대신 해당 로직을 검증했으므로 단위 테스트에서 말하는 "식별할 수 있는 동작"을 테스트하기 위해 mock을 적절히 사용했다고 말할 수는 있겠습니다.
5. 안좋은 예 : 내부 클래스간 메세지 전달에 목 사용
유저가 같은 유저에게 팔로우 요청을 여러번 보내도 한 번만 반영된다는 멱등성을 테스트하려 했습니다. userService를 목 처리 했기 때문에, 아래와 같이 테스트를 작성했습니다.
@Test
@DisplayName("같은 유저를 여러번 팔로우해도 결과는 한 번만 반영된다.")
public void idempotentFollowTest() {
when(userService.getUserByEmail(userEmail1)).thenReturn(user1);
when(userService.getUserById(user2.getId())).thenReturn(user2);
Follow saved1 = followService.follow(user1.getEmail(), user2.getId());
Follow saved2 = followService.follow(user1.getEmail(), user2.getId());
assertEquals(saved1,saved2);
}
userService에는 이메일과 아이디로 유저를 찾는 기능인 getUserByEmail()과 getUserById() 메소드가 존재합니다. 간단한 이 기능을 when()을 통해 모킹한 것을 확인할 수 있습니다. 말 그대로 유저1의 이메일로 조회하면 유저1이, 유저2의 아이디로 조회하면 유저2가 반환되는 동작을 선언했습니다.
followService의 follow()는 유저의 팔로우 요청이 일어날 때 실행되는 메소드입니다. 같은 팔로우를 2번 날려도 그 결과는 같다는 것이죠.
그런데 userSerivce는 프로그램 내부에 있는 어플리케이션 코드로서, DB와 연결되는 repository가 아니면 딱히 시스템 외부 시스템에 의존이 없는 단순한 서비스 계층의 코드입니다. 위 코드는 정말로 간단한 모킹처리고 내용이 한 번에 이해되기 때문에 문제가 크게 다가오지 않는다고 해도 이는 엄연한 내부 시스템간 통신에 불필요한 목 처리를 했다고 볼 수 있습니다.
6. 나쁘지 않은 예 : 필요한 비즈니스 로직을 검증
특별한 테스트 내용은 아니기도 하고 불필요한 모킹이 존재하기 때문에 완벽히 좋은 테스트라고 할 순 없지만, 정말 필요한 정책 테스트를 한 부분이 있습니다.
@Test
@DisplayName("레시피의 리뷰가 모두 삭제되면(개수가 0개면) 평점은 0점이다.")
public void zeroRatingsTest() {
// given : 리뷰를 작성한다.
CreateReviewDto request = getCreateRequest(); // 팩토리 메소드
Review created = reviewService.createReview(reviewAuthorEmail,request);
// when : 리뷰를 삭제한다.
reviewService.deleteReview(reviewAuthorEmail, created.getId());
// then : 레시피의 리뷰 갯수는 0개이고, 평점은 0점이다.
assertEquals(0, recipe.getRecipeStatistics().getReviewCount());
assertEquals(0, recipe.getRecipeStatistics().getRatings());
}
엔티티의 초기 값, 엣지 케이스에 대한 데이터 정책 등은 이렇게 단위 테스트를 통해 검증하는 것이 좋습니다. 커버할 수 있는 범위의 정책과 로직은 단위 동작을 통해 테스트 할 수 있도록 합시다.
"단위 테스트"라는 책을 읽었습니다.
단위 테스트 - 예스24
소프트웨어 개발에 있어 단위 테스트는 이제 선택이 아니라 필수가 됐다. 단위 테스트에 대한 오해를 바로잡고, 올바른 단위 테스트에 대한 원칙, 테스트를 작성하는 스타일과 효과적인 테스트
www.yes24.com
의미 있는 테스트란 무엇인지, 그것을 위해 어떻게 코드를 작성해야하며 그를 위한 방법은 무엇인지 전반적인 방법론과 철학을 설명한 책입니다. 현재 "레시피토리" 프로젝트는 어플리케이션 코드에 사용되는 로직에 단위 테스트 코드를 작성했는데, 책의 내용과 대조해보면서 그동안 작성했던 테스트 코드에 문제가 있는지, 혹은 잘 작성되었는지 확인해보도록 합시다.
# 전반적인 책의 내용
책에 있는 모든 내용은 아니더라도, 테스트 코드를 살펴보기 위한 기준점이 될만한 중요한 내용을 몇가지 정리하겠습니다.
1) 가치 있는 테스트만을 진행해야 한다.
테스트 코드에 대한 작성, 그리고 테스트를 실행하는데 드는 컴퓨팅 리소스도 모두 자원이기 때문에 의미있고 효율적인 테스트만을 수행해야합니다. 의미 있고 효율적인 테스트란, 회귀 방지(동작의 오류를 잘 잡아냄)와 리팩토링 내성(테스트가 구현 사항에 강결합되지 않아 세부 구현이 바뀌었다고 해서 동작 결과에 영향을 미치지 않음), 빠른 피드백(테스트가 빨리 수행되는 것)을 만족하는 코드입니다.
또한 테스트를 진행하면서 특정 커버리지 지표를 목표로 하는 것은 좋지 않습니다. 의미없는 테스트만 많아질 뿐이고, 커버리지가 높으면 테스트 코드가 구현 코드에 강결합되어 리팩토링 내성을 낮추는 원인이 될 수도 있습니다.
2) 테스트는 "동작 단위"의 코드 조각을 검증해야 한다.
테스트할 대상 클래스를 정해두고 대상 클래스의 메소드의 동작 하나하나를 검증하는게 아니라, 특정 기능을 기준으로 클래스가 수행하는 동작을 검증해야합니다. 예를 들어, (1+1)+3을 테스트 할 때, 1+1, 2+3이 잘 수행되는지 테스트하는 것이 아니라 저 연산의 결과가 5인것을 검증해야 한다는 뜻입니다.
3) 구현 세부사항보단 구현 결과를 검증, 즉 출력을 확인하는 테스트를 해야 한다.
2번과 이어지는 내용입니다. 추상화된 것을 테스트하는 것으로, 세부 동작을 검증해야하는 특별한 때가 아니면 일반적으로 블랙박스 테스트가 선호됩니다. 테스트코드 복잡도를 낮추기 위해 테스트할 것만을 공개하고 나머지는 캡슐화를 시키는 것이 좋습니다. 객체지향 원칙이 테스트 코드에도 적용된 것이라고 볼 수 있습니다. 무엇을 "어떻게"하는지가 아닌 "무엇을"하는지를 검증하므로 세부 구현사항에 테스트는 약하게 결합하고, 이는 거짓 양성 정도를 줄여 의미있는 테스트가 만들어지도록 합니다.
4) 목은 시스템 외부, 어플리케이션 경계를 넘나드는 "식별할 수 있는 동작"을 확인하기 위해서만 사용되어야 한다.
시스템 내부에서 단위 동작을 수행하기 위해 메소드를 호출하는 것에 목을 사용할 이유가 없습니다. 의미 없는 회귀 테스트가 되며, 이는 어쩌면 테스트할 가치가 없는 기능일지도 모릅니다. 하지만 외부 시스템이나 API를 이용하는 코드의 경우, 어플리케이션 경계를 넘어가는 데이터가 존재하고 이는 사이드 이펙트를 발생시킴으로써 식별할 수 있는 동작이기 때문에 목을 사용해도 됩니다.
그러나 모든 외부의존성이 목으로 대체되어야하는 것은 아닙니다. DB와 같은 의존성은 어플리케이션 내부에서만 접근할 수 있는 내부 의존성입니다. 이는 어플리케이션이 완전히 제어권을 가진 모듈이고, 클라이언트의 기준에서 식별할 수 없는 동작으로 분류됩니다.
5) 코드가 중요하거나 복잡할수록 협력자가 적어야 한다.
컨트롤러 코드의 경우, 여러가지 협력 클래스(의존성)를 구성으로 두고 클래스 메소드를 수행하는 역할을 합니다. 협력자가 많지만 복잡도가 낮은 이러한 컨트롤러 코드는 통합 테스트를 통해 간단히 테스트해야합니다. 반대로 복잡한 비즈니스 로직이나 중요한 도메인 로직을 수행하는 클래스의 경우, 최소한의 협력자만을 두고 해당 클래스가 수행하는 단위 동작에 대한 검증을 진행해야 합니다.
때문에 가치 있는 단위 테스트를 위한 코드 작성은,
- 협력자가 많고 로직이 단순한 컨트롤러 코드
- 협력자가 적고 로직이 복잡한 도메인 모델 및 알고리즘 클래스
두 가지 종류의 클래스를 작성하고, 전자는 통합 테스트를, 후자는 단위 테스트를 진행하는 것입니다.
6) 이해하기 쉬운 테스트 코드를 작성해야한다.
테스트도 결국 유지보수의 대상입니다. 다른 개발자들에게 읽혀야하는 코드이므로 가독성에 집중하며 읽기 쉽고 간단하게 작성해야 합니다.
이제 프로젝트에서 작성했던 몇 가지 단위 테스트코드를 살펴보면서 위의 기준을 잘 만족했는지, 만족하거나 그렇지 않았다면 왜 그런건지 훑어보도록 합시다.
1. 좋은 예 : 테스트 클래스 내 팩토리 메소드 사용
유저가 프로젝트의 메인 컨텐츠인 레시피에 "북마크"를 하는 상황입니다. 자기 자신의 레시피는 북마크 할 수 없다는 비즈니스 로직을 단위 테스트 하려고 합니다.
테스트 코드 내부에 @BeforeEach 메소드를 선언해서 user1, user2가 각각 recipe1, recipe2를 작성하는 상황을 가정할 수 있도록 했습니다.
public class BookMarkServiceTest {
private BookMarkService bookMarkService;
private UserService userService;
private RecipeService recipeService;
User user1, user2;
Recipe recipe1, recipe2;
@BeforeEach
public void setUp() {
BookMarkRepository testBookMarkRepository = new TestBookMarkRepository();
bookMarkService = new BookMarkService(testBookMarkRepository,userService,recipeService);
// user1이 recipe1
// user2가 recipe2 작성
user1 = User.builder().id(1L).email("user1@test.com")
.name("user1").role(Role.USER).build();
user2 = User.builder().email("user2@test.com")
.id(2L).name("user2").role(Role.USER).build();
recipe1 = Recipe.builder().author(user1)
.id(1L).title("recipe1").recipeStatistics(new RecipeStatistics()).build();
recipe2 = Recipe.builder().author(user2)
.id(2L).title("recipe2").recipeStatistics(new RecipeStatistics()).build();
}
}
테스트 코드는 읽기 간단해야하고, 로직과 크게 연관되지 않은 이러한 준비 코드는 비공개 팩토리 메소드로 따로 뽑아내 재사용하는 것이 좋습니다. 테스트 유저와 테스트 유저가 작성한 각 레시피를 팩토리 메소드로 구성함으로써 테스트 메소드의 준비 구절을 줄일 수 있습니다.
2. 애매한 예 : DB 테스트에 Stub 사용
책에서는 DB 테스트에 대해 다음과 같은 지침을 안내합니다.
- 하나의 단위 동작을 수행하기 위한 DB 트랜잭션을 하나로 유지하도록 하고, 하나의 트랜잭션을 재사용하지 않도록 한다.
- DB는 애플리케이션 내부에서만 접근 가능한 의존성으로, 바깥에서 식별할 수 없는 동작이기 때문에 동작 검증을 위해 목을 사용하는 것은 의미가 별로 없다.
- 테스트 전용 인메모리 DB를 사용하지 말라. 실제 DB 환경과 다르게 동작할 수 있다.
- 개발자마다 로컬에서 실행 가능한 DB 인스턴스를 별도로 두어 서로의 테스트 결과가 영향을 미치지 않도록 하라.
요컨데 단위 작업을 하나의 트랜잭션으로 묶고, 실환경과 최대한 비슷한 테스트 전용 DB 인스턴스를 사용할 수 있도록 하라는 것입니다.
1번의 setUp() 예시를 다시 보면, 저는 Repository에 대해 스텁을 사용했습니다(TestBookMarkRepository). 엔티티 저장의 역할을 하는 save()와 필드 값으로 엔티티를 찾는 findByBookMarker()를 구현한 스텁 코드의 일부분을 가져와봤습니다. ID로 사용할 AtomicLong 객체와 엔티티 데이터를 저장할 bookMarks를 클래스 데이터로 두고, domain 패키지에 있는 BookMarkRepository 인터페이스를 구현하는 리포지토리 구현체입니다.
public class TestBookMarkRepository implements BookMarkRepository {
private final List<BookMark> bookMarks = new ArrayList<>();
private final AtomicLong atomicLong = new AtomicLong(1L);
@Override
public BookMark save(BookMark bookMark) {
BookMark saved = BookMark.builder().id(atomicLong.getAndIncrement())
.bookMarker(bookMark.getBookMarker()).recipe(bookMark.getRecipe())
.build();
bookMarks.add(saved);
return saved;
}
@Override
public List<BookMark> findByBookMarker(User bookMarker) {
return bookMarks.stream().filter(bookMark ->
bookMark.getBookMarker().equals(bookMarker))
.toList();
}
}
엔티티를 저장할 collection 필드를 스트림으로 돌며 findXXX 메소드를 구현하는 형태입니다. 진짜로 DB 로직만을 수행하는 스텁으로 동작하도록 구성했습니다. 테스트 전용으로 DB 인스턴스를 따로 띄우지 않고, 테스트 환경에서 이 클래스를 사용하는 것으로 빠른 단위 테스트를 하려고 한 것입니다.
"빠른 피드백"의 측면에서 위 코드는 훌륭하다고 할 수 있겠습니다. DB 인스턴스와의 연결 과정이 필요 없고, DB엔진을 띄우기 위한 구동 작업이 필요 없이 어플리케이션 내에서 간단하게 클래스 하나만 생성하면 되기 때문입니다. 하지만 실제 구현체로써 사용하고 있는 MySQL의 동작과 위 stub repository의 동작은 상당히 다릅니다.
- 어플리케이션 전반적으로 직접 JDBC를 이용한 data source를 관리하는 것이 아닌 JPA로 추상화된 DB 접근을 이용한다는 점
- 해당 테스트는 DB의 동작이 아닌 서비스 코드의 로직을 테스트한다는 점
- 세부 구현사항은 다소 다르더라도 repository method가 하는 일 자체는 동일하다는 점
때문에 테스트 전용 Repository 구현체를 만들어 사용했습니다만, unit testing에서 말하는 이상적인 데이터베이스 테스트에는 완벽히 미치지 못했기 때문에 "애매한 예"로 두었습니다.
3. 좋은 예 : 구현 사항이 아닌 동작 결과를 검증
"레시피"와 "레시피 재료"는 다대다 관계를 맺고 있습니다. 레시피도 여러개, 레시피 재료도 여러개인 상황에 서로가 서로에 포함되는 것입니다. 이렇게 만든 이유는 레시피 재료로 레시피를 찾는 기능을 만들고 싶어서입니다. 예를들어 "오징어"가 들어간 레시피를 찾는다, 처럼 말이죠.
레시피 생성 과정에서 같은 이름을 가진 재료가 DB에 존재하지 않으면 레시피 엔티티를 추가하고, 그렇지 않으면 연관 관계만 추가하는 로직을 수행하는 동작을 검증하려고 합니다. 아래 테스트 코드가 그것입니다.
@Test
@DisplayName("존재하지 않는 재료는 새로 생성된다.")
void notExistIngredient() {
// given : "신재료" 이름을 가진 new ingredient는 존재하지 않는다.
String newIngredientName = "신재료";
assertTrue(ingredientRepository.findByName(newIngredientName).isEmpty());
// when : newIngredientName을 saveOrFind() 하면,
ingredientService.saveOrFind(newIngredientName);
// then : 새로운 객체가 생성된다.
assertTrue(ingredientRepository.findByName(newIngredientName).isPresent());
}
saveOrFind()의 내부 동작을 몰라도, 기존에 존재하지 않던 "신재료"이름을 가진 엔티티가 isEmpty() -> isPresent()로 바뀌었습니다. 뭐 내부적으로는 재료를 저장한 DB에서 이름으로 검색하고, 존재하지 않는다면 save()를 진행하는 식으로 구현이 되어있겠죠. 하지만 이걸 몰라도, 우리가 원하는 동작만 수행한다면 위 코드는 리팩토링 내성과 회귀 방지의 측면에서 좋은 효과를 가진 테스트라고 할 수 있겠습니다.
만약 위 테스트코드가 saveOrFind()호출을 위해 재료 repository의 특정 메소드를 수행하는지 검증하고, 그때 전달되는 인자가 "신재료"임을 확인하는 등 세부 구현사항을 모조리 확인한다면 어떨까요? saveOrFind()에 조금의 로직 변화만 있어도 위 테스트는 깨지게 될 것입니다. 이것은 리팩토링 내성을 가지지 않는 테스트이며, 확실히 좋지 않은 테스트 코드라고 말할 수 있겠습니다.
4. 좋은 예 : 식별할 수 있는 외부 모듈 호출에 목 사용
현재 프로젝트는 kafka를 메세지 큐로 사용하는 구조를 띄고 있습니다.

유저의 특정 post 요청이 일어났을 때, WAS -> kafka -> WAS로 메세지가 전달되는지 확인하는 단위 테스트를 진행하고자 합니다.
해당 요청은 식별 가능한 사이드 이펙트를 발생시킵니다. WAS가 카프카에 메세지를 발행하고, 이는 카프카 로그에 쌓이고, 이를 또다시 구독하는 WAS가 소비하기 때문입니다.
카프카 메세지 발행을 확인하기 위한 테스트 코드는 아래과 같습니다. 메세지 발행 자체가 비동기로 이뤄지기 때문에 awaitility 모듈을 사용하여 verify를 진행했습니다. 외부 카프카 모듈을 사용하는 notificationMessageSender가 목 처리 되어있습니다.
@MockBean
private NotificationMessageSender notificationMessageSender;
// ...
// then : sender가 author인 알림이 발송된다.
ArgumentCaptor<NotificationMessage> argumentCaptor =
ArgumentCaptor.forClass(NotificationMessage.class);
await().atMost(3, TimeUnit.SECONDS).untilAsserted(() -> {
// Awaitility를 이용한 비동기 테스트
verify(notificationMessageSender)
.sendNotificationMessage(argumentCaptor.capture());
});
NotificationMessage sent = argumentCaptor.getValue();
assertEquals(NotificationType.NEW_RECIPE, sent.getNotificationType());
assertEquals(author.getId(), sent.getSenderId());
카프카 모듈을 사용하기 위한 파라미터로써 전달되는 메세지가 잘 전달 되는지 mock을 통해 확인하는 부분을 봅시다. ArgumentCaptor를 통해 @MockBean인 KafkaMessageSender의 동작을 확인하는 것을 알 수 있습니다.
이제 메세지 소비를 살펴봅시다. 실제로 카프카의 메세지를 주고받는게 아니라 이렇게 발행/소비를 나눠 단위 테스트를 하는 것입니다. 아래 코드는 MQ에 발행된 알림 메세지를 소비하는 listener 로직을 테스트하는 코드입니다.
public class NotificationListenerTest {
private KafkaNotificationListener notificationListener;
NotificationRepository notificationRepository = new TestNotificationRepository();
UserRepository userRepository = new TestUserRepository();
FollowRepository followRepository = new TestFollowRepository();
@BeforeEach
public void setUp() {
notificationListener = new KafkaNotificationListener(
notificationRepository, userRepository, followRepository);
}
@Test
@DisplayName("도착한 알림 메세지에 맞게 알림을 저장한다.")
public void notificationListenerTest() {
// given : sender, receiver 유저가 설정된 NotificationMessage가 발행된다.
User sender = userRepository.save(
User.builder().name("sender").email("sender@email.com").role(Role.USER).build());
User receiver = userRepository.save(
User.builder().name("receiver").email("receiver@email.com").role(Role.USER).build());
NotificationMessage message = NotificationMessage.builder()
.senderId(sender.getId()).receiverId(receiver.getId())
.notificationType(NotificationType.FOLLOW).build();
// when : 알림을 저장한다.
notificationListener.saveNotification(message);
// then : 메세지로 전송된 것과 같은 알림 entity가 조회된다.
List<Notification> found = notificationRepository.findByReceiver(receiver);
assertAll(() -> assertEquals(1,found.size()),
() -> assertEquals(sender.getId(), found.get(0).getSender().getId()),
() -> assertEquals(message.getNotificationType(), found.get(0).getNotificationType()));
}
}
repository 자체가 stub이기 때문에 따지고보면 모킹은 아니지만(모킹은 out을, stub은 in을 흉내) 어쨌든 실제 외부 시스템을 호출하는 대신 해당 로직을 검증했으므로 단위 테스트에서 말하는 "식별할 수 있는 동작"을 테스트하기 위해 mock을 적절히 사용했다고 말할 수는 있겠습니다.
5. 안좋은 예 : 내부 클래스간 메세지 전달에 목 사용
유저가 같은 유저에게 팔로우 요청을 여러번 보내도 한 번만 반영된다는 멱등성을 테스트하려 했습니다. userService를 목 처리 했기 때문에, 아래와 같이 테스트를 작성했습니다.
@Test
@DisplayName("같은 유저를 여러번 팔로우해도 결과는 한 번만 반영된다.")
public void idempotentFollowTest() {
when(userService.getUserByEmail(userEmail1)).thenReturn(user1);
when(userService.getUserById(user2.getId())).thenReturn(user2);
Follow saved1 = followService.follow(user1.getEmail(), user2.getId());
Follow saved2 = followService.follow(user1.getEmail(), user2.getId());
assertEquals(saved1,saved2);
}
userService에는 이메일과 아이디로 유저를 찾는 기능인 getUserByEmail()과 getUserById() 메소드가 존재합니다. 간단한 이 기능을 when()을 통해 모킹한 것을 확인할 수 있습니다. 말 그대로 유저1의 이메일로 조회하면 유저1이, 유저2의 아이디로 조회하면 유저2가 반환되는 동작을 선언했습니다.
followService의 follow()는 유저의 팔로우 요청이 일어날 때 실행되는 메소드입니다. 같은 팔로우를 2번 날려도 그 결과는 같다는 것이죠.
그런데 userSerivce는 프로그램 내부에 있는 어플리케이션 코드로서, DB와 연결되는 repository가 아니면 딱히 시스템 외부 시스템에 의존이 없는 단순한 서비스 계층의 코드입니다. 위 코드는 정말로 간단한 모킹처리고 내용이 한 번에 이해되기 때문에 문제가 크게 다가오지 않는다고 해도 이는 엄연한 내부 시스템간 통신에 불필요한 목 처리를 했다고 볼 수 있습니다.
6. 나쁘지 않은 예 : 필요한 비즈니스 로직을 검증
특별한 테스트 내용은 아니기도 하고 불필요한 모킹이 존재하기 때문에 완벽히 좋은 테스트라고 할 순 없지만, 정말 필요한 정책 테스트를 한 부분이 있습니다.
@Test
@DisplayName("레시피의 리뷰가 모두 삭제되면(개수가 0개면) 평점은 0점이다.")
public void zeroRatingsTest() {
// given : 리뷰를 작성한다.
CreateReviewDto request = getCreateRequest(); // 팩토리 메소드
Review created = reviewService.createReview(reviewAuthorEmail,request);
// when : 리뷰를 삭제한다.
reviewService.deleteReview(reviewAuthorEmail, created.getId());
// then : 레시피의 리뷰 갯수는 0개이고, 평점은 0점이다.
assertEquals(0, recipe.getRecipeStatistics().getReviewCount());
assertEquals(0, recipe.getRecipeStatistics().getRatings());
}
엔티티의 초기 값, 엣지 케이스에 대한 데이터 정책 등은 이렇게 단위 테스트를 통해 검증하는 것이 좋습니다. 커버할 수 있는 범위의 정책과 로직은 단위 동작을 통해 테스트 할 수 있도록 합시다.