13. 까다로운 테스트

111706 단어 junitjunit

여기서는 스레드와 영속성(persistence)에 연관된 코드를 테스트한다. 또한 스텁과 목을 사용하여 어려운 의존성을 끊고 테스트를 단순화하며, 항상 변화하는 현재 시간에 대한 의존성을 끊는다.

1. 멀티스레드 코드 테스트

동시성 처리가 필요한 애플리케이션 코드를 테스트하는 것은 기술적으로 단위 테스트의 영역이 아니다. 이는 통합 테스트(integration testing)으로 분류하는 것이 더 낫다. 이때 애플리케이션 고유의 로직 중 일부는 동시적으로 실행될 수 있음을 고려하여 통합적으로 검증해야 한다.

1. 단순하고 똑똑하게 유지

멀티스레드 코드를 테스트 할때는 다음 주요 주제를 따라야 한다.

  • 스레드 통제와 애플리케이션 코드 사이의 중첩을 최소화한다 : 스레드 없이 다량의 애플리케이션 코드를 단위 테스트할 수 있도록 설계를 변경한다. 남은 코드에 대해 스레드에 대한 집중적인 테스트를 작성한다.

  • 다른 사람의 작업을 믿어라 : 자바 5에는 더그 리아(Doug Lea)의 훌륭한 동시성 유틸리티 클래스(java.util.concurrent 패키지에 있음)가 들어있다. 예를 들어 생산자(producer) / 소비자(consumer) 문제를 직접 코딩하지 말고 다른 사람들이 직접 써보며 유용성을 입증한 BlockingQueue 클래스를 사용하라.

여기서는 스레드와 애플리케이션 로직을 구별하면서 코드를 재설계하는 것에 집중한다.

2. 모든 매칭 찾기

ProfileMatcher 클래스는 관련 있는 모든 프로파일을 수집한다. 클라이언트는 주어진 조건 집합에서 ProfileMatcher 인스턴스는 프로파일을 순회하여 조건에 매칭되는 결과를 MatchSet 인스턴스와 함께 반환한다.

import java.util.*;
import java.util.concurrent.*;
import java.util.stream.*;

public class ProfileMatcher {
   private Map<String, Profile> profiles = new HashMap<>(); 
   private static final int DEFAULT_POOL_SIZE = 4;

   public void add(Profile profile) {
      profiles.put(profile.getId(), profile);
   }

   public void findMatchingProfiles(
         Criteria criteria, MatchListener listener) {
      ExecutorService executor = 
            Executors.newFixedThreadPool(DEFAULT_POOL_SIZE);

      List<MatchSet> matchSets = profiles.values().stream()
            .map(profile -> profile.getMatchSet(criteria)) 
            .collect(Collectors.toList());
      for (MatchSet set: matchSets) {
         Runnable runnable = () -> {
            if (set.matches())
               listener.foundMatch(profiles.get(set.getProfileId()), set);
         };
         executor.execute(runnable);
      }
      executor.shutdown();
   }

애플리케이션을 빠르게 반응하도록 하기 위해 findMatchingProfiles() 메서드를 각각의 별도의 스레드 맥락에서 매칭을 계산하도록 설계했다. 또한 모든 처리가 완료될 때까지 클라이언트가 블록되는 것이 아니라 findMatchingProfiles() 메서드에 MatchListener 파라미터를 넣도록 했다. 매칭되는 각 프로파일은 MatchListener 인터페이스의 foundMatch() 메서드로 반환된다.

코드를 다시 설명하면, findMatchingProfiles() 메서드는 각 프로파일에 대해 MatchSet 인스턴스의 리스트를 모은다. 각 MatchSet에 대해 메서드는 별도의 스레드를 생성하여 MatchSet 객체의 matches() 반환값이 true이면 프로파일과 그에 맞는 MatchSet 객체를 MatchListener로 보낸다.

3. 애플리케이션 로직 추출

이 메서드는 애플리케이션 로직과 스레드 로직을 둘 다 사용한다. 첫 번째 과제는 둘을 분리하는 것이다.

MatchSet 인스턴스를 모으는 로직을 같은 클래스의 collectMatchSets() 메서드로 추출한다.

import java.util.*;
import java.util.concurrent.*;
import java.util.stream.*;

public class ProfileMatcher {
   private Map<String, Profile> profiles = new HashMap<>();
   private static final int DEFAULT_POOL_SIZE = 4;

