이번 포스팅에서는 실전 테스트 코드를 작성하기에 앞서, 미리 알아두어야 할 사전 지식에 대해 알아보도록 하겠습니다. 실전 테스트 코드 작성법에 대해서는 다음 포스팅을 참고해주시기 바랍니다.
① 단위 테스트(Unit Test)
② 통합 테스트(Integration Test)
③ 시스템 테스트
④ 인수 테스트
이외에도 회귀 테스트, 성능 테스트, 보안 테스트 등을 수행할 수도 있다.
개발자들이 테스트 코드를 작성해가며 테스트를 수행하는 단계는 주로, 단위 테스트나 통합 테스트 단계에 해당된다. 이 과정에서 테스트 프레임워크가 사용되는데, 여기서 테스트 프레임워크란 소프트웨어 테스트의 자동화 및 체계화를 위한 도구를 말한다. 테스트 프레임워크를 사용했을 때의 얻을 수 있는 이점은 아래와 같다.
※ Test Coverage
테스트 커버리지란, 작성된 테스트 코드가 실제 어플리케이션의 얼마나 많은 부분을 테스트하고 있는지를 정량적으로 나타낸 지표로, 주로 단위 테스트 단계에서 사용되는 개념이다. 테스트 커버리지가 높을 경우, 잠재적 버그를 조기에 발견할 가능성이 높아지고, 이에 따라 안정성, 신뢰성, 유지보수 용이성이 향상될 수 있다. (단, 높은 커버리지가 항상 높은 코드 품질을 보장한다고 말할 수는 없다. 일례로, 테스트 커버리지가 100%일지라도 테스트 자체의 품질이 낮으면, 무의미한 테스트가 되어버릴 수도 있다.) 테스트 커버리지의 종류는 아래와 같다.
- 라인 커버리지(Line Coverage)
- 기준: 얼마나 많은 코드 Line이 테스트에 의해 실행되었는가?
- 예시: (테스트 Line / 전체 Line)을 백분율로 표시
- 문장 커버리지(Statement Coverage)
- 기준: 함수 내의 명령문이 최소 한번씩은 실행되었는가?
- 특징: 라인 커버리지와 유사하지만, 각 테스트 케이스가 어떤 명령문을 실행하는지에 초점이 맞추어져 있다.
- 예시:
if(num % 2 == 0) {...} else {...}
라는 코드에 대해, num이 짝수인 경우와 홀수인 경우를 모두 테스트 해야 문장 커버리지가 100%가 된다.- 분기 커버리지(Branch Coverage)
- 기준: 조건문에서 가능한 모든 분기가 실행되었는가?
- 예시: if-else 문의 경우, if 분기와 else 분기가 모두 테스트되었는지 확인한다.
- 조건 커버리지(Condition Coverage)
- 기준: 조건문의 각 조건이 모두 True와 False로 한번씩 평가되어 실행되었는가?
- 예시:
if(num1 != 1 && num2 != 1) {...}
라는 코드에 대해 num1과 num2가 각각 1인 경우와 1이 아닌 모든 경우를 테스트해야 조건 커버리지가 100%가 된다.- 함수 커버리지(Function Coverage):
- 기준: 각 함수가 최소 한 번은 호출되었는가?
테스트 프레임워크에는 아래와 같이 다양한 종류가 있다.
① JUnit
② PyTest
③ Mocha
④ Jest
Node.js 코드 테스팅을 위해 사용할 수 있는 프레임워크에는 Jest와 Mocha가 있다. 하지만, 대부분의 상황에서 Mocha에 비해 초기 설정이 단순하고, 사용이 편리한 Jest 프레임워크가 선호된다. 따라서, 여기서도 Jest 모듈을 사용하여 JavaScript 기반의 테스트 코드를 작성해보기로 한다.
① 아래의 명령을 입력하여 Jest 모듈을 설치한다.
npm init -y
npm install --save-dev jest
② npm test
를 입력했을 때 Jest 모듈이 실행될 수 있도록, src > package.json 파일을 아래와 같이 수정한다.
"scripts": {
...
"test": "jest",
},
③ src 디렉토리 하위로 test라는 이름의 디렉토리를 생성하고, test 디렉토리 하위로, isValidEmail.test.js 파일을 추가한다.
.test.js
인 모든 파일을 찾아 실행힌다.④ isValidEmail.test.js 파일에 아래의 내용을 입력한다.
const { isEmail } = require("../util/ValueChecker")
test("이메일 유효성 평가", () => {
expect(isEmail('gmlstjq123@naver.com')).toBe(true);
});
⑤ 터미널에 npm test
를 입력하여 테스트의 결과를 확인할 수 있다.
⑥ 특정 테스트 파일만 실행해보고 싶다면 npm test {테스트 파일 경로}
를 입력하면 된다.
위에서 사용된 코드를 분석해보자.
① test
② expect ... toBe
===
연산자와 유사하며, toEqual은 ==
연산자와 유사하다.만약 아래와 같이 테스트 케이스에 대한 기대 값이 잘못될 경우, 어떤 테스트에서 실패하였는지를 보여준다.
const { isEmail } = require("../util/ValueChecker")
test("이메일 유효성 평가", () => {
expect(isEmail('gmlstjq123@naver.com')).toBe(true);
expect(isEmail('크롬의 백엔드 연구소')).toBe(true); // 기대 값이 잘못된 테스트 케이스
expect(isEmail('hyunseop123@naver.com')).toBe(true);
});
toBe와 toEqual 같은 메서드를 Matcher라고 하는데, 자주 사용되는 Matcher의 종류는 아래와 같다.
① toBeTruthy(), toBeFalsy()
test("toBeTruthy(), toBeFalsy() 사용법", () => {
expect(0).toBeFalsy(); // 0은 false로 간주됨.
expect("Hello").toBeTruthy(); // 문자열은 true로 간주됨.
});
② toHaveLength(n), toContain(e)
test("toHaveLength(n) 사용법", () => {
const colors = ["Red", "Yellow", "Blue"];
expect(colors).toHaveLength(3);
expect(colors).toContain("Yellow");
expect(colors).not.toContain("Green");
});
③ toMatch(re)
test("toMatch(re) 사용법", () => {
expect('gmlstjq123@naver.com').toMatch(/^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*\.[a-zA-Z]{2,3}$/i);
});
④ toThrow()
const { InvalidInputError } = require('../exception/scheduleException')
const isValidUserId = (userId) => {
if (userId < 0 || userId > 100) {
throw new InvalidInputError();
}
}
test("toThrow() 사용법", () => {
expect(() => isValidUserId(10)).not.toThrow(); // 예외가 호출되지 않아야 성공
expect(() => isValidUserId(1000)).toThrow(); // 예외가 호출되어야 성공
expect(() => isValidUserId(1000)).toThrow(InvalidInputError); // InvalidInputError 예외가 호출되어야 성공
expect(() => isValidUserId(1000)).toThrow('유효하지 않은 입력입니다.'); // 예외 객체의 메시지가 '유효하지 않은 입력입니다.'여야 성공
});
⑤ toHaveBeenCalled(), toHaveBeenCalledTimes(n)
test('mockResolvedValue 테스트', async () => {
const asyncMockFn = jest.fn();
asyncMockFn.mockResolvedValue(100);
await asyncMockFn(); // 1번째 호출
await asyncMockFn(); // 2번째 호출
await asyncMockFn(); // 3번째 호출
asyncMockFn.mockRejectedValue(new Error('error'));
await expect(asyncMockFn()).rejects.toThrow('error'); // 4번째 호출
expect(asyncMockFn).toHaveBeenCalledTimes(4);
});
Jest 모듈을 사용하여 테스트 커버리지를 확인하는 방법에 대해 알아보자.
① 먼저 packages.json 파일의 scripts에 아래의 내용을 추가한다.
npm run coverage
명령을 통해 테스트 커버리지를 확인할 수 있다."scripts": {
...
"coverage": "jest --coverage",
},
② 표에 나타나는 레이블의 의미는 아래와 같다.
③ npm run coverage
명령을 수행하면, coverage라는 디렉토리가 생성된다.
④ coverage > Icov-report > index.html 파일을 열면, 커버리지 레포트를 웹 페이지 형태로 열람할 수도 있다.
단위 테스트를 수행할 때, 테스트하려는 코드가 의존하는 부분을 직접 생성하기 어렵거나, 테스트 데이터를 실제 DB에 삽입하기 부담스러운 경우에 Mocking을 사용할 수 있다. 여기서 Mocking은 실제 객체와 동일한 동작을 수행할 수 있는 가짜 객체를 생성하는 것을 의미한다. Mocking을 사용함으로써 얻을 수 있는 이점은 아래와 같다.
Jest 모듈에서는 가짜 함수를 생성할 수 있는 jest.fn
메서드를 제공한다. jest.fn의 종류는 아래와 같다.
① mockReturnValue(value)
test('mockReturnValue 테스트', () => {
const mockFn = jest.fn();
// 항상 100을 반환하도록 설정
mockFn.mockReturnValue(100);
console.log(mockFn()); // 100 출력
// 한 번만 반환값을 설정
mockFn.mockReturnValueOnce('First Call');
mockFn.mockReturnValueOnce('Second Call');
console.log(mockFn()); // First Call 출력
console.log(mockFn()); // Second Call 출력
console.log(mockFn()); // 100 출력
})
② mockImplemetation(value)
test('mockImplementation 테스트', () => {
const mockFn = jest.fn();
mockFn.mockImplementation( (name) => `Hello ${name}!` );
// 또는 아래와 같은 방식을 사용할 수도 있다.
// const mockFn = jest.fn( (name) => `Hello ${name}!` );
console.log(mockFn("Chrome"));
})
③ mockResolvedValue(value), mockRejectedValue(value)
test('mockResolvedValue 테스트', async () => {
const asyncMockFn = jest.fn();
asyncMockFn.mockResolvedValue(100);
const result1 = await asyncMockFn();
expect(result1).toBe(100);
asyncMockFn.mockRejectedValue(new Error('error'));
await expect(asyncMockFn()).rejects.toThrow('error');
asyncMockFn.mockResolvedValueOnce(200).mockResolvedValueOnce(300);
const result2 = await asyncMockFn();
expect(result2).toBe(200);
const result3 = await asyncMockFn();
expect(result3).toBe(300);
const result4 = await asyncMockFn();
expect(result4).toBe(100);
});
Jest는 기본적으로 API 호출을 지원하지 않는다. 따라서, API 테스트를 수행하기 위해서는 추가 라이브러리가 필요한데, 그중에서 가장 자주 사용되는 라이브러리가 바로 Supertest이다.
Supertest는 실제 서버를 실행하지 않고도, 요청을 보내고 응답을 검증할 수 있는 테스트 환경을 제공한다. Supertest 모듈을 활용한 통합 테스트는 일반적으로, HTTP 요청을 생성 및 전송한 후, 응답 객체의 상태 코드 및 본문을 예상 값과 비교하는 방식으로 구성된다.
Supertest 모듈을 설치하는 명령은 아래와 같다. (Jest 모듈을 설치한 경로와 동일한 곳에서 아래의 명령을 입력한다.)
npm i -D supertest
Supertest에서 주로 사용되는 메서드는 아래와 같다.
① request()
② agent()
이해를 돕기 위해 아래와 같은 코드를 가정하자.
① 테스트할 API
const request = require('supertest');
const express = require('express');
const app = express();
app.use(express.json());
app.post('/login', (req, res) => {
if (req.body.username === 'user' && req.body.password === 'password') {
res.cookie('session_id', '123456');
res.status(200).send('Login successful');
} else {
res.status(401).send('Login failed');
}
});
app.get('/protected', (req, res) => {
if (req.cookies.session_id === '123456') {
res.status(200).send('Access to Protected Content');
} else {
res.status(401).send('Unauthorized');
}
});
② 테스트 코드
const request = require('supertest');
const app = require('./app'); // 위에서 정의한 Express 앱
const agent = request.agent(app); // supertest agent 생성
describe('인증 정보 저장 여부 확인', () => {
test('Protected content에 접근 성공', async () => {
await agent
.post('/login')
.send({ username: 'user', password: 'pass' })
.expect(200);
await agent
.get('/protected')
.expect(200)
.then(response => {
console.log(response.text); // Protected content
});
});
test('Protected content에 접근 실패', async () => {
await request(app)
.get('/protected')
.expect(401);
});
});
request로 생성된 요청에 대해서는, 인증이 수행되지 않는 것을 확인할 수 있다.
모든 경우를 고려하여 테스트 케이스를 작성하는 것은 불가능에 가깝다. 그러므로, 테스트 케이스를 효율적으로 작성하기 위한 기준이 필요하다. 이 때, 아래와 같은 기준을 고려해볼 수 있다.
① 동치 분할 검사
② 경계값 분석
③ 오류 예측 검사
④ 원인-효과 그래프 검사