테스트 코드/JUnit

TDD - 테스트(단위 테스트) 리팩토링

Wings of Freedom 2021. 4. 18. 16:15

 

단위 테스트, 혹은 여느 테스트 코드를 작성하는 일은 상당한 투자와 비용이 드는 작업이다. 하지만 테스트는 프로덕 코드의 결함을 최소화하고 리팩토링으로 프로덕 시스템을 깔끔하게 유지시켜준다. 그렇지만 역시 지속적인 비용을 의미하는 것은 부정할 수 없다. 시스템이 변경됨에 따라 테스트 코드도 다시 들여다보아야한다. 때때로 변경 사항들을 생겨나고 그 결과로 수많은 테스트 케이스가 깨져 테스트 코드를 수정해야 한다.

 

그렇다면 만약 테스크 코드가 굉장히 복잡하고 지저분하다면 어떻게 될까? 새로운 변경사항이 생겨날 때마다 테스트 코드를 수정하는 일은 더욱 더 힘든 일이 될 것이다. 그래서 이번 포스팅에서는 테스트 코드를 리팩토링하여 유지보수가 쉬운 테스트 코드를 만드는 것을 간단하게 소개할 것이다. 이번 포스팅의 목표는 리팩토링의 대상이 프로덕 코드만이 아니라 테스트 코드도 되어야한다라는 것을 알기 위한 글이다.

 

검색 기능에 대한 테스트 코드가 아래와 같이 있다. 하지만 해당 테스트 코드를 보면 이 테스트 코드가 어떠한 것을 검증하고 증명하려하는지 한눈에 파악하기 힘든 코드이다. 더군다나 우리는 Search 하는 클래스가 정확히 어떤 일을 하는 지도 모른다.

public class SearchTest {
   @Test
   public void testSearch() {
      try {
        String pageContent = "There are certain queer times and occasions "
              + "in this strange mixed affair we call life when a man "
              + "takes this whole universe for a vast practical joke, "
              + "though the wit thereof he but dimly discerns, and more "
              + "than suspects that the joke is at nobody's expense but "
              + "his own.";
         byte[] bytes = pageContent.getBytes();
         ByteArrayInputStream stream = new ByteArrayInputStream(bytes);
         // search
         Search search = new Search(stream, "practical joke", "1");
         Search.LOGGER.setLevel(Level.OFF);
         search.setSurroundingCharacterCount(10);
         search.execute();
         assertFalse(search.errored());
         List<Match> matches = search.getMatches();
         assertThat(matches, is(notNullValue()));
         assertTrue(matches.size() >= 1);
         Match match = matches.get(0);
         assertThat(match.searchString, equalTo("practical joke"));
         assertThat(match.surroundingContext, 
               equalTo("or a vast practical joke, though t"));
         stream.close();
 
         // negative
         URLConnection connection = 
               new URL("http://bit.ly/15sYPA7").openConnection();
         InputStream inputStream = connection.getInputStream();
         search = new Search(inputStream, "smelt", "http://bit.ly/15sYPA7");
         search.execute();
         assertThat(search.getMatches().size(), equalTo(0));
         stream.close();
      } catch (Exception e) {
         e.printStackTrace();
         fail("exception thrown in test" + e.getMessage());
      }
   }
}

 

이 테스트 코드의 문제점 몇 가지를 찾아보자.

  • 테스트 이름인 testSearch는 어떤 유용한 정보도 제공하지 못한다.
  • 몇 줄의 주석은 우리에게 어떠한 도움도 주지 못한다.
  • 다수의 단언(assert)가 존재해 무엇을 증명하고 검증하려고 하는지 이해하지 못한다.

더 많은 냄새가 나지만 일단 위와 같은 문제점들이 딱 눈에 보인다. 우리는 이 코드를 깔끔하게 리팩토링 해볼 것이다.

 

1)불필요한 테스트 코드

