본문 바로가기
✅ 테스트

단위 테스트란 무엇일까? 런던파와 고전파의 차이점 🆚

by kukim 2022. 3. 19.

이 글은 책 Unit Testing(단위 테스트) 2장과 하단 Reference 참고했습니다. 잘못된 내용이 있다면 편하게 말씀해주세요 🙏🏻

 

목차

- 런던파? 고전파?

- 단위 테스트 정의

- 런던파의 테스트 격리

- 고전파의 테스트 격리

- 런던파와 고전파의 장단점과 차이

- 통합 테스트

- 개인적 결론


런던파? 고전파?

단위 테스트(Unit Test)는 무엇일까? 단위 테스트는 두 가지 견해 고전파(classical school)와 런던파(London school)로 구분할 수 있다. 

고전파는 켄트 백의 테스트 주도 개발(TDD)으로 원론적으로 접근하는 방식 때문에 '고전'이라고 한다.

런던파는 런던 프로그래밍 커뮤니티에서 시작됐다. 목 추종자(mockist)라고도 불린다.

두 분파는 어떤 차이로 단위 테스트를 정의할까?

 


단위 테스트 정의

단위 테스트는 크게 3가지 정의로 볼 수 있다.

1. 작은 코드 조각(단위)을 검증한다.

2. 빠르게 수행한다.

3. 격리된 방식으로 처리하는 자동화된 테스트다.

 

고전파, 런던파는 1,2번의 정의는 동일하나 3번, 테스트를 할 때 격리를 바라보는 관점이 다르다. 어떤 차이가 있을까?


런던파의 테스트 격리 

런던파에서 격리 문제테스트 대상 시스템(System under test, sut)협력자(coolaborator)에게서 격리하는 것을 말한다. 예를 들어 A 클래스를 테스트한다고 했을 때 A 클래스가 B나 C 클래스에 의존한다면 이 모든 의존성을 테스트 대역(Test Double)으로 대체한다. A 클래스 동작을 외부 영향과 격리(분리)해서 테스트 대상 클래스에만 집중할 수 있도록 하는 것이다.

책 단위테스트 2장 - 그림 2.1

그림 2.1을 보면 위의 테스트는 SUT가 1,2번에 의존적이었지만 런던파 테스트로 한다면 의존성을 테스트 대역으로 대체하여 격리된 환경에서 테스트할 수 있다.

 

장점

- 테스트가 실패하면 의존성이 없기 때문에 테스트 대상의 문제만이라는 것을 알 수 있다. -> 쉽게 문제점을 발견할 수 있다.

- 객체 그래프(object graph)를 분할할 수 있다. 의존성을 중간에 끊을 수 있기 때문에 유일하게 해당 클래스만 테스트할 수 있다.

- 프로젝트에서 한 번에 한 클래스만 테스트하라는 지침이 있다면, 전체 단위 테스트 스위트를 간단한 구조로 만들 수 있다. (그림 2.2 참고)

 

책 단위테스트 2장 - 그림 2.2

 

런던파 테스트 코드 예제

