켄트 벡의 TDD("테스트 주도 개발")에 보면 Junit 구현에 Pluggable Selector라는 패턴을 사용했다는 내용이 나온다. 책에서 언급된 내용은 코드보다는 말로 설명되어 있고, 아래 링크에 가면 코드가 어느 정도 나와 있다.

JUnit A Cook's Tour


하지만 안타깝게도 테스트 케이스를 수행하기 위한 객체 생성 부분이 없어서 상상력을 발휘해서 Pluggable Selector 패턴을 구현해 볼까 한다. 혹시 이 글을 보고 실제 Junit이 이렇게 구현되어 있다고 생각하지는 말길 바란다. 여기에 나오는 것은 Pluggable Selector 패턴을 설명하기 용이하게 만들어진 예제에 불과하다.


Pluggable Selector 패턴이란?

첫번째 예는 Unit Test Framework인 Junit3에 관한 것이다(Junit4 부터는 annotation 기반으로 바뀌었기 때문에 외부 구현 상에서 유사점을 찾기 힘들 것이다.) Junit3에서 Unit Test를 작성하기 위해서는 TestCase 라는 클래스를 상속 받아야 한다. 필요한 경우 setUp() 함수나 tearDown() 함수를 재정의 해야 한다.(여기까지는 Pluggable Selector 패턴과 별 상관이 없다.) Pluggable Selector와 관련이 있는 부분은 테스트 함수 부분인데, Junit3에서는 void testNAME() 형식의 함수를 찾아 실행하도록 되어 있다. 더욱이 중요한 부분은 각 테스트 함수들을 실행 할 때 이전 테스트와의 의존성을 배제하기 위해서 테스트 객체를 매번 다시 생성해서 실행 시킨다는 점이다.

보다 정확한 설명을 위해서 우리가 동작 시켜볼 테스트 클래스를 살펴 보도록 하자. 문제를 명확하게 하기 위해서 불필요하게 TestCase를 상속 받는 부분을 제외하였다.

class UnderTest{

    public void setUp(){

        System.out.println("setUp()");

    }

   

    public void testCase1(){

        System.out.println("testCase1()");

    }

   

    public void testCase2(){

        System.out.println("testCase2()");

    }

   

    public void tearDown(){

        System.out.println("tearDown()");

    }

}

위와 같은 테스트 클래스를 Junit3에서 실행 시키면 아래와 같은 순서로 실행이 된다.

(UnderTest 클래스 객체의 생성)

setUp()

    testCase1()

tearDown()


(또 다른 UnderTest 클래스 객체의 생성)

setUp()

    testCase2()

tearDown()


() 안의 동작은 Junit3 프레임 워크 내부에서 수행해 주는 일이다. 반복적인 테스트가 수행되는데 다른 점은 첫번째에는 testCase1()이 실행되고, 두번째는 testCase2()가 실행된다는 점이다. 이 부분이 Pluggable Selector 패턴의 핵심이다. 클래스 상에서 test로 시작하는 테스트 함수들을 모두 찾고, 실행시켜 주는 것이다.


Pluggable Selector  패턴에 대한 좀 더 범용적인 이해를 돕기 위해 다른 예를 들어 보겠다. 상위 클래스를 상속 받은 여러 하위 클래스들이 있다고 하자. 이들 클래스는 상위 클래스와 대부분 비슷하지만 모두 각자 (상위 클래스로부터 상속 받지 않은) 다른 메소드 하나씩을 가지고 있다고 하자. 이 때 상위 클래스에서 제공하지 않는 메소드들을 한번씩 실행 시켜야 한다면 어떻게 해야 할까? 이럴 때 사용하는 것이 Pluggable Selector 패턴이다.


다시 Junit3 예제로 돌아가보자. Java에서 이를 구현하는 방법은 Reflection을 이용하는 것이다. 특히 Reflection을 이용하면 테스트 함수들을 찾고, 객체를 생성하고, 함수별로 실행 시켜주는 과정을 모두 자동화 할 수 있다.


테스트 대상 클래스는 이미 위에서 만들었으니 Pluggable Selector 패턴으로 구현된 JUnit 클래스를 만들어 보자. JUnit 클래스는 테스트 클래스 등록(addTest())과 테스트의 실행(runTest()) 단계로 크게 구분될 수 있다. 우선 addTest()부터 구현해 보자.

class JUnit{

    Map<String, Class> map = new HashMap<String, Class>();

   

    public void addTest(Class clazz){

        Method[] methods = clazz.getDeclaredMethods();

        for(Method method : methods){

            method.setAccessible(true);

   

            if(method.getName().startsWith("test")){

                map.put(method.getName(), clazz);

            }

        }

    }

Reflection으로 구현되어 있기 때문에 약간 생소할 수도 있다. 먼저 addTest() 함수의 매개변수는 클래스 객체이다. 여기에 우리는 테스트 대상 클래스인 TestClass.class를 매개변수로 넣을 것이다.

다음으로 Method[]를 추출해 내는 작업을 수행한다. 클래스 객체에서 getDeclaredMethods() 함수를 호출하면 해당 클래스가 선언하고 있는 모든 메소드들을 배열 형태로 리턴해 준다.

이제 각 Method를 대상으로 for 문을 수행하는데, 맨 처음 호출되는 것은 method.setAccessible(true)이다. 이것은 메소드가 public이 아닐 경우에도 접근이 가능하도록 설정해 주는 것이다.

그 다음에는 method.getName()을 이용해서 메소드의 이름을 체크한다. 여기서는 JUnit3과 마찬가지로 test로 시작하는 함수를 추출한다. 그리고 만약 test로 시작하는 함수가 있다면 이 메소드 이름을 key로 사용하는 map에 메소드 이름과 클래스 객체를 값으로 하여 집어 넣게 된다.

이 과정을 마치고 나면 모든 test로 시작하는 메소드 이름 : 클래스 객체의 맵이 만들어지게 된다. 이 맵은 이제 만들어 볼 runTest() 메소드에서 사용된다.

우선 runTest() 메소드에서 해야 할 일을 다시 떠올려 보자. 첫번째 테스트 함수를 테스트 하기 위해서 테스트 객체를 생성하고, setUp() 함수를 호출하고, test 함수를 호출하고 tearDown() 함수를 호출한다. 다음 테스트 함수에 대해서도 마찬가지이다.(이 중에서 setUp() 함수와 tearDown() 함수의 호출 이유는 그다지 중요하지 않으니 Unit Test를 모르는 분들은 무시해도 된다.)

그럼 구현을 살펴보자.

