본문 바로가기

카테고리 없음

Stub 사용기 (런던파 vs. 고전파)

    테스트 코드를 작성하며 Fixture들을 만들 때 실제 값을 사용 못하기에 Test Double을 사용해야 하는 때도 있습니다. JUnit + Mockito 조합으로 하는 테스트에 익숙하다 보니 처음에는 모킹을 위주로 유닛 테스트를 작성하였습니다. 그렇지만 과연 모킹이 실제 동작을 보장할 수 있을까 항상 고민이 있었습니다. 그래서 간혹 유닛 테스트와 함께 통합 테스트를 작성하거나 통합 테스트만 작성하려고 하던 시기도 있었습니다. 모킹을 하거나 스텁 클래스를 사용하는 등 여러가지 방법으로 스터빙 해보고 또 고민해 본 현재 사용하는 방식까지의 여정을 관련 내용과 함께 정리해 보려고 합니다. 

 

A. Test Double 이란 

    우선 테스트 더블(Test Double)의 종류를 알아보겠습니다. Test Double은 실제 객체 대신 테스트 목적으로 사용되는 모든 종류의 가상객체를 말합니다. 이 Mock도 Stub도 테스트 더블의 일종이고 테스트 더블은 5가지 종류가 있습니다.

 

1. Dummy

  • 객체는 전달 되지만, 사용되지 않고 일반적으로 매개 변수 목록을 채우는 목적으로 사용 됩니다. 
  • 인스턴스화된 객체는 필요하지만 기능은 필요하지 않는 경우에 사용됩니다.
  • 정상동작을 보장하지 않습니다. 
String id = sut.signUp(mock(Member.class));

 

2. Fake

  • 실제로 작동되는 구현을 가지고 있지만 프로덕션에는 적합하지 않게 몇가지 동작들을 단순화해서 제공됩니다.
  • 대표적인 예시로는 테스트에 쓰이는 인메모리 데이터베이스가 있습니다.

3. Stub

  • 테스트 중에 미리 만들어진 호출에 준비된 답변을 제공하며 테스트를 위해 프로그래밍되며 이외에는 응답하지 않습니다.
  • when/then을 이용하는 경우도 스터빙(Stubbing)이라고 불려 헷갈릴수 있으나, Stubbing은 메소드나 함수의 동작을 임시로 대체하는 기술이나 과정을 의미하는 '동작'입니다.
  • Stub은 대체 실제로 동작하는것처럼 보이게 만드는 '객체' 입니다.
  • 보통 인터페이스나 클래스로 최소한으로 구현합니다.
  • 검증:  상태 검증
Order order = new Order();
order.add(someProduct);

assertEquals(order.getAmount, 2)

 

4. Spy

  • Stub 처럼 실제 동작 하듯이 구현하지만 Mock 처럼 호출내역을 또한 알 수 있도록 구성합니다.
  • Stub 처럼 모두 동작하듯이 구현할 수 있지만 Mock 처럼 부분적으로 스터빙하여 사용할 수도 있습니다.
  • Mockito 라이브러리 사용시 Mock 객체 처럼 verify를 이용하여 호출 내역을 알 수 있습니다. 
  • 검증: 상태검증 및 행동검증 모두 가능. 