   public void add(Profile profile) {
      profiles.put(profile.getId(), profile);
   }

   public void findMatchingProfiles(
         Criteria criteria, MatchListener listener) {
      ExecutorService executor = 
            Executors.newFixedThreadPool(DEFAULT_POOL_SIZE);
      for (MatchSet set: collectMatchSets(criteria)) {
         Runnable runnable = () -> {
            if (set.matches())
               listener.foundMatch(profiles.get(set.getProfileId()), set);
         };
         executor.execute(runnable);
      }
      executor.shutdown();
   }

   List<MatchSet> collectMatchSets(Criteria criteria) {
      List<MatchSet> matchSets = profiles.values().stream()
            .map(profile -> profile.getMatchSet(criteria))
            .collect(Collectors.toList());
      return matchSets;
   }
}

이제 collectMatchSets() 메서드를 위한 테스트 코드를 작성한다.

import static org.junit.Assert.*;
import static org.hamcrest.CoreMatchers.*;
import java.util.*;
import java.util.stream.*;
import org.junit.*;

public class ProfileMatcherTest {
   private BooleanQuestion question;
   private Criteria criteria;
   private ProfileMatcher matcher;
   private Profile matchingProfile;
   private Profile nonMatchingProfile;

   @Before
   public void create() {
      question = new BooleanQuestion(1, "");
      criteria = new Criteria();
      criteria.add(new Criterion(matchingAnswer(), Weight.MustMatch));
      matchingProfile = createMatchingProfile("matching");
      nonMatchingProfile = createNonMatchingProfile("nonMatching");
   }

   private Profile createMatchingProfile(String name) {
      Profile profile = new Profile(name);
      profile.add(matchingAnswer());
      return profile;
   }

   private Profile createNonMatchingProfile(String name) {
      Profile profile = new Profile(name);
      profile.add(nonMatchingAnswer());
      return profile;
   }
   
   @Before
   public void createMatcher() {
      matcher = new ProfileMatcher();
   }
   
   @Test
   public void collectsMatchSets() {
      matcher.add(matchingProfile);
      matcher.add(nonMatchingProfile);

      List<MatchSet> sets = matcher.collectMatchSets(criteria);
      
      assertThat(sets.stream()
                .map(set->set.getProfileId()).collect(Collectors.toSet()),
       equalTo(new HashSet<>
          (Arrays.asList(matchingProfile.getId(), nonMatchingProfile.getId()))));
   }

   private Answer matchingAnswer() {
      return new Answer(question, Bool.TRUE);
   }

   private Answer nonMatchingAnswer() {
      return new Answer(question, Bool.FALSE);
   }
}

위와 유사하게 매칭된 프로파일 정보를 리스너(listener)로 넘기는 애플리케이션 로직도 추출한다.

import java.util.*;
import java.util.concurrent.*;
import java.util.stream.*;

public class ProfileMatcher {
   private Map<String, Profile> profiles = new HashMap<>();
   private static final int DEFAULT_POOL_SIZE = 4;

   public void add(Profile profile) {
      profiles.put(profile.getId(), profile);
   }

   public void findMatchingProfiles(
         Criteria criteria, MatchListener listener) {
      ExecutorService executor = 
            Executors.newFixedThreadPool(DEFAULT_POOL_SIZE);

      for (MatchSet set: collectMatchSets(criteria)) {
         Runnable runnable = () -> process(listener, set);
         executor.execute(runnable);
      }
      executor.shutdown();
   }

   void process(MatchListener listener, MatchSet set) {
      if (set.matches())
         listener.foundMatch(profiles.get(set.getProfileId()), set);
   } 

   List<MatchSet> collectMatchSets(Criteria criteria) {
      List<MatchSet> matchSets = profiles.values().stream()
            .map(profile -> profile.getMatchSet(criteria))
            .collect(Collectors.toList());
      return matchSets;
   }
}

새로운 process() 메소드에 대한 테스트 코드를 작성한다.

// ...
import static org.mockito.Mockito.*;

public class ProfileMatcherTest {
   // ...
   private MatchListener listener;

   @Before
   public void createMatchListener() {
      listener = mock(MatchListener.class); // (1)
   }