위 테스트 코드에 크게 감싸고 있는 try/catch 구문은 사실 크게 이점이 없는 코드 블록이다. 현재 테스트 코드에서 해당 블록이 하는 역할은 예외가 발생하면 스택 트레이서를 출력하고 테스트를 실패시키며 예외 메시지를 뿌려주는 역할을 한다. 하지만 사실 try/catch 구문이 없더라도 JUnit은 발생한 예외를 잡아 스택 트레이서를 뿌려주고 테스트에 오류가 발생함을 알려주기 때문에 위의 try/catch는 불필요하다.(무조건 테스트에서 try/catch구문이 필요없다는 것이 아니다.)

 

두번째는 20Line에 있는 notNullValue() 부분이다. 바로 21Line에는 matchs의 사이즈가 1보다 크거나 같음을 이미 테스트하고 있다. 또한 널을 체크하는 구문이 없더라도 만약 matches가 널이라면 21Line에서 테스트 실패가 날것이므로 굳이 널 체크 단언을 넣을 필요가 없다. 물론 프로덕 코드에서는 널 체크하는 구문이 아주 큰 역할을 할 수는 있다. 하지만 이 테스크 코드에서는 크게 이점이 없는 코드구문이다.

public class SearchTest {
   @Test
   public void testSearch() throws IOException {
      String pageContent = "There are certain queer times and occasions "
            + "in this strange mixed affair we call life when a man "
            + "takes this whole universe for a vast practical joke, "
            + "though the wit thereof he but dimly discerns, and more "
            + "than suspects that the joke is at nobody's expense but "
            + "his own.";
      byte[] bytes = pageContent.getBytes();
      // ...
      ByteArrayInputStream stream = new ByteArrayInputStream(bytes);
      // search
      Search search = new Search(stream, "practical joke", "1");
      Search.LOGGER.setLevel(Level.OFF);
      search.setSurroundingCharacterCount(10);
      search.execute();
      assertFalse(search.errored());
      List<Match> matches = search.getMatches();
      assertTrue(matches.size() >= 1);
      Match match = matches.get(0);
      assertThat(match.searchString, equalTo("practical joke"));
      assertThat(match.surroundingContext, equalTo(
            "or a vast practical joke, though t"));
      stream.close();
 
      // negative
      URLConnection connection = 
            new URL("http://bit.ly/15sYPA7").openConnection();
      InputStream inputStream = connection.getInputStream();
      search = new Search(inputStream, "smelt", "http://bit.ly/15sYPA7");
      search.execute();
      assertThat(search.getMatches().size(), equalTo(0));
      stream.close();
   }
}

 

위의 문제점을 고쳐 위와 같은 테스트 코드로 리팩토링하였다. 하지만 여전히 지저분해보이는 코드이다.

 

2)추상화 누락

위 코드에서는 다수의 단언(assert)가 존재한다. 이 중에서 20,22,23Line의 단언은 사실 하나의 개념을 구체화하고 있는 단언이다. 하나의 개념을 구체화하고 있다는 뜻은 하나의 assert 문으로도 충분히 테스트할 수 있는 케이스라는 뜻이다. 우리는 여기에서 사용자 정의 매처를 생성하여 위의 3개의 단언문을 하나의 단언으로 리팩토링할 것이다.

public class ContainsMatches extends TypeSafeMatcher<List<Match>> {
   private Match[] expected;
 
   public ContainsMatches(Match[] expected) {
      this.expected = expected;
   }
 
   @Override
   public void describeTo(Description description) {
      description.appendText("<" + expected.toString() + ">");
   }
 
   private boolean equals(Match expected, Match actual) {
      return expected.searchString.equals(actual.searchString)
         && expected.surroundingContext.equals(actual.surroundingContext);
   }
 
   @Override
   protected boolean matchesSafely(List<Match> actual) {
      if (actual.size() != expected.length)
         return false;
      for (int i = 0; i < expected.length; i++)
         if (!equals(expected[i], actual.get(i)))
            return false;
      return true;
   }
 
   @Factory
   public static <T> Matcher<List<Match>> containsMatches(Match[] expected) {
      return new ContainsMatches(expected);
   }
}

 

