middlefitting

JUnit5, SpringBoot 테스트 코드 작성 본문

TestCode

JUnit5, SpringBoot 테스트 코드 작성

middlefitting 2023. 12. 24. 02:18

테스트 코드가 중요하단 것은 개발을 공부하는 모두가 알고 있다. 그런데 생각보다 실천에 옮기기 어렵다. 실제로 학생으로써 개발을 공부하며 다양한 사이드 프로젝트에 참여해 왔지만, 테스팅 환경이 잘 구성된 경우는 없었다. 테스트 코드가 존재하더라도 이미 다 깨진 테스트 코드가 대부분이었고, 관리는 전혀 이루어지지 않았다. 

 

테스트 코드는 잘 작성하지 않는 이유는 무엇일까. 이유는 다양하겠지만, 테스트 코드를 작성하는 시간이 아깝다고 느끼는 경우가 많은 것 같다. 기능 구현하기도 바빠서 테스트를 짤 시간이 없다는 생각이 대부분의 이유이다. 근데 기능 구현이 끝나도 테스트 코드를 잘 작성하지 않는다. 심지어 배포 이후에 버그가 펑펑 터지고 그걸로 고생하는 경우가 생겨도 테스트 코드를 작성하지 않는다. 

 

이런 상황이 생기는 이유는 무엇일까. 내 나름대로의 생각은 초기의 프로젝트는 프로젝트를 진행하는 팀원이 모든 기능 개발을 담당하는 것이 원인인 것 같다. 설계까지 모두 담당을 하기 때문에 버그가 터져도 머리속에 남아있는 프로젝트 구조로 버그의 위치를 대략적으로 파악할 수 있다. 그래서 버그를 잡고 거기서 마무리한다.

 

여기까지가 전부라면 테스트 코드를 작성할 이유가 크게 없어 보인다. 그런데 잘 생각해봐야 할 것은 프로그램은 지속적으로 개선 및 변경이 이루어진다는 것이다. 기능이 추가되고, 개발자가 바뀐다. 테스트 코드가 없다면 새로운 개발자는 두려움에 떨면서 개발을 해야 한다. 그리고 기존에 테스트 코드가 없으니 본인도 잘 작성하지 않게 된다. 악순환이 반복되는 것이다. 이런 순환은 보통 리스크를 점점 커지게 만든다. 단순한 CRUD라도 모이면 복잡해진다. 서비스는 점점 복잡해져 가는데 테스트 코드가 없다면, 기능 추가할 때마다 여기저기 터지니 개발 속도가 느려진다.

 

더불어 테스트 코드가 없다면 변경이 이루어졌을 때 신규 기능 외에도 기존의 작성된 코드가 잘 돌아갈 것이라는 확신을 가질 수 없다. 이는 배포 이후 고객에게 휴먼 디버깅을 맡기는 작업으로 이어진다. 안정적이지 않은 서비스는 고객에게 신뢰를 얻을 수 없다. 따라서 안정적인 프로젝트, 팀원 그리고 고객을 위해서라도 테스트 코드를 잘 작성해야 하지 않을까.

 

개인적으로 본인은 최근 참여한 프로젝트에서, 고장난 기존의 테스트 코드를 고치고, 테스팅 환경을 구축하며 정말 많은 성장을 이룰 수 있었다. 팀원들에게 보다 안정적인 개발환경을 제공할 수 있었고, 프로젝트에 고장난 120개 정도의  통합 테스트 코드를 고치면서, 해당 프로젝트를 정말 말 그대로 자연스럽게 이해할 수 있었다. 디버깅 포인트로 서비스 여기저기를 구경하게 되니 이해를 못할수가 없다. 토비의 스프링의 저자 이일민님은 프레임워크를 이해하는 가장 쉬운 방법은 테스트 코드를 짜는 것이라고 말했다. 이는 참여하는 사이드 프로젝트에도 해당이 되는 이야기이다. 테스트 코드를 짜면 프로젝트를 자연스럽게 이해할 수 있다. 

 

