11. 노드 서비스 테스트하기
포스트
취소

11. 노드 서비스 테스트하기

기존 블로그에 작성했던 포스트를 이전한 글입니다.

해당 포스트는 NODEJS를 학습하며 정리한 내용에 대한 포스트입니다.




🌈 노드 서비스 테스트하기

💻 테스트 준비하기

🍳 테스트를 하는 이유

자신이 만든 서비스가 제대로 동작하는지 테스트해야 함

  • 기능이 많다면 수작업으로 테스트하기 힘듦

  • 프로그램이 프로그램을 테스트할 수 있도록 자동화함

  • 테스트 환경을 최대한 실제 환경과 비슷하게 흉내냄

  • 아무리 철저하게 테스트해도 에러를 완전히 막을 수는 없음

테스트를 하면 좋은 점

  • 허무한 에러로 인해 프로그램이 고장나는 것은 막을 수 있음

  • 한 번 발생한 에러는 테스트로 만들어두면 같은 에러가 발생하지 않게 막을 수 있음

  • 코드를 수정할 때 프로그램이 자동으로 어떤 부분이 고장나는 지 알려줌


🍳 Jest 설치하기

1
npm i –D jest

🔻 pakage.json

1
2
3
4
5
6
7
8
9
10
11
...
{
  "name": "nodebird",
  "version": "0.0.1",
  "description": "익스프레스로 만드는 SNS 서비스",
  "main": "app.js",
  "scripts": {
    "start": "nodemon app",
    "test": "jest"  // <- 이 부분을 작성해줌
  },
  ...

Nodebird 프로젝트를 그대로 사용함


🍳 테스트 실행해보기

1
npm test

테스트용 파일은 파일명에 test나 spec이 있으면 됨

npm test로 test나 spec 파일들을 테스트함.

아무런 테스트를 작성하지 않고 실행해 봄

image

routes 폴더 안에 middlewares.test.js 생성

테스트를 아무것도 작성하지 않았으므로 에러 발생(테스트 실패)

image


🍳 첫 테스트 코드 작성하기

middlewares.test.js 작성하기

test 함수의 첫 번째 인수로 테스트에 대한 설명

두 번째 인수인 함수에는 테스트 내용을 적음

expect 함수의 인수로 실제 코드를, toEqual 함수의 인수로는 예상되는 결괏값을

expect와 toEqual의 인수가 일치하면 테스트 통과

🔻 middlewares/index.test.js

1
2
3
test('1 + 1 은 2 입니다.', () => {
  expect(1 + 1).toEqual(2);
});

image


🍳 실패하는 경우

두 인수를 다르게 작성하면 실패(메시지를 살펴볼 것)

🔻 middlewares/index.test.js

1
2
3
test('1 + 1 은 2 입니다.', () => {
  expect(1 + 1).toEqual(3);
});

image




💻 유닛(단위) 테스트

🍳 middlewares 테스트하기

toBeCalled로 함수 호출 테스트

toBeCalledTimes로 몇번 호출할지 정할 수 있음

toBeCalledWith로 함수의 호출여부와 파라미터를 테스트할 수 있다.

🔻 middlewares/index.test.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
const { isLoggedIn, isNotLoggedIn } = require('./');

//그룹화 -> describe
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('로그인 필요');
  });
});

describe('isNotLoggedIn', () => {
  const res = {
    redirect: jest.fn(),
  };
  const next = jest.fn();

  test('로그인 되어있으면 isNotLoggedIn이 에러를 응답해야 함', () => {
    const req = {
      isAuthenticated: jest.fn(() => true),
    };
    isNotLoggedIn(req, res, next);
    const message = encodeURIComponent('로그인한 상태입니다.');
    expect(res.redirect).toBeCalledWith(`/?error=${message}`);
  });

  test('로그인 되어있지 않으면 isNotLoggedIn이 next를 호출해야 함', () => {
    const req = {
      isAuthenticated: jest.fn(() => false),
    };
    isNotLoggedIn(req, res, next);
    expect(next).toHaveBeenCalledTimes(1);
  });
});


🍳 컨트롤러 테스트

🔻 controllers/user.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const User = require('../models/user');