위 코드와 같이 사용자 정의 매처를 생성한다. 사용자 정의 매처는 햄크레스트의 TypeSafeMatcher<T>를 상속하여 구현할 수 있다. 첫번째 오버라이드하는 메서드 describeTo는 테스트 실패시 우리가 기대한 값을 출력하기 위한 메서드이다. 두번째 matchesSafely는 실제 expected값과 actual 값 비교를 위한 메서드이다. 마지막 containsMatches는 JUnit 코드에서 사용되는 팩토리 메소드이다.

public class SearchTest {
   @Test
   public void testSearch() throws IOException {
      String pageContent = "There are certain queer times and occasions "
            // ...
            + "in this strange mixed affair we call life when a man "
            + "takes this whole universe for a vast practical joke, "
            + "though the wit thereof he but dimly discerns, and more "
            + "than suspects that the joke is at nobody's expense but "
            + "his own.";
      byte[] bytes = pageContent.getBytes();
      ByteArrayInputStream stream = new ByteArrayInputStream(bytes);
      // search
      Search search = new Search(stream, "practical joke", "1");
      Search.LOGGER.setLevel(Level.OFF);
      search.setSurroundingCharacterCount(10);
      search.execute();
      assertFalse(search.errored());
      assertThat(search.getMatches(), containsMatches(new Match[] { 
         new Match("1", "practical joke", 
                   "or a vast practical joke, though t") }));
      stream.close();
      // ...
 
      // negative
      URLConnection connection = 
            new URL("http://bit.ly/15sYPA7").openConnection();
      InputStream inputStream = connection.getInputStream();
      search = new Search(inputStream, "smelt", "http://bit.ly/15sYPA7");
      search.execute();
      assertTrue(search.getMatches().isEmpty());
      stream.close();
   }
}

 

우리는 사용자 정의 매처를 만들어서 위와 같이 3개의 assert를 하나의 assert 구문으로 바꾸는 마법을 부릴 수 있게 되었다. 한편으로는 "테스트 몇 줄을 줄이기 위해 사용자 정의 매처를 만드는 수고를 해야되?"라고 생각할 수는 있지만 이 사용자 정의 매처는 여러 테스트 코드에서 재활용될 수 있기에 충분한 가치가 있다.

 

그리고 마지막의 단언이였던 search.getMatches의 크기가 equalsTo(0)인가라는 단언은 위와 같이 .isEmpty()로 바꾸어서 조금 더 사실적인 정보를 줄 수 있다.

 

3)부절절한 정보

잘 추상화된 테스트는 코드를 이해하는 데 중요한 것을 부각시켜 주고 그렇지 않은 것은 보이지 않게 해준다. 테스트에 사용되는 데이터는 어떠한 테스트 시나리오를 설명할 수 있게 도움을 주어야 한다.

 

때때로 테스트에는 부적절하지만, 당장 컴파일 에러를 피하기 위해 데이터를 넣기도 한다. 예를 들어 메서드가 테스트에는 어떤 영향도 없는 부가적인 인수를 취하기도 한다.

 

테스트는 그 의미가 불분명한 "매직 리터럴"들을 포함하고 있다.

매직 리터럴 - 프로그래밍에서 상수로 선언하지 않은 숫자 리터럴을 "매직 넘버"라고 하며, 코드에는 되도록 사용하면 안된다.

...
 
Search search = new Search(stream, "practical joke", "1");
 
assertThat(search.getMatches(), containsMatches(new Match[] { 
         new Match("1", "practical joke", 
                   "or a vast practical joke, though t") }));
 
...

 

위 코드를 보면 상수 "1"이 무슨 역할을 하는지 확신할 수 없다. 따라서 그 의미를 파악하기 위해서는 Search와 Match 클래스를 까봐야한다.(실제 코드 내부적으로는 "1"이 검색 제목을 의미하며 실제로 검색에 사용되지 않는 필드 값이다.)

 

"1"을 포함한 매직 리터럴은 불필요한 질문을 유발한다. 또한 이 값이 테스트에서 어떠한 영향을 미치는 지 소스를 파느라 시간을 낭비하게 될 것이다.