새롭게 프로젝트를 진행하거나, 자신이 진행하고 있는 프로젝트가 테스팅 환경이 잘 되어 있지 않다면 적극적으로 테스트 코드를 도입해보자. 본인이 성장과 더불어 팀, 고객에게 기여할 아주 좋은 기회이다. 이 글은 스프링 부트로 테스트 코드를 작성하기 위한 방법과 본인의 고민을 나누는 글이다. 테스트 코드가 아직 추상적으로 다가오는 분들에게 조금이나마 도움이 되길 바란다.

 

 

 


# Junit

Spring에서 테스트 코드를 작성하기 위해서는 Junit을 활용할 수 있다. Junit은 자바 기반의 단위 테스트 전용 프레임워크이다. Junit의 유용한 도구들은 테스트를 쉽고, 직관적으로 작성할 수 있게 도와준다. 스프링을 사용할 때 많이 사용하는 프레임워크이고 진입장벽이 낮은 편이라 자신한다. 아직 JUnit이 낯설다면 한번 알아보도록 하자.

 

 


1) Junit의 규칙

Junit은 프레임워크이기 때문에 Junit 만의 규칙이 존재한다. Junit을 잘 사용하기 위해서는 규칙들에 대한 기본적인 이해가 있어야 한다. 그 내용에 대해 알아보도록 한다.

 

 

@Test, @ParameterizedTest

 

Junit의 테스트러너는 @Test 혹은 @ParameterizedTest 애노테이션이 붙은 테스트를 실행시킨다. 해당 애노테이션들은 메소드 단위로 붙게 된다. @Test는 인자가 없는 테스트, @ParameterizedTest는 인자가 있는 테스트를 여러 케이스로 반복해서 실행시킬 수 있다.

 

public class SampleTest {

  @Test
  void test() {
    System.out.println("HelloWorld!");
  }

  @ParameterizedTest
  @ValueSource(ints = {1, 5, 10})
  void parameterizedTest(int argument) {
    System.out.println(argument);
  }
}

@Test는 하나의 단일 테스트를 수행하고, @ParameterizedTest는 주어진 파라미터를 통해 테스트를 여러번 수행하기 때문에 잘 활용하면 중복된 코드를 개선할 수 있다. @ParameterizedTest는 활용할 수 있는 방법이 많기 때문에 이후 별도의 글에서 다뤄보고자 한다.

 

테스트를 실행해 보면 다음과 같이 테스트가 수행되는 걸 확인해 볼 수 있다.

 

 

Void 메서드

 

테스트를 수행할 메서드는 void로 작성되어야 한다. Junit5 이전에는 무조건 접근제어자가 public 메서드로 작성되어야 한다는 규칙도 존재하였지만, Junit5 부터는 private 인 경우를 제외하고는 모두 허용된다. 해당 규칙을 어기게 되면 테스트를 인식하지 못한다

 

@Test
int test() {
  return 0;
}

다음과 같이 반환형을 void로 하지 않으면 테스트를 찾지 못하는 것을 확인할 수 있다.

 

 

테스트는 무작위 독립적으로 실행된다.

 

모든 테스트는 무작위 독립적으로 수행된다. 같은 클래스에 여러 테스트 메소드들이 존재하여도, 그들은 기본적으로 실행될때마다 새로운 클래스 인스턴스에서 실행되며 독립성을 보장한다. 또한 그들의 실행 순서는 정해져 있지 않다.

 

여기서 기본적이라는 전제를 붙인 이유는 그것을 제어할 방법이 존재하기 때문이다. 제어를 하고 싶다면 @TestInstance 애노테이션을 통해 인스턴스 라이프사이클을 제어하거나 @TestMethodOrder, @Order를 통해 제어를 하도록 하자.

 

 


2) 애노테이션

Junit에는 유용한 애노테이션이 많다.  앞서 말한 @Test, @ParameterizedTest 를 제외하고 어떤 애노테이션이 있는지 알아보자. 아래에서 소개하는 애노테이션 말고도 유용한 애노테이션이 정말 많으니 궁금하면 좀 더 알아보길 바란다.

 

 