exports.follow = async (req, res, next) => {
  try {
    const user = await User.findOne({ where: { id: req.user.id } });
    if (user) { // req.user.id가 followerId, req.params.id가 followingId
      await user.addFollowing(parseInt(req.params.id, 10));
      res.send('success');
    } else {
      res.status(404).send('no user');
    }
  } catch (error) {
    console.error(error);
    next(error);
  }
};

follow 함수는 async 함수이므로 await를 붙여야 함수가 전부 실행 완료된 후 expect 함수가 실행된다.

🔻 controllers/user.test.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const { follow } = require('./user');

describe('follow', () => {
  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 follow(req, res, next);
    expect(res.send).toBeCalledWith('success');
  });

  test('사용자를 못 찾으면 res.status(404).send(no user)를 호출함', async () => {
    await follow(req, res, next);
    expect(res.status).toBeCalledWith(404);
    expect(res.send).toBeCalledWith('no user');
  });

  test('DB에서 에러가 발생하면 next(error)를 호출함', async () => {
    await follow(req, res, next);
    expect(next).toBeCalledWith(message);
  });
});

이 테스트 코드는 실패함

image

그 이유는 바로 User 모델 떄문임

follow 컨트롤러 안에는 User라는 모델이 들어있음

근데 이 모델은 실제 데이터베이스와 연결되어 있으므로 테스트 환경에서는 사용할 수 없음

따라서 모킹을 사용해야함

🔻 controllers/user.test.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
jest.mock('../models/user');
const User = require('../models/user');
const { follow } = require('./user');

describe('follow', () => {
  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({
      addFollowing(id) {
        return Promise.resolve(true);
      }
    });
    await follow(req, res, next);
    expect(res.send).toBeCalledWith('success');
  });

  test('사용자를 못 찾으면 res.status(404).send(no user)를 호출함', async () => {
    User.findOne.mockReturnValue(null);

    await follow(req, res, next);
    expect(res.status).toBeCalledWith(404);
    expect(res.send).toBeCalledWith('no user');
  });

  test('DB에서 에러가 발생하면 next(error)를 호출함', async () => {
    const message = 'DB에러';
    User.findOne.mockReturnValue(Promise.reject(message));
    await follow(req, res, next);
    expect(next).toBeCalledWith(message);
  });
});

image


🍳 서비스로 분리하기

🔻 services/user.js

1
2
3
4
5
6
7
8
9
10
11
const User = require('../models/user');

exports.follow = async (userId, followingId) => {
  const user = await User.findOne({ where: { id: userId } });
  if (user) {
    await user.addFollowing(parseInt(followingId, 10));
    return 'ok';
  } else {
    return 'no user';
  }
};

🔻 controllers/user.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const User = require('../models/user');

exports.follow = async (req, res, next) => {
  try {
    const user = await User.findOne({ where: { id: req.user.id } });
    if (user) { // req.user.id가 followerId, req.params.id가 followingId
      await user.addFollowing(parseInt(req.params.id, 10));
      res.send('success');
    } else {
      res.status(404).send('no user');
    }
  } catch (error) {
    console.error(error);
    next(error);
  }
};

🔻 services/user.test.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
jest.mock('../models/user');
const User = require('../models/user');
const { follow } = require('./user');

describe('follow', () => {
  test('사용자를 찾아 팔로잉을 추가하고 ok를 반환함', async () => {
    User.findOne.mockReturnValue({
      addFollowing(id) {
        return Promise.resolve(true);
      }
    });
    const result = await follow(1, 2);
    expect(result).toEqual('ok');
  });

  test('사용자를 못 찾으면 no user를 반환함', async () => {
    User.findOne.mockReturnValue(null);
    const result = await follow(1, 2);
    expect(result).toEqual('no user');
  });

  test('DB에서 에러가 발생하면 Throw', async () => {
    const message = 'DB에러';
    User.findOne.mockReturnValue(Promise.reject(message));
    try {
      await follow(1, 2);
    } catch (err) {
      expect(err).toEqual(message);
    }
  });
});

🔻 controllers/user.test.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
jest.mock('../services/user');
const { follow } = require('./user');
const { follow: followService } = require('../services/user');

