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

스프링4 입문

스프링 4

DDD Start

객체 지향과
디자인 패턴

JSP 2.3

JPA 입문
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

댓글을 달아 주세요

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

지금까지 각각의 구현들을 만들어나갔다. 그러면서 채워진 도메인 영영역은 아래와 같다. 아래 그림은 DestinationStorage과 DestinationStorageFactory에 대한 구현 클래스를 포함하고 있는데, MediaSourceFile과 ResultCallback도 동일하게 구현체를 일부 구현하였다. 아래 그림에서는 공간 제약 때문에 표시하지 않았다.



현재까지 영속성에 대한 것 없이 위 내용을 구현했다. Job의 transcode() 기능을 구현했고, JobRepository와 Job을 이용해서 AddJobService, TranscodingService 등을 구현했다.


안정적으로 Job의 상태를 보관하기 위해서 JobRepository DB 구현체를 만들어보자. DB 구현체를 테스트 하려면 DB 연동이 필요하다. DB를 따로 설치하고 준비하면 최초 개발에 시간이 걸리니 일단 메모리 DB인 HSQL을 사용해서 테스트를 진행해보기로 하자.


JpaJobRepository 구현 테스트 추가


JPA를 이용한 JobRepository를 구현할 것이다. JpaJobRepository 클래스를 테스트 하려면 사실상 DB 연동 등 많은 부분이 필요하기 때문에 스프링이 제공하는 테스트 지원 기능을 사용할 것이다. 데이터를 조회하는 기능으로부터 시작할 것이다.


@RunWith(SpringJUnit4ClassRunner.class)

@ContextConfiguration(classes = { ApplicationContextConfig.class })

public class JpaJobRepositoryIntTest {


    @Autowired

    private JobRepository jobRepository; // 스프링 설정에 JpaJobRepository로 등록


    @Test

    public void findById() {

        Job job = jobRepository.findById(1L);

        assertNotNull(job);

        assertTrue(job.isWaiting());

        assertEquals(2, job.getOutputFormats().size());

    }

}


도메인의 데이터 구조 표현을 위한 JobData 출현


DB와의 연동 부분은 JPA를 이용해서 처리할 것이다. 그런데, JPA를 Job 도메인 모델에 그대로 적용하기에는 한 가지 제약이 있다. 그것은 바로 JPA가 @Embeddable에 대한 상속을 지원하지 않는다는 점이다. 예를 들어, DestinationStorage는 Job에 포함되는 @Embeddable 객체이며 별도 @Entity 객체는 아니다. 그리고, DestinationStorage는 여러 하위 타입을 갖는다. 따라서 Job과 함게 DestinationStorage를 JPA를 이용해서 처리하려면 @Embeddable 객체인 DestinationStorage의 상속 관계를 설정할 수 있어야 하는데, 현재 JPA API는 이를 지원하지 않는 걸로 알고 있다. (JPA 구현체 중 TopLink와 같은 건 지원하는 걸로 알고 있지만 이는 벤더에 특화된 기능이고 표준은 아닌 듯 하다.)


특정 JPA 구현체에 의존한 코드를 만들 수도 있지만, 특정 구현체에 의존하기는 싫다. 그러면서도 Job 및 (추상화 된) 관련 객체들을 데이터 구조(테이블)에 저장할 수 있어야 한다. 그래서 선택한 방법은 다소 수고스럽더라도 도메인 객체와 DB 사이에 징검다리 역할을 해 줄 데이터 모델을 만드는 것이다.


데이터 모델은 Job 객체를 다시 복원할 수 있을 만큼의 정보를 가져야 하기에, 위 그림 상에 출현한 모든 데이터를 갖도록 구현했다.


@Entity

@Table(name = "JOB")

public class JobData {


    @Id

    @Column(name = "JOB_ID")

    @TableGenerator(name = "JOB_ID_GEN", table = "ID_GENERATOR", 

        pkColumnName = "ENTITY_NAME", pkColumnValue = "JOB", valueColumnName = "ID_VALUE")

    @GeneratedValue(strategy = GenerationType.TABLE, generator = "JOB_ID_GEN")

    private Long id;


    @Column(name = "STATE")

    @Enumerated(EnumType.STRING)

    private Job.State state;


    @Column(name = "SOURCE_URL")

    private String sourceUrl;


    @Column(name = "DESTINATION_URL")

    private String destinationUrl;


    @Column(name = "CALLBACK_URL")

    private String callbackUrl;


    @Column(name = "EXCEPTION_MESSAGE")

    private String exceptionMessage;


    @ElementCollection(fetch = FetchType.EAGER)

    @CollectionTable(name = "JOB_OUTPUTFORMAT", 

        joinColumns = { @JoinColumn(name = "JOB_ID") })

    @OrderColumn(name = "LIST_IDX")

    private List<OutputFormat> outputFormats;


   ... // getter


OutputFormat은 그 자체가 데이터이므로 OutputFormat에도 JPA 연동 정보를 추가하였다. (음, OutputFormat은 도메인 소속인데 JPA 정보가 스며들어갔다. 일단, 지금은 뭔가 동작하게 만들고 그 다음에 정리해보자.)


@Embeddable

public class OutputFormat {


    @Column(name = "WIDTH")

    private int width;


    @Column(name = "HEIGHT")

    private int height;


    @Column(name = "BITRATE")

    private int bitrate;


    @Column(name = "CONTAINER")

    @Enumerated(EnumType.STRING)

    private Container container;


    @Column(name = "VIDEO_CODEC")

    @Enumerated(EnumType.STRING)

    private VideoCodec videoCodec;


    @Column(name = "AUDIO_CODEC")

    @Enumerated(EnumType.STRING)

    private AudioCodec audioCodec;

    ...


HSQL DB 사용


Job을 저장하기 위해 사용되는 데이터 모델인 JobData 및 OutputFormat에 대한 JPA 설정을 완료했다. 이제 테스트를 위한 DB를 준비할 차례이다. 일단 지금은 메모리 DB인 HSQL DB를 사용해서 테스트 주기를 빠르게 유지하는 게 중요해 보인다. HSQL DB에 맞는 테이블 생성 쿼리는 아래와 같다.


create table ID_GENERATOR (

    ENTITY_NAME varchar(50),

    ID_VALUE int,

    primary key (ENTITY_NAME)

);


create table JOB (

    JOB_ID INT IDENTITY,

    STATE varchar(20),

    SOURCE_URL varchar(100),

    DESTINATION_URL varchar(100),

    CALLBACK_URL varchar(100),

    EXCEPTION_MESSAGE varchar(255),

    primary key (JOB_ID)

);


create table JOB_OUTPUTFORMAT (

    JOB_ID INT,

    LIST_IDX INT,

    WIDTH INT,

    HEIGHT INT,

    BITRATE INT,

    CONTAINER varchar(20),

    VIDEO_CODEC varchar(20),

    AUDIO_CODEC varchar(20)

);

create INDEX JOB_OUTPUTFORMAT_IDX ON JOB_OUTPUTFORMAT (JOB_ID, LIST_IDX);


또한, 테스트를 진행하려면 테이블에 데이터가 포함되어 있어야 한다. 테스트에 사용할 데이터를 추가해주는 쿼리는 다음과 같다.


insert into JOB values (1, 'WAITING', 'file://source.avi', 'file://dest', 'http://calback', null);

insert into JOB_OUTPUTFORMAT values (1, 0, 10, 20, 30, 'MP4', 'H264', 'AAC');

insert into JOB_OUTPUTFORMAT values (1, 1, 100, 200, 300, 'AVI', 'MPEG4', 'MP3');


insert into ID_GENERATOR values ('JOB', 10);



테스트를 실행하기 위한 스프링 설정


테스트를 실행하려면 다음을 설정해 주어야 한다.
  • DataSource 설정
  • JPA 관련 설정
  • 리포지토리 설정
  • 리포지토리가 의존하는 다른 빈에 대한 설정
HSQL DB 임베딩 설정
DataSource는 스프링이 제공하는 Embedded DB 지원 기능을 사용할 것이다. 설정은 아래와 같다.

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

<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:jdbc="http://www.springframework.org/schema/jdbc"
    xsi:schemaLocation="http://www.springframework.org/schema/beans   
       http://www.springframework.org/schema/beans/spring-beans.xsd
       http://www.springframework.org/schema/jdbc
       http://www.springframework.org/schema/jdbc/spring-jdbc.xsd">

