TDD - JUnit 테스트 코드 구성 방법.(테스트 코드 조직)

2021. 4. 17. 17:23 테스트 코드/JUnit

 

이번 포스팅에서 다루어볼 내용은 테스트 코드를 잘 조직하고 구조화 할 수 있는 JUnit 기능을 살펴볼 것이다.

포스팅에서 다룰 내용은 아래와 같다.

 

  • 준비-실행-단언을 사용하여 테스트를 가시적이고 일관성 있게 만드는 방법
  • 메서드를 테스트하는 것이 아니라 동작을 테스트하여 테스트 코드의 유지 보수성을 높이는 방법
  • 테스트 이름의 중요성
  • @Before와 @After 애너테이션을 활용하여 공통 초기화 및 정리 코드를 설정하는 방법
  • 거슬리는 테스트를 안전하게 무시하는 방법

 

AAA로 테스트 일관성 유지

 

  1. 준비(Arrange) : 테스트 코드를 실행하기 전에 시스템이 적절한 상태에 있는지 확인한다. 객체들을 생성하거나 이것과 의사소통하거나 다른 API를 호출하는 것 등이다. 드물지만 시스템이 우리가 필요한 상태로 있다면 준비 상태를 생략하기도 한다.
  2. 실행(Act) : 테스트 코드를 실행한다. 보통은 단일 메서드를 호출한다.
  3. 단언(Assert) : 실행한 코드가 기대한 대로 동작하는지 확인한다. 실행한 코드의 반환값 혹은 그 외 필요한 객체들의 새로운 상태를 검사한다. 또 테스트한 코드와 다른 객체들 사이의 의사소통을 검사하기도 한다.
  4. 사후(After) : 때에 따라 테스트를 실행할 때 어떤 자원을 할당했다면 잘 정리되었는지 확인해야한다.
@FunctionalInterface
public interface Scoreable {
   int getScore();
}
 
public class ScoreCollection {
   private List<Scoreable> scores = new ArrayList<>();
   
   public void add(Scoreable scoreable) {
      scores.add(scoreable);
   }
   
   public int arithmeticMean() {
      int total = scores.stream().mapToInt(Scoreable::getScore).sum();
      return total / scores.size();
   }
}
 
public class ScoreCollectionTest {
   @Test
   public void answersArithmeticMeanOfTwoNumbers() {
      //Arrange
      ScoreCollection collection = new ScoreCollection();
      collection.add(() -> 5);
      collection.add(() -> 7);
      
      //Act
      int actualResult = collection.arithmeticMean();
 
      //Assert
      assertThat(actualResult, equalTo(6));
   }
}

 

동작 테스트 vs 메서드 테스트

테스트를 작성할 때는 클래스 동작에 집중해야 하며 개별 메서드를 테스트한다고 생각하면 안된다. 은행의 ATM을 예로 들면, 그 클래스의 메서드에는 deposit(),withdraw(),getBalance() 메서드가 있다.(예금,출금,잔액조회) 

 

만약 위와 같은 메서드를 가진 클래스가 존재하고, 출금에 관한 테스트 코드를 작성한다고 생각해보자. 출금은 하나의 메서드로 구현이 되어 있다. 그러면 출금을 테스트하기 위해서는 딱 withdraw()라는 단일 메서드만 신경쓰면 될까? 아니다. 출금을 위해서는 계좌개설 이후에 먼저 입금이 되어있어야 가능하다. 여기서 말하고자 하는 바는 테스트 코드를 작성한다는 것은 하나의 단일 메서드를 테스트하기 위한 테스트 코드가 아닌 하나의 동작, 혹은 일련의 동작의 모음을 테스트 하기 위한 코드 작성으로 생각하여 전체적인 시각에서 테스트코드를 시작, 작성해야 한다는 것이다.

 

테스트와 프로덕션 코드의 관계

JUnit 테스트는 검증 대상인 프로덕션 코드와 같은 프로젝트에 위치할 수 있다. 하지만 테스트는 주어진 프로젝트 안에서 프로덕션 코드와 분리해야 한다. 

 

단위 테스트는 일방향성이다. 테스트 코드는 프로덕션 코드에 의존하지만, 그 반대는 해당하지 않는다. 프로덕션 코드는 테스트 코드의 존재를 모른다. 하지만 테스트를 작성하는 행위가 프로덕션 시스템의 설계에 영향을 주지 않는다는 것은 아니다. 더 많은 단위 테스트를 작성할수록 설계를 변경했을 때 테스트 작성이 훨씬 용이해지는 경우가 늘어날 수 있다.

 

테스트와 프로덕션 코드 분리