   @Test
   public void processNotifiesListenerOnMatch() {
      matcher.add(matchingProfile);  // (2)
      MatchSet set = matchingProfile.getMatchSet(criteria); // (3)

      matcher.process(listener, set); // (4)
      
      verify(listener).foundMatch(matchingProfile, set); // (5)
   }

   @Test
   public void processDoesNotNotifyListenerWhenNoMatch() {
      matcher.add(nonMatchingProfile);
      MatchSet set = nonMatchingProfile.getMatchSet(criteria);

      matcher.process(listener, set);
      
      verify(listener, never()).foundMatch(nonMatchingProfile, set);
   }
   // ...
}

이 테스트는 모키토의 기대 사항을 검증하는 기능을 활용하고 있다. 기대한 파라미터로 메서드가 호출되는지 검증한다.

첫 번째 테스트인 processNotifiesListenerOnMatch() 메서드는 다음 절차를 따른다.

  1. 모키토의 정적 mock() 메서드를 사용하여 MatchListener 목 인스턴스를 생성한다. 이 인스턴스로 기대 사항을 검증한다.
  2. 매칭되는 프로파일(주어진 조건에 매칭될 것으로 기대되는 프로파일)을 matcher 변수에 추가한다.
  3. 주어진 조건 집합에 매칭된느 프로파일에 대한 MatchSet 객체를 요청한다.
  4. 목 리스너와 MatchSet 객체를 넘겨 matcher 변수에 매칭 처리를 지시한다.
  5. 모키토를 활용하여 목으로 만든 리스너 객체에 foundMatch() 메서드가 호출되었는지 확인한다. 이때 매칭 프로파일과 MatchSet 객체를 파라미터로 넘긴다. 기대 사항이 맞지 않으면 테스트는 실패한다.

4. 스레드 로직의 테스트 지원을 위해 설계

collectMatchers()와 process() 메서드를 추출하고 남은 findMatchingProfiles() 메서드의 코드 대부분은 스레드 로직이다. findMatchingProfiles() 메서드의 현재 상태이다.

public void findMatchingProfiles(
        Criteria criteria, MatchListener listener) {
    ExecutorService executor = 
        Executors.newFixedThreadPool(DEFAULT_POOL_SIZE);

    for (MatchSet set: collectMatchSets(criteria)) {
        Runnable runnable = () -> process(listener, set);
        executor.execute(runnable);
    }
    executor.shutdown();
}

findMatchingProfiles() 메서드를 테스트하기 위해 코드를 재설계하면 다음과 같다.

import java.util.*;
import java.util.concurrent.*;
import java.util.function.*;
import java.util.stream.*;

public class ProfileMatcher {
   private Map<String, Profile> profiles = new HashMap<>();
   private static final int DEFAULT_POOL_SIZE = 4;

   public void add(Profile profile) {
      profiles.put(profile.getId(), profile);
   }

   private ExecutorService executor = 
         Executors.newFixedThreadPool(DEFAULT_POOL_SIZE);
   
   ExecutorService getExecutor() {
      return executor;
   }
   
   public void findMatchingProfiles( 
         Criteria criteria, 
         MatchListener listener, 
         List<MatchSet> matchSets,
         BiConsumer<MatchListener, MatchSet> processFunction) {
      for (MatchSet set: matchSets) {
         Runnable runnable = () -> processFunction.accept(listener, set); 
         executor.execute(runnable);
      }
      executor.shutdown();
   }
 
   public void findMatchingProfiles( 
         Criteria criteria, MatchListener listener) { 
      findMatchingProfiles(
            criteria, listener, collectMatchSets(criteria), this::process);
   }

   void process(MatchListener listener, MatchSet set) {
      if (set.matches())
         listener.foundMatch(profiles.get(set.getProfileId()), set);
   }