    <jdbc:embedded-database id="dataSource" type="HSQL">
        <jdbc:script location="classpath:schema.sql" />
        <jdbc:script location="classpath:testdata.sql" />
    </jdbc:embedded-database>

</beans>

위 설정에서 schema.sql과 testdata.sql은 앞에서 살펴봤던 테이블 생성 쿼리와 데이터 추가 쿼리를 포함하고 있다.

JPA 관련 설정
JPA는 @Configuration을 이용해서 설정했다.

@Configuration
public class JpaConfig {

    @Autowired
    private DataSource dataSource;

    @Bean
    public PersistenceExceptionTranslationPostProcessor persistenceExceptionTranslationPostProcessor() {
        return new PersistenceExceptionTranslationPostProcessor();
    }

    @Bean
    public LocalContainerEntityManagerFactoryBean entityManagerFactory()
            throws PropertyVetoException {
        LocalContainerEntityManagerFactoryBean factoryBean =
                new LocalContainerEntityManagerFactoryBean();
        factoryBean.setPersistenceUnitName("s4t");
        factoryBean.setDataSource(dataSource);
        factoryBean.setJpaVendorAdapter(jpaVendorAdapter());
        return factoryBean;
    }

    @Bean
    public JpaVendorAdapter jpaVendorAdapter() {
        HibernateJpaVendorAdapter hibernateJpaVendorAdapter = new HibernateJpaVendorAdapter();
        hibernateJpaVendorAdapter.setDatabase(Database.HSQL);
        return hibernateJpaVendorAdapter;
    }

    @Bean
    public PlatformTransactionManager transactionManager()
            throws PropertyVetoException {
        JpaTransactionManager jpaTransactionManager = new JpaTransactionManager();
        jpaTransactionManager.setEntityManagerFactory(entityManagerFactory()
                .getObject());
        return jpaTransactionManager;
    }
}

리포지토리 설정
리포지토리 설정은 아래와 같다.

@Configuration
public class RepositoryConfig {

    @Bean
    public JobRepository jobRepository() {
        return new JpaJobRepository();
    }
}

설정 모으기
위 설정들을 한 파일만 참조하면 사용할 수 있도록 하기 위해 아래와 같이 별도 설정 클래스를 만들었다. 또한, 이 설정 클래스는 @Transactional 지원을 위해 @EnableTransactionManagement을 추가하였다.

@Configuration
@Import({ RepositoryConfig.class, JpaConfig.class })
@ImportResource("classpath:spring/datasource.xml")
@EnableTransactionManagement
public class ApplicationContextConfig {

}

JpaJobRepository#findById 구현 시작

이제 DB 통합 테스트를 위한 기반 환경 구축은 끝났다. 이제 앞서 만들었던 테스트를 실행해보자. 테스트를 통과하는데 실패했다.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { ApplicationContextConfig.class })
public class JpaJobRepositoryIntTest {

    @Autowired
    private JobRepository jobRepository; // 스프링 설정에 JpaJobRepository로 등록

    @Test
    public void findById() {
        Job job = jobRepository.findById(1L);
        assertNotNull(job); // 실패!
        assertTrue(job.isWaiting());
        assertEquals(2, job.getOutputFormats().size());
    }
}

아직 JpaJobRepository에 구현이 없으니 당연히 실패다. JpaJobRepository의 코드를 일부 구현했다.

@Repository
public class JpaJobRepository implements JobRepository {

    @PersistenceContext
    private EntityManager entityManager;
    
    @Transactional
    @Override
    public Job findById(Long jobId) {
        JobData jobData = entityManager.find(JobData.class, jobId);
        if (jobData == null) {
            return null;
        }
        return createJobFromJobData(jobData);
    }

    private Job createJobFromJobData(JobData jobData) {
        return null; // JobData -> Job
    }

위 코드에서 핵심은 JobData으로부터 Job 객체를 복원하는 것이다. 이를 위해서 우리는 다음의 작업을 해야 한다.
  • JobData#sourceUrl 로부터 MediaSourceFile 객체 생성
  • JobData#destinationUrl 로부터 DestinationStorage 객체 생성
  • JobData#callbackUrl 로부터 ResultCallback 객체 생성
위 작업을 하려면 각 객체 타입별 Factory가 필요하다. 이를 위해, JpaJobRepository에 각 종류별 Factory를 추가해주고 이를 사용해서 Job 객체를 생성할 수 있도록 했다.

@Repository
public class JpaJobRepository implements JobRepository {

    @PersistenceContext
    private EntityManager entityManager;

    private MediaSourceFileFactory mediaSourceFileFactory;
    private DestinationStorageFactory destinationStorageFactory;
    private ResultCallbackFactory resultCallbackFactory;

    public JpaJobRepository(MediaSourceFileFactory mediaSourceFileFactory,
            DestinationStorageFactory destinationStorageFactory,
            ResultCallbackFactory resultCallbackFactory) {
        this.mediaSourceFileFactory = mediaSourceFileFactory;
        this.destinationStorageFactory = destinationStorageFactory;
        this.resultCallbackFactory = resultCallbackFactory;
    }

    @Transactional
    @Override
    public Job findById(Long jobId) {
        JobData jobData = entityManager.find(JobData.class, jobId);
        if (jobData == null) {
            return null;
        }
        return createJobFromJobData(jobData);
    }

    private Job createJobFromJobData(JobData jobData) {
        return new Job(jobData.getId(), jobData.getState(),
                mediaSourceFileFactory.create(jobData.getSourceUrl()),
                destinationStorageFactory.create(jobData.getDestinationUrl()),
                jobData.getOutputFormats(),
                resultCallbackFactory.create(jobData.getCallbackUrl()),
                jobData.getExceptionMessage());
    }

JpaJobRepository의 생성자가 변경되었으니, 이와 관련된 스프링 설정인 RepositoryConfig도 변경해 주어야 한다.

@Configuration
public class RepositoryConfig {

    @Autowired
    private MediaSourceFileFactory mediaSourceFileFactory;
    @Autowired
    private DestinationStorageFactory destinationStorageFactory;
    @Autowired
    private ResultCallbackFactory resultCallbackFactory;

    @Bean
    public JobRepository jobRepository() {
        return new JpaJobRepository(mediaSourceFileFactory,
                destinationStorageFactory, resultCallbackFactory);
    }
}

RepositoryConfig에서는 도메인 영역의 팩토리 객체를 필요로 한다. 따라서, 도메인 영역의 팩토리 객체도 스프링 설정에 추가해 주어야 한다. DomainConfig에 이들 팩토리 객체의 설정을 추가하고, ApplicationContextConfig에 반영하자.

@Configuration
public class DomainConfig {

    @Bean
    public ResultCallbackFactory resultCallbackFactory() {
        return new DefaultResultCallbackFactory();
    }

    @Bean
    public DestinationStorageFactory destinationStorageFactory() {
        return new DefaultDestinationStorageFactory();
    }

    @Bean
    public MediaSourceFileFactory mediaSourceFileFactory() {
        return new DefaultMediaSourceFileFactory();
    }
}


@Configuration
@Import({ DomainConfig.class, RepositoryConfig.class, JpaConfig.class })
@ImportResource("classpath:spring/datasource.xml")
@EnableTransactionManagement
public class ApplicationContextConfig {

}


다시 테스트를 실행해보자. 녹색! 통과다.

JpaJobRepository#save 기능 구현

다음으로 구현할 기능은 save() 기능이다. 이 기능을 위해 테스트를 작성하였다.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { ApplicationContextConfig.class })
public class JpaJobRepositoryIntTest {