프로덕션 소프트웨어를 배포할 때 테스트를 함께 포함할 수 있지만, 대부분 그렇게 하지 않는다. 그렇게 하면 로딩하는 JAR 파일이 커지고 코드 베이스의 공격 표면(Attack surface)도 늘어난다.

 

  • 테스트를 프로덕션 코드와 같은 디렉터리 및 패키지에 넣기 : 구현하기 쉽지만 어느 누구도 실제 시스템에 이렇게 하지 않는다. 이 정책을 쓰면 실제 배포할 때 테스트 코드를 걷어 내는 스크립트가 필요하다. 클래스 이름으로 구별하거나 테스트 클래스 여부를 식별할 수 있는 리플렉션 코드를 작성해야 한다. 테스트를 같은 디렉터리에 유지하면 디렉터리 목록에서 뒤져야 하는 파일 개수도 늘어난다.
  • 테스트를 별도 디렉터리로 분리하지만 프로덕션 코드와 같은 패키지에 넣기 : 대부분은 이것은 선택한다. 이클립스와 메이븐 같은 도구는 이 모델을 권장한다. src 디렉토리와 별개로 클래스 패스에 test 디렉토리를 두고, 실제 테스트할 클래스의 패키지명을 동일하게 만들어 테스트 클래스를 작성한다. 이렇게 디렉토리와 패키지를 구성하면 각 테스트는 검증하고자 하는 대상 클래스와 동일한 패키지를 갖는다. 즉, 테스트 클래스는 패키지 수준의 접근 권한을 가진다.
  • 테스트를 별도의 디렉터리와 유사한 패키지에 유지하기 : test 디렉터리에 검증 클래스와는 조금 다른 패키지를 생성해 테스트 코드를 유지한다. 테스트 코드를 프로덕션 코드의 패키지와 다르게 하면 public 인터페이스만 활용하여 테스트 코드를 작성한다.

 

단일 목적의 테스트 작성

public class ProfileTest {
    
    private Profile profile;
    private Question question;
    private Criteria criteria;
    
    @Before
    public void init() {
        profile = new Profile("Kakao");
        question = new BooleanQuestion(1, "Got bonuses?");
        criteria = new Criteria();
    }
 
   @Test
   public void matchAnswerFalseWhenMustMatchCriteriaNotMet() {
      
      profile.add(new Answer(question, Bool.FALSE));
      criteria.add(new Criterion(new Answer(question, Bool.TRUE), Weight.MustMatch));
     
      boolean matches = profile.matches(criteria);
  
      assertFalse(matches);
   }
   
   @Test
   public void matchAnswersTrueForAnyDonCareCriteria() {
       profile.add(new Answer(question, Bool.FALSE));
       criteria.add(new Criterion(new Answer(question, Bool.TRUE), Weight.DontCare));
       
       boolean matches = profile.matches(criteria);
       
       assertTrue(matches);
   }
   
}
 
===============================================================================================
 
public class ProfileTest {
    
    private Profile profile;
    private Question question;
    private Criteria criteria;
    
    @Before
    public void init() {
        profile = new Profile("Kakao");
        question = new BooleanQuestion(1, "Got bonuses?");
        criteria = new Criteria();
    }
   @Test
   public void matchAnswerFalseWhenMustMatchCriteriaNotMet() {
      
      profile.add(new Answer(question, Bool.FALSE));
      criteria.add(new Criterion(new Answer(question, Bool.TRUE), Weight.MustMatch));
     
      boolean matches = profile.matches(criteria);
  
      assertFalse(matches);
 
      profile.add(new Answer(question, Bool.FALSE));
      criteria.add(new Criterion(new Answer(question, Bool.TRUE), Weight.DontCare));
       
      boolean matches = profile.matches(criteria);
       
      assertTrue(matches);
   }
   
}

 

만약 위에 같은 테스트 코드가 있는데, 비슷한 테스트 케이스이기에 아래와 같이 테스트 코드를 하나의 메소드로 합쳤다. 이렇게 코드를 리팩토링하면 @Test 애너테이션이 하나이기에 ProfileTest 인스턴스는 하나만 생성되고 @Before 애너테이션이 붙은 초기화코드도 한번만 초기화되면서 두개를 테스트 해볼 수 있다. 하지만 이러한 리팩토링은 아래와 같은 이점을 잃게 된다.

 

테스트를 분리하는 이점

  • Assert가 실패했을 때 실패한 테스트 이름이 표시되기 때문에 어느 동작에서 문제가 있는지 빠르게 파악할 수 있다.
  • 실패한 테스트를 해독하는 데 필요한 시간을 줄일 수 있다. JUnit은 각 테스트를 별도의 인스턴스로 실행하기 때문이다. 따라서 현재 실패한 테스트에 대해 다른 테스트의 영향을 제거할 수 있다.
  • 모든 케이스가 실행되었음을 보장할 수 있다. Assert가 실패하면 현재 테스트 메서드는 중단된다. Assert 실패는 java.lang.AssertionError를 던지기 때문이다.(JUnit은 이것을 잡아 테스트를 실패로 표시한다.) Assert 실패 이후의 테스트 케이스는 실행되지 않는다.

즉, 단일 목적을 가진 테스트로 나누는 것이 좋다.

 

일관성 있는 이름으로 테스트 문서화

