Skip to main content

15장 JUnit 들여다보기

1. 📌 핵심 개념 정리

✅ 요약하기

1. JUnit 프레임워크
  • JUnit은 저자가 많지만 켄트 벡과 에릭 감마 두 사람이 아틀란타 행 비행기를 타고 가다 만들었다.
  • 저자가 챕터에서 소개할 코드는 ComparisonCompactor모듈로 문자열 비교 오류를 파악할 때 유용한 모듈이다.
  • 예를 들어 ABCDE, ABXDE를 입력받으면 <...B[X]D...>를 반환한다
  • ComparisonCompactor 모듈 코드
    package junit.framework;
    
    public class ComparisonCompactor {
      private static final String ELLIPSIS = "...";
      private static final String DELTA_END = "]";
      private static final String DELTA_START = "[";
      private int fContextLength;
      private String fExpected;
      private String fActual;
      private int fPrefix;
      private int fSuffix;
    
      public ComparisonCompactor(int contextLength, String expected, String actual) {
        fContextLength = contextLength;
        fExpected = expected;
        fActual = actual;
      }
    
      public String compact(String message) {
        if (fExpected == null || fActual == null || areStringsEqual()) {
          return Assert.format(message, fExpected, fActual);
        }
        findCommonPrefix();
        findCommonSuffix();
        String expected = compactString(fExpected);
        String actual = compactString(fActual);
        return Assert.format(message, expected, actual);
      }
    
      private String compactString(String source) {
        String result = DELTA_START + source.substring(fPrefix, source.length() - fSuffix + 1) + DELTA_END;
        if (fPrefix > 0) {
          result = computeCommonPrefix() + result;
        }
        if (fSuffix > 0) {
          result = result + computeCommonSuffix();
        }
        return result;
      }
    
      private void findCommonPrefix() {
        fPrefix = 0;
        int end = Math.min(fExpected.length(), fActual.length());
        for (; fPrefix < end; fPrefix++) {
          if (fExpected.charAt(fPrefix) != fActual.charAt(fPrefix)) {
            break;
          }
        }
      }
    
      private void findCommonSuffix() {
        int expectedSuffix = fExpected.length() - 1;
        int actualSuffix = fActual.length() - 1;
        for (; actualSuffix >= fPrefix && expectedSuffix >= fPrefix; actualSuffix--, expectedSuffix--) {
          if (fExpected.charAt(expectedSuffix) != fActual.charAt(actualSuffix)) {
            break;
          }
        }
        fSuffix = fExpected.length() - expectedSuffix;
      }
    
      private String computeCommonPrefix() {
        return (fPrefix > fContextLength ? ELLIPSIS : "") + fExpected.substring(Math.max(0, fPrefix - fContextLength), fPrefix);
      }
    
      private String computeCommonSuffix() {
        int end = Math.min(fExpected.length() - fSuffix + 1 + fContextLength, fExpected.length());
        return fExpected.substring(fExpected.length() - fSuffix + 1, end) + (fExpected.length() - fSuffix + 1 < fExpected.length() - fContextLength ? ELLIPSIS : "");
      }
    
      private boolean areStringsEqual() {
        return fExpected.equals(fActual);
      }
    }
    
    저자들이 모듈을 아주 좋은 상태로 남겨두었지만 보이스카우트 규칙에 맞게 더 깨끗한 코드를 만들어보자.

2. 접두어 f
  • 가장 먼저 눈에 거슬리는 부분은 멤버 변수 앞에 붙인 접두어 'f'이다.

  • 오늘날 개발 환경에서는 변수 이름에 범위를 명시할 필요가 없으므로 제거한다.

  • 개선 전

    private int fContextLength;
    private String fExpected;
    private String fActual;
    private int fPrefix;
    private int fSuffix;
    
  • 개선 후

    private int contextLength;
    private String expected;
    private String actual;
    private int prefix;
    private int suffix;
    