@DisplayName

 

말 그대로 테스트 수행 후 보여지는 이름을 정의하기 위한 애노테이션이다. 클래스나 메소드 중 원하는 곳에 붙이면 된다.

 

 

@Nested

 

테스트를 수행하다보면 여러 개의 테스트를 묶고 싶은 경우가 생긴다. @Nested를 내부 클래스 레벨에 붙이면 테스트를 좀 더 직관적으로 확인할 수 있다. 보통 하나의 클래스에서 여러 메소드를 테스트하는 경우가 많기 때문에 메소드별로 테스트를 묶는다던지 할 때 활용하는 등 유용한 애노테이션이다.

 

public class SampleTest {

  @Nested
  @DisplayName("methodOne 테스트")
  class methodOne {
    @Test
    void test() {
    }
    @Test
    void testTwo() {

    }
  }

  @Nested
  @DisplayName("methodTwo 테스트")
  class methodTwo {
    @Test
    void test() {
    }
    @Test
    void testTwo() {

    }
  }
}

테스트가 시각적으로 잘 구분되는걸 확인할 수 있다. 

 

 

@Disabled

 

테스트 코드를 지우거나 주석처리하기는 싫고 임시로 비활성화하고 싶은 경우가 있을 것이다. 그럴때 해당 애노테이션을 붙여주면 된다.

 

 

@RepeatedTest

 

테스트를 반복해서 실행시키고 싶을때 사용하면 된다.

 

 

@BeforeEach

 

해당 애노테이션은 메소드 레벨에 선언하면 되는데, 모든 테스트의 수행 전에 반복하고 싶은 내용을 작성하면 된다. 내부 클래스에도 작성할 수 있으며 외부 클래스의 @BeforeEach가 먼저 동작하고 이후 내부 클래스의 @BeforeEach가 작동한다.

 

 

public class SampleTest {

  @BeforeEach
  void setup() {
    System.out.println("Hello ");
  }

  @Nested
  @DisplayName("methodOne 테스트")
  class methodOne {

    @BeforeEach
    void setup() {
      System.out.println("World!");
    }

    @Test
    void test() {
    }

    @Test
    void testTwo() {

    }
  }
}

부모 클래스의 @BeforeEach 작업인 Hello가 먼저 출력되고 이후 내부 클래스의 작업인 World!가 테스트마다 수행되는 것을 확인할 수 있다.

 

 

@AfterEach

 

BeforeEach와 비슷하게 동작한다. 차이점은 테스트 시작이 아니라 끝날때마다 호출된다는 것이다.

 

 

@BeforeAll

 

해당 클래스의 테스트를 진행하기 전에 단 한번만 호출된다. @BeforeEach보다 먼저 실행된다.

 

 

@AfterAll

 

해당 클래스의 테스트를 모두 진행하고 단 한번만 호출된다. @AfterEach보다 나중에 실행된다.

 

 


3) 단언문

흔히 assert 구문으로 알려진 단언문은, 테스트의 성공과 실패 여부를 판단하게 할 수 있다. 단언문이 없는 경우에는 exception이 발생하지 않고 테스트 메서드가 정상 return 한 테스트는 성공한다. 여기에 단언문을 활용하면 세밀한 테스트가 가능하다. 단언문이 실패하면 테스트는 실패하기 때문이다. 단언문의 종류는 많으니 원하는 것을 사용하면 되는데 org.junit.jupiter.api.Assertions보다는  org.assertj.core.api.Assertions 구문을 사용하는 것을 추천한다. 메소드 체이닝 방식으로 테스트를 직관적으로 파악할 수 있기 때문이다. 한번 직접 눈으로 확인해보자.

 

org.junit.jupiter.api.Assertions
final String name = "peter";

@Test
void test() {
  String user = name;

  Assertions.assertEquals(name, user);
}