URLConnection connection = 
            new URL("http://bit.ly/15sYPA7").openConnection();
      InputStream inputStream = connection.getInputStream();
      search = new Search(inputStream, "smelt", "http://bit.ly/15sYPA7");

 

또한 위의 URL 값은 어떻게 보면 위의 URL과 연관성이 있어 보이지만, 실제로는 무관한 값이다. 이렇게 "1"과 URL 값 같이 의미가 불분명하거나 혼란스러운 상황을 유발하는 리터럴 같은 경우는 의미파악이 쉬운 상수로 대체하면 의미를 분명히 전달할 수 있게 된다.

public class SearchTest {
   private static final String A_TITLE = "1";
   @Test
   public void testSearch() throws IOException {
      String pageContent = "There are certain queer times and occasions "
            + "in this strange mixed affair we call life when a man "
            + "takes this whole universe for a vast practical joke, "
            + "though the wit thereof he but dimly discerns, and more "
            + "than suspects that the joke is at nobody's expense but "
            + "his own.";
      byte[] bytes = pageContent.getBytes();
      ByteArrayInputStream stream = new ByteArrayInputStream(bytes);
      // search
      Search search = new Search(stream, "practical joke", A_TITLE);
      Search.LOGGER.setLevel(Level.OFF);
      search.setSurroundingCharacterCount(10);
      search.execute();
      assertFalse(search.errored());
      assertThat(search.getMatches(), containsMatches(new Match[] 
         { new Match(A_TITLE, "practical joke", 
                              "or a vast practical joke, though t") }));
      stream.close();
 
      // negative
      URLConnection connection = 
            new URL("http://bit.ly/15sYPA7").openConnection();
      InputStream inputStream = connection.getInputStream();
      search = new Search(inputStream, "smelt", A_TITLE);
      search.execute();
      assertTrue(search.getMatches().isEmpty());
      stream.close();
   }
}

 

혹은 빈 문자열 등을 인자로 넘겨 테스트와는 무관한 값임을 표현하는 것도 하나의 방법이 될 수 있다.

 

4)부푼 생성

테스트 코드를 보면 Search 생성자에 InputStream 객체를 넘기고 있다. 또한 이 InputStream를 만들기 위해서 3개의 라인을 차지 하고 있다. 이러한 생성 관련된 코드를 하나의 메서드로 분리하면 여러 코드에서도 재활용가능하다.

public class SearchTest {
   private static final String A_TITLE = "1";
 
   @Test
   public void testSearch() throws IOException {
      InputStream stream =
            streamOn("There are certain queer times and occasions "
             + "in this strange mixed affair we call life when a man "
             + "takes this whole universe for a vast practical joke, "
             + "though the wit thereof he but dimly discerns, and more "
             + "than suspects that the joke is at nobody's expense but "
             + "his own.");
      // search
      Search search = new Search(stream, "practical joke", A_TITLE);
      // ...
      Search.LOGGER.setLevel(Level.OFF);
      search.setSurroundingCharacterCount(10);
      search.execute();
      assertFalse(search.errored());
      assertThat(search.getMatches(), containsMatches(new Match[]
         { new Match(A_TITLE, "practical joke",
                              "or a vast practical joke, though t") }));
      stream.close();
 
      // negative
      URLConnection connection =
            new URL("http://bit.ly/15sYPA7").openConnection();
      InputStream inputStream = connection.getInputStream();
      search = new Search(inputStream, "smelt", A_TITLE);
      search.execute();
      assertTrue(search.getMatches().isEmpty());
      stream.close();
   }
 
   private InputStream streamOn(String pageContent) {
      return new ByteArrayInputStream(pageContent.getBytes());
   }
}

 

5)다수의 단언문(assert)

하나의 단위 테스트는 하나의 단언문으로 가는 것이 좋다. 때때로 단일 테스트에 다수의 단언문이 필요하긴 하지만 너무 많은 단언문을 가진다면 테스트 케이스를 두 개 이상을 포함하고 있는지 의심해봐야한다.

 

