...
Mocking 원리
mocking이란 (mock = 모조품) 뜻 그대로 받아드리면 된다.
즉 테스트하고자 하는 코드가 의존하는 function이나 class에 대해 모조품을 만들어 '일단' 돌아가게 하는 것이다.
한마디로, 단위 테스트를 작성할 때, 해당 코드가 의존하는 부분을 가짜(mock)로 대체하는 기법을 말한다.
왜 가짜로 대체하는가?
테스트 하고싶은 기능이 다른 기능들과 엮여있을 경우(의존) 정확한 테스트를 하기 힘들기 때문이다.
예를들어 request body에 사용자의 id와 password를 넣어서 post요청을 보내면
컨트롤러에서 정보를 추출한 후 데이터베이스에 넣어주는 단위테스트를 하고 싶다고 하자.
컨트롤러는 라우터에서 응답을 보내는 미들웨어를 특별히 부르는 말이다. 그냥 함수다.
데이터베이스에 저장 요청을 보내면 성공이든 실패든 응답이 반환될 것이고,
반환된 응답을 기준으로 테스트의 성공과 실패를 구분한다.
이때 실제 데이터베이스에 사용자의 id, password를 넣는 방식으로 테스트를 하는 것은 좋은 방법이 아니다.
실제 트랜잭션이 일어나기에 IO 시간도 테스트에 포함되고, 데이터베이스 연결 상태에 따라 테스트가 실패할 수도 있기 때문이다.
테스트가 실패했을 경우 내가 작성한 컨트롤러 코드의 문제인지, 데이터베이스의 문제인지 알아차리기도 힘들기 때문에 올바른 단위테스트라고 할 수 없다.
따라서 실제 데이터베이스에 데이터를 넣는 것이 아니라 넣은 셈 치자는 개념이다.
데이터베이스가 잘 작동하는지는 데이터베이스 관련 테스트에서 확인하면 되고,
우리는 지금 컨트롤러에 대한 테스트를 진행하고 있으니 데이터베이스가 잘 작동한다는 전제를 깔고 가자는 뜻이다.
데이터베이스 mocking을 표현하면 다음 그림과 같다.
기존의 데이터베이스 저장 메소드를 mock 함수로 만든다.
이제 이 아무 의미 없는 mock함수를 호출했을때 반환 받기 원하는 값을 우리가 직접 지정해 준다.
우리는 controller의 로직에 집중해야하니 데이터베이스는 "대충 이런이런 값을 반환한다고 치자"라고 하고 넘어가는 개념이다.
Mocking 메소드 - jest.fn
Jest는 가짜 함수(mock functiton)를 생성할 수 있도록 jest.fn() 함수를 제공한다.
이를 이용해서 일회성 테스트용으로서 내부의 함수를 진짜같이 구동해서 코드를 구동 시킬 수 있다.
jest.fn 종류
mockReturnValue(value)
함수에는 당연히 값을 리턴하는데, 이 또한 사용자 지정으로 정할 수 있다.
// 변수를 mock함수로 만들기
const mockFn = jest.fn();
// mock는 빈 함수이기 때문에 기본적으로 undefined
mockFn(); // undefined
mockFn(1); // undefined
mockFn([1, 2], { a: "b" }); // undefined
// mock 리턴값 지정하기
mockFn.mockReturnValue("I am a mock!"); // I am a mock!
mockReturnValue() 는 함수가 호출될 때마다 반환 값을 정한다.
const mock = jest.fn();
mock.mockReturnValue(42);
mock(); // 42
mock.mockReturnValue(63);
mock(); // 63
mockImplemetation(value)
모크 함수는 기본적으로 아무것도 안들어있다. (아무런 동작, 리턴을 하지 않는다.)
mockImplemetation() 는 모크 함수를 즉석으로 구현할 수 있다. 동작하는 모크 함수를 만드는 것이라고 보면 된다.
const mockFn = jest.fn();
// 동작하는 모크 함수를 하나 만든다.
mockFn.mockImplementation( (name) => `I am ${name}!` );
console.log(mockFn("Dale")); // I am Dale!
// 단축 속성으로 아예 jest.fn() 안에다가 바로 함수를 써서도 똑같이 구현할 수 있다.
// 이 방법이 더 간편하고 직관적이기에 자주 사용하는 편이다.
const mockFn = jest.fn( (name) => `I am ${name}!` );
console.log(mockFn("Dale")); // I am Dale!
mockResolvedValue(value) / mockRejectedValue(value)
비동기 함수에서 resolve 값을 받는다. / 비동기 함수에서 reject 값을 받는다.
test('async resolve test', async () => {
const asyncMock = jest.fn().mockResolvedValue(43);
await asyncMock(); // 43
});
test('async reject test', async () => {
const asyncMock = jest.fn().mockRejectedValue(new Error('Async error'));
await asyncMock(); // throws "Async error"
});
테스트를 작성할 때 가짜 함수가 진짜로 유용한 이유는 가짜 함수는 자신이 어떻게 호출되었는지를 모두 기억한다는 점이다.
test("mock Test", () => {
const mockFn = jest.fn();
mockFn.mockImplementation(name => `I am ${name}`);
mockFn("a");
mockFn(["b", "c"]);
expect(mockFn).toBeCalledTimes(2); // 몇번 호출? -> 2번
expect(mockFn).toBeCalledWith("a"); // a로 호출? -> true
expect(mockFn).toBeCalledWith(["b", "c"]); // 배열로 호출? -> true
})
모킹 실전 - 미들웨어 검증
다음 미들웨어를 테스트한다고 하자.
middlewares.js
exports.isLoggedIn = (req, res, next) => {
// isAuthenticated()로 검사해 로그인이 되어있으면
if (req.isAuthenticated()) {
next(); // 다음 미들웨어
} else {
res.status(403).send('로그인 필요');
}
};
exports.isNotLoggedIn = (req, res, next) => {
if (!req.isAuthenticated()) {
next(); // 로그인 안되어있으면 다음 미들웨어
} else {
const message = encodeURIComponent('로그인한 상태입니다.');
res.redirect(`/?error=${message}`);
}
};
middleware.test.js
// 테스트할 함수를 가져오기
const { isLoggedIn, isNotLoggedIn } = require('./middlewares');
//? if문을 분기점으로 보고 여러 갈래로 테스트할 것
//? 같은 테스트들이 여러개 있으면 describe로 그룹화 할수 있다.
//? 테스트하는 메소드의 인자는 가짜를 넣어도 된다. 의도대로만 실행되면 되니까.
describe('isLoggedIn', () => {
//& 1. 가짜 변수, 함수 선언
const res = {
//? 테스트할 함수안에 들어있는 실행 로직들은 꼼꼼하게 하나하나 선언해주는게 좋다. (에러방지)
status: jest.fn(() => res), // 체이닝 하니까 자기자신을 리턴
send: jest.fn(),
},
next = jest.fn(); // 가짜 함수
test('로그인 되어 있으면 isLoggedIn이 next를 호출해야 함', () => {
//& 2. 가짜 변수를 넣어서 이 함수의 결과값(리턴값)을 도출
const req = {
// 우리가 확인하려하는건 if (req.isAuthenticated()) { next(); } 의 next()지 req.isAuthenticated()를 검증하는것이 아니다.
// 따라서 가짜를 넣어 결과를 강제로얻게 한다.
isAuthenticated: jest.fn(() => true),
};
isLoggedIn(req, res, next); // 함수가 실행되서 true이면 next()를 반환하게 된다.
//& 3. 결과값이 의도한게 옳은지 검증
expect(next).toBeCalledTimes(1); // next가 1번 호출되는지 검증
});
test('로그인 되어 있지 않으면 isLoggedIn이 에러를 응답해야함', () => {
const req = {
// 우리가 확인하려하는건 if (req.isAuthenticated()) { next(); } 의 next()지 req.isAuthenticated()를 검증하는것이 아니다.
isAuthenticated: jest.fn(() => false), //! 실패를 테스트하는 것이니까 false
};
isLoggedIn(req, res, next);
expect(res.status).toBeCalledWith(403);
expect(res.send).toBeCalledWith('로그인 필요');
});
});
describe('isNotLoggedIn', () => {
const res = {
redirect: jest.fn(),
},
next = jest.fn();
test('로그인 되어 있으면 isNotLoggedIn이 그대로 redirect해야함', () => {
const req = {
isAuthenticated: jest.fn(() => true),
};
const message = encodeURIComponent('로그인한 상태입니다.');
isNotLoggedIn(req, res, next);
expect(res.redirect).toBeCalledWith(`/?error=${message}`);
});
test('로그인 되어 있지 않으면 isNotLoggedIn이 에러를 응답해야함', () => {
const req = {
isAuthenticated: jest.fn(() => false),
};
isNotLoggedIn(req, res, next);
expect(next).toBeCalledTimes(1);
});
});
실제 코드에선 익스프레스가 req, res 객체와 next 함수를 인수로 넣었기에 사용할 수 있지만, 테스트 환경에선 어떻게 넣어야 할지 고민될 것이다.
req 객체에는 isAuthenticated라는 메서드가 존재하고, res 객체에서 status, send, redirect 등의 메서드가 존재하는데, 코드가 성공적으로 실행되기 위해선 이것들을 모두 일일히 구현해야만 할 것이다.
이럴 땐 과감히 가짜 객체와 함수를 만들어 넣으면 된다.
테스트의 역할은 코드나 함수가 제대로 실행되는지를 검사하고 값이 일치하는지를 검사하는 것이므로,
테스트 코드의 객체가 실제 익스프레스 객체가 아니어도 상관 없다.
바로 이러한 가짜 객체, 가짜 함수를 넣는 행위를 모킹(mocking) 이라고 한다.
예를 들어,
req.isAuthenticated() 는 세션을 다루는 메소드라, 테스트를 하기위해선 세션값을 가져오고 세션이 있으면 true를 반환 시켜야 하는데, 우리는 세션값을 테스트하는게 아니고 단지 결과만 필요하니까 가짜로 true를 반환시키게 하는 것이다.
(함수를 모킹할 때는 jest.fn 메서드를 사용한다.)
함수의 반환값을 지정하고 싶다면 req.isAuthenticated 메서드와 같이 반환값을 넣어 작성해주면 된다.
res.status 는 res.status(403).send('hello') 와 같은 메서드 체이닝이 가능해야하므로 자기자신 res를 반환하도록 한다.
물론 실제론 req나 res 객체엔 많은 속성과 메서드가 들어 있다.
그러나 지금 테스트에선 isAuthenticated, status, send만 사용하므로 나머진 필요없으니 제외해준다.
test 함수 내부에선 모킹된 객체와 함수를 사용하여 isLoggedIn 미들웨어를 호출한 후, expect로 원하는 내용대로 실행되었는지 체크하면 된다.
- toBeCalledTimes(숫자) 는 정확하게 몇 번 호출되었는지를 체크하는 메서드
- toBeCalledWith(인수) 는 특정 인수와 함께 호출되었는지를 체크하는 메서드
이렇게 작은 단위의 함수나 모듈이 의도된 대로 정확히 작동하는지 테스트하는 것을 유닛 테스트 (unit test) 또는 단위 테스트라고 부른다.
Mocking 메소드 - jest.mock
- jest.fn() : 개별적으로 하나하나씩 모킹 처리 해줄때 사용
- jset.mock() : 그룹을 한꺼번에 모킹 처리 해줄때 사용
위의 코드를 당시 상기해보자.
만일 isLoggedin() 이라는 함수를 테스트하기 위해서는,
함수 안에서 쓰이는 req.isAuthenticated() 함수를 모킹화해서 가짜로 동작하도록 해야한다.
그럼 만일, req.메소드를 100개를 쓰는 함수를 테스트 한다고 가정하면 어떻게 할까?
다음과 같이 100개의 메소드를 일일히 모킹화해야 한다.
...
const req = {
isAuthenticated : jest.fn( () => true ),
isOne : jest.fn(),
isTwo : jest.fn(),
isThree : jest.fn(),
...
... 이하 100개
}
매우 비효율 적인 방식이다.
여기서 jest.mock() 가 등장하는 것이다.
jest.mock('../models/user'); // ../models/user.js에서 export한 객체와 모든 내부요소들을 그룹 모킹화 한다.
const User = require('../models/user'); // 이제 앞으로 ../models/user.js에서 꺼내 쓰는 요소들은 모두 모킹화 된 것들이다.
User.findOrCreate().mockReturnValue() // jest.fn() 처리 없이 자동으로 모킹화 되어있어 바로 사용하면 된다.
보통 객체 안에 요소들이 엄청 많은 경우, 이를 모듈화 해서 분리해서 관리한다. 이를 이용해 모듈 자체를 그룹으로 모킹화 하는 기법을 취한다.
위의 코드에서 보다시피, ../models/user.js 안의 모든 요소들을 모킹화 시키고, User 객체를 꺼내 사용하고 있음을 볼 수 있다.
본래라면 User.메소드 = jest.fn() 으로 하나하나 일일히 모킹화를 시켜주어야 하는데, 그룹으로 묶어 모킹화하니 코드량이 줄었다.
보통 모듈 위에 반드시 jest.mock() 쓰라고 하던데 사실 코드를 어디에다 쓰든 순서는 상관없다.
왜냐하면 import나 function같이 위로 자동으로 올라가기 때문이다.
모킹 실전 - 컨트롤러 검증
간단한 유닛 테스트를 살펴봤으니, 이제 본문 처음에 언급한 데이터베이스와 엑세스하는 함수(컨트롤러)를 테스트 하는 방법을 알아보자.
컨트롤러는 라우터에서 응답을 보내는 미들웨어를 특별히 부르는 말이다. 그냥 함수다.
우리가 테스트 하려는 건 REST API를 요청했을때 동작이 잘 되는지 검증을 하는 것이다.
/user/1/follow로 POST요청을 하면, 로그인 되어있으면, 데이터베이스에 user 팔로우 관련 데이터를 트랜잭션 하는 것이 addFollowing 미들웨어의 기능이다.
//* POST /user/1/follow -> 사용자(내)가 상대방을 팔로우 요청
router.post('/:id/follow', isLoggedIn, addFollowing);
// 테스트할 함수(컨트롤러)
exports.addFollowing = async (req, res, next) => {
try {
const user = await User.findOne({ where: { id: req.user.id } });
//! 분기별로 테스트
if (user) {
//! 테스트 분기 1
await user.addFollowing(parseInt(req.params.id, 10));
res.send('success');
} else {
//! 테스트 분기 2
res.status(404).send('no user');
}
} catch (error) {
//! 테스트 분기 3
console.error(error);
next(error);
}
};
user.test.js
const { addFollowing } = require('./user');
//* 모킹화 대상 (멀쩡한 메소드 가짜화)
jest.mock('../models/user');
const User = require('../models/user'); //! jest.mock 밑에다 써야 모킹화가 된다.
describe('addFollowing', () => {
const req = {
user: { id: 1 }, // req.user.id
params: { id: 2 }, // req.params.id,
};
const res = {
status: jest.fn(() => res),
send: jest.fn(),
};
const next = jest.fn();
//! 테스트 분기 1
test('사용자를 찾아 팔로잉을 추가하고 success를 응답해야 함', async () => {
//? User.findOne() 의 리턴값을 강제로 지정.
//? 본 메소드가 프로미스 객체를 반환했으니 똑같이 설정
User.findOne.mockReturnValue(
Promise.resolve({
id: 1,
name: 'zerocho',
addFollowings(value) {
Promise.resolve(true);
},
}),
);
await addFollowing(req, res, next); // 비동기 함수
expect(res.send).toBeCalledWith('success');
});
//! 테스트 분기 2
test('사용자를 못 찾으면 res.status(404).send(no user)를 호출함', async () => {
User.findOne.mockReturnValue(null);
await addFollowing(req, res, next); // 비동기 함수
expect(res.status).toBeCalledWith(404);
expect(res.send).toBeCalledWith('no user');
});
//! 테스트 분기 3
test('DB에서 에러가 발생하면 next(error) 호출함', async () => {
const error = '테스트용 에러';
User.findOne.mockReturnValue(Promise.reject(error));
await addFollowing(req, res, next); // 비동기 함수
expect(next).toBeCalledWith(error);
});
});
가짜 변수 res, req, next를 선언해주고, test()를 각 if문 분기별로 나누어 총 3개를 검증하면 된다.
그러나, 여기서 주의할 점이 바로 User.findOne() 메소드이다.
이 메소드는 데이터베이스와 연결되어 값을 가져오는데, 위에서 언급했다 싶이, 테스트를 하는데 정말로 데이터베이스의 값을 가져올 필요가 없기 때문에 이 역시 모킹 한다.
데이터베이스 연결 함수를 모킹화 하여도, 실제로 데이터베이스에 값을 넣는 것이 아니므로, 데이터베이스와의 연동에서도 제대로 테스트되는 것인지는 알 수 없다.
따라서 해당 함수에 테스트에 성공을 해도 실제 서비스의 실제 데이터베이스에서는 문제가 발생할 수 있다.
그럴 땐 유닛 테스트 말고 다른 종류의 테스트를 진행해야 한다. 이를 점검하기 위해 통합 테스트나 시스템 테스트를 하곤 한다.
단, User 안에는 무수히 많은 메서드들을 내포하고 있기때문에, 따로따로 jest.fn() 으로 해주기보단, 편리하게 jest.mock() 로 그룹 모킹화하여, 모델 객체 안의 메소드들을 자동 모킹화 처리 해준다.
Mocking 메소드 - jest.spyOn
mocking에는 스파이(spy)라는 개념이 있다.
현실이나 영화 속에서 스파이라는 직업은 “몰래” 정보를 캐내야 한다.
테스트를 작성할 때도 이처럼, 어떤 객체에 속한 함수의 구현을 가짜로 대체하지 않고, 해당 함수의 호출 여부와 어떻게 호출되었는지만을 알아내야 할 때가 있다.
test('spyOn Test', () => {
// 일반 객체
const calculator = {
add: (a, b) => a + b, // 객체 메소드
};
// calculator.add() 메소드에 스파이를 붙임
const spyFn = jest.spyOn(calculator, 'add');
// 객체 메소드 실행
const result = calculator.add(2, 3);
expect(spyFn).toBeCalledTimes(1); // 몇번 호출? -> 1번
expect(spyFn).toBeCalledWith(2, 3); // 뭘로 호출? -> 인자 2, 3 으로
expect(result).toBe(5); // 리턴값은 5와 같나? -> true
});
위 예제를 보면, jest.spyOn() 함수를 이용해서 calculator 객체의 add라는 함수에 스파이를 붙였다.
따라서 add 함수를 호출 후에 호출 횟수와 어떤 인자가 넘어갔는지 정보를 캘 수 있다. (spying)
하지만 가짜 함수로 대체한 것은 아니기 때문에 결과 값은 원래 구현대로 2와 3의 합인 5가 되는 것을 알 수 있다.
# 참고자료
https://www.daleseo.com/jest-fn-spy-on/
https://llshl.tistory.com/42
https://sunup1992.tistory.com/44
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.