   List<MatchSet> collectMatchSets(Criteria criteria) {
      List<MatchSet> matchSets = profiles.values().stream()
            .map(profile -> profile.getMatchSet(criteria))
            .collect(Collectors.toList());
      return matchSets;
   }
}

테스트 코드에서 ExecutorService 인스턴스에 접근할 필요가 있다. 따라서 그것의 초기화를 필드 수준으로 추출하고 Executor 참조를 반환하는 패키지 접근 수준의 getter 메서드를 제공한다.

이미 process() 메서드를 테스트했기 때문에 그 메서드는 잘 동작한다고 안전하게 가정할 수 있으며, findMatchingProfiles() 메서드를 테스트할 때는 그 로직을 무시한다. process() 메서드의 동작을 스텁 처리하려고 findMatchingProfiles() 메서드를 오버로딩한다. 남아 있는 구현에 processFunction 인수를 추가한다. 이는 각 스레드에서 실행되는 함수를 나타낸다. processFunction 함수 참조를 사용하여 각 MatchSet을 처리하는 적절한 로직을 호출한다.

또 원래의 findMatchingProfiles() 메서드에는 내부적으로 위의 오버로딩한 메서드에 동작을 위임한다. 함수 인수에 대해서는 this::process를 넘긴다. 이것은 ProfileMatcher 클래스의 이미 동작이 검증된 process 메서드의 참조이다.

5. 스레드 로직을 위한 테스트 작성

// ...
import static org.mockito.Mockito.*;
public class ProfileMatcherTest {
//...
   @Test
   public void gathersMatchingProfiles() {
      Set<String> processedSets = 
            Collections.synchronizedSet(new HashSet<>()); // (1)
      BiConsumer<MatchListener, MatchSet> processFunction = 
            (listener, set) -> { // (2)
         processedSets.add(set.getProfileId()); // (3)
      };
      List<MatchSet> matchSets = createMatchSets(100); // (4)

      matcher.findMatchingProfiles( // (5)
            criteria, listener, matchSets, processFunction); 
      
      while (!matcher.getExecutor().isTerminated()) // (6)
         ;
      assertThat(processedSets, equalTo(matchSets.stream()
         .map(MatchSet::getProfileId).collect(Collectors.toSet()))); // (7)
   }

   private List<MatchSet> createMatchSets(int count) {
      List<MatchSet> sets = new ArrayList<>();
      for (int i = 0; i < count; i++)
         sets.add(new MatchSet(String.valueOf(i), null, null));
      return sets;
   }
}
  1. 리스너가 수신하는 MatchSet 객체들의 프로파일 ID 목록을 저장할 문자열 Set 객체를 생성한다.
  2. processFunction() 함수를 정의한다. 이 함수는 process() 메서드의 프로덕션 버전을 대신한다.
  3. 리스너에 대한 각 콜백에서 MatchSet 객체의 프로파일 ID를 processedSets 변수에 추가한다.
  4. 도우미 메서드를 사용하여 테스트용 MatchSet 객체들을 생성한다.
  5. 인수를 함수로 갖는 findMatchingProfiles() 메서드를 호출하고 인수로 processFunction() 구현을 넘긴다.
  6. 매처에서 ExecutorService 객체를 얻어 와서 모든 스레드의 실행이 완료될 때까지 반복문을 실행한다.
  7. processedSets 컬렉션(리스너에 포착된 프로파일 ID 목록)이 테스트에서 생성된 모든 MatchSet 객체의 ID와 매칭되는지 검증한다.

애플리케이션 로직과 스레드 로직의 관심사를 분리햐여 테스트를 작성할 수 있었다.

2. 데이터베이스 테스트

QuestionController와 상호 작용하는 questionText() 메서드에 대한 테스트 코드를 작성하려고 한다.

public Map<Integer,String> questionText(List<BooleanAnswer> answers) {
    Map<Integer,String> questions = new HashMap<>();
    answers.stream().forEach(answer -> {
        if (!questions.containsKey(answer.getQuestionId()))
            questions.put(answer.getQuestionId(), 
            controller.find(answer.getQuestionId()).getText()); });
    return questions;
}

quetionText() 메서드는 답변 객체의 리스트를 인자로 받아 답변 ID를 키로 하고 질문 텍스트를 값으로 하는 해시 맵을 반환한다. forEach 반복문에서는 response 맵에 없는 각 답변 ID에 대해 controller 변수를 사용하여 대응하는 질문을 찾고, 그 질문의 텍스트를 response 맵에 넣는다.

1. 고마워, Controller

questionText() 메서드의 테스트를 작성하기 어려운 이유는 자바 영속성 API(JPA)를 사용하는 포스트그레(Postgres) 데이터베이스와 통신하는 controller 변수 때문이다. QuestionController는 어떻게 동작하고 있는가?

import iloveyouboss.domain.*;
import java.time.*;
import java.util.*;
import java.util.function.*;
import javax.persistence.*;

public class QuestionController {
   private Clock clock = Clock.systemUTC();