테스트 케이스를 단일 메서드로 결합할수록 테스트 이름 또한 일반적이고 의미를 잃어 간다. 좀 더 작은 테스트로 이동할수록 각각은 분명한 행동에 집중한다. 또 각 테스트 이름에 더 많은 의미를 부여할 수 있다. 테스트하려는 맥락을 제안하기 보다는 어떤 맥락에서 일련의 행동을 호출했을 때 어떤 결과가 나오는지를 명시하는 것이 좋다.

 

예제로 행위 주도 개발(BDD, Behavior-Driven Development)에서 말하는 given-when-then 같은 양식을 사용할 수도 있다.

ex)givenSomeContextWhenDoingSomeBehaviorThenSomeResultOccurs(주어진 조건에서 어떤 일을 하면 어떤 결과가 나온다.)

 

위와 같은 메서드 이름은 너무 길수도 있다. 그러면 givenSomeContext부분은 제거하여

 

ex)whenDoingSomeBehaviorThenSomeResultOccurs(어떤 일을 하면 어떤 결과가 나온다.)

 

어느 형식이든지 일관성을 유지하는 것이 중요하다. 주요 목표는 테스트 코드를 다른 사람에게 의미 있게 만드는 것이다. 그리고 주석이 없어도 개발자가 봤을 때, 흐름을 이해할 수 있는 혹은 맥락을 이해할 수 있는 테스크 코드를 작성하는 것이 좋다.

 

@Before와 @After 더 알아보기

연관된 행동 집합에 대해 더 많은 테스트를 추가하면 상당한 테스트 코드가 같은 초기화 부분을 가진다. @Before 메서드를 활용하면 공통적인 초기화 코드를 하나의 메서드로 추출할 수 있어 중복된 코드들을 막을 수 있다.

 

JUnit이 @Before와 @Test 메서드를 어떤 순서로 실행하는지 이해하는 것은 중요하다.

public class AssertMoreTest {
   
   @Before
   public void createAccount() {
      // ...
   }
   
   @After
   public void closeConnections() {
      // ...
   }
   
   @Test
   public void depositIncreasesBalance() {
      // ...
   }
   
   @Test
   public void hasPositiveBalance() {
      // ...
   }
}

 

만약 위와 같은 테스트 클래스가 존재한다고 생각해보자. 실행 순서는 어떻게 될까?

 

  1. @Before
  2. @Test(depositIncreasesBalance)
  3. @After
  4. @Before
  5. @Test(hasPositiveBalance)
  6. @After

 

  1. @Before
  2. @Test(hasPositiveBalance)
  3. @After
  4. @Before
  5. @Test(depositIncreasesBalance)
  6. @After

테스트 코드는 위와 같은 순서로 실행된다. 사실 @Test는 어떠한 순서로 실행될지 알 수 없다. 물론 @FixMethodOrder(MethodSorters.NAME_ASCENDING)와 같은 애너테이션을 테스트 클래스에 붙여 메서드 이름의 내림차순,오름차순으로 순서를 지정할 수는 있다. 하지만 조금 특이한 것이 보인다. 초기화와 후처리 메서드가 매 테스트 메서드마다 실행된다는 점이다. 이것은 즉, @Test 메서드를 수행할 때마다, 테스트 클래스는 새로운 인스턴스를 생성하여 @Before,@After를 다시 실행한다는 것이다.

 

이것은 JUnit의 특징이며 중요한 성질이다. JUnit은 우리에게 독립된 테스트 실행을 제공해주는 것이다.

 

@BeforeClass와 @AfterClass 애너테이션

해당 애너테이션이 붙은 메서드는 어떤 테스트를 처음 실행하기 전에 한 번만 실행된다.

public class AssertMoreTest {
   @BeforeClass
   public static void initializeSomethingReallyExpensive() {
      // ...
   }
   
   @AfterClass
   public static void cleanUpSomethingReallyExpensive() {
      // ...
   }
   
   @Before
   public void createAccount() {
      // ...
   }
   
   @After
   public void closeConnections() {
      // ...
   }
   
   @Test
   public void depositIncreasesBalance() {
      // ...
   }
   
   @Test
   public void hasPositiveBalance() {
      // ...
   }
}

 

실행 순서는 아래와 같다.

 

  1. @BeforeClass
  2. @Before
  3. @Test
  4. @After
  5. @Before
  6. @Test
  7. @After
  8. @AfterClass

테스트제외

만약 테스트에서 제외하고 싶은 메서드가 있다면 @Ignore 애너테이션을 붙여준다. 그리고 결과를 보면 스킵된 테스트 개수를 볼 수 있다.(왼쪽 상단의 Runs: 2/2 (1 skipped) )

public class AssertMoreTest {
   
   @Test
   public void depositIncreasesBalance() {
      // ...
   }
   
   @Ignore
   @Test
   public void hasPositiveBalance() {
      // ...
   }
}

 

 

여기까지 테스트 코드를 어떻게 조직하며 내부적인 JUnit 동작 메커니즘을 다루어보았다. 사실 너무 간단한 내용만 다루어봤지만 이렇게 하나하나 다루다보면 테스트 코드 작성에 익숙해지는 날이 오지 않을까 싶다.



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