5. Mock

  • 호출에 대한 기대를 명세하고, 내용에 따라 동작하도록 프로그래밍 된 객체 입니다. (objects pre-programmed with expectations which form a specification of the calls they are expected to receive, Martin Fowler
  • 이를 달성하기위해 보통 when/then을 이용해 부분적으로 스터빙을 하여 호출에 대한 기대를 의도 한대로 프로그래밍 하여 동작하도록 합니다. 
  • 즉, 기대하는 동작(sut의 동작)에 대한 명세를 알아야 하고 그에 따라 테스트마다 추가적인 프로그래밍을 해야합니다. (부분 스터빙)
    • sut = System Under Test (테스트의 대상이 되는 시스템/ 테스트의 대상)
  • 검증: 행위 검증
Behaviour behaviour = new Behaviour(movingObject);
behvaiour.move();

verify(movingObject).moveForward();

 

B. 격리의 방식: 런던파 vs. 고전파

1. 단위 테스트란? 

  • 작은 코드 조각(단위)를 검증합니다.
  • 빠르게 수행합니다.
  • 격리된 방식으로 처리하는 자동화된 테스트입니다.. (이 부분에서 런던파와 고전파가 갈립니다.)

2. 런던파 (London School, Mockist) 

 

  • 코드 조각(단위, Unit)을 격리하기 위해 런던파에서는 sut를 협력자(collaborator)에게서 격리 시킵니다.
  • 협력자란, sut가 의존하고 있는 외부 클래스 입니다.
  • 런던파에서는 값객체 같은 불변 의존성을 제외하고는 모두 테스트 더블로 대체하여 사용합니다. 
  • 대표적인 지지자로 스티브 프리먼(Steve Freeman)과 냇 프라이스(Nat Pryce)가 있으며 대표 저서는 "Growing Object-Oriented Software, Guided by Tests"입니다. (박종훈님)
  • 장점 1: 이 경우 테스트 실패시 sut의 문제라는 것이 확실하기에 문제를 쉽게 발견할 수 있습니다. 
  • 장점 2: 클래스 그래프(연결된 의존성 많아져도)가 커져도 테스트 하기가 쉽다. 그러나 이 경우 잘못된 설계로 클래스 그래프가 불필요하게 커진것일 수 있다. 
  • 단점 1: SUT과 강하게 결합되어 SUT의 명세에 따라 의존성들을 동작을 스터빙을 해줘야 하므로 테스트가 깨지기 쉽다(fragile).

3. 고전파 (Classical School, Detroit)

  • 공유의존성(shared dependency), 또는 프로세스 외부 의존성(out-of-process dependency)는 모킹하고 비공개 의존성(private dependency)는 모킹하지 않습니다. 
  • 공유 의존성은 테스트간에 공유되고 서로에게 영향을 끼칠 수 있는 의존성으로 정적 가변 필드나 데이터 베이스가 있습니다.
  • 프로세스 외부 의존성은 어플리케이션 프로세스 바깥에 있는 의존성으로 도커에서 데이터 베이스를 띄울 경우 프로세스 외부 의존성이 될 수 있습니다. 
  • 비공개 의존성은 대표적으로 다른 클래스들인데 이 경우는 그대로 둘 수 있습니다. 
  • 대표적인 저서로는 켄트 백(Kent Beck)의 "테스트 주도 개발"이 있습니다. (박종훈님)

C. 나의 Stub 여정

1. Mocking

    @Test
    @DisplayName("가게 등록 - 정상적인 가게 등록")
    void registerPlace() {
    	//given
        Place place = mock(Place.class);
        given(place.getPlaceName()).willReturn("비비큐 치킨 한솔점");
        given(placeJPARepository.findByPlaceName(anyString())).willReturn(Optional.empty());

        //when
        sut.registerPlace(place);

        //then
        verify(placeJPARepository, times(1)).save(place);
    }
// Mock Builder
public class SignUpFormMock {
    private SignUpForm signUpForm;
    private SignUpFormMock() {
        this.signUpForm = mock(SignUpForm.class);
    }
    public static SignUpFormMock mockBuilder(){
        return new SignUpFormMock();
    }
    public SignUpForm build(){
        return signUpForm;
    }
    //Fields
    public SignUpFormMock email(String email) {
        when(signUpForm.getEmail()).thenReturn(email);
        return this;
    }
    public SignUpFormMock password(String password) {
        when(signUpForm.getPassword()).thenReturn(password);
        return this;
    }
  •  처음은 모킹을 통해 테스트를 많이 작성했습니다.  
  • 심지어 위의 코드 처럼 목 빌더를 만들어 사용할까 생각도 하였습니다.
  • 이후 테스트를 공부하며 '구글 엔지니어는 이렇게 일한다' 라는 책을 읽고 모킹으로 작성된 테스트가 실제와 다르게 작동하여 문제가 되었다는 경우를 듣고 경각심을 가져야 겠다고 생각했지만, 어떤 경우인지 쉽게 와닿지 않았습니다.
  • 그러다가 모킹이 실제와 다르게 동작한 경우가 생겼습니다. get으로 Optional을 받는 경우도 있는데, 테스트에서는 Optional한 반환을 하지 않았고 기능은 정상적으로 동작하지만 테스트는 실패하였습니다. (거짓 양성)   
  • 리팩터링 후에 기능은 정상인데 테스트가 실패하면 거짓 양성(False Positive)라고 합니다.
    • 거짓 양성은 테스트의 신뢰도를 깨지에 하며 특히 테스트가 sut 세부 명세와 강하게 결합된 경우 자주 발생합니다.
    • 거짓 양성은 테스트의 리팩토링에 대한 내성을 떨어뜨려 깨지기 쉬운 테스트가 됩니다. 
  • 리팩터링 후 기능이 비정상인데 테스트가 성공하면 거짓 음성(False Negative)라고 합니다. 
    • 거짓 음성은 테스트의 존재 이유인 '회귀 방지'를 수행하지 못하므로 프로덕션에 문제가 될 수 있습니다.
    • 테스트의 구현이 잘 못된 것을 테스트 하고 있거나 테스트가 실제 구현과 다른 것을 테스트하고 있을 수 있습니다. 
  • 런던파의 방식은 거짓 양성과 거짓 음성에 모두 취약합니다. 모킹으로 만들어진 테스트는 실제와 다르며 코드 변경에 취약합니다. 

2. Integrated Test 

@SpringBootTest
class MemberWriteApplicationTest {

    @Autowired
    private MemberWriteApplication sut;
  • 이후에는 맘편히 통합 테스트를 진행하자고 생각하였습니다.
  • 그러나 통합테스트로 모든 테스트를 구현 할 경우 빌드때 시간이 오래 걸립니다.
  • 또한, 데이터 베이스 같이 프로세스 밖의 의존성을 띄워 놓지 않으면 테스트가 어렵습니다.
  • 이런 이유로 통합테스트만 사용할 경우 테스트 때마다 외부 의존성들을 준비해 주어야 하여 테스트가 어렵습니다.

3. Stub Class + Fixture

public class MemberJPARepositoryStub implements MemberRepository {

    Set<Member> members = new HashSet<>();

    @Override
    public Member findByEmail(String email) {
        Optional<Member> memberFound = members.stream().filter(member -> member.getEmail().equals(email)).findFirst();
        if (memberFound.isPresent()) {
            return memberFound.get();
        } else {
            throw new MemberException(NOT_EXITING_USER);
        }
    }
	//stub 검증 테스트
	@Test
        @DisplayName("이메일로 조회 - 성공.")
        void findByEmail() {
            //given
            Member member = MemberFixture.create();
            db.save(member);
            stub.save(member);

            //when
            Member dbResult = db.findByEmail(member.getEmail());
            Member stubResult = stub.findByEmail(member.getEmail());

            //then
            assertThat(UUIDTester.isUUID(dbResult.getId())).isTrue();
            assertThat(UUIDTester.isUUID(stubResult.getId())).isTrue();
            assertThat(dbResult.getEmail()).isEqualTo(stubResult.getEmail());
            assertThat(dbResult.getName()).isEqualTo(stubResult.getName());
            assertThat(dbResult.getPassword()).isEqualTo(stubResult.getPassword());
            assertThat(dbResult.getPhone()).isEqualTo(stubResult.getPhone());
            assertThat(dbResult.getAddress()).isEqualTo(stubResult.getAddress());
            assertThat(dbResult.isLocationServiceEnabled()).isEqualTo(stubResult.isLocationServiceEnabled());
            assertThat(dbResult.getPoint()).isEqualTo(stubResult.getPoint());
        }
  • 이때 부터 고전파의 방법을 도입하기 위해 노력을 하였습니다.
  • Mock 객체 대신 Fixture를 사용하게 되며 도메인 계층의 테스트들은 외부 의존성 자체가 없어 완전히 실제 객체만 사용하여 테스트를 진행 할 수 있었습니다.
  • 실제 객체를 사용하지 못하는 외부 의존성은 Stub을 이용해서 테스트 더블을 만들어 테스트 하였습니다. 
  • 또한, Stub의 구현이 실제와 틀릴 수 있다고 생각하여 이를 검증하는 통합 테스트도 구현하였습니다. (위 두번째 예시)

 

4. 혼합. 

  • Stub으로 동작하는 테스트는 믿음직하였으나, 문제는 구현하고 관리하는데 시간이 많이 들었습니다.
  • 특히 네이티브 쿼리를 사용하는 복잡한 SQL로직의 경우 Stub으로 구현하는데도 시간이 꽤나 들었습니다. 그러다 보니 프로덕션 환경에서는 단순한 CRUD만 있는 상황이 아니라 과연 적용이 가능할지 의문이 들었습니다.  
  • 그래서 현재는 Fixture를 이용해 도메인간 비즈니스 로직은 실제 객체로 테스트 하고, 레어어드 아키텍처의 각 계층은 모킹하여 테스트하고 있습니다.
  • DDD의 관점에서나 OOP 관점에서 설계가 잘 되어 있다면, 사실상 중요한 비즈니스 로직은 도메인 계층에서 실제 객체로 테스트 해 볼수 있으니 안심이 될 것이라고 생각했습니다.
  • 또한, 레어어드 아키텍처에서 계층간 협력은 인터페이스를 통해 정해진 값을 주도록 되어 있기에 이미 특정한 값을 받기로 기대를 하고 있어서 모킹을 해서 사용해서 생기는 문제가 크지 않을 것으로 생각되었습니다.
  • 이에 Stub 객체를 만들고 관리하는 trade-off를 생각했을때, 프로덕션 코드의 변화가 적어지고 시간이 허용하는 경우에 따라 조금씩 Stub을 도입해도 좋을 것 같다고 생각했습니다. 
    @Test
    @DisplayName("회원가입 성공.")
    void signUp() {
        //given
        String uuid = "uuid";
        Member newMember = MemberFixture.create();
        when(memberService.signUp(newMember)).thenReturn(uuid);

        //when
        String id = sut.signUp(newMember);

        //then
        assertNotNull(id);
    }

 

D. 결론. 

    사실상 테스트를 어떻게 할지는 항상 trade-off를 생각하고 진행해야 할 것 같습니다. 개인적으로는 Stub클래스를 이용해서 완전히 고전파의 방식으로 테스트를 하는게 장기적인 프로덕션 관리에 도움이 되니 좋다는 생각도 있습니다. 그렇지만, 업무를 진행해본 경험을 돌이켜 보면 회사를 위해서는 들어가는 비용이나 타이밍 생각할때 빠른 프로덕션의 출시가 중요한 경우도 왕왕 있고 그렇기에 상황에 따라 혼합해서 진행하는 것이 좋다고 생각합니다. 

 

 

출처

Mocks Aren't Stubs by Martin Fowler

Azderica님의 [Test]Mock 테스트와 Stub 테스트 차이

Tecoble님의 Test Double을 알아보자

Jeremy님의 효율적인 테스트를 위한 Stub 객체 활용법

박종훈님의 단위테스트의 두 분파(고전파와 런던파)

yeonk님의 Spring | @TestConfiguration과 @Import

YoungHo-Cha [테스트 마스터하기#4] 좋은 단위 테스트

Abel님의 [Java, Spring] 단위 테스트의 재정립

알고싶은 승민님의 단위테스트: 생산성과 품질을 위한 단위 테스트 원칙과 패턴 - 4장 - 좋은 단위 테스트의 4대 요소

javajoha님의 단위테스트? 통합테스트? 런던파 vs 고전파

JiwonMoon님의 유닛 테스트(Unit Test), 통합 테스트(Integration Test), 기능 테스트(Funcional Test)란? (feat.JUnit5, AssertJ)

 

 

책: 구글 엔지니어는 이렇게 일한다. 

책: 단위 테스트