테스트 코드란 무엇이고 테스트 코드의 필요성과 javascript의 대표적인 테스팅 프레임워크인 jest의 사용법을 알아보는 글이다.
테스트 코드는 다른 코드의 동작을 확인하고 검증하는 데 사용되는 코드이다. 사용자가 직접 프로그램을 실행하며 일일이 코드의 작동을 확인할 필요 없이 자동으로 작성한 코드가 기대한 대로 작동하는지 확인하고 버그를 찾아낸다.
테스트 코드를 이용한 테스트 방법으로는 단위 테스트(Unit Test), 통합 테스트(Integration Test), 기능 테스트(Functional Test) 등이 있다. 단위 테스트는 코드의 작은 단위인 함수나 메서드를 개별적으로 테스트하는 것이며, 통합 테스트와 기능 테스트는 여러 컴포넌트 또는 시스템의 동작을 확인하는 것이다.
테스트 코드는 프로그램 개발과 유지보수 시 프로그램의 신뢰성과 안정성을 향상시키는 데 도움을 준다.
테스트를 진행하며 실제 데이터베이스를 이용하여 테스트를 하는 것은 위험한 일이다. 기존 데이터에 원하지 않는 변경을 줄 수도 있고, 나도 모르게 쓸데없는 데이터와 기능이 시스템 자원을 잡아먹을 수도 있다. 특히 여러 라이브러리를 이용하여 외부와 통신할 때 시스템 외부에도 영향을 줄 것이다. 이를 방지하기 위해 사용하는 것이 모킹(Mocking)이다.
모킹은 테스트 중에 외부 의존성을 가진 객체를 대체하기 위해 가짜 객체를 생성하고 사용하는 것을 의미한다. 이 가짜 객체는 실제 동작을 흉내 내며, 테스트 환경에서 특정 시나리오를 재현하고 테스트할 수 있게 해준다.
// user.js
import mongoose from 'mongoose';
const User = mongoose.model('User', new mongoose.Schema({ name: String }));
export const createUser = async (name) => {
const user = new User({ name });
await user.save();
return user;
}
위 코드는 서버에서 새로운 user 데이터를 추가하는 코드이다. 위 코드를 테스트한다고 했을 때 우리는 코드가 정상 작동하는지 테스트만 하고 싶지 실제 데이터베이스에 테스트 데이터를 생성하고 싶진 않을 것이다. 그래서 모킹을 사용한다.
아래는 위 코드를 모킹을 사용해서 테스트하는 코드이다.
// user.test.js
import { createUser } from './user';
import mongoose from 'mongoose';
jest.mock('mongoose');
test('creates a user', async () => {
const name = 'Test User';
const mockUser = { name, save: jest.fn() };
mongoose.model.mockReturnValue(mockUser);
const user = await createUser(name);
expect(user).toEqual(mockUser);
expect(user.save).toHaveBeenCalled();
});
위 코드를 보면 mongoose.model을 사용할 때 해당 코드가 어떻게 작동할지 임의로 정해줬다. 서버 코드에서 user.save
는 jest.fn()
이라는 가짜 함수를 할당해서 호출시 아무 동작도 하지 않는다. 결과적으로 데이터베이스를 건드리는 일 없이 미리 정해준 mockUser
만 반환한다. 이것이 바로 모킹이다.
여기까지 보다보면 실제 데이터베이스와 모듈과의 통신을 통해서가 아니라 사용자가 임의로 각 메서드의 반환값을 지정해준다면 테스트가 무슨 의미가 있는지 의아할 수도 있다. 맞다. 실제로 직접 눌러보고 실행해보며 하는 테스트보다 모킹을 이용한 테스트는 완전할 수 없다. 또 모킹을 제대로 사용하려면 사용자가 이미 코드에 대한 지식이 충분해서 알맞은 입출력 값을 다 예상할 수 있어야 한다. 그럼 왜 테스트 코드를 사용하는 것인가? 그럼에도 테스트 코드가 필요한 이유는 아래와 같다.
개발자는 실수를 할 수 있다. 코드를 작성하는 과정에서 생각하지 못한 예외 상황이나, 오타, 논리적인 오류 등이 발생할 수 있다. 테스트 코드는 이러한 실수를 발견하는 데 도움을 준다.
코드가 수정되거나 확장될 때, 기존의 기능이 올바르게 동작하는지 확인할 수 있다. 테스트 코드가 없다면, 코드의 변경이 기존의 기능에 어떤 영향을 미치는지 일일이 실행해보며 수동으로 확인해야 한다.
테스트 코드의 유용함의 꽃이라고 할 수 있는 부분이다. 테스트 코드가 있으면 코드의 내부 구조를 변경하거나 최적화하는 과정에서 기능이 올바르게 동작하는지 확인할 수 있다. 이는 코드의 유지보수를 쉽게 만들어준다.
테스트 코드는 해당 코드의 기능과 동작 방식을 명확하게 보여주므로, 다른 개발자가 코드를 이해하는 데 도움이 된다.
jest는 Meta에서 개발하고 관리하는 자바스크립트 테스팅 프레임워크이다. 자바스크립트 테스팅 프레임워크로는 상당한 인지도를 가지고 있는 대표적인 프레임워크다. Jest는 사용자 친화적이고, 빠르며, 안전한 테스팅 환경을 제공하며, 백엔드, 프론트엔드 가리지 않고 다양한 자바스크립트 라이브러리와 프레임워크와 함께 사용할 수 있다.
우선 다른 모듈들과 마찬가지로 npm이나 yarn을 이용하여 jest 모듈을 설치해야 한다. 그 후 사용할 수 있는데 아래는 간단한 사용 예시이다.
function sum(a, b) {
return a + b;
}
module.exports = sum;
위와 같은 코드를 테스트한다고 치면 아래와 같이 작성할 수 있다.
const sum = require('./sum');
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3);
});
sum 함수의 반환값을 정해주고 일치하는지 확인하는 테스트 코드이다.
더 자세한 사용법을 알아보기 위해 jest에서 제공하는 기능들을 알아보자.
describe: 테스트를 그룹화하는 데 사용된다. 첫 번째 인수로 그룹의 이름을 문자열로 받고, 두 번째 인수로 그룹에 속한 테스트를 정의하는 함수를 받는다.
test 또는 it: 개별 테스트를 정의하는 데 사용된다. describe와 마찬가지로 첫 번째 인수로 테스트의 이름을 문자열로 받고, 두 번째 인수로 테스트를 수행하는 함수를 받는다.
beforeEach와 afterEach: 각 테스트 전후에 실행되는 함수를 정의한다.
beforeAll와 afterAll: 모든 테스트 전후에 한 번씩만 실행되는 함수를 정의한다.
// sum.js
function sum(a, b) {
return a + b;
}
module.exports = sum;
// sum.test.js
const sum = require('./sum');
// describe를 사용하여 테스트를 그룹화
describe('sum 함수 테스트', () => {
// test 또는 it를 사용하여 개별 테스트를 정의
test('1 + 2를 더하면 3이어야 합니다', () => {
expect(sum(1, 2)).toBe(3);
});
it('5 + 5를 더하면 10이어야 합니다', () => {
expect(sum(5, 5)).toBe(10);
});
// beforeEach를 사용하여 각 테스트 전에 실행되는 함수를 정의
beforeEach(() => {
console.log('테스트를 실행하기 전에 준비합니다...');
});
// afterEach를 사용하여 각 테스트 후에 실행되는 함수를 정의
afterEach(() => {
console.log('테스트 실행이 완료되었습니다...');
});
// beforeAll를 사용하여 모든 테스트 전에 한 번만 실행되는 함수를 정의
beforeAll(() => {
console.log('테스트를 시작하기 전에 준비합니다...');
});
// afterAll를 사용하여 모든 테스트 후에 한 번만 실행되는 함수를 정의
afterAll(() => {
console.log('모든 테스트가 완료되었습니다...');
});
});
이 코드는 sum 함수를 테스트하는 Jest 테스트다. describe 함수를 사용하여 테스트를 그룹화하고, test 또는 it 함수를 사용하여 개별 테스트를 정의한다. beforeEach, afterEach, beforeAll, afterAll 함수를 사용하여 각 테스트 전후 또는 모든 테스트 전후에 실행되는 함수를 정의한다.
호출할 때 쓰이는 인자나 메서드의 결과값을 확인하는 메서드들이다.
toBe: 값이 기대하는 값과 같은지 확인한다.
toEqual: 객체 또는 배열이 기대하는 객체 또는 배열과 동일한 속성과 값을 가지는지 확인한다.
not: 기대하는 값과 다른지 확인한다.
toBeTruthy와 toBeFalsy: 값이 truthy 또는 falsy인지 확인한다.
toContain: 배열이 특정 항목을 포함하는지 확인한다.
toMatch: 문자열이 정규 표현식과 일치하는지 확인한다.
toThrow: 함수가 특정 오류를 던지는지 확인한다.
// sum.js
function sum(a, b) {
return a + b;
}
module.exports = sum;
// sum.test.js
const sum = require('./sum');
describe('sum 함수 테스트', () => {
test('1 + 2는 3이어야 합니다', () => {
expect(sum(1, 2)).toBe(3); // toBe 사용
});
test('5 + 5는 11이 아니어야 합니다', () => {
expect(sum(5, 5)).not.toBe(11); // not 사용
});
test('결과는 참(truthy)이어야 합니다', () => {
expect(sum(1, 1)).toBeTruthy(); // toBeTruthy 사용
});
test('결과는 거짓(falsy)이 아니어야 합니다', () => {
expect(sum(1, 1)).not.toBeFalsy(); // not.toBeFalsy 사용
});
});
// array.test.js
describe('배열 테스트', () => {
const arr = [1, 2, 3, 4, 5];
test('배열에 3이 포함되어야 합니다', () => {
expect(arr).toContain(3); // toContain 사용
});
test('배열에 6이 포함되지 않아야 합니다', () => {
expect(arr).not.toContain(6); // not.toContain 사용
});
});
// string.test.js
describe('문자열 테스트', () => {
const str = 'Hello, world!';
test('문자열이 정규식과 일치해야 합니다', () => {
expect(str).toMatch(/world/); // toMatch 사용
});
});
jest.fn(): 모의 함수를 생성한다. 이 함수는 호출 횟수, 호출 시 사용된 인수 등을 추적한다.
mockReturnValue: 모의 함수가 특정 값을 반환하도록 한다.
mockResolvedValue와 mockRejectedValue: 모의 함수가 특정 값을 반환하는 Promise를 반환하도록 한다.
jest.mock(): 모듈을 모킹한다. 이 함수는 모듈의 모든 함수를 자동으로 모의 함수로 대체한다.
jest.spyOn(): 객체의 메서드를 감시하고, 호출 횟수, 호출 시 사용된 인수 등을 추적한다.
// myFunctions.js
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
module.exports = { add, subtract };
// myFunctions.test.js
const myFunctions = require('./myFunctions');
// jest.fn()을 사용하여 모의 함수 생성
const mockAdd = jest.fn(myFunctions.add);
test('add function', () => {
mockAdd(1, 2);
expect(mockAdd).toHaveBeenCalled(); // 함수가 호출되었는지 확인
expect(mockAdd).toHaveBeenCalledWith(1, 2); // 함수가 특정 인수로 호출되었는지 확인
});
// jest.mock()을 사용하여 모듈 모킹
jest.mock('./myFunctions');
test('subtract function', () => {
myFunctions.subtract(5, 3);
expect(myFunctions.subtract).toHaveBeenCalled(); // 함수가 호출되었는지 확인
expect(myFunctions.subtract).toHaveBeenCalledWith(5, 3); // 함수가 특정 인수로 호출되었는지 확인
});
// jest.spyOn()을 사용하여 함수 감시
const spy = jest.spyOn(myFunctions, 'add');
test('add function', () => {
spy(1, 2);
expect(spy).toHaveBeenCalled(); // 함수가 호출되었는지 확인
expect(spy).toHaveBeenCalledWith(1, 2); // 함수가 특정 인수로 호출되었는지 확인
});
// mockReturnValue를 사용하여 모의 함수의 반환값 설정
const mockSubtract = jest.fn(myFunctions.subtract).mockReturnValue(10);
test('subtract function', () => {
expect(mockSubtract(5, 3)).toBe(10); // 함수가 특정 값을 반환하는지 확인
});
테스트 코드는 테스트 코드 사용에 능숙할 뿐만 아니라 우선적으로 원래 코드를 잘 이해해야지만 제대로 작성할 수 있다. 또 작성하는데 상당한 시간과 수고가 든다. 그래서 빨리 빨리 제품을 만들어 내야 하는 상황에서는 테스트 코드를 작성하는 것이 불필요한 낭비라고 생각될 수도 있다. 하지만 지속적으로 유지보수하며 개량시켜나가야 할 프로젝트에서는 위에서 언급한 이유로 거의 필수적이라고 할 수 있다. 따라서 개발자로서 살아가기 위해서는 어렵고 힘들 수 있지만 꼭 익혀야 할 기술 중에 하나이다.