위 테스트에서는 어떠한 입력값에 대한 테스트와 어떠한 매칭도 되지 않는 테스트를 하나의 메서드 내에 작성하였음으로 두 개의 테스트로 분리 가능하다.

public class SearchTest {
   private static final String A_TITLE = "1";
 
   @Test
   public void returnsMatchesShowingContextWhenSearchStringInContent() 
         throws IOException {
      InputStream stream = 
            streamOn("There are certain queer times and occasions "
            + "in this strange mixed affair we call life when a man "
            + "takes this whole universe for a vast practical joke, "
            + "though the wit thereof he but dimly discerns, and more "
            + "than suspects that the joke is at nobody's expense but "
            + "his own.");
      // search
      Search search = new Search(stream, "practical joke", A_TITLE);
      Search.LOGGER.setLevel(Level.OFF);
      search.setSurroundingCharacterCount(10);
      search.execute();
      assertFalse(search.errored());
      assertThat(search.getMatches(), containsMatches(new Match[]
         { new Match(A_TITLE, "practical joke", 
                              "or a vast practical joke, though t") }));
      stream.close();
   }
 
   @Test
   public void noMatchesReturnedWhenSearchStringNotInContent() 
         throws MalformedURLException, IOException {
      URLConnection connection = 
            new URL("http://bit.ly/15sYPA7").openConnection();
      InputStream inputStream = connection.getInputStream();
      Search search = new Search(inputStream, "smelt", A_TITLE);
      search.execute();
      assertTrue(search.getMatches().isEmpty());
      inputStream.close();
   }
   // ...
 
   private InputStream streamOn(String pageContent) {
      return new ByteArrayInputStream(pageContent.getBytes());
   }
}

 

6)테스트와 무관한 세부 사항들

위에서는 테스트와 부관한 로그를 끄는 코드, 스트림을 사용 후에 닫은 코드등 종단 관심이 아닌 횡단 관심에 해당되는 코드가 산재되어 있다. 이러한 코드는 @Before,@After 등과 같은 메서드로 분리가능하다.

public class SearchTest {
   private static final String A_TITLE = "1";
   private InputStream stream;
   
   @Before
   public void turnOffLogging() {
      Search.LOGGER.setLevel(Level.OFF);
   }
   
   @After
   public void closeResources() throws IOException {
      stream.close();
   }
 
   @Test
   public void returnsMatchesShowingContextWhenSearchStringInContent() {
      stream = streamOn("There are certain queer times and occasions "
            + "in this strange mixed affair we call life when a man "
            + "takes this whole universe for a vast practical joke, "
            + "though the wit thereof he but dimly discerns, and more "
            + "than suspects that the joke is at nobody's expense but "
            + "his own.");
      Search search = new Search(stream, "practical joke", A_TITLE);
      search.setSurroundingCharacterCount(10);
      search.execute();
      assertThat(search.getMatches(), containsMatches(new Match[]
         { new Match(A_TITLE, "practical joke", 
                              "or a vast practical joke, though t") }));
   }
 
   @Test
   public void noMatchesReturnedWhenSearchStringNotInContent() 
         throws MalformedURLException, IOException {
      URLConnection connection = 
            new URL("http://bit.ly/15sYPA7").openConnection();
      stream = connection.getInputStream();
      Search search = new Search(stream, "smelt", A_TITLE);
      search.execute();
      assertTrue(search.getMatches().isEmpty());
   }
   // ...
 
   private InputStream streamOn(String pageContent) {
      return new ByteArrayInputStream(pageContent.getBytes());
   }
}

 

7)잘못된 조직

테스트에서 어느 부분들이 준비(Arrange), 실행(Act), 단언(Assert) 부분인지 아는 것은 테스트를 빠르게 인지할 수 있게 한다. 이 조직은 "AAA"조직이라 불린다. 보통 이러한 블럭은 개행으로 분리한다.

public class SearchTest {
   private static final String A_TITLE = "1";
   private InputStream stream;
   
   @Before
   public void turnOffLogging() {
      Search.LOGGER.setLevel(Level.OFF);
   }
   
   @After
   public void closeResources() throws IOException {
      stream.close();
   }
 