describe('follow', () => {
  const req = {
    user: { id: 1 },
    params: { id: 2 }
  };

  const res = {
    status: jest.fn(() => res),
    send: jest.fn()
  };

  const next = jest.fn();

  test('사용자를 찾아 팔로잉을 추가하고 success를 응답해야함', async () => {
    followService.mockReturnValue('ok');
    await follow(req, res, next);
    expect(res.send).toBeCalledWith('success');
  });

  test('사용자를 못 찾으면 res.status(404).send(no user)를 호출함', async () => {
    followService.mockReturnValue(null);
    await follow(req, res, next);
    expect(res.status).toBeCalledWith(404);
    expect(res.send).toBeCalledWith('no user');
  });

  test('DB에서 에러가 발생하면 next(error)를 호출함', async () => {
    const message = 'DB에러';
    followService.mockReturnValue(Promise.reject(message));
    await follow(req, res, next);
    expect(next).toBeCalledWith(message);
  });
});




💻 테스트 커버리지

🍳 테스트 커버리지란

전체 코드 중에서 테스트되고 있는 코드의 비율

테스트되지 않는 코드의 위치도 알려줌

  • “coverage”: “jest –coverage”
  • Stmts: 구문
  • Branch: 분기점
  • Funcs: 함수
  • Lines: 줄 수

🔻 pakage.json

1
2
3
4
5
6
7
8
9
10
{
  "name": "nodebird",
  "version": "0.0.1",
  "description": "익스프레스로 만드는 SNS 서비스",
  "main": "app.js",
  "scripts": {
    "start": "nodemon app",
    "test": "jest",
    "coverage": "jest --coverage"
  },

image


🍳 테스트 커버리지 올리기

models/users.test.js 작성

테스트 커버리지를 상승시킬 뿐 의미는 없는 테스트

🔻models/users.test.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const Sequelize = require('sequelize');
const User = require('./user');
const config = require('../config/config')['test'];
const sequelize = new Sequelize(
  config.database, config.username, config.password, config,
);

describe('User 모델', () => {
  test('static init 메서드 호출', () => {
    expect(User.initiate(sequelize)).toBe(undefined);
  });
  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);
  });
});

image

파일명에 test 있는거 제외시킴

🔻 models/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const Sequelize = require('sequelize');
const fs = require('fs');
const path = require('path');
const env = process.env.NODE_ENV || 'development';
const config = require('../config/config')[env];

const db = {};
const sequelize = new Sequelize(
  config.database, config.username, config.password, config,
);

db.sequelize = sequelize;

const basename = path.basename(__filename);
fs
  .readdirSync(__dirname) // 현재 폴더의 모든 파일을 조회
  .filter(file => { // 숨김 파일, index.js, js 확장자가 아닌 파일 필터링
    return (file.indexOf('.') !== 0) && !file.includes('test') && (file !== basename) && (file.slice(-3) === '.js');
  })
  .forEach(file => { // 해당 파일의 모델 불러와서 init
    const model = require(path.join(__dirname, file));
    console.log(file, model.name);
    db[model.name] = model;
    model.initiate(sequelize);
  });

Object.keys(db).forEach(modelName => { // associate 호출
  if (db[modelName].associate) {
    db[modelName].associate(db);
  }
});

module.exports = db;


🍳 테스트 커버리지 주의점

모든 코드가 테스트되지 않는데도 커버리지가 100%임

  • 테스트 커버리지를 맹신할 필요가 없음

  • 커버리지를 높이는 것이 의미는 있지만 높이는 데 너무 집착할 필요는 없음

  • 필요한 부분 위주로 올바르게 테스트하는 것이 좋음




💻 통합 테스트

🍳 통합 테스트 해보기

라우터 하나를 통째로 테스트해 봄(여러 개의 미들웨어, 모듈을 한 번에 테스트).

app.js 분리하기 Supertest 사용

1
npm i -D supertest

app.js에서 listen 부분을 분리해야함

🔻 app.js

1
2
3
4
5
6
7
module.exports = app;

// app.listen(app.get('port'), () => {
//   console.log(app.get('port'), '번 포트에서 대기중');
// });

🔻 server.js

1
2
3
4
5
const app = require('./app');

app.listen(app.get('port'), () => {
  console.log(app.get('port'), '번 포트에서 대기중');
});

🔻 pakage.json

1
2
3
4
"name": "nodebird",
  "version": "0.0.1",
  "description": "익스프레스로 만드는 SNS 서비스",
  "main": "server.js",