해당 구문은 대상인 user가 expect 값인 name을 기대한다는 것을 나타낸다. 무엇이 비교대상이고 무엇이 검증하려는 값인지 직관적이지 않다.

 

org.assertj.core.api.Assertions
final String name = "peter";

@Test
void test() {
  String user = name;

  assertThat(user).isEqualTo(name);
}

반면 asesertj 구문은 대상인 user가 expect 값인 name을 기대한다는 것을 한 눈에 파악할 수 있다.

 

final String name = "peter";

@Test
void test() {
  String user = name;
  String user2 = "peter";

  assertThat(user).isEqualTo(name).isEqualTo(user2);
}

 

더불어 메소드 체이닝 방식이기 때문에 이렇게 연속적인 검증도 가능하다. 이런 장점들이 존재하니 왠만하면 asesertj 구문을 사용하도록 하자.

 

 

 


# 스프링 부트와 테스트

그럼 이제 스프링 부트에서 테스트를 작성하는 방법을 알아보도록 하자.  친절하게도 스프링 부트는 테스트를 작성할 수 있는 환경을 test 폴더로 기본 제공한다. 더불어 test 태스크를 통해 바로 실행시킬 수 있으니 굉장히 편리하다. 

 

스프링에서는 앞서 설명한 Junit과 Mokito만으로도 단위, 테스트를 충분히 짤 수 있고, DI 기반 Ioc 컨테이너를 통해 상황별로 원하는 컨텍스트 환경을 구성해서 통합 테스트를 쉽게 짤 수 있다. 이렇게 꿈과 희망이 가득한 환경을 친절하게 제공해주는데 스프링을 사용하면서 테스트 코드를 작성하지 않는 건 예의에 어긋난다. 적극적으로 테스트 코드를 작성하도록 하자.

 

 


1) 테스트 환경

빌드 도구로 gradle을 선택하였을 때, 다음과 같은 환경을 구성하였다면 테스트가 가능하다.

 

testImplementation 'org.springframework.boot:spring-boot-starter-test'

test {
   useJUnitPlatform()
}

 

spring-boot-starter-test는 테스트에 필요한 의존성을 추가한다. Junit에 관한 의존성도 이때 추가된다. test의 경우에는 태스크인데 useJunitPlatform()을 통해 junit 테스트를 수행시킨다. 테스트를 수행할 위치는 기본적으로 자바 기준 src/test/java에 위치한다.

test 를 상속받는 태스크를 정의하거나 test에서 include, exclude 명령어를 사용하여 커스터마이징이 가능하며, 이를 통해 좀 더 세밀한 테스트가 가능하다.

 

 


1) 단위 테스트

단위(Unit) 테스트는 작은 단위의 코드에 대해 테스트를 수행하는 것을 말한다. 작은 단위라는 것은 정해진 기준은 없지만 하나의 책임, 혹은 기능으로 볼 수 있다. SRP 원칙에 맞게 코드가 잘 작성되었다면 하나의 책임을 가지는 클래스, 하나의 기능을 수행하는 메소드로 잘 분리가 되었을 것이다. 단위 테스트는 해당 내용에 대한 검증을 수행한다.

 

단위 테스트는 의존성 및 외부 환경을 고려할 필요가 없으니 빠르게 작성할 수 있고, TDD를 작성하는 등 개발방법론에 적용할 수 있으니 잘 활용해보자.

 

 

왜 단위 테스트를 작성할까

 

단위 테스트를 작성하는 이유는 격리된 상태에서 검증을 수행한다는 것에 의미가 있다. 단위 테스트를 가능한 모든 엣지 케이스에서 테스트를 수행한다면 해당 메소드, 클래스가 정상적으로 동작한다는 것을 확인할 수 있다. 더불어 이후 변경이 이루어졌을 때 기존의 작성된 단위 테스트가 실패한다면 해당 영역에서 무엇인가 잘못되었다는 것을 즉각적으로 파악할 수 있기 때문에 유용하다.

 

 