   @Test
   public void returnsMatchesShowingContextWhenSearchStringInContent() {
      stream = streamOn("There are certain queer times and occasions "
            + "in this strange mixed affair we call life when a man "
            + "takes this whole universe for a vast practical joke, "
            + "though the wit thereof he but dimly discerns, and more "
            + "than suspects that the joke is at nobody's expense but "
            + "his own.");
      Search search = new Search(stream, "practical joke", A_TITLE);
      search.setSurroundingCharacterCount(10);
 
      search.execute();
 
      assertThat(search.getMatches(), containsMatches(new Match[]
         { new Match(A_TITLE, "practical joke", 
                              "or a vast practical joke, though t") }));
   }
 
   @Test
   public void noMatchesReturnedWhenSearchStringNotInContent() 
         throws MalformedURLException, IOException {
      URLConnection connection = 
            new URL("http://bit.ly/15sYPA7").openConnection();
      stream = connection.getInputStream();
      Search search = new Search(stream, "smelt", A_TITLE);
 
      search.execute();
 
      assertTrue(search.getMatches().isEmpty());
   }
 
   private InputStream streamOn(String pageContent) {
      return new ByteArrayInputStream(pageContent.getBytes());
   }
}

 

8)암시적 의미

각 테스트가 분명하게 대답해야 할 가장 큰 질문은 "왜 그러한 결과를 기대하는 가?"이다. 테스트 코드를 보는 누군가에게 테스트 준비와 단언 부분을 상호 연관 지을 수 있게 해야한다. 단언이 기대하는 이유가 분명하지 않다면 코드를 읽는 사람들은 그 해답을 얻기 위해 다른 코드를 뒤져 가며 시간을 낭비할 것이다.

 

returnsMatchesShowingContextWhenSearchStringInContent 테스트는 이름만 딱 봐도 특정 컨텐츠 안에 특정 문자열이 포함되있다면 컨텍스트를 가지는 Matches를 리턴한다라는 것을 파악할 수 있다. 하지만 단언의 결과는 테스트 코드를 보는 사람들로 하여금 이해하기 힘들며 직접 하나하나 따져봐야하는 결과이다.("or a vast practical joke, though t") 

 

우리는 의미없는 문장을 넣어 이해하기 쉬운 기대 결과값을 만들어 낼 수 있다. 또한 URLConnection은 비용이 어느정도 있는 객체이기 때문에 굳이 사용할 필요가 없이, 외부환경과 분리해서 임의의 텍스트를 넣어 초기화하였다.

public class SearchTest {
   private static final String A_TITLE = "1";
   private InputStream stream;
   
   @Before
   public void turnOffLogging() {
      Search.LOGGER.setLevel(Level.OFF);
   }
   
   @After
   public void closeResources() throws IOException {
      stream.close();
   }
 
   @Test
   public void returnsMatchesShowingContextWhenSearchStringInContent() {
      stream = streamOn("rest of text here"
            + "1234567890search term1234567890"
            + "more rest of text");
      Search search = new Search(stream, "search term", A_TITLE);
      search.setSurroundingCharacterCount(10);
 
      search.execute();
 
      assertThat(search.getMatches(), containsMatches(new Match[]
         { new Match(A_TITLE, 
                    "search term", 
                    "1234567890search term1234567890") }));
   }
 
   @Test
   public void noMatchesReturnedWhenSearchStringNotInContent() {
      stream = streamOn("any text");
      Search search = new Search(stream, "text that doesn't match", A_TITLE);
 
      search.execute();
 
      assertTrue(search.getMatches().isEmpty());
   }
 
   private InputStream streamOn(String pageContent) {
      return new ByteArrayInputStream(pageContent.getBytes());
   }
}

 

여기까지 간단히 테스트 코드에 대한 리팩토링을 다루어봤다. 사실 리팩토링 능력은 경험의 차이가 아주 큰 것 같다. 테스트 코드를 짜는 습관을 들이다 보면 나도 리팩토링을 잘하는 날이 오겠지..



출처: https://coding-start.tistory.com/267?category=814944 [코딩스타트]