Spring/테스트 코드

[Test] Mock, Spy

hanrabong 2023. 12. 4. 15:50

배경

 테스트 코드를 작성할 때 Mock, Spy, Stubbing이라는 단어는 빼 놓을 수 없습니다. 테스트 코드를 처음 접했을 때 해당 용어들이 많이 헷갈렸습니다. 테스트 코드를 계속 작성을 해나가면서 어떤 상황에서는 Spy를 사용하고 어떤 상황에서는 Mock을 사용하고 이런 것들을 stubbing이라고 말하는 구나를 깨우칠 수 있었습니다. 

 

이번에는 Test의 기본이 되는 Mock, Spy에 대해서 얘기해보려고 합니다.

 

 

해당 글에서 사용한 코드는 밑의 github에 있습니다.

테스트 소스 코드

 

GitHub - Rabongg/Test-Junit: Junit을 이용한 test 예제

Junit을 이용한 test 예제. Contribute to Rabongg/Test-Junit development by creating an account on GitHub.

github.com

 

 

 

Mock

사전에 정의된 Mock의 뜻을 보면 다음과 같습니다.

동사
(특히 흉내를 내며) 놀리다[조롱하다]

형용사
거짓된, 가짜의, 모의의

 

테스트 코드에서 말하는 Mock도 비슷합니다. 

 

Test 작성 시 Mock을 이용하여 모의 객체를 만듭니다. 모의 객체이기 때문에 우리가 원하는 대로 동작을 할 수 있게 만들 수 있습니다. 이렇게 모의 객체, 즉 Mock 객체를 원하는 대로 동작을 할 수 있게 조작하는 것을 Stubbing 이라고 합니다.

 

 

말로만 하면 잘 이해가 안 가기에 코드를 통해 보겠습니다.

// Person.java
public class Person {

    private int age;

    private String name;

    public int getAge() {
        return age;
    }

    public String getName() {
        return name;
    }

    public Person() {
        this.age = 11;
        this.name = "rabong";
    }
}

// Test.java
public class SimpleMockTest {

    @Test
    public void mockTest() {
        Person mockPerson = mock(Person.class);
        Person person = new Person();

        System.out.println(mockPerson.getAge());
        System.out.println(mockPerson.getName());
        System.out.println(person.getAge());
        System.out.println(person.getName());
    }
}

 

위의 예시 코드를 실행시키면 어떤 값이 나오게 될까요?

 

0
null
11
rabong

 

다음과 같이 나오게됩니다. 왜 이렇게 값이 나오는지 살펴보겠습니다.

 

mockPerson이라는 객체는 mock 객체이고 person이라는 객체는 실제 생성자를 이용해서 생성한 객체입니다. 따라서 mock 객체인 mockPerson의 메서드를 실행시키면 기본 값인 int면 0, String이면 null이 나오는 것입니다.

 

그럼 여기서 mockPerson의 나이를 불러올 때 20이라는 값을 나오게 하려면 어떻게 해야할까요? 이 때 바로 stubbing을 하면됩니다.

@Test
public void mockTest() {
	Person mockPerson = mock(Person.class);
	Person person = new Person();
	when(mockPerson.getAge()).thenReturn(20);  // mockPerson mock 객체 stubbing

	System.out.println(mockPerson.getAge());  
	System.out.println(mockPerson.getName());
	System.out.println(person.getAge());
	System.out.println(person.getName());
}

20
null
11
rabong

 

이렇게 stubbing을 하면 원하는 값을 나오게 할 수 있습니다.

 

※ 참고 ※
mock을 하기 위해 mockito-junit-jupiter, mockito-core 의 2개 라이브러리가 필요합니다. 보통 gradle로 프로젝트를 설정할 때 spring-boot-starter-test를 의존성에 포함시켜주는데 해당 라이브러리에 기본적으로 포함이 되어 있습니다.

 

 

지금까지 Mock 객체가 무엇인지 Stubbing이 무엇인지에 대해 알아보았습니다. 솔직하게 예시 코드를 보면 어떻게 동작을 하는지는 알겠지만, 언제 써야하고 왜 필요한지에 대해서는 이해가 가지 않습니다.

 

 

제가 테스트 코드를 짜면서 언제 Mock 객체를 쓰면 좋은지에 대해 설명하려고 합니다.

 

 

 

