티스토리 뷰

728x90
SMALL

NodeBird 서비스에 테스팅을 적용해보겠다. 실제 서비스를 개발 완료한 후, 개발자나 QA들은 자신이 만든 서비스가 제대로 동작하는지 테스트해본다. 이번 장에서는 여러 가지 테스트 기법 중 유닛 테스트, 통합 테스트, 부하 테스트, 테스트 커버리지 체크를 살펴본다.

 

테스트에 사용할 패키지는 jest이다. 

npm i -D jest

package.json

  "scripts": {
    "start": "nodemon app",
    "test": "jest"
  },

 

테스트용 파일은 파일명과 확장자 사이에 test나 spec을 넣으면 된다. => npm test

 

middleware.test.js

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

 

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

두번째 인수인 함수에는 테스트 내용. expect 함수의 인수로 실제 코드, toEqual 함수의 인수로 예상되는 결괏값

유닛 테스트

middleware.test.js

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

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);
  });
});

테스트의 역할을 코드나 함수가 제대로 실행되는지를 검사하고 값이 일치하는지를 거사하는 것이므로, 테스트 코드의 객체가 실제 익스프레스 객체가 아니어도 된다. 이렇게 가짜 객체, 가짜 함수를 넣는 행위를 모킹이라고 한다.

모킹할 때는 jest.fn 메서드를 사용한다. 함수의 반환값을 지정하고 싶다면 jest.fn(()=>반환값) 사용

toBeCalledTimes(숫자)는 정확하게 몇 번 호출되었는지 체크하는 메서드

toBeCalledWith(인수)는 특정 인수와 함께 호출되었는지를 체크하는 메서드

 

이렇게 작은 단위의 함수나 모듈이 의도된 대로 정확히 작동하는지 테스트하는 것을 유닛 테스트 또는 단위 테스트라고 부른다.

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);
  });
});

 

데이터베이스 모델을 불러올 수 없다. 그래서 테스트에서는 모델도 모킹해야 한다.

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 () => {
    User.findOne.mockReturnValue(Promise.reject(error));
    const error = '테스트용 에러';
    await addFollowing(req, res, next);
    expect(next).toBeCalledWith(error);
  });
});

jest.mock 메서드에 모킹할 모듈의 경로를 인수로 넣고, 그 모듈을 불러온다. jest.mock에서 모킹할 메서드에 mockReturnValue라는 메서드를 넣는다. 이 메서드로 가짜 반환값을 지정할 수 있다.

이렇게 테스트를 해도 실제 서비스의 실제 데이터베이스에서는 문제가 발생할 수도 있다.

테스트 커버리지

전체 코드 중에서 테스트되고 있는 코드의 비율과 테스트되고 있지 않은 코드의 위치를 알려주는 jest의 기능이 있다. 바로 커버리지(coverage) 기능이다. 커버리지 기능을 사용하기 위해 pacakage.json에 설정

  "scripts": {
    "start": "nodemon app",
    "test": "jest",
    "coverage":"jest --coverage"
  },

=> npm run coverage

 

File(파일과 폴더 이름), %Stmts(구문 비율), %Branch(if문 등의 분기점 비율), %Funcs(함수 비율), %Lines(코드 줄 수 비율), Uncovered Line #s(커버되지 않은 줄 위치)

퍼센티지가 높을수록 많은 코드가 테스트되었다.

 

이거봐. 100프로가 아니네. user부분이

-----------------|---------|----------|---------|---------|-------------------
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-50
 routes          |     100 |      100 |     100 |     100 | 
  middlewares.js |     100 |      100 |     100 |     100 | 
-----------------|---------|----------|---------|---------|-------------------

 

models/user.test.js

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

 

npm run coverage 하면 100으로 바뀜

통합 테스트

npm i -D supertest

 

app을 export

server.js

const app = require('./app');

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

 

config.json에 test모드 만들어. 단, development모드랑 db이름 다르게

npx sequelize db:create --env test   : nodebird_test 디비 생성

routes/auth.test.js

const request = require('supertest');
const { sequelize } = require('../models');
const app = require('../app');
const { describe } = require('../models/user');

beforeAll(async () => {
  await sequelize.sync();
});

describe('POST /login', () => {
  test('로그인 수행', async (done) => {
    request(app)
      .post('/auth/login')
      .send({
        email: '123@123',
        password: '123',
      })
      .expect('Location', '/')
      .expect(302, done);
  });
});

 

1. beforeAll은 현재 테스트를 실행하기 전에 수행되는 코드다. 여기에 sequelize.sync()를 넣어 데이터베이스에 테이블을 생성하고 있다. 비슷한 함수로 afterAll(모든 테스트가 끝난 후), beforeEach(각각의 테스트 수행 전), afterEach(각각의 테스트 수행 후)가 있다.

2. supertest 패키지로부터 request 함수를 불러와서 app 객체를 인수로 넣는다. 여기에 get, post, put, patch, delete 등의 메서드로 원하는 라우터에 요청을 보낼 수 있다. 그 후 expect로 일치하는지 테스트한다.