    @Autowired
    private JobRepository jobRepository;

    @Test
    public void findById() {
        ...
    }

    @Test
    public void save() {
        List<OutputFormat> outputFormats = new ArrayList<OutputFormat>();
        outputFormats.add(new OutputFormat(60, 40, 150, Container.MP4));

        Job job = new Job(
                new LocalStorageMediaSourceFile("file://./video.avi"),
                new FileDestinationStorage("file://./target"), outputFormats,
                new HttpResultCallback("http://"));
        Job savedJob = jobRepository.save(job);
        assertNotNull(savedJob); // 아직 savedJob은 null
        assertNotNull(savedJob.getId());
        assertJobsEquals(job, savedJob);
    }

    private void assertJobsEquals(Job job, Job savedJob) {
        assertEquals(job.getOutputFormats().size(), savedJob.getOutputFormats()
                .size());
    }
}

아직 save()는 null을 리턴하므로, 위 테스트는 통과하지 못한다. 간단하게 필요한 구현을 넣어봤다.

@Repository
public class JpaJobRepository implements JobRepository {
    ...
    @Transactional
    @Override
    public Job save(Job job) {
        JobData jobData = null; // Job을 JobData로 변환 필요!
        entityManager.persist(jobData);
        return createJobFromJobData(jobData);
    }

    private Job createJobFromJobData(JobData jobData) {
        return new Job(jobData.getId(), jobData.getState(),
                mediaSourceFileFactory.create(jobData.getSourceUrl()),
                destinationStorageFactory.create(jobData.getDestinationUrl()),
                jobData.getOutputFormats(),
                resultCallbackFactory.create(jobData.getCallbackUrl()),
                jobData.getExceptionMessage());
    }

}

위에서 해결해야 할 부분은 Job 객체로부터 JobData를 만들어내는 것이다. 구현 방법으로는 다음과 같은 것들이 떠오른다.
  • JobData를 생성하는데 필요한 모든 정보를 제공해주기 위해 Job에 get 메서드 추가. 즉 getMediaSourceUrl(), getResultCallbackUrl() 등의 메서드를 Job 에 추가.
  • Job의 데이터를 익스포트 해주는 빌더 사용
위 방법 중 첫 번째 방법이 쉽지만, 도메인 객체에 get을 최소화하기 위해 두 번째 방법인 빌더 패턴을 사용해보기로 하자.

Job에서 JobData를 생성하기 위해 빌더 패턴 사용하기

현재까지는 Job 객체가 변환되는 타입은 JobData 뿐이지만, 뷰 영역을 구현하게 되면 Job 객체를 뷰에 알맞게 변환해서 제공해 주어야 한다. 물론, 그 변환 타입이 JobData와 동일한 구조를 가질 수도 있지만 뷰에서 JobData를 바로 사용하면 안 된다. 지금의 JobData는 어디까지나 DB 연동을 위해서 필요했던 것이기 때문이다. 영속성 메커니즘이 DB가 아닌 파일이나 단순히 메모리로 바뀐다면 JobData는 더 이상 존재하지 않게 되므로, 뷰는 이 클래스를 사용하면 안 된다.

Job 객체로부터 JobData 객체 또는 뷰를 위한 (아직 미정인) JobView 객체를 생성하는 과정은 매우 유사하다. Job 객체로부터 JobData/JobView 객체를 생성할 때 필요한 일련의 데이터를 차례대로 받고, 그 데이터를 이용해서 각각의 객체를 생성하는 것이다.

이건 딱 빌더 패턴에 들어맞는다. 빌더 패턴으로 한 번 풀어보자.

우선, Job 으로부터 순차적으로 데이터를 받을 수 있는 빌더를 정의하자. 여기서는 Job의 데이터를 어딘가로 내보낸다는 의미에서 Exporter라는 이름을 부여하였다.

    public static interface Exporter<T> { // Job 내부에 정의함
        public void addId(Long id);

        public void addState(Job.State state);

        public void addMediaSource(String url);

        public void addDestinationStorage(String url);

        public void addResultCallback(String url);

        public void addExceptionMessage(String exceptionMessage);

        public void addOutputFormat(List<OutputFormat> outputFormat);
        
        public T build();
    }

Exporter는 Job으로부터 주요 데이터를 받을 수 있는 메서드를 정의하고 있으며, 받은 데이터로부터 새로운 데이터를 만들 수 있는 build() 메서드를 정의하고 있다.

Job은 이제 Exporter를 이용해서 익스포트 과정을 처리할 수 있다.

public class Job {
    ...
    public <T> T export(Exporter<T> exporter) {
        exporter.addId(id);
        exporter.addState(state);
        exporter.addMediaSource(mediaSourceFile.getUrl());
        exporter.addDestinationStorage(destinationStorage.getUrl());
        exporter.addResultCallback(callback.getUrl());
        exporter.addOutputFormat(getOutputFormats());
        exporter.addExceptionMessage(exceptionMessage);
        return exporter.build();
    }
    
    public static interface Exporter<T> {
        public void addId(Long id);
        ...
        public T build();
    }
}

Exporter를 사용함으로써 생기는 이점은 다음과 같다.
  • Job이 데이터 추출 과정을 제어한다.
  • Job이 데이터를 제공하므로 get 메서드를 최소화할 수 있다.
이제 Job의 데이터를 필요로 하는 곳에서는 Exporter를 구현해서 Job 객체에 전달해주기만 하면 된다. 그럼, Job으로부터 필요한 데이터를 받아와 알맞은 객체를 생성할 수 있다.


예를 들어, JobData를 생성해주는 Exporter는 다음과 같이 구현할 수 있다.

@Entity
@Table(name = "JOB")
public class JobData {
    ...
    @Id
    private Long id;
    ...
    public static class ExporterToJobData implements Job.Exporter<JobData> {

        private JobData jobData = new JobData();

        @Override
        public void addId(Long id) {
            jobData.id = id;
        }

        @Override
        public void addState(State state) {
            jobData.state = state;
        }

        @Override
        public void addMediaSource(String url) {
            jobData.sourceUrl = url;
        }

        @Override
        public void addDestinationStorage(String url) {
            jobData.destinationUrl = url;
        }

        @Override
        public void addResultCallback(String url) {
            jobData.callbackUrl = url;
        }

        @Override
        public void addExceptionMessage(String exceptionMessage) {
            jobData.exceptionMessage = exceptionMessage;
        }

        @Override
        public void addOutputFormat(List<OutputFormat> outputFormat) {
            jobData.outputFormats = outputFormat;
        }

        @Override
        public JobData build() {
            return jobData;
        }
    }
}

위 코드에서 ExporterToJobData는 JobData 클래스의 내부 클래스이다. 따라서, ExporterToJobData에서 Job의 필드에 직접 접근해서 데이터를 초기화하고 있다. 이렇게 함으로써 JobData 클래스는 불필요한 set 메서드를 제공하지 않아도 된다.

이제 Job 객체로부터 JobData를 생성하는 부분을 처리했으니, JpaJobRepository의 save() 메서드를 완성해보자.

@Repository
public class JpaJobRepository implements JobRepository {
    ...
    private Job createJobFromJobData(JobData jobData) {
        return new Job(jobData.getId(), ...);
    }

    @Transactional
    @Override
    public Job save(Job job) {
        JobData.ExporterToJobData exporter = new JobData.ExporterToJobData();
        JobData jobData = job.export(exporter);
        entityManager.persist(jobData);
        return createJobFromJobData(jobData);
    }

}

테스트 실행.... 녹색! 통과다.

현재까지 만들어진 결과물의 정적 구조는 다음과 같다.



JpaJobRepository에서 생성한 Job의 기능 확인

JpaJobRepository을 구현했으니 이제 JpaJobRepository로부터 읽어온 Job이 제대로 동작하는지 확인해보자. 이를 위해 다음과 같은 테스트를 작성했다.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { ApplicationContextConfig.class })
public class JobIntTest {