Spring boot에서 Unit Test를 작성할 때

 Spring boot에서 service 로직에 대하여 단위 테스트(Unit Test)를 짤 때 Mock 객체를 가장 많이 사용합니다.

 

다음과 같은 코드가 있다고 생각을 해봅시다.

// UserService.java
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserDao userDao;

    public String[] getAllUser() {
        String[] users = userDao.findAllUser();
        for (int i = 0; i < users.length; i++) {
            users[i] = users[i].toUpperCase();
        }
        return users;
    }
}

// UserDao.java
@Service
public class UserDao {

    public String[] findAllUser() {

        return new String[]{"rabong", "orange"};
    }
}

 

위 코드를 간략하게 설명하면 UserService에서 UserDao를 주입받고 있고 UserDao에서 받아온 User들의 이름을 대문자로 바꿔서 반환하는 로직입니다. 가상의 시나리오여서 User 값을 직접 설정을 해주었지만, 보통 프로젝트에서는 데이터 베이스에서 해당 값을 추출하여 반환합니다.

 

 

위의 코드에 대한 테스트 코드를 어떻게 짜야하는지 살펴보겠습니다.

@ExtendWith(MockitoExtension.class)
public class UserServiceTest {

    @Mock
    private UserDao userDao;

    @InjectMocks
    private UserService subject;  // 테스트 할 객체

    @Test
    void getAllUserTest() {
        String[] users = {"rabong", "apple"};

        when(userDao.findAllUser()).thenReturn(users);  // userDao stubbing

        String[] result = subject.getAllUser();  // 테스트 할 메서드

        Assertions.assertEquals(2, result.length);
        Assertions.assertEquals("RABONG", result[0]);
        Assertions.assertEquals("APPLE", result[1]);
    }
}

 

다음과 같이 코드를 작성할 수 있습니다. 갑자기 못보던 어노테이션이 많아져서 헷갈릴 수도 있습니다. 찾아보면 자세히 설명해주는 글들이 많아서 간단히 말하고 넘어가겠습니다.

 

※ 참고 ※
@Mock 은 Mock 객체를 만들어주는 어노테이션입니다.
@InjectMocks 의 경우 해당 Mock 객체를 주입하여 해당 객체를 만들어 주는 어노테이션입니다.
@Extendwith(MockitoExtension.class)의 경우 앞서 설명한 어노테이션을 해당 테스트 클래스에서 사용하기위해 필요한 어노테이션입니다.

 

 

getAllUser 메서드를 Test하기 위해서는 해당 객체가 의존하는 UserDaofindAllUser 메서드를 호출을 해야합니다. 그렇지 않으면 로직 상으로 값을 제대로 받아와서 처리하는지 확인하기 어렵습니다.

 

위 코드에서는 단순하게 String 배열을 반환하지만, 실제 로직에서는 데이터베이스에 저장되어있는 값들을 읽어오곤 합니다. 만약, UserDao를 Mocking하지 않고 실제 객체를 주입하고 호출을 한다면 데이터베이스에서 값을 정확하게 읽어와야만 해당 코드가 정상적으로 동작함을 보장할 수 있습니다. 이럴 경우 단위테스트의 목적에 부합하지 않게 됩니다. 단위테스트는 말 그대로 해당 테스트 자체가 잘 동작하는지 여부를 확인하는 것입니다. 흔히 단위 테스트 원칙이라고 말하는 FIRST 의 F(Fast), I(Independent)에 어긋나게 됩니다.

 

 

※ 참고 ※
First란
F(fast): 단위 테스트는 빠르게 실행되고 빠르게 결과값을 알아야한다.
I(Independent): 단위 테스트는 다른 테스트에 의존하지 않고 그 자체만으로 실행되어야 한다.
R(Repeatable): 단위 테스트는 몇 번을 진행하든 똑같은 결과가 나올 수 있게 반복가능해야한다.
S(Self-validating): 단위 테스트는 테스트 자체로 통과/실패 결과로 자체 검증이 가능해야한다.
T(Timly): 단위 테스트는  철저하고 적절하게 작성이 되어야한다.

 

 

 

Interface를 테스트 해야할 때

