실용주의 프로그래머를 위한 단위테스트 With JUnit

‘단위 테스트’는 테스트 대상이 되는 코드 기능의 아주 작은 특정 영역을 실행해 보는, 개발자가 작성한 코드 조각이다. 대개 단위 테스트는 특정 상황에서 특정메서드를 시험해 본다.

단위 테스트는 어떤 코드 조각이 개발자가 생각한 대로 동작하는지 증명하기 위해 수행하는 것이다.

  • 테스트의 6가지 영역
    1. 결과가 옳은가?
      우선 분명하게 테스트해야 할 영역은 바로 예상한 결과가 옳은지 살표보는 것, 즉 결과의 유효성 검사를 하는 것이다.
    2. 모든 경계 조건이 CORRECT한가?
      1. 형식 일치(Conformance) – 값의 형식이 기대한 형식과 일치하는가?
      2. 순서(Ordering) – 적절히 순서대로 되어 있거나 그렇지 않은 값인가?
      3. 범위(Range) – 적당한 최소값과 최대값 사이에 있는 값인가?
      4. 참조(Reference) – 코드가 자기가 직접 제어하지 않는 외부 코드를 참조하는가?
      5. 존재성(Existence) – 값이 존재하는가?(예: null이 아님, 0이 아님, 집합 안에 존재함 등)
      6. 개체 수(Cardinality) – 확실히 충분한 값이 존재하는가?
      7. 시간(Time)(절대적으로, 그리고 상대적으로) – 모든 것이 순서대로 일어나느가? 제시간에? 때맞추어?
    3. 역 관계를 확인할 수 있는가?
      몇몇 메서드는 논리적 역을 적용하여 검증해 볼 수 있다. 예를 들어 제곱근을 계산하는 메서드를 검증할 때에는 결과를 제곱한 값이 원래 값과 오차 한계 안에서 같은지 테스트하면 된다.
    4. 다른 수단을 사용해서 결과를 교차 확인 할 수 있는가?
      다른 수단을 이용해서 메서드의 결과를 교차 확인 할 수도 있다. 보통 어떤 값을 계산할 때에는 방법이 한 가지만 있는 건 아니다. 어떤 한 알고리즘을 선택하는 이유는 그것이 더 좋은 성능을 내거나, 또는 그밖에 다른 좋은 특성이 있기 때문이다. 제품에는 당연히 제일 좋은 것을 사용하겠지만, 테스트 시스템에서는 결과를 교차 확인하기 위해 다른 알고리즘을 사용해 볼 수도 있다. 이 기법은 어떤 일을 수행하기 위한 알려진 방법이 있는데, 제품 코드에 사용하기에는 지나치게 느리거나 유연성이 없는 경우에 특히 유용하다.
    5. 에러 조건을 강제로 만들어 낼 수 있는가?
      현실 세계에서는 에러가 발생한다. 디스크는 꽉 차고, 네트워크는 끊어지고, 이메일은 블랙홀로 사라지고, 프로그램은 갑자기 멈춘다. 여러분은 강제로 에러를 일으켜 코드가 이 현실 세계의 문제들을 제대로 처리한다는 것을 테스트 할 수 있어야 한다.
    6. 성능 특성이 한도 내에 있는가?
      성능 그 자체가 아니라 입력 양이 많아지고, 문제가 복잡해지면서 성능이 변하는 경향을 말하는 것이다.
  • CORRECT 경계 조건
    버그는 경계 조건 근처, 즉 그 코드가 평소의 루틴과 다르게 동작하는 조건에서 많이 발생한다. 예를 들어, 정수 인자를 두 개 받는 함수를 생각해 보자.

    public int calculate(int a, int b) {
      return a / (a + b);
    }

    이 코드는 열에 아홉은 기대한 숫자를 반환할 것이다. 하지만 a와 b의 합이 0이 되면, 반환값 대신 ?ArithmeticException이 생긴다. 이것이 ‘경계 조건’이다. 갑자기 뭔가 잘못되거나, 적어도 기대한 것과는 다르게 동작하는 상황 말이다.

    1. 형식 일치(Conformance) 많은 경우 여러분은 어떤 특정한 형식을 따르는 데이터를 기대하거나 만들어 낸다. 한 예로, 이메일 주소는 단순한 문자열이 아니다. 일정 형식을 지켜야 할 것이다.
    2. 순서(Ordering) 고려할 또 하나의 영역은 큰 데이터 모음에서 데이터의 순서나 데이터의 한 부분의 위치다. 이것은 순서 영역에서 중요한 부분이다. 어떤 검색 루틴이라 해도 반드시 대상이 시작 부분이나 끝 부분에 있는 조건을 테스트 받아야 한다. 많은 버그들을 이런 식으로 찾아낼 수 있다.
    3. 범위(Range) 범위는 어떤 변수형이, 필요하거나 원하는 값보다 범위를 허용하는 상황을 포괄적으로 함축하는 단어다. 좋은 객체지향 설계에서는 나이나 범위처럼 한계가 있는 정수형 값을 저장할 때 있는 그대로의 기본형을 사용하지 않는다.
    4. 참조(Reference) 메서드가 자기 영역을 벗어난 어떤 것들을 참조하는가? 외부 의존성이 있는가? 클래스가 가져야 하는 상태는? 그 메서드가 제대로 동작하려면 그밖에 어떤 조건을 갖춰야 하는가?
    5. 존재성(Existence) 다음과 같은 핵심 질문을 던짐으로써 수많은 잠재 버그를 발견할 수 있다. “주어진 것이 존재하는가?” 넘겨받거나 가지고 있는 모든 값에 대해, 그 값이 존재하지 않는다면, 즉 null이거나 비었거나 0이라면 그 메셔드에 어떤 일이 일어날지 자문해 보라. 여러분의 메서드가 무를 확실히 이겨낼 수 있게 하라.
    6. 개체 수(Cardinality)
    7. 시간(Time)
  • 모의 객체 사용하기
    단위 테스트의 목표는 한번에 메서드 하나만을 실행해 보는 것인데, 그 메서드가 다른 것들, 즉 네트워크나 데이터베이스나 서블릿 엔진처럼 제어하기 어려운 것들에 의존한다면 어떻게 될까?코드가 시스템의 다른 부분, 아마도 굉장히 많을 다른 부분들에 의존한다면? 충분한 주의를 기울이지 않는다면, 그저 테스트가 돌아갈 상황을 만들어 주기위해 시스템의 거의 모든 컴포넌트를 초기화해야 하는 상황에 이른 자신을 발견하게 되는 것이다. 이것은 시간을 잡아먹을 뿐 아니라, 테스트 과정에서 우스꽝스럽기까지 한 중복 작업을 초래한다. 누군가 인터페이스나 데이터베이스 테이블을 수정하기라도 하면, 갑자기 그 보잘것없는 단위 테스트를 위한 준비 설정 코드가 영문 모르게 죽어 버린다. 가장 의욕이 넘치는 개발자라 해도 이런 일이 몇 번 일어나면 일할 의욕을 잃게 되고, 결국 모든 테스트를 포기해 버릴 것이다. 하지만 도움이 될 수 있는 기법이 몇 가지 있다.

    1. 간단한 스텁
      우리가 해야 하는 일은 실제 세계에서 비협조적인 모든 부분들을 추려내어 각각 더 친밀한 아군인 우리가 직접 만든 ‘조명 대역’으로 교체하는 것이다. 보통 실제로 진짜 데이터베이스나 진짜 총 계산 시간을 가지고 테스트하고 싶지는 않을 것이다. 간단한 예를 살펴보자.코드에서 현재 시간을 반환하기 위해 getTime()이라는 사용자 정의 메서드를 호출한다고 생각해 보자. 이 메서드는 다음과 비슷하게 정의되어 있을 것이다.

      public long getTime() {
        return System.currentTimeMillis();
      }

      이런 코드는 여러분이 직접 작성한 메서드에 현재 시간의 개념이 들어있기 때문에, 다음과 같이 수정해서 디버깅을 더 쉽게 만들 수 있다.

      public long getTime() {
        if (debug) {
          return debug_cur_time;
        } else {
          return System.currentTimeMillis();
        }
      }

      이렇게 하면, 직접 발생시키지 않고서는 무작정 기다려야 할 이벤트들을 발생시키기 위해 시스템의 ‘현재 시간’ 개념을 조작하는 또 다른 디버깅 루틴들도 만들 수 있을 것이다.

      이것은 진짜 기능을 대신할 대역을 만들어 내는 한 방법이지만, 너무 난잡하다. 무엇보다 이런 방법은 코드가 계속 getTime()을 호출하고 자바의 System.currentTimeMillis()를 직접 호출하지 않는 경우에만 제대로 통한다. 우리에게 필요한 것은 똑같은 일을 해내면서 더 깔끔하고, 더 객체지향적인 방법이다.

    2. 모의 객체
      다행히, 유용한 테스트 패턴이 하나 있다. 바로 모의 객체(mock object)다. 모의 객체는 디버깅하기 위해 쓴느 실세계 객체의 대용물이다. 모의 객체가 우리를 도와줄 수 있는 상황은 꽤 많다. 팀 맥키논은 다음과 같은 상황 목록을 제안한다.

      • 진짜 객체가 비결정적인 동작을 한다.(예상할 수 없는 결과를 만들어 낸다. 주식 시장의 시세를 알려주는 프로그램과 마찬가지로 말이다.)
      • 진짜 객체를 준비 설정하기 어렵다.
      • 진짜 객체가 직접 유발시키기 어려운 동작을 한다.(예를 들면, 네트워크 에러가 있다.)
      • 진짜 객체가 느리다.
      • 진짜 객체가 사용자 인터페이스를 가지거나, 사용자 인터페이스 자체다.
      • 테스트가 진짜 객체에게 그것이 어떻게 사용되었는지 물어보아야 한다.(예를 들면, 어떤 테스트는 콜백 함수가 정말 호출되었는지 확인해야 할 수도 있다.)
      • 진짜 객체가 아직 존재하지 않는다.(다른 팀이나 새로운 하드웨어 시스템과 함계 일할 때 흔한 문제다.)

      모의 객체를 사용하면 이 모든 문제를 해결할 수 있다. 테스트에서 모의 객체를 사용하기 위한 핵심 세 단계는 다음과 같다.

      1. 객체를 설명하기 위해 인터페이스를 사용한다.
      2. 제품 코드에 맞게 그 인터페이스를 구현한다.
      3. 테스트에 쓸 모의 객체의 인터페이스를 구현한다.

      테스트 대상이 되는 코드는 항상 인터페이스로 객체를 참조할 뿐이다. 따라서 이 코드는 모르는 게 약이듯이, 진짜 객체를 사용하는지 모의 객체를 사용하는지 모르는 채로 있어도 된다. 앞에서 다뤘던 시간에 관한 예를 다른 측면에서 살표보자. 처음에 실제 세계의 몇 가지 환경적 개념을 위한 인터페이스를 만들텐데, 그 개념들 중 하나는 현재 시간이다.

      public interface Environmental {
        public long getTime();
      }

      다음으로, 진짜로 쓸 부분을 구현한다.

      public class SystemEnvironment implements Environmental {
        public long getTime() {
          return System.currentTimeMillis();
        }
      }

      마지막으로 모의 구현을 한다.

      public class MockSystemEnvironment implements Environmental {
        public long getTime() {
          return current_time;
        }
        public void setTime(long aTime) {
          current_time = aTime;
        }
        private long current_time;
      }

      이 모의 구현에서, 모의 객체를 제어하게 해주는 추가 메서드 setTime()을 집어넣었음에 주의하라.

      이제 getTime() 메서드에 의존하는 새 메서드를 작성했다고 생각해 보자. 구체적인 부분은 어느 정도 생략되었지만, 관심을 두어야 할 부분은 다음과 비슷할 것이다.

      import java.util.Calendar;
      
      public class Checker {
        public Checker(Environmental anEnv) {
          env = anEnv;
        }
        public void reminder() {
          Calendar cal = Calendar.getInstance();
          cal.setTimeInMillis(env.getTime());
          int hour = cal.get(Calendar.HOUR_OF_DAY);
      
          if (hour >= 17) {
            env.playWavFile("quit_whistle.wav");
          }
        }
      
        private Environmental env;
      }

      고객에게 배달되는 진짜 코드를 작성하는 제품 환경에서, 이 클래스의 객체는 진짜 SystemEnvironment를 넘겨받아 초기화 될 것이다. 그 반면 테스트 코드는 MockSystemEnvironment를 쓴다.

      env.getTime()을 사용하는 코드는 테스트 환경과 진짜 환경의 차리를 모른다. 두 환경이 모두 같은 인터페이스를 구현하기 때문이다. 이제 시간을 알려진 값으로 설정하고 난 다음 기대하는 동작을 하는지 확인함으로써 이 모의 객체를 활용하는 테스트를 만들 수 있다.

      Environmental 인터페이스는 앞에서 보인 getTime() 호출뿐 아니라, playWavFile() 메서드 호출도 지원한다. 이 모의 객체에 짧은 지원 코드를 더 집어넣기만 하면, 컴퓨터 스피커에 귀 기울일 필요없이 playWavFile() 메서드가 호출되었는지 확인하는 테스트를 추가할 수도 있다.

      public void playWavFile(String filename) {
        playedWav = true;
      }
      public boolean wavWasPlayed() {
        return playedWav;
      }
      public void resetWav() {
        playedWav = false;
      }
      private boolean playedWav = false;

      이 모든 것을 합친 테스트는 생김새가 다음과 비슷할 것이다.

      import junit.framework.*;
      import java.util.Calendar;
      
      public class TestCheck extends TestCase {
        public void testQuittingTime() {
          MockSystemEnvironment env = new MockSystemEnvironment();
      
          /* 테스트할 시간을 설정한다. */ 
          Calendar cal = Calendar.getInstance();
          cal.set(Calendar.YEAR, 2004);
          cal.set(Calendar.MONTH, 10);
          cal.set(Calendar.DAY_OF_MONTH, 1);
          cal.set(Calendar.HOUR_OF_DAY, 16);
          cal.set(Calendar.MINUTE, 55);
          long t1 = cal.getTimeInMillis();
      
          env.setTime(t1);
      
          Checker checker = new Checker(env);
      
          /* cechker를 실행하여 확인한다. */ 
          checker.reminder();
      
          /* 아직 아무것도 재생되지 않아야 한다. */ 
          assertFalse(env.wavWasPlayd());
      
          /* 시간을 5분 증가시킨다. */ 
          t1 += (5 * 60 * 1000);
          env.setTime(t1);
      
          /* checker를 실행하여 확인한다. */ 
          checker.reminder();
      
          /* 재생된 적이 있어야 한다. */ 
          assertTrue(env.wavWasPlayed());
      
          /* 플래그를 리셋해서 다시 시도할 수 있게 만든다. */ 
          env.resetWav();
      
          /* 시간을 2시간 증가시키고 난 다음 다시 확인한다. */ 
          t1 += 2 * 60 * 60 * 1000;
          env.setTime(t1);
      
          checker.reminder();
          assertTrue(env.wavWasPlayed());
        }
      }

       

    3. 서블릿 테스트
      서블릿은 웹 서버가 관리하는 코드 덩어리다. 특정 URL에대한 요청은 자카르타 톰캣과 같은 서블릿 컨테이너로 포워딩되고, 이번에는 이 컴테이너가 서블릿 코드를 실행한다. 그러면 이 서블릿은 요청을 보낸 브라우저로 돌려보낼 응답을 생성한다. 최종 사용자의 관점에서는 그냥 여타 다른 페이지에 접근하는 것과 같다.아래 리스트는 화씨 단위에서 섭씨 단위로 온도를 변환하는 작은 서블릿 소스 일부를 보여주고 있다. 이 코드의 동작을 빠르게 살펴보자.

      public void doGet(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException
      {
        String str_f = req.getParameter("Fahrenheit");
      
        res.setContentType("text/html");
        PrintWriter out = res.getWriter();
      
        try {
          int temp_f = Integer.parseInt(str_f);
          double temp_c = (temp_f - 32) * 5.0 / 9.0;
          out.println("Fahrenheit: " + temp_f + ", Celsius: " + temp_c);
        } catch (NumberFormatException e) {
          out.println("Invalid temperature: " + str_f);
        }
      }

      이 코드 조각은 상당히 복잡한 환경에서 돌아간다. 웹 서버와 서블릿 컨테이너가 필요하고, 사용자가 브라우저 앞에 앉아 상효 작용을 해야 한다. 이것이 자동화된 좋은 단위 테스트의 기본을 갖췄다고 보기 어렵다. 모의 객체가 구하러 간다.

      서블릿 코드의 인터페이스는 아주 단순하다. 앞에서 언급한 바 있듯이, 요청과 응답이라는 두 매개 변수를 받는다. 요청 객체는 자신의 getParameter() 메서드가 호출될 때 적당한 문자열을 내어 줄 수 있어야 하고, 응답 객체는 setContentType()과 getWriter()를 지원해야 한다.

      HttpServletRequest와 ?HttpServletResponse 모두 인터페이스이기 때문에 우리가 해야 할 일은 그저 이 인터페이스를 구현하는 클래스 두 개를 재빨리 뽑아내는 것뿐이고, 그러면 일은 다 된 것이다. 안타깝게도 인터페이스를 보면, 코드를 컴파일하려고만 해도 메서드를 몇십 개나 구현해야 함을 깨닫게 될 것이다. 이는 앞에서 보았던, 조금은 부자연스러운 시간/wav 파일 예만큼 쉬빚 않은 일이다. 다행스럽게도, 다른 친구들이 이미 이 어려운 일을 끝내 놓았다.

      맥키넌, 프리먼, 크레이그는 모의 객체의 형식화를 창안했고, 자파 프로그래머를 위한 모의 객체 프레임워크 코드(http://www.mockobjects.com)도 개발해 놓았다. 이 모의 객체 패키지는 모의 객체를 개발하는 일을 더 편하게 만들어 주는 기본 프레임워크 코드뿐 아니라, 상당수의 애플리케이션 수준 모의 객체들을 제공한다.

      여기에는 모의 출력 객체들, java.sql 라이브러리를 흉내내는 객체들과, 서블릿 환경에서 테스트할 때 쓰는 클래스들이 있다. 특히, 이 놀아운 우연으로 우리가 테스트하려는 메서드의 매개 변수 유형인 ?HttpServletRequest와 ?HttpServletResponse를 흉내낸 버전도 제공하고 있다.

      예전의 예제에서 시간을 가짜로 설정했던 것처럼, 이 패키지를 이용해서 테스트를 조작할 수 있다.

      import junit.framework.*;
      import com.mockobjects.servelt.*;
      
      public class TestTempServlet extends TestCase {
        public void test_bad_parameter() throws Exception {
          TemperatureServlet s = new TemperatureServlet();
          MockHttpServletRequest request = new MockHttpServletRequest();
          MockHttpServletResponse response = new MockHttpServletResponse();
      
          request.setupAddParameter("Fahrenheit", "boo!");
          response.setExpectedContentType("text/html");
          s.doGet(request, response);
          response.verify();
          assertEquals("Invalid temperature: boo!\n", response.getOutputStreamContents());
        }
      
        public void test_boil() throws Exception() {
          TemperatureServlet s = new TemperatureServlet();
          MockHttpServletRequest request = new MockHttpServletRequest();
          MockHttpServletResponse response = new MockHttpServletResponse();
      
          request.setupAddParameter("Fahrenheit", "212");
          response.setExpectedContentType("text/html");
          s.doGet(request, response);
          response.verify();
          assertEquals("Fahrenheit: 212, Celsius: 100.0\n", response.getOutputStreamContents());
        }
      }
  • 좋은 테스트의 특징
    1. 자동적(Automatic)
      단위 테스트는 자동적으로 실행되어야 한다. ‘자동적으로’라는 말은 최소 두 가지 경우, 테스트를 실행하는 경우와 결과를 확인하는 경우를 모두 의미하는 것이다.
    2. 철저함(Thorough)
      좋은 단위 테스트는 철저하다. 문제가 될 수 있는 모든 것을 테스트한다. 그런데 얼마나 철저하게 해야 할까? 이것은 프로젝트에서 어떤 요구가 생기느냐에 기반을 두고 판단할 문제다.
    3. 반복 가능(Repeatable)
      모든 테스트는 다른 테스트들로부터 독립적이어야 하는 것과 마찬가지로, 환경으로부터도 독립적이어야 한다. 모든 테스트가 어떤 순서로든 여러 번 반복 실행될 수 있어야 하고, 그때마다 ‘늘 같은 결과를 내야 한다’는 목표도 변함없다. 이것은 테스트가 프로그그래머의 직접 제어 아래 있지 않은 외부 호나경에 의존해서는 안 된다는 것을 의미한다.반복 가능성을 갖추지 않는다면, 최악의 순간에 뜻밖의 일을 당하는 상황을 면할 수 없을지도 모른다. 더 나쁜 것은, 이 뜻밖의 일이란 것이 보통 가짜라는 것이다. 이것은 진짜 버그가 아니라, 테스트와 관련된 문제일 뿐이다. 이 ㅇ류령 같은 문제들을 찾아내기 위해 시간 낭비를 할 만한 여유는 없다.

      각 테스트는 늘 같은 결과를 내야만 한다. 만약 그렇지 않다면, 코드에 ‘진짜’ 버그가 있다고 알려 주는 신호임에 틀림없다.

    4. 독립적(Independent)
      테스트는 깔끔함과 단정함을 유지해야 한다. 즉, 확실히 한 대상에 집중한 상태여야 하며, 환경과 다른 개발자들에게서 독립적인 상태를 유지해야 한다.테스트를 작성할 때는 그 시간에 그 한 가지를 본인만이 테스트하고 있다는 것을 확실히 하라.

      이것은 한 테스트에서 단정 메서드를 단 하나만 사용해야 한다는 뜻이 아니라, 한 테스트 메서드는 제품 메서드 한 개나, 또는 어떤 기능을 함께 제공하는 제품 메서드의 작은 집합 한 개에 집중해야 한다는 뜻이다.

      때로 한 테스트 메서드가 복잡한 어떤 제품 메서드의 일부분만을 테스트하는 경우도 있다. 이때에는 이 제품 메서드를 전부 테스트해 보기 위하여 테스트 메서드가 여러 개 필요할 것이다.

    5. 전문적(Professional)
      단위 테스트를 위해 작성하는 코드는 진짜다. 이것은 단위 테스트 코드가 제품 코드와 마찬가지로 전문적 표준을 유지하면서 작성되어야 한다는 것을 의미한다. 좋은 설계를 위한 모든 일반적인 규칙, 캡슐화 유지, DRY 원칙 지키기, 결합도 낮추기 등은 제품 코드에서 그랬듯이 테스트 코드에서도 반드시 지켜져야 한다.테스트 코드는 진짜 코드와 같은 식으로 작성되어야 한다. 이는 공통되고 반복되는 부분을 뽑아내고 그 기능 부분을 메서드 하나에 집어넣어, 다른 여러 장소에서 호출할 수 있게끔 만든다는 의미다.

      여러분에게 도움이 되지 않는 부분을 테스트하는 데 시간을 낭비하지 마라. 테스트 코드는 버그를 가지고 있을 만한 메서드에서 신경 쓰이는 모든 것을 테스트해야 한다는 점에서 철저해야 한다. 버그를 가지고 있을 만하지 않다면, 굳이 테스트할 필요 없다.

      마지막으로, 테스트 코드가 적어도 제품 코드만큼 있을 거라고 생각하라.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 항목은 *(으)로 표시합니다