런던파 vs 고전파(시카고) 테스트 코드 비교(Java version)(책  소스코드는 C#으로 되어있지만 개인적으로 Java로 바꾸었다. 틀릴 수 있다.)

 

런던파는 시스템 대상 시스템(SUT) 이외의 클래스(협력자)를 테스트 대역(Test Double)으로 표현한다. 아래 코드는 Java의 Mockito 라이브러리를 사용하여 테스트 대역을 구현하고 있다. Customer 클래스의 purchase 메서드를 테스트한다고 하자. 이때 purchase 메서드 안에서 Store 클래스의 hasEnoughInventory 메서드를 사용한다. Customer 클래스는 SUT이고 Store는 협력자이다. Store.hasEnoughInventory 메서드를 테스트 대역으로 구현한다. 실제 구현체가 없어도 가짜 메서드를 만들고 리턴 값을 미리 정한다. 따라서 Customer 테스트는 Store에 완전히 격리된 상태에서 테스트할 수 있다. 테스트할 때 success의 결과를 확인하고, Customer이 Store를 호출해야 하는 메서드 횟수까지 검증할 수 있다. 이는 테스트 대상과 협력자의 상호 작용을 검사한다. (테스트 대상 단위 : 코드, 단일 클래스)

// /london/lodonTest.java

class londonTest {
    @Test
    void Purchase_succeeds_when_enough_inventory() {
        // Arrange
        Store storeMock = Mockito.mock(Store.class);
        when(storeMock.hasEnoughInventory(Product.SHAMPOO, 5))
                .thenReturn(true);
        Customer customer = new Customer();

        // Act
        boolean success = customer.purchase(storeMock, Product.SHAMPOO, 5);

        // Assert
        assertThat(success).isTrue();
        verify(storeMock, times(1)).removeInventory(Product.SHAMPOO, 5);
    }
    // 생략
}

고전파의 테스트 격리

고전파의 격리하는 방법은 코드를 격리하는 것을 넘어 단위 테스트 간에 격리한다. 테스트 순서와 상관없이 각각의 단위 테스트는 서로의 결과에 영향을 미치지 않는다. 각각의 테스트를 격리하는 것은 각각의 클래스들 간에 멤버 변수를 공유하거나 영향을 미치지 않는다면 한 번에 테스트해도 된다. 

 

고전파 테스트 코드 예제

런던파 vs 고전파(시카고) 테스트 코드 비교(Java version)(책  소스코드는 C#으로 되어있지만 개인적으로 Java로 바꾸어보았다. 틀릴 수 있다.)

고전파 테스트는 런던파와 다르게 테스트 대상 Customer와 협력자인 Store 클래스의 구현체가 실제로 구현되어있다. 따라서 Customer.purchase() 를 테스트하기 위해 Store를 Mock 객체로 사용하지 않고 실제 구현체로 테스트한다. 고전파의 격리는 Purchase_succeeds_when_enough_inventory() 테스트와 Purchase_fails_when_not_enough_inventory() 테스트 간에 격리되어있다. 다시 말해 단위 테스트 간 객체는 서로 영향을 주지 않는다. 런던파와 다르게 Customer + Store 둘 다 검증한다. Customer이 올바르게 작동하더라도 Store 내부 버그가 있다면 단위 테스트에 실패할 수 있다. 두 클래스는 런던파와 다르게 격리돼 있지 않다. (테스트 대상 단위 : 동작 단위 (런던파와 다르게 코드 단위가 아니다))

// /chicago/chicagoTest.java
class ChicagoTest {

    @Test
    void Purchase_succeeds_when_enough_inventory() {
        // Arrange
        Store store = new Store();
        store.addInventory(Product.SHAMPOO, 10);
        Customer customer = new Customer();

        // Act
        boolean success = customer.purchase(store, Product.SHAMPOO, 5);

        // Assert
        assertThat(success).isTrue();
        assertThat(store.getInventory(Product.SHAMPOO)).isEqualTo(5);
    }


    @Test
    void Purchase_fails_when_not_enough_inventory() {
        // Arrange
        Store store = new Store();
        store.addInventory(Product.SHAMPOO, 10);
        Customer customer = new Customer();

        // Act
        boolean success = customer.purchase(store, Product.SHAMPOO, 15);

        // Assert
        assertThat(success).isFalse();
        assertThat(store.getInventory(Product.SHAMPOO)).isEqualTo(10);
    }
}

하지만 단위 테스트 간에 격리시키기 어려운 상황이 있다. 테스트할 때 공유 의존성(shared dependency)를 사용하는 경우다.

공유 의존성이란 테스트 간에 공유되고 서로 결과에 영향을 미칠 수 있는 의존성이다. 예를 들어 static 멤버 변수가 있다고 했을 때 테스트 간에 static 멤버 변수를 수정하면 다음 테스트에도 영향을 미친다. 또는 데이터 베이스나 파일 시스템을 다룰 때도 공유 의존성이 있다고 볼 수 있다. 따라서 고전파에서 공유 의존성을 없애고 테스트해야 한다.

 

정리

런던파와 고전파의 차이는 단위 테스트를 할 때 격리의 차이에서 발생한다. 런던파는 sut(클래스)간 격리하고 고전파는 단위 테스트 간 격리한다.

  격리 주체 단위 크기 테스트 대역 사용 대상
런던파 단위 단일 클래스 불변 의존성 외의 모든 의존성
고전파 단위 테스트 단일 클래스 또는 클래스 세트 공유 의존성

런던파와 고전파의 장단점과 차이

(책 단위 테스트 저자는 개인적으로 고전파를 선호한다고 한다.)

 

런던파의 장점반박

장점 1 : 한 번에 한 클래스를, 자세한 메서드, 클래스 확인하기 때문에 테스트 범위가 좁고 세밀하다.

반박 1 : 테스트 목표가 입자성이 아니다. 단일 동작 단위가 좋은 테스트이지 않을까? 예를 들어 '우리집 강아지를 부르면, 바로 나에게 온다'의 동작 테스트가 중요한 것이지 '우리 집 강아지를 부르면 먼저 왼쪽 앞다리를 운직이고, 이어서 오른쪽 앞다리를 움직이고, 머리를 돌리고...'의 테스트는 좋지 못하다.

 

장점 2 : 클래스의 그래프가 커져도(연결된 의존성이 많아져도) 테스트하기 쉽다. 테스트 대역으로 대체하면 된다.

반박 2 : 클래스 그래프가 커진 것을 테스트 대역으로 테스트할 방법을 찾는 게 중요한 것이 아니라 애초에 큰 클래스 그래프 설계가 잘못된 것이지 않을까?

 

장점 3 : 테스트 내 다른 의존성이 없기 때문에 테스트 실패한다면 SUT에만 문제가 있기 때문에 문제 파악이 쉽다.

반박 3 : 오히려 의존성이 연결된 모든 곳까지 테스트하기 때문에 그 문제를 고치면서 의존성의 구현체까지 고칠 수 있다. 계단식으로 실패하는 데 가치가 있다. 

 

TDD 설계 방식 차이

런던파 : 하향식(탑다운), 상위 레벨 테스트부터 시작하고 하위는 대역으로 대체하여 나중에 구현할 수 있다.

고전파 : 테스트에서 실제 객체를 다뤄야 하기 때문에 일반적으로 상향식(바텀업)으로 구현한다. 도메인 모델을 시작으로 구현하는 편이다.

 

테스트 코드와 SUT의 결합도 차이

런던파 테스트 코드는 SUT의 코드, 클래스 단위로 테스트하다 보니 고전파보다 테스트 코드가 SUT와 더 강하게 결합되는 편이다. 이는 운영 코드가 바뀌면  테스트 코드도 다 바뀌어야 하는 문제가 있다. 런던파 테스트 코드는 깨지기 쉽다(Fragile Tests)

 

오버 엔지니어링

고전파의 경우 바텀업, 저수준부터 개발하기 때문에 나중에 필요치 않은 코드, 오버 엔지니어링 될 수 있다.


통합 테스트

런던파의 통합 테스트 : 실제 협력자 객체를 사용하는 모든 테스트를 통합 테스트이다. 고전파는 런던파 입장에서 모두 통합 테스트이다.

고전파의 통합 테스트 : 공유 의존성 접근하는 테스트 또는 둘 이상의 동작 단위 검증할 때 통합 테스트이다.

 

엔드 투 엔드 테스트(end-to-end test)

엔드 투 엔드 테스트는 공유 의존성뿐만 아니라 조직 내 다른 팀이 개발한 코드 등과 통합해 작동하는 테스트다. 일반 통합 테스트보다 더 많은 의존성이 있다. UI(User Interface), GUI(Graphic User Interface) 테스트라고도 할 수 있다. 

책 단위테스트 2장 - 그림 2.6

엔드 투 엔드 테스트 작성에 가장 많은 비용이 발생하기 때문에 모든 단위 테스트, 통합 테스트 통과한 후 후반에 작성, 실행하는 것이 좋다. 하지만 엔드 투 엔드 테스트의 의존성을 원하는 상태를 만들 수 없을 수 있기 때문에 테스트 대역을 쓸 때도 있다.(e.g. PG사 시스템이 죽는다면?) 통합 테스트와 엔드 투 엔드 테스트를 정확히 구분하기 어려울 수 있다.


개인적 결론

용도에 맞는 곳에 적절하게 둘 다 사용해야 한다고 생각한다.

만약 TDD로 개발한다면, 알고리즘 위주의 프로젝트라면 고전파 방식의 TDD, 테스트가 유리할 수 있다. 도메인부터 구현하며 테스트 코드 작성하면 되기 때문이다. 하지만 웹 개발(스프링 MVC)를 구현한다면 도메인부터 구현하기 어려울 수 있다. 행동을 목표로 구현한다고 하면 상위의 컨트롤러부터 구현해야 하지만 컨트롤러는 서비스 -> 리포지토리의 의존성을 가지고 있기 때문이다. 이럴 땐 런던파의 Mock을 사용해 서비스를 Mock객체로 테스트를 작성하면 되지 않을까? 생각해 본다.

+a) 2022.07.04 추가

웹 개발 할 때 TDD로 구현한다면 컨트롤러 -> 서비스 -> 리포지토리 테스트를 할 때 각 계층을 Mocking 하여 빠른 테스트를 할 수 있는 장점이 있다고 하였다. 하지만 회의적인 생각으로 바뀌었다. 왜냐하면 각 계층을 Mocking 할 때, 리팩터링 내성이 낮다. 다시 말해 서비스 레이어의 시그니처가 바뀐다면 Mocking 하고 있는 테스트 코드가 실패한다. 테스트 코드도 함께 바꿔야 하는 거짓 양성이 발생하기 때문이다. 또한 Mocking test 작성에 상당한 시간이 소요된다. 무엇보다 통합 테스트 환경이 아니기 때문에 실제로 작동한다라는 보장이 없다. 현재는 domain 객체의 단위 테스트를 최대한 많이 한다. 단위 테스트에서 테스트하지 못하는 (외부 의존성) 테스트들은 컨트롤러, 서비스, 리포지토리를 한 번에 관통하는 통합 테스트를 구현하고 있다. Mock은 외부 의존성의 종류에 따라 사용한다. 만약 내가 관리할 수 있는 외부 의존성(리포지토리, DB 연결)이라면 Mock 하지 않고 통합 테스트로 직접 테스트한다. 하지만 내가 관리할 수 없는 외부 의존성(e.g. OAuth 서버, 결제 시스템, 알림 서비스)은 Mock을 사용한다. 컨트롤할 수 없는 외부 의존성이기에, 시그니처 변경점도 적다. 해당 외부 의존성은 내가 할 테스트의 범주가 아니고 제공해주는 쪽의 테스트 범주이다.


⛓ Reference

책 단위 테스트 - 블라드미르 코리코프 저 / 임준혁 역

책 공식 소스 코드 (C#)

ch2 소스코드 Java로 변환 (kukim)

Classic TDD or "London School"? - codemanship

London vs Chicago - msbaek

 

 

댓글