    @Autowired
    private JobRepository jobRepository;

    private Transcoder transcoder;
    private ThumbnailExtractor thumbnailExtractor;

    @Before
    public void setup() {
        transcoder = mock(Transcoder.class);
        thumbnailExtractor = mock(ThumbnailExtractor.class);
    }

    @Test
    public void jobShouldChangeStateInDB() {
        RuntimeException trancoderException = new RuntimeException("강제발생");
        when(
                transcoder.transcode(any(File.class),
                        anyListOf(OutputFormat.class))).thenThrow(
                trancoderException);

        Long jobId = new Long(1);
        Job job = jobRepository.findById(jobId); // DB로부터 Job 로딩
        try {
            job.transcode(transcoder, thumbnailExtractor); // job 기능 실행
        } catch (RuntimeException ex) {
        }

        Job updatedJob = jobRepository.findById(jobId); // DB에서 동일 Job 로딩

        assertEquals(Job.State.TRANSCODING, job.getLastState());
        assertEquals(Job.State.TRANSCODING, updatedJob.getLastState()); // 반영 확인
    }
}

위 테스트는 JpaJobRepository를 이용해서 DB로부터 Job을 읽어온다. Job의 trancode()를 실행해서 트랜스코딩 기능을 실행하는데, 이 과정에서 익셉션을 발생하도록 transcoder Mock 객체를 설정했다. 따라서, Job의 최종 상태는 TRANSCODING 이여야 한다.

메모리에서만 반영되고 DB에는 반영되지 않으면 상태를 조회하는 어플리케이션이 잘못된 상태 값을 가져가게 되므로, 같은 ID를 갖는 Job 객체를 구해서 그 객체의 상태도 TRANSCODING인지 확인한다.

위 테스트를 실행해보자. 위 코드에서 빨간색으로 표시한 부분에서 테스트가 실패한다.

Job의 changeState() 메서드 살펴보기

Job은 changeState() 메서드를 이용해서 상태를 변경한다.

public class Job {
    ...
    public void transcode(Transcoder transcoder,
            ThumbnailExtractor thumbnailExtractor) {
        try {
            File multimediaFile = copyMultimediaSourceToLocal();
            List<File> multimediaFiles = transcode(multimediaFile, transcoder);
            List<File> thumbnails = extractThumbnail(multimediaFile,
                    thumbnailExtractor);
            storeCreatedFilesToStorage(multimediaFiles, thumbnails);
            notifyJobResultToRequester();
            completed();
        } catch (RuntimeException ex) {
            exceptionOccurred(ex);
            throw ex;
        }
    }

    private void changeState(State newState) {
        this.state = newState;
    }

    private File copyMultimediaSourceToLocal() {
        changeState(Job.State.MEDIASOURCECOPYING);
        return mediaSourceFile.getSourceFile();
    }
    ...
}

changeState() 메서드는 state 필드만 변경하기 때문에, 위 메서드가 호출된다고 하더라도 DB에 결과가 반영되지 않는다. changeState() 메서드에 DB 연동 기능을 넣으면, 지금까지 도메인과 영속성 처리 코드를 애써 분리해온 게 무용지물이 된다.

Job 클래스는 그대로 유지하면서 Job 클래스의 상태를 유지하는 방법은 뭐가 있을까? 답은 다형성에 있다. JpaJobRepository가 Job 객체 대신 Job을 상속받은 JobImpl 객체를 생성하고, changeState() 메서드를 오버라이딩해서 DB 처리를 수행하도록 만들면 될 것 같다.

오버라이딩 JobImpl은 아마 이런 식일 것 같다.

public class JobImpl extends Job {

    public JobImpl(Long id, State state, MediaSourceFile mediaSourceFile,
            DestinationStorage destinationStorage,
            List<OutputFormat> outputFormats, ResultCallback callback,
            String errorMessage) {
        super(id, state, mediaSourceFile, destinationStorage, outputFormats,
                callback, errorMessage);
    }

    @Override
    protected void changeState(State newState) {
        super.changeState(newState);
        jobDataDao.updateState(getId(), newState); // JobDataDao?????
    }

}

음..... jobDataDao라는 게 출현했다. 이 jobDataDao는 JobImpl의 생성자로부터 받아야 하는데, 그렇다면 JpaJobRepository가 jobDataDao 역할을 해야 하나?

@Repository
public class JpaJobRepository implements JobRepository {
    ...
    @Transactional
    @Override
    public Job findById(Long jobId) {
        JobData jobData = entityManager.find(JobData.class, jobId);
        if (jobData == null) {
            return null;
        }
        return createJobFromJobData(jobData);
    }

    private Job createJobFromJobData(JobData jobData) {
        return new JobImpl(jobData.getId(), jobData.getState(),
                mediaSourceFileFactory.create(jobData.getSourceUrl()),
                destinationStorageFactory.create(jobData.getDestinationUrl()),
                jobData.getOutputFormats(),
                resultCallbackFactory.create(jobData.getCallbackUrl()),
                jobData.getExceptionMessage(),
                this); // JpaJobRepository가 JobDataDao인가???
    }
    
    public void updateState(Long id, Job.State newState) {
        ...
    }
}

뭔가 기분이 안 좋다. 음,,, 이유를 알았다. JobImpl이 JobDataDao를 필요로 하는 순간 알게 된 것이 있다. 그것은 바로 JpaJobRepository가 두 개의 책임을 지고 있다는 것이다.

JpaJobRepository의 책임 분리: SRP


JpaJobRepository는 다음의 두 가지 책임을 갖고 있다.

  • Job과 JobData 사이의 변환 실행
  • JobData와 DB 사이의 매핑 처리
단일 책임 원칙(SRP)를 위반하고 있다. SRP를 적용하면 JpaJobRepository로부터 DB 연동 부분이 분리된다.
  • DbJobRepository: DB를 이용한 JobRepository 구현
  • JobDataDao: JobData에 대한 DAO. 구현은 JPA를 이용해서 구현
JobDataDao는 Spring Data를 사용하면 최소한의 코딩으로 구현할 수 있다. Spring Data를 이용해서 JobDataDao 인터페이스를 다음과 같이 정의하였다.

import org.springframework.data.repository.Repository;

public interface JobDataDao extends Repository<JobData, Long> {

    public JobData save(JobData jobData);

    public JobData findById(Long id);

}

DB 연동 부분이 생겼으니, JpaJobRepository는 다음과 같이 JobDataDao를 사용하도록 변경된다.

@Repository // 이 애노테이션은 필요 없으니 삭제
public class JpaJobRepository implements JobRepository {

    private JobDataDao jobDataDao;
    ...

    public JpaJobRepository(JobDataDao jobDataDao,
            MediaSourceFileFactory mediaSourceFileFactory,
            DestinationStorageFactory destinationStorageFactory,
            ResultCallbackFactory resultCallbackFactory) {
        this.jobDataDao = jobDataDao;
        this.mediaSourceFileFactory = mediaSourceFileFactory;
        this.destinationStorageFactory = destinationStorageFactory;
        this.resultCallbackFactory = resultCallbackFactory;
    }

    @Transactional // 트랜잭션 처리는 JobDataDao로 이동
    @Override
    public Job findById(Long jobId) {
        JobData jobData = jobDataDao.findById(jobId);
        if (jobData == null) {
            return null;
        }
        return createJobFromJobData(jobData);
    }

    private Job createJobFromJobData(JobData jobData) {
        ...
    }

    @Transactional
    @Override
    public Job save(Job job) {
        JobData.ExporterToJobData exporter = new JobData.ExporterToJobData();
        JobData jobData = job.export(exporter);
        JobData savedJobData = jobDataDao.save(jobData);
        return createJobFromJobData(savedJobData);
    }

}

JpaJobRepository는 더 이상 JPA를 사용하고 있지 않으므로 이름을 DbJobRepository로 변경해 주자.

생성자를 변경했으므로 RepositoryConfig에서 컴파일 에러가 발생한다. 이제 RepositoryConfig에서 컴파일 에러를 없애주자.

@Configuration
@EnableJpaRepositories(basePackages = "org.chimi.s4t.infra.persistence")
public class RepositoryConfig {