supertest를 사용하면 app.listen을 수행하지 않고도 서버 라우터를 실행할 수 있다.

 

npm test하면 테스트용 db에 정보가 없기때문에 실패

다시

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 /login', () => {
  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('가입되지 않은 회원', async (done) => {
    const message = encodeURIComponent('가입되지 않은 회원입니다.');
    request(app)
      .post('/auth/login')
      .send({
        email: 'zerohch1@gmail.com',
        password: 'nodejsbook',
      })
      .expect('Location', `/?loginError=${message}`)
      .expect(302, done);
  });

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

  test('비밀번호 틀림', async (done) => {
    const message = encodeURIComponent('비밀번호가 일치하지 않습니다.');
    request(app)
      .post('/auth/login')
      .send({
        email: 'zerohch0@gmail.com',
        password: 'wrong',
      })
      .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: 'zerohch0@gmail.com',
        password: 'nodejsbook',
      })
      .end(done);
  });

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

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

 

부하 테스트

npm i -D artillery

npx artillery quick --count 100 -n 50 http://localhost:8001

Started phase 0, duration: 2s @ 23:40:32(+0900) 2021-03-03
Report @ 23:40:42(+0900) 2021-03-03
Elapsed time: 10 seconds
  Scenarios launched:  100
  Scenarios completed: 11
  Requests completed:  4082
  Mean response/sec: 419.2
  Response time (msec):
    min: 13.8
    max: 409.5
    median: 216.4
    p95: 305.1
    p99: 349.3
  Codes:
    200: 4082

Report @ 23:40:43(+0900) 2021-03-03
Elapsed time: 12 seconds
  Scenarios launched:  0
  Scenarios completed: 89
  Requests completed:  918
  Mean response/sec: 521.38
  Response time (msec):
    min: 13.6
    max: 210.4
    median: 117.6
    p95: 182.3
    p99: 197.7
  Codes:
    200: 918

All virtual users finished
Summary report @ 23:40:43(+0900) 2021-03-03
  Scenarios launched:  100
  Scenarios completed: 100
  Requests completed:  5000
  Mean response/sec: 429.18
  Response time (msec):
    min: 13.6
    max: 409.5
    median: 203.6
    p95: 295.6
    p99: 339.6
  Scenario counts:
    0: 100 (100%)
  Codes:
    200: 5000

가상의 사용자 100명이 생성(Scenarios launched)

요청이 완료(Scenarios completed)

요청 5000번 수행(Request completed)

초당 474.38번의 요청이 처리(Mean response/sec)

Response time(응답 지연 속도) : 보통 median과 p95의 차이가 크지 않으면 좋다.

Scenarios counts는 총 사용자의 수를 보여주고, Codes는 HTTP 상태 코드를 나타낸다. 5000건의 요청 모두 200(성공) 응답 코드를 받았다. 혹시나 에러가 발생한다면 Errors 항목이 추가로 생긴다.

 

note

실제 서비스 중인 서버(AWS, GCP)에 무리하게 부하 테스트를 하면 과다한 요금 청구될 수 있어. 따라서 실제 서버와 같은 사양의 서버(보통 staging 서버라고 불림)를 만든 후, 그 서버에 부하 테스트를 진행하는 것이 좋다.

 

부하 테스트를 할 때 단순히 한 페이지에만 요청을 보내는 것이 아니라 실제 사용자의 행동을 모방하여 시나리오를 작성할 수 있다.

loadtest.json

{
  "config": {
    "target": "http://localhost:8001",
    "phases": [
      {
        "duration": 60,
        "arrivalRate": 30
      }
    ]
  },
  "scenarios": [
    {
      "flow": [
        {
          "get": {
            "url": "/"
          }
        },
        {
          "post": {
            "url": "/auth/login",
            "json": {
              "email": "zerohch0@gamil.com",
              "password": "nodejsbook"
            }
          }
        },
        {
          "get": {
            "url": "/hashtag?hashtag=nodebird"
          }
        }
      ]
    }
  ]
}

config 객체에서 target을 현재 서버로 잡고, phases에서 60초 동안(duration), 매초 30명의 사용자(arrivalRate)를 생성하도록 했다. 즉, 1800명이 접속하는 상황이다.

이제 이 가상 사용자들이 어떠한 동작을 할지 scenarios 속성에 적는다. 위에서는 flow가 하나지만 첫번째 flow와는 다른 일련의 과정을 시뮬레이션하고 싶다면 두번째 flow로 만들면 된다.

npx artillery run loadtest.json

Started phase 0, duration: 60s @ 00:09:41(+0900) 2021-03-04
Report @ 00:09:51(+0900) 2021-03-04
Elapsed time: 10 seconds
  Scenarios launched:  299
  Scenarios completed: 299
  Requests completed:  1196
  Mean response/sec: 120.69
  Response time (msec):
    min: 1.6
    max: 49.7
    median: 3
    p95: 7.3
    p99: 22.1
  Codes:
    200: 897
    302: 299

