왜 테스트를 해야 할까?

홍지범·2023년 12월 3일
0

Why? 왜 테스트를 해야할까?

먼저
이 글에서는 테스트 이론에 대해 다루지 않습니다. 🙅
테스트에 대해 아는 만큼만 다룹니다. 🤷‍

발전하는 소프트웨어는 곧 무질서해 집니다.

여기서 말하는 무질서란 기능 수행만을 위해 가독성이 좋지 않은 코드를 짜거나, 불필요한 변수가 늘어나는 경우 그리고 책임 범위를 벗어난 행위 등을 의미합니다.

이런 문제를 고스란히 안고 있는 기능을 수정해야 한다고 가정해볼까요?

테스트가 없는 개발 환경에서는 인수 테스트를 통해 해당 기능이 작동하는지 확인해야 합니다.
직접 화면을 띄우고 조건을 바꿔가며 동작 결과가 나오는지 확인해야하죠.

이런! 변경한 코드를 빌드 후 테스트 해봤더니 원래 작동하던 기능이 작동하지 않는 문제가 발생했습니다.
이 문제를 회귀 버그라고 합니다.
회귀 버그는 개발자로 하여금 코드 변경에 소극적이게 만들고 지속적인 통합의 장애 요인이 됩니다.
제품의 빠른 출시가 생명인 서비스 산업에서는 치명적인 문제이죠.

이러한 문제를 즉시 확인하고 빠른 피드백을 받을 수 있도록 하는 방법이 자동 테스트 입니다.
자동 테스트를 애플리케이션이 기대한 대로 동작하는 것을 보장할 수 있습니다.

어떻게 테스트를 작성해야 할까?

처음 테스트를 작성한다면 어떻게 짜야할지 막막한 경우가 많습니다.
단순히 많은 메서드를 테스트로 채우면(테스트 커버리지) 좋은 테스트를 했다고 할 수 있을까요?
한 메서드마다 예외 상황은 몇개나 체크해야 할까요?
내가 작성한 테스트가 어떻게 생산성을 향상시킬 수 있을까요?

어떻게 테스트 코드를 작성해야 의미있는 테스트가 될 지? 초보 개발자 관점에서 공유 해보겠습니다.

How? 검증할 내용은 하나

@Test
void review_update_success() {
  	//given
    ...
	/* 
 		리뷰 업데이트 로직 : 기존 리뷰 find -> 기존 member find 
                        -> 리뷰 writer == 기존 멤버? -> 리뷰 udpate
                        -> 병원 평점 update
    */
    when(reviewRepository.findByUniqueId(any(Long.class))).thenReturn(review);
    when(memberService.findByLoginId(any(Long.class))).thenReturn(Optional.of(member));
	when(reviewRepository.update(any(Review.class))).thenReturn(1);
    doNothing().when(hospitalService).updateStatistics(any(Review.class));

 	//when
    reviewService.update(request, loginId);

  	//then
    verify(reviewRepository, Mockito.times(1)).findByUniqueId(1L);
    verify(memberService, Mockito.times(1)).findByLoginId(loginId);
    verify(reviewRepository, Mockito.times(1)).update(any(Review.class));
    verify(hospitalService, Mockito.times(1)).updateStatistics(any(HospitalStatistics.class));
}

무엇을 테스트하고 싶은지 보이시나요?

리뷰 update 로직은 검증 -> 리뷰 수정 -> 만족도 수정 의 과정을 거칩니다.
하지만 then 절에서 네 가지 메서드가 호출 되었는지 확인하고 있어 무엇을 테스트 하는지 명확하지 않습니다.
마치 update는 A도 호출하고, B도 호출하고 .. updateStatistics를 호출한다. 라고 말하는 것 같죠.

예외 발생 없이 정상적인 흐름으로 진행되었다면 updateStatistics를 호출하고 끝낼 것 입니다.
따라서 then 절도 아래처럼 명확하게 검증해야 합니다.

    //then
    //verify(reviewRepository, Mockito.times(1)).findByUniqueId(1L);
    //verify(memberService, Mockito.times(1)).findByLoginId(loginId);
    //verify(reviewRepository, Mockito.times(1)).update(any(Review.class));
    verify(hospitalService, Mockito.times(1)).updateStatistics(any(HospitalStatistics.class));
}