3. 캡슐화되지 못한 조건문
  • compact 메서드 시작부를 보면 캡슐화되지 못한 조건문이 존재한다.

  • 의도를 명확히 표현하기 위해서 조건문을 캡슐화하고 적절한 이름을 붙여준다.

  • 또한 부정문은 긍정문보다 이해하기가 약간 더 어렵기에 긍정문으로 만들어 조건문을 반전한다.

  • 개선 전

    public String compact(String message) {
        if (fExpected == null || fActual == null || areStringsEqual()) {
          return Assert.format(message, fExpected, fActual);
        }
        ...
    }
    
  • 개선 후

    public String compact(String message) {
        if (canBeCompacted()) {
          findCommonPrefix(); 
          findCommonSuffix();
          String compactExpected = compactString(expected); 
          String compactActual = compactString(actual);
          return Assert.format(message, compactExpected, compactActual); 
        } else {
          return Assert.format(message, expected, actual);	
        }
    }
    
    private boolean canBeCompacted() {
      return expected != null && actual != null && areStringsEqual();
    }
    

4. 함수 이름 변경
  • 위 코드를 보면 canBeCompacted 메서드가 false이면 압축하지 않는다.

  • 그러므로 함수에 compact라는 이름을 붙이면 오류 점검이라는 부가 단계가 숨겨진다.

  • 게다가 함수는 단순히 압축된 문자열이 아니라 형식이 갖춰진 문자열을 반환한다.

  • 따라서 실제로는 formatCompatedComparison이라는 이름이 적합하다.

  • 개선 전

    public String compact(String message) {
        if (canBeCompacted()) {
          findCommonPrefix(); 
          findCommonSuffix();
          String compactExpected = compactString(expected); 
          String compactActual = compactString(actual);
          return Assert.format(message, compactExpected, compactActual); 
        } else {
          return Assert.format(message, expected, actual);	
        }
    }
    
  • 개선 후

    // 멤버 변수로 승격시킴
    private String compactExpected;
    private String compactActual;
    
    public String formatCompactedComparison(String message) {
        if (canBeCompacted()) {
            compactExpectedAndActual();
            return Assert.format(message, compactExpected, compactActual);
        } else {
            return Assert.format(message, expected, actual);
        }
    }
    
    private void compactExpectedAndActual(} { 
      prefixIndex = findCommonPrefix(}; 
      suffixIndex = findCommonSuffix(prefixlndex); 
      compactExpected = compactString(expected}; 
      compactActual = compactString(actual};
    }
    
    private int findCommonPrefix() {
      int prefixIndex = 0;
      int end = Math.min(expected. Length, actual.length());
      for (; prefixIndex < end; prefixIndex++) {
          if (expected.charAt(prefixindex) =! actual.charAt(pretixIndex))
              break;
      }
      return prefixIndex;
    }
    
    private int findCommonSuffix(int prefixIndex) {
        int expectedSuffix = expected.length() - 1;
        int actualSuffix = actual.length() - 1;
        for (; actualSuffix >= prefixIndex & expectedSuffix >= prefixIndex;
                actualSuffix-, expectedSuffix--) {
            if (expected. charAt (expectedSuffix) != actual.charAt(actualSuffix))
                break;
        }
        return expected.length() - expectedSuffix;
    }
    

    압축하는 기능을 compactExpectedAndActual 메서드에게 전적으로 맡기고 함수를 분리해 코드를 개선시켰다.
    findCommonSuffix 메서드는 findCommonPrefixprefixIndex를 계산하는 것에 의존한다.
    따라서 숨겨진 시간적인 결합을 노출시키고자 prefixIndex를 인수로 넘긴다.