Interface를 인자로 받거나 인자로 넘길 때 mock을 주로 사용합니다. 말로 설명을 하면 이해가 잘 가기 때문에 바로 코드로 설명을 하겠습니다.

 

다음과 같은 코드가 있다고 가정을 해보겠습니다. (테스트를 위한 코드이지 보통 이렇게 코드를 짜진 않습니다.)

// UserService.java
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserDao userDao;

    public String[] getUsers() {
        Page users = userDao.findUsers();

        String[] userList = users.getContent();

        for (int i = 0; i < userList.length; i++) {
            userList[i] = userList[i].toUpperCase();
        }
        return userList;
    }
}

// Page.java(interface)
public interface Page {

    int getTotalCount();

    String[] getContent();
}

 

코드에 대해 간략히 설명하면 userDao에서 값을 받아오는데 Page라는 interface로 값을 받아와서 대문자로 변경하여 반환하는 코드입니다. 당연히 userDao.findUsers() 메서드에서는 Page라는 interface를 구현한 객체를 반환할 것입니다.

 

하지만 userService에서 구현된 객체를 알 수 없다고 했을 때 어떻게 테스트 코드를 짜야할까요?

 

 

Mock 없이는 다음과 같이 테스트 코드를 짤 수 있습니다.

@ExtendWith(MockitoExtension.class)
public class UserServiceTest {

    @Mock
    private UserDao userDao;

    @InjectMocks
    private UserService subject;

    @Test
    void getUsersTest() {
        when(userDao.findUsers()).thenReturn(new Page() {
            @Override
            public int getTotalCount() {
                return 2;
            }

            @Override
            public String[] getContent() {
                return new String[]{"rabong", "apple"};
            }
        });

        String[] result = subject.getUsers();

        Assertions.assertEquals(2, result.length);
        Assertions.assertEquals("RABONG", result[0]);
        Assertions.assertEquals("APPLE", result[1]);
    }
}

 

Page가 interface이기 때문에 익명 클래스를 사용해서 구현을 해야합니다. 위의 코드의 경우 메서드가 2개이기 때문에 간단하게 익명 클래스로 구현을 하고 원하는 값을 반환하게 설정할 수 있습니다. 하지만 해당 인터페이스에 메서드가 20개가 있고 내가 테스트에서 필요한 메서드는 2개뿐인데 테스트할 때마다 항상 익명 클래스를 만든다고 생각을 해보면 가독성도 떨어지고 테스트 코드 작성 시에 많은 시간이 소요됩니다.

 

 

Mock을 사용하면 다음과 같이 간단하게 테스트를 짤 수 있습니다.

@Test
void getUsersTest() {

	Page users = mock(Page.class); // interface를 mocking
	when(users.getContent()).thenReturn(new String[]{"rabong", "apple"}); // mock한 interface stubbing
	when(userDao.findUsers()).thenReturn(users);

	String[] result = subject.getUsers();

	Assertions.assertEquals(2, result.length);
	Assertions.assertEquals("RABONG", result[0]);
	Assertions.assertEquals("APPLE", result[1]);
}

 

훨씬 코드가 더 간결해집니다. interface를 mock하고 필요한 메서드만 stubbing을 해서 사용하면 됩니다.

 

 

Interface를 Mocking한다는 말이 잘 안 와닿을 수도 있습니다. Interface를 인자로 전달하거나 받으려면 항상 구현한 객체가 있을텐데 그럼 구현한 객체를 쓰면되지 왜 mock을 해야할까라고 의문이 들 수도 있습니다. 하지만 Interface로 인자를 받을 때 구현한 객체를 모르는 경우도 많습니다.

예를 들어, jpa에서 Pageable로 데이터를 pagination하여 값을 받을 때도 인터페이스로 데이터를 받습니다. 또한 큰 프로젝트의 경우는 보통 서비스를 하나로 크게 배포하지 않고 MSA 구조로 도메인 별로 나눠서 서버에 서비스를 배포하게 됩니다. 이럴 때 다른 서비스에 요청을 하여 데이터를 받을 때도 인터페이스로 데이터를 받는 경우도 종종 있습니다.

 

 

 

Spy

사전에 정의된 Spy의 뜻을 보면 다음과 같습니다.

명사
스파이, 정보원, 첩자