    @Autowired
    private MediaSourceFileFactory mediaSourceFileFactory;
    @Autowired
    private DestinationStorageFactory destinationStorageFactory;
    @Autowired
    private ResultCallbackFactory resultCallbackFactory;
    @Autowired
    private JobDataDao jobDataDao;

    @Bean
    public JobRepository jobRepository() {
        return new DbJobRepository(jobDataDao, mediaSourceFileFactory,
                destinationStorageFactory, resultCallbackFactory);
    }
}

위 코드에서 @EnableJpaRepositories 애노테이션은 Spring Data가 제공하는 기능으로서, 이 애노테이션을 적용하면 Spring Data의 Repository 인터페이스를 상속받은 인터페이스로부터 구현 객체를 생성해준다. 이 예제의 경우 JobDataDao가 Repository 인터페이스를 상속받고 있으므로, JobDataDao에 대한 구현 객체를 생성해서 빈으로 등록해 준다. 따라서, 위 코드와 같이 @Autowired를 이용해서 생성된 JobDataDao 구현 객체를 참조할 수 있게 된다.

JpaJobRepository를 DbJobRepository로 변경하고 DB 연동 부분을 JobDataDao로 분리해냈다. 수정하는 작업을 했으니 테스트를 실행해서 정상적으로 동작하는 지 확인해 보자. 기존에 만들어둔 JpaJobRepositoryIntTest가 있으므로 이 테스트를 실행해보면 된다. 실행해보자. 녹색! 오~ 통과다. 테스트를 통과했으므로 이 테스트 클래스의 이름을 DbJobRepositoryIntTest로 변경하자.

다시 DbJobRepository에서 생성한 Job의 기능 확인

앞서, JpaJobRepository가 생성한 Job이 정상적으로 동작하는 지 확인해보는 과정에서 JpaJobRepository의 역할을 분리하게 되었다. 다시 돌아가도록 하자. 기억이 나지 않는다면, 앞 부분을 다시 읽어보고 여기로 오면 된다. 이제 JobImpl 클래스는 chageState() 메서드에서 JobDataDao를 이용해서 DB에 저장된 상태 값을 변경할 수 있다.

public class JobImpl extends Job {

    private JobDataDao jobDataDao;

    public JobImpl(JobDataDao jobDataDao, Long id, State state,
            MediaSourceFile mediaSourceFile,
            DestinationStorage destinationStorage,
            List<OutputFormat> outputFormats, ResultCallback callback,
            String errorMessage) {
        super(id, state, mediaSourceFile, destinationStorage, outputFormats,
                callback, errorMessage);
        this.jobDataDao = jobDataDao;
    }

    @Override
    protected void changeState(State newState) {
        super.changeState(newState);
        jobDataDao.updateState(getId(), newState); // 아직 updateState() 메서드 없음
    }

}

JobDataDao에 updateState() 메서드가 없으므로 위 코드에서 빨간색 부분이 컴파일 에러가 발생한다. JobDataDao에 updateState() 메서드를 추가하자.

public interface JobDataDao extends Repository<JobData, Long> {

    public JobData save(JobData jobData);

    public JobData findById(Long id);

    public int updateState(Long id, Job.State newState);
}

이제 JobImpl에서 컴파일 에러가 사라진다.

이제 DbJobRepository가 Job 대신 JobImpl 객체를 생성하도록 수정하자.

public class DbJobRepository implements JobRepository {
    ...
    @Override
    public Job findById(Long jobId) {
        ...
        return createJobFromJobData(jobData);
    }

    private Job createJobFromJobData(JobData jobData) {
        return new JobImpl(jobDataDao, jobData.getId(), jobData.getState(),
                mediaSourceFileFactory.create(jobData.getSourceUrl()),
                destinationStorageFactory.create(jobData.getDestinationUrl()),
                jobData.getOutputFormats(),
                resultCallbackFactory.create(jobData.getCallbackUrl()),
                jobData.getExceptionMessage());
    }

    @Transactional
    @Override
    public Job save(Job job) {
        ...
        return createJobFromJobData(savedJobData);
    }

}

JobImpl까지 만들었으니, 앞에서 작성했던 JobIntTest 클래스를 다시 실행해보자. 빨간색! 실패다.

실패가 발생한 이유는 Spring Data가 앞서 추가한 updateState() 메서드에 대한 알맞은 구현체를 만들지 못하기 때문이다. 필요한 JPA QL을 직접 지정해서 수정 기능을 완성짓도록 하면 될 것 같다.

public interface JobDataDao extends Repository<JobData, Long> {

    public JobData save(JobData jobData);

    public JobData findById(Long id);

    @Transactional
    @Modifying
    @Query("update JobData j set j.state = ?2 where j.id = ?1")
    public int updateState(Long id, Job.State newState);
}

다시 JobIntTest를 실행해보자. 녹색 통과다!

JobIntTest에 검증하는 기능을 추가해서 넣자. Job 객체는 변환 과정 중 에러가 발생하면 exceptionMessage에 에러 원인을 보관한다. JobIntTest는 중간 과정에서 오류가 발생한 경우에 상태 값이 올바른지 테스트 하고 있으므로, 다음과 같이 오류 메시지가 올바르게 저장되는 검증하는 코드를 추가해 보자.

public class JobIntTest {
    ...
    @Test
    public void jobShouldChangeStateInDB() {
        RuntimeException trancoderException = new RuntimeException("강제발생");
        when(
                transcoder.transcode(any(File.class),
                        anyListOf(OutputFormat.class))).thenThrow(
                trancoderException);

        Long jobId = new Long(1);
        Job job = jobRepository.findById(jobId);
        try {
            job.transcode(transcoder, thumbnailExtractor);
        } catch (RuntimeException ex) {
        }

        Job updatedJob = jobRepository.findById(jobId);

        assertEquals(Job.State.TRANSCODING, job.getLastState());
        assertEquals(Job.State.TRANSCODING, updatedJob.getLastState());
        assertEquals("강제발생", job.getExceptionMessage());
        assertEquals(job.getExceptionMessage(), updatedJob.getExceptionMessage());
    }
}

테스트를 실행해보자. 그럼, 위 코드에서 붉게 표시한 부분에서 통과하지 못한다. 앞서 상태 변경과 동일하게 메모리 상에 오류 메시지를 보관하고 있으나 DB에는 반영이 되지 않아 통과하지 못한 것이다. 이 부분은 상태를 변경하는 부분과 비슷하게 구현하면 될 것 같다.

눈치 챘는지 모르겠지만, JobIntTest를 통과시키는 과정에서 Job 클래스의 private 메서드 두 개를 protected로 변경했다.

public class Job {
    ...
    protected void changeState(State newState) {
        this.state = newState;
    }

    protected void exceptionOccurred(RuntimeException ex) {
        exceptionMessage = ExceptionMessageUtil.getMessage(ex);
        callback.nofiyFailedResult(id, state, exceptionMessage);
    }
    ...
}

위와 같이 변경한 이유는 JobImpl 클래스에서 위 두 기능을 오버라이딩해야 했기 때문이다. 기능을 구현하기 위해 하위 클래스에 이 정도 개방해주는 것은 허용해도 괜찮을 것 같다.

최종 모습

지금까지 JobRepository의 DB 구현을 만들었다. 그 결과로 아래와 같은 구조가 만들어졌다.


위 그림에서 job 도메인의 어떤 타입도 persistence 영역에 대한 의존을 갖지 않는다. (아니다, 정확하게는 OutputFormat이 JPA 애노테이션을 사용하니까 의존이 있긴 하지만, 설정 파일을 사용하면 제거 가능하므로 의존을 갖지 않는다고 표현해도 될 것 같다.) 따라서, persistence의 새로운 구현이 필요하더라도 job 도메인은 영향을 받지 않는다.


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

댓글을 달아 주세요

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