🍳 테스트용 DB 설정하기

개발/배포용 DB랑 별도로 설정하는 것이 좋음

  • config/config.json의 test 속성

🔻config/config.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
  "development": {
    "username": "root",
    "password": "nodejsbook",
    "database": "nodebird",
    "host": "127.0.0.1",
    "dialect": "mysql"
  },
  "test": {
    "username": "root",
    "password": "nodejsbook",
    "database": "nodebird_test",
    "host": "127.0.0.1",
    "dialect": "mysql"
  },
  "production": {
    "username": "root",
    "password": null,
    "database": "database_production",
    "host": "127.0.0.1",
    "dialect": "mysql"
  }
}
1
npx sequelize db:create --env test

image


🍳 라우터 테스트

routes/auth.test.js 작성

  • beforeAll: 모든 테스트 전에 딱 한번 실행

  • afterAll : 모든 테스트가 끝나고 나서 한번만 호출

  • beforEach와 afterEach는 테스트를 실행할 때마다 동작한다.
    • beforEach는 테스트 전에 동작하고,
    • afterEach는 테스트 후에 동작한다.
  • request(app).post(주소)로 요청

  • send로 data 전송

  • done 호출에 주의

  • agent 변수로 선언해서 일치시킴 ` const agent = request.agent(app);`

🔻routes/auth.test.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
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: 'zerohch0@gmail.com',
        nick: 'zerocho',
        password: 'nodejsbook',
      })
      .expect('Location', '/')
      .expect(302, done);
  });
});

describe('POST /join', () => {
  const agent = request.agent(app);
  beforeEach((done) => {
    agent
      .post('/auth/login')
      .send({
        email: 'zerohch0@gmail.com',
        password: 'nodejsbook',
      })
      .end(done);
  });

  test('이미 로그인했으면 redirect /', (done) => {
    const message = encodeURIComponent('로그인한 상태입니다.');
    agent
      .post('/auth/join')
      .send({
        email: 'zerohch0@gmail.com',
        nick: 'zerocho',
        password: 'nodejsbook',
      })
      .expect('Location', `/?error=${message}`)
      .expect(302, done);
  });
});

describe('POST /login', () => {
  test('가입되지 않은 회원', (done) => {
    const message = encodeURIComponent('가입되지 않은 회원입니다.');
    request(app)
      .post('/auth/login')
      .send({
        email: 'zerohch1@gmail.com',
        password: 'nodejsbook',
      })
      .expect('Location', `/?error=${message}`)
      .expect(302, done);
  });

  test('로그인 수행', (done) => {
    request(app)
      .post('/auth/login')
      .send({
        email: 'zerohch0@gmail.com',
        password: 'nodejsbook',
      })
      .expect('Location', '/')
      .expect(302, done);
  });

  test('비밀번호 틀림', (done) => {
    const message = encodeURIComponent('비밀번호가 일치하지 않습니다.');
    request(app)
      .post('/auth/login')
      .send({
        email: 'zerohch0@gmail.com',
        password: 'wrong',
      })
      .expect('Location', `/?error=${message}`)
      .expect(302, done);
  });
});

describe('GET /logout', () => {
  test('로그인 되어있지 않으면 403', (done) => {
    request(app)
      .get('/auth/logout')
      .expect(403, done);
  });

  const agent = request.agent(app);
  beforeEach((done) => {
    agent
      .post('/auth/login')
      .send({
        email: 'zerohch0@gmail.com',
        password: 'nodejsbook',
      })
      .end(done);
  });

  test('로그아웃 수행', (done) => {
    agent
      .get('/auth/logout')
      .expect('Location', `/`)
      .expect(302, done);
  });
});

afterAll(async () => {
  await sequelize.sync({ force: true }); 
});

afterAll을 사용해서 DB 초기화를 해줌




💻 부하 테스트

🍳 부하 테스트란

서버가 얼마만큼의 요청을 견딜 수 있는지 테스트

  • 서버가 몇 명의 동시 접속자를 수용할 수 있는지 예측하기 매우 어려움

  • 실제 서비스 중이 아니라 개발 중일 때는 더 어려움

  • 코드에 문제가 없더라도 서버 하드웨어 때문에 서비스가 중단될 수 있음(메모리 부족 문제 등)

  • 부하 테스트를 통해 미리 예측할 수 있음