동사
정보[스파이] 활동을 하다

 

테스트 코드에서도 Spy 는 비슷합니다. 영화에서 보면 Spy들은 잘 행동하는 것처럼 하면서 정보를 빼돌리는 행위를 합니다. Spy 객체 또한 실제 객체의 행동을 하면서 우리가 조작한 행동(stubbing)은 우리가 설정한대로 동작을 하게 됩니다.

Mock과 차이점을 생각하면 Mock 객체는 애초에 가짜 객체로 모든 메서드를 전부 stubbing해서 사용을 해야합니다. 반면, Spy 객체는 조작하고 싶은 행동만 stubbing으로 조작하고나머지 메서드들은 기존 메서드처럼 실행이됩니다.

 

 

코드를 통해서 알아보겠습니다.

public class SimpleSpyTest {

    @Test
    public void spyTest() {
        Person spyPerson = spy(Person.class);
        Person person = new Person();

        System.out.println(spyPerson.getAge());
        System.out.println(spyPerson.getName());
        System.out.println(person.getAge());
        System.out.println(person.getName());
    }
}

 

위의 SimpleMockTest 와 비슷한 코드입니다. 다른 점이 있다면 mock 객체 대신 spy 객체를 만들었다는 점입니다.

 

결과는 어떻게 나올까요?

11
rabong
11
rabong

 

위에서 말했듯이 Spy 객체는 stubbing을 해주지 않는 이상 원래 객체의 메서드가 실행되기에 다음과 같은 값이 나오게 됩니다.

 

 

stubbing을 하는 방법은 mock에서 했던 방법과 같습니다.

@Test
public void spyTest() {
    Person spyPerson = spy(Person.class);
    Person person = new Person();
    
    when(spyPerson.getAge()).thenReturn(20);
    
    System.out.println(spyPerson.getAge());    // 20
    System.out.println(spyPerson.getName());   // rabong
    System.out.println(person.getAge());       // 11
    System.out.println(person.getName());      // rabong
}

 

이렇게 stubbing을 하면 우리가 조작한 값이 반환이 됩니다.

 

 

그럼 이제, Mock 과 Spy에 대해서 알아보았는데 Mock 객체를 쓰는 경우에 대해서도 알아보았습니다. 그럼 Spy 객체는 언제 써야할까요?

 

둘의 차이를 생각해보면 Spy는 실제 객체의 동작을 유지하기 때문에, 실제 객체의 동작을 유지해야할 때 써야하는 것을 알 수 있습니다. 말로하면 이해가 되기는 하는데 실제 테스트 코드를 작성할 때 언제 작성을 해야할지 감이 안 잡힐 수도 있습니다. 예시를 통해서 언제 Spy를 쓰면 되는지에 대해 알아보겠습니다.

 

 

Unit Test시 내부에서 호출하는 메서드를 Stubbing하기 위해

 

위에 Mock을 설명할 때 사용한 코드를 다음과 같이 수정해 보겠습니다.

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserDao userDao;

    public String[] getAllUser() {
        String[] users = userDao.findAllUser();
        setUserUpper(users);

        return users;
    }

    public String[] getUsers() {
        Page users = userDao.findUsers();

        String[] userList = users.getContent();
        setUserUpper(userList);

        return userList;
    }
    
    public void setUserUpper(String[] users) {
        for (int i = 0; i < users.length; i++) {
            users[i] = users[i].toUpperCase();
        }
    }
}

 

변경된 점이 있다면 users를 대문자로 바꾸는 코드만 따로 메서드로 뺐습니다. 원래는 내부에서만 사용하기에 private으로 접근 제어자를 설정해야하지만, Spy 예시를 들기 위해 public으로 설정해두었습니다.

 

 

보통 단위 테스트를 작성할 때, 라인 커버리지 100%를 기준으로 삼는 경우도 있고 모든 메서드에 대해 테스트를 전부 작성하는 것을 목표로 삼는 경우도 있습니다. 저의 경우 메서드에 대한 테스트 코드는 전부 다 작성을 하는 편이고 라인 커버리지의 경우 100%를 목표로 잡고 테스트 코드를 작성합니다.

 

 

그럼 이제 테스트 코드를 작성해보겠습니다.