   private static EntityManagerFactory getEntityManagerFactory() {
      return Persistence.createEntityManagerFactory("postgres-ds");
   }
   public Question find(Integer id) {
      return em().find(Question.class, id);
   }
   
   public List<Question> getAll() {
      return em()
         .createQuery("select q from Question q", Question.class)
         .getResultList();
   }
   
   public List<Question> findWithMatchingText(String text) {
      String query = 
         "select q from Question q where q.text like '%" + text + "%'";
      return em().createQuery(query, Question.class) .getResultList();
   }
   
   public int addPercentileQuestion(String text, String[] answerChoices) {
      return persist(new PercentileQuestion(text, answerChoices));
   }

   public int addBooleanQuestion(String text) {
      return persist(new BooleanQuestion(text));
   }

   void setClock(Clock clock) {
      this.clock = clock;
   }

   void deleteAll() {
      executeInTransaction(
         (em) -> em.createNativeQuery("delete from Question")
                   .executeUpdate());
   }
   
   private void executeInTransaction(Consumer<EntityManager> func) {
      EntityManager em = em();

      EntityTransaction transaction = em.getTransaction();
      try {
         transaction.begin();
         func.accept(em);
         transaction.commit();
      } catch (Throwable t) {
         t.printStackTrace();
         transaction.rollback();
      }
      finally {
        em.close();
      }
   }
   
   private int persist(Persistable object) {
      object.setCreateTimestamp(clock.instant());
      executeInTransaction((em) -> em.persist(object));
      return object.getId();
   }
   
   private EntityManager em() {
      return getEntityManagerFactory().createEntityManager();
   }
}

QuestionController 클래스에 있는 대부분의 로직은 JPA 인터페이스를 구현하는 코드에 대한 단순한 위임이다. JPA 관련 인터페이스를 모두 스텁으로 만들어 단위 테스트를 하기 보다는 포스트그레 데이터베이스와 연결되었음을 증명하는 테스트를 하는 것이 낫다. 테스트 대상은 자바 코드, 매핑 설정(src/META-INF/persistence.x ml 폴더에 위치)과 데이터베이스 자체이다.

2. 데이터 문제

영속적인 모든 상호작용을 시스템의 한 곳으로 고립시킬 수 있다면 통합 테스트의 대상은 소규모로 줄어든다.

진짜 데이터베이스와 상호 작용하는 통합 테스트를 작성할 때 데이터베이스의 데이터와 그것을 어떻게 가져올지는 매우 중요한 고려 사항이다. 데이터베이스가 기대한 대로 질의(query) 결과가 나온다고 증명하려면 먼저 적절한 데이터를 넣거나 이미 이러한 데이터베이스에 있다고 가정해야 한다.

따라서 적절한 참조 데이터를 포함한 데이터베이스를 테스트마다 다르게 하는 것이다. 매 테스트는 그 다음 자기가 쓸 데이터를 추가하거나 그것으로 작업한다. 이렇게 하면 테스트 간 의존성 문제를 최소화 할 수 있다. 테스트 간 의존성 문제는 다른 테스트에서 남아 있던 데이터 때문에 어떤 테스트가 망가지는 것을 의미한다.

만약에 테스트를 위해 공유된 데이터베이스에만 접근할 수 있다면 다음과 같은 해결 방법이 있다. 데이터베이스가 트랜잭션(transaction)을 지원한다면 테스트마다 트랜잭션을 초기화하고, 테스트가 끝나면 롤백하는 것이다.(트랜잭션 처리는 보통 @Before과 @After 메서드에 위임한다.)

마지막으로 통합 테스트는 작성과 유지 보수가 어렵다. 자주 망가지고, 그 틀이 깨졌을 때 문제를 디버깅하는 것도 상당히 오래 걸린다. 하지만 테스트 전략의 필수적인 부분이다.

3. 클린 룸 데이터베이스 테스트

controller를 위한 테스트는 매 테스트 메서드의 실행 전후에 데이터베이스를 비운다.

import static org.junit.Assert.*;
import static org.hamcrest.CoreMatchers.*;
import java.time.*;
import java.util.*;
import java.util.stream.*;
import iloveyouboss.domain.*;
import org.junit.*;
public class QuestionControllerTest {