Report @ 00:10:01(+0900) 2021-03-04
Elapsed time: 20 seconds
  Scenarios launched:  300
  Scenarios completed: 300
  Requests completed:  1200
  Mean response/sec: 120.36
  Response time (msec):
    min: 1.5
    max: 16.6
    median: 3.2
    p95: 6.9
    p99: 9.4
  Codes:
    200: 900
    302: 300

Warning: 
CPU usage of Artillery seems to be very high (pids: 16340)
which may severely affect its performance.
See https://artillery.io/docs/faq/#high-cpu-warnings for details.

Report @ 00:10:11(+0900) 2021-03-04
Elapsed time: 30 seconds
  Scenarios launched:  300
  Scenarios completed: 300
  Requests completed:  1200
  Mean response/sec: 120.12
  Response time (msec):
    min: 1.6
    max: 19
    median: 3.2
    p95: 6.8
    p99: 8.9
  Codes:
    200: 900
    302: 300

Report @ 00:10:21(+0900) 2021-03-04
Elapsed time: 40 seconds
  Scenarios launched:  300
  Scenarios completed: 300
  Requests completed:  1200
  Mean response/sec: 120.12
  Response time (msec):
    min: 1.4
    max: 21.5
    median: 2.4
    p95: 4.8
    p99: 6.7
  Codes:
    200: 900
    302: 300

Warning: High CPU usage warning (pids: 16340).
See https://artillery.io/docs/faq/#high-cpu-warnings for details.

Report @ 00:10:31(+0900) 2021-03-04
Elapsed time: 50 seconds
  Scenarios launched:  300
  Scenarios completed: 300
  Requests completed:  1200
  Mean response/sec: 120.12
  Response time (msec):
    min: 1.3
    max: 22
    median: 2.8
    p95: 6.6
    p99: 9.5
  Codes:
    200: 900
    302: 300

Report @ 00:10:41(+0900) 2021-03-04
Elapsed time: 1 minute, 0 seconds
  Scenarios launched:  300
  Scenarios completed: 300
  Requests completed:  1200
  Mean response/sec: 120.12
  Response time (msec):
    min: 1.3
    max: 92.1
    median: 3.1
    p95: 9.1
    p99: 22.4
  Codes:
    200: 900
    302: 300

Report @ 00:10:41(+0900) 2021-03-04
Elapsed time: 1 minute, 0 seconds
  Scenarios launched:  1
  Scenarios completed: 1
  Requests completed:  4
  Mean response/sec: 8
  Response time (msec):
    min: 2
    max: 23.6
    median: 6.7
    p95: 23.6
    p99: 23.6
  Codes:
    200: 3
    302: 1

All virtual users finished
Summary report @ 00:10:41(+0900) 2021-03-04
  Scenarios launched:  1800
  Scenarios completed: 1800
  Requests completed:  7200
  Mean response/sec: 118.93
  Response time (msec):
    min: 1.3
    max: 92.1
    median: 3
    p95: 7
    p99: 14.9
  Scenario counts:
    0: 1800 (100%)
  Codes:
    200: 5400
    302: 1800

 

median과 p95 p99 차이가 크다. 중간에 warning도 있어

이를 해결하기 위해서 서버의 사양을 업그레이드하거나, 서버를 여러개 두거나, 코드를 더 효율적으로 개선하는 방법이 있다. 지금 상황에서는 노드가 싱글 코어만 사용하고 있으므로 클러스터링 같은 기법을 통해 서버를 여러 개 실행하는 것을 우선적으로 시도해볼만하다.

일반적으로 디비에 접근할 때 가장 많은 시간이 소요된다. 서버는 늘리기 쉽지만 데이터베이스는 늘리기 어려우므로 최대한 데이터베이스에 접근하는 요청을 줄이면 좋다. 반복적으로 가져오는 데이터는 캐싱을 한다든지 하여 데이터베이스에 접근하는 일을 줄이도록 하자.

서버의 성능과 네트워크 상황에 따라 다르지만 arrivalRate를 줄이거나 늘려서 자신의 서버가 어느 정도의 요청을 수용할 수 있을지 체크해보는 것이 좋다. 또한 같은 설정으로 테스트하여 평균치를 내보는게 좋다.

 

테스트하기 어려운 패키지는 모킹하고, 테스트할 수 있는 패키지는 그대로 테스트한다. 단, 모킹할 때는 실제 상황에서는 에러가 발생할 수 있음을 염두해 두자. 대표적인 테스트로는 시스템 테스트와 인수 테스트가 있다.

 

스스로 해보기

모든 함수에 대한 유닛 테스트 작성하기(테스트 커버리지 100% 도전하기)

모든 라우터에 대한 통합 테스트 작성하기

부하 테스트를 통해 병목점 및 개선점 찾기

728x90
LIST
댓글
공지사항