    범균님 덕분에 TDD9까지 집에서 테스트 해봤습니다.
    너무 좋은 자료 감사합니다. 한 10일은 걸린거 같아요 뛰엄뛰엄 하느냐구요
    첫술에 배부를수는 없지만 반복해서 다시 해보려구요 생각보다 쉽지 않아서요^^
    여러번 해보는 방법밖에는 없겠죠??

  2. 나그네 2014.04.09 17:51 신고  댓글주소  수정/삭제  댓글쓰기

    드디어...2번 다했습니다. ^^
    이번엔 프로젝트와 맞물려 좀 소홀히? 한거 같아요~ 좀 디테일하게 소스코드를 봐서 그런지 얻어가는게 더 많아진 기분입니다.
    감사합니다. 해보면서 느낀건데 진짜 많은 노력과 정성이 들어간 내용들이었습니다.
    앞으로 8번 남았네요!!

  3. 나그네 2014.04.09 17:54 신고  댓글주소  수정/삭제  댓글쓰기

    답변 남겨주시면 정말 힘이 날거 같아요...으으읔

  4. 나그네 2014.04.10 09:53 신고  댓글주소  수정/삭제  댓글쓰기

    아니에요 너무너무 도움이 많이 되요~~ 앞으로 8번...!!!

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

지금까지 한 번에 한 개의 변환 결과만 생성하는 기능을 구현했다. 이번엔 한 번에 여러 형식의 변환 결과를 생성해주는 기능을 구현해보도록 하자.


두 개 이상의 OutputFormat을 입력으로 받는 테스트 추가하기


TDD니까, 테스트를 먼저 작성해보도록 하자.


public class FfmpegTranscoderTest {

    ...

    private OutputFormat mp4Format;

    private OutputFormat mp4Format2;

    private OutputFormat aviFormat;


    @Before

    public void setup() {

        outputFormats = new ArrayList<OutputFormat>();

        mp4Format = new OutputFormat(160, 120, 150, Container.MP4,

                VideoCodec.H264, AudioCodec.AAC);

        mp4Format2 = new OutputFormat(80, 60, 80, Container.MP4,

                VideoCodec.H264, AudioCodec.AAC);

        aviFormat = new OutputFormat(160, 120, 150, Container.AVI,

                VideoCodec.MPEG4, AudioCodec.MP3);

        multimediaFile = new File("src/test/resources/sample.avi");


        transcoder = new FfmpegTranscoder();

    }

    ...

    private void executeTranscoderAndAssert() {

        List<File> transcodedFiles = transcoder.transcode(multimediaFile,

                outputFormats);

        assertEquals(outputFormats.size(), transcodedFiles.size());

        for (int i = 0; i < outputFormats.size(); i++) {

            assertTrue(transcodedFiles.get(i).exists());

            VideoFormatVerifier.verifyVideoFormat(outputFormats.get(i),

                    transcodedFiles.get(i));

        }

    }


    @Test

    public void transcodeWithTwoMp4OutputFormats() {

        outputFormats.add(mp4Format);

        outputFormats.add(mp4Format2);

        executeTranscoderAndAssert();

    }

}


새로운 크기를 갖는 OutputFormat 객체인 mp4Format2를 필드로 추가하였고, transcodeWithTwoMp4OutputFormats() 메서드를 이용해서 두 개의 변환 결과에 대한 테스트를 수행하도록 했다. executeTrancoderAndAssert() 메서드는 두 개 이상의 변환 요청을 검증할 수 있도록 수정하였다.


테스트를 실행해보자. 아래와 같은 메시지와 함께 테스트에 실패한다.


java.lang.AssertionError: expected:<160> but was:<80>

    at org.junit.Assert.fail(Assert.java:91)

    at org.junit.Assert.failNotEquals(Assert.java:645)

    at org.junit.Assert.assertEquals(Assert.java:126)

    at org.junit.Assert.assertEquals(Assert.java:470)

    at org.junit.Assert.assertEquals(Assert.java:454)

    at org.chimi....VideoFormatVerifier.assertVideoFile(VideoFormatVerifier.java:88)

    at org.chimi....VideoFormatVerifier.verify(VideoFormatVerifier.java:40)

    at org.chimi....VideoFormatVerifier.verifyVideoFormat(VideoFormatVerifier.java:18)

    at org.chimi.....executeTranscoderAndAssert(FfmpegTranscoderTest.java:64)

    at org.chimi....transcodeWithTwoMp4OutputFormats(FfmpegTranscoderTest.java:79)

    ... 


이유는 뭘까? FfmpegTranscoder 클래스는 다음과 같이 동일한 비디오 컨테이너에 대해 항상 동일한 파일을 생성한다. 따라서, 가장 마지막으로 요청한 OutputFormat의 결과만 최종적으로 남아있게 된다.


public class FfmpegTranscoder implements Transcoder {

    ...

    private String getFileName(OutputFormat format) {

        return "outputFile." + format.getFileExtension();

    }


}


예를 들어, 위 테스트의 경우 크기가 80*60인 비디오 파일이 최종적으로 남게 되고, 따라서 생성된 비디오를 검증하는 과정에서 첫 번째 변환 요청인 160*120 크기를 검사하는 과정에서 테스트를 통과하지 못하게 되었다.


이 테스트를 통과시키려면 FfmpegTranscoder의 getFileName() 메서드가 매번 서로 다른 파일 이름을 리턴해 주어야 한다. 변환된 결과 파일 이름의 경우 변환 시스템을 사용하는 곳에 따라 작명 규칙이 달라질 수 있을 것 같다. 그래서, 파일 이름을 생성하는 규칙을 NamingRule이라는 별도 인터페이스로 분리하고 FfmpegTranscoder는 NamingRule에 위임하도록 코드를 구성하는 게 좋을 것 같다. 아래는 그렇게 코딩한 결과이다.


public class FfmpegTranscoder implements Transcoder {


    private NamingRule namingRule;


    public FfmpegTranscoder(NamingRule namingRule) {

        this.namingRule = namingRule;

    }

    ...

    private String getFileName(OutputFormat format) {

        return namingRule.createName(format);

    }


}


NamingRule 인터페이스를 다음과 같이 만들었다.


public interface NamingRule {


    String createName(OutputFormat format);


}


FfmpegTranscoder의 생성자가 변경되었으므로, FfmpegTranscoderTest 클래스가 컴파일 에러가 난다. 컴파일 에러가 나지 않도록 NamingRule을 전달해주자.


public class FfmpegTranscoderTest {


    private Transcoder transcoder;

    ...

    private NamingRule namingRule;

    ...

    @Before

    public void setup() {

        outputFormats = new ArrayList<OutputFormat>();

        mp4Format = new OutputFormat(160, 120, 150, Container.MP4,

                VideoCodec.H264, AudioCodec.AAC);

        ...

        transcoder = new FfmpegTranscoder(namingRule); // 아직 namingRule은 null

    }


NamingRule의 Mock 객체를 만들어서 테스트를 통과시킬까 고민하다가, DefaultNamingRule이라는 걸 만드는 게 좋을 것 같다라는 결론에 도달했다. 다음과 같이 NamingRule 인터페이스에 이너 클래스로 DefaultNamingRule 클래스를 정의했고, DEFAULT 상수가 값으로 DefaultNameRule 객체를 갖도록 했다.


public interface NamingRule {


    String createName(OutputFormat format);


    public static final NamingRule DEFAULT = new DefaultNamingRule();


    public static class DefaultNamingRule implements NamingRule {


        private Random random = new Random();

        private String baseDir;


        public DefaultNamingRule() {

            baseDir = System.getProperty("java.io.tmpdir");

        }


        public void setBaseDir(String baseDir) {

            this.baseDir = baseDir;

        }


        @Override

        public String createName(OutputFormat format) {

            String fileName = getFileNameFromTime();

            File file = createFileFromFileNameAndFormat(format, baseDir,

                    fileName);

            return file.getPath();

        }