🍳 Artillery 사용하기

1
npm i -D artillery

개발 환경에서만 할 것

새 콘솔에서 다음 명령어 입력

  • Count 옵션은 가상의 사용자 수
  • N 옵션은 횟수
  • 100명의 사용자가 50번씩 요청을 보내는 상황
  • 결과 보고서
  • 사용자 생성(scenarios launched)
  • 테스트 성공(scenarios completed)
  • 요청 성공 횟수(requests completed)
  • 초당 요청 처리 횟수(RPS sent)
  • 응답 지연 속도(Request latency)
  • Min: 최소, Max: 최대, median: 중앙값
  • P95: 하위 95%, P99: 하위 99%
  • 하위는 속도 순서를 말함
  • Median과 P95가 많이 차이나지 않는 게 좋음
1
2
npx artillery quick --count 100 -n 50 http://localhost:8001
# 100명이 50번씩 http://localhost:8001에 GET 요청을 보낸다.

image


🍳 여러 페이지 요청 시나리오

loadtest.json에 사용자의 행동 흐름 작성 가능

  • target: 요청 도메인
  • Phases에서 duration: 몇 초 동안(60초)
  • arrivalRate: 매 초 몇 명(30명)
  • flow: 사용자의 이동
  • get, post 등의 메서드를 나타냄
  • url은 이동한 url
  • json은 서버로 전송한 데이터
  • 현재 GET /, POST /auth/login, GET /hashtag 순

🔻loadtest.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
{
  "config": {
    "target": "http://localhost:8001",
    "http": {
      "timeout": 30
    },
    "phases": [
      {
        "duration": 30,
        "arrivalRate": 20
      }
    ]
  },
  "scenarios": [
    {
      "flow": [
        {
          "get": {
            "url": "/"
          }
        },
        {
          "post": {
            "url": "/auth/login",
            "json": {
              "email": "abc2752@naver.com",
              "password": "!leekoby11"
            },
            "followRedirect": false
          }
        },
        {
          "get": {
            "url": "/hashtag?hashtag=nodebird"
          }
        }
      ]
    }
  ]
}


🍳 여러 페이지 요청 시나리오(실행)

문제점 발견

  • 요청 후반부가 될 수록 응답 시간이 길어짐

  • 첫 응답은 4.7밀리초, 마지막 응답은 51초

  • 5400개의 요청은 200 응답코드, 1800개는 302

  • 서버가 지금 정도의 요청을 감당하지 못함

  • 서버 사양을 업그레이드하거나, 여러 개 두거나

  • 코드를 더 효율적으로 개선하는 방법 등.

  • 현재는 싱글코어만 사용하므로, 클러스터링 기법 도입을 시도해볼만 함

  • arrivalRate를 줄이거나 늘려서 어느 정도 수용 가능한지 체크해보는 것이 좋음

  • 여러 번 테스트하여 평균치를 내보는 게 좋음

image


🍳 테스트 범위

어떤 것을 테스트하고 어떤 것을 테스트 안 할 지 고민됨.

  • 자신이 짠 코드는 최대한 많이 테스트하기

  • npm을 통해 설치한 패키지는 테스트하지 않음(그걸 만든 사람의 몫임)

  • 우리는 그 패키지/라이브러리를 사용하는 부분만 테스트

  • 테스트하기 어려운 패키지는 모킹

  • 모킹해서 통과하더라도 실제 상황에서는 에러날 수 있음을 염두에 두어야 함

  • 시스템 테스트: QA처럼 테스트 목록을 두고 체크해나가면서 진행하는 테스트

  • 인수 테스트: 알파 테스트/베타 테스트처럼 특정 사용자 집단이 실제로 테스트

  • 다양한 종류의 테스트를 주기적으로 수행해 서비스를 안정적으로 유지하는 게 좋음




💻 함께 보면 좋은 자료

노드의 test 모듈

노드의 assert 모듈

jest 공식문서

artillery 공식문서

datadog 공식문서

newrelic 공식문서




📚 레퍼런스

조현영. Node.js 교과서 = Node.js Textbook / 조현영 지음 (2022). Print.

[리뉴얼] Node.js 교과서 - 기본부터 프로젝트 실습까지

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.