How To Tdd Using jMock

TDD를 수행하는 데 있어 Mock Object를 효과적으로 적용하는 것은 중요한 요소 중 하나이다. 케이스 스터디를 통해 jMock 프레임워크를 사용하여 TDD를 접목하는 방법을 알아보자.

케이스 스터디는 TDD 수행 방법에 따라 다음 순서로 진행된다.

  1. 새로운 요구사항(Requirement)을 검증(validate)하기 위한 테스트 작성
  2. 테스트를 수행하는 기능 코드 작성
  3. 코드 리팩토링

케이스 스터디를 수행하려면 먼저 jMock 프레임워크와 JUnit 프레임워크를 설치해야 한다.
jMock 프레임워크는 http://www.jmock.org에서 구할 수 있다. 이번 케이스 스터디에서는 1.2.0 버전을 사용한다.(현재 최신 버전은 2.5.1)
JUnit 프레임워크는 http://www.junit.org에서 구할 수 있다. 이번 케이스 스터디에서는 3.8.2 버전을 사용한다.(현재 최신 버전은 4.5)

Interaction 1

Requirement
캐쉬 컴포넌트는 lookUp() 이 처음 호출될 때 하나의 object를 로드해야만 한다.

1.1 test code

TimedCache 클래스를 위한 유닛 테스트, TimedCacheTest를 먼저 만든다.

import org.jmock.MockObjectTestCase;
 
public class TimedCacheTest extends MockObjectTestCase {
    public void testLoadsObjectThatIsNotCached() {
        Mock mockLoader = mock(ObjectLoader.class);
        TimedCache cache = new TimedCache((ObjectLoader)mockLoader.proxy());
        Object KEY = new Object();
        Object VALUE = new Object();
        mockLoader.expects(once()).method("load").with(eq(KEY)).will(returnValue(VALUE));
        assertSame("should be first object", VALUE, cache.lookUp(KEY));
    }
}