단위 테스트 작성하기

 

단위(Unit) 테스트는 일반적으로 메서드 단위로 작성해주면 된다. 팀 규칙에 따라 다르긴 하지만, private은 일반적으로 내부 구현으로 분류되고 테스트 대상에서 제외된다. 더불어 테스트를 하려면 클래스 내부를 제외하고는 리플렉션을 걸 수 밖에 없는데, 이는 안정적이지 않다. 기본적으로 public, 나아가 같은 패키지 내에서는 protected와 default 접근 제어자도 테스트가 가능하니 해당 부분까지 고려를 해보자.

 

@AllArgsConstructor
public class Animal {
  
  String name;

  public String getName() {
    return name;
  }
}


public class SampleTest {

  @Test
  void test() {
    //given
    String name = "peter";
    Animal animal = new Animal(name);

    //when
    String result = animal.getName();

    //then
    assertThat(result).isEqualTo(name);
  }
}

단위 테스트는 다음과 같은 플로우로 진행하면 된다. 이는 통합 테스트에서도 마찬가지인데 행동 주도 개발에서 사용되는 패턴인 given, when, then에 맞추어 테스트를 진행한다. given에서는 테스트에 필요한 환경 및 데이터와 같은 사전 조건을 준비한다. when에서는 테스트의 핵심 행동, 메서드를 실행한다. then에서는 기대하는 결과를 검증한다. when에서 수행된 행동의 결과가 예상대로인지 확인하는 것이다.

 

 

단위 테스트와 Mockito

 

Mockito는 Java에서 사용되는 모의 객체 사용을 위한 라이브러리이다. Mockito와 의존성 주입의 개념을 활용하면 테스트하려는 객체가 의존하는 다른 객체들을 쉽게 모의 객체로 만들어서 테스트를 진행할 수 있다. stub, mock의 기능을 모두 활용할 수 있다. 한번 단위 테스트에서 Mockito를 통해 모의 객체를 만드는 방법을 알아보자

 

@AllArgsConstructor
public class Animal {

  private int age;
  private final AnimalService animalService;

  public int ageAfterOneYear() {
    age = animalService.ageAfterOneYear(age);
    return age;
  }
}


public class AnimalService {

  public int ageAfterOneYear(int age) {
    return age + 1;
  }
}


public class SampleTest {

  @Test
  void test() {
    //Arrange
    int age = 10;
    AnimalService animalService = Mockito.mock(AnimalService.class);
    Animal animal = new Animal(age, animalService);
    Mockito.when(animalService.ageAfterOneYear(age)).thenReturn(age + 1);

    //Act
    int nextAge = animal.ageAfterOneYear();

    //Assert
    Assertions.assertThat(nextAge).isEqualTo(age + 1);
    Mockito.verify(animalService, Mockito.times(1)).ageAfterOneYear(age);
  }
}

예제를 보면 age와 AnimalService를 의존성 주입 받는 Animal 클래스가 존재하고, 테스트에서는 Animal 클래스의 ageAfterOneYear 메서드를 테스트하려는 것을 확인할 수 있다. 단위 테스트 관점에서는 AnimalService가 끼어들면 안된다. 따라서 AnimalService 모의 객체를 만들어서 테스트를 진행하면 된다.

 

when 문법을 통해 모의 객체가 어떤 입력에 대한 어떤 값을 반환할지에 대한 스텁의 기능을 정의하고, verify과 같은 mock의 기능 문법을 통해 실제로 모의 객체에 어떤 일이 일어났는지를 검증할 수 있다.

 

Mockito.when(animalService.ageAfterOneYear(Mockito.eq(age))).thenReturn(age + 1);
Mockito.when(animalService.ageAfterOneYear(Mockito.any(Integer.class))).thenReturn(age + 1);