5. 깨끗하게 고치기
  • prefixIndex를 인수로 전달하는 방식은 다소 자의적이다.
    함수 호출 순서는 확실히 정해지지만 prefixIndex가 필요한 이유는 설명하지 못한다.
  • prefixIndex가 필요한 이유가 드러나지 않아 다른 프로그래머가 원래대로 되돌릴 가능성이 존재한다.
  • 불필요한 if문을 제거하고 compactString 구조를 다듬어 좀더 깔끔하게 만들자.
  • 개선 전
    private void compactExpectedAndActual(} { 
      prefixIndex = findCommonPrefix(}; 
      suffixIndex = findCommonSuffix(prefixlndex); 
      compactExpected = compactString(expected}; 
      compactActual = compactString(actual};
    }
    
    private int findCommonPrefix() {
      int prefixIndex = 0;
      int end = Math.min(expected. Length, actual.length());
      for (; prefixIndex < end; prefixIndex++) {
          if (expected.charAt(prefixindex) =! actual.charAt(pretixIndex))
              break;
      }
      return prefixIndex;
    }
    
    private int findCommonSuffix(int prefixIndex) {
        int expectedSuffix = expected.length() - 1;
        int actualSuffix = actual.length() - 1;
        for (; actualSuffix >= prefixIndex & expectedSuffix >= prefixIndex;
                actualSuffix-, expectedSuffix--) {
            if (expected. charAt (expectedSuffix) != actual.charAt(actualSuffix))
                break;
        }
        return expected.length() - expectedSuffix;
    }
    
  • 개선 후
    private void compactExpectedAndActual() {
        findCommonPrefixAndSuffix();
        compactExpected = compactString(expected);
        compactActual = compactString(actual);
    }
    
    private void findCommonPrefixAndSuffix() { 
        findCommonPrefix(); 
        int suffixLength = 1; 
        for (; !suffixOverlapsPrefix(suffixLength); suffixLength++) {
            if (charFromEnd(expected, suffixLength) != 
                    charFromEnd(actual, suffixLength))
                break;
        }
        suffixIndex = suffixLength;
    }
    
    private char charFromEnd(String s, int i) { 
        return s.charAt(s.length()-i);
    }
    
    private boolean suffix0verlapsPrefix(int suffixLength) { 
        return actual.length() - suffixLength < prefixLength ||
            expected.length() - suffixLength < prefixLength;
    }
    

6. 결론
  • 코드를 리팩터링 하다 보면 원래 했던 변경을 되돌리는 경우가 흔하다.
  • 리팩터링은 코드가 어느 수준에 이를 때까지 수많은 시행착오를 반복하는 작업이기 때문이다.
  • 보이스카우트 규칙을 통해 모듈을 처음보다 조금 더 깨끗하게 수정할 수 있었다.
  • 코드를 처음보다 조금 더 깨끗하게 만드는 책임은 우리 모두에게 있다.

2. 🤔 이해가 어려운 부분

🔍 질문하기

1. 캡슐화되지 못한 조건문
  • 어려웠던 부분
    private boolean canBeCompacted() {
      return expected != null && actual != null && areStringsEqual();
    }
    
    위 코드처럼 저자는 많은 코드를 캡슐화하기 위해 분리를 했는데 불필요한 분리가 아닌가?
  • 궁금한 점
    메서드와 클래스를 너무 작게 쪼개면 Shotgun Surgery 형태의 코드 구조가 나오게 될 것 같은데
    저자가 왜 이렇게 코드를 리팩터링했는지 궁금하다.

2. 코드 커버리지
  • 궁금한 점
    코드 커버리지가 높으면 버그가 없다고 볼 수 있는가?

    • 그렇지 않다. 코드 커버리지는 단지 "코드가 실행되었는가"만을 측정할 뿐, "기능이 올바르게 동작하는가"는 알 수 없다.
    • 코드 커버리지 100%는 모든 줄이 실행되었음을 의미할 뿐, 그 줄에서의 결과가 옳은지, 기대한 동작을 했는지는 확인하지 않음.
    • 테스트에 검증(assert)이 빠져있거나, 잘못된 시나리오를 기반으로 작성된 경우에도 커버리지는 올라갈 수 있음.
    • 특히 엣지 케이스, 예외 처리, 의도하지 않은 부작용 등은 단순 커버리지 수치로는 잡히지 않음.

    🧠 예시:

    @Test
    public void testAdd() {
        Calculator calc = new Calculator();
        calc.add(1, 2); // 실행은 되지만 결과를 확인하지 않음
    }
    

    위 테스트는 실행만 하고, 결과를 검증하지 않아도 커버리지가 올라가지만, 테스트로서의 의미는 거의 없음.


3. 📚 참고 사항

📢 논의하기

1. 관련 자료 공유

2. 논의하고 싶은 주제
  • 주제
    기존의 ComparisonCompactor 충분한거 같은데 개선을 하였다.
    개선의 이유를 어디서 찾은것인가?
  • 설명
    필요성에 의한 개선이 아니라 굳이 꼬치꼬치 따져가면서까지 개선을 해야하는가?