Code Notes:

  • MockObjectTestCase를 상속함으로써 jMock과 JUnit를 적용할 수 있다.
  • 캐쉬는 ObjectLoader 컴포넌트에 로드 기능을 위임할 것이기에 테스트 코드에서 mock ObjectLoader는 캐쉬 생성자 파라미터로서 건네진다.
  • KEY, VALUE는 Object로 만들어진다.
  • mockLoader는 한번 호출될 것으로 간주한다. 즉, load(KEY)를 호출하여 VALUE를 리턴한다.
  • assertSame(VALUE, cache.lookUp(KEY)을 호출한다.

Test 결과
« TimedCacheTest.testLoadsObjectThatIsNotCached fails»
테스트 코드는 컴파일되지 않는다.

CI Tip:
여기까지 작성한 코드는 저장소에 커밋하지 않는다. 테스트가 성공하지 않았기 때문이다.

1.2 functional code

테스트를 수행하는 데 필요한 코드를 작성한다.

public interface ObjectLoader {
    public abstract Object load(Object Key);
}

Code Notes:

  • ObjectLoader는 load method signature를 제공한다.

TimedCache 클래스는 테스트를 성공하게 만드는 가장 단순한 구현으로 만든다. DTSTTCPW(Do The Simplest Thing That Could Possibly Work)

public class TimedCache {
    private ObjectLoader loader;

    public TimedCache(ObjectLoader loader) {
        this.loader = loader;
    }

    public Object lookUp(Object key) {
        return loader.load(key);
    }
}

Code Notes:

  • loader는 생성자에 의해 삽입되는(injected) private field이다.
  • TimedCache.lookUp() 메쏘드의 현재 버전은 테스트를 성공시키기 위한 가장 간단한 구현이다 - 단지 로드된 object를 리턴할 뿐이다.

Test Results
« TimedCachedTest.testLoadsObjectThatIsNotCached pass»

CI Tip:
여기까지 작성한 코드는 저장소에 커밋한다.
eclipse에서 subversion을 사용하는 방법은 다음 사이트를 참고하라.

커밋을 끝냈다면 이제 퇴근해도 좋지만 뭔가 아쉬우니 리팩토링을 하자.

1.3 리팩토링

TestTimedCacheTest를 리팩토링한다.

public class TimedCacheTest extends MockObjectTestCase {
    private static final Object KEY = new Object();
    private static final Object VALUE = new Object();
    private Mock mockLoader;
    private TimedCache cache;
 
    @Override
    protected void setUp() throws Exception {
        mockLoader = mock(ObjectLoader.class);
        cache = new TimedCache((ObjectLoader) mockLoader.proxy());
        super.setUp();
    }
 
    public void testLoadsObjectThatIsNotCached() {
        mockLoader.expects(once()).method("load").with(eq(KEY)).will(
                returnValue(VALUE));
        assertSame("should be first object", VALUE, cache.lookUp(KEY));
    }
}

테스트 결과
« pass »

CI Tip:
여기까지 리팩토링한 코드를 저장소에 커밋한다. 자, 이제 맘 편히 퇴근을 할 수 있겠다.

Interaction 2

Requirement
캐쉬 컴포넌트는 처음 호출될 때 object를 로드하여야 한다.
캐쉬 컴포넌트는 이전에 로드된 object는 다시 로드하면 안 된다.

2.1 test code

추가된 요구사항을 위한 새 테스트를 테스트 클래스에 추가한다.

...
    public void testDoesNotLoadObjectThatIsCached() {
        mockLoader.expects(once()).method("load").with(eq(KEY)).will(
                returnValue(VALUE));
        assertSame("should load the object", VALUE, cache.lookUp(KEY));
        assertSame("should get the cached object", VALUE, cache.lookUp(KEY));
    }
...

Code Notes:

  • mockLoader는 한번 호출될 것으로 간주한다. 즉, load(KEY)를 호출하여 VALUE를 리턴한다.
  • assertSame(VALUE, cache.lookUp(KEY)이 호출된다. - 첫번째
  • assertSame(VALUE, cache.lookUp(KEY)이 호출된다. - 두번째 호출

테스트 결과
« TimedCacheTest.testLoadsObjectThatIsNotCached pass »
« TimedCacheTest.testDoesNotLoadObjectThatIsCached fails »

현재의 캐쉬 컴포넌트 구현 버전은 lookUp()를 호출할 때마다 loader를 호출한다. 이것은 새로운 테스트 메쏘드 testDoesNotLoadObjectThatIsCached()에서 에러를 일으킨다. 기본적으로 mockLoader.load() 메쏘드에 대한 jMock expectation은 두번 호출되면 실패한다.

2.2 functional code

TimedCache class:
캐쉬 기능을 추가하여 TimedCach를 기능적으로 확장해보자. 앞서 로드된 object는 다시 로드되지 않을 것이다.

public class TimedCache {
    private ObjectLoader loader;
    private Map<Object, Object> cachedValues = new Hashtable<Object, Object>();
 
    public TimedCache(ObjectLoader loader) {
        this.loader = loader;
    }
 
    public Object lookUp(Object key) {
        Object value = cachedValues.get(key);
        if (value == null) {
            value = loader.load(key);
            cachedValues.put(key, value);
        }
        return value;
    }
}

Code Notes:
Object 값에 Object 키를 맵핑하기 위해 cachedValues(private Hashtable 필드)를 도입한다.
lookUp() 메쏘드를 업데이트해서 Object들이 처음 호출되면 캐쉬되고 cachedValues에 없는 경우에만 로드되도록 한다.

테스트 결과
« TimedCacheTest.testLoadsObjectThatIsNotCached pass »
« TimedCacheTest.testDoesNotLoadObjectThatIsCached pass »

CI Tip:
테스트가 성공했으므로 커밋한다.

Interaction 3

Requirement
캐쉬 컴포넌트는 lookUp()이 처음 호출될 때 하나의 object를 로드해야 한다.
캐쉬 컴포넌트는 앞서 로드되어 너무 오랜동안 캐쉬에 있는 object는 다시 로드하지 말아야 한다.
캐쉬 컴포넌트는 앞서 로드되어 너무 오랜동안 캐쉬에 존재하지 않은 object는 다시 로드해야 한다.

3.1. 테스트 코드

새로운 요구사항을 위한 새 메쏘드를 테스트 코드에 추가한다.
새 요구사항은 시간의 관점을 추가한다. 시간이 경과함에 따라 object를 다시 로드할지가 결정된다. 시간에 대한 포인트는 Timestamp object로 표현할 수 있다. ReloadPolicy 컴포넌트에 Object들을 다시 로드할지 결정하는 책임을 부여한다.

TimedCachedTest class

...
    public void testCachedObjectsAreNotReloadedBeforeTimeout() {
        mockLoader.expects(once()).method("load").with(eq(KEY)).will(
                returnValue(VALUE));
        mockClock.expects(atLeastOnce()).method("getCurrentTime")
                .withNoArguments().will(returnValue(CURRENT_TIME));
        mockReloadPolicy.expects(atLeastOnce()).method("shouldReload").with(
                eq(LOAD_TIME), eq(CURRENT_TIME)).will(returnValue(false));
        assertSame("loaded object", VALUE, cache.lookUp(KEY));
        assertSame("loaded object", VALUE, cache.lookUp(KEY));
    }
...

Code Notes:

  • mockLoader 는 한번 호출될 것으로 예상해서 load(KEY)호출에 대해 VALUE를 리턴한다. - lookUp()의 두번째 호출은 reloadPolicy가 object를 다시 로드하지 말라고 할 것이므로 loader를 호출하지 않을 것이다.
  • mockClock은 최소한 한번 호출하는 것으로 예상해서, getCurrentTime()호출에 대해 CURRENT_TIME을 리턴한다. - clock은 reloadPolicy에게 묻기 전에 현재 시간을 얻기 위해 호출되어야 한다.
  • mockReloadPolicy는 최소 한번 호출할 것으로 예상되어, shouldReload(LOAD_TIME, CURRENT_TIME) 호출에 대해 false를 리턴한다.
  • assertSame(VALUE, cache.lookUp(KEY) 호출 - 첫번째: VALUE Object가 로드되어야 한다.
  • assertSame(VALUE, cache.lookUp(KEY) 호출 - 두번째: VALUE Object가 로드되지 말아야 한다. returnPolicy.shouldReload()가 false를 리턴하기 때문이다.
...
    private static final Timestamp CURRENT_TIME = new Timestamp(System
            .currentTimeMillis());
    private static final Timestamp LOAD_TIME = CURRENT_TIME;
...

Code Notes:

  • CURRENT_TIME과 LOAD_TIME은 테스트에 사용될 상수이다.
  • 둘 다 같은 초기값을 갖는다. 캐쉬 테스트 결과가 그들 값에 의존하지 않기 때문이다.
  • expectation은 CURRENT_TIME과 LOAD_TIME을 사용하여 위임에 의해(deliberately) mock object에 설정된다는 사실에 유의하라.
...
    private Mock mockClock;
    private Mock mockReloadPolicy;
 
    @Override
    protected void setUp() throws Exception {
        mockLoader = mock(ObjectLoader.class);
        mockClock = mock(Clock.class);
        mockReloadPolicy = mock(ReloadPolicy.class);
 
        cache = new TimedCache((ObjectLoader) mockLoader.proxy(),
                (Clock) mockClock.proxy(), (ReloadPolicy) mockReloadPolicy
                        .proxy());
        super.setUp();
    }
...

Code Notes:

  • 캐쉬는 reload policy검증(verification)을 ReloadPolicy컴포넌트에 위임: mockReloadPolicy는 캐쉬 생성자 파라미터로서 삽입(inject)된다.
  • 캐쉬는 time retrieval 기능을 Clock 컴포넌트에 위임: mockClock은 캐쉬 생성자 파라미터로서 삽입(inject)된다.
  • Timestamp class를 위해 java.sql.Timestamp를 임포트해야 한다.

Test Results
« TimedCacheTest.testLoadsObjectThatIsNotCached fails»
« TimedCacheTest.testDoesNotLoadObjectThatIsCached fails »
« TimedCacheTest.testCachedObjectsAreNotReloadedBeforeTimeout fails »

테스트 코드는 컴파일되지 않는다.

3.2 functional code

새로운 코드를 수행하기 위한 코딩 작업

Clock Interface

import java.sql.Timestamp;
 
public interface Clock {
    Timestamp getCurrentTime();
}

Code Notes:

  • getCurrentTime() 메쏘드를 갖는 Clock 인터페이스
  • clock컴포넌트의 쓰임새는 타임 관련 테스트를 수행하는 것: Clock 컴포넌트이 없다면, 캐쉬 유닛 테스트는 더 복잡해지고 시간을 잡아먹을 것이다.

ReloadPolicy 인터페이스

import java.sql.Timestamp;
 
public interface ReloadPolicy {
    boolean shouldReload(Timestamp loadTime, Timestamp currentTime);
}

Code Notes:

  • shouldReload() 메쏘드를 갖는 ReloadPolicy인터페이스
  • ReloadPolicy 인터페이스를 구현하는 컴포넌트는 로드 타임과 현재 타임을 근거로 Object를 다시 로드할지 결정해주는 shouldReload() 메쏘드를 제공해야 한다.

TimedCache class

...
    private Clock clock;
    private ReloadPolicy policy;
 
    public TimedCache(ObjectLoader loader, Clock clock, ReloadPolicy policy) {
        this.loader = loader;
        this.clock = clock;
        this.policy = policy;
    }

Code Notes:

  • Clock과 ReloadPolicy가 생성자에 의해 TimeCache private 필드로서 추가
  • 생성자는 Clock과 ReloadPolicy 생성자를 추가할 수 있게 수정
...
    private Map<Object, TimestampedValue> cachedValues = new Hashtable<Object, TimestampedValue>();
 
    private class TimestampedValue {
        public Object value;
        public Timestamp loadTime;
    }

Code Notes:

  • TimestampedValue(TimedCache의 private inner class)를 로드 타임 타임스탬프 뿐만 아니라 value object를 갖기 위해 만든다.
  • Hashtable의 value 타입(key/value)은 Object를 TimestampedValue로 바꾼다.
...
    public Object lookUp(Object key) {
        TimestampedValue timestampedValue = (TimestampedValue) cachedValues
                .get(key);
        Timestamp currentTime = clock.getCurrentTime();
 
        if (timestampedValue == null
                || (policy.shouldReload(timestampedValue.loadTime, currentTime))) {
            Object value = loader.load(key);
            timestampedValue = new TimestampedValue();
            timestampedValue.loadTime = currentTime;
            timestampedValue.value = value;
            cachedValues.put(key, timestampedValue);
        }
        return timestampedValue.value;
    }

Code Notes:

  • 현재 시간과 object reload 가능성을 결정하기 위해 clock과 reloadPolicy를 호출하도록 lookUp() 메쏘드를 수정한다.
  • value object를 캐싱할 때, cachedValues에 insert되기 전에 timedstampedValue에 로드 시간을 마킹한다.

테스트 결과
« TimedCacheTest.testLoadsObjectThatIsNotCached fails»
« TimedCacheTest.testDoesNotLoadObjectThatIsCached fails »
« TimedCacheTest. testCachedObjectsAreNotReloadedBeforeTimeout pass »

Why did this happen?
TimedCache.lookUp() 메쏘드 구현이 변경되었다. 지금은 Clock과 ReloadPolicy가 호출되고 있다.

jMock은 무언가 어떤 메쏘드 호출에 대해 불평을 하고 있다. 왜냐하면 당신이 expectation에 앞서 설명을 안 했기 때문이다. 기본적으로 이전 테스트들은 clock.getCurrentTime()과 reloadPolicy.shouldReload()를 호출할 준비가 안 되어 있다.

TimedCacheTest class
testLoadsObjectThatIsNotCached와 testDoseNotLoadObjectThatIsCached 유닛 테스트 메쏘드를 위한 변경 - expectation을 올바르게 설정하기 위함

...
    public void testLoadsObjectThatIsNotCached() {
        mockLoader.expects(once()).method("load").with(eq(KEY)).will(
                returnValue(VALUE));
        mockClock.expects(once()).method("getCurrentTime").withNoArguments()
                .will(returnValue(CURRENT_TIME));
        mockReloadPolicy.expects(never()).method("shouldReload");
        assertSame("should be first object", VALUE, cache.lookUp(KEY));
    }
 
    public void testDoesNotLoadObjectThatIsCached() {
        mockLoader.expects(once()).method("load").with(eq(KEY)).will(
                returnValue(VALUE));
        mockClock.expects(exactly(2)).method("getCurrentTime")
                .withNoArguments().will(returnValue(CURRENT_TIME));
        mockReloadPolicy.expects(atLeastOnce()).method("shouldReload").with(
                eq(LOAD_TIME), eq(CURRENT_TIME)).will(returnValue(false));
        assertSame("should load the object", VALUE, cache.lookUp(KEY));
        assertSame("should get the cached object", VALUE, cache.lookUp(KEY));
    }

Code Notes:

  • mockClock expectation은 상황에 따라 설정되었다.
  • mockReloadPolicy expectation은 상황에 따라 설정되었다.

Test Results
« TimedCacheTest.testLoadsObjectThatIsNotCached pass »
« TimedCacheTest.testDoesNotLoadObjectThatIsCached pass »
« TimedCacheTest.testCachedObjectsAreNotReloadedBeforeTimeout pass »

3.3 Refactor

일단 모든 테스트 시나리오들이 성공했다.

마지막 테스트 - testCachedObjectsAreNotReloadedBeforeTimeout - 에 대해, object들이 reload policy에 따라 타임아웃되지 않으면 다시 로드되지 않는지 확인해본다. 캐쉬와 그 reload policy가 잘 동작하는지 확인하기 위해, 타임아웃된 object들이 다시 로드될 케이스들을 테스트할 필요가 있다.

...
    private static final Object NEW_VALUE = new Object();
...
    public void testReloadsCachedObjectAfterTimeout() {
        mockClock.expects(atLeastOnce()).method("getCurrentTime")
                .withNoArguments().will(returnValue(CURRENT_TIME));
        mockLoader.expects(exactly(2)).method("load").with(eq(KEY)).will(
                onConsecutiveCalls(returnValue(VALUE), returnValue(NEW_VALUE)));
        mockReloadPolicy.expects(atLeastOnce()).method("shouldReload").with(
                eq(LOAD_TIME), eq(CURRENT_TIME)).will(returnValue(true));
        assertSame("should be loaded object", VALUE, cache.lookUp(KEY));
        assertSame("should be reloaded object", NEW_VALUE, cache.lookUp(KEY));
    }
...

Code Notes:

  • NEW_VALUE를 추가 - 새 유닛 테스트 메쏘드에 필요한 상수
  • mockClock은 최소한 한번 호출될 것으로 기대되며(expected), getCurrentTime() 호출에 대해 CURRENT_TIME을 리턴한다.
  • mockLoader는 두번 호출될 것으로 기대되며(expected), consecutive load(KEY) 호출에 대해 VALUE와 NEW_VALUE를 리턴한다.
  • mockReloadPolicy is expected to be invoked at least once, returning true for invocation.
  • mockReloadPolicy는 최소 한번 호출될 것으로 기대되며, shouldReload (LOAD_TIME, CURRENT_TIME) 호출에 대해 true를 리턴한다.
  • assertSame(VALUE, cache.lookUp(KEY) 호출 - 첫번째, VALUE Object가 로드되어야 한다.
  • assertSame(NEW_VALUE, cache.lookUp(KEY) 호출 - 두번째, NEW_VALUE Object가 lookUp(KEY)의 두번째 호출에 대해 다시 로드되는 value이다. returnPolicy.shouldReload()가 false를 리턴하기 때문에 reload가 필요하다.

테스트 결과
« TimedCacheTest.testLoadsObjectThatIsNotCached pass»
« TimedCacheTest.testDoesNotLoadObjectThatIsCached pass »
« TimedCacheTest.testCachedObjectsAreNotReloadedBeforeTimeout pass »
« TimedCacheTest.testReloadsCachedObjectAfterTimeout pass »

참고:
TheServerSide Using JMock with Test Driven Development by Paulo Caroli

Page tags: jmock tdd
Unless otherwise stated, the content of this page is licensed under Creative Commons Attribution-ShareAlike 3.0 License