저작권 안내: 저작권자표시 Yes 상업적이용 No 컨텐츠변경 No

스프링4 입문

스프링 4

DDD Start

객체 지향과
디자인 패턴

JSP 2.3

JPA 입문

이전 글(JUnit 5 소개 http://javacan.tistory.com/463)에 이어 Jupiter API에 대해 좀 더 알아보자. 이 글에서 알아볼 내용은 Assume, @Nested, 태깅에 대한 것이다.


Assumption을 이용한 상황에 따른 테스트 실행


로컬 개발 PC에서만 테스트를 실행하거, 특정 OS에서만 테스트를 실행하고 싶을 때가 있을 것이다. 이럴 때 사용할 수 있는 것이 Assumptions이다. 아래 코드를 보자.


import static org.junit.jupiter.api.Assumptions.assumeTrue;


public class AssumptionTest {

    @Test

    void runTest_IfWindonw() {

        assumeTrue(System.getProperty("os.name").startsWith("Windows"));

        assertEquals(1, 2);

    }


    @Test

    void runTest_IfLinux() {

        assumeTrue(() -> System.getProperty("os.name").startsWith("Linux"));

        assertEquals(1, 2);

    }

}


Assumptions.assumeTrue()는 인자로 전달받은 값이 true이면 이후 테스트를 진행하고, 그렇지 않으면 테스트를 생략한다. assumeTrue()에 전달한 값이 false인 경우 테스트를 생략하는 것이지 해당 테스트가 실패하는 것은 아니다.


두 번째 테스트를 보면 assumeTrue()에 람다식을 전달하고 있다. 이 람다식은 결과를 boolean을 리턴하는데, 이 결과 값에 따라 테스트를 진행하거나 생략한다.


assumeFalse()의 경우는 해당 가정이 false인 경우에만 테스트를 진행한다.


assumeTrue()와 assumeFalse()는 가정이 충족하지 않으면 테스트 메서드 전체를 생략하는데, assumingThat()은 지정한 가정을 충족한 경우 지정한 검증을 수행한다. 다음은 사용 예이다.


    @Test

    void runTest() {

        String osName = System.getProperty("os.name");

        assumingThat(

                osName.startsWith("Linux"), // (1) 가정 boolean 또는 BooleanSupplier

                () -> assertEquals(1, 2) // (2) 가정을 충족할 때 실행할 코드(Executable 타입)

        );

        assertEquals(1, 1); // (3)

    }


assumingThat()의 첫 번째 인자의 값이 true이면, 두 번째 인자로 받은 검증을 수행한다. 위 코드의 경우 OS 이름이 Linux로 시작하는 경우 assertEquals(1, 2) 코드를 실행한다. 위 테스트를 윈도우에서 실행하면 assumingThat()의 (1)이 false이므로, (2)의 검증 코드를 실행하지 않으므로 테스트에 통과한다. 반면에 위 테스트를 리눅스에서 실행하면 (1)이 true이므로 (2)를 실행하고 그 결과 테스트에 실패한다.


@Nested를 이용한 중첩 구성


@Nested를 사용하면 중첩된 구조로 테스트를 구성할 수 있다. 기존에는 JUnit과 다른 도구를 함께 사용해야  중첩 구조를 가진 테스트를 구성할 수 있었는데 이제 Jupiter API 만으로 중첩 구조 테스트를 작성할 수 있다.


@Nested를 사용할 때의 개략적인 구조는 다음과 같다.


@DisplayName("중첩 구조 테스트")

public class AuthenticatorTest {

    private Authenticator authenticator = new Authenticator();

    private MemoryUserRepository mockRepository = new MemoryUserRepository();


    @BeforeEach

    void setUp() {

        authenticator.setUserRepository(mockRepository);

    }


    @Test

    void when_Use_BadParam_Then_IAE_Thrown() {

        assertThrows(IllegalArgumentException.class,

                () -> authenticator.authenticate(null, null));

    }


    @Nested

    public class GivenNoUser {

        @BeforeEach

        void givenNoUser() {

            mockRepository.clear();

        }


        @Test

        void when_Auth_Then_Fail() {

            ...검증코드

        }

    }


    @Nested

    public class GivenUser {

        private String userId = "user";


        @BeforeEach

        void givenUser() {

            mockRepository.save(new User(userId, "pw"));

        }


        @Test

        void when_Use_NoMatchPw_then_Fail() {

            ...검증코드

        }

    }

}


중첩 테스트를 사용하면 상황이나 실행 부분을 별도 클래스로 분리해서 구현할 수 있는데, 이는 테스트 코드 구조를 관리하는데 도움이 된다.



태깅과 필터링


@Tag를 사용하면 테스트 클래스나 메서드에 태그를 달 수 있다.


- 테스트 클래스에 붙인 예


@Tag("slow")

@DisplayName("중첩 구조 테스트")

public class AuthenticatorTest {

    ... 테스트 코드

}


- 테스트 메서드에 붙인 예


@Tag("very-slow")

@Test

void verySlowTest() {

    ...

}


JUnit Platform은 테스트 대상을 고를 때 이 태그를 이용한다. 메이븐을 사용할 경우 다음과 같은 설정을 사용해서 테스트에 포함하거나 제외시킬 태그를 선택할 수 있다.


<plugin>

    <artifactId>maven-surefire-plugin</artifactId>

    <version>2.19.1</version>

    <configuration>

        <properties>

            <includeTags>slow,very-slow</includeTags>

            <excludeTags>pay</excludeTags>

        </properties>

    </configuration>

    <dependencies>

        ...JUnit 5 관련 의존

    </dependencies>

</plugin>


includeTags 프로퍼티는 테스트에 포함시킬 태그 목록을 지정하고 excludeTags는 테스트에서 제외할 태그 목록을 지정한다. 위 경우는 slow와 very-slow 태그가 붙은 테스트 중에서 pay 태그가 붙은 테스트를 제외한 나머지 테스트를 실행한다. includeTags만 있으면 해당 태그가 붙은 테스트만 실행하고, excludeTags만 있으면 해당 태그가 붙은 테스트를 제외한 나머지 테스트를 실행한다.


그레이들을 사용한다면 빌드에 다음 설정을 추가한다.


junitPlatform {

    filters {

        tags {

            include 'slow', 'very-slow'

            exclude 'pay'

        }

    }

}


참고: JUnit 5 연습에 사용한 코드: https://github.com/madvirus/junit5-prac


저작자 표시 비영리 변경 금지
신고
Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

JUnit 5 소개

TDD 또는 Test 2017.10.10 21:24

Junit 5 정식 버전이 나왔다. 테스트 코드를 작성하는 개발자 입장에서 JUnit 5를 보면 JUnit 4에 비해 중요한 차이점은 다음과 같다.

  • JUnit 4가 단일 jar였던 것에 비해, JUnit 5는 크게 JUnit Platform, JUnit Jupiter, JUnit Vintage 모듈로 구성되어 있다.
  • 테스트 작성자를 위한 API 모듈과 테스트 실행을 위한 API가 분리되어 있다.
    • 예를 들어, JUnit Jupiter는 테스트 코드 작성에 필요한 junit-jupiter-api 모듈과 테스트 실행을 위한 junit-jupiter-engine 모듈로 분리되어 있다.
  • 자바 8 또는 그 이상 버전을 요구한다.

모듈 개요

JUnit 5를 사용해서 테스트를 작성하고 실행하려면 모듈의 개략적인 구성 정도는 알아야 설정을 이해하는데 도움이 된다. 모듈은 크게 다음과 같은 구조를 갖는다. (세부적인 모듈 목록은 JUnit 5 User Guide에서 확인할 수 있다.)



JUnit Platform은 테스트를 발견하고 테스트 계획을 생성하는 TestEngine 인터페이스를 정의하고 있다. Platform은 TestEngine을 통해서 테스트를 발견하고, 실행하고, 결과를 보고한다.


TestEngine의 실제 구현체는 별도 모듈로 존재한다. 이 모듈 중 하나가 jupiter-engine이다. 이 모듈은 jupiter-api를 사용해서 작성한 테스트 코드를 발견하고 실행한다. Jupiter API는 JUnit 5에 새롭게 추가된 테스트 코드용 API로서, 개발자는 Jupiter API를 사용해서 테스트 코드를 작성할 수 있다.


기존에 JUnit 4 버전으로 작성한 테스트 코드를 실행할 때에는 vintage-engine 모듈을 사용한다.


만약 테스트 코드를 작성하기 위한 새로운 API를 창안하다면, 그 API에 알맞은 엔진 모듈을 함께 구현해서 제공하면 JUnit Platform 수정없이 새로 창안한 테스트 API를 실행하고 결과를 리포팅할 수 있게 된다.


프로젝트 의존 설정


메이븐 프로젝트 설정


메이븐 프로젝트를 사용한다면 다음과 같이 설정한다.


<?xml version="1.0" encoding="UTF-8"?>

<project ...생략>

    ...생략


    <dependencies>

        <dependency>

            <groupId>org.junit.jupiter</groupId>

            <artifactId>junit-jupiter-api</artifactId>

            <version>5.0.1</version>

            <scope>test</scope>

        </dependency>


        <!-- 컴파일 경고 메시지 제거 목적 -->

        <dependency>

            <groupId>org.apiguardian</groupId>

            <artifactId>apiguardian-api</artifactId>

            <version>1.0.0</version>

            <scope>test</scope>

        </dependency>

    </dependencies>


    <build>

        <plugins>

            <plugin>

                <artifactId>maven-compiler-plugin</artifactId>

                <version>3.1</version>

                <configuration>

                    <source>1.8</source>

                    <target>1.8</target>

                    <encoding>utf-8</encoding>

                </configuration>

            </plugin>


            <plugin>

                <artifactId>maven-surefire-plugin</artifactId>

                <version>2.19.1</version>

                <dependencies>

                    <dependency>

                        <groupId>org.junit.platform</groupId>

                        <artifactId>junit-platform-surefire-provider</artifactId>

                        <version>1.0.1</version>

                    </dependency>

                    <dependency>

                        <groupId>org.junit.jupiter</groupId>

                        <artifactId>junit-jupiter-engine</artifactId>

                        <version>5.0.1</version>

                    </dependency>

                </dependencies>

            </plugin>

        </plugins>

    </build>


</project>


의존 설정:

  • org.junit.jupiter:junit-jupiter-api :테스트 코드 작성에 필요한 API 제공
  • org.apiguardian:apiguardian-api: 컴파일 경고 메시지를 제거하고 싶으면 추가(이 의존을 추가하지 않으면 

maven-surefire-plugin 설정

  • 2.19.1 버전 사용: 2.20이 메모리릭 문제가 있고, 2.20.1 버전은 JUnit 5.0과 호환되지 않아 mvn test 시 에러 발생함(이슈 참고)
  • JUnit 5의 플랫폼을 사용해서 테스트를 실행하기 위해 플러그인 의존 추가
    • junit-platform-surefire-provider: 메이븐 Surefire에서 JUnit Platform을 실행하기 위한 모듈
    • junit-jupiter-engine: Jupiter API로 작성한 테스트를 위한 엔진 모듈
이렇게 설정하면 mvn test 명령어를 사용해서 Jupiter API를 사용해서 작성한 테스트를 메이븐에서 실행할 수 있다.

그레이들 프로젝트 설정


그레이들 프로젝트에서 JUnit Jupiter 테스트를 사용하기 위한 설정은 아래와 같다.


group 'junit5-prac'

version '1.0-SNAPSHOT'


buildscript {

    repositories {

        mavenCentral()

    }

    dependencies {

        classpath 'org.junit.platform:junit-platform-gradle-plugin:1.0.1'

    }

}


apply plugin: 'org.junit.platform.gradle.plugin'

apply plugin: 'java'


sourceCompatibility = 1.8


repositories {

    mavenCentral()

}


dependencies {

    testCompile("org.junit.jupiter:junit-jupiter-api:5.0.1")

    testRuntime("org.junit.jupiter:junit-jupiter-engine:5.0.1")

}


buildscript로 빌드 과정에서 그레이들이 JUnit Platform을 사용하도록 설하고, apply plugin으로 플러그인을 적용한다. dependencies에는 Jupiter API를 사용해서 테스트를 작성하기 위한 API와 런타임에 실행하기 위한 엔진 의존을 추가한다. Jupiter Engine은 테스트를 실행할 때 필요하므로 testRuntime으로 설정한다.


이제 gradle test 명령어로 Jupiter API를 사용해서 작성한 테스트를 실행할 수 있다.


Jupiter API: 테스트 작성과 실행


테스트 코드 작성하기


Jupiter API를 이용해서 테스트 코드를 작성하는 방법은 JUnit 4 버전과 크게 다르지 않다. 다음은 Jupiter API를 이용한 테스트 코드 작성예이다.


import org.junit.jupiter.api.Test;


import static org.junit.jupiter.api.Assertions.assertEquals;


public class PasswordMeterTest {


    @Test

    void weak() {

        PasswordMeter meter = new PasswordMeter();

        assertEquals(PasswordLevel.WEAK, meter.meter("1234"));

    }

}


JUnit 4를 이용한 테스트 코드와 거의 동일한 것을 알 수 있다. @Test 애노테이션을 이용해서 테스트로 사용할 메서드를 지정하고, assertEquals() 메서드를 이용해서 기대하는 값과 실제 결과 값이 같은지 비교한다. 차이점은 @Test 애노테이션과 assertEquals() 메서드가 속한 패키지이다. Jupiter API의 패키지는 org.junit.jupiter.api로 시작한다.


인텔리J에서 실행하기


인텔리J 최신 버전(2017.2.5)은 JUnit 5를 지원한다. 메이븐이나 그레이들 프로젝트를 임포트했다면 해당 테스트 클래스나 메서드를 실행하면 된다.


[참고]

인텔리J 버전마다 내장하고 있는 JUnit 버전이 다르다. 2017.2.5의 경우는 Jupiter 5.0.0 버전을 내장하고 있다.([인텔리j]\plugins\junit\lib에 위치) JUnit Platform Launcher와 Jupiter Engine을 내장된 버전 대신 다른 버전을 사용하고 싶다면 User Guide의 Ide 지원을 참고한다.


이클립스에서 실행하기 1: JUnit 4를 이용한 실행 


현재 사용중인 이클립스 Oxygen(4.7) 버전은 아직 JUnit 5의 Platform과 Jupiter API를 지원하지 않는다. 이런 환경에서 Jupiter API로 작성한 테스트를 실행하려면 다음과 같은 의존을 추가해 주어야 한다.


       <!-- JUnit5를 지원하지 않는 환경(eclipse oxygen 등)에서 실행하기 위한 설정 -->

        <dependency>

            <groupId>junit</groupId>

            <artifactId>junit</artifactId>

            <version>4.12</version>

        </dependency>

        <dependency>

            <groupId>org.junit.platform</groupId>

            <artifactId>junit-platform-runner</artifactId>

            <version>1.0.1</version>

            <scope>test</scope>

        </dependency>

        <dependency>

            <groupId>org.junit.jupiter</groupId>

            <artifactId>junit-jupiter-engine</artifactId>

            <version>5.0.1</version>

            <scope>test</scope>

        </dependency>


JUnit 4 버전에 대한 의존을 추가했는데, 그 이유는 JUnit 4의 @RunWith를 사용해서 Jupiter API 기반 실행하기 위함이다. junit-platform-runner 모듈은 Jupiter 기반 테스트를 실행할 수 있는 Runner인 JUnitPlatform을 제공한다. 이 Runner를 사용하면 JUnit 4만 지원하는 개발 환경에서 Jupiter API로 작성한 테스트를 실행할 수 있다. 다음은 사용 예이다. 아래 코드에서 @Test 애노테이션의 JUnit 4의 @Test가 아니라 Jupiter API의 @Test 임을 알 수 있다.


import org.junit.jupiter.api.Test;

import org.junit.platform.runner.JUnitPlatform;

import org.junit.runner.RunWith;


import static org.junit.jupiter.api.Assertions.assertEquals;


@RunWith(JUnitPlatform.class)

public class PasswordMeterTest4Eclipse {


    @Test

    void weak() {

        PasswordMeter meter = new PasswordMeter();

        assertEquals(PasswordLevel.WEAK, meter.meter("1234"));

    }

}



이클립스에서 실행하기 2: 플러그인 사용


Oxygen에서 JUnit 5를 실행할 수 있는 또 다른 방법은 아직은 베타 버전인 플러그인을 설치하는 것이다. 이에 대한 설치 방법은 https://wiki.eclipse.org/JDT_UI/JUnit_5 문서에서 확인할 수 있다.



Jupiter API: assert 메서드


기본 assert 메서드, fail() 메서드


Jupiter API의 org.junit.jupiter.api.Assertions 클래스는 값 검증을 위한 assert로 시작하는 static 메서드를 제공하고 있다. 주요 메서드는 다음과 같다.

  • assertEquals(expected, actual) : int, long 등 기본타입과 Object에 대한 assertEquals 메서드가 존재한다.
  • assertNotEquals(Object unexpected, Object actual)
  • assertTrue(boolean condition)
  • assertFalse(boolean condition)
  • assertNull(Object actual)
  • assertNotNull(Object actual)
  • fail()

assertAll()


아래 코드를 보자.


@Test

void assertEach() {

    Game game = new Game(123);

    Score score = game.guess(134);

    assertEquals(1, score.getStrikes());

    assertEquals(1, score.getBalls());

}


이 코드에서 만약 첫 번째 assertEquals()가 실패하면 그 시점에서 테스트 실패하므로 두 번째 assertEquals()를 실행하지 않는다. 이 두 assertEquals()는 실제로는 하나의 score를 검증하는 것이므로 개념적으로 하나를 검증하는 것이다. 이럴 때 유용하게 사용할 수 있는 것이 assertAll()이다. assertAll()은 여러 검증을 하나로 묶어서 테스트 할 수 있게 해준다. 다음은 assertAll()의 예를 보여주고 있다.


public class AssertAllTest {

    @Test

    void assertAllSample() {

        Game game = new Game(123);

        Score score = game.guess(145);

        assertAll(

                () -> assertEquals(2, score.getStrikes()),

                () -> assertEquals(1, score.getBalls())

        );

    }

}


assertAll()은 함수형인터페이스인 Executable 목록을 파라미터로 갖는다.(Executable 인터페이스는 파라미터가 없고 리턴 타입이 void인 execute() 메서드를 정의하고 있다.) 함수형 인터페이스이므로 위 코드와 같이 람다식을 사용해서 여러개의 검증을 목록으로 전덜할 수 있다.


assertAll()의 특징은 목록으로 받은 모두 Executable을 실행한다는 점이다. 그리고 그 중에서 실패한 검증에 대해서만 리포트를 한다. 예를 들어, 위 코드에서 assertAll()로 전달한 모든 Executable이 검증에 실패했다고 하자. 이를 인텔리J에서 실행해보면 다음과 같이 assertAll()에서 실패한 모든 검증 결과가 콘솔에 출력되는 것을 알 수 있다.


xpected :2

Actual   :3

 <Click to see difference>


Expected :1

Actual   :0

 <Click to see difference>


assertThrows()


실행한 코드에서 특정 익셉션이 발생하는지 확인할 때에는 assertThrows() 메서드를 사용한다. 다음은 사용예이다.


@Test

void simple() {

    assertThrows(ArithmeticException.class, () -> divide(100, 0));

}


private int divide(int op1, int op2) {

    return op1 / op2;

}



Jupiter API: 테스트 라이프사이클


Jupiter API도 JUnit 4와 동일한 라이프사이클을 갖는다. Jupiter API의 애노테이션이 좀 더 의미를 살리는 이름을 사용한다.


public class LifecycleTest {


    @BeforeAll // JUnit 4의 @BeforeClass

    static void initAll() {

        System.out.println("initAll");

    }


    @BeforeEach // JUnit 4의 @Before

    void init() {

        System.out.println("init");

    }


    @Test

    void someTest() {

        System.out.println("someTest");

    }


    @Test

    void anyTest() {

        System.out.println("anyTest");

    }


    @AfterEach // JUnit 4의 @After

    void tearDown() {

        System.out.println("tearDown");

    }


    @AfterAll // JUnit 4의 @AfterClass

    static void tearDownAll() {

        System.out.println("tearDownAll");

    }


}



보조 애노테이션: @DisplayName과 @Disabled


@DisplayName은 테스트 클래스나 메서드의 표시 이름을 지정한다.


@DisplayName("라이프사이클 테스트")

public class LifecycleTest {


    @DisplayName("어떤 테스트")

    @Test

    void someTest() {

        System.out.println("someTest");

    }


    @Disabled("테스트 환경 구성 필요")

    @Test

    void anyTest() {

        System.out.println("anyTest");

    }


위 코드를 실행하면 아래 그림과 같이 테스트 결과에서 @DisplayName에 지정한 이름을 사용하는 것을 알 수 있다.


위 코드에서 @Disabled 애노테이션을 사용했는데, 이 애노테이션은 테스트를 실행 대상에서 제외한다. 위 그에서도 테스트를 하지 않고 생략한 것을 알 수 있다.


다음에는


이어지는 글(http://javacan.tistory.com/464)에서는 계속해서 Jupiter API에 추가된 기능을 추가로 살펴볼 예정이다.








저작자 표시 비영리 변경 금지
신고
Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.
TDD 발담그기 @ 공감세미나 from beom kyun choi
저작자 표시 비영리 변경 금지
신고
Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

테스트와 DB

TDD 또는 Test 2016.08.26 09:28

최근 프로젝트에서 다음 세 가지 종류의 테스트를 작성하고 있다.

  • 단위 테스트
  • 서버 기능을 확인하기 위한 컨트롤러, 서비스, 리포지토리 등에 대한 통합 테스트
  • 웹 브라우저에서 DB까지 모두를 포함한 E2E(end-to-end) 테스트
통합 테스트나 E2E 테스트를 작성하다보면 테스트 목적에 맞게 @Before나 테스트 대상 기능을 실행하기 전에 DB를 원하는 상태로 변경한다. DB 상태를 맞추기 위해 테이블을 비우거나(truncate), 특정 조건의 데이터를 지우거나 추가한다.

E2E 테스트를 작성하지 않은 기능은 통합 테스트와 단위 테스트를 기반으로 기능을 완성한 뒤에 직접 수동으로 웹 브라우저에서 테스트를 진행한다. 이 과정에서 한 가지 불편한 점이 있다. 수동으로 테스트를 하려는 시점의 데이터는 이전에 마지막으로 수행한 수동 시점의 데이터가 아니라는 점이다. 예를 들어, 수동 테스트에서 로그인할 때 사용한 'admin' 계정을 통합 테스트 과정에서 비활성화했다면 로그인이 안 되는 그런 식이다.


이런 불편을 줄이려고 사용한 방법은 다음의 두 가지 정도다.

  • DB 구분
  • 수동으로 데이터 초기화

자동화된 테스트와 수동 테스트에서 사용하는 DB를 구분하는 방법은 메이븐과 같은 빌드 도구의 프로필이나 스프링 프로필을 사용해서 처리한다. 이 방식을 사용하면 개발 과정에서 실행한 테스트 코드가 DB 상태를 변경해도, 수동 테스트에서 사용할 데이터는 영향을 받지 않는다. 내가 이전에 웹 브라우저에서 확인한 마지막 상태에서 다시 시작할 수 있어, 맥이 끊기지 않는 느낌을 받았다.


반면에 개발 과정에서 자동화 테스트용 DB의 스키마를 변경하면 수동 테스트용 DB 스키마를 함께 변경해 주어야 한다. 이 단점은 Flyway나 Liquibase와 같은 도구를 완화할 수 있다.


자동화된 테스트와 수동 테스트에서 사용하는 DB가 같다면, 수동 테스트를 시작하기 전에 데이터를 수동 테스트에 알맞은 상태로 되돌리는 방법을 사용한다. 초기화를 위한 SQL 파일을 하나 만들고 이 파일을 수동 테스트 전에 실행하는 방식을 주로 사용한다. 로컬 서버를 구동할 때 옵션을 주면 SQL 파일을 실행하는 방법도 사용해봤다. 이 방법은 서버 구동 시점에 데이터를 초기화하니까 편한데, 대신 습관적으로 서버 실행 명령어를 입력하다보면 원치 않게 데이터가 초기화되는 상황이 종종 발생하기도 한다.


하다보면 두 방식을 혼용해서 사용하고 싶어진다. 방식이 늘어나면 뭔가 더 많이 하는 것 같아 거부감이 들기도 하지만, 테스트 코드가 그런 것처럼 결과적으로 개발 시간을 줄여주는 효과를 준다.

저작자 표시 비영리 변경 금지
신고
Posted by 최범균 madvirus
TAG DB, 테스트

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

요즘 만드는 모듈 중 하나로 클라이언트 모듈이 있는데, 이 모듈은 서버와 HTTP를 이용해서 JSON으로 데이터를 주고 받는다. 내가 맡은 부분은 클라이언트쪽이었는데, 서버와 어떤 구조의 JSON을 주고 받을지 정한 상태였다. 난 클라이언트 기능이 정상적으로 동작하는지 확인하고 싶었는데 아직 서버는 만들어지기 전이었다. 간단한 웹 서버를 만들어서 테스트를 해야 하나라는 생각을 순간 했다가 바로 접고 검색을 해 봤는데, 딱 원하는 걸 해주는 걸 발견했다. 바로 WireMock이다.


WireMock은 테스트 목적으로 사용할 수 있는 일종의 mock 웹 서버다. WireMock을 사용하면서 특정 포트로 지정한 규칙과 매칭된 HTTP 요청이 들어오면 지정한 응답을 전송한다. 따라서, 실제 웹 서버가 존재하지 않더라도 HTTP 클라이언트를 테스트할 수가 없다. 특히 좋은 점은 JUnit을 지원한다는 것이다.


다음은 실제 WireMock을 사용한 코드의 한 부분을 발췌한 것이다. (이름을 약간 변경했다.)


import static com.github.tomakehurst.wiremock.client.WireMock.*;

...


public class SomeClientTest {


    @Rule

    public WireMockRule wireMockRule = new WireMockRule(8089);

    private SomeClient sender;

    ...


    @Before

    public void setupStub() {

        stubFor(post(urlEqualTo(url)) // url로 요청이 오면

                .willReturn(aResponse() // 응답으로

                        .withStatus(200) // 200을 전송하고

                        .withHeader("Content-Type", "application/json") // JSON 타입의 

                        .withBody("{....json데이터...}"))); // 데이터를 전송

        sender = new SomeClient("localhost", 8089); // WireMock이 생성한 서버에 연결

    }


    @Test

    public void postSomeMetric() throws Exception {

        ...

        MetricValues metricValues = new MetricValues(NOW, values);

        sender.send(metricValues);


        List<LoggedRequest> requests = findAll(postRequestedFor(urlMatching(url)));

        MetricValues postedValues = mapper.readValue(

            requests.get(0).getBodyAsString(), MetricValues.class);

        ...

    }



위 코드에서 WireMockRule은 JUnit의 Rule로서 Mock 서버를 구동하고 중지하는 처리를 한다.


@Before 메서드는 mock 웹 서버를 설정하고 있다. WireMock.stubFor()를 이용해서 mock 웹 서버의 동작 방식을 지정한다. 위 코드에서는 지정한 URL로 POST 요청이 들어오면, 200 상태 코드와 함께 JSON 형식의 데이터를 응답으로 전송하도록 설정하고 있다. 따라서, 클라이언트 해당 URL을 요청하면 @Before에서 설정한 응답을 받게 된다.


실제 테스트 메서드 대상은 WireMock이 생성한 mock 서버에 연결하기 위해 호스트와 포트를 지정하고, 클라이언트 기능을 실행한다. 위 @Test 메서드는 WireMock.findAll()을 이용해서 POST로 들어온 요청의 데이터를 구하고 있다. 즉, 클라이언트 전송한 데이터를  구하는데, 이를 이용해서 클라이언트가 제대로 데이터를 전송하는지 확인했다.


WireMock은 테스트를 지원하기 위한 다양한 기능을 제공하고 있다. 요청이 정해진 규칙대로 왔는지 검증하는 기능이 있고, 타임아웃을 테스트할 수 있는 응답 지연 기능이 있다. 프록시 기능을 제공하고, 독립 모드로 실행할 수 있다. WireMock에 대한 내용은 자세한 내용은 http://wiremock.org/ 사이트에서 확인할 수 있다.



저작자 표시 비영리 변경 금지
신고
Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

db-unit은 XML 파일을 이용해서 테스트 데이터를 초기화할 수 있는 기능을 제공하기 때문에, 서버 수준에서 통합 테스트를 진행할 때 매우 유용하게 사용하고 있다. db-unit의 XML 설정은 다음과 같이 테이블 이름과 컬럼 이름을 사용해서 초기화에 사용할 데이터를 입력한다. 태그는 테이블을 나타내고, 속성은 컬럼을, 속성의 값은 컬럼의 값을 의미한다.


<?xml version="1.0" encoding="UTF-8"?>

<dataset>

    <POOL ID="1" SUBNET_CONFIG_ID="1" RANGE_FROM="10.0.10.2" RANGE_TO="10.0.10.255" />


    <POOL ID="2" SUBNET_CONFIG_ID="2" RANGE_FROM="10.0.20.2" RANGE_TO="10.0.20.255"

          SUBNETMASK="255.255.255.0" GATEWAY="10.0.20.1" DNSLIST="1.2.3.4" />


    <POOL ID="3" SUBNET_CONFIG_ID="3" RANGE_FROM="10.0.30.2" RANGE_TO="10.0.30.255" />


</dataset>


spring-db-unit을 사용하면 db-unit을 스프링과 연동해서 사용할 수 있다. 예를 들어, 약간의 애노테이션 설정으로 스프링에 설정한 DataSource를 사용해서 db-unit을 실행할 수 있다. 다음은 spring-db-unit이 제공하는 @DatabaseSetup 애노테이션의 사용 예이다.


@ContextConfiguration("classpath:spring/*.xml")

@TestExecutionListeners({ DependencyInjectionTestExecutionListener.class,

        DirtiesContextTestExecutionListener.class,

        TransactionalTestExecutionListener.class,

        DbUnitTestExecutionListener.class })

@DatabaseSetup(value = "/ConfigTestData.xml", type = DatabaseOperation.CLEAN_INSERT)

@RunWith(SpringJUnit4ClassRunner.class)

public class UsableIpRangeFinderIntTest {

    ...

}


그런데, spring-db-unit을 사용할 때 주의할 점은 db-unit의 XML 처리 방식에 있다. 예를 들어, 다음과 같은 XML 설정을 사용하면 당연히 SUBNETMASK 컬럼과 GATEWAY 컬럼의 값이 사용될거라 생각되겠지만, 실제로는 ID, SUBNET_CONFIG_ID, RANGE_FROM, RANGE_TO 컬럼의 값만 제대로 처리된다.


<dataset>

    <POOL ID="1" SUBNET_CONFIG_ID="1" RANGE_FROM="10.0.10.2" RANGE_TO="10.0.10.255" />


    <POOL ID="2" SUBNET_CONFIG_ID="2" RANGE_FROM="10.0.20.2" RANGE_TO="10.0.20.255"

          SUBNETMASK="255.255.255.0" GATEWAY="10.0.20.1" DNSLIST="1.2.3.4" />


</dataset>


SUBNETMASK 컬럼과 GATEWAY 컬럼이 처리되지 않는 이유는, 가장 첫 번째로 만난 태그의 속성 목록만을 컬럼으로 인식하기 때문이다. 즉, 위 XML 파일의 경우 <POOL> 태그 중 첫 번째 <POOL> 태그에 정의된 속성만 컬럼으로 처리하기 때문에, 두 번째 <POOL> 태그에만 존재하는 SUBNETMASK와 GATEWAY는 컬럼으로 인식되지 않는다.


태그에 포함된 모든 속성을 컬럼으로 처리하려면 db-unit의 컬럼 센싱이라는 설정을 활성화해야 하는데, (아직까지) spring-db-unit은 컬럼 센싱을 활성화해주는 설정을 지원하지 않고 있다. 이 컬럼 센싱 기능을 활성화려면 spring-db-unit의 DataSetLoader의 커스텀 구현 클래스를 만들어야 한다. 컬럼 센싱 기능을 활성화하기 위한 커스텀 DataSetLoader 클래스의 구현 예는 다음과 같다.


import com.github.springtestdbunit.dataset.AbstractDataSetLoader;

import org.dbunit.dataset.IDataSet;

import org.dbunit.dataset.xml.FlatXmlDataSetBuilder;

import org.springframework.core.io.Resource;


import java.io.InputStream;


public class ColumnSensingFlatXmlDataSetLoader extends AbstractDataSetLoader {


    @Override

    protected IDataSet createDataSet(Resource resource) throws Exception {

        FlatXmlDataSetBuilder builder = new FlatXmlDataSetBuilder();

        builder.setColumnSensing(true);

        InputStream inputStream = resource.getInputStream();

        try {

            return builder.build(inputStream);

        } finally {

            inputStream.close();

        }

    }

}


커스텀 구현 클래스를 사용하려면 @DbUnitConfiguration 애노테이션을 추가로 설정해주면 된다.


@ContextConfiguration("classpath:spring/*.xml")

@TestExecutionListeners({ DependencyInjectionTestExecutionListener.class,

        DirtiesContextTestExecutionListener.class,

        TransactionalTestExecutionListener.class,

        DbUnitTestExecutionListener.class })

@DatabaseSetup(value = "/ConfigTestData.xml", type = DatabaseOperation.CLEAN_INSERT)

@DbUnitConfiguration(dataSetLoader = ColumnSensingFlatXmlDataSetLoader.class)

@RunWith(SpringJUnit4ClassRunner.class)

public class UsableIpRangeFinderIntTest {

    ...

}


이제 XML 설정에 있는 모든 속성을 컬럼으로 사용하기 때문에, 특정 테이블을 위한 첫 번째 태그에 속성이 존재하지 않더라도 컬럼에 올바르게 값이 설정된다.


저작자 표시 비영리 변경 금지
신고
Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

스프링캠프 2013에서 발표했던 'TDD 라이브'의 영상입니다.



(발표자료는 http://javacan.tistory.com/entry/TDD-Live-in-SpringCamp-2013 참고)

저작자 표시 비영리 변경 금지
신고
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 꿈꾸는 개발자 2014.02.03 23:53 신고  댓글주소  수정/삭제  댓글쓰기

    정말 좋은 자료 감사합니다.
    범균님 혹시 소스 파일 받아 볼수 있나요???

  2. 꿈꾸는 개발자 2014.02.04 08:46 신고  댓글주소  수정/삭제  댓글쓰기

    범균님 감사감사~~~~~^^
    복 많이 받으실꺼에요

  3. 김상균 2014.08.25 11:55 신고  댓글주소  수정/삭제  댓글쓰기

    궁금 한게 있습니다
    @autowired는 by type으로 되어있다고 알고 있습니다
    근데 지금 제가 전에 만들었던 소스를 보고 있는데 이런식으로 되어있어요
    근데 문제 없이 잘 작동 합니다.
    책에 의하면 이렇게 될 경우 스프링은 어떤 객체를 매칭시켜줄지 몰라서 익셉션을
    발생한다고 알고 있는데 이게 어떡해 잘 되는건가요 이럴경우 @Qualifier 사용 해서 하거나 그냥 @Resource를 사용하는걸로 알고 있는데요...
    @Autowired
    A a;
    @Autowired
    A b;

    <bean id="a" class="A" />
    <bean id="b" class="A" />

    • 최범균 madvirus 2014.08.25 23:52 신고  댓글주소  수정/삭제

      책에서 설명하진 않았지만 @Autowired 애노테이션이 적용된 필드 이름과 같은 식별자를 갖는 (할당 가능한 타입의) 빈이 있으면 그 빈을 자동 설정 대상으로 사용하게 됩니다.

      저 같은 경우는 빈의 이름을 이용한 매칭 방법을 방법을 선호하지 않는데, 이런 선호 때문에, 저도 모르게 이에 대한 내용을 넣지 않은 것도 같네요. 나중에 개정판을 낼 때 넣어야 할 필요성을 느끼게 해 주셔서 감사합니다. ^^

      그리고, 선호하지 않는 이유는.... 음 여기에 다 적으려고 보니 뭔가 주저리 주저리 적을 말이 많네요. 대단한 이유는 아닌데 주저리 주저리 적어야 해서, 그냥 제가 왜 그런 선호를 갖는지에 대한 부분이 궁금하시면 이메일로 다소 길게 정리해서 보내드릴께요. 제 이메일 주소는 madvirus@madvirus.net 인데, 이곳으로 메일 주소 알려주시면, 답변 드리도록 하겠습니다.

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

OKJSP 13주년 컨퍼런스에서 발표한 자료입니다.




저작자 표시 비영리 변경 금지
신고
Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

기존에 Mockito와 JUnit Eclosed를 이용해서 작성한 테스트 코드를 Spock을 이용해서 재작성해봤다. 뭐랄까, 속이 다 후련하다. Spock이 좀 더 익숙해지면 여러 상황을 위한 테스트 코드 만드는데 도움이 될 것 같다.


class PaymentErpSyncSpockSpecification extends Specification {

    def PaymentErpSync sync = new PaymentErpSync()

    def PaymentSyncSourceDao mockSyncSourceDao = Mock()

    def PaymentInfoConverter mockPaymentInfoConverter = Mock();

    def ExternalErpClient mockExternalErpClient = Mock();

    def OrderSyncResultDao mockOrderSyncResultDao = Mock();


    def setup() {

        sync.setPaymentSyncSourceDao(mockSyncSourceDao)

        sync.setExternalErpClient(mockExternalErpClient)

        sync.setPaymentInfoConverter(mockPaymentInfoConverter)

        sync.setOrderSyncResultDao(mockOrderSyncResultDao)

    }

    

    def "PaymentSyncSourceDao 읽기 실패시"() {

        when: "동기화 실행"

        sync.syncPaymentInfo()

        

        then: "SyncSource 읽기 실패하고, 실패 결과 기록해야 함"

        mockSyncSourceDao.findAllByBeforeSync() >> { throw new RuntimeException() };

        mockOrderSyncResultDao.insert(_) >> { OrderSyncResult result ->

            assert result.result == false

            assert result.syncType == null

            assert result.failedSource == "PaymentSyncSourceDao"

        }

    }

    

    def "PaymentSyncSourceDao 읽기 성공시"() {

        setup: "PaymentSyncSourceDao 데이터 제공 설정"

        def paymentSyncSources =[

            PaymentSyncSource.builder().id(1L).saleDate(new Date()).type("p").build(),

            PaymentSyncSource.builder().id(2L).saleDate(new Date()).type("p").build()

        ]

        mockSyncSourceDao.findAllByBeforeSync() >> paymentSyncSources

        

        def paymentInfo = []

        paymentSyncSources.each { source ->

            mockPaymentInfoConverter.convert(source) >> paymentInfo

        }

        

        when: "동기화 실행하면,"

        sync.syncPaymentInfo()

        

        then: "ERP 전송 실패하고, 실패 결과 기록해야 함"

        2 * mockExternalErpClient.send(paymentInfo) >> { throw new SendFailureException() }

        2 * mockOrderSyncResultDao.insert({ OrderSyncResult result ->

            result.result == false &&

            result.syncType == SyncType.payment &&

            result.failedSource == "ExternalErpClient"

        })

        

        when: "동기화 실행하면,"

        sync.syncPaymentInfo()

        

        then: "모두 성공하고, 성공 결과 기록해야 함"

        2 * mockExternalErpClient.send(paymentInfo)

        2 * mockOrderSyncResultDao.insert({OrderSyncResult result ->

            result.result == true &&

            result.syncType == SyncType.payment

        })

    }

}



저작자 표시 비영리 변경 금지
신고
Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

자바로 테스트 코드 만드는 게 좀 짜증이 나서 편하게 사용할만한 테스트 프레임워크를 뒤지다가 페이스북에서 Spock 이라는 놈을 줏어 들었다. 자바 기반의 프로젝트를 진행중인데, Spock가 그루비 기반이라서 궁합도 잘 맞을 것 같기에 일부 테스트 코드에서 넣어보기로 결심을 했다.


본격 사용하기에 앞서 개발 환경 구축은 필수! 아래의 순서로 진행을 했다.

  1. 이클립스에서 그루비 플러그인 설치
  2. pom.xml에 Spock 실행 위한 설정 추가
  3. src/test/groovy 폴더 생성
  4. Spock 테스트 코드 실행
그루비 플러그인 설치


http://groovy.codehaus.org/Eclipse+Plugin 사이트에 가면 이클립스 버전 별로 업데이트 사이트 주소를 확인할 수 있다. 필자는 이클립스 4.3 (Kepler) 버전을 사용중이서 http://dist.springsource.org/release/GRECLIPSE/e4.3/ 주소를 이용해서 플러그인을 설치했다. 메이븐 플러그인(m2e)와 연동을 해야 하므로, 메이븐 플러그인 커넥터도 함께 설치했다. (사실은 다 설치했다.)


그루비 플러그인을 설치한 다음에는 Windows > Preferences > Groovy > Compiler 에서 컴파일러 버전을 2.0 으로 변경한다. (변경하면 이클립스를 재시작한다.) 2.1이 아닌 2.0을 사용하는 이유는 Spock 0.7 버전이 현재 그루비 2.0 까지만 지원하기 때문이다.


pom.xml 파일 설정


메이븐 pom.xml 설정이 다소 복잡한데, 사용한 설정은 아래와 같다. Spock 0.7 버전은 현재 그루비 1.8 버전 또는 2.0 버전을 지원한다.


<project ...>

    <dependencies>

        ...

        <dependency>

            <groupId>org.spockframework</groupId>

            <artifactId>spock-core</artifactId>

            <version>0.7-groovy-2.0</version>

            <!-- 0.7-groovy-1.8 -->

            <scope>test</scope>

        </dependency>

        <dependency>

            <groupId>org.codehaus.groovy</groupId>

            <artifactId>groovy-all</artifactId>

            <version>2.0.5</version>

            <scope>test</scope>

        </dependency>

    </dependencies>


    <build>

        <plugins>

            <plugin>

                <groupId>org.apache.maven.plugins</groupId>

                <artifactId>maven-compiler-plugin</artifactId>

                <version>3.1</version>

                <configuration>

                    <source>${java.version}</source>

                    <target>${java.version}</target>

                    <encoding>${project.build.sourceEncoding}</encoding>

                    <compilerId>groovy-eclipse-compiler</compilerId>

                </configuration>

                <dependencies>

                    <dependency>

                        <groupId>org.codehaus.groovy</groupId>

                        <artifactId>groovy-eclipse-compiler</artifactId>

                        <version>2.8.0-01</version>

                    </dependency>

                    <dependency>

                        <groupId>org.codehaus.groovy</groupId>

                        <artifactId>groovy-eclipse-batch</artifactId>

                        <version>2.0.7-03</version>

                        <!-- <version>1.8.6-01</version> -->

                    </dependency>

                </dependencies>

            </plugin>

        </plugins>

    </build>


</project>


테스트 목적으로만 쓸 거라서 groovy-all 의존의 범위를 test로 지정하였다.


주의사항:

  • spock 0.7 메이븐 의존은 Junit-dep 4.10 버전에 대한 의존을 갖는다. 따라서, Junit 4.11과 같이 다른 버전의 JUnit을 사용하고 있다면, JUnit 버전을 맞추기 위해 spock 의존 설정에 <exclustion>을 추가해서 junit-dep 의존을 제거한다.
  • groovy-all 용량이 커서, 최초 다운로드에 올래 걸리기도 한다. 메이븐 프로젝트 구성할 때에 인내심이 필요할 수 있다.

src/test/groovy 폴더 생성


그루비로 만든 테스트 코드를 넣기 위해 src/test 폴더 아래 groovy 폴더를 생성한다.


Spock 테스트 코드 실행


이제 남은 건 Spock 테스트 코드를 실행하는 것이다. src/test/groovy 폴더에 Spock 홈페이지에 있는 테스트 코드를 넣어본다. (HelloSpock.groovy)


import spock.lang.*


class HelloSpock extends spock.lang.Specification {

def "length of Spock's and his friends' names"() {

expect:

name.size() == length


where:

name     | length

"Spock"  | 5

"Kirk"   | 4

"Scotty" | 6

}

}


JUnit을 실행하는 것과 동일하게 "JUnit Run"으로 실행한다. 그러면, 아래와 같이 테스트가 통과하는 것을 확인할 수 있다.




저작자 표시 비영리 변경 금지
신고
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 미니양 2014.02.11 04:43 신고  댓글주소  수정/삭제  댓글쓰기

    Spock을 한번 따라해 보고 싶어서 작성해 주신 글대로 따라 해 봤습니다.
    Spock의 기본 샘플로 주신건 실행이 되는데,
    def MockSampleObj = Mock(SampleObj)
    이렇게 하면 MockSampleObj.someMethod를 하면 someMethod를 가져오지 못하고 밑줄이 그어지네요.

    혹시 원인을 알 수 있나요?

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

발표 자료:


저작자 표시 비영리 변경 금지
신고
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 이호영 2013.10.15 13:28 신고  댓글주소  수정/삭제  댓글쓰기

    수고하셨습니다.
    좋은 내용 감사합니다 ^^

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

숫자 야구 게임을 이용해서 TDD를 연습해 봤습니다.




저작자 표시 비영리 변경 금지
신고
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 개발자 2014.01.19 12:28 신고  댓글주소  수정/삭제  댓글쓰기

    안녕하세요 동영상 강의 잘봤습니다.
    마지막에 guessNumber random은 피곤하셔서 구현을 못하셨는데 어떤식으로 하면 될까요?
    궁금해서 못참겠어요..ㅠ.ㅠ

    • 최범균 madvirus 2014.01.20 09:47 신고  댓글주소  수정/삭제

      0부터 9까지의 수를 섞은 다음에... (랜덤하게 인덱스를 추출해서 맨 앞으로 보낸다는가 하는 식으로 15~20회 수행 등), 1/3/5 번째를 뽑는다든가 하는 식으로 숫자 3개를 뽑아낼 수 있을 것 같아요.

  2. 개발자 2014.01.20 13:24 신고  댓글주소  수정/삭제  댓글쓰기

    넵 감사합니다. 참고하고 해보겠습니다.
    아참 그리고 범균님 책 잘읽었습니다.
    '개발자가 반드시 정복해야할 객체 지향과 디자인패턴' 끝까지 읽어보고 리뷰 달아볼께요.

  3. 개발자 2014.01.20 13:29 신고  댓글주소  수정/삭제  댓글쓰기

    랜덤하게 뽑아내는건 문제가 아닌데...동영상강의 마지막에 interface로 GameNumberGenerator를 만들었는데 의존관계를 갖지 않고 어떻게 GameNumberGenerator를 구현해야 되는지 그게 좀 어려워서요...
    만약 개발자가 반드시 정복해야할 객체 지향과 디자인패턴 이책을 안읽고 그냥 했다면 구현은 가능하겠으나...책도 거의 다 읽어가는 마당에 그렇게 코딩하면 안될거 같아서요..^^;;

  4. 나그네 2014.02.26 10:12 신고  댓글주소  수정/삭제  댓글쓰기

    혹시 소스 파일있나요???

  5. 나그네 2014.03.04 10:49 신고  댓글주소  수정/삭제  댓글쓰기

    네엡 감사합니다~~~~~^^

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

이 글은 http://www.slipp.net/questions/153 에 있는 내용을 보고 리팩토링을 과정을 한 번 정리해본 것이다.


1. 테스트 코드 일부 리팩토링 및 버그 수정


테스트 대상은 ApiUsersController 클래스이다. 이 클래스를 테스트하는 코드는 아래와 같다.


@RunWith(value = MockitoJUnitRunner.class)

public class ApiUsersControllerTest {

    @Mock

    private SocialUserService userService;


    @InjectMocks

    private ApiUsersController dut = new ApiUsersController();


    @Test

    public void duplicateUserId_login_isSameUser() {

        String userId = "userId";

        SocialUser loginUser = new SocialUser(1L);

        when(userService.findByUserId(userId)).thenReturn(loginUser);


        String actual = dut.duplicateUserId(loginUser, userId);

        assertThat(actual, is("false"));

    }


    @Test

    public void duplicateUserId_login_isNotSameUser() {

        String userId = "userId";

        SocialUser loginUser = new SocialUser(1L);

        when(userService.findByUserId(userId)).thenReturn(loginUser);


        String actual = dut.duplicateUserId(new SocialUser(2L), userId);

        assertThat(actual, is("true"));

    }


    @Test

    public void duplicateEmail_doesnot_existed() {

        String actual = dut.duplicateEmail(SocialUser.GUEST_USER, "userId", ProviderType.slipp);

        assertThat(actual, is("false"));

    }


    @Test

    public void duplicateEmail_login_isSameUser() {

        String email = "email@slipp.net";

        ProviderType providerType = ProviderType.slipp;

        SocialUser loginUser = new SocialUser(1L);

        when(userService.findByEmailAndProviderId(email, providerType)).thenReturn(loginUser);


        String actual = dut.duplicateEmail(loginUser, email, providerType);

        assertThat(actual, is("false"));

    }


    @Test

    public void duplicateEmail_login_isNotSameUser() {

        String email = "email@slipp.net";

        ProviderType providerType = ProviderType.slipp;

        SocialUser loginUser = new SocialUser(1L);

        when(userService.findByEmailAndProviderId(email, providerType)).thenReturn(loginUser);


        String actual = dut.duplicateEmail(loginUser, email, providerType);

        assertThat(actual, is("false"));

    }

}


다음의 두 가지가 걸린다.

  • 상수처럼 쓰이는 것: "userId", "email@slipp.net", new SocialUser(1L), "false", "true"
  • loginUser 변수의 의미가 두 메서드에서 다름

이 두 가지를 정리하기 위해 먼저 상수처럼 쓰이는 것들을 상수로 정리한다.


@RunWith(value = MockitoJUnitRunner.class)

public class ApiUsersControllerTest {

    public static final String USER_ID = "userId";

    public static final String EMAIL = "email@slipp.net";

    public static final SocialUser USER1 = new SocialUser(1L);

    public static final String FALSE = "false";

    public static final String TRUE = "true";


    @Mock

    private SocialUserService userService;


    @InjectMocks

    private ApiUsersController dut = new ApiUsersController();


    @Test

    public void duplicateUserId_login_isSameUser() {

        when(userService.findByUserId(USER_ID)).thenReturn(USER1);

        String actual = dut.duplicateUserId(USER1, USER_ID);

        assertThat(actual, is(FALSE));

    }


    @Test

    public void duplicateUserId_login_isNotSameUser() {

        when(userService.findByUserId(USER_ID)).thenReturn(USER1);

        String actual = dut.duplicateUserId(new SocialUser(2L), USER_ID);

        assertThat(actual, is(TRUE));

    }


    @Test

    public void duplicateEmail_doesnot_existed() {

        String actual = dut.duplicateEmail(SocialUser.GUEST_USER, USER_ID, ProviderType.slipp);

        assertThat(actual, is(FALSE));

    }


    @Test

    public void duplicateEmail_login_isSameUser() {

        when(userService.findByEmailAndProviderId(EMAIL, ProviderType.slipp)).thenReturn(USER1);

        String actual = dut.duplicateEmail(USER1, EMAIL, ProviderType.slipp);

        assertThat(actual, is(FALSE));

    }


    @Test

    public void duplicateEmail_login_isNotSameUser() {

        when(userService.findByEmailAndProviderId(EMAIL, ProviderType.slipp)).thenReturn(USER1);

        String actual = dut.duplicateEmail(USER1, EMAIL, ProviderType.slipp);

        assertThat(actual, is(FALSE));

    }

}


리팩토링 후, 테스트를 돌려보니 정상 동작한다.


음,, 근데, duplicateEmail_login_isSameUser() 메서드와 duplicateEmail_login_isNotSameUser() 메서드가 구현이 동일하다. 이를 올바르게 변경하기 위해 duplicateEmail_login_isNotSameUser()를 다음과 같이 변경한다.


@RunWith(value = MockitoJUnitRunner.class)

public class ApiUsersControllerTest {

    ...

    public static final SocialUser USER2 = new SocialUser(2L);


    @Test

    public void duplicateUserId_login_isNotSameUser() {

        when(userService.findByUserId(USER_ID)).thenReturn(USER1);

        String actual = dut.duplicateUserId(USER2, USER_ID);

        assertThat(actual, is(TRUE));

    }

    ...

    @Test

    public void duplicateEmail_login_isNotSameUser() {

        when(userService.findByEmailAndProviderId(EMAIL, ProviderType.slipp)).thenReturn(USER1);

        String actual = dut.duplicateEmail(USER2, EMAIL, ProviderType.slipp);

        assertThat(actual, is(TRUE));

    }

}


2. 테스트 대상 코드


테스트 대상 클래스인 ApiUsersController 클래스는 아래와 같다.


@Controller

public class ApiUsersController {

    private SocialUserService userService;


    @RequestMapping("/duplicate_userid")

    @ResponseBody

    public String duplicateUserId(SocialUser loginUser, String userId) {

        SocialUser socialUser = userService.findByUserId(userId);

        if (socialUser == null) {

            return "false";

        }

        if (socialUser.isSameUser(loginUser)) {

            return "false";

        }

        return "true";

    }


    @RequestMapping("/duplicate_email")

    @ResponseBody

    public String duplicateEmail(SocialUser loginUser, String email, ProviderType providerType) {

        SocialUser socialUser = userService.findByEmailAndProviderId(email, providerType);

        if (socialUser == null) {

            return "false";

        }

        if (socialUser.isSameUser(loginUser)) {

            return "false";

        }

        return "true";

    }


}


http://www.slipp.net/questions/153 링크의 글을 보면 위의 중복된 코드를 제거하기 위한 작업에서 시작된 것을 알 수 있다. 음.... 중복 코드 제거 작업 이전에 한 가지 먼저 처리하고 싶은 것이 있다. 그것은 ApiUsersController 로부터 중복 검사 로직을 분리하는 것이다. ApiUsersController 클래스는 두 가지 책임을 가지고 있다.

  • 웹 요청 수신 및 처리 결과 응답
  • 중복 검사 구현

중복 검사 구현이 변경되어도 바뀌고, 응답 방식이 바뀌어도 바뀐다. 즉, 단일 책임 원칙 위반(SRP; Single Responsibility Principle)이다. 분리하자. 이거 분리하려면 리팩토링을 몇 단계를 거쳐야 한다.

  • 중복 검사 기능 제공하는 CheckDupService 클래스에 두 메서드를 복사하고,
  • CheckDupService 클래스의 객체를 생성하고
  • 그 객체에 위임하고
  • ApiUsersControllerTest 통과하나 확인하고,
  • CheckDupService 클래스를 CheckDupServiceImpl로 복사하고, CheckDupService를 인터페이스로 바꾸고,
  • ApiUsersControllerTest 클래스로부터 CheckDupServceImpl에 대한 단위 테스트를 작성해서 동작확인하고,
  • ApiUsersControllerTest 클래스는 CheckDupService 인터페이스를 Mock으로 하도록 바꾸고,
  • 등등등
음, 글로 일일이 정리하면 글 쓰다가 토가 나올지 모르니, 일단 중간 과정의 코드를 보자.

3. ApiUsersController에서 중복 검사 기능 별도 클래스로 분리

중복 검사 기능을 별도 클래스로 분리하고, 그것이 정상적으로 동작하는지 확인한 과정까지의 진행 결과이다. 먼저 CheckDupService 클래스의 코드는 아래와 같다. 두 메서드의 리턴 타입을 String에서 boolean으로 변경했다.

// ApiUsersController의 코드 상당 부분 이동
public class CheckDupService {
    private SocialUserService userService;

    public CheckDupService(SocialUserService userService) {
        this.userService = userService;
    }

    public boolean duplicateUserId(SocialUser loginUser, String userId) {
        SocialUser socialUser = userService.findByUserId(userId);
        if (socialUser == null)
            return false;
        return !socialUser.isSameUser(loginUser);
    }

    public boolean duplicateEmail(SocialUser loginUser, String email, ProviderType providerType) {
        SocialUser socialUser = userService.findByEmailAndProviderId(email, providerType);
        if (socialUser == null)
            return false;

        return !socialUser.isSameUser(loginUser);
    }
}

ApiUsersController 클래스는 이제 CheckDupService를 사용하도록 바뀐다.

@Controller
public class ApiUsersController {
    private CheckDupService checkDupService;

    public ApiUsersController(CheckDupService checkDupService) {
        this.checkDupService = checkDupService;
    }

    @RequestMapping("/duplicate_userid")
    @ResponseBody
    public String duplicateUserId(SocialUser loginUser, String userId) {
        return Boolean.toString(checkDupService.duplicateUserId(loginUser, userId));
    }

    @RequestMapping("/duplicate_email")
    @ResponseBody
    public String duplicateEmail(SocialUser loginUser, String email, ProviderType providerType) {
        return Boolean.toString(checkDupService.duplicateEmail(loginUser, email, providerType));
    }

}

ApiUsersController 클래스는 이제 아래와 같이 ApiUsersController에 CheckDupService 객체를 전달하도록 바뀐다.


@RunWith(value = MockitoJUnitRunner.class)

public class ApiUsersControllerTest {


    @Mock

    private SocialUserService userService;


    private ApiUsersController dut;


    @Before

    public void init() {

        CheckDupService checkDupService = new CheckDupService(userService);

        dut = new ApiUsersController(checkDupService);

    }


    // 테스트 메서드는 바뀌지 않음

    @Test

    public void duplicateUserId_login_isSameUser() {

        when(userService.findByUserId(USER_ID)).thenReturn(USER1);

        String actual = dut.duplicateUserId(USER1, USER_ID);

        assertThat(actual, is(FALSE));

    }


테스트를 돌려보면 통과다. 다음 단계로 넘어가자.


4. CheckDupService를 인터페이스로 추출하기


CheckDupService 클래스에서 인터페이스를 추출하자.


public interface CheckDupService {

    boolean duplicateUserId(SocialUser loginUser, String userId);


    boolean duplicateEmail(SocialUser loginUser, String email, ProviderType providerType);

}


// 기존 CheckDupService 클래스를 CheckDupServiceImpl 클래스로 변경

public class CheckDupServiceImpl implements CheckDupService {

    private SocialUserService userService;


    public CheckDupServiceImpl(SocialUserService userService) {

        this.userService = userService;

    }


테스트 클래스는 이제 CheckDupServiceImpl 클래스를 이용해서 객체를 생성하도록 바뀐다.


@RunWith(value = MockitoJUnitRunner.class)

public class ApiUsersControllerTest {

    ...

    @Before

    public void init() {

        CheckDupService checkDupService = new CheckDupServiceImpl(userService);

        dut = new ApiUsersController(checkDupService);

    }


5. CheckDupServiceImpl 클래스에 대한 단위 테스트 만들기


ApiUsersControllerTest 클래스는 사실상 CheckDupServiceImpl 클래스의 테스트에 가까워졌다. ApiUsersControllerTest 클래스를 복사해서 CheckDupServiceImpl 클래스에 대한 단위 테스트를 만들자.


@RunWith(value = MockitoJUnitRunner.class)

public class CheckDupServiceImplTest {

    public static final String USER_ID = "userId";

    public static final String EMAIL = "email@slipp.net";

    public static final SocialUser USER1 = new SocialUser(1L);

    public static final SocialUser USER2 = new SocialUser(2L);


    @Mock

    private SocialUserService userService;


    private CheckDupService checkDupService;


    @Before

    public void init() {

        checkDupService = new CheckDupServiceImpl(userService);

    }


    @Test

    public void duplicateUserId_login_isSameUser() {

        when(userService.findByUserId(USER_ID)).thenReturn(USER1);

        boolean actual = checkDupService.duplicateUserId(USER1, USER_ID);

        assertThat(actual, is(false));

    }


    @Test

    public void duplicateUserId_login_isNotSameUser() {

        when(userService.findByUserId(USER_ID)).thenReturn(USER1);

        boolean actual = checkDupService.duplicateUserId(new SocialUser(2L), USER_ID);

        assertThat(actual, is(true));

    }


    @Test

    public void duplicateEmail_doesnot_existed() {

        boolean actual = checkDupService.duplicateEmail(

               SocialUser.GUEST_USER, USER_ID, ProviderType.slipp);

        assertThat(actual, is(false));

    }


    @Test

    public void duplicateEmail_login_isSameUser() {

        when(userService.findByEmailAndProviderId(EMAIL, ProviderType.slipp)).thenReturn(USER1);

        boolean actual = checkDupService.duplicateEmail(USER1, EMAIL, ProviderType.slipp);

        assertThat(actual, is(false));

    }


    @Test

    public void duplicateEmail_login_isNotSameUser() {

        when(userService.findByEmailAndProviderId(EMAIL, ProviderType.slipp)).thenReturn(USER1);

        boolean actual = checkDupService.duplicateEmail(USER2, EMAIL, ProviderType.slipp);

        assertThat(actual, is(true));

    }

}


테스트 실행, 통과다.


6. ApiUsersControllerTest 클래스에서 UserService 관련 코드 제거


이제 ApiUsersController 클래스는 더 이상 UserService를 사용하지 않으므로, UserService Mock 객체를 만들 필요가 없다. ApiUsersController 협업 대상이 CheckDupService로 변경되었으므로, CheckDupService에 대한 Mock을 만들어서 테스트하도록 ApiUsersControllerTest를 바꾸자.


@RunWith(value = MockitoJUnitRunner.class)

public class ApiUsersControllerTest {

    public static final String USER_ID = "userId";

    public static final String EMAIL = "email@slipp.net";

    public static final SocialUser USER1 = new SocialUser(1L);

    public static final SocialUser USER2 = new SocialUser(2L);

    public static final String TRUE = "true";

    public static final String FALSE = "false";


    @Mock

    private CheckDupService checkDupService;


    private ApiUsersController dut;


    @Before

    public void init() {

        dut = new ApiUsersController(checkDupService);

    }


    @Test

    public void duplicateUserId_login_isSameUser() {

        when(

             checkDupService.duplicateUserId(any(SocialUser.class), anyString())

        ).thenReturn(Boolean.FALSE);

        String actual = dut.duplicateUserId(USER1, USER_ID);

        assertThat(actual, is(FALSE));

    }


    @Test

    public void duplicateUserId_login_isNotSameUser() {

        when(

            checkDupService.duplicateUserId(any(SocialUser.class), anyString())

        ).thenReturn(Boolean.TRUE);


        String actual = dut.duplicateUserId(USER2, USER_ID);

        assertThat(actual, is(TRUE));

    }


    /* 더 이상 필요 없으므로 삭제!

    @Test

    public void duplicateEmail_doesnot_existed() {

        String actual = dut.duplicateEmail(SocialUser.GUEST_USER, USER_ID, ProviderType.slipp);

        assertThat(actual, is(FALSE));

    }

    */


    @Test

    public void duplicateEmail_login_isSameUser() {

        when(

            checkDupService.duplicateEmail(

                any(SocialUser.class), anyString(), any(ProviderType.class))

        ).thenReturn(Boolean.FALSE);

        String actual = dut.duplicateEmail(USER1, EMAIL, ProviderType.slipp);

        assertThat(actual, is(FALSE));

    }


    @Test

    public void duplicateEmail_login_isNotSameUser() {

        when(

            checkDupService.duplicateEmail(

                any(SocialUser.class), anyString(), any(ProviderType.class))

        ).thenReturn(Boolean.TRUE);

        String actual = dut.duplicateEmail(USER2, EMAIL, ProviderType.slipp);

        assertThat(actual, is(TRUE));

    }

}


7. 다시 최초의 문제로 돌아가


최초의 문제는 ApiUsersController 클래스의 아이디/이메일이 이미 존재하는지 확인하는 코드에서 중복이 발생해서 그걸 빼고 싶은 것이었다. 이 부분이 지금은 CheckDupServiceImpl 클래스로 옮겨졌으니, 그 부분의 코드를 보자.


public class CheckDupServiceImpl implements CheckDupService {

    ...

    @Override

    public boolean duplicateUserId(SocialUser loginUser, String userId) {

        SocialUser socialUser = userService.findByUserId(userId);

        if (socialUser == null)

            return false;

        return !socialUser.isSameUser(loginUser);

    }


    @Override

    public boolean duplicateEmail(SocialUser loginUser, String email, ProviderType providerType) {

        SocialUser socialUser = userService.findByEmailAndProviderId(email, providerType);

        if (socialUser == null)

            return false;

        return !socialUser.isSameUser(loginUser);

    }

}


굵게 표시한 부분이 중복된 코드인데, 이를 어떻게 없애면 좋을까? 의미를 먼저 파악해보자.

  • userId와 일치하는 유저가 존재하는지 검사한다. 존재하지 않으면 false를 리턴한다.
  • 단, 현재 로그인한 유저의 userId를 입력한 경우에는 false를 리턴한다.
음,,, 두 번째의 의미를 잘 모르겠다. 이 의미를 알면 조금 더 정리해 볼 수 있을 것 같은데, 지금으로서는 단순히 메서드로 빼는 것 정도에서 끝내야 겠다.

public class CheckDupServiceImpl implements CheckDupService {
    private SocialUserService userService;

    public CheckDupServiceImpl(SocialUserService userService) {
        this.userService = userService;
    }

    @Override
    public boolean duplicateUserId(SocialUser loginUser, String userId) {
        SocialUser socialUser = userService.findByUserId(userId);
        return doSomeMeaningCheck(loginUser, socialUser);
    }

    @Override
    public boolean duplicateEmail(SocialUser loginUser, String email, ProviderType providerType) {
        SocialUser socialUser = userService.findByEmailAndProviderId(email, providerType);
        return doSomeMeaningCheck(loginUser, socialUser);
    }

    private boolean doSomeMeaningCheck(SocialUser loginUser, SocialUser socialUser) {
        if (socialUser == null) return false;
        return ! socialUser.isSameUser(loginUser);
    }
}

CheckDupServiceImplTest를 실행해보자. 녹색이다. 테스트 통과다. doSomeMeaningCheck() 메서드는 아직 의미를 정확하게 몰라서 메서드 이름을 이렇게 지었다.


8. 의미에 맞는 기능 분리


약간의 대화를 거쳐서 doSomeMeaningCheck() 메서드의 의미를 알았다. 음, 정확하게는 다음의 두 기능이 필요한 것이다.

  • 회원 가입시 사용할 사용자아이디/이메일 중복 검사 기능
  • 회원 정보 수정에서 사용자아이디/이메일 중복 검사 기능
    • 현재와 동일한 사용자아이디/이메일 입력시 중복으로 처리하지 않음

이 두 가지는 의미가 다소 다르기 때문에 분리된 메서드를 제공하는 것이 좋지 않을까 하는 생각이 든다. 예를 들면 아래와 같은 분리가 필요하지 않을까? 이건 나중에 다시 생각해보고, 쉬운 것 부터 해보자.


9. 코드 정리: 메서드 이름 변경


CheckDupService 인터페이스는 다음과 같이 정의되어 있다.


public interface CheckDupService {

    boolean duplicateUserId(SocialUser loginUser, String userId);

    boolean duplicateEmail(SocialUser loginUser, String email, ProviderType providerType);

}


duplicateUserId 란 이름은 "중복된 사용자 ID" 이므로, 검사한다는 의미를 부여할 수 있도록 아래와 같이 check라는 단어를 붙여보자.


public interface CheckDupService {

    boolean checkDuplicateUserId(SocialUser loginUser, String userId);


    boolean checkDuplicateEmail(SocialUser loginUser, String email, ProviderType providerType);

}


이름을 변경했으면 단위 테스트를 실행해서 통과되는지 확인한다.


ApiUsersController 클래스도 동일하게 check 단어를 붙여주자.


@Controller

public class ApiUsersController {

    ...

    @RequestMapping("/duplicate_userid")

    @ResponseBody

    public String checkDuplicateUserId(SocialUser loginUser, String userId) {

        return Boolean.toString(checkDupService.checkDuplicateUserId(loginUser, userId));

    }


    @RequestMapping("/duplicate_email")

    @ResponseBody

    public String checkDuplicateEmail(SocialUser loginUser, String email, ProviderType providerType) {

        return Boolean.toString(checkDupService.checkDuplicateEmail(loginUser, email, providerType));

    }


}


테스트 메서드의 이름도 변경해주자.


@RunWith(value = MockitoJUnitRunner.class)

public class CheckDupServiceImplTest {

    public static final String USER_ID = "userId";

    public static final String EMAIL = "email@slipp.net";

    public static final SocialUser USER1 = new SocialUser(1L);

    public static final SocialUser USER2 = new SocialUser(2L);


    @Mock

    private SocialUserService userService;


    private CheckDupService checkDupService;


    @Before

    public void init() {

        checkDupService = new CheckDupServiceImpl(userService);

    }


    @Test

    public void shouldReturnFalseWhenLoggedUserInputSelfCurrentUserId() {

        when(userService.findByUserId(USER_ID)).thenReturn(USER1);

        boolean actual = checkDupService.checkDuplicateUserId(USER1, USER_ID);

        assertThat(actual, is(false));

    }


    @Test

    public void shouldReturnTrueWhenLoggedUserInputUserIdOfAnotherUser() {

        when(userService.findByUserId(USER_ID)).thenReturn(USER1);

        boolean actual = checkDupService.checkDuplicateUserId(USER2, USER_ID);

        assertThat(actual, is(true));

    }


    @Test

    public void shouldReturnFalseWhenGuestInputEmailWhichDoesNotExist() {

        when(userService.findByEmailAndProviderId(USER_ID, ProviderType.slipp)).thenReturn(null);

        boolean actual = checkDupService.checkDuplicateEmail(SocialUser.GUEST_USER, USER_ID, ProviderType.slipp);

        assertThat(actual, is(false));

    }


    @Test

    public void shouldReturnFalseWhenLoggedUserInputSelfCurrentEmail() {

        when(userService.findByEmailAndProviderId(EMAIL, ProviderType.slipp)).thenReturn(USER1);

        boolean actual = checkDupService.checkDuplicateEmail(USER1, EMAIL, ProviderType.slipp);

        assertThat(actual, is(false));

    }


    @Test

    public void shouldReturnTrueWhenLoggedUserInputEmailOfOtherUser() {

        when(userService.findByEmailAndProviderId(EMAIL, ProviderType.slipp)).thenReturn(USER1);

        boolean actual = checkDupService.checkDuplicateEmail(USER2, EMAIL, ProviderType.slipp);

        assertThat(actual, is(true));

    }

}


@RunWith(value = MockitoJUnitRunner.class)

public class ApiUsersControllerTest {

    public static final String USER_ID = "userId";

    public static final String EMAIL = "email@slipp.net";

    public static final SocialUser USER1 = new SocialUser(1L);

    public static final SocialUser USER2 = new SocialUser(2L);

    public static final String TRUE = "true";

    public static final String FALSE = "false";


    @Mock

    private CheckDupService checkDupService;


    private ApiUsersController dut;


    @Before

    public void init() {

        dut = new ApiUsersController(checkDupService);

    }


    @Test

    public void shouldReturnFalseWhenCheckDuplicateUserIdReturnFalse() {

        when(checkDupService.checkDuplicateUserId(any(SocialUser.class), anyString())).thenReturn(Boolean.FALSE);

        String actual = dut.checkDuplicateUserId(USER1, USER_ID);

        assertThat(actual, is(FALSE));

    }


    @Test

    public void shouldReturnTrueWhenCheckDuplicateUserIdReturnTrue() {

        when(checkDupService.checkDuplicateUserId(any(SocialUser.class), anyString())).thenReturn(Boolean.TRUE);

        String actual = dut.checkDuplicateUserId(new SocialUser(2L), USER_ID);

        assertThat(actual, is(TRUE));

    }


    @Test

    public void shouldReturnFalseWhenCheckDuplicateEmailReturnFalse() {

        when(checkDupService.checkDuplicateEmail(any(SocialUser.class), anyString(), any(ProviderType.class))).thenReturn(Boolean.FALSE);

        String actual = dut.checkDuplicateEmail(USER1, EMAIL, ProviderType.slipp);

        assertThat(actual, is(FALSE));

    }


    @Test

    public void shouldReturnFalseWhenCheckDuplicateEmailReturnTrue() {

        when(checkDupService.checkDuplicateEmail(any(SocialUser.class), anyString(), any(ProviderType.class))).thenReturn(Boolean.TRUE);

        String actual = dut.checkDuplicateEmail(USER2, EMAIL, ProviderType.slipp);

        assertThat(actual, is(TRUE));

    }

}


의미에 맞는 기능분리까지 하고 싶으나, UI 코드의 상황을 잘 모르는 관계로 여기에서 마무리 지어본다.



저작자 표시 비영리 변경 금지
신고
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. maruldy 2013.05.23 14:53 신고  댓글주소  수정/삭제  댓글쓰기

    안녕하세요. 종종 와서 도움받고 갑니다.. ^^;

    리팩토링 글을 읽다가.. 궁금한점이 있어서 댓글을 달게 됐습니다 ^^;
    java 에서 상호참조는 절대로 하면 안되는 건가요?

    어떤분은 상호참조는 안좋다.
    또 누구는 reference 처리만 잘해주면 좋다..
    패키지 분류만 잘 해놨다면 같은 패키지 내에서는 상호참조는 허용해도 좋다.

    등등 얘기가 있던데요.. 솔직히 어떤말이 정답인지를 잘 모르겠어서요..

    추상 클래스 , 인터페이스 기반이던 구현클래스 기반이던 일단 서로 참조를 하게 되면
    각각 클래스 내용을 수정할 때 심적 소모가 더 들것 같긴 하지만..
    그래도 쓰면 가끔 편할것 같아서 쓰고싶을때가 있다.. 정도로 생각하고 있습니다..

    최범균님은 어떻게 생각하시는지요.. 조언 부탁드립니다.

    • 최범균 madvirus 2013.05.23 16:48 신고  댓글주소  수정/삭제

      이런 류에 정답은 없지만,
      부득이한 경우가 아니라면 상호 참조는 가급적(!) 하지 않는 것이 좋을 것 같습니다. 특히, 서로 다른 패키지 간의 상호 참조/순환 참조는 하지 않는 것이 코드 변경에 유리하다고 생각합니다.
      물론, 같은 패키지 내에서는 필요에 따라 상호 참조가 발생할 수 있겠죠.

    • maruldy 2013.05.23 17:48 신고  댓글주소  수정/삭제

      부득이한 경우가 아니라면 상호 참조는 하지 않는것이 좋지만, 같은 패키지 내에서는 필요에 의해 상호참조를 허용할 수 있다는 말씀이시군요.

      시간 내서 답변 해주셔서 감사합니다. 참고 하겠습니다! ^^ㅎㅎ;

  2. 나그네 2014.03.11 22:37 신고  댓글주소  수정/삭제  댓글쓰기

    열심히 보구 배우는 개발자 입니다.
    위에 리펙토링 한 소스 보구 궁금한게 있어서 리플답니다.
    ApiUsersControllerTest에 각 테스트케이스에 when(checkDupService.checkDuplicateUserId(any(SocialUser.class), anyString())).thenReturn(Boolean.FALSE); 이런식으로 결과값을 미리 지정을 하시고 마지막에 assertThat(actual, is(FALSE)); 라고 작성하셨는데 when 문장이 필요한건가요?
    의미가 별루 없어 보이는데....
    이미 when문장에서 리턴값을 true 또는 false라고 명시하고 assert문장에서 그것이 맞는지 검증하는 부분은 불필요해보여서요..^^;
    어차피 String actual = dut.checkDuplicateUserId(USER1, USER_ID); 이문장은 내부적으로 checkDupService.checkDuplicateUserId(..., ..., ...)를 호출하기 때문에 무조건 thenReturn값을 반환할텐데...

    혹시나 다른 이유가 있어서 적으신건지 궁금해서요 ^^;;;

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

최근에 시작한 프로젝트는 모사의 기간계 시스템의 기능을 개선하는 작업이다. 이 기간계 시스템은 다음의 기술들을 사용한다.

  • 클라이언트 영역
    • 마이플랫폼
  • 서버 영역
    • 글루(glue) 프레임워크 (포스데이터에서 만든 프레임워크)
      • 스프링 프레임워크 기반 (DB 연동, 트랜잭션 처리 등을 글루 프레임워크에서 확장)
    • 글루를 위한 마이플랫폼용 어댑터
개발과정은 주로 다음과 같다.
  • 서버사이드
    • 사용할 SQL 쿼리를 XML 파일에 추가한다.
    • 글루의 AD(ActivityDiagram)에서 Activity 및 Activity 흐름을 정의한다.
      • 이 Activity에서 DAO를 이용해서 쿼리를 실행한다.
  • 클라이언트 사이드
    • 화면을 만든다.
    • 화면에서 특정 이벤트(조회 버튼 클릭 등)가 발생하면 서버에 특정 Activity 흐름 실행을 요청하는 코드를 작성한다.
    • 응답을 받아 화면에 반영하는 코드를 작성한다.
스프링 설정 파일에는 다음과 같은 것들이 정의되어 있다.
  • DataSource / 모든 Activity에서 사용할 DAO / 트랜잭션 매니저

테스트하는 과정


코드를 만들면 개발자가 만든 코드가 잘 동작하는지 확인을 하게 되는데, 이 과정은 아래와 같다.

  1. 로컬에 웹로직을 실행환다.
  2. 서버 어플리케이션을 war로 묶어 웹로직에 배포한다. (평균 20~30초 정도 소요)
  3. 마이플랫폼 화면을 실행한다.
  4. 화면 조작을 통해 서버와 통신을 하고, 이 과정에서 눈으로 확인한다. (5초에서 심하면 1분 이상 소요)

그리고, 서버 영역에서 쿼리 변경, Glue AD 변경, 직접 구현한 Activity 클래스 변경 등이 발생하면 2-4 과정을 다시한다. 문제는 아주 사소한 쿼리 변경 조차도 이게 정상적으로 동작하는지 확인하려면 보통 1분 이상 소요된다는 점이다. 웹로직에 배포하고, 클라이언트를 실행해서 화면 조작을 통해 눈으로 확인해야 한다. 이거 참,, 참을 수 없는 상황이다.


원하는 것


원하는 건 이거다.

  • 서버 영역 코드가 잘 동작하는 지 확인하기 위해서 클라이언트를 최대한 사용하지 않는 것
  • 웹로직에 배포 없이 서버 영역 코드를 확인하는 것.
즉, 마이플랫폼을 최대한 사용하지 않고, 웹로직 서버를 실행하지 않으면서 서버 코드를 테스트하고 싶은 것이다. 서버 영역의 코드를 테스트하기 위해 마이플랫폼 코드까지 작성해야 하고, 마임플랫폼에서 서버를 호출해 주어야 하다보니, 뭔가 잘 안되면 이게 서버 문제인지 마이플랫폼 코드를 잘못 작성한 건지 확인하는데에도 시간이 많이 소요된다.

테스트 대상

대부분 DB 데이터 중심으로 작업이 이루어지기 때문에, 쿼리 자체는 오렌지나 SQLDeveloper와 같은 오라클 클라이언트 도구를 통해서 거의 검증이 된다. 마이플랫폼을 통해서 확인하고 싶은 것은 서버쪽 글루 AD가 올바르게 동작하는지 확인하는 것이다.

글루를 이용해서 프로그래밍 해 본 경험이 있다면 다음과 같은 Activity Diagram을 그려봤을 것이다. (난 처음 그려봤다.) 클라이언트(마이플랫폼이나 웹브라우저)에서 요청을 보내면, 글루 프레임워크는 요청에서 지정한 Activity Diagram(AD) 코드를 선택한 뒤에 그 코드에 정의된 순서에 맞춰 차례대로 Activity를 실행한다.


위 그림에서 한 개의 타원이 각각 1개의 Activity를 의미하며, 클라이언트의 요청이 들어올 경우 시작부터 끝에 이르기까지 순차적으로 연결된 Activity 들이 실행된다. 클라이언트가 요청을 보내면 위 그림에서 굵은 선으로 표시한 것과 비슷하게 시작 지점부터 끝 지점까지 실행이되는데, 바로 이 하나의 흐름이 정상적으로 동작하는 지 확인하기 위해 클라이언트를 실행하는 것이다.


JUnit을 테스트 실행의 어려움


약간(?)의 분석 과정을 거쳐 글루 AD의 한 흐름을 테스트하려면 다음의 코드를 실행하면 된다는 것을 알아내었다. (글루 프레임워크 소스는 없었기에 디컴파일러의 힘도 좀 빌렸다.)


// AD를 선택하고 차례대로 실행해주는 컨트롤러

PosBizControlIF controller = PosBizController.getController();


PosContext posContext = new PosContext();


// 클라이언트의 요청 정보를 생성

posContext.put("ServiceName", "A1101010010F-service");

posContext.put("cobSysList", "1");

posContext.put("CODE_ID", "C51010");


// AD 실행

controller.doAction(posContext);


// 결과 확인

PosRowSet rowSet = (PosRowSet) posContext.get("ds_sysList");


그런데, 문제는 PosBizControllIF의 구현 클래스인 PosBizController가 사용하는  초기화 코드에 있다. 이 초기화 코드는 최종적으로 스프링의 XmlBeanFactory를 생성하는데, 이 때 사용하는 스프링 설정 경로가 아래와 같이 하드코딩되어 있다.


// PosBizController의 생성자 부분의 코드

((PosServiceLoader)PosContext.getBeanFactory().getBeanObject("serviceLoader"));  


// PosContext.getBeanFactory()

public static PosBeanFactory getBeanFactory() {

    ...

    PosBeanFactory factory = new PosBeanFactoryImpl();

    ...

}


// 하드 코딩 되어 있는 PosBeanFactoryImpl

public class PosBeanFactoryImpl implements PosBeanFactory {

    protected XmlBeanFactory ctx;


    public PosBeanFactoryImpl() {

        ctx = new XmlBeanFactory(new ClassPathResource("applicationContext.xml"));

    }


위 클래스들은 제공되는 jar 파일에 포함되어 있고 소스도 없기 때문에 (위 내용은 디컴파일해서 알아냈다), 소스를 수정할 수 없다. (디컴파일한 코드를 수정해서 다시 밀어넣고 싶지 않았고, 이 jar 파일을 사용하는 다른 프로젝트가 수십개에 달한다.)


제품용 코드와 테스트용 코드를 별도 소스폴더로 구분하고 각각 다른 바이너리 디렉토리에 생성되도록 한 뒤에 테스트용 클래스패스의 우선순위를 높여주면, 일단 같은 파일 이름을 사용하면서 테스트 코드에서 테스트용 스프링 설정을 사용할 수 있다. 하지만, 여전히 다른 이름을 가진 스프링 설정 파일을 사용할 수는 없다.


javassit를 이용한 클래스 변경


그래서, 런타임에 클래스를 조작해서 설정 파일을 변경하는 방법을 도전해봤다. 클래스 변경 도구로는 여러 프로젝트에서 사용되는 javassit를 선택했다. 약간의 테스트를 거쳐 변경에 성공했다. 다행히 내가 하려는 건 간단하게 할 수 있었다. 실제 코드는 다음과 같다.


public abstract class AbstractPosBizControllerTest {


    protected PosBizControlIF controller;


    @Before

    public void init() throws Exception {

        ClassPool pool = ClassPool.getDefault();

        CtClass cc = pool.get("com.posdata.glue.bean.PosBeanFactoryImpl");

        CtConstructor c = cc.getDeclaredConstructor(null);

        c.insertAfter("this.ctx = new org.springframework.beans.factory.xml.XmlBeanFactory("+

            "new org.springframework.core.io.ClassPathResource("+

            "\"applicationContextForUnitTest.xml\"));");

        cc.toClass();


        controller = PosBizController.getController();

    }

}


위 코드에서 c.insertAfter()는 해당 생성자에 지정한 코드를 추가하는 것으로 결과적으로 PosBeanFactoryImpl 클래스의 생성자를 다음과 같이 변경해준다.


    public PosBeanFactoryImpl() {

        ctx = new XmlBeanFactory(new ClassPathResource("applicationContext.xml"));

        ctx = new XmlBeanFactory(new ClassPathResource("applicationContextForUnitTest.xml"));

    }


따라서, ctx는 최종적으로 applicationContext.xml 파일이 아닌 applicationContextForUnitTest.xml 파일을 사용해서 BeanFactory를 생성하게 된다. 각 테스트 코드는 앞서 작성한 추상 클래스를 상속받아 로컬에서 서버배포 없이 AD 테스트를 수행할 수 있게 되었다.


public class SomeADTest extends AbstractPosBizControllerTest {


    @Test

    public void shouldHaveResultRows() {

        PosContext posContext = new PosContext();

        posContext.put("ServiceName", "SomeAD-service");

        posContext.put("cobSysList", "1");

        posContext.put("CODE_ID", "C51010");

        controller.doAction(posContext);


        PosRowSet rowSet = (PosRowSet) posContext.get("ds_sysList");

        assertTrue(rowSet.count() > 0);

    }


}


이제 쿼리 설정 XML 파일의 사소한 변경, 글루 AD의 사소한 변경 등 서버의 사소한 변경 이후 제대로 동작하는 지 테스트하기 위해 낭비되는 시간(war 묶기->웹로직 배포->컨텍스트재시작, 마이플랫폼 클라이언트 UI 클릭질, 눈으로 확인: 보통 1분 이상)을 줄일 수 있게 되었다. 이는 곧 개발 속도의 향상이고, 이는 다시 빠른 퇴근과 잉여시간 발생으로 이어지리라!


저작자 표시 비영리 변경 금지
신고
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 박병상 2013.06.07 15:51 신고  댓글주소  수정/삭제  댓글쓰기

    ㅋ. 최차장님 안녕하세요.
    전에 유지보수하던 박병상입니다.
    GLUE 쪽을 건드리시고 계시네요.. ㅎㅎ

    저때 저걸 할수 있었다면 좋았을텐데.. ㅎ
    건강하세요.. ㅎ

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

테스트를 작성하다보면 DB와 연동되는 부분을 테스트해야 할 때가 있다. 특히 UI 레이어를 제외한 나머지 레이어(어플리케이션, 도메인, 인프라)에 대한 통합 테스트를 진행하려면 DB 데이터 사용은 필수적이다. 그런데, DB와의 연동 테스트를 작성하다보면 귀찮은 작업이 있는데, 그것은 바로 데이터의 초기화작업이다.


JUnit을 이용해서 작성한 테스트를 실행할 때 마다 DB에 삽입되어 있는 데이터가 다르다면, 테스트를 실행할 때 마다 DB에 포함되어 있는 데이터에 따라 테스트 코드를 변경해야 하는 귀찮은 상황이 발생하기도 한다. 이렇게 되면 자동화된 테스트는 물건너 가버린다. 따라서, DB까지 모두 사용하는 테스트를 작성하려면 테스트를 수행하기 전에 DB 상태를 일정하게 만들어주는 기능이 필요하다. 본 글에서는 DbUnit을 이용해서 이런 상황을 처리하는 방법을 살펴보고자 한다.


본 글에서는 DbUnit 자체에 대한 내용은 자세하게 다루지 않으며, DbUnit을 이용해서 테스트를 실행할 때 DB 데이터를 초기화하는 방법만을 살펴볼 것이다. 데이터 초기화 이외에도 DbUnit을 사용하면 테스트 후에 DB 상태가 원하는 결과로 들어가 있는지 확인하는 기능도 제공하는데, 이에 대한 내용이 궁금하다면 http://www.dbunit.org/howto.html#assertdata 를 방문해보기 바란다.


DbUnit을 이용한 DB 데이터 초기화


DbUnit을 이용해서 DB 데이터를 초기화하려면 다음의 두 가지 작업만 하면 된다.


  • 초기 데이터를 정의하고 있는 XML 파일을 작성한다.
  • XML 파일로부터 데이터를 읽어와 DB를 초기화한다.

초기 데이터를 정의한 XML 파일 작성


매번 동일한 상태로 DB를 만들기 위해 사용되는 XML 파일을 만드는 방법은 간단하다. 데이터를 초기화할 테이블, 테이블 컬럼 목록, 그리고 사용할 초기 데이터를 지정해주기만 하면 된다. 다음은 XML 파일의 작성 예이다.


<?xml version="1.0" encoding="UTF-8" ?>


<dataset>

    <table name="IDREPO">

        <column>ENTITY</column>

        <column>NEXTVAL</column>

        <row>

            <value>USER</value>

            <value>1000</value>

        </row>

        <row>

            <value>DEAL</value>

            <value>1000</value>

        </row>

    </table>


    <table name="USER">

        <column>USER_ID</column>

        <column>EMAIL</column>

        <column>ENC_PASSWORD</column>

        <column>NAME</column>

        <column>EMAIL_AUTH_YN</column>

        <column>AUTH_KEY</column>

        <row>

            <value>1</value>

            <value>madvirus@madvirus.net</value>

            <value>[enc]password</value>

            <value>최범균</value>

            <value>Y</value>

            <value>testauthkey</value>

        </row>

    </table>

</dataset>


위 파일에서 각 태그틑 다음의 의미를 갖는다.

  • table: 테이블을 의미한다. name 속성으로 초기화할 테이블 이름을 지정한다.
    • column: 데이터 초기화시에 사용할 컬럼 목록을 지정한다.
    • row: 한 개의 레코드를 의미한다.
      • value: 해당하는 컬럼에 삽입될 값을 지정한다. 동일한 순서의 column 태그와 쌍을 갖는다.
예를 들어, 위 설정의 경우  IDREPO 테이블에 두 개의 레코드를 삽입하는데, 첫 번째 레코드는 ENTITY 컬럼과 NEXTVAL 컬럼의 값으로 각각 USER와 1000을 사용한다. 비슷하게 USER 테이블에도 USER_ID 컬럼 값이 1이고 EMAIL 컬럼 값이 madvirus@madvirus.net인 한 개의 레코드를 삽입한다.


XML 파일로부터 DB 데이터 초기화하기


알맞게 XML 파일을 작성했다면, 그 다음으로 할 작업은 이 XML 파일을 이용해서 DB를 초기화하는 것이다. 이를 위해 작성한 코드는 다음과 같다.


-- SeedDataLoader 클래스


import java.io.FileNotFoundException;

import java.io.FileReader;

import java.sql.SQLException;


import org.dbunit.database.IDatabaseConnection;

import org.dbunit.dataset.DataSetException;

import org.dbunit.dataset.IDataSet;

import org.dbunit.dataset.xml.XmlDataSet;

import org.dbunit.operation.DatabaseOperation;


public class SeedDataLoader {


    public static void loadIntegrationTestDataSeed() {

        loadData("src/test/resources/inttest-seed-data.xml");

    }


    public static void loadData(String seedFile) {

        IDatabaseConnection conn = null;

        try {

            conn = DbUnitConnectionUtil.getConnection();

            IDataSet data = createDataSet(seedFile);

            DatabaseOperation.CLEAN_INSERT.execute(conn, data);

        } catch (Throwable e) {

            throw new RuntimeException(e);

        } finally {

            close(conn);

        }

    }1


    private static IDataSet createDataSet(String seedFile)

            throws DataSetException, FileNotFoundException {

        return new XmlDataSet(new FileReader(seedFile));

    }


    private static void close(IDatabaseConnection conn) {

        if (conn != null) {

            try {

                conn.close();

            } catch (SQLException e) {

            }

        }

    }

}


-- DbUnitConnectionUtil 클래스

public class DbUnitConnectionUtil {


    public static IDatabaseConnection getConnection()

            throws ClassNotFoundException, SQLException, DatabaseUnitException {

        Class.forName(JdbcConstants.DRIVER);

        return new DatabaseConnection(DriverManager.getConnection(

                JdbcConstants.JDBCURL, JdbcConstants.USER,

                JdbcConstants.PASSWORD));

    }


}


위 코드에서 핵심 부분은 다음의 두 부분이다.


- XmlDataSet을 이용해서 초기화할 데이터 집합을 생성하는 부분

- DataOperations.CLEAN_INSERT.execute()를 이용해서 데이터를 초기화하는 부분


XmlDataSet은 앞서 작성했던 XML을 이용해서 데이터 집합을 생성할 때 사용된다. 이렇게 생성한 데이터 집합을 테스트 DB에 반영하려면 DataOperations가 제공하는 CLEAN_INSERT를 사용하면 된다. CLEAN_INSERT는 전달받은 데이터집합과 관련된 모든 테이블의 데이터를 삭제한 뒤에, 데이터를 추가한다. 이 작업을 테스트 전에 수행하면 모든 데이터를 지우고 설정된 데이터 집합을 삽입하기 때문에, 항상 동일한 상태로 테스트를 실행할 수 있게 된다.


테스트 코드에서 사용하기


실제 테스트 코드에서 사용하려면 다음과 같이 테스트를 수행하기 전에 DB를 초기화해주면 된다.


@RunWith(SpringJUnit4ClassRunner.class)

@ContextConfiguration(classes = { ApplicationContextConfig.class })

public class IntTestBase {


    @BeforeClass

    public static void setup() {

        SeedDataLoader.loadIntegrationTestDataSeed();

    }

}



public class DealRepositoryIntTest extends IntTestBase {


    @Autowired

    private DealRepository dealRepository;


    @Test

    public void shouldReturnDealsWhichIdIsLessThanThreeByUsingSpecAndPageable() {

        Specification<Deal> specs = DealSpecs.beforeDeal(4L);

        Pageable pageable = createPageable();

        List<Deal> deals = dealRepository.findBySpecification(specs, pageable);

        assertEquals(2, deals.size());

        assertEquals(3L, getIdOfIdexedDeal(deals, 0));

        assertEquals(2L, getIdOfIdexedDeal(deals, 1));

    }

    ...

}


필자의 경우 동일한 데이터 집합을 사용하는 테스트 기반 클래스(IntTestBase)를 만들고, 이 기반 클래스를 상속받은 클래스에서 DB를 사용하는 테스트를 수행하도록 만든다. @BeforeClass를 사용해서 DB를 초기화하므로, 테스트에 포함된 모든 테스트 메서드를 실행하기 전에 한 번 DB를 초기화한다. 만약 테스트 메서드마다 초기화를 해 주어야 한다면, @Before를 이용해서 초기화 작업을 실행해주면 된다.


앞서 작성한 SeedDataLoader.loadData() 메서드를 사용하면 테스트 클래스마다 서로 다른 데이터 셋을 사용하도록 설정할 수도 있으므로, 상황에 따라 기반 클래스를 상속받지 않고 직접 초기화 작업을 수행해도 된다.


XML 이외도 CVS 포맷 등을 이용해서 데이터 집합을 만들 수도 있는데, 이에 대한 내용이 궁금하다면, http://www.dbunit.org/ 사이트에서 제공하는 문서를 참고하면 된다.


저작자 표시 비영리 변경 금지
신고
Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.