새소식

Welcome to the tech blog of Junior Backend Developer Seulbin Kang!

Backend

(BE 개발자 4개월차 독후감) Unit Testing : 단위 테스트 1차 독후감

  • -

👀 들어가기 전에

이 글은 지금까지 써왔던 현업에서의 고민 시리즈와는 조금 다르게, 따로 공부하거나 책을 읽으면서 느꼈던 생각들을 정리하기 위해 작성하게 되었다.

 

현업에서 테스트 코드를 작성하다 보면, '이 테스트가 정말 올바르게 작성된 걸까?'라는 의문이 자주 들었다. 또 테스트 주도 개발에서는 어떤 방식으로 테스트를 설계해야 하는지, 그리고 내가 지금까지 고민해왔던 문제들을 다른 개발자들은 어떻게 해결해왔는지도 궁금해졌다.

최근 들어 테스트에 대한 관심이 크게 늘면서 이런저런 자료를 찾아보던 중 개발바닥 향로님께서 추천해주신 책이 눈에 들어왔고 그 계기로 이 책을 읽게 되었다.

향로님께 추천받은 책

 

매번 느끼는 것이지만 책을 한 권 처음부터 끝까지 차분히 읽으며 공부하는 것과 어렴풋이 알고 있는 상태인 것은 확실히 큰 차이가 있다는 것을 이번에도 다시 한 번 뼈져리게 느꼈다...😭


👀 본론

테스트의 관점 Mockist vs. Classicst

이 책에서는 테스트를 바라보는 두 가지 관점을 런던파와 고전파로 구분한다.

개인적으로 나는 나는 Classicst에 더 동의하는 편이고 책을 읽으며 그 이유가 명확해졌다. 내가 생각하는 이상적인 테스트가 고전파에 가깝다 Classicst의 핵심은 아래와 같다. 테스트는 어떤 순서로 실행되더라도 서로의 결과에 영향을 주지 않아야 하며 독립적으로 실행 가능해야 한다. 고전파에서의 교체의 대상은 공유 의존성뿐이다. 반면 런던파 즉 Mockist에서는 공유 의존성뿐만 아니라 변경 가능한 의존성까지도 교체 대상으로 본다.


🖐 여기서 잠깐

외부 의존성 = 공유 의존성은 아니라는 것이다.

공유 의존성은 거의 항상 프로세스 외부에 존재하지만 프로세스 외부에 있다고 해서 반드시 공유 의존성은 아니기 때문이다.

외부 의존성을 공유하려면 단위 테스트가 서로 통신할 수 있는 수단이 있어야 한다.

의존성 내부 상태를 수정하면 통신이 어려워진다.

테스트의 구조

 

예를 들어, 프로세스 외부에 존재하더라도 불변이며 테스트 간 상태를 공유하지 않는 의존성이라면 테스트 간에 영향을 줄 수 있는 수단 자체가 없다.

 

테스트는 외부의 어떤 것도 수정하지 않기 떄문에 이러한 의존성은 테스트 컨텍스트를 오염시키지 않는다.

그래서, 공유 의존성이 없는 한 여러 클래스가 메모리에 함께 존재하더라도 여러 클래스를 묶어서 단위 테스트하는 것 자체는 문제가 되지 않는다.

의존성의 종류


AAA vs. Given / When / Then

AAA(Arrange / Act / Assert)와 Given / When / Then은 의미상 매우 유사하며 실제로도 큰 차이가 없다.

다만, 테스트는 혼자만 읽는 코드가 아니라 다른 개발자, 다른 팀과의 의사소통 수단이 될 수 있다. (책을 읽으면서 다시금 깨닳게 되었다.)

AAA vs. Given / When / Then

 

그런 맥락에서 비개발자나 다른 직군과 테스트 의도를 공유해야 한다면 Given/When/Then 구조가 더 적합하다고 느꼈다.

중요한 것은 결국 형식이 아니라 어떤 의도냐이다. 어떤 구조를 선택하든, 테스트의 흐름이 자연스럽게 읽혀야 한다.