        private String getFileNameFromTime() {

            SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMddHHmmss");

            String time = dateFormat.format(new Date());

            int num = random.nextInt(1000);

            String fileName = time + "_" + num;

            return fileName;

        }


        private File createFileFromFileNameAndFormat(OutputFormat format,

                String tempDir, String fileName) {

            File file = new File(tempDir, fileName + "."

                    + format.getFileExtension());

            return file;

        }

    }

}


이제 FfmpegTranscoderTest가 NamingRule로 NamingRule.DEFAULT를 사용하도록 수정할 차례이다.


public class FfmpegTranscoderTest {


    private Transcoder transcoder;

    private File multimediaFile;

    private List<OutputFormat> outputFormats;


    private NamingRule namingRule; // 필요 없으므로 삭제


    private OutputFormat mp4Format;

    private OutputFormat mp4Format2;

    private OutputFormat aviFormat;


    @Before

    public void setup() {

        outputFormats = new ArrayList<OutputFormat>();

        mp4Format = new OutputFormat(160, 120, 150, Container.MP4,

                VideoCodec.H264, AudioCodec.AAC);

        mp4Format2 = new OutputFormat(80, 60, 80, Container.MP4,

                VideoCodec.H264, AudioCodec.AAC);

        aviFormat = new OutputFormat(160, 120, 150, Container.AVI,

                VideoCodec.MPEG4, AudioCodec.MP3);

        multimediaFile = new File("src/test/resources/sample.avi");


        transcoder = new FfmpegTranscoder(NamingRule.DEFAULT);

    }


테스트를 실행한다. 녹색! 통과다. 서로 다른 포맷을 갖는 OutputFormat을 생성하는 기능 구현이 완료되었다.


OutputFormat: 컨테이너와 코덱 옵션


OutputFormat의 생성자는 컨테이너 정보와 코덱 정보를 함께 받고 있다. 하지만, 컨테이너의 기본 코덱을 사용할 수 있기 때문에 코덱 정보를 전달하지 않을 경우 컨테이너의 기본 코덱을 사용하도록 하는 것이 편리할 것 같다. 이를 위해 OutputFormat 생성자에 코덱 정보를 전달하지 않는 테스트 코드부터 작성해보자. OutputFormat를 생성할 때 컨테이너 정보만 전달해도 정상적으로 실행되는 지 확인하면 되므로  VideoConverterTest를 이용해보도록 하겠다.


public class VideoConverterTest {

    ...

    @Test

    public void transcodeWithOnlyContainer() {

        IMediaReader reader = ToolFactory.makeReader(SOURCE_FILE);

        

        OutputFormat outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                Container.AVI); // 일치하는 생성자 없으므로 컴파일 에러

        VideoConverter writer = new VideoConverter("target/sample.avi", reader,

                outputFormat);

        reader.addListener(writer);

        while (reader.readPacket() == null)

            do {

            } while (false);

        

        VideoFormatVerifier.verifyVideoFormat(outputFormat, new File(

                "target/sample.avi"));

    }

}


컴파일 에러를 없애야 하므로 OutputFormat에 생성자를 추가해주고, 다음과 같이 구현해 주었다.


public class OutputFormat {


    private int width;

    private int height;

    private int bitrate;

    private Container container;

    private VideoCodec videoCodec;

    private AudioCodec audioCodec;


    public OutputFormat(int width, int height, int bitrate,

            Container container, VideoCodec videoCodec, AudioCodec audioCodec) {

        this.width = width;

        this.height = height;

        this.bitrate = bitrate;

        this.container = container;

        this.videoCodec = videoCodec;

        this.audioCodec = audioCodec;

    }


    public OutputFormat(int width, int height, int bitrate, Container container) {

        this(width, height, bitrate, container, null, null);

    }


컴파일 에러를 없앴으므로, VideoConverterTest를 실행해보자. transcodeWithOnlyContainer() 테스트 메서드는 통과하지 못했다. 실패 메시지 및 관련 위치는 다음과 같다.


-- 실패 메시지

java.lang.AssertionError: expected:<null> but was:<MPEG4>

    at org.junit.Assert.fail(Assert.java:91)

    at org.junit.Assert.failNotEquals(Assert.java:645)

    at org.junit.Assert.assertEquals(Assert.java:126)

    at org.junit.Assert.assertEquals(Assert.java:145)

    at org....VideoFormatVerifier.assertVideoFile(VideoFormatVerifier.java:90)

    at org....VideoFormatVerifier.verify(VideoFormatVerifier.java:40)

    at org....VideoFormatVerifier.verifyVideoFormat(VideoFormatVerifier.java:18)

    at org....VideoConverterTest.transcodeWithOnlyContainer(VideoConverterTest.java:52)


-- 실패 위치

    private void assertVideoFile() {

        assertEquals(expectedFormat.getWidth(), width);

        assertEquals(expectedFormat.getHeight(), height);

        assertEquals(expectedFormat.getVideoCodec(),

                CodecValueConverter.toDomainVideoCodec(videoCodec)); // 테스트 실패

        assertEquals(expectedFormat.getAudioCodec(),

                CodecValueConverter.toDomainAudioCodec(audioCodec));

    }


OutputFormat을 생성하는 과정에서 videoCodec의 값으로 null을 전달했기 때문에, expectedFormat.getVideoCodec()가 null을 리턴했다. 동일하게 audioCodec도 null 값을 갖게 된다. 이 문제는 간단하게 처리할 수 있다. 다음과 같이 코덱을 전달받지 않는 OutputFormat 생성자에서 null 대신 Container의 기본 코덱을 전달하도록 코드를 수정해 주면 될 것 같다.


public class OutputFormat {

    ...

    public OutputFormat(int width, int height, int bitrate,

            Container container, VideoCodec videoCodec, AudioCodec audioCodec) {

        ...

        this.videoCodec = videoCodec;

        this.audioCodec = audioCodec;

    }


    public OutputFormat(int width, int height, int bitrate, Container container) {

        this(width, height, bitrate, container, 

                container.getDefaultVideoCodec(), container.getDefaultAudioCodec());

    }


다시 VideoConverterTest를 실행해 보자. 녹색! 통과다.


VideoConverterTest 작은 손질


VideoConverterTest를 정리할 타이밍이 왔다. 현재까지 만들어진 코드는 다음과 같다.


public class VideoConverterTest {


    private static final int WIDTH = 160;

    private static final int HEIGHT = 120;

    private static final int BITRATE = 150;

    private static final String SOURCE_FILE = "src/test/resources/sample.avi";

    private static final String TRANSCODED_FILE = "target/sample.mp4";


    @Test

    public void transcode() {

        IMediaReader reader = ToolFactory.makeReader(SOURCE_FILE);


        OutputFormat outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                Container.MP4, VideoCodec.H264, AudioCodec.AAC);

        VideoConverter writer = new VideoConverter(TRANSCODED_FILE, reader,

                outputFormat);

        reader.addListener(writer);

        while (reader.readPacket() == null)

            do {

            } while (false);


        VideoFormatVerifier.verifyVideoFormat(outputFormat, new File(

                TRANSCODED_FILE));

    }

    

    @Test

    public void transcodeWithOnlyContainer() {

        IMediaReader reader = ToolFactory.makeReader(SOURCE_FILE);

        

        OutputFormat outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                Container.AVI);

        VideoConverter writer = new VideoConverter("target/sample.avi", reader,

                outputFormat);

        reader.addListener(writer);

        while (reader.readPacket() == null)

            do {

            } while (false);

        

        VideoFormatVerifier.verifyVideoFormat(outputFormat, new File(

                "target/sample.avi"));

    }

}


두 테스트 메서드가 상당히 비슷하다. 우선, 두 메서드에서 사용되는 값들을 메서드의 앞 쪽에 변수로 선언해서 모으자.


public class VideoConverterTest {

    ...

    @Test

    public void transcode() {

        IMediaReader reader = ToolFactory.makeReader(SOURCE_FILE);

        OutputFormat outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                Container.MP4, VideoCodec.H264, AudioCodec.AAC);