@Test
void setUserUpperTest() {
    String[] users = {"rabong", "apple"};
	
    subject.setUserUpper(users);

    Assertions.assertEquals("RABONG", users[0]);
    Assertions.assertEquals("APPLE", users[1]);
}

 

setUserUpper 에 대한 테스트 코드는 다음과 같이 작성할 수 있습니다. 여기서 setUserUpper를 보면 getAllUser, getUsers 메서드에서도 호출이 되고 있습니다.

예시에서 setUserUpper 코드 자체가 짧기도 하고 따로 mock객체를 만들다든가 복잡하지가 않아서 테스트 코드를 그대로 놔둬도 테스트 코드가 잘 돌아갑니다. 하지만 만약 메서드 내부에서 복잡한 코드를 호출하는 경우가 많을 때 호출한 메서드내부 코드에 대한 stubbing 등 테스트 코드 작성을 해줘야합니다.

 

이럴 때 사용할 수 있는게 Spy 입니다. Spy를 이용하여 해당 메서드를 원하는대로 Stubbing을 해주면 됩니다.

 

Spy를 사용해서 어떻게 코드를 수정할 수 있을지 보겠습니다.

@ExtendWith(MockitoExtension.class)
public class UserServiceTest {

    @Mock
    private UserDao userDao;

    @Spy
    @InjectMocks
    private UserService subject;

    @Test
    void getAllUserTest() {
        String[] users = {"rabong", "apple"};

        when(userDao.findAllUser()).thenReturn(users);  // userDao stubbing

        String[] result = subject.getAllUser();

        Assertions.assertEquals(2, result.length);
        Assertions.assertEquals("RABONG", result[0]);
        Assertions.assertEquals("APPLE", result[1]);
    }
    @Test
    void getUsersTest() {

        Page mockUsers = mock(Page.class); // interface를 mocking
        String[] users = {"rabong", "apple"};
        when(userDao.findUsers()).thenReturn(mockUsers);
        when(mockUsers.getContent()).thenReturn(users); // mock한 interface stubbing
        doNothing().when(subject).setUserUpper(users);  // 내부 메서드 stubbing

        String[] result = subject.getUsers();

        Assertions.assertEquals(2, result.length);
        Assertions.assertEquals("rabong", result[0]);
        Assertions.assertEquals("apple", result[1]);
    }

    @Test
    void setUserUpperTest() {
        String[] users = {"rabong", "apple"};

        subject.setUserUpper(users);

        Assertions.assertEquals("RABONG", users[0]);
        Assertions.assertEquals("APPLE", users[1]);
    }
}

 

getAllUserTest의 경우는 setUserUpperTest를 Stubbing을 하지 않았고 getUsersTest의 경우는 setUserUpper를 stubbing했습니다. getUsersTest 에서 setUserUpper 가 아무 동작도 하지않게 stubbing을 하였기에 결과 값이 기존과 똑같이 나오게됩니다. 

 

위의 코드를 보면 setUserUpper에 대해서만 stubbing을 하고 싶을 때만 하고 나머지 코드는 제대로 동작을 하게 만들어야 하기에 Mock을 사용하면 안되고 Spy를 사용해야 합니다. 

 

※ 참고 ※
mock을 주입한 해당 객체를 Spy할 때 @Spy를 붙여주면 Spy 객체가 만들어집니다.

 

 

마무리...

 이번 글에서는 테스트에 기본이 되는 Mock, Spy에 대해 알아보았습니다. 처음 테스트 코드를 작성하였을 때 해당 개념이 너무 헷갈렸습니다. 점차 테스트 코드를 많이 작성을 하면서 언제 mock이 필요하고 Spy가 필요한지에 대해 알게 되었습니다. Mock 객체를 사용하면 단위 테스트 코드를 작성할 때 정말 편하게 코드를 작성할 수 있습니다. 심지어 Static 메서드에 대해서도 Stubbing을 할 수도 있습니다.

앞으로도 테스트 코드를 짜면서 사용한 꿀팁과 사용하면 좋은 기능들에 대해서 적어보겠습니다. 😊

 

'Spring > 테스트 코드' 카테고리의 다른 글

[Test] ArgumentCaptor 란?  (0) 2023.12.18
[Test] 테스트 코드를 왜 작성해야할까?  (0) 2023.10.28