책에서 강조하는 테스트 코드 구조 이 책에서 강조하는 테스트 원칙 중 하나는 다음이다.

실행이 하나인 테스트만이 단위 테스트에 가깝다.


단위 테스트는 간단하고 빠르며 이해하기 쉽게 작성되어야하기에 하나의 테스트 안에 여러 개의 실행이 존재해서는 안된다는 것이다.

실행이 여러 개인 테스트는 결국 이미 하나의 행위를 검증하는 테스트가 아니라 시나리오 테스트에 가까워진다.

AAA vs. Given / When / Then 차이


테스트에서는 if문 피하기

단위 테스트든 통합 테스트든 테스트 코드 안에는 분기가 없어야 한다.

테스트에 if문이 등장하면 테스트는 검증 코드가 아니라 또 하나의 구현 코드가 되기 시작한다.

도메인 로직은 작은 단위부터 하나씩 쌓아 올리며 검증해야 한다.

이 입력이 주어졌을 때, 과연 이 결과가 나오는 것이 맞는가?

 

이 질문에만 집중해야 한다. 그래야 명확하게 테스트가 되는 것이다.


준비 구절이 과도하게 커질 때

일반적으로 준비 구절은 세 구절 중 가장 커질 수 있다. 하지만 준비 구절이 실행과 검증을 합친 것보다 훨씬 커진다면 이는 설계에서 문제를 생각해봐야한다.

  • 같은 클래스 내 비공개 메서드로 도출한다.
  • 별도의 테스트용 팩토리 클래스로 분리한다.

위 두 가지 방법으로 설계를 고민해보는 것이 바람직하다. 이 부분은 이전부터 테스트를 써오면서 큰 장점이라고 생각해왔던 부분이기도 하다. 테스트 코드는 서비스에 대한 검증을 해주지만, 이런 부분에서는 결국 설계에 대한 검증도 되는 것 같다고 생각했었다.


SUT : 의존성과 테스트 대상의 분리

책에서는 테스트 대상 객체를 SUT라는 용어로 명확히 구분할 것을 권장한다.

Calculator sut = new Calculator();

 

이런 방식으로 명시적으로 SUT를 드러내게 되면 아래 세가지 이점이 있다.

  1. 테스트의 주인공이 명확해진다.
  2. 협력 객체와의 경계가 분명해진다.
  3. 테스트 의도를 더 쉽게 파악할 수 있다.

두 테스트의 차이

 

네이밍을 변경하는 것 만으로도 테스트를 보는 사람이 얼마나 편한지가 달라진다.

테스트에 대한 사고방식을 코드로 펼쳐내는 것이다.


AAA 패턴과 주석의 기준

이 책에서는 AAA 패턴을 따르되 준비와 검증 구절이 구분된다면 굳이 주석을 추가하지 않아도 되지만

반대로 테스트 설정이 복잡하다면 주석을 유지하는 것이 오히려 가독성을 높일 수 있다고 한다.

 

대규모 통합 테스트에서 복잡한 설정이 반복된다면 분리하는 것이 맞지만, 그렇지 않은 경우에는 과도한 추상화보다는 명확함이 낫다고 생각한다.


DB 초기화는 어디서 하는게 맞을까?

평소에도 코드 기준으로 DB는 외부 자원으로 판단되기에 DB연결과 초기화는 따로 @BeforeEach에서 처리해주는 편이었다.

허나, 항상 이게 맞는지에 대한 의문을 가지고 있었다. 책에서는 아래와 같이 설명한다.

DB는 대표적인 공유 의존성이며 테스트 간 상태 오염을 막기 위해서는 결국 초기화 전략이 일관되어야 한다.

즉, 기초 클래스를 둬서 DB를 따로 초기화하는 것이 합리적이다.

기초 클래스를 통해 관리하게 되면 결국 각 테스트가 모두 어떤 순서로 돌아가든 독립적으로 동일하게 돌아갈 수 있는 것이다.


테스트 명명

규칙 내가 테스트를 짜면서 가장 고민했고 가장 궁금했던 부분이다.

