TDD
- TDD 발담그기 공감세미나 발표 자료 2017.05.08
- JI 개발 리뷰 발표 자료 (신림프로그래머 모임) 2014.09.26
- 스프링 캠프 2013 TDD 라이브 발표 영상 2014.01.28 (5)
- 간단한 Spock 샘플 2013.12.06
- TDD Live 발표 자료 (스프링캠프 2013) 2013.10.11 (1)
- 숫자 야구 게임 TDD 연습 2013.10.04 (7)
- TDD 연습 9, DB 연동 리포지토리 테스트, 빌더 패턴, SRP, Spring Data, 다형 2012.11.15 (6)
- TDD 연습 8, 여러 OutputFormat 변환 처리하기, 코덱 옵션 처리, VideoConverterTest 리팩토링 2012.11.06 (2)
- TDD 연습 7, 컨테이너 정보 추가와 FfmpegTranscoder가 생성할 파일 확장자 처리 2012.11.04
- TDD 연습 6, Transcodoer 구현체인 FfmpegTranscoder 만들기, Xuggler 실험 2012.11.02 (2)
- TDD 연습 5, Job에 기능 추가, 물론 Mock으로,그리고 리팩토링 2012.10.29
- TDD 연습 4-2, 리팩토링 Job 도메인 모델 추가 그리고 변화 과정 2012.10.26 (2)
- TDD 연습 4-1. 리팩토링! TranscodingService와 Job을 바꿔라! 2012.10.25 (3)
- TDD 연습 3. 테스트 코드 정리 2012.10.24 (2)
- TDD 연습 2. TranscodingService의 실패 시나리오 테스트를 통한 점진적 구현 추가 2012.10.24 (2)
TDD 발담그기 공감세미나 발표 자료
JI 개발 리뷰 발표 자료 (신림프로그래머 모임)
스프링 캠프 2013 TDD 라이브 발표 영상
스프링캠프 2013에서 발표했던 'TDD 라이브'의 영상입니다.
(발표자료는 http://javacan.tistory.com/entry/TDD-Live-in-SpringCamp-2013 참고)
-
-
김상균 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 인데, 이곳으로 메일 주소 알려주시면, 답변 드리도록 하겠습니다.
-
간단한 Spock 샘플
기존에 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
})
}
}
TDD Live 발표 자료 (스프링캠프 2013)
숫자 야구 게임 TDD 연습
숫자 야구 게임을 이용해서 TDD를 연습해 봤습니다.
-
개발자 2014.01.19 12:28
안녕하세요 동영상 강의 잘봤습니다.
마지막에 guessNumber random은 피곤하셔서 구현을 못하셨는데 어떤식으로 하면 될까요?
궁금해서 못참겠어요..ㅠ.ㅠ-
최범균 madvirus 2014.01.20 09:47 신고
0부터 9까지의 수를 섞은 다음에... (랜덤하게 인덱스를 추출해서 맨 앞으로 보낸다는가 하는 식으로 15~20회 수행 등), 1/3/5 번째를 뽑는다든가 하는 식으로 숫자 3개를 뽑아낼 수 있을 것 같아요.
-
-
TDD 연습 9, DB 연동 리포지토리 테스트, 빌더 패턴, SRP, Spring Data, 다형
지금까지 각각의 구현들을 만들어나갔다. 그러면서 채워진 도메인 영영역은 아래와 같다. 아래 그림은 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 관련 설정
- 리포지토리 설정
- 리포지토리가 의존하는 다른 빈에 대한 설정
- JobData#sourceUrl 로부터 MediaSourceFile 객체 생성
- JobData#destinationUrl 로부터 DestinationStorage 객체 생성
- JobData#callbackUrl 로부터 ResultCallback 객체 생성
- JobData를 생성하는데 필요한 모든 정보를 제공해주기 위해 Job에 get 메서드 추가. 즉 getMediaSourceUrl(), getResultCallbackUrl() 등의 메서드를 Job 에 추가.
- Job의 데이터를 익스포트 해주는 빌더 사용
- Job이 데이터 추출 과정을 제어한다.
- Job이 데이터를 제공하므로 get 메서드를 최소화할 수 있다.
JpaJobRepository의 책임 분리: SRP
JpaJobRepository는 다음의 두 가지 책임을 갖고 있다.
- Job과 JobData 사이의 변환 실행
- JobData와 DB 사이의 매핑 처리
- DbJobRepository: DB를 이용한 JobRepository 구현
- JobDataDao: JobData에 대한 DAO. 구현은 JPA를 이용해서 구현
위 그림에서 job 도메인의 어떤 타입도 persistence 영역에 대한 의존을 갖지 않는다. (아니다, 정확하게는 OutputFormat이 JPA 애노테이션을 사용하니까 의존이 있긴 하지만, 설정 파일을 사용하면 제거 가능하므로 의존을 갖지 않는다고 표현해도 될 것 같다.) 따라서, persistence의 새로운 구현이 필요하더라도 job 도메인은 영향을 받지 않는다.
-
나그네 2014.03.04 10:53
범균님 덕분에 TDD9까지 집에서 테스트 해봤습니다.
너무 좋은 자료 감사합니다. 한 10일은 걸린거 같아요 뛰엄뛰엄 하느냐구요
첫술에 배부를수는 없지만 반복해서 다시 해보려구요 생각보다 쉽지 않아서요^^
여러번 해보는 방법밖에는 없겠죠?? -
TDD 연습 8, 여러 OutputFormat 변환 처리하기, 코덱 옵션 처리, VideoConverterTest 리팩토링
지금까지 한 번에 한 개의 변환 결과만 생성하는 기능을 구현했다. 이번엔 한 번에 여러 형식의 변환 결과를 생성해주는 기능을 구현해보도록 하자.
두 개 이상의 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() 메서드를 조금 더 정리하고 싶은 마음은 있지만, 일단 이 정도까지 코드를 정리하자.
-
전홍석 2015.12.04 11:08
안녕하세요
스프링 4 프로그래밍 입문을 구입하여 열공중인 중년 초보개발자 입니다.
대용량의 동영상 converter작업할일이 있어 찾아보니 저자님 글이 있어
학습하여 익히려 합니다.
염치 불구하고 위 전체 소스를 받아서 도움받을수 있도록 부탁드립니다.
-
최범균 madvirus 2015.12.04 14:31 신고
아,,, 이게 그냥 막 연습삼아서 하던거라서,,,, 코드가 있는지 모르겠네요. 업무용 장비에는 코드가 없네요. 아무래도 내용 보시면 관련 코드가 나오니까, 그 코드를 참조하셔야 할 것 같아요.
-
TDD 연습 7, 컨테이너 정보 추가와 FfmpegTranscoder가 생성할 파일 확장자 처리
앞서 작업까지의 결과물인 FfmpegTranscoder 클래스의 코드를 보자.
public class FfmpegTranscoder implements Transcoder {
@Override
public List<File> transcode(File multimediaFile,
List<OutputFormat> outputFormats) {
List<File> results = new ArrayList<File>();
for (OutputFormat format : outputFormats) {
results.add(transcode(multimediaFile, format));
}
return results;
}
private File transcode(File sourceFile, OutputFormat format) {
IMediaReader reader = ToolFactory.makeReader(sourceFile
.getAbsolutePath());
String outputFile = "outputFile.mp4"; // 항상 확장자, 파일 명이 같음
VideoConverter converter = new VideoConverter(outputFile, reader,
format);
...
}
}
원하는 파일이 MP4인지 AVI인지에 상관없이 항상 파일명은 outputFile.mp4 이다. 이제 이 부분을 건드려볼 차례이다.
우선, 확장자는 멀티미디어 컨테이너에 따라 다르다. 예를 들어, AVI, MP4, MOV, FLV 등이 컨테이너이며, 보통 이 이름을 파일의 확장자로 사용한다. 또한, 각 컨테이너의 특징에 따라 사용가능한 코덱에 제한이 있다고도 한다. 하튼, 필자는 이 부분의 전문가는 아니기 때문에 컨테이너 포맷이나 코덱 등의 정보가 궁금한 사람은 따로 정보를 검색해보시라.
컨테이너 모델 추가
확장자를 검사하도록 테스트를 먼저 수정하자. 확장자를 검사하기 적당한 위치는 VideoFormatVerifier 클래스이다. 이 클래스에 확장자를 확인하는 기능을 추가해 넣도록 하자.
public class VideoFormatVerifier {
...
public VideoFormatVerifier(OutputFormat expectedFormat, File videoFile) {
this.expectedFormat = expectedFormat;
this.videoFile = videoFile;
}
public void verify() {
try {
assertExtension();
makeContainer();
extractMetaInfoOfVideo();
assertVideoFile();
} finally {
closeContainer();
}
}
private void assertExtension() {
assertEquals(expectedFormat.getFileExtenstion(), fileExtenstion());
}
private String fileExtenstion() {
String filePath = videoFile.getAbsolutePath();
int lastDotIdx = filePath.lastIndexOf(".");
String extension = filePath.substring(lastDotIdx + 1);
return extension;
}
OutputFormat의 getFileExtension() 메서드가 파일 확장자를 제공한다고 했다. 이 메서드가 없으니 아래와 같이 추가해서 기존의 테스트가 통과되도록 하자.
public class OutputFormat {
private int width;
private int height;
private int bitrate;
...
public String getFileExtension() {
return "mp4"; //일단 테스트가 통과하도록
}
public VideoCodec getVideoCodec() {
return videoCodec;
}
public AudioCodec getAudioCodec() {
return audioCodec;
}
}
FfmpegTranscoderTest를 실행해 보자. 녹색! 통과다.
이제 본격적으로 컨테이너 정보를 추가해 보자. 파일의 확장자를 결졍짓는 것은 컨테이너이므로, 코덱과 별개로 컨테이너 정보를 추가하자.
public enum Container {
MP4(VideoCodec.H264, AudioCodec.AAC, "mp4"),
AVI(VideoCodec.MPEG4,AudioCodec.MP3, "avi");
private VideoCodec defaultVideoCodec;
private AudioCodec defaultAudioCodec;
private String fileExtension;
private Container(VideoCodec defaultVideoCodec,
AudioCodec defaultAudioCodec, String fileExtenstion) {
this.defaultVideoCodec = defaultVideoCodec;
this.defaultAudioCodec = defaultAudioCodec;
this.fileExtension = fileExtenstion;
}
public VideoCodec getDefaultVideoCodec() {
return defaultVideoCodec;
}
public AudioCodec getDefaultAudioCodec() {
return defaultAudioCodec;
}
public String getFileExtension() {
return fileExtension;
}
}
Container는 enum 타입으로 각 값은 해당 컨테이너의 기본 코덱 정보와 확장자 정보를 갖도록 했다.
Container를 추가했으므로, 변환 결과물 정보를 담는 OutputFormat에 추가할 차례이다. OutputFormat이 Container를 갖도록 필드를 추가혹, 생성자를 통해서 Container를 전달받도록 수정하자.
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 String getFileExtension() {
return container.getFileExtension();
}
...
}
OutputFormat에 getFileExtension() 메서드가 추가되었다. 이제 VideoFormatVerifier 테스트에 추가했던 확장자 확인 코드가 정상적으로 컴파일된다.
public class VideoFormatVerifier {
...
...
private void assertExtension() {
assertEquals(expectedFormat.getFileExtenstion(), fileExtenstion()); // 컴파일 됨
}
위 코드는 정상적으로 컴파일되지만, OutputFormat 생성자에 새로운 파라미터가 추가되었기 때문에, OutputFormat 객체를 생성하는 테스트 코드에서는 컴파일 에러가 발생한다.
public class VideoConverterTest {
...
@Test
public void transcode() {
IMediaReader reader = ToolFactory.makeReader(SOURCE_FILE);
OutputFormat outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,
VideoCodec.H264, AudioCodec.AAC);
VideoConverter writer = new VideoConverter(TRANSCODED_FILE, reader,
outputFormat);
...
}
}
public class FfmpegTranscoderTest {
...
@Test
public void transcodeWithOneOutputFormat() {
File multimediaFile = new File("src/test/resources/sample.avi");
List<OutputFormat> outputFormats = new ArrayList<OutputFormat>();
outputFormats.add(new OutputFormat(160, 120, 150, VideoCodec.H264,
AudioCodec.AAC));
List<File> transcodedFiles = transcoder.transcode(multimediaFile,
outputFormats);
...
}
}
컴파일 에러가 나지 않도록 OutputFormat 생성자에 Container를 값으로 전달해 준다.
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);
VideoConverter writer = new VideoConverter(TRANSCODED_FILE, reader,
outputFormat);
...
}
}
public class FfmpegTranscoderTest {
...
@Test
public void transcodeWithOneOutputFormat() {
File multimediaFile = new File("src/test/resources/sample.avi");
List<OutputFormat> outputFormats = new ArrayList<OutputFormat>();
outputFormats.add(new OutputFormat(160, 120, 150, Container.MP4,
VideoCodec.H264, AudioCodec.AAC));
...
}
}
위와 같이 변경했으면, 두 테스트가 정상적으로 동작하는지 다시 테스트 해 본다. 녹색! 통과다.
AVI 변환 추가
이제 AVI 파일로 변환해주는 기능을 추가해 넣고 테스트를 해 보자. 이를 위해 FfmpegTranscoderTest를 다음과 같이 작성하였다. (음,, 바로 이전 코드하고 좀 많이 변경되었는데, 하나 하나 설명하지 않고 한 번에 넘어간 점 양해 바란다. 중복된 부분을 별도 메서드로 분리하고 setup에서 초기화를 진행하도록 수정했다.)
public class FfmpegTranscoderTest {
private Transcoder transcoder;
private File multimediaFile;
private List<OutputFormat> outputFormats;
private OutputFormat mp4Format;
private OutputFormat aviFormat;
@Before
public void setup() {
outputFormats = new ArrayList<OutputFormat>();
mp4Format = new OutputFormat(160, 120, 150, 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);
}
@Test
public void transcodeWithOneMp4OutputFormat() {
outputFormats.add(mp4Format);
executeTranscoderAndAssert();
}
private void executeTranscoderAndAssert() {
List<File> transcodedFiles = transcoder.transcode(multimediaFile,
outputFormats);
assertEquals(1, transcodedFiles.size());
assertTrue(transcodedFiles.get(0).exists());
VideoFormatVerifier.verifyVideoFormat(outputFormats.get(0),
transcodedFiles.get(0));
}
@Test
public void transcodeWithOneAviOutputFormat() {
outputFormats.add(aviFormat);
executeTranscoderAndAssert();
}
}
AVI 변환에 대한 테스트 코드를 넣었다. 테스트 실행! 실패다. 실패 이유는 다음과 같다.
실패 메시지:
org.junit.ComparisonFailure: expected:<[avi]> but was:<[mp4]>
at org.junit.Assert.assertEquals(Assert.java:123)
at org.junit.Assert.assertEquals(Assert.java:145)
at org.chimi.s4t.infra.ffmpeg.VideoFormatVerifier.assertExtension(VideoFormatVerifier.java:47)
at org.chimi.s4t.infra.ffmpeg.VideoFormatVerifier.verify(VideoFormatVerifier.java:37)
...
실재 난 테스트 메서드:
@Test
public void transcodeWithOneAviOutputFormat() {
outputFormats.add(aviFormat);
executeTranscoderAndAssert();
}
}
파일명이 AVI이길 기대했는데 실제 파일 이름은 MP4 여서 테스트를 통과하지 못했다. 이제 테스트를 통과시켜 보자. FfmpegTranscoder가 OutputFormat을 이용해서 파일의 확장자를 결정하도록 수정했다.
public class FfmpegTranscoder implements Transcoder {
@Override
public List<File> transcode(File multimediaFile,
List<OutputFormat> outputFormats) {
List<File> results = new ArrayList<File>();
for (OutputFormat format : outputFormats) {
results.add(transcode(multimediaFile, format));
}
return results;
}
private File transcode(File sourceFile, OutputFormat format) {
IMediaReader reader = ToolFactory.makeReader(sourceFile
.getAbsolutePath());
String outputFile = getFileName(format);
VideoConverter converter = new VideoConverter(outputFile, reader,
format);
reader.addListener(converter);
while (reader.readPacket() == null)
do {
} while (false);
return new File(outputFile);
}
private String getFileName(OutputFormat format) {
return "outputFile." + format.getFileExtension(); // 기존 "outputFile.mp4"
}
}
테스트를 실행해보자. 녹색! 통과다!
지금까지 한 번에 1개의 변환을 처리하는 부분에 집중했는데, 다음 연습 8에서는 여러 형식으로 변환하는 기능을 추가해 보도록 하자.
TDD 연습 6, Transcodoer 구현체인 FfmpegTranscoder 만들기, Xuggler 실험
구현 진도를 확 빼고 싶지만, 글을 남기면서 진행하다보면 코드가 더디게 나간다. 답답함이 좀 있지만 몇 가지 영역에 대해서는 글로 남기는 게 좋을 것 같다.
Ffmpeg을 이용한 Transcoder 구현체 만들기! 테스트부터 작성
지금까지 TranscodingService 부터 시작해서 Job까지 점진적으로 구현을 만들어왔다. 다음 작업 대상은 Job 객체에서 호출할 Transcoder의 구현체이다. ffmpeg를 사용할 것이므로 구현체의 이름을 FfmpegTranscoder로 정리해 봤다. 구현의 시작은 테스트부터이다. 테스트부터 만들어보자.
public class FfmpegTranscoderTest {
private Transcoder transcoder;
@Before
public void setup() {
transcoder = new FfmpegTranscoder();
}
@Test
public void transcodeWithOnfOutputFormat() {
File multimediaFile = new File(".");
List<OutputFormat> outputFormats = new ArrayList<OutputFormat>();
outputFormats.add(new OutputFormat(640, 480, 300, "h264", "aac"));
List<File> transcodedFiles = transcoder.transcode(multimediaFile,
outputFormats);
assertEquals(1, transcodedFiles.size());
assertTrue(transcodedFiles.get(0).exists());
}
}
위 테스트는 한 개의 비디오 파일에 대해 한 개의 변환 포맷을 입력으로 지정하고, 변환 결과로 생성된 파일이 존재하는 지 확인한다. 마음 같아서 생성된 파일을 분석해서 그 파일이 지정한 형식으로 올바르게 변환되었는지 확인하고 싶지만, 지금은 일단 위 수준에서 넘어가보자. (이 부분은 나중에 한 번 시도해 볼 것이다.)
FFmpegTranscoder 클래스가 없으니 컴파일 에러가 발생한다. 일단 이 클래스를 만들어서 컴파일 에러부터 없애자.
다음으로 할 작업은 테스트를 통과시키는 것이다. 테스트 통과는 어렵지 않다. 빠르게 테스트를 통과시키기 위해 아래와 같이 코드를 작성한다.
public class FfmpegTranscoder implements Transcoder {
@Override
public List<File> transcode(File multimediaFile,
List<OutputFormat> outputFormats) {
List<File> results = new ArrayList<File>();
for (OutputFormat format : outputFormats) {
results.add(new File("."));
}
return results;
}
}
이제 FfmpegTranscoderTest를 실행하자. 녹색, 통과다.
다음으로 할 작업은 테스트 코드를 좀 더 의미있게 변경하는 것이다. 지금은 단순히 파일이 존재하는지 여부로 검증하고 있는데, 더 정확하게 검증하려면 생성된 파일이 지정한 비디오 포맷과 크기를 갖는지의 여부로 검증해야 한다.
사용법을 익히기 위한 테스트 코드
생성된 비디오 결과물을 확인하기 위한 용도로 xuggler를 사용해 보기로 했다. xuggler는 ffmpeg을 자바에서 사용할 수 있도록 래핑해 놓은 라이브러리이다. 관련 네이티브 모듈을 모두 포함하고 있어서 jar 파일이 다소 크지만, ffmpeg를 로컬에 설치하고 네이티브를 만들거나 별도 프로세스로 호출해서는 사용하는 것 보다 편리하다가 판단되어 xuggler를 사용하기로 결정했다.
이 프로젝트는 Maven을 사용하고 있는데, 다음과 같은 설정으로 xuggler를 사용할 수 있다.
<repositories>
<repository>
<id>xuggle repo</id>
<url>http://xuggle.googlecode.com/svn/trunk/repo/share/java/</url>
</repository>
</repositories>
<dependencies>
<!-- ffmpeg -->
<dependency>
<groupId>xuggle</groupId>
<artifactId>xuggle-xuggler</artifactId>
<version>5.3</version>
</dependency>
...
Xuggler를 클래스패스에 추가했으므로, xuggler의 이용방법을 익힐 차례이다. 이를 위해 Xuggler 관련 샘플 코드를 좀 검색한 뒤에 그 코드를 복사해서 다음과 같은 테스트를 만들었다.
public class XugglerTest {
@Test
public void getMetadataOfExistingAVIFile() {
IContainer container = IContainer.make();
int openResult = container.open("src/test/resources/sample.avi",
IContainer.Type.READ, null);
if (openResult < 0) {
throw new RuntimeException("Xuggler file open failed: "
+ openResult);
}
int numStreams = container.getNumStreams();
System.out.printf("file \"%s\": %d stream%s; ",
"src/test/resources/sample.avi", numStreams,
numStreams == 1 ? "" : "s");
System.out.printf("bit rate: %d; ", container.getBitRate());
System.out.printf("\n");
for (int i = 0; i < numStreams; i++) {
IStream stream = container.getStream(i);
IStreamCoder coder = stream.getStreamCoder();
System.out.printf("stream %d: ", i);
System.out.printf("type: %s; ", coder.getCodecType());
System.out.printf("codec: %s; ", coder.getCodecID());
System.out.printf("duration: %s; ",
stream.getDuration() == Global.NO_PTS ? "unknown" : ""
+ stream.getDuration());
System.out.printf("start time: %s; ",
container.getStartTime() == Global.NO_PTS ? "unknown" : ""
+ stream.getStartTime());
System.out.printf("timebase: %d/%d; ", stream.getTimeBase()
.getNumerator(), stream.getTimeBase().getDenominator());
System.out.printf("coder tb: %d/%d; ", coder.getTimeBase()
.getNumerator(), coder.getTimeBase().getDenominator());
if (coder.getCodecType() == ICodec.Type.CODEC_TYPE_AUDIO) {
System.out.printf("sample rate: %d; ", coder.getSampleRate());
System.out.printf("channels: %d; ", coder.getChannels());
System.out.printf("format: %s", coder.getSampleFormat());
} else if (coder.getCodecType() == ICodec.Type.CODEC_TYPE_VIDEO) {
System.out.printf("width: %d; ", coder.getWidth());
System.out.printf("height: %d; ", coder.getHeight());
System.out.printf("format: %s; ", coder.getPixelType());
System.out.printf("frame-rate: %5.2f; ", coder.getFrameRate()
.getDouble());
}
System.out.printf("\n");
}
container.close();
}
}
위 코드를 이용해서 테스트를 돌려보고 어떤 결과값들이 출력되는지 확인한다. 몇 차례 코드를 수정하면서 테스트를 더 해 보면서 Xuggler를 이용해서 정보를 읽어오는 방법을 숙지해 나간다.
다시 FfmpegTranscoderTest로, 결과값 검증하는 코드 만들기
앞의 FfmpegTranscoderTest 코드에 변환 결과로 생성된 비디오 파일을 검증하는 코드를 추가해 보자.
public class FfmpegTranscoderTest {
...
@Test
public void transcodeWithOnfOutputFormat() {
File multimediaFile = new File("src/test/resources/sample.avi");
List<OutputFormat> outputFormats = new ArrayList<OutputFormat>();
outputFormats.add(new OutputFormat(640, 480, 300, "h264", "aac"));
List<File> transcodedFiles = transcoder.transcode(multimediaFile,
outputFormats);
assertEquals(1, transcodedFiles.size());
assertTrue(transcodedFiles.get(0).exists());
verifyTranscodedFile(outputFormats.get(0), transcodedFiles.get(0));
}
private void verifyTranscodedFile(OutputFormat outputFormat, File file) {
IContainer container = IContainer.make();
int openResult = container.open(file.getAbsolutePath(),
IContainer.Type.READ, null);
if (openResult < 0) {
throw new RuntimeException("Xuggler file open failed: "
+ openResult);
}
int numStreams = container.getNumStreams();
int width = 0;
int height = 0;
ICodec.ID videoCodec = null;
ICodec.ID audioCodec = null;
for (int i = 0; i < numStreams; i++) {
IStream stream = container.getStream(i);
IStreamCoder coder = stream.getStreamCoder();
if (coder.getCodecType() == ICodec.Type.CODEC_TYPE_AUDIO) {
audioCodec = coder.getCodecID();
} else if (coder.getCodecType() == ICodec.Type.CODEC_TYPE_VIDEO) {
videoCodec = coder.getCodecID();
width = coder.getWidth();
height = coder.getHeight();
}
}
container.close();
assertEquals(outputFormat.getWidth(), width);
assertEquals(outputFormat.getHeight(), height);
assertEquals(outputFormat.getVideoFormat(), videoCodec.toString()); // ???
assertEquals(outputFormat.getAudioFormat(), audioCodec.toString()); // ???
}
}
- OutputFormat의 메서드 이름이 getVideoFormat()/getAudioFormat()이다. 이건 getVideoCodec()과 같이 코덱의 의미로 바꿔줘야 할 것 같다.
- OutputFormat은 String으로 값을 갖고 있는 반면에 비교하려는 값은 ICodec.ID 열거 타입이다. 이 둘 간에 변환 방법이 필요할 것 같다.
- 테스트를 위한 VideoConverterTest 작성
- OutputFormat에서 코덱 정보를 표현하기 위해 VideoCodec과 AudioCodec 타입이 도메인 모델에 추가
- 변환 결과로 생성되는 파일의 이름을 만들 정책이 필요하다.
- MP4, AVI 등 컨테이너에 대한 정보 추가가 필요하다.
-
백명석 2012.11.05 14:33
"사용법을 익히기 위한 테스트 코드"
이런 걸 study test라고 한다고 함. - 토비의 스프링3에서.
사용법을 익히는 데는 실제로 실행해 보는 것이 가장 좋은 방법.
자세한 사용법은 필요한 경우 찾아서 보게됨. 처음부터 한글자도 안 빼고 읽어보는 개발자는 없을 듯.
이런 테스트도 사용법을 익혔고 별로 의미 없을 것 같다고 지운면 안됨.
후에 다른 개발자가 동일한 라이브러리 사용법을 배우거나 업그레이드로 인한 문제점 파악에 도움이 됨.
TDD 연습 5, Job에 기능 추가, 물론 Mock으로,그리고 리팩토링
TDD 연습은 오늘도 계속된다. 지난 금요일 점심 때 살짝 작업해 본 코드를 GitHub에 커밋하는 걸 깜빡해서 주말에 진행하지 못했다. 음, 그덕에 오늘 밤에 진행한다.
Job에 새로운 모델 추가
오늘은 Job에 모델을 추가해볼 거다. 비디오 변환을 처리하면 변환된 결과 비디오 파일과 썸네일 파일이 생성되는데, 그 파일을 작업 요청자가 지정한 위치에 저장해야 한다. 이 위치는 FTP가 될 수도 있고, HTTP 파일 업로드가 될 수도 있고, 단순히 로컬 파일 시스템이 될 수도 있다. 자, 일단, 구현의 상세함은 뒤로 미루고 다음과 같이 결과 파일이 보관될 위치를 추상화해서 표현할 수 있을 것이다.
public class Job {
private Long id;
private MediaSourceFile mediaSourceFile;
private DestinationStorage destinationStorage;
private State state;
private Exception occurredException;
public Job(Long id, MediaSourceFile mediaSourceFile,
DestinationStorage destinationStorage) {
this.id = id;
this.mediaSourceFile = mediaSourceFile;
this.destinationStorage = destinationStorage;
}
뭔가 이름이 적당한 게 떠오르지 않는다. (이럴 땐 영어공부를 게을리한 필자가 안타깝다.) 하튼, 이름은 DestinationStorage로 가기로 했다. 나중에 더 좋은 이름이 떠오르면 이클립스의 힘을 빌려 쉽게 바꿀 수 있다.
새로운 타입이 출현했다. 아무것도 없으므로 컴파일 에러가 난다. DestinationStorage 타입을 생성해주자. 그리고, Job 클래스의 생성자가 변경되었으므로 테스트 코드에서 Mock을 이용해서 DestinationStorage를 전달해준다.
@RunWith(MockitoJUnitRunner.class)
public class TranscodingServiceImplTest {
private Long jobId = new Long(1);
@Mock
private MediaSourceFile mediaSourceFile;
@Mock
private DestinationStorage destinationStorage;
private Job mockJob;
...
@Before
public void setup() {
mockJob = new Job(jobId, mediaSourceFile, destinationStorage);
when(mediaSourceFile.getSourceFile()).thenReturn(mockMultimediaFile);
transcodingService = new TranscodingServiceImpl(transcoder,
thumbnailExtractor, createdFileSender, jobResultNotifier,
jobRepository);
바꿨으니 당연히 뭘 해야 하는지 알 거다. 테스트를 실행해보자. 기존 코드에 문제가 없음을 확인한다.
앞서 Job에 MediaSourceFile을 추가했던 과정을 생각해보자. (기억이 안 나면 http://javacan.tistory.com/221 글을 다시 보자.) 그 과정과 마찬가지로 다음과 같은 작업을 진행할 것이다.
- 목적지를 알고 있는 것은 DestinationStorage이므로, Job이 DestinationStorage에 파일을 저장해 달라고 요청하도록 코드를 수정한다.
- 이렇게 되면 CreatedFileSaver가 필요없지므로 삭제한다.
createdFileSaver를 호출하는 코드는 필요없으므로 삭제한다. destinationStorage는 save 메서드가 없어서 컴파일 에러가 난다. 컴파일 에러가 나지 않도록 save() 메서드를 추가해 준다. 이클립스의 만능키 Ctrl+1을 누루면 바로 추가되므로 쉽게 컴파일 에러를 제거할 수 있다.
public interface DestinationStorage {
void save(List<File> multimediaFiles, List<File> thumbnails);
}
오케이 일단 뭔가 바꿨다. 테스트 코드를 돌려보자. .... 빨간불. 통과에 실패했다. 원인은 아래와 같다.
Wanted but not invoked:
createdFileSender.store([], [], 1);
-> at org...TranscodingServiceImplTest.verifyCollaboration(TranscodingServiceImplTest.java:117)
at org...TranscodingServiceImplTest.verifyCollaboration(TranscodingServiceImplTest.java:117)
...
실패 원인은 createdFileSender.store()가 호출되지 않은 것 때문이다. 앞서 Job 클래스의 storeCreatedFilesToStorage() 메서드에서 createFileSaver 객체의 store() 메서드를 호출하는 부분을 삭제했기 때문에 발생한 것이다. 이제 이 부분은 createdFileSender가 아니라 destinationStorage.save() 메서드가 호출되었는지 검증하는 코드로 바꿔야 한다. 그래서, 아래와 같이 테스트 코드에서 협업 객체들의 메서드 호출 여부를 검증하는 부분을 변경하였다.
private void verifyCollaboration(VerifyOption verifyOption) {
if (verifyOption.transcoderNever)
verify(transcoder, never()).transcode(any(File.class), anyLong());
else
verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
if (verifyOption.thumbnailExtractorNever)
verify(thumbnailExtractor, never()).extract(any(File.class),
anyLong());
else
verify(thumbnailExtractor, only()).extract(mockMultimediaFile,
jobId);
if (verifyOption.destinationStorageNever) // createdFileSaverNever를 이름 변경
verify(destinationStorage, never()).save(anyListOf(File.class),
anyListOf(File.class));
else
verify(destinationStorage, only()).save(mockMultimediaFiles,
mockThumbnails);
if (verifyOption.createdFileSenderNever)
verify(createdFileSender, never()).store(anyListOf(File.class),
anyListOf(File.class), anyLong());
else
verify(createdFileSender, only()).store(mockMultimediaFiles,
mockThumbnails, jobId);
if (verifyOption.jobResultNotifierNever)
verify(jobResultNotifier, never()).notifyToRequester(jobId);
else
verify(jobResultNotifier, only()).notifyToRequester(jobId);
}
destinationStorage Mock 객체의 save() 메서드가 호출되는 지의 여부로 변경했으므로, 다시 테스트를 실행해 보자. 6개의 테스트 메서드 중에서 1개가 실패가 난다. 실패한 테스트는 아래 메서드이다.
@Test
public void transcodeFailBecauseExceptionOccuredAtCreatedFileSender() {
doThrow(mockException).when(createdFileSender).store(
mockMultimediaFiles, mockThumbnails, jobId);
executeFailingTranscodeAndAssertFail(Job.State.STORING);
VerifyOption verifyOption = new VerifyOption();
verifyOption.jobResultNotifierNever = true;
verifyCollaboration(verifyOption);
}
private void executeFailingTranscodeAndAssertFail(State expectedLastState) {
try {
transcodingService.transcode(jobId);
fail("발생해야 함"); // 익셉션이 발생하지 않아서 실패
} catch (Exception ex) {
assertSame(mockException, ex);
}
...
}
검증하는 코드에서는 익셉션이 발생해야 통과되는데, Job 객체를 수정하는 바람에 더 이상 createdFileSaver 객체가 호출되지 않게 되었다. 위 코드는 createdFileSaver 대신 새롭게 추가된 모델인 destinationStorage의 save() 메서드에서 익셉션을 발생하도록 수정해주면 통과 시킬 수 있을 것 같다. 메서드 이름도 함께 변경해 주자.
@Test
public void transcodeFailBecauseExceptionOccuredAtDestinationStorage() {
doThrow(mockException).when(destinationStorage).save(
mockMultimediaFiles, mockThumbnails);
executeFailingTranscodeAndAssertFail(Job.State.STORING);
VerifyOption verifyOption = new VerifyOption();
verifyOption.jobResultNotifierNever = true;
verifyCollaboration(verifyOption);
}
다시 테스트 코드 실행. 모두 녹색. 통과다.
이제 필요 없어진 코드가 있다. 바로 CreatedFileSaver 이다. 이제 이 타입을 사용하는 코드를 모두 과감하게 삭제한다. (주석으로 막아서 테스트를 실행하고 통과되면 지우는 방법을 점진적으로 삭제한다.)
Job 클래스의 작은 리팩토링
필자와 개인적으로 친분이 있는 분께서 http://javacan.tistory.com/221#comment9371876와 같은 의견을 주셨다. 그 중 첫 번째 Job 클래스 부분에 대한 피드백은 아래 코드에서 구조적인 반복이 나타난다는 점이었다. 상태를 변경하고 작업 실행하고, 또 상태를 변경하고 작업을 실행하는 구조를 갖고 있다.
public void transcode(Transcoder transcoder,
ThumbnailExtractor thumbnailExtractor,
JobResultNotifier jobResultNotifier) {
try {
changeState(Job.State.MEDIASOURCECOPYING);
File multimediaFile = copyMultimediaSourceToLocal();
changeState(Job.State.TRANSCODING);
List<File> multimediaFiles = transcode(multimediaFile, transcoder);
changeState(Job.State.EXTRACTINGTHUMBNAIL);
List<File> thumbnails = extractThumbnail(multimediaFile,
thumbnailExtractor);
changeState(Job.State.STORING);
storeCreatedFilesToStorage(multimediaFiles, thumbnails);
changeState(Job.State.NOTIFYING);
notifyJobResultToRequester(jobResultNotifier);
changeState(Job.State.COMPLETED);
} catch (RuntimeException ex) {
exceptionOccurred(ex);
throw ex;
}
}
우선, 상태 변경과 작업 실행 순서 관리를 transcode()가 처리하고 있는데, 이는 transcode()의 역할을 잘못 잡은 것 같다. 실제 상태의 변화는 각각의 메서드에서 처리하고 transcode() 메서드는 순서대로 작업이 실행되도록 하는 것이 적당한 역할을 수행하는 것으로 생각된다. 그래서, 상태 변경은 각 메서드에서 처리하도록 수정했다. 그리고 완료 상태로 변경하는 부분은 completed()라는 메서드를 만들어서 그 메서드에서 처리하도록 했다.
public class Job {
...
public void transcode(Transcoder transcoder,
ThumbnailExtractor thumbnailExtractor,
JobResultNotifier jobResultNotifier) {
try {
File multimediaFile = copyMultimediaSourceToLocal();
List<File> multimediaFiles = transcode(multimediaFile, transcoder);
List<File> thumbnails = extractThumbnail(multimediaFile,
thumbnailExtractor);
storeCreatedFilesToStorage(multimediaFiles, thumbnails);
notifyJobResultToRequester(jobResultNotifier);
completed();
} catch (RuntimeException ex) {
exceptionOccurred(ex);
throw ex;
}
}
private File copyMultimediaSourceToLocal() {
changeState(Job.State.MEDIASOURCECOPYING);
return mediaSourceFile.getSourceFile();
}
private List<File> transcode(File multimediaFile, Transcoder transcoder) {
changeState(Job.State.TRANSCODING);
return transcoder.transcode(multimediaFile, id);
}
private List<File> extractThumbnail(File multimediaFile,
ThumbnailExtractor thumbnailExtractor) {
changeState(Job.State.EXTRACTINGTHUMBNAIL);
return thumbnailExtractor.extract(multimediaFile, id);
}
private void storeCreatedFilesToStorage(List<File> multimediaFiles,
List<File> thumbnails) {
changeState(Job.State.STORING);
destinationStorage.save(multimediaFiles, thumbnails);
}
private void notifyJobResultToRequester(JobResultNotifier jobResultNotifier) {
changeState(Job.State.NOTIFYING);
jobResultNotifier.notifyToRequester(id);
}
private void completed() {
changeState(Job.State.COMPLETED);
}
}
이제 Job은 작업을 순서대로 실행하는 한 가지 일만을 책임지게 되었으므로 정리가 된 느낌이다. 하지만, 여전히 각각의 메서드들은 상태변경-작업실행 요청의 반복적 구조를 갖고 있다. 이런 구조를 제거할 수 있는 다른 방법이 있을 것도 같다. 예를 들어, 각 단계를 State 같은 걸로 추상화하고 State가 지정된 순서대로 전이되도록 StateChain 같은 걸 만들어서 지정한 순서대로 각 단계의 작업이 실행되도록 할 수 있을 것 같다. 하지만, 이런 구조를 갖는 것은 현재로서는 유연함에서 오는 이점보다 복잡함에서 오는 단점이 더 많은 것 같다.
그래서, 위의 구조적 반복이 여전히 남아 있긴 하지만, 일단 이쯤에서 정리한다. 혹시 Job의 하위 타입이 생긴다거나 새로운 상태가 생긴다거나 실행 순서의 변경이 발생하게 된다면, 그 때 가서 다시 리팩토링을 해 보도록 하자. (이 정도로 지인분의 요구가 충족될지는........ 이 정도로 만족하시지요!)
테스트의 VerifyOption에 기능 주기
지인분께서 http://javacan.tistory.com/221#comment9371876에 남기신 두 번째 피드백은 verifyOption이 검증과 관련된 정보를 갖고 있으니, VerifyOption을 단순 데이터 구조가 아닌 기능을 제공하는 객체로 만들어보자는 내용이다. 음, 이 부분은 고민이 좀 된다. 테스트 코드에서 사용되는 데이터인데 그것까지 객체로 만들 필요가 있을까 하는 고민이었다. 그래도 변경하는게 어렵지 않으니 바꿔보자.
뭔가 협업 객체가 올바르게 호출되는지 여부를 확인하는 코드는 아래와 같은 방식으로 사용된다.
@Test
public void transcodeFailBecauseExceptionOccuredAtTranscoder() {
when(transcoder.transcode(mockMultimediaFile, jobId)).thenThrow(
mockException);
executeFailingTranscodeAndAssertFail(Job.State.TRANSCODING);
VerifyOption verifyOption = new VerifyOption();
verifyOption.thumbnailExtractorNever = true;
verifyOption.destinationStorageNever = true;
verifyOption.jobResultNotifierNever = true;
verifyCollaboration(verifyOption);
}
private void verifyCollaboration(VerifyOption verifyOption) {
if (verifyOption.transcoderNever)
verify(transcoder, never()).transcode(any(File.class), anyLong());
else
verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
if (verifyOption.thumbnailExtractorNever)
verify(thumbnailExtractor, never()).extract(any(File.class),
anyLong());
else
verify(thumbnailExtractor, only()).extract(mockMultimediaFile,
jobId);
if (verifyOption.destinationStorageNever)
verify(destinationStorage, never()).save(anyListOf(File.class),
anyListOf(File.class));
else
verify(destinationStorage, only()).save(mockMultimediaFiles,
mockThumbnails);
if (verifyOption.jobResultNotifierNever)
verify(jobResultNotifier, never()).notifyToRequester(jobId);
else
verify(jobResultNotifier, only()).notifyToRequester(jobId);
}
public class VerifyOption {
public boolean transcoderNever;
public boolean thumbnailExtractorNever;
public boolean destinationStorageNever;
public boolean jobResultNotifierNever;
}
}
우선, verifyCollaboration 메서드를 VerifyOption의 public 메서드로 이동시킨다. 물론, 옮기면서 this를 사용하도록 변경한다. 그리고, 각 테스트 메서드에서는 VerifyOption의 verifyCollaboration() 메서드를 호출하도록 변경한다. 그럼 코드는 아래와 같은 바뀐다.
@Test
public void transcodeFailBecauseExceptionOccuredAtDestinationStorage() {
doThrow(mockException).when(destinationStorage).save(
mockMultimediaFiles, mockThumbnails);
executeFailingTranscodeAndAssertFail(Job.State.STORING);
VerifyOption verifyOption = new VerifyOption();
verifyOption.jobResultNotifierNever = true;
verifyOption.verifyCollaboration();
}
public class VerifyOption {
public boolean transcoderNever;
public boolean thumbnailExtractorNever;
public boolean destinationStorageNever;
public boolean jobResultNotifierNever;
public void verifyCollaboration() {
if (this.transcoderNever)
verify(transcoder, never()).transcode(any(File.class),
anyLong());
else
verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
if (this.thumbnailExtractorNever)
verify(thumbnailExtractor, never()).extract(any(File.class),
anyLong());
else
verify(thumbnailExtractor, only()).extract(mockMultimediaFile,
jobId);
if (this.destinationStorageNever)
verify(destinationStorage, never()).save(anyListOf(File.class),
anyListOf(File.class));
else
verify(destinationStorage, only()).save(mockMultimediaFiles,
mockThumbnails);
if (this.jobResultNotifierNever)
verify(jobResultNotifier, never()).notifyToRequester(jobId);
else
verify(jobResultNotifier, only()).notifyToRequester(jobId);
}
}
바꿨으니 테스트 실행이다. 녹색! 통과되었다.
이름이 마음에 안 든다. 이름을 바꾸자. VerifyOption은 더 이상 단순 옵션이 아니다. 실제 검증을 수행하는 역할을 갖게 되었으므로, CollaborationVerifier와 같이 역할에 맞는 이름을 부여한다. verifyCollaboration() 메서드는 순히 verify()로 바꿔도 될 것 같다. 웃, verify()로 이름을 변경하려고 했더니 메서드 내부에서 사용한 Mockito의 verify()가 컴파일 에러가 난다. 음, 다른 이름을 찾아보자. 음.. 딱히 이름이 안 떠오른다. 일단 verifyCollaboration()으로 놔두자.
@Test
public void transcodeFailBecauseExceptionOccuredAtDestinationStorage() {
doThrow(mockException).when(destinationStorage).save(
mockMultimediaFiles, mockThumbnails);
executeFailingTranscodeAndAssertFail(Job.State.STORING);
CollaborationVerifier colVerifier = new CollaborationVerifier();
colVerifier.jobResultNotifierNever = true;
colVerifier.verifyCollaboration();
}
public class CollaborationVerifier {
public boolean transcoderNever;
public boolean thumbnailExtractorNever;
public boolean destinationStorageNever;
public boolean jobResultNotifierNever;
public void verifyCollaboration() {
if (this.transcoderNever)
verify(transcoder, never()).transcode(any(File.class),
anyLong());
else
verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
...
}
}
}
이름을 바꿨으니 다시 테스트! 녹색! 통과되었다.
CollaborationVerifier와 관련된 다음 고민은 아래와 같다.
- transcoderNever, thumbnailExtractorNever 등의 필드를 private으로 바꿔야 하나?
- verifyCollaboration() 메서드에서 접근하는 transcoder 등의 객체를 필드로 정의해야 하나?
- CollaborationVerifier 클래스를 private으로 바꿔야 하나?
TDD 연습 4-2, 리팩토링 Job 도메인 모델 추가 그리고 변화 과정
연습 4-1(http://javacan.tistory.com/220)에 이어 계속해서 Job 부분을 만들어 나가자.
새로운 모델의 출현: MediaSourceFile
Job은 원본 미디어 파일 정보를 갖고 있어야 한다. 미디어 원본 파일을 MediaSourceFile로 추상화하고, 아래와 같이 이 타입을 필드로 갖도록 코드를 만들었다.
public class Job {
...
private MediaSourceFile mediaSourceFile;
...
public Job(Long id, MediaSourceFile mediaSourceFile) {
this.id = id;
this.mediaSourceFile = mediaSourceFile;
}
...
}
빨간 줄이 나오니까 컴파일 에러가 나지 않도록 변경해 주자. 먼저 MediaSourceFile을 생성할 것이다. 아직 MediaSourceFile의 정확한 구현을 알 수 없으므로 일단 인터페이스로 만든다. 인터페이스만 있고 메서드는 없다.
// 아직 기능 없음
public interface MediaSourceFile {
}
Job 클래스의 컴파일 에러는 사라졌는데, 대신 TranscodingServiceImplTest 코드에서 컴파일 에러가 발생한다.
@RunWith(MockitoJUnitRunner.class)
public class TranscodingServiceImplTest {
private Long jobId = new Long(1);
private Job mockJob = new Job(jobId);
Job의 생성자가 변경되어서 컴파일 에러가 발생하고 있다. Job 객체를 생성하려면 MediaSourceFile 구현체가 필요하다. 일단, MediaSourceFile에 대한 Mock 객체를 만들고, 그 Mock 객체를 받도록 변경한다. Job 객체를 생성하는 위치는 setup() 메서드로 이동시킨다.
@RunWith(MockitoJUnitRunner.class)
public class TranscodingServiceImplTest {
private Long jobId = new Long(1);
@Mock
private MediaSourceFile mediaSourceFile;
private Job mockJob;
...
@Before
public void setup() {
mockJob = new Job(jobId, mediaSourceFile);
...
뭔가 바꾸면 해야 할 게 있다. 그렇다, 바로 테스트 실행이다. 테스트 실행 ... 녹색바! 통과다.
MediaSourceFile은 미디어 파일을 복사하는 것과 관련되었으므로, Job 클래스의 copyMultimediaSourceToLocal() 메서드를 보자.
public class Job {
private Long id;
private MediaSourceFile mediaSourceFile;
...
public Job(Long id, MediaSourceFile mediaSourceFile) {
this.id = id;
this.mediaSourceFile = mediaSourceFile;
}
public void transcode(MediaSourceCopier mediaSourceCopier,
Transcoder transcoder, ThumbnailExtractor thumbnailExtractor,
CreatedFileSaver createdFileSaver,
JobResultNotifier jobResultNotifier) {
try {
changeState(Job.State.MEDIASOURCECOPYING);
File multimediaFile = copyMultimediaSourceToLocal(mediaSourceCopier);
...
} catch (RuntimeException ex) {
exceptionOccurred(ex);
throw ex;
}
}
private File copyMultimediaSourceToLocal(MediaSourceCopier mediaSourceCopier) {
return mediaSourceCopier.copy(id); // mediaSourceFile을 줘야 하나?
}
copyMultimediaSourceToLocal() 메서드는 mediaSourceCopier 객체의 copy() 메서드를 호출할 때 id 필드를 값으로 주고 있다. 읽어올 미디어 원본 파일 정보가 mediaSourceFile 필드에 있으므로, id 대신 mediaSourceFile을 건내주면 될 것 같다.
음... 잠깐! MediaSourceFile에 다음과 같은 메서드를 정의하면 어떨까?
public interface MediaSourceFile {
public File getSourceFile();
}
또는
public interface MediaSourceFile {
public void writeTo(File file);
}
두 가지 중 어떤 것을 선택해도 mediaSourceCopier의 필요성이 사라진다. 오호~ 그렇군. 조금 고민하다가 파일이 어디에 보관될지 여부는 Job이 스스로 결정하는 게 좋을 듯 싶어, getSourceFile() 메서드를 사용하기로 결정했다. 결정했으니 바꿔보자.
- MediaSourceFile 인터페이스에 getSourceFile() 메서드를 추가한다.
- copyMultimediaSourceToLocal() 메서드가 mediaSourceFile.getSourceFile()를 사용하도록 변경한다.
- 이렇게 바꾸면, copyMultimediaSourceToLocal() 메서드가 MediaSourceCopier를 필요로 하지 않는다. 그러니 파라미터에서 mediaSourceCopier를 제거한다.
테스트 실행,,, 녹색! 모두 통과다. 이로서 Job과 관련된 새로운 모델인 MediaSourceFile을 추가하고 MediaSourceFile이 원본으로부터 파일을 가져오도록 설계를 변경하는데 성공했다.
MediaSourceCopier는 이제 필요 없어요!
MediaSourceFile의 등장으로 MediaSourceCopier는 필요 없어졌다. 이제 과감하게 MediaSourceCopier를 소스 코드에서 제거할 차례이다. MediaSourceCopier 타입을 없애고, MediaSourceCopier 타입의 필드, 초기화 코드, 파라미터 등을 모두 제거한다.
// Job 클래스에서 제거
public class Job {
....
public void transcode(MediaSourceCopier mediaSourceCopier,
Transcoder transcoder, ThumbnailExtractor thumbnailExtractor,
CreatedFileSaver createdFileSaver,
JobResultNotifier jobResultNotifier) {
try {
// TranscodingServceImpl 클래스에서 제거
public class TranscodingServiceImpl implements TranscodingService {
private MediaSourceCopier mediaSourceCopier;
private Transcoder transcoder;
...
public TranscodingServiceImpl(MediaSourceCopier mediaSourceCopier,
Transcoder transcoder, ThumbnailExtractor thumbnailExtractor,
CreatedFileSaver createdFileSaver,
JobResultNotifier jobResultNotifier,
JobRepository jobRepository) {
this.mediaSourceCopier = mediaSourceCopier;
this.transcoder = transcoder;
...
}
@Override
public void transcode(Long jobId) {
Job job = jobRepository.findById(jobId);
job.transcode(mediaSourceCopier, transcoder, thumbnailExtractor,
createdFileSaver, jobResultNotifier);
}
// 테스트 코드에서 제거
@RunWith(MockitoJUnitRunner.class)
public class TranscodingServiceImplTest {
...
@Mock
private MediaSourceCopier mediaSourceCopier;
@Mock
private Transcoder transcoder;
...
@Before
public void setup() {
mockJob = new Job(jobId, mediaSourceFile);
when(mediaSourceFile.getSourceFile()).thenReturn(mockMultimediaFile);
transcodingService = new TranscodingServiceImpl(mediaSourceCopier,
transcoder, thumbnailExtractor, createdFileSender,
jobResultNotifier, jobRepository);
when(jobRepository.findById(jobId)).thenReturn(mockJob);
when(mediaSourceCopier.copy(jobId)).thenReturn(mockMultimediaFile);
...
}
코드를 수정했으니까, 다음으로 할 작업은 테스트 실행이다. 테스트 실행~.. 녹색! 모두 통과다.
테스트 코드를 이용한 안정적인 리팩토링
TDD를 이용해서 TranscodingServce의 구현부터 Job의 일부 구현까지 진행되었다. 한 번 정리해 보자.
최초의 TDD 결과물은 아래와 같았다.
하지만, 결과물이 마음에 안 들었고 그래서 Job으로 기능을 옮겼다. 물론, 테스트는 그대로 유지하면서. 그래서 다음과 같이 Job이 출현하고 불필요해진 두 개의 타입이 사라졌다.
그리고, 새로운 도메인 모델인 MediaSourceFile을 추가하는 과정에서 일부 로직이 MediaSourceFile로 이동했고, 이 과정에서 MediaSourceCopier 타입이 또 사라졌다.
그리고, 무엇보다도 중요한 건, 테스트 코드를 통해서 이러한 변화 과정을 안정적으로 진행했다는 점이다.
TDD 연습 4-1. 리팩토링! TranscodingService와 Job을 바꿔라!
http://javacan.tistory.com/218#comment9368196 댓글을 보자. 사실 필자가 하고 만들고 싶었던 코드는 지금까지 작성했던 그런 코드가 아니었다. 그래 마음에 안 든다. 마음에 안 드는 것들은 다음과 같다.
- TranscodingServiceImp이 Job의 상태 변경을 관리한다.
- Job이 자기의 상태 변화를 직접 관리하는 것이 아니라 남이 변경을 한다.
- Job이 작업 순서를 관리하는 게 아니라 남이 관리해 준다.
- 향후 작업 순서가 바뀌거나 Job의 종류에 따라 작업 순서가 달려져야 할 경우, TranscodingServiceImpl에 변화가 생긴다. 뭔가 Job의 다형성이 안 될 것 같은 느낌이 든다.
- transcode() 메서드가 jobId를 전달받는데, 그럴 필요가 없다. 왜냐면, jobId를 알고 있는 건 Job이기 때문이다. Job에 이를 위해 id 필드를 추가한다. 그리고, 각 메서드는 jobId 파라미터 대신 id 필드를 사용하도록 바꾼다.
- transcodingExceptionHandler가 필요했던 이유는 TranscodingServiceImpl이 에러 사실을 Job에 알리기 위함이었다. 위 코드가 Job 내부에서 실행되므로 이제 에러가 발생하면 Job이 알게 된다. 따라서, try-catch 블록은 transcode() 메서드에만 위치하면 되며, 나머지 다른 메서드의 try-catch는 필요 없다. 또한, transcodingExceptionHandler도 필요없다.
- jobStateChanger가 필요했던 이유는 TranscodingServceImpl에서 job의 상태를 변경하기 위함이었다. 이제 Job 객체가 실행 흐름을 제어하므로 스스로 상태를 변경할 수 있게 되었다. 따라서, jobStateChanger가 필요 없다.
- 또한, changeState() 메서드가 있으므로 changeJobState() 메서드는 더 이상 필요 없다. trancode() 메서드 내부에서 changeJobState() 메서드 대신에 changeState() 메서드를 호출하도록 변경하자.
- mediaSourceCopier, jobResultNotifier 등의 협업 객체는 transcode() 메서드를 통해서 전달받도록 하고, transcode() 메서드가 다시 알맞은 협업 객체를 전달해주는 방식으로 바꾼다.
- JobStateChanger
- TranscodingExceptionHandler
- Job이 기능을 수행하기 위해 필요한 협업 객체를 Job에게 전달하기 위해 TranscodingServceImpl 객체가 mediaSourceCopier, transcoder 등의 필드를 갖고 있다.
-
백명석 2012.10.26 13:01
알고 있겠지만. 보는 이들을 위해... job#transcode가 너무 크다.
"function should do onething !!"
"Extract till you drop !!"
그리고 의존성 문제는 spring의 경우 @Configurable이나 aspectJ로 해소하는 방법밖에 없지 않을까나 ?-
최범균 madvirus 2012.10.26 15:39 신고
AspectJ를 사용하지 않게 되면,,,,, 아무래도 Locator 같은 것이 출현하겠죠? 푸,, AspectJ 사용하면 단위 테스트 목적으로 별도 메서드나 기능을 만들어야 해서,, 그게 좀 짱나요. ^^;
-
TDD 연습 3. 테스트 코드 정리
세 번째 TDD 연습에서는 테스트 코드 자체를 좀 정리할 것이다. 연습1과 2를 통해서 TranscodingServiceImpl 클래스의 정상 동작인 경우와 비정상 동작인 경우에 대해서 테스트를 진행하였다. 그런데 테스트 코드를 만들면서 중복된 코드들이 많이 출현하였다.
먼저 다음의 두 메서드를 비교해 보자.
@Test
public void transcodeSuccessfully() {
when(jobRepository.findById(jobId)).thenReturn(mockJob);
File mockMultimediaFile = mock(File.class);
when(mediaSourceCopier.copy(jobId)).thenReturn(mockMultimediaFile);
List<File> mockMultimediaFiles = new ArrayList<File>();
when(transcoder.transcode(mockMultimediaFile, jobId)).thenReturn(
mockMultimediaFiles);
List<File> mockThumbnails = new ArrayList<File>();
when(thumbnailExtractor.extract(mockMultimediaFile, jobId)).thenReturn(
mockThumbnails);
Job job = jobRepository.findById(jobId);
assertTrue(job.isWaiting());
transcodingService.transcode(jobId);
job = jobRepository.findById(jobId);
assertTrue(job.isFinished());
assertTrue(job.isSuccess());
assertEquals(Job.State.COMPLETED, job.getLastState());
assertNull(job.getOccurredException());
verify(mediaSourceCopier, only()).copy(jobId);
verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
verify(thumbnailExtractor, only()).extract(mockMultimediaFile, jobId);
verify(createdFileSender, only()).store(mockMultimediaFiles,
mockThumbnails, jobId);
verify(jobResultNotifier, only()).notifyToRequester(jobId);
}
@Test
public void transcodeFailBecauseExceptionOccuredAtJobResultNotifier() {
when(jobRepository.findById(jobId)).thenReturn(mockJob);
File mockMultimediaFile = mock(File.class);
when(mediaSourceCopier.copy(jobId)).thenReturn(mockMultimediaFile);
List<File> mockMultimediaFiles = new ArrayList<File>();
when(transcoder.transcode(mockMultimediaFile, jobId)).thenReturn(
mockMultimediaFiles);
List<File> mockThumbnails = new ArrayList<File>();
when(thumbnailExtractor.extract(mockMultimediaFile, jobId)).thenReturn(
mockThumbnails);
RuntimeException mockException = new RuntimeException();
doThrow(mockException).when(jobResultNotifier).notifyToRequester(jobId);
Job job = jobRepository.findById(jobId);
assertTrue(job.isWaiting());
try {
transcodingService.transcode(jobId);
fail("발생해야 함");
} catch (Exception ex) {
assertSame(mockException, ex);
}
job = jobRepository.findById(jobId);
assertTrue(job.isFinished());
assertFalse(job.isSuccess());
assertEquals(Job.State.NOTIFYING, job.getLastState());
assertNotNull(job.getOccurredException());
verify(mediaSourceCopier, only()).copy(jobId);
verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
verify(thumbnailExtractor, only()).extract(mockMultimediaFile, jobId);
verify(createdFileSender, only()).store(mockMultimediaFiles,
mockThumbnails, jobId);
verify(jobResultNotifier, only()).notifyToRequester(jobId);
}
}
굵게 표시한 부분은 테스트를 위한 Mock 객체를 초기화하는 부분으로 다른 테스트 메서드도 일부 다른 부분이 있지만, 거의 유사하다. 먼저 다음과 같이 코드를 수정한다.
- mockMultimediaFile 변수, mockMultimediaFiles 변수, mockThumbnails 변수를 필드로 변경한다.
- 각 테스트 메서드에서 세 변수 선언 부분을 삭제한다.
변경된 코드는 다음과 같다.
@RunWith(MockitoJUnitRunner.class)
public class TranscodingServiceImplTest {
...
// 각 테스트 메서드에서 공통으로 사용되는 로컬 변수를 필드로 전환
private File mockMultimediaFile = mock(File.class);
private List<File> mockMultimediaFiles = new ArrayList<File>();
private List<File> mockThumbnails = new ArrayList<File>();
...
@Test
public void transcodeSuccessfully() {
// 필드로 바뀐 로컬 변수 선언 삭제됨
when(jobRepository.findById(jobId)).thenReturn(mockJob);
when(mediaSourceCopier.copy(jobId)).thenReturn(mockMultimediaFile);
when(transcoder.transcode(mockMultimediaFile, jobId)).thenReturn(
mockMultimediaFiles);
when(thumbnailExtractor.extract(mockMultimediaFile, jobId)).thenReturn(
mockThumbnails);
...
// verify에서 필드 사용
verify(mediaSourceCopier, only()).copy(jobId);
verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
...
}
// 나머지 테스트 코드도 동일하게 변경
...
@Test
public void transcodeFailBecauseExceptionOccuredAtJobResultNotifier() {
when(jobRepository.findById(jobId)).thenReturn(mockJob);
when(mediaSourceCopier.copy(jobId)).thenReturn(mockMultimediaFile);
when(transcoder.transcode(mockMultimediaFile, jobId)).thenReturn(
mockMultimediaFiles);
when(thumbnailExtractor.extract(mockMultimediaFile, jobId)).thenReturn(
mockThumbnails);
...
verify(mediaSourceCopier, only()).copy(jobId);
verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
...
}
}
뭔가 바꿨으니 테스트를 실행해 본다. 녹색! 문제가 발생하지 않았다. 이제 다음 중복으로 넘어가자. 또 다른 중복되는 부분은 Mock 객체의 행위를 지정하는 부분이다. (when().thenReturn() 류의 코드.) 이런 코드들 역시 각 테스트 메서드에서 중복해서 출현하므로 setUp() 메서드로 이동시키자.
@Before
public void setup() {
transcodingService = new TranscodingServiceImpl(mediaSourceCopier,
transcoder, thumbnailExtractor, createdFileSender,
jobResultNotifier, jobStateChanger, transcodingExceptionHandler);
// 각 테스트 메서드의 중복되는 초기화 코드를 setup으로 이동
when(jobRepository.findById(jobId)).thenReturn(mockJob);
when(mediaSourceCopier.copy(jobId)).thenReturn(mockMultimediaFile);
when(transcoder.transcode(mockMultimediaFile, jobId)).thenReturn(
mockMultimediaFiles);
when(thumbnailExtractor.extract(mockMultimediaFile, jobId)).thenReturn(
mockThumbnails);
doAnswer(new Answer<Object>() {
@Override
public Object answer(InvocationOnMock invocation) throws Throwable {
Job.State newState = (State) invocation.getArguments()[1];
mockJob.changeState(newState);
return null;
}
}).when(jobStateChanger).chageJobState(anyLong(), any(Job.State.class));
...
}
@Test
public void transcodeSuccessfully() {
// 초기화 코드 제거됨
Job job = jobRepository.findById(jobId);
assertTrue(job.isWaiting());
transcodingService.transcode(jobId);
...
}
@Test
public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {
// 초기화 코드 제거됨
...
}
@Test
public void transcodeFailBecauseExceptionOccuredAtTranscoder() {
// 초기화 코드 제거됨
RuntimeException mockException = new RuntimeException();
when(transcoder.transcode(mockMultimediaFile, jobId)).thenThrow(
mockException);
...
}
@Test
public void transcodeFailBecauseExceptionOccuredAtThumbnailExtractor() {
// 초기화 코드 제거됨
RuntimeException mockException = new RuntimeException();
when(thumbnailExtractor.extract(mockMultimediaFile, jobId)).thenThrow(
mockException);
...
try {
transcodingService.transcode(jobId);
fail("발생해야 함");
} catch (Exception ex) {
assertSame(mockException, ex);
}
}
@Test
public void transcodeFailBecauseExceptionOccuredAtCreatedFileSender() {
// 초기화 코드 제거됨
RuntimeException mockException = new RuntimeException();
doThrow(mockException).when(createdFileSender).store(
mockMultimediaFiles, mockThumbnails, jobId);
...
}
@Test
public void transcodeFailBecauseExceptionOccuredAtJobResultNotifier() {
// 초기화 코드 제거됨
RuntimeException mockException = new RuntimeException();
doThrow(mockException).when(jobResultNotifier).notifyToRequester(jobId);
...
}
뭔가를 바꿨으니 또 테스트다. 녹색! 통과다.
이제 실패의 경우를 테스트하는 5개의 테스트 메서드에서 중복을 제거하자. 실패 시나리오를 테스트하는 두 메서드를 보자.
@Test
public void transcodeFailBecauseExceptionOccuredAtCreatedFileSender() {
RuntimeException mockException = new RuntimeException();
doThrow(mockException).when(createdFileSender).store(
mockMultimediaFiles, mockThumbnails, jobId);
try {
transcodingService.transcode(jobId);
fail("발생해야 함");
} catch (Exception ex) {
assertSame(mockException, ex);
}
Job job = jobRepository.findById(jobId);
assertTrue(job.isFinished());
assertFalse(job.isSuccess());
assertEquals(Job.State.STORING, job.getLastState());
assertNotNull(job.getOccurredException());
verify(mediaSourceCopier, only()).copy(jobId);
verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
verify(thumbnailExtractor, only()).extract(mockMultimediaFile, jobId);
verify(createdFileSender, only()).store(mockMultimediaFiles,
mockThumbnails, jobId);
verify(jobResultNotifier, never()).notifyToRequester(jobId);
}
@Test
public void transcodeFailBecauseExceptionOccuredAtJobResultNotifier() {
RuntimeException mockException = new RuntimeException();
doThrow(mockException).when(jobResultNotifier).notifyToRequester(jobId);
Job job = jobRepository.findById(jobId);
assertTrue(job.isWaiting());
try {
transcodingService.transcode(jobId);
fail("발생해야 함");
} catch (Exception ex) {
assertSame(mockException, ex);
}
job = jobRepository.findById(jobId);
assertTrue(job.isFinished());
assertFalse(job.isSuccess());
assertEquals(Job.State.NOTIFYING, job.getLastState());
assertNotNull(job.getOccurredException());
verify(mediaSourceCopier, only()).copy(jobId);
verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
verify(thumbnailExtractor, only()).extract(mockMultimediaFile, jobId);
verify(createdFileSender, only()).store(mockMultimediaFiles,
mockThumbnails, jobId);
verify(jobResultNotifier, only()).notifyToRequester(jobId);
}
굵게 표시한 부분이 중복된 코드이다. 이 중복을 다음과 같이 제거해 보자.
- mockException 로컬 변수를 필드로 전환
- try - catch 블록과 job의 상태를 검사하는 코드를 메서드로 분리
TDD 연습 2. TranscodingService의 실패 시나리오 테스트를 통한 점진적 구현 추가
* TDD 과정을 공유하기 위해 연습하는 과정을 글로 남긴다.
실패 상황 시나리오 구현하기
앞서 http://javacan.tistory.com/entry/TDD-exercise-1-Design-communication-using-tdd 글에 이어 실패 상황을 구현해 보자. 우선 원본 소스로부터 파일을 복사해 오는 과정이 실패났을 경우를 가정해서 테스트 코드를 만든다.
@RunWith(MockitoJUnitRunner.class)
public class TranscodingServiceImplTest {
private Long jobId = new Long(1);
@Mock
private MediaSourceCopier mediaSourceCopier;
....
private TranscodingService transcodingService;
@Test
public void transcodeSuccessfully() {
....
}
@Test
public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {
try {
transcodingService.transcode(jobId);
fail("발생해야 함");
} catch (Exception ex) {
}
Job job = jobRepository.findById(jobId);
assertFalse(job.isSuccess());
assertEquals(Job.State.MEDIASOURCECOPYING, job.isLastState());
verify(mediaSourceCopier, only()).copy(jobId);
verify(transcoder, never()).transcode(any(File.class), anyLong());
verify(thumbnailExtractor, never()).extract(any(File.class), anyLong());
verify(createdFileSender, never()).store(anyListOf(File.class),
anyListOf(File.class), anyLong());
verify(jobResultNotifier, never()).notifyToRequester(jobId);
}
}
위 테스트 메서드는 MediaSourceCopier가 copy 과정에서 문제가 발생한 경우 그걸 검증하는 코드로 작성되었다. 먼저 transcodingService.transcode() 메서드는 익셉션을 발생시켜야 한다. 그렇지 않을 경우 테스트가 실패하도록 했다. 그리고, mediaSourceCopier를 제외한 나머지 협업 객체는 호출되어서는 안 되므로, 호출 검증 값을 never()로 지정하였다.
드디어 Job과 jobRepository가 출현했다. 먼저 연습 1에서 했던 것 처럼, jobRepository를 필드로 만들고 JobRepository 인터페이스를 생성한다. 그리고, Job 클래스도 함께 만들어준다.
@RunWith(MockitoJUnitRunner.class)
public class TranscodingServiceImplTest {
...
@Mock
private JobRepository jobRepository;
private TranscodingService transcodingService;
...
@Test
public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {
try {
transcodingService.transcode(jobId);
fail("발생해야 함");
} catch (Exception ex) {
}
Job job = jobRepository.findById(jobId);
assertFalse(job.isSuccess());
assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());
...
}
}
컴파일 에러다. 컴파일 에러가 나지 않도록 다음과 같이 메서드/enum 타입 등을 추가해 주자.
public interface JobRepository {
Job findById(Long jobId);
}
public class Job {
public static enum State {
MEDIASOURCECOPYING
}
public boolean isSuccess() {
return false;
}
public State getLastState() {
return null;
}
}
컴파일 에러를 제거했으므로 테스트를 실행한다. 테스트가 실패한다. 원인은 다음과 같다.
java.lang.AssertionError: 발생해야 함
at org.junit.Assert.fail(Assert.java:91)
at org.chimi.s4t.domain.transcode.TranscodingServiceImplTest.transcodeFailBecauseExceptionOccuredAtMediaSourceCopier(TranscodingServiceImplTest.java:74)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
...
아! transcodingService.transcode() 메서드가 익셉션을 발생시켜야 하는데, 무사히 실행되어서 fail()에 걸렸다. 이 테스트는 원본 미디어 파일을 복사해 오는 도중에 에러가 발생한 경우를 가정한 것이므로 mediaSourceCopier Mock 객체가 익셉션을 발생시키도록 하자.
@Test
public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {
RuntimeException mockException = new RuntimeException();
when(mediaSourceCopier.copy(jobId)).thenThrow(mockException);
try {
transcodingService.transcode(jobId);
fail("발생해야 함");
} catch (Exception ex) {
assertSame(mockException, ex);
}
Job job = jobRepository.findById(jobId);
assertFalse(job.isDone());
assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());
...
}
다시 테스트 실행.... 빨간색이다. NullPointerException이다. 스택트레이스를 보니 아래 줄에서 job이 null이어서 발생한 것이다.
assertFalse(job.isSuccess());
jobRepository가 Job 객체를 리턴하도록 Mock 객체를 설정하자.
@Test
public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {
when(jobRepository.findById(jobId)).thenReturn(new Job());
RuntimeException mockException = new RuntimeException();
when(mediaSourceCopier.copy(jobId)).thenThrow(mockException);
try {
transcodingService.transcode(jobId);
fail("발생해야 함");
} catch (Exception ex) {
assertSame(mockException, ex);
}
Job job = jobRepository.findById(jobId);
assertFalse(job.isSuccess());
assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());
다시 테스트 실행. 에러가 났다. job.getLastState()가 MEDIASOURCECOPYING이 아니라는 에러다. 그런데, job.isSuccess()가 false인지 확인하는 코드는 통과되었다. 이런! 잠시 고민하다가, 앞에서 작성했었던 transcodeSuccessfully() 테스트에 다음과 같이 Job에 대한 검증 코드를 추가하였다.
@Test
public void transcodeSuccessfully() {
when(jobRepository.findById(jobId)).thenReturn(new Job());
...
transcodingService.transcode(jobId);
Job job = jobRepository.findById(jobId);
assertTrue(job.isSuccess());
assertEquals(Job.State.COMPLETED, job.getLastState());
... // Mock 객체들의 상호작용 확인
}
@Test
public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {
when(jobRepository.findById(jobId)).thenReturn(new Job());
RuntimeException mockException = new RuntimeException();
when(mediaSourceCopier.copy(jobId)).thenThrow(mockException);
try {
transcodingService.transcode(jobId);
fail("발생해야 함");
} catch (Exception ex) {
assertSame(mockException, ex);
}
Job job = jobRepository.findById(jobId);
assertFalse(job.isSuccess());
assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());
... // Mock 객체들의 상호작용 확인
}
Job.State enum 타입에 COMPLETED를 추가한 뒤에 다시 테스트를 실행해 보자. 에러다! 두 테스트 메서드에서 모두 실패가 발생했다.
- transcodeSuccessfully() 메서드의 실패 지점
- assertTrue(job.isSuccess())
- 아직 assertEquals(Job.State.COMPLETED, job.getLastState()) 는 확인도 못한다.
- transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() 메서드의 실패 지점
- assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());
이제 이 두 테스트 메서드가 모두 통과되도록 TranscodingServiceImpl을 수정해 보자.
TranscodingServiceImpl 구현 고민 그리고 새로운 협업 객체 등장
테스트를 통과시키기 위해서 TranscodingServiceImpl 클래스에 다음의 처리를 해야 한다.
- 성공적으로 실행되면 Job의 상태를 COMPLETED로 바꾸고 성공 여부를 true로 지정한다.
- 원본 파일 복사 전에 Job의 상태를 MEDIASOURCECOPYING로 바꾸고, 복사 과정 중 익셉션이 발생하면 Job의 성공 여부를 false로 지정한다.
- 작업이 시작 전 / 진행 중 / 끝남 여부
- 작업의 성공/실패 여부
- 실패했다면, 어느 단계에서 실패했는 지 그리고 실패 원인
- Job.isWaiting(): 작업이 대기 중인지의 여부
- Job.isFinished(): 작업이 완료되었는지의 여부
- Job.getOccuredException(): 진행 과정 중 발생한 익셉션을 구한다.
결과는?
- 드디어 Job과 JobRepository가 출현했다.
- 두 개의 협업 객체가 새롭게 추가되었다. (최초에 필자가 머리속으로 상상했던 것 보다 더 많은 협업 객체가 출현했다.)
- 성공 시나리오와 실패 시나리오에 대한 테스트도 만들어졌다.
연습3에서는 앞서 말한데로 테스트 코드의 중복을 제거해 보도록 하자.
-
백명석 2012.10.25 07:16
. service, repository, controller 등에는 위임(상호작용)을 제외하고는 테스트가 필요할 수준의 코드가 없는 것이 좋다고 생각함. 그에 비해 위 테스트는 너무 자세함. exception이 발생해야 할 경우 발생했나만 확인하면 되지 굳이 catch로 막고, assert/verify 등을 할 필요가 있을까 ?
. TranscodingService의 생성자에 인자의 수가 너무 많은 듯(6개). 이럴 경우 setter가 더 보기 좋을 수도. 그리고 이렇게 많아야만 하는지가 가장 의문시 됨.
. Job#changeState에서 state를 바로 변경하기 보다 start, end 등의 trigger 메소드를 추가하고 해당 메소드에서 state 객체에게 위임(this를 넘겨서 state가 자신을 변경하고 job의 상태도 변경하도록)하는 것이 좋을 듯.-
최범균 madvirus 2012.10.25 09:24 신고
TranscodingService와 Job의 State는 다음과 같은 고민의 결과입니다. 아직까지 완전한 답을 찾지 못했고, 그래서 일단 제가 하기 쉬운 방법으로 구현을 진행하게 되었어요.
첫 번째 고민거리는 Job에 execute() 메서드를 넣고, 그 execute() 메서드에서 변환 처리를 수행하는 것에 대한 부분이였어요. Job이 작업을 처리하면, TranscodingService는 리포지토리에서 Job을 구하고, 그 Job의 execute() 메서드를 호출하는 걸로 끝나게 되죠.
처음에는 이 방법으로 약간의 구현을 생각해봤는데, 다음과 같은 것들이 걸렸어요.
- 각 단계가 끝나면 Job의 상태가 변경되어서 작업 요청자가 확인할 수 있어야 함
- State 패턴을 적용해서 State별로 자기 작업을 수행하고, 실패를 처리하는 등의 것을 생각해 봄
- 그런데, 향후에 뭐가 되었든 Job이 Persistence 영역과 엮이게 되면, 각 State 객체의 메서드가 내부적으로 트랜잭션을 처리해 주어야 하고, State의 전이가 발생하면 Job의 물리적 데이터가 해당 State로 변경되어야 함
그런데, 제가 좋아하는 ORM과 스프링을 사용할 경우, (사실, 이게 문제죠! 제가 구현을 너무 빨리 고려했다는 것),
- Job은 DB 세션과 연결되어 있어서 State의 전이가 일어날 때 그걸 바로 반영하려면 뭔가 Persistece 관련 기능에 의존할 것 같고
- State의 메서드마다 별도로 트랜잭션을 처리하려면 뭔가 Transaction을 추상화해서 Inject 해 줘야 할 것 같고,
- State의 전이는 새로운 State 객체의 생성과도 연결되니 객체의 생성 시점에 Inject 하는 방법이 필요할 것 같고
음,, 이렇게 생각이 뻗어나가면서 해답을 찾지 못했어요. 이건 좋은 아이디어 있으면 좀 주셔요.
하튼, 그래서 트랜잭션을 쉽게 분리할 수 있는 방법을 찾았고, 그 결과로 각 단계마다 별도 협업 객체를 출현시키는 방법이 출현하게 되었어요.
이렇게 되다보니 Job의 상태가 단순 값으로 바뀌었고, 상태 전이 부분은 향후 리팩을 기약하며(^^;) 먼저 동작하는 코드를 만들었어요.
시간이 많으면 더 고민하겠는데, 저녁에 짬짬히 하다보니 (드라마를 병행하면서....) 생각이 잘 안 떠오르네요.
-