우테코 프리코스 온보딩 미션을 진행하며 테스트 코드 실행과 관련하여 새로운 개념을 알게 되었다...! 바로 MOCK 이다. 바로 알아보자.
모의 객체(Mock Object)란 주로 객체 지향 프로그래밍으로 개발한 프로그램을 테스트 할 경우 테스트를 수행할 모듈과 연결되는 외부의 다른 서비스나 모듈들을 실제 사용하는 모듈을 사용하지 않고 실제의 모듈을 "흉내"내는 "가짜" 모듈을 작성하여 테스트의 효용성을 높이는데 사용하는 객체이다. 사용자 인터페이스(UI)나 데이터베이스 테스트 등과 같이 자동화된 테스트를 수행하기 어려운 때 널리 사용된다.
출처: 위키백과
한 마디로 테스트 할 때 가짜를 만들어 테스트를 진행해주는 녀석이다.
단위 테스트를 할 때 주로 사용하며 그 중 Mockito라는 프레임워크를 많이 사용한다고 한다.
대충 개념을 알았으니 당시 에러를 해결했던 사고 흐름을 따라가보며 좀 더 이해해보자.
다음은 에러가 날 당시 컴퓨터의 랜덤 값을 가져오는 함수를 구현한 것이다.
public static List<Integer> assignComputerRandomNumber() {
return Randoms.pickUniqueNumbersInRange(1, 9, 3);
}
처음 작성했을 당시 문제에 컴퓨터 난수로 생성하는 예시가 있었고, 물론 그것을 써도 됐지만 Randoms 클래스를 보니 저렇게 1~9 사이의 3개의 서로다른 숫자를 리턴하는 함수가 있어 바로 사용했다. ~바로 이게 원인이었다.~
그렇다면 당시의 에러 해결 과정을 따라가며 자세히 알아보자.
@Test
void 게임종료_후_재시작() {
assertRandomNumberInRangeTest(
() -> {
run("246", "135", "1", "597", "589", "2");
assertThat(output()).contains("낫싱", "3스트라이크", "1볼 1스트라이크", "3스트라이크", "게임 종료");
},
1, 3, 5, 5, 8, 9
);
}
당시 코드를 다 짜고 테스트 코드를 돌려보는데 이 녀석이 계속 통과를 못했다. 하나씩 껍데기를 까서 안을 들여다 보면
public static void assertRandomNumberInRangeTest(
final Executable executable,
final Integer value,
final Integer... values
) {
assertRandomTest(
() -> Randoms.pickNumberInRange(anyInt(), anyInt()),
executable,
value,
values
);
}
먼저 assertRandomNumberInRangeTest를 열어보면 다음과 같은 함수가 나온다. camp.nextstep.edu.missionutils.test 패키지에 있는 함수로 익숙한 pickNumberInRange 함수도 보인다. 한 번 더 들어가보자
private static <T> void assertRandomTest(
final Verification verification,
final Executable executable,
final T value,
final T... values
) {
assertTimeoutPreemptively(RANDOM_TEST_TIMEOUT, () -> {
try (final MockedStatic<Randoms> mock = mockStatic(Randoms.class)) {
mock.when(verification).thenReturn(value, Arrays.stream(values).toArray());
executable.execute();
}
});
}
다음은 assertRandomTest함수가 보인다 마찬가지로 같은 패키지에 존재하는 함수이고 드디어 MOCK 어쩌구가 보인다!!
코드를 보면 Random 클래스를 가지고 가짜 객체를 하나 생성한 뒤 인자로 넘어온 verification을 실행했을 때 -> thenReturn으로 value, values를 배열로 반환한다는 것을 알 수 있다.
❗️여.기.서
verification은 뭔데?
이 함수로 들어오기 전 호출한 부분에 가보면 첫 번째 인자로 pickNumberInRange 함수를 람다 함수로 넘기고 있다. 따라서
verification == pickNumberInRange인 것을 알 수 있다.
executable은 뭔데?
가장 바깥에 있던 assertRandomNumberInRangeTest함수를 보면 람다함수로 실행할 함수를 정의해놓은 것이 있다. 이 함수가 인자로 계속해서 넘어오고 있는 것을 알 수 있다.
자 이제 해석해보자
assertTimeoutPreemptively(RANDOM_TEST_TIMEOUT, () -> {
// Randoms 클래스에 대한 MockedStatic 객체 생성
try (final MockedStatic<Randoms> mock = mockStatic(Randoms.class)) {
// mock 객체가 verification(pickNumberInRange)를 실행할 때 -> value, values를 차례대로 반환한다.
mock.when(verification).thenReturn(value, Arrays.stream(values).toArray());
// 정의한 테스트 코드를 실행한다.
executable.execute();
}
});
다시 테스트 코드를 보면
@Test
void 게임종료_후_재시작() {
//excute되는 함수
assertRandomNumberInRangeTest(
() -> {
//안에 들어가보면 입력 버퍼에 해당 값들을 넣고 있다.
run("246", "135", "1", "597", "589", "2");
//output()은 출력 버퍼 값을 string으로 바꾸어 리턴한다.
assertThat(output()).contains("낫싱", "3스트라이크", "1볼 1스트라이크", "3스트라이크", "게임 종료");
},
//pickNumberInRange 호출될 때마다 순서대로 리턴하는 값
1, 3, 5, 5, 8, 9
);
}
이렇게 각 코드의 의미가 정리될 수 있다.
여기서 중요한 것은 테스트 코드는 pickNumberInRange가 호출될 때 미리 정의한 값들을 집어넣고 있다.
하지만 내가 사용한 함수의 내부를 보면
public static List<Integer> pickUniqueNumbersInRange(
final int startInclusive,
final int endInclusive,
final int count
) {
validateRange(startInclusive, endInclusive);
validateCount(startInclusive, endInclusive, count);
final List<Integer> numbers = new ArrayList<>();
for (int i = startInclusive; i <= endInclusive; i++) {
numbers.add(i);
}
return shuffle(numbers).subList(0, count);
}
pickNumberInRange 함수는 호출되지 않는다.
그러다 보니 컴퓨터 난수로 들어갈 value, values들도 어디 들어가지 못해 computer 난수 값이 없는 채로 게임이 진행되어 에러가 발생한 것이다.
그래서 다시 문제에서 제시한 예시로 컴퓨터 난수 생성 로직을 바꾸니
List<Integer> computer = new ArrayList<>();
while (computer.size() < 3) {
int randomNumber = Randoms.pickNumberInRange(1, 9);
if (!computer.contains(randomNumber)) {
computer.add(randomNumber);
}
}
return computer;
바로 성공!
드디어 해치워따,,,
처음엔 잘 해결되지 않아 살짝 불안도 했지만 해결하는 과정에서 라이브러리 여기저기 돌아다니며 코드 따라가는게 너무 재밌었고 코드 구조도 이해하는데 많은 도움이 됐다.
마지막으로
추가로 mock에 대해 좀만 더 알아보자
xUnit Test Patterns의 저자인 제라드 메스자로스(Gerard Meszaros)가 만든 용어로 테스트를 진행하기 어려운 경우 이를 대신해 테스트를 진행할 수 있도록 만들어주는 객체를 말한다.
출처: 설명 링크
그림과 설명처럼 테스트 코드를 작성할 때 실제 객체를 생성하여 테스트하기 복잡하거나 까다로운 경우 test double을 생성하여 대신 테스트를 진행한다.
이 test double 종류에 MOCK이 존재하며 MOCK은 보통 stub와 비슷하면서도 다른 개념으로 많이 사용된다고 한다.
mock의 개념에 대해 위에서 잠깐 알아봤지만 여기서 다시 좀 더 자세히 알아보자
두 개의 차이는 행위검증(mock) vs 상태검증(stub)이다.
말 그대로 mock은 함수가 어떠한 '행위를 하는가'를 테스트할 때 사용하고 stub은 '상태'를 검증할 때 사용한다고 한다.
또한 stub은 테스트 도중 호출된 경우에 대해 미리 리턴값을 정해두고 이를 리턴하며 테스트 이외에는 동작하지 않는다.
Mock은 호출했을 때 사전에 정의된 명세대로의 결과를 돌려주도록 미리 프로그래밍되어있다.
예상치 못한 호출이 있을 경우 예외를 던질 수 있으며, 모든 호출이 예상된 것이었는지 확인할 수 있다.
public class SimpleService implements Service {
private Collaborator collaborator;
public void setCollaborator(Collaborator collaborator) {
this.collaborator = collaborator;
}
// part of Service interface
public boolean isActive() {
return collaborator.isActive();
}
}
public void testActiveWhenCollaboratorIsActive() throws Exception {
service.setCollaborator(new Collaborator() {
public boolean isActive() {
return true;
}
});
assertTrue(service.isActive());
}
다음은 stub 예제이다.
Collaborator collaborator = EasyMock.createMock(Collaborator.class);
EasyMock.expect(collaborator.isActive()).andReturn(true);
EasyMock.replay(collaborator);
service.setCollaborator(collaborator);
assertTrue(service.isActive());
EasyMock.verify(collaborator);
다음은 mock 예제이다.
stub이 지금까지 짰던 테스트 코드인 것 같다.(이번에 처음 짜봤지만;;)
어떤 로직을 실행하고 그 결과(상태) 값을 테스트하는 과정인 것 같다.
mock은 정말 행위에 대해 검증하는 방식인 것 같다.
일단 간단히 정리해놓고 경험을 통해 확인하자 이후에 더 추가해도 문제 없다!