어떤 곳에서는 한글로 테스트를 짜며, 어디선가는 영어로만 테스트를 작성하였다.

무엇이 정답인지, 어떻게 쓰는 것이 더 개발 쪽에서 합당하게 쓰이는지를 항상 고민해왔었다.

최근에는 JUnit의 @DisplayName을 활용하여 한글 이름은 해당 Name에 작성하고 영어로 된 이름을 테스트 명으로 사용했었다.

테스트 명을 지을 때는 항상 내가 기준이 되어 내가 알아보기 쉽게...를 기준으로 지었던 것 같다. (책을 읽다가 많이 부끄러워졌다..)

테스트 이름에 SUT의 메서드 이름을 포함하지 말 것

(코드를 테스트하는 것이 아니라 어플리케이션의 동작을 테스트하는 것이기 때문이다.)

하지만, 내 테스트 이름들은 항상 SUT의 메서드 이름이 들어갔고 거기서 어떤 에러를 체크하냐를 기준으로 짜왔던 것 같다.

앞으로는 이런 부분들을 신경쓰면서 테스트를 짜야겠다.


이 책에서 반가웠던 점은 요즘 테스트를 짜기 시작하며 관심이 갔던 OOP에 대한 언급이 있었던 점인데

OOP를 쓰면 코드 자체를 읽을 때 이야기처럼 읽을 수 있는 방식으로 코드를 구성할 수 있다고 한다.

확실히 맞는 말인듯,. 얼른 이거 다읽고 OBJECT 도 읽으면서 제대로 공부해보고 싶드아


좋은 단위 테스트의 4대 요소

좋은 테스트의 4대 요소

 

이 책에서는 좋은 단위 테스트의 요소로 다음 네 가지를 제시한다.

테스트의 4대 요소와 각각의 의미

 

이 네가지는 모두 중요하며 테스트의 가치는 이 네 요소의 곱으로 결정된다.

사실 저 리팩터링 내성은 처음 들었다. 한동안 계속 회귀방지를 위해 테스트 코드를 작은 줄 하나하나에도 의미를 두고 짜왔던 터라,

하위 기능을 변경했을 때도 통과하는 테스트를 짜야한다는 것을 생각지도 못했던 것이다. (반성합미다..🙇🏻‍♂️)

 

기능은 정상적으로 동작하지만 테스트가 실패하는 상황을 거짓 양성이라 한다.

이는 테스트가 SUT의 결과가 아니라 구현 세부 사항과 결합되어 있을 때 발생한다.

하위 클래스의 구성이나 순서를 변경해도 결과가 동일하다면 버그는 아니다.

하지만 테스트가 구현에 결합되어 있다면 이런 변경만으로도 테스트는 깨진다.

테스트는 구현을 검증하는 도구가 아니라 어플리케이션의 동작을 검증하는 것이라는 사실을 망각한 것이다.

좀만 더 생각했었으면,,쩝 이상적인 테스트란 무엇인가 회귀 방지, 리팩터링 내성, 빠른 피드백은 결국 서로 상충하는 특성을 가진다.

이 중 두 가지를 극대화하는 것은 쉽지만, 결국에 나머지 하나를 희생하게 된다.

완벽한 테스트는 존재하지 않는다. 따라서 세 가지 특성을 모두 조금씩 양보하는 균형점이 필요한 것이다.

 

이 책에서는 테스트가 상당히 빠르지 않은 한 리팩터링 내성을 최대한 많이 갖는 것을 목표로 해야한다고 한다.

결국 테스트를 단단하게 만들기 위해서는 유지보수성과 리팩터링 내성을 최대로 가지며 회귀방지와 빠른 피드백 사이의 절충이 귀결되어야한다. 생각을 좀만 해보면 맞는 말이다. 매번 기능이 수시로 변경될 때마다 테스트만 실패를 하게 되면 안되니, 그렇기에 테스트 코드는 생각을 많이 하고 비즈니스 로직에 맞게 짜야될 문제인 것 같다.

블랙박스 테스트 vs. 화이트박스 테스트

 