   private QuestionController controller;
   @Before
   public void create() {
      controller = new QuestionController();
      controller.deleteAll();
   }
   
   @After
   public void cleanup() {
      controller.deleteAll();
   }

   @Test
   public void findsPersistedQuestionById() {
      int id = controller.addBooleanQuestion("question text");
      
      Question question = controller.find(id);
      
      assertThat(question.getText(), equalTo("question text"));
   }
   
   @Test
   public void questionAnswersDateAdded() {
      Instant now = new Date().toInstant();
      controller.setClock(Clock.fixed(now, ZoneId.of("America/Denver")));
      int id = controller.addBooleanQuestion("text");
      
      Question question = controller.find(id);
      
      assertThat(question.getCreateTimestamp(), equalTo(now));
   }
   
   @Test
   public void answersMultiplePersistedQuestions() {
      controller.addBooleanQuestion("q1");
      controller.addBooleanQuestion("q2");
      controller.addPercentileQuestion("q3", new String[] { "a1", "a2"});
      
      List<Question> questions = controller.getAll();
      
      assertThat(questions.stream()
            .map(Question::getText)
            .collect(Collectors.toList()), 
         equalTo(Arrays.asList("q1", "q2", "q3")));
   }
   @Test
   public void findsMatchingEntries() {
      controller.addBooleanQuestion("alpha 1");
      controller.addBooleanQuestion("alpha 2");
      controller.addBooleanQuestion("beta 1");

      List<Question> questions = controller.findWithMatchingText("alpha");
      
      assertThat(questions.stream()
            .map(Question::getText)
            .collect(Collectors.toList()),
         equalTo(Arrays.asList("alpha 1", "alpha 2")));
   }
}

코드는 @Before와 @After 메서드 모두에서 QuestionController 클래스의 deleteAll() 메서드를 호출한다. 문제를 해결할 때는 @After 메서드에서 deleteAll() 메서드 호출을 주석 처리해야 테스트가 완료된 후의 데이터를 볼 수 잇다.

테스트는 종단 간 애플리케이션의 기능성을 테스트하는 것이 아니라, 질의 기능에 집중하여 controller가 데이터베이스에 항목들을 잘 추가하는지 검증하는 것이다.

4. controller를 목 처리

지금까지는 직접적인 데이터베이스와 모든 상호 작용을 QuestionController 클래스로 고립시키고 테스트했다. 이제 StatCompiler 클래스의 questionTest() 메서드를 테스트할 차례이다. QuestionController 클래스는 검증되었기 때문에 find() 메서드를 안전하게 스텁으로 만든다.

public Map<Integer,String> questionText(List<BooleanAnswer> answers) {
    Map<Integer,String> questions = new HashMap<>();
    answers.stream().forEach(answer -> {
        if (!questions.containsKey(answer.getQuestionId()))
            questions.put(answer.getQuestionId(), 
               controller.find(answer.getQuestionId()).getText()); });
      return questions;
   }

테스트 코드는 다음과 같이 모키토를 사용한다.

 @Mock private QuestionController controller;
 @InjectMocks private StatCompiler stats;

 @Before
 public void initialize() {
     stats = new StatCompiler();
     MockitoAnnotations.initMocks(this);
 }

 @Test
 public void questionTextDoesStuff() {
     when(controller.find(1)).thenReturn(new BooleanQuestion("text1"));
     when(controller.find(2)).thenReturn(new BooleanQuestion("text2"));
     List<BooleanAnswer> answers = new ArrayList<>();
     answers.add(new BooleanAnswer(1, true));
     answers.add(new BooleanAnswer(2, true));

     Map<Integer, String> questionText = stats.questionText(answers);

     Map<Integer, String> expected = new HashMap<>();
     expected.put(1, "text1");
     expected.put(2, "text2");
     assertThat(questionText, equalTo(expected));
}

테스트가 무엇을 하는지 읽고 의미를 파악하기 쉽다. 모키토는 테스트에서 목의 활용을 단순하고 선언적으로 유지하게 한다. 모키토에 대해 많이 알지 못해도 테스트를 읽고 빠르게 그 의도를 이해할 수 있다.

참고

좋은 웹페이지 즐겨찾기