여기서는 기본형 인자를 전달하였지만 실제 서비스는 DTO 인스턴스를 전달하는 형태도 많이 사용될 것이다. 그리고 실제 서비스에 전달되는 DTO가 외부에서 전달되는 것이 아니라, 인스턴스 내부에서 생성하는 것이라면 인스턴스가 일치하지 않는 상황도 생긴다. 그런 상황에는 any, eq 문법과 같은 내용을 활용하도록 하자. 자세하게 검증하려면 왠만하면 eq가 좋다. 그리고 eq를 사용하기 위해서는 DTO의 equals와 hashcode 메서드를 재정의하자.

 

 

 

 


3) 스프링의 통합 테스트

통합(Integration) 테스트는 상호작용을 테스트한다. 상호작용을 진행하는 대상은 여러 클래스가 될수도 있고, 스프링 애플리케이션 컨텍스트, 데이터베이스와 같은 외부환경이 될 수도 있다. 통합 테스트는 작성하는 것이 단위테스트보다 어렵다. 클래스 간의 의존성을 모두 고려해서 작성해야 하기 때문이다.

 

더불어 서비스에 따라 메일링 서비스와 같은 외부 API를 사용하는 경우가 존재할 수 있고, 이는 경우에 따라 테스트의 대상으로 분류되지 않기 때문에 해당 부분에 대한 모킹 처리 등 서비스가 복잡해질수록 고려할 사항이 많아질 것이다. 하지만 멋진 스프링 부트와 Junit5와 함께라면 얼마든지 할 수 있다.

 

 

통합 테스트를 작성하는 이유

 

단위 테스트 만으로는 부족한 것인가, 왜 통합테스트를 작성할까. 물론 단위 테스트 만으로는 부족하다. 단위 테스트는 하나의 메소드에 대한 검증이 이루어지는데, 모듈 간의 상호작용에 있어서는 이상한 일이 발생할 수도 있다. 더불어 스프링 애플리케이션 컨텍스트를 사용해야만 테스트를 할 수 있는 부분이 있다. DI가 제대로 이루어지는지부터 시작해서 @Validated 와 같은 유효성 검사를 수행하는건 컴포넌트에서만 할 수 있다. 단위 테스트는 POJO 관점에서 테스트를 진행하는 것이고, 통합 테스트는 Bean에서부터 데이터베이스와 같은 외부환경, 모듈간 기능 연동 등 다양한 부분의 테스트를 하는 것이다.

 

 

@SpringBootTest, @WebMvcTest, @DataJpaTest

 

해당 내용은 가장 광범위하게 사용되는 통합 테스트용 애노테이션들이다. @SpringBootTest는 전체 애플리케이션 컨텍스트를 로드한다. 모든 레이어에 걸친 통합 테스트를 진행할 때 사용한다. @WebMvcTest는 @Controller, @ControllerAdivice와 같은 웹 레이어에 필요한 빈들과 스프링 MVC에 필요한 구성들을 로드한다. 주로 컨트롤러 통합 테스트를 위한 용도로 사용된다. @DataJpaTest는 JPA 관련 설정과, 데이터베이스의 상호작용을 테스트하는데 필요한 환경을 설정한다.

 

 

통합 테스트와 Mockito

 

다음은 통합 테스트에서 Mockito를 활용하기 위한 방법을 소개한다. 통합 테스트에서는 애플리케이션 컨텍스트를 로드하기 때문에 컨텍스트에 로드할 빈을 모의 객체로 바꿔치기 해야 한다. 

 

@Component
public class Animal {

  public static final int startAge = 1;
  private int age;
  private final AnimalService animalService;

  public Animal(AnimalService animalService) {
    this.animalService = animalService;
    this.age = startAge;
  }

  public int ageAfterOneYear() {
    age = animalService.ageAfterOneYear(age);
    return age;
  }
}


@Component
public class AnimalService {

  public int ageAfterOneYear(Integer age) {
    return age + 1;
  }
}


@SpringBootTest
public class SampleTest {

  @MockBean
  AnimalService animalService;

  @Autowired
  Animal animal;