    public void runTest(){

        for(String testName : map.keySet()){

            Class clazz = map.get(testName);

            try {

                Object testObject = clazz.newInstance();


                Class[] parameterTypes = new Class[0];

                Method setUp = clazz.getDeclaredMethod("setUp", parameterTypes);

                Method tearDown = clazz.getDeclaredMethod("tearDown", parameterTypes);

                Method testMethod = clazz.getDeclaredMethod(testName, parameterTypes);

                Object[] parameters = new Object[0];

                setUp.invoke(testObject, parameters);

                testMethod.invoke(testObject, parameters);

                tearDown.invoke(testObject, parameters);

            } catch (InstantiationException e) {

            } catch (IllegalAccessException e) {

            } catch (SecurityException e) {

            } catch (IllegalArgumentException e) {

            } catch (InvocationTargetException e) {

            } catch (NoSuchMethodException e) {

            }

        }

    }

우선 맵의 키 값(여기서는 test 함수 이름을 나타내는 String 객체) 이용하여 for문을 수행한다. 실제 테스트를 수행하려면 우선 객체가 필요하다. 그래서 맵에서 test 함수의 클래스 객체를 가지고 온 다음, testObject라는 이름으로 newInstance() 함수를 호출해서 객체를 생성한다. 클래스 객체를 이용하면 이와 같이 객체의 생성도 수행할 수 있다.

그 다음에는 setUp(), tearDown() 메소드를 클래스 객체로부터 얻어 온다. 마지막으로 테스트할 메소드도 가지고 온다.

실제로 이 메소드들을 실행하는 방법은 Method 객체에 구현된 invoke() 함수를 이용하는 것이다. 이 때 invoke는 특정 객체(위 코드에서는 testObject)의 것을 호출하는 것이다. 따라서 Method 객체는 형식만 지정하는 역할을 하고, 실제 수행되는 객체인 testObject를 매개 변수로 넣어 주어야 한다. 파라메터는 없으므로 비어 있는 Object[] 객체를 넣어 주면 된다.

전체 구현 내용을 종합해 보면 다음과 같다.


최종 결과

class JUnit{

    Map<String, Class> map = new HashMap<String, Class>();

   

    public void addTest(Class clazz){

        Method[] methods = clazz.getDeclaredMethods();

        for(Method method : methods){

            method.setAccessible(true);

   

            if(method.getName().startsWith("test")){

                map.put(method.getName(), clazz);

            }

        }

    }

   

    public void runTest(){

        for(String testName : map.keySet()){

            Class clazz = map.get(testName);

            try {

                Class[] parameterTypes = new Class[0];

                Object testObject = clazz.newInstance();

                Method setUp = clazz.getDeclaredMethod("setUp", parameterTypes);

                Method tearDown = clazz.getDeclaredMethod("tearDown", parameterTypes);

                Method testMethod = clazz.getDeclaredMethod(testName, parameterTypes);

                Object[] parameters = new Object[0];

                setUp.invoke(testObject, parameters);

                testMethod.invoke(testObject, parameters);

                tearDown.invoke(testObject, parameters);

            } catch (InstantiationException e) {

            } catch (IllegalAccessException e) {

            } catch (SecurityException e) {

            } catch (IllegalArgumentException e) {

            } catch (InvocationTargetException e) {

            } catch (NoSuchMethodException e) {

            }

        }

    }

}


이제 실행을 시켜 보도록 하자.

public static void main(String[] args) {

    JUnit junit = new JUnit();

    junit.addTest(UnderTest.class);

    junit.runTest();

} 

위와 같이 실행 코드를 작성하고 실행 시켜 보면 아래와 같이 출력된다.

setUp()

testCase2()

tearDown()

setUp()

testCase1()

tearDown()

의도한 대로 각 테스트 함수들이 잘 실행되는 것을 알 수 있다.

JUnit 처럼 테스트 대상 클래스가 어떤 명칭의 테스트 함수를 구현하게 될지 알 수 없을 때에 Pluggable Selector 패턴은 매우 적절한 선택이다. 소프트웨어를 개발하다 보면 향후에 확장을 대비해서 상당한 수준의 자유도를 미리 확보해야 하는 경우가 종종 있는데, 이런 경우에 사용하면 좋을 것이다.

'5.디자인패턴' 카테고리의 다른 글

Adapter 패턴  (0) 2016.08.23
Decorator 패턴(synchronizedList의 구현 패턴)  (0) 2016.08.23
State 패턴  (0) 2016.08.15
Strategy 패턴  (0) 2016.08.15
Telescoping Parameter 패턴  (0) 2016.08.13
Posted by 이세영2
,