이러한 내용은 Given-When-Then 패턴으로 각 상황을 명확히 할 수 있는데요.

예를 들면 이렇게 표현할 수 있습니다.

GivenWhenThen
정상적인 요청이 주어졌을 때update 호출 시updateStatistics를 한 번 호출한다.

예외 상황은 이렇게 표현할 수 있습니다.

GivenWhenThen
미등록된 회원이 주어졌을 때update 호출 시NOT_FOUND_MEMBER 예외를 발생시킨다.
미등록된 병원이 주어졌을 때update 호출 시NOT_FOUND_HOSPITAL 예외를 발생시킨다.

얻은것 : 문서화와 비지니스 명세

"request body에 userId가 미등록된 ID라면 NOT_FOUND_MEMBER 라는 메세지를 반환할거야" 라고 가정하고 postman 을 통해 하던 행위를 코드로 만드는 것과 같습니다.

Given 일 때 When 하면 Then 해야해 라는 테스트 코드들이 모여 문서화와 동시에 비지니스 규칙을 명시할 수 있습니다.

How? 냄새나는 코드를 찾아라

ReviewService의 update는 리뷰 도메인의 수정에 대한 책임을 지니고 있습니다.
하지만 비지니스 로직의 병원 만족도 update(updateStatistics)로 끝납니다.
즉, 병원 도메인의 역할을 수행하고 있습니다.

@Test
void review_update_success() {
  	//given
    ...
	/* 
 		리뷰 업데이트 로직 : 기존 리뷰 find -> 기존 member find 
                        -> 리뷰 writer == 기존 멤버? -> 리뷰 udpate
                        -> 병원 평점 update
    */

 	//when
    reviewService.update(request, loginId);

  	//then
    verify(hospitalService, Mockito.times(1)).updateStatistics(any(HospitalStatistics.class));
}

이는 ReviewService에 필요 이상의 책임을 지니고 있음을 의심할 수 있습니다.

public class ReviewService {
    private final MemberService memberService;
    private final HospitalService hospitalService;
    private final ReviewRepository reviewRepository;
}

전 이 경우 책임을 분리하기 위해 facade 패턴을 사용했습니다.

리뷰 Controller->리뷰 Facade->리뷰 Service
->멤버 Service
->병원 Service

facade 레이어가 리뷰를 하기 위해 필요한 정보를 조회하는 역할을 대신 수행합니다.
이제 리뷰 서비스의 update 로직은 리뷰 수정에 대한 책임만(단일 책임)을 지니기 때문에 재사용성이 좋아졌습니다.
그리고 도메인 간 순환 참조의 잠재적 위험에서 멀어질 수 있습니다.

얻은것 : OOP 설계의 기회

테스트 코드를 작성하면서 프로덕션 코드를 짤 때는 신경쓰지 못했던 점을 발견할 수 있습니다.
SOLID 원칙을 지켜 객체 지향적인 설계를 하고 있는지 고민해볼 수 있습니다.

그리고 이런 의심 포인트는 쉽게 찾을 수 있는데요.
바로 테스트 하기 어렵다면 의심해볼 만한 point 입니다.
예를 들면,

  • G-W-T 패턴으로 정의했는데 흐름이 이상할 때 -> 단일 책임을 벗어났는지 의심
  • 테스트할 객체를 주입하기 힘들 때 -> 외부에 의존적인지 의심

정리

처음으로 자동 테스트를 도입하면서 아래 세 가지를 얻었습니다.

  1. 클릭 한 번으로 테스트를 할 수 있다.
  2. 테스트 코드로 비지니스를 명문화 할 수 있다.
  3. 냄새나는 코드를 찾을 수 있다.

테스트를 도입하고 무르 익기까지의 과정은 쉽지 않을 것 입니다.(저도 아직 열심히 도입하고 있어요.)
하지만 매 번 개발, 수정 후 하는 인수 테스트 시간에 커피 한 잔을 하거나,
냄새나는 코드를 찾아 지속 발전 가능한 프로덕트를 만들 수 있는 기회를 버리실건가요?

그렇지 않다면 테스트 도입을 적극 추천 드립니다!🙇‍

profile
왜? 다음 어떻게?

0개의 댓글