그래서 화이트박스 테스트보다는 블랙박스 테스트를 기본 전략으로 가며, 테스트를 분석할 때는 화이트 박스의 방법을 사용하는 것을 바람직하다고 보는 것 같다. 블랙박스 테스트와 화이트박스 테스트의 분석 블랙박스 테스트, 화이트박스 테스트 정처기를 한 번이라도 공부해보았다면 다 알고 있을 것들이다. 테스트 작성은 블랙박스, 테스트 분석은 화이트박스라는 것이 무슨 말을 의미하는 것일까?

// 블랙박스 테스트 예시
@Test
void calculateDiscount() {
	// 입력
	Order order = new Order(10_000);
	// 실행 (내부는 몰라도 됨)
	int result = sut.calculate(order);
	// 출력만 검증
	assertThat(result).isEqualTo(9_000);
}

 

예를 들어, 위 코드처럼 결과에 대한 결과값만을 체크하는 것이다. 반면 화이트박스 테스트로의 검증은 아래와 같다.

// 화이트박스 분석 예시
// 내부 구현
int calculate(Order order) {
	if (order.amount > 5000) {
		// 이 분기 테스트 필요! ✓
		return order.amount * 0.9;
	}
	return order.amount;
}
// 두 가지 케이스 테스트 작성
// 1. amount > 5000
// 2. amount <= 5000

추가 예시

 

내부 분기나 구현 로직은 테스트의 주요 목적이 아닌 것이다


다시 돌아와 Mockist vs. Classicist 

Mockist vs. Classicist

 

아래는 Mockist 스타일 (런던파) 예시 코드와 Classicist (고전파) 예시 코드이다

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private DiscountPolicy discountPolicy;

    @Mock
    private OrderRepository orderRepository;

    @InjectMocks
    private OrderService sut;

    @Test
    void 주문시_할인정책이_적용된다() {
        // given
        Order order = new Order(10_000);
        given(discountPolicy.calculate(order)).willReturn(1_000);

        // when
        sut.placeOrder(order);

        // then
        verify(discountPolicy).calculate(order);
        verify(orderRepository).save(any());
    }
}

1. Mockist 스타일

class OrderServiceTest {

    private final OrderService sut =
            new OrderService(
                new RateDiscountPolicy(10),
                new InMemoryOrderRepository()
            );

    @Test
    void 주문금액_10000원에_10퍼센트_할인이_적용된다() {
        // given
        Order order = new Order(10_000);

        // when
        Order result = sut.placeOrder(order);

        // then
        assertThat(result.getFinalPrice()).isEqualTo(9_000);
    }
}

2. Classicist 스타일

Mockist vs. Classicist 차이점

 

Classicist 스타일은 행위가 아니라 결과에 더 집중하게 되며 협력 객체 교체에도 영향을 받지 않는다.

그만큼 결국 리팩터링에 강한 테스트가 되는 것이다.

물론 이 책에서도 그렇고 나의 생각도 동일하지만, '무조건 Classicist가 좋다' 가 아니다.

개발에는 무조건은 존재하지 않으며 항상 TradeOff가 존재하기 마련이다. 테스트 코드도 동일하다.

분명히 Mocking을 해야만 하는 순간이 있기에 그때 그때 유동적으로 Mocking하면 되지 않을까하는 생각이다.


👀 마치며...

사실 이 책을 읽기 전까지는 테스트 커버리지에 과하게 집착했던 것 같다. 결국 "얼마나 많이 검증했는가"가 테스트에서 가장 중요하다고 생각했다. 회귀를 막기 위해서라면 구현의 세부 사항까지도 촘촘하게 검증해야 하고 커버리지를 올리는 것이 곧 좋은 테스트를 만드는 것이라고 생각했다. 기능이 정상일때 테스트가 깨지는 순간에 대해서 크게 신경을 쓰지 않았던 것 같다. 돌아보면 리팩터링 내성은 신경을 쓰지 않은 테스트였다. 앞으로는 리팩터링 내성을 가진 테스트를 작성하기 위해 커버리지의 숫자에만 집착하지 않으며 차분히 작성해봐야겠다.

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.