Mock을 이용해서, 제품 정보를 가지고 오는 테스트를 해볼려고 합니다.
실제로 두개의 클래스로 이루어진 파일이 있습니다.
class ProductClient {
fetchItems() {
return fetch('http://example.com/login/id+password').then((response) =>
response.json()
);
}
}
module.exports = ProductClient;
ProductClient 클래스
는, 가상의 주소로 데이터 요청을 받는 코드입니다. 실제로 네트워크 동작은 하지 않습니다.
const ProductClient = require('./product_client');
class ProductService {
constructor() {
this.productClient = new ProductClient();
}
fetchAvailableItems() {
return this.productClient
.fetchItems()
.then((items) => items.filter((item) => item.available));
}
}
module.exports = ProductService;
ProductService
에서 ProductClient
를 사용한다면, 서로 모듈간의 상호작용을 테스트를 하면 안되고, 단일의 테스트만 진행해야합니다.
ProductService
내부에서, ProductClient
를 사용하기 떄문에, this.productClient
의 fetchItems
라는 함수도 우리도 모르게 내부적으로 테스트가 됩니다.
그래서 클라이언트 내부에서 함수가 실패하거나 데이터를 받아오지 못해
서 또는 일시적으로 네트워크에 문제가 생긴다면, 서비스 테스트 코드는 실패
하게 됩니다.
네트워크 상태에 의존하는 테스트 코드는 좋지 않습니다.
따라서, ProductClient
와 별개로 독립적
으로 의존하지않도록 ProductClient 자체를 mock
하면 됩니다.
ProductService 내부에서 Product Client를 사용하기에, jest에서 제공하는 mock을 사용할 수 있습니다.
const ProductService = require('../product_service_no_di.js');
const ProductClient = require('../product_client.js');
// 가품의 프로덕트 클라이언트를 쓴다고 명시
// 실제로 구현된 코드를 사용하는 것이 아닌, 가품의 productClient를 사용한다고 명시!
jest.mock('../product_client');
describe('ProductService', () => {
// 실제로, productClient에서 데이터 요청을 하는데, 어떤 데이터를 받는지 우리가, 리턴값을 가상으로 정의
// fetchItems를 호출하면 mock 함수가, 비동기적으로 아래의 배열을 호출할것입니다.
const fetchItems = jest.fn(async () => [
{ item: 'Milk', available: true },
{ item: 'Banana', available: false },
])
// 다음은 가상의 fetchItems 함수와,mock하고있는 productClient를 연결해주면 됩니다.
ProductClient.mockImplementation(() => {
return {
fetchItems, // 생략 fetchItems: fetchItems;
}
});
let productService;
beforeEach(() => {
productService = new ProductService();
});
});
위의 코드처럼 작성하면, ProductService
의 클래스를 테스트하는 것 입니다.
이 곳에 **어떤 다른 모듈 클래스를 사용하든, 영향을 받지 않도록**
나머지 **다른 의존성에 대해서는 위의 코드처럼 mock을 이용**
하면됩니다.
**mock 함수와, mockImplementation을 통해서 어떤 함수를 호출했을 때 어떤 결과를 도출하는지 연결**
해주는 작업을 한 겁니다.
그러면, 우리는 ProductService 클래스의 **available이 true인 애들만 필터링 하는 작업에만 집중**
해서 테스트를 작성할 수 있습니다.
이렇게 mock을 이용했을 떄 큰 장점은 productClient
가 실패하든, 네트워크 자체에서 실패를하든 환경적인 요인에 영향을 받지 않습니다. 우리가 원하는 로직만 날카롭게 검증
할 수 있습니다. 이것이 **단위 테스트**
입니다.
우리는, 이제 ProductService의 기능만 테스트를 진행하면 됩니다.
const ProductService = require('../product_service_no_di.js');
const ProductClient = require('../product_client.js');
jest.mock('../product_client');
describe('ProductService', () => {
const fetchItems = jest.fn(async () => {
return [
{ item: 'Milk', available: true },
{ item: 'Banana', available: false },
];
});
ProductClient.mockImplementation(() => {
return {
fetchItems: fetchItems,
};
});
let productService;
beforeEach(() => {
productService = new ProductService();
});
it('사용 가능한(available) 아이템만 필터링 하는 테스트', async () => {
const items = await productService.fetchAvailableItems();
expect(items.length).toBe(1);
expect(items).toEqual([{ item: 'Milk', available: true }]);
});
// * mock을 이용할 떄 주의할 점, 위의 테스트 말고도, 여러개의 테스트를 진행한다고 가정.
it('test', async () => {
const items = await productService.fetchAvailableItems();
// 클라이언트에서는, fetchItems라는 mock함수가 한번만 호출되어야 함.
expect(fetchItems).toHaveBeenCalledTimes(1);
});
});
추가 케이스를 추가해서, 테스트를 실행하면 모두 성공하는 것을 확인할 수 있습니다.
// jest.config.js
module.exports = {
clearMocks: true,
}
별도로, 다른 것들을 하지 않아도 각각의 테스트가 수행이 되면 자동으로 Mock에 관한게 초기화 되기 떄문입니다.
// jest.config.js
// false로 바꾸면 실패한 것을 확인할 수 있다.
module.exports = {
clearMocks: false,
}
왜 실패했는지, 살펴보면 총 2개의 테스트 케이스를 지금 테스트 하고 있는데
첫번째 테스트 케이스에서도 결국에는 fetchItems 라는 함수를 호출
했습니다.
두번쨰 테스트 케이스에서도 동일하게 fetchItems 라는 함수를 호출
했기 때문입니다.
총 2번 호출했음을 확인할 수 있습니다.
clearMocks를 false라고
했을 때 우리가 이러한 에러를 방지하기 위해서는 beforEach
문에, 항상 테스트가 실행되기 전에 mock을 초기화해주는 작업을 해주어야 합니다.
const ProductService = require('../product_service_no_di.js');
const ProductClient = require('../product_client.js');
jest.mock('../product_client');
describe('ProductService', () => {
const fetchItems = jest.fn(async () => {
return [
{ item: 'Milk', available: true },
{ item: 'Banana', available: false },
];
});
ProductClient.mockImplementation(() => {
return {
fetchItems: fetchItems,
};
});
let productService;
beforeEach(() => {
productService = new ProductService();
// clearMocks를 false로 설정했을 때 항상, 코드 실행 전에 mockClear()를 해주어야 한다.
fetchItems.mockClear();
ProductClient.mockClear();
});
it('사용 가능한(available) 아이템만 필터링 하는 테스트', async () => {
const items = await productService.fetchAvailableItems();
expect(items.length).toBe(1);
expect(items).toEqual([{ item: 'Milk', available: true }]);
});
it('test', async () => {
const items = await productService.fetchAvailableItems();
expect(fetchItems).toHaveBeenCalledTimes(1);
});
});
beforeEach
문에서, clearMocks를 false
로 설정했을 때 항상, 코드 실행 전에 mockClear()
를 해주어야 한다.
완성 코드
const ProductService = require('../product_service_no_di.js');
const ProductClient = require('../product_client.js');
// 가품의 프로덕트 클라이언트를 쓴다고 명시
jest.mock('../product_client');
// Mock 을 남용하는 좋지 않은 사례임!
// {return} 은생략가능
describe('ProductService', () => {
// ProductService Class를 테스트하는건데, ProductService에서 어떤 다른 모듈 클래스를 사용하든, 그것들에 영향을 받지 않도록, 나머지 모든 의존성에 대해서는 mock을 이용합니다.
// mock 함수를 선언하고
const fetchItems = jest.fn(async () => {
return [
{ item: 'Milk', available: true },
{ item: 'Banana', available: false },
];
});
// mockImplementation을 통해, 어떤 함수를 호출했을 떄 어떤 데이터를 갖고오는지 컨트롤할 수 있게 만들었습니다.
ProductClient.mockImplementation(() => {
return {
fetchItems: fetchItems, // fetchItems라고 축약 가능.
};
});
let productService;
beforeEach(() => {
productService = new ProductService();
fetchItems.mockClear();
ProductClient.mockClear();
});
it('should filter out only available items', async () => {
const items = await productService.fetchAvailableItems();
expect(items.length).toBe(1);
expect(items).toEqual([{ item: 'Milk', available: true }]);
});
it('test', async () => {
const items = await productService.fetchAvailableItems();
expect(fetchItems).toHaveBeenCalledTimes(1);
});
});
// 이렇게 의존성을 낮추면 ProductClient 실제 데이터 요청을 하는 곳에서 네트워크 요청이
// 실패하든 말든, 환경적인 요인에 영향을 받지 않습니다. 원하는 로직만 날카롭게 검증
// 이것이 단위 테스트이다.
// 서로간의 의존성이 있다면, 목을 이용하여 의존성이 없도록 만들 수 있다.