        String outputFile = TRANSCODED_FILE;

        VideoConverter writer = new VideoConverter(outputFile, reader,

                outputFormat);

        reader.addListener(writer);

        while (reader.readPacket() == null)

            do {

            } while (false);


        VideoFormatVerifier.verifyVideoFormat(outputFormat,

                new File(outputFile));

    }


    @Test

    public void transcodeWithOnlyContainer() {

        IMediaReader reader = ToolFactory.makeReader(SOURCE_FILE);

        OutputFormat outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                Container.AVI);

        String outputFile = "target/sample.avi";

        VideoConverter writer = new VideoConverter(outputFile, reader,

                outputFormat);

        reader.addListener(writer);

        while (reader.readPacket() == null)

            do {

            } while (false);


        VideoFormatVerifier.verifyVideoFormat(outputFormat,

                new File(outputFile));

    }

}


두 메서드에서 reader, outputFormat, outputFile을 필드로 정의한다. 이클립스와 같은 IDE는 메서드 로컬 변수를 필드로 빼주는 기능을 제공하고 있으니 그 기능을 사용하면 편리하다. 아 그 전에 테스트 하는 것 잊지 말기 바란다.


public class VideoConverterTest {

    ...

    private IMediaReader reader;

    private OutputFormat outputFormat;

    private String outputFile;


    @Test

    public void transcode() {

        reader = ToolFactory.makeReader(SOURCE_FILE);

        outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                Container.MP4, VideoCodec.H264, AudioCodec.AAC);

        outputFile = TRANSCODED_FILE;

        VideoConverter writer = new VideoConverter(outputFile, reader,

                outputFormat);

        reader.addListener(writer);

        while (reader.readPacket() == null)

            do {

            } while (false);


        VideoFormatVerifier.verifyVideoFormat(outputFormat,

                new File(outputFile));

    }


    @Test

    public void transcodeWithOnlyContainer() {

        reader = ToolFactory.makeReader(SOURCE_FILE);

        outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                Container.AVI);

        outputFile = "target/sample.avi";

        VideoConverter writer = new VideoConverter(outputFile, reader,

                outputFormat);

        reader.addListener(writer);

        while (reader.readPacket() == null)

            do {

            } while (false);


        VideoFormatVerifier.verifyVideoFormat(outputFormat,

                new File(outputFile));

    }

}


이제 두 메서드에서 코드가 완전히 동일한 부분을 별도의 메서드로 분리해내자. 다음의 두 부분이 완전히 동일하다.

  • reader를 생성하는 부분 -> @Before를 붙인 setup 메서드로 이동
  • VideoConverter를 생성하고 변환하는 부분 -> testVideoConverter 메서드로 이동

public class VideoConverterTest {


    private static final int WIDTH = 160;

    private static final int HEIGHT = 120;

    private static final int BITRATE = 150;

    private static final String SOURCE_FILE = "src/test/resources/sample.avi";

    private static final String TRANSCODED_FILE = "target/sample.mp4";

    private IMediaReader reader;

    private OutputFormat outputFormat;

    private String outputFile;


    @Before

    public void setup() {

        reader = ToolFactory.makeReader(SOURCE_FILE);

    }


    @Test

    public void transcode() {

        outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE, Container.MP4,

                VideoCodec.H264, AudioCodec.AAC);

        outputFile = TRANSCODED_FILE;

        testVideoConverter();

    }


    private void testVideoConverter() {

        VideoConverter writer = new VideoConverter(outputFile, reader,

                outputFormat);

        reader.addListener(writer);

        while (reader.readPacket() == null)

            do {

            } while (false);


        VideoFormatVerifier.verifyVideoFormat(outputFormat,

                new File(outputFile));

    }


    @Test

    public void transcodeWithOnlyContainer() {

        outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE, Container.AVI);

        outputFile = "target/sample.avi";

        testVideoConverter();

    }

}


outputFormat과 outputFile을 초기화하는 부분도 비슷하다. 이 부분도 한 번 별도 메서드로 분리해보자.


public class VideoConverterTest {

    ...

    @Test

    public void transcode() {

        initOutput(Container.MP4, VideoCodec.H264, AudioCodec.AAC,

                TRANSCODED_FILE);

        testVideoConverter();

    }


    private void initOutput(Container outputContainer, VideoCodec videoCodec,

            AudioCodec audioCodec, String outputFileName) {

        if (videoCodec == null && audioCodec == null) {

            outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                    outputContainer);

        } else {

            outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                    outputContainer, videoCodec, audioCodec);

        }

        outputFile = outputFileName;

    }


    private void initOutput(Container outputContainer, String outputFileName) {

        initOutput(outputContainer, null, null, outputFileName);

        outputFile = outputFileName;

    }


    private void testVideoConverter() {

        ...

    }


    @Test

    public void transcodeWithOnlyContainer() {

        initOutput(Container.AVI, "target/sample.avi");

        testVideoConverter();

    }

}


이제 TRANSCODED_FILE 상수는 더 이상 의미가 없다. 이 상수를 사용하지 않도록 돌려 놓자.


지금까지의 작업으로 바뀐 VideoConverterTest는 다음과 같다.


public class VideoConverterTest {


    private static final int WIDTH = 160;

    private static final int HEIGHT = 120;

    private static final int BITRATE = 150;

    private static final String SOURCE_FILE = "src/test/resources/sample.avi";

    private IMediaReader reader;

    private OutputFormat outputFormat;

    private String outputFile;


    @Before

    public void setup() {

        reader = ToolFactory.makeReader(SOURCE_FILE);

    }


    @Test

    public void transcode() {

        initOutput(Container.MP4, VideoCodec.H264, AudioCodec.AAC,

                "target/sample.mp4");

        testVideoConverter();

    }


    @Test

    public void transcodeWithOnlyContainer() {

        initOutput(Container.AVI, "target/sample.avi");

        testVideoConverter();

    }


    private void initOutput(Container outputContainer, String outputFileName) {

        initOutput(outputContainer, null, null, outputFileName);

        outputFile = outputFileName;

    }


    private void initOutput(Container outputContainer, VideoCodec videoCodec,

            AudioCodec audioCodec, String outputFileName) {

        if (videoCodec == null && audioCodec == null) {

            outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                    outputContainer);

        } else {

            outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                    outputContainer, videoCodec, audioCodec);

        }

        outputFile = outputFileName;

    }


    private void testVideoConverter() {

        VideoConverter writer = new VideoConverter(outputFile, reader,

                outputFormat);

        reader.addListener(writer);

        while (reader.readPacket() == null)

            do {

            } while (false);


        VideoFormatVerifier.verifyVideoFormat(outputFormat,

                new File(outputFile));

    }

}


@Test 메서드가 단순해졌다. 새로운 컨테이너의 추가라든가, 코덱 변환 등을 테스트하고 싶을 때에 간단하게 테스트를 추가할 수 있게 되었다. testVideoConverter() 메서드와 initOutput() 메서드를 조금 더 정리하고 싶은 마음은 있지만, 일단 이 정도까지 코드를 정리하자.




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

댓글을 달아 주세요

  1. 전홍석 2015.12.04 11:08 신고  댓글주소  수정/삭제  댓글쓰기

    안녕하세요
    스프링 4 프로그래밍 입문을 구입하여 열공중인 중년 초보개발자 입니다.
    대용량의 동영상 converter작업할일이 있어 찾아보니 저자님 글이 있어
    학습하여 익히려 합니다.
    염치 불구하고 위 전체 소스를 받아서 도움받을수 있도록 부탁드립니다.


    • 최범균 madvirus 2015.12.04 14:31 신고  댓글주소  수정/삭제

      아,,, 이게 그냥 막 연습삼아서 하던거라서,,,, 코드가 있는지 모르겠네요. 업무용 장비에는 코드가 없네요. 아무래도 내용 보시면 관련 코드가 나오니까, 그 코드를 참조하셔야 할 것 같아요.

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

티스토리 툴바