Jest
서비스를 개발 완료한 후, 개발자나 QA들은 자신이 만든 서비스가 제대로 동작하는지 테스트해본다. 그러나 기능이 많다면 일일이 수작업으로 테스트하기엔 작업량이 너무 많을 수 있다. 이런 경우엔 테스트를 자동화하여 프로그램에 프로그램을 테스트하도록 할 수 있다.
테스트 기법에는 여러 가지가 있지만, 그 중 유닛 테스트, 통합 테스트, 부하 테스트, 테스트 커버리지를 살펴보자.
Jest 는 페이스북에서 만든 오픈소스 라이브러리로, 테스팅에 필요한 툴들을 대부분 갖추고 있어 편리한 라이브러리다. 먼저 jest 패키지를 개발 전용으로 다운로드 받아놓자 (테스팅 툴은 개발 시에만 사용하므로).
> npm i -D jest
이후 package.json 의 test 커맨드로 jest를 넣어두자. 앞으로 npm test 라는 명령어를 입력하면 jest가 실행될 것이다.
{
...
"scripts": {
"start": "nodemon app",
"test": "jest"
},
...
}
이제 jest를 사용할 준비가 완료되었다.
첫 번째 테스트 해보기
https://github.com/beomseok-kang/learn-nodejs-express/tree/master/nodebird
위 레포지토리의 코드를 바탕으로 테스트를 진행해보았다. 먼저 routes/middlewares.js를 테스트하기 위한 middlewares.test.js를 만들어보자. 테스트용 파일은 파일명과 확장자 사이에 test 또는 spec을 넣으면 된다.
npm test로 jest를 실행하면 자동으로 파일명에 test나 spec이 들어간 파일들을 모두 찾아서 실행한다. 한 번 실행해보자.
PS C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird> npm test
> nodebird@0.0.1 test C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird
> jest
FAIL routes/middlewares.test.js
● Test suite failed to run
Your test suite must contain at least one test.
at onResult (node_modules/@jest/core/build/TestScheduler.js:175:18)
at node_modules/@jest/core/build/TestScheduler.js:304:17
at node_modules/emittery/index.js:260:13
at Array.map (<anonymous>)
at Emittery.Typed.emit (node_modules/emittery/index.js:258:23)
Test Suites: 1 failed, 1 total
Tests: 0 total
Snapshots: 0 total
Time: 3.225 s
Ran all test suites.
npm ERR! Test failed. See above for more details.
테스트를 아무 것도 작성하지 않았으니 에러가 발생한다. 이를 테스트가 실패했다고 표현한다. 첫 번째 테스트 코드를 한번 작성해보자.
test('1 + 1 은 2일겁니다.', () => {
expect(1 + 1).toEqual(2);
});
실행해주면,
PS C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird> npm test
> nodebird@0.0.1 test C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird
> jest
PASS routes/middlewares.test.js
√ 1 + 1 은 2일겁니다. (3 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 2.98 s
Ran all test suites.
test 함수의 첫 번째 인수로는 테스트에 대한 설명, 두 번째 인수인 함수에는 테스트 내용을 적는다. expect 함수에는 실제 코드가, toEqual 함수에는 예상되는 결괏값을 넣으면 된다. expect에 넣은 값과 toEqual에 넣은 값이 일치하여 테스트를 통과했다. toEqual의 값을 3으로 바꾼 후 테스트를 진행해보자.
PS C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird> npm test
> nodebird@0.0.1 test C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird
> jest
FAIL routes/middlewares.test.js
× 1 + 1 은 2일겁니다. (9 ms)
● 1 + 1 은 2일겁니다.
expect(received).toEqual(expected) // deep equality
Expected: 3
Received: 2
1 | test('1 + 1 은 2일겁니다.', () => {
> 2 | expect(1 + 1).toEqual(3);
| ^
3 | });
at Object.<anonymous> (routes/middlewares.test.js:2:19)
Test Suites: 1 failed, 1 total
Tests: 1 failed, 1 total
Snapshots: 0 total
Time: 3.51 s
Ran all test suites.
npm ERR! Test failed. See above for more details.
테스트가 실패하면 어떤 부분에서 실패했는지 시각적으로 보여준다. 따라서 코드에 대해 테스트를 작성해두면 어떤 부분에 문제가 있는지 파악할 수 있다.
유닛 테스트
실제 코드를 테스트해보자. 먼저 middlewares.js의 isLoggedIn과 isNotLoggedIn 함수를 테스트해보자.
테스트하기 전, 함수가 어떻게 동작하는지 확인해야 한다. middlewares.js를 보자.
exports.isLoggedIn = (req, res, next) => {
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}`);
}
};
실제 코드에선 익스프레스가 req, res 객체와 next 함수를 인수로 넣었기에 사용할 수 있지만, 테스트 환경에선 어떻게 넣어야 할지 고민될 것이다. req 객체에는 isAuthenticated라는 메서드가 존재하고, res 객체에서 status, send, redirect 등의 메서드가 존재하는데, 코드가 성공적으로 실행되기 위해선 이것들을 모두 구현해야 한다.
이럴 땐 과감히 가짜 객체와 함수를 만들어 넣으면 된다. 테스트의 역할은 코드나 함수가 제대로 실행되는지를 검사하고 값이 일치하는지를 검사하는 것이므로 테스트 코드의 객체가 실제 익스프레스 객체가 아니어도 상관 없다. 이러한 가짜 객체, 가짜 함수를 넣는 행위를 모킹 (mocking) 이라고 한다. 아래와 같이 모킹한 객체를 만들어 테스트 함수에 넣어주자.
const { isLoggedIn, isNotLoggedIn } = require('./middlewares');
describe('isLoggedIn', () => {
const res = {
status: jest.fn(() => res),
send: jest.fn()
};
const next = jest.fn();
test('로그인되어 있으면 isLoggedIn이 next를 호출해야 함', () => {
const req = {
isAuthenticated: jest.fn(() => true),
};
isLoggedIn(req, res, next);
expect(next).toBeCalledTimes(1);
});
test('로그인되어 있지 않으면 isLoggedIn이 에러를 응답해야 함', () => {
const req = {
isAuthenticated: jest.fn(() => false),
};
isLoggedIn(req, res, next);
expect(res.status).toBeCalledWith(403);
expect(res.send).toBeCalledWith('로그인 필요');
});
});
함수를 모킹할 때는 jest.fn 메서드를 사용한다. 함수의 반환값을 지정하고 싶다면 req.isAuthenticated 메서드와 같이 반환값을 넣어 작성해주면 된다. res.status는 res.status(403).send('hello')와 같은 메서드 체이닝이 가능해야하므로 res를 반환하도록 한다.
실제론 req나 res 객체엔 많은 속성과 메서드가 들어 있다. 그러나 지금 테스트에선 isAuthenticated, status, send만 사용하므로 나머진 제외해주었다. test 함수 내부에선 모킹된 객체와 함수를 사용하여 isLoggedIn 미들웨어를 호출한 후 expect로 원하는 내용대로 실행되었는지 체크하면 된다. toBeCalledTimes(숫자) 는 정확하게 몇 번 호출되었는지를 체크하는 메서드고, toBeCalledWith(인수) 는 특정 인수와 함께 호출되었는지를 체크하는 메서드이다.
이제 테스트를 돌려보면,
PS C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird> npm test
> nodebird@0.0.1 test C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird
> jest
PASS routes/middlewares.test.js
isLoggedIn
√ 로그인되어 있으면 isLoggedIn이 next를 호출해야 함 (3 ms)
√ 로그인되어 있지 않으면 isLoggedIn이 에러를 응답해야 함 (2 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 3.095 s
Ran all test suites.
위와 같이 잘 실행되었음을 보여준다.
isNotLoggedIn 부분도 마저 작성해주자.
...
describe('isNotLoggedIn', () => {
const res = {
redirect: jest.fn()
};
const next = jest.fn();
test('로그인되어 있지 않으면 isNotLoggedIn이 next를 호출해야 함', () => {
const req = {
isAuthenticated: jest.fn(() => false)
};
isNotLoggedIn(req, res, next);
expect(next).toBeCalledTimes(1);
});
test('로그인되어 있으면 isNotLoggedIn이 에러를 응답해야 함', () => {
const req = {
isAuthenticated: jest.fn(() => true)
};
isNotLoggedIn(req, res, next);
const message = encodeURIComponent('로그인한 상태입니다.');
expect(res.redirect).toBeCalledWith(`/?error=${message}`);
});
});
실행 결과
PS C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird> npm test
> nodebird@0.0.1 test C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird
> jest
PASS routes/middlewares.test.js
isLoggedIn
√ 로그인되어 있으면 isLoggedIn이 next를 호출해야 함 (2 ms)
√ 로그인되어 있지 않으면 isLoggedIn이 에러를 응답해야 함 (2 ms)
isNotLoggedIn
√ 로그인되어 있지 않으면 isNotLoggedIn이 next를 호출해야 함
√ 로그인되어 있으면 isNotLoggedIn이 에러를 응답해야 함 (1 ms)
Test Suites: 1 passed, 1 total
Tests: 4 passed, 4 total
Snapshots: 0 total
Time: 2.951 s
Ran all test suites.
이렇게 작은 단위의 함수나 모듈이 의도된 대로 정확히 작동하는지 테스트하는 것을 유닛 테스트 (unit test) 또는 단위 테스트라고 부른다. 나중에 함수를 수정하면 기존에 작성해둔 테스트는 실패하게 된다. 따라서 함수가 수정되었을 때 어떤 부분이 고장나는지를 테스트를 통해 알 수 있다. 테스트 코드도 기존 코드가 변경된 것에 맞춰 수정해야 한다.
라우터와 긴밀하게 연결되어 있는 미들웨어도 테스트해보자. 이 경우 유닛 테스트를 위해 미들웨어를 분리해야 한다. routes/user.js 파일을 보면 POST /:id/follow 라우터의 async 함수 부분은 따로 분리할 수 있어 보인다. controllers 폴더를 만들고 그 안에 user.js를 만들어주자. 참고로 컨트롤러란 라우터에서 응답을 보내는 미들웨어를 특별히 부르는 말이다.
const User = require('../models/user');
exports.addFollowing = async(req, res, next) => {
try {
const user = await User.findOne({ where: { id: req.user.id } });
if (user) {
await user.addFollowing(parseInt(req.params.id, 10));
res.send('success');
} else {
res.status(404).send('no user');
}
} catch (err) {
console.error(err);
next(err);
}
};
컨트롤러가 분리되었으므로 routes/user.js도 따라서 수정해주어야 한다.
const express = require('express');
const { isLoggedIn } = require('./middlewares');
const { addFollowing } = require('../controllers/user');
const User = require('../models/user');
const router = express.Router();
router.post('/:id/follow', isLoggedIn, addFollowing);
...
module.exports = router;
이제 addFollowing 컨트롤러를 테스트할 차례이다. controllers/user.test.js를 작성해주자.
const { addFollowing } = require('./user');
describe('addFollowing', () => {
const req = {
user: { id: 1 },
params: { id: 2 }
};
const res = {
status: jest.fn(() => res),
send: jest.fn(),
};
const next = jest.fn();
test('사용자를 찾아 팔로잉을 추가하고 success를 응답해야 함', async () => {
await addFollowing(req, res, next);
expect(res.send).toBeCalledWith('success');
});
test('사용자를 못 찾으면 res.status(404).send(\'no user\')를 호출', async () => {
await addFollowing(req, res, next);
expect(res.status).toBeCalledWith(404);
expect(res.send).toBeCalledWith('no user');
});
test('DB에서 에러 발생 시 next(error) 호출', async () => {
const error = '테스트용 에러';
await addFollowing(req, res, next);
expect(next).toBeCalledWith(error);
});
});
addFollowing 컨트롤러가 async 함수이므로 await을 붙여야 컨트롤러가 실행 완료된 후 expect 함수가 실행된다. 그러나 이 테스트는 실패한다.
PASS routes/middlewares.test.js
FAIL controllers/user.test.js
● Console
console.error
TypeError: Cannot convert undefined or null to object
at Function.keys (<anonymous>)
at Function.findAll (C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird\node_modules\sequelize\lib\model.js:1692:47)
at Function.findOne (C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird\node_modules\sequelize\lib\model.js:1917:23)
at addFollowing (C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird\controllers\user.js:5:33)
at Object.<anonymous> (C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird\controllers\user.test.js:18:15)
at Object.asyncJestTest (C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird\node_modules\jest-jasmine2\build\jasmineAsyncInstall.js:106:37)
at C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird\node_modules\jest-jasmine2\build\queueRunner.js:45:12
at new Promise (<anonymous>)
at mapper (C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird\node_modules\jest-jasmine2\build\queueRunner.js:28:19)
at C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird\node_modules\jest-jasmine2\build\queueRunner.js:75:41
at processTicksAndRejections (internal/process/task_queues.js:93:5)
11 | }
12 | } catch (err) {
> 13 | console.error(err);
| ^
14 | next(err);
15 | }
16 | };
at exports.addFollowing (controllers/user.js:13:17)
at Object.<anonymous> (controllers/user.test.js:18:9)
console.error
TypeError: Cannot convert undefined or null to object
at Function.keys (<anonymous>)
at Function.findAll (C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird\node_modules\sequelize\lib\model.js:1692:47)
at Function.findOne (C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird\node_modules\sequelize\lib\model.js:1917:23)
at addFollowing (C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird\controllers\user.js:5:33)
at Object.<anonymous> (C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird\controllers\user.test.js:23:15)
at Object.asyncJestTest (C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird\node_modules\jest-jasmine2\build\jasmineAsyncInstall.js:106:37)
at C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird\node_modules\jest-jasmine2\build\queueRunner.js:45:12
at new Promise (<anonymous>)
at mapper (C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird\node_modules\jest-jasmine2\build\queueRunner.js:28:19)
at C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird\node_modules\jest-jasmine2\build\queueRunner.js:75:41
at processTicksAndRejections (internal/process/task_queues.js:93:5)
11 | }
12 | } catch (err) {
> 13 | console.error(err);
| ^
14 | next(err);
15 | }
16 | };
at exports.addFollowing (controllers/user.js:13:17)
at Object.<anonymous> (controllers/user.test.js:23:9)
console.error
TypeError: Cannot read property 'id' of undefined
at addFollowing (C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird\controllers\user.js:5:65)
at Object.<anonymous> (C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird\controllers\user.test.js:30:15)
at Object.asyncJestTest (C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird\node_modules\jest-jasmine2\build\jasmineAsyncInstall.js:106:37)
at C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird\node_modules\jest-jasmine2\build\queueRunner.js:45:12
at new Promise (<anonymous>)
at mapper (C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird\node_modules\jest-jasmine2\build\queueRunner.js:28:19)
at C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird\node_modules\jest-jasmine2\build\queueRunner.js:75:41
at processTicksAndRejections (internal/process/task_queues.js:93:5)
11 | }
12 | } catch (err) {
> 13 | console.error(err);
| ^
14 | next(err);
15 | }
16 | };
at addFollowing (controllers/user.js:13:17)
at Object.<anonymous> (controllers/user.test.js:30:15)
● addFollowing › 사용자를 찾아 팔로잉을 추가하고 success를 응답해야 함
expect(jest.fn()).toBeCalledWith(...expected)
Expected: "success"
Number of calls: 0
17 | test('사용자를 찾아 팔로잉을 추가하고 success를 응답해야 함', async () =>
{
18 | await addFollowing(req, res, next);
> 19 | expect(res.send).toBeCalledWith('success');
| ^
20 | });
21 |
22 | test('사용자를 못 찾으면 res.status(404).send(\'no user\')를 호출', async
() => {
at Object.<anonymous> (controllers/user.test.js:19:26)
● addFollowing › 사용자를 못 찾으면 res.status(404).send('no user')를 호출
expect(jest.fn()).toBeCalledWith(...expected)
Expected: 404
Number of calls: 0
22 | test('사용자를 못 찾으면 res.status(404).send(\'no user\')를 호출', async
() => {
23 | await addFollowing(req, res, next);
> 24 | expect(res.status).toBeCalledWith(404);
| ^
25 | expect(res.send).toBeCalledWith('no user');
26 | });
27 |
at Object.<anonymous> (controllers/user.test.js:24:28)
● addFollowing › DB에서 에러 발생 시 next(error) 호출
expect(jest.fn()).toBeCalledWith(...expected)
Expected: "테스트용 에러"
Received
1: [TypeError: Cannot convert undefined or null to object]
2: [TypeError: Cannot convert undefined or null to object]
3: [TypeError: Cannot read property 'id' of undefined]
Number of calls: 3
29 | const error = '테스트용 에러';
30 | await addFollowing(res, res, next);
> 31 | expect(next).toBeCalledWith(error);
| ^
32 | });
33 | });
at Object.<anonymous> (controllers/user.test.js:31:22)
Test Suites: 1 failed, 1 passed, 2 total
Tests: 3 failed, 4 passed, 7 total
Snapshots: 0 total
Time: 5.022 s
Ran all test suites.
npm ERR! Test failed. See above for more details.
이는 User 모델 때문이다. addFollowing 컨트롤러 안에는 User라는 모델이 들어 있다. 이 모델은 실제 데이터베이스와 연결되어 있으므로 테스트 환경에서는 사용할 수 없다. 따라서 User 모델도 모킹해야 한다. jest에서는 모듈도 모킹할 수 있다. jest.mock 메서드를 사용한다.
jest.mock('../models/user');
const User = require('../models/user');
const { addFollowing } = require('./user');
describe('addFollowing', () => {
const req = {
user: { id: 1 },
params: { id: 2 }
};
const res = {
status: jest.fn(() => res),
send: jest.fn(),
};
const next = jest.fn();
test('사용자를 찾아 팔로잉을 추가하고 success를 응답해야 함', async () => {
User.findOne.mockReturnValue(Promise.resolve({
addFollowing(id) {
return Promise.resolve(true);
}
}));
await addFollowing(req, res, next);
expect(res.send).toBeCalledWith('success');
});
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');
});
test('DB에서 에러 발생 시 next(error) 호출', async () => {
const error = '테스트용 에러';
User.findOne.mockReturnValue(Promise.reject(error));
await addFollowing(req, res, next);
expect(next).toBeCalledWith(error);
});
});
jest.mock에서 모킹할 메서드 (User.findOne) 에 mockReturnValue라는 메서드를 넣는다. 이 메서드로 가짜 반환값을 지정할 수 있다.
PASS routes/middlewares.test.js
PASS controllers/user.test.js
● Console
console.error
테스트용 에러
11 | }
12 | } catch (err) {
> 13 | console.error(err);
| ^
14 | next(err);
15 | }
16 | };
at exports.addFollowing (controllers/user.js:13:17)
at Object.<anonymous> (controllers/user.test.js:37:9)
Test Suites: 2 passed, 2 total
Tests: 7 passed, 7 total
Snapshots: 0 total
Time: 4.791 s
Ran all test suites.
이렇게 되면 테스트를 통과하는 것을 볼 수 있다.
그러나 실제 데이터베이스에 팔로잉을 등록하는 것이 아니므로 데이터베이스와의 연동에서도 제대로 테스트되는 것인지는 알 수 없다. 따라서 테스트를 해도 실제 서비스의 실제 데이터베이스에서는 문제가 발생할 수 있다. 그럴 땐 유닛 테스트 말고 다른 종류의 테스트를 진행해야 한다. 이를 점검하기 위해 통합 테스트나 시스템 테스트를 하곤 한다.
테스트 커버리지
테스트 커버리지 (test coverage) 란, 전체 코드 중 어떤 부분이 테스트 되고 어떤 부분이 테스트 되지 않았는지를 알려주는 기능이다. 전체 코드 중 테스트되고 있는 코드의 비율과 테스트되고 있지 않은 코드의 위치를 알려주는 기능이다. package.json에 커맨드로 coverage 를 다음과 같이 넣어주어 사용해보자.
{
...
"scripts": {
"start": "nodemon app",
"test": "jest",
"coverage": "jest --coverage"
},
...
}
PS C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird> npm run coverage
> nodebird@0.0.1 coverage C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird
> jest --coverage
PASS routes/middlewares.test.js
PASS controllers/user.test.js
● Console
console.error
테스트용 에러
11 | }
12 | } catch (err) {
> 13 | console.error(err);
| ^
14 | next(err);
15 | }
16 | };
at exports.addFollowing (controllers/user.js:13:17)
at Object.<anonymous> (controllers/user.test.js:37:9)
-----------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------------|---------|----------|---------|---------|-------------------
All files | 84 | 100 | 60 | 84 |
controllers | 100 | 100 | 100 | 100 |
user.js | 100 | 100 | 100 | 100 |
models | 33.33 | 100 | 0 | 33.33 |
user.js | 33.33 | 100 | 0 | 33.33 | 5-54
routes | 100 | 100 | 100 | 100 |
middlewares.js | 100 | 100 | 100 | 100 |
-----------------|---------|----------|---------|---------|-------------------
Test Suites: 2 passed, 2 total
Tests: 7 passed, 7 total
Snapshots: 0 total
Time: 5.045 s
Ran all test suites.
테스트 결과와 함께 표가 하나 출력된다. 표의 열을 보면 각각 File (파일과 폴더 트리), % Stmts (구문 비율), % Branch (if문 등의 분기점 비율), % Func (함수 비율), % Lines (코드 줄 수 비율), Uncovered Line #s (커버되지 않은 줄 위치) 이다. 퍼센티지가 높을 수록 많은 코드가 테스트되었다는 뜻이다.
여기선 명시적으로 테스트하고 require한 코드만 커버리지 분석이 된다는 점을 주의해야 한다. 이전에 만들었던 controllers/user.test.js 파일에서 models/user.js를 require하였기 때문에 불러와 테스트가 되었는지 확인한 것이다.
models/user.js에선 33.333%의 구문과 100%의 분기점, 0%의 함수, 33.33%의 코드 줄이 커버되었다고 나온다. 또한, 5-54 번째 줄은 테스트되지 않았다고 나온다. 실제 코드를 보자.
const Sequelize = require('sequelize');
class User extends Sequelize.Model {
static init(sequelize) {
return super.init({ // 줄 5
// 이메일
email: {
type: Sequelize.STRING(40),
allowNull: true,
unique: true,
},
// 닉네임
nick: {
type: Sequelize.STRING(15),
allowNull: false,
},
// 비밀번호
password: {
type: Sequelize.STRING(100),
allowNull: true,
},
// 인증 프로바이더 (로컬 로그인 또는 카카오 로그인)
provider: {
type: Sequelize.STRING(10),
allowNull: false,
defaultValue: 'local',
},
// SNS 아이디
snsId: {
type: Sequelize.STRING(30),
allowNull: true,
}
},{
sequelize,
timestamps: false,
underscored: false,
modelName: 'User',
tableName: 'users',
paranoid: false,
charset: 'utf8',
collate: 'utf8_general_ci',
});
}
static associate(db) {
// user - post => 1:N
db.User.hasMany(db.Post);
// user - user (follower or following) => N:M
db.User.belongsToMany(db.User, {
foreignKey: 'followingId',
as: 'Followers',
through: 'Follow'
});
db.User.belongsToMany(db.User, { // 줄 54
foreignKey: 'followerId',
as: 'Followings',
through: 'Follow'
});
}
}
module.exports = User;
보면 각 줄들엔 함수 호출이 위치하고 있다. 다른 함수들 또한 마찬가지이다. 함수들은 테스트를 하나도 작성하지 않았으므로 % Funcs가 0%로 나오는 것이다. 테스트 커버리지를 올리기 위해 models/user.test.js 테스트를 작성해보자.
const Sequelize = require('sequelize');
const User = require('./user');
const { describe } = require('./user');
const config = require('../config/config')['test'];
const sequelize = new Sequelize(
config.database, config.usernane, config.password, config
);
describe('User 모델', () => {
test('static init 메서드 호출', () => {
expect(User.init(sequelize)).toBe(User);
});
test('static associate 메서드 호출', () => {
const db = {
User: {
hasMany: jest.fn(),
belongsToMany: jest.fn()
},
Post: {}
};
User.associate(db);
expect(db.User.hasMany).toHaveBeenCalledWith(db.Post);
expect(db.User.belongsToMany).toHaveBeenCalledTimes(2);
});
});
각각 init과 associate 메서드가 제대로 호출되는지 테스트한다. 위 테스트 수행 시 성공한다.
...
Test Suites: 3 passed, 3 total
Tests: 9 passed, 9 total
Snapshots: 0 total
Time: 4.925 s
Ran all test suites.
이제 커버리지를 확인해보자.
-----------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------------|---------|----------|---------|---------|-------------------
All files | 100 | 100 | 100 | 100 |
controllers | 100 | 100 | 100 | 100 |
user.js | 100 | 100 | 100 | 100 |
models | 100 | 100 | 100 | 100 |
user.js | 100 | 100 | 100 | 100 |
routes | 100 | 100 | 100 | 100 |
middlewares.js | 100 | 100 | 100 | 100 |
-----------------|---------|----------|---------|---------|-------------------
Test Suites: 3 passed, 3 total
Tests: 9 passed, 9 total
Snapshots: 0 total
Time: 4.772 s
Ran all test suites.
테스트 커버리지가 대폭 올라갔다. 현재 테스트 커버리지는 100%이지만 모든 코드가 테스트되고 있는 상황은 아니다. 따라서 테스트 커버리지를 높이는 것에 집착하기 보단 필요한 부분 위주로 올바르게 테스트를 하는 것이 더 좋다.
통합 테스트
하나의 라우터를 통째로 테스트해보자. routes/auth.test.js를 작성한다. 하나의 라우터에는 여러 개의 미들웨어가 붙어 있고, 다양한 라이브러리가 사용된다. 이런 것들이 모두 유기적으로 잘 작동하는지 테스트하는 것을 통합 테스트 (integration test) 라고 한다.
supertest 패키지를 설치해주자.
> npm i -D supertest
supertest 패키지를 이용해 auth.js의 라우터들을 테스트해줄 것이다. supertest를 사용하기 위해선 app 객체를 모듈로 만들어 분리해야 한다. app.js 파일에서 app 객체를 모듈로 만든 후, server.js에서 불러와 listen한다. server.js는 app의 포트 리스닝만 담당하게 된다. 아래는 app.js이다.
...
app.use((err, req, res, next) => {
res.locals.message = err.message;
res.locals.error = process.env.NODE_ENV !== 'production' ? err : {};
res.status(err.status || 500);
res.render('error');
});
module.exports = app;
아래는 server.js이다. app.js와 같은 디렉터리에 있다.
const app = require('./app');
// listen to port
app.listen(app.get('port'), () => {
console.log(app.get('port'), '번 포트에서 대기 중');
})
서버 실행의 주체가 바뀌었으니 package.json의 start 커맨드도 아래와 같이 바꿔준다.
{
...
"scripts": {
"start": "nodemon server",
...
},
...
}
테스트용 데이터베이스도 재설정한다. 통합 테스트에서는 데이터베이스 코드를 모킹하지 않으므로 데이터베이스에 실제로 테스트용 데이터가 저장된다. 그런데 실제 서비스 중인 데이터베이스에 테스트용 데이터가 들어가면 안되므로, 테스트용 데이터베이스를 따로 만드는 것이 좋다.
config/config.json에서 test 속성을 수정한다. 테스트 환경에서는 test 속성의 정보를 사용해 데이터베이스에 연결하게 된다.
{
...
"test": {
"username": "root",
"password": ".........",
"database": "nodebird_test",
"host": "127.0.0.1",
"dialect": "mysql"
},
...
}
위 데이터베이스명인 nodebird_test는 테스트용 데이터베이스이다. 콘솔에 nodebird_test 데이터베이스를 생성하는 명령어를 입력하여 만들어준다.
PS C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird> npx sequelize db:create --env test
Sequelize CLI [Node: 14.7.0, CLI: 6.2.0, ORM: 6.3.4]
Loaded configuration file "config\config.json".
Using environment "test".
Database nodebird_test created
이제 테스트 코드를 작성하면 된다. routes/auth.est.js 파일을 작성해주자.
const request = require('supertest');
const { sequelize } = require('../models');
const app = require('../app');
beforeAll(async () => {
await sequelize.sync();
});
describe('POST /login', () => {
test('로그인 수행', async (done) => {
request(app)
.post('/auth/login')
.send({
email: 'bumsuk980119@gmail.com',
password: '.......'
})
.expect('Location', '/')
.expect(302, done);
});
});
보면 beforeAll이라는 함수가 추가되었다. 이는 현재 테스트를 실행하기 전 수행되는 코드이다. 여기에 sequelize.sync()를 넣어 데이터베이스에 테이블을 생성한다. 비슷한 함수로 afterAll (모든 테스트가 끝난 후), beforeEach (각각의 테스트 수행 전), afterEach (각각의 테스트 수행 후) 가 있다. 테스트를 위한 값이나 외부 환경을 설정할 때 테스트 전후로 수행할 수 있도록 사용하는 함수이다.
supertest 패키지로부터 불러온 request 함수에 app 객체를 인수로 넣어준다. 여기에 get, post, put, patch, delete 등의 메서드로 원하는 라우터에 요청을 보낼 수 있다. 데이터는 send 메서드에 담아서 보낸다. 그 후, 예상되는 응답의 결과를 체이닝으로 expect 메서드의 인수로 넣어 그 값과 일치하는지 테스트한다. 또한, done을 두 번째 인수로 넣어 테스트가 마무리되었음을 알려야 한다.
supertest를 사용하면 app.listen의 수행 없이도 서버 라우터를 실행할 수 있다. 통합 테스트의 경우 모킹을 최소한으로 하는것이 좋지만, 직접적인 테스트 대상이 아닐 경우 모킹해도 된다. 위 테스트를 실행해보자.
...
FAIL routes/auth.test.js (5.125 s)
...
● POST /login › 로그인 수행
expected "Location" of "/", got "/?loginError=%EA%B0%80%EC%9E%85%EB%90%98%EC%A7%80%20%EC%95%8A%EC%9D%80%20%ED%9A%8C%EC%9B%90%EC%9E%85%EB%8B%88%EB%8B%A4."
...
Test Suites: 1 failed, 3 passed, 4 total
Tests: 1 failed, 9 passed, 10 total
Snapshots: 0 total
Time: 8.856 s
Ran all test suites.
npm ERR! Test failed. See above for more details.
테스트가 실패했다. 테스트용 데이터베이스에는 현재 내 회원 정보가 없기 때문이다. 따라서 로그인 시 loginError가 발생했다. 로그인 라우터를 테스트하기 전 회원가입 라우터부터 테스트하여 회원정보를 넣어주자.
const request = require('supertest');
const { sequelize } = require('../models');
const app = require('../app');
beforeAll(async () => {
await sequelize.sync();
});
describe('POST /join', () => {
test('로그인 안 했으면 가입', (done) => {
request(app)
.post('/auth/join')
.send({
email: 'bumsuk980119@gmail.com',
nick: 'beomseok',
password: '........'
})
.expect('Location', '/')
.expect(302, done);
});
});
describe('POST /join', () => {
const agent = request.agent(app);
beforeEach((done) => {
agent
.post('/auth/login')
.send({
email: 'bumsuk980119@gmail.com',
password: '........'
})
.end(done);
});
test('이미 로그인했으면 redirect /', (done) => {
const message = encodeURIComponent('로그인한 상태입니다.');
agent
.post('/auth/join')
.send({
email: 'bumsuk980119@gmail.com',
nick: 'beomseok',
password: '.......'
})
.expect('Location', `/?error=${message}`)
.expect(302, done);
});
});
먼저, 첫 번째 describe에서 회원가입을 테스트한다. 그 다음 describe에서는 로그인한 상태에서 회원가입을 시도하는 경우를 테스트한다. 이 때, 코드의 순서가 매우 중요하다. 로그인한 상태여야 회원가입을 테스트할 수 있으므로 로그인 요청과 회원가입 요청이 순서대로 이루어져야 한다. 이 때 agent를 만들어서 하나 이상의 요청에서 재사용할 수 있다.
beforeEach는 각각의 테스트 실행에 앞서 먼저 실행되는 부분으로, 회원가입 테스트를 위해 방금 생성한 agent 객체로 로그인을 먼저 수행한다. end(done)으로 beforeEach 함수가 마무리되었음을 알려야 한다.
이후엔 로그인된 agent로 회원가입 테스트를 진행한다. 로그인한 상태이기 때문에 '로그인한 상태입니다.'라는 메시지와 함께 리다이렉트된다.
이후 테스트를 수행해주면 성공한다.
Test Suites: 4 passed, 4 total
Tests: 11 passed, 11 total
Snapshots: 0 total
Time: 8.484 s
Ran all test suites.
허나 다시 테스트를 수행하면 실패한다.
Test Suites: 1 failed, 3 passed, 4 total
Tests: 1 failed, 10 passed, 11 total
Snapshots: 0 total
Time: 8.312 s
Ran all test suites.
npm ERR! Test failed. See above for more details.
이는 이전에 이미 bumsuk980119@gmail.com 계정을 생성해놓았기 때문이다. 이와 같이 테스트 후에 데이터베이스에 데이터가 남아 있다면 다음 테스트에 영향을 미칠 수가 있다. 따라서 테스트 종료 시 데이터를 정리하는 코드를 추가해야 한다. afterAll에 정리하는 코드를 추가해주자.
...
afterAll(async () => {
await sequelize.sync({ force: true });
});
위 코드는 sync 메서드에 force: true를 넣어 테이블을 다시 만들게 했다. 시퀄라이즈를 쓰지 않더라도 afterAll에 데이터를 정리하는 코드를 넣으면 된다. 테스트를 다시 수행하고 또 다시 수행하면 성공한다.
회원가입 이외에도 로그인과 로그아웃도 테스트해주자.
...
describe('POST /login', () => {
test('가입되지 않은 회원', async (done) => {
const message = encodeURIComponent('가입되지 않은 회원입니다.');
request(app)
.post('/auth/login')
.send({
email: 'notUser@dummy.auth',
password: 'somePassword'
})
.expect('Location', `/?loginError=${message}`)
.expect(302, done);
});
test('로그인 수행', async (done) => {
request(app)
.post('/auth/login')
.send({
email: 'bumsuk980119@gmail.com',
password: '........'
})
.expect('Location', '/')
.expect(302, done);
});
test('비밀번호 틀림', async (done) => {
const message = encodeURIComponent('비밀번호가 일치하지 않습니다.');
request(app)
.post('/auth/login')
.send({
email: 'bumsuk980119@gmail.com',
password: 'someWrongPassword'
})
.expect('Location', `/?loginError=${message}`)
.expect(302, done);
});
});
describe('GET /logout', () => {
test('로그인되어 있지 않으면 403', async (done) => {
request(app)
.get('/auth/logout')
.expect(403, done);
});
const agent = request.agent(app);
beforeEach((done) => {
agent
.post('/auth/login')
.send({
email: 'bumsuk980119@gmail.com',
password: '........'
})
.end(done);
});
test('로그아웃 수행', async (done) => {
agent
.get('/auth/logout')
.expect('Location', '/')
.expect(302, done);
});
});
afterAll(async () => {
await sequelize.sync({ force: true });
});
테스트를 진행해보면 성공하게 된다.
Test Suites: 4 passed, 4 total
Tests: 16 passed, 16 total
Snapshots: 0 total
Time: 9.849 s
Ran all test suites.
이와 같은 방식으로 다른 라우터도 통합 테스트를 진행하면 된다. 다른 라우터 중에서도 로그인을 해야 접근할 수 있는 라우터가 있을 것이다. 그럴 때는 마찬가지로 beforeEach로 미리 로그인한 agent를 만들어주면 된다.
부하 테스트
부하 테스트 (load test) 란 서버가 얼마만큼의 요청을 견딜 수 있는지 테스트하는 방법이다. 내 코드가 실제로 배포되었을 때 어떤 문법적, 논리적 문제가 있을지는 유닛 테스트와 통합 테스트를 통해 어느 정도 확인할 수 있다. 그러나 내 서버가 몇 명의 동시 접속자나 일일 사용자를 수용할 수 있는지 예측하는 것은 매우 어렵다. 특히 실제 서비스 중이 아니라 개발 중일 때는 더더욱 어렵다.
코드에 문법적, 논리적 문제가 없더라도 서버의 하드웨어 제약으로 인해 서비스가 중단될 수 있다. 대표적인 것이 OOM (out of memory) 문제인데, 이는 서버가 접속자들의 정보 저장을 위해 각 사용자마다 일정한 메모리를 할당할 때, 메모리의 양이 계속 증가하다가 결국 서버의 메모리 용량을 넘어서게 되면 발생하는 문제이다. 이를 부하 테스트를 통해 어느 정도 예측할 수 있다.
artillery를 설치하고 서버를 실행해보자.
> npm i -D artillery
...
> npm start
이제 서버가 켜진 상태에서 새로운 콘솔을 하나 더 띄운 후 다음 명령어를 입력한다.
> npx artillery quick --count 100 -n 50 http://localhost:8001
위 명령어는 http://localhost:8001에 빠르게 부하 테스트를 하는 방법이다. --count 옵션은 가상의 사용자 수를 의미하고, -n 옵션은 요청 횟수를 의미한다. 즉, 위 명령어에선 100명의 가상 사용자가 50번의 요청을 각각 보내는 것이므로, 총 5000번의 요청이 서버로 전달될 것이다. 많다고 생각할 수도 있겠지만, 실제 서비스에서 5000번의 요청은 그렇게 많은 양이 아니다. 단지 절대적인 숫자가 아닌, 하나의 요청이 얼마나 많은 작업을 하는지가 더 중요하다.
> npx artillery quick --count 100 -n 50 http://localhost:8001
...
All virtual users finished
Summary report @ 15:08:48(+0900) 2020-08-17
Scenarios launched: 100
Scenarios completed: 100
Requests completed: 5000
Mean response/sec: 120.8
Response time (msec):
min: 72.4
max: 1211.5
median: 801
p95: 949
p99: 986.7
Scenario counts:
0: 100 (100%)
Codes:
200: 5000
완료시 위와 같은 결과가 뜬다. 콘솔을 보면 가상의 사용자 100명이 생성 (Scenarios launched) 되고 완료 (Scenarios completed) 되었으며, 총 5000번의 요청이 완료 (Requests completed) 되었으며, 초당 약 120.8 번의 요청이 (Mean response/sec) 처리되었다.
Response time (msec) 을 주목해서 보면 좋은데, 최소 (min) 는 72.4ms, 최대 (max) 는 1211.5ms가 걸렸다. 중간 값 (median) 은 801ms 이며, 하위 95% 값 (p95) 은 949ms, 하위 99% 값 (p99) 은 986.7ms이다. 이 수치는 해석사기 나름이긴 하지만 보통 median과 p95의 차이가 크지 않으면 좋다. 수치의 차이가 적을 수록 대부분의 요청이 비슷한 속도로 처리되었다는 의미이기 때문이다.
Scenarios counts는 총 사용자 수를 보여주고, Codes는 HTTP 상태 코드를 나타낸다. 5000건의 요청이 모두 200 (성공) 응답 코드를 받았다. 혹시나 에러가 발생한다면 Errors 항목이 추가로 생기게 된다.
그러나, 실제 서비스를 부하 테스트할 때는 주의해야한다. 지금은 http://localhost:8001 서버에 요청을 보내고 있지만, 이 서버는 개발용 서버인 데다가 내 컴퓨터를 뜻하므로 서버가 중지된다 하더라도 문제가 없다. 그러나 실제 서비스 중인 서버에 무리하게 부하 테스트를 할 경우 실제 서비스가 중단될 수 있다. 또한, AWS나 GCP 같은 클라우드에 종량제 요금을 선택한 경우엔 과다한 요금이 청구될 수 있다.
따라서 실제 서비스에 부하 테스트를 하기보단 실제 서버와 같은 사양의 서버 (이를 보통 staging 서버라고 부른다) 를 만든 후에 그 서버에 부하 테스트를 진행하는 것이 좋다.
부하 테스트를 할 때 단순히 한 페이지에만 요청을 보내는 것이 아니라 실제 사용자의 행동을 모방하여 시나리오를 작성할 수 있다. 이 때는 JSON 또는 YAML 형식의 설정 파일을 작성해야 한다. 익숙한 JSON 형식을 사용하여 진행해보자.
{
"config": {
"target": "http://localhost:8001",
"phases": [
{
"duration": 60,
"arrivalRate": 30
}
]
},
"scenarios": [{
"flow": [
{
"get": {
"url": "/"
}
}, {
"post": {
"url": "/auth/login",
"json": {
"email": "bumsuk980119@gmail.com",
"password": "......."
}
}
}, {
"get": {
"url": "/hashtag?hashtag=hello"
}
}
]
}]
}
먼저 config 객체에서 target을 현재 서버로 잡고, phases에서 60초 동안 (duration), 매초 30명의 사용자 (arrivalRate) 를 생성하도록 했다. 즉 1800 명이 접속하는 상황이다.
이제 이 가상 사용자들이 어떠한 동작을 할지 scenarios 속성에 적는다. 먼저 첫 번째 flow로 메인 페이지 (GET /)에 접속하고, 로그인 (POST /auth/login) 을 한 후 해시태그 검색 (GET /hashtag?hashtag=hello) 을 한다. 로그인할 때 요청의 본문으로 email과 password를 JSON 형식으로 보냈다.
만약 첫 번째 flow와 다른 일련의 과정을 시뮬레이션하고 싶다면 두 번째 flow로 만들면 된다.
이제 npx artillery run 파일명 명령어로 부하 테스트를 실행해보자. 1800명의 접속자가 각각 네 번의 요청을 보내기 때문에 (한 번의 redirect를 포함) 총 7200번의 요청이 전송될 것이다. 각각의 요청이 모두 데이터베이스에 최소 한 번씩 접근하기 때문에 상당히 무리가 간다.
...
All virtual users finished
Summary report @ 15:35:57(+0900) 2020-08-17
Scenarios launched: 1800
Scenarios completed: 1800
Requests completed: 7200
Mean response/sec: 45.24
Response time (msec):
min: 85.5
max: 55583.7
median: 20416.7
p95: 42460.9
p99: 50889.5
Scenario counts:
0: 1800 (100%)
Codes:
200: 3600
302: 1800
404: 1800
404는 해쉬태그 검색에서 hello 검색을 했을 때 아무 결과도 뜨지 않기 때문에 난 것이고, 나머지는 예상 가능한 숫자가 나왔다 그런데 보면 min이 85.5ms더라도 max가 55583.7ms, 즉 55초가 나온 것을 볼 수 있다. 일단 median 값이 20.4초이며, 최대 55.6초, p95는 42.5초, p99는 50.9초가 나왔다. 즉, 어떠한 시나리오는 네 개 요청을 처리하는 데 49초나 걸렸다는 것이다.
또한 중간 로그를 살펴보면 테스트를 진행할수록 요청을 처리하는 속도가 점점 느려짐을 알 수 있다. 이는 서버가 지금 부하 테스트를 하는 정도의 요청을 감당하지 못한다는 뜻이다. 따라서 이 문제를 해결할 방법을 고민해봐야 한다. 서버의 사양을 업그레이드하거나, 서버를 여러 개 두거나, 코드를 더 효율적으로 개선하는 방법이 있다. 지금 상황에선 노드가 싱글 코어만 사용하고 있기 때문에 클러스터링 같은 기법을 통해 서버를 여러 개 실행하는 것을 우선적으로 시도해볼 만하다.
일반적으론 요청-응답 시 데이터베이스에 접근할 때 가장 많은 시간이 소요된다. 서버는 여러대로 늘리기 쉽지만 데이터베이스는 늘리기 어려우므로 하나의 데이터베이스에 많은 요청이 몰리곤 한다. 따라서 최대한 데이터베이스에 접근하는 요청을 줄이면 좋다. 반복적으로 가져오는 데이터는 캐싱을 한다던지 하여 데이터베이스에 접근하는 일을 최대한 줄여주도록 한다.
서버의 성능과 네트워크 상황에 따라 arrivalRate를 줄이거나 늘려 자신의 서버가 어느 정도의 요청을 수용할 수 있을지 테스트 해보는 것이 좋다. 또한 한 번만 테스트하는 것이 아니라 여러 번 같은 설정값으로 테스트하여 평균치를 내보는 것이 좋다.
마치며
위에서 사용한 테스트 기법 이외에도 여러 기법들이 있다. 대표적으로 시스템 테스트와 인수 테스트가 있다. 회사에서 QA들이 테스트 목록을 두고 체크해나가며 진행하는 테스트가 주로 시스템 테스트이고, 알파 테스트와 베타 테스트와 같이 특정 사용자 집단이 실제 서비스를 사용하는 것처럼 진행하는 테스트가 인수 테스트이다. 가능한 한 다양한 종류의 테스트를 주기적으로 수행해 보며 서비스를 안정적으로 유지 점검하는 것이 좋다.
출처
Node.js 교과서 개정 2판 - 길벗, 조현영
'Node.js' 카테고리의 다른 글
Node.js - Socket.IO (0) | 2020.08.20 |
---|---|
Node.js - ws로 웹 소켓 사용해보기 (Web Socket Using ws Module) (0) | 2020.08.20 |
Node.js - CORS (Cross-Origin Resource Sharing) (0) | 2020.08.16 |
Node.js - API 사용량 제한 구현, express-rate-limit (API Limit) (0) | 2020.08.16 |
Node.js - API 서버를 위한 도메인 등록 (Domain for API Server) (0) | 2020.08.16 |