  @Test
  void test() {
    //Arrange
    int age = Animal.startAge;
    Mockito.when(animalService.ageAfterOneYear(age)).thenReturn(age + 1);

    //Act
    int nextAge = animal.ageAfterOneYear();

    //Assert
    Assertions.assertThat(nextAge).isEqualTo(age + 1);
    Mockito.verify(animalService, Mockito.times(1)).ageAfterOneYear(age);
  }
}

앞서 단위 테스트를 진행한 예제와 거의 비슷한 구성이다. 다만 Animal 컴포넌트에서 사용하는 age에는 클래스 변수를 통해 초기화하는 것으로 진행하였다. 보통 컴포넌트의 경우는 여러 클라이언트가 공유를 하기 때문에 상태를 가지는 것이 일반적으로 좋지는 않지만, 여기서는 예제를 위해 추가하였다. static 필드로 초기화를 한 이유는 모든 책임을 하나의 위치로 위임하는 것이 좋기 때문이다. 아무리 많은 테스트가 작성되어도 startAge 클래스 멤버 하나로 관리를 할 수 있기 때문이다.

 

테스트코드의 진행은 다음과 같다. @SpringBootTest를 통해 애플리케이션 컨텍스트를 로드하는 과정에서 테스트 대상이 아닌 AnimalService는 @MockBean을 통해 모의 객체로 교체한다. 이후 @Autowired를 통해 컨텍스트에서 Animal을 가져오고 테스트를 진행하는 것이다. 이렇게 통합 테스트를 진행할 때에는 @MockBean 애노테이션을 통해 컨텍스트 로드 시점에 객체를 바꿔치기 할 수 있다. 외부 API와 같은 테스트 대상에서 벗어나는 객체를 적절히 모킹하는 등 활용방안은 다양하다.

 

 

 


 

간단한 예제들을 다루어 봤지만 해당 내용을 활용하면 테스트를 작성함에 있어 큰 무리는 없을 것이다. 테스트에 대해 잘 알지 못했던 분들이라면 해당 글이 조금이나마 도움이 되었으면 좋겠다.

 

나는 처음에 Mocking에 대해서 걱정이 있었다. 의존한 객체들의 입력값이나 반환값이 바뀌어 버리면 어떻게 되는 것인가. 의존한 친구들이 처음에 의도한 값과 다른 결과를 내뱉으면 큰일나는거 아닌가, 어디서 그걸 캐치할 수 있을까, 라는 걱정이 있었다. 결론은 그런 걱정은 할 필요가 없었다.

 

테스트 코드에서 의존하는 다른 객체들은 저마다 각자의 책임을 가지며, 인터페이스를 통한 계약을 명시한다. 그 계약을 위배하게 되면 그것은 설계가 어긋난 것이며, 해당 객체의 단위 테스트에서 책임을 져야 하는 부분인 것이다. 물론 인터페이스의 명세 자체가 바뀐다면 그것에 의존하던 기존 단위 테스트들은 모두 고장날 수밖에 없다. 근데 그건 애초에 설계가 잘못된 것이다. 객체 지향 프로그래밍은 원래 변경에 예민하지 않은가. 애초에 명세가 변경될 수 없게 설계를 잘 하고, 정 안되면 신규 기능으로 대체해야 한다. 그것도 안되면 최후의 수단으로 기존 테스트를 파기하는 한이 있어도 계약을 바꾸게 되는 것이다.

 

이것은 내가 생각하는 테스트 코드의 또다른 강점이다. 이런 위험이 존재하기 때문에 테스트 코드를 짜다보면 설계에 민감해진다. 좋지 않은 설계는 내가 작성한 테스트 코드를 잠재적으로 박살내는 주범이기 때문에 경계하게 된다. 자연스럽게 좋은 설계를 위한 갈망이 생긴다. 적어도 메서드나 반환 파라미터에 있어 변경될 일이 절대 없는 코드가 아니라면 무조건 DTO를 전달하자. 전달할 필드 하나 늘었다고 인터페이스 계약을 변경하는건 객체지향 개발자로써 너무 아쉽지 않은가.