본문 바로가기

BackEnd/Node

[노드교과서] 섹션 10. 11장 테스트 해보기(단위, 통합, 부하)

테스트 준비하기

 ⇒ 테스트에 필요한 패키지는 jest이다.  이 패키지는 페이스북에서 만든 오픈 소스로

    테스팅에 필요한 툴들을 갖추고 있어 편리하다.

  -  테스트 파일이 될 대상은 파일명에 index.spec.js, index.test.js 가 들어가야 한다.

 

  - npm 개발자모드로 설치

$ npm i -D jest

 

  - pakage.json에 실행 스크립트 수정

// package.json
{
	...
    "scripts" : {
    	"start" : "nodemon app",
        "test" : "ject",
    },
    ...
}

 

 

  - test 실행하기

$ npm test

11.2 단위(유닛) 테스트 해보기

 ⇒ 테스트 틀을 잡고 describe로 테스트를 그룹화하여 관리하자.

 

  - middlewares 단위 테스트

// middlewares/index.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}`);
    }
}

  ⇒ exprect 메서드

   - toEqual : 앞의 인수와 맞는지 체크

   - toBeCalledWith로 인수 체크

   - toBeCalledTimes로 호출 회수 체

// middlewares/index.test.js

const { isLoggedIn, isNotLoggedIn} = require('./index');
// 단위(유닛) 테스트
// test('1 + 1은 2 입니다.', () => {
//     // 코드     -> 예상결과
//     expect(1 + 1).toEqual(2);
// });

// describe는 비슷한 그룹끼리 묶어줄 수 있다.
describe(('isLoggedIn'), () => {

    test('로그인이 되어 있으면 isLoggedIn이 next를 호출해야 함.', () => {
        // 모킹이라는 개념으로 가짜 매개변수들을 만들어준다.
        const res = {
            status : jest.fn(() => res),
            send : jest.fn(),
        };
        const req = {
            //                callback함수를 선언해 true를 리턴할 수 있다.
            isAuthenticated : jest.fn(() => true),
        };

        // const next = () => {}; <-- 이런 빈 함수는 toBeCalledTimes에서 인식이 되질 않는다.
        // next는 jest가 추적하는 함수가 된다.
        const next = jest.fn();
        // isLoggedIn을 호출하여 next가 한번 호출되었는지 확인할 수 있다.
        isLoggedIn(req, res, next);
        expect(next).toBeCalledTimes(1);
    });

    test('로그인이 되어 있지 있으면 isLoggedIn이 에러를 응답해야 함.', () => {
        // 모킹이라는 개념으로 가짜 매개변수들을 만들어준다.
        const res = {
            status : jest.fn(() => res),
            send : jest.fn(),
        };
        const req = {
            isAuthenticated : jest.fn(() => false),
        };
        isLoggedIn(req, res);
        // expect는 여러건 넣을 수 있으며 전부다 통과해야 성공이다.
        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);
        const message = encodeURIComponent('로그인한 상태입니다.');
        expect(res.redirect).toBeCalledWith(`/?error=${message}`);
    });

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

 

  - controller user 단위 테스트

// controllers/user.js

const User = require('../models/user');

exports.follow = async (req, res, next) => {
    try {
        const user = await User.findOne({where : {id : req.user.id}});
        if(user) {
            // addFollowing이라는 메서드명이 어떻게 생기는건지 sequelize 학습하기
            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);
    }
};

 

// controllers/user.test.js

jest.mock('../models/user');  // User를 대체하기 위한 초기설정
const User = require('../models/user');
const { follow } = require('./user');

// 테스트는 if문이나 try~caych를 기점으로 만들어 주는 편이 좋다.
describe(('follow'), () => {

    test('사용자를 찾아 팔로잉을 추가하고 suceess를 응답해야 함', async () => {
        const res = {
            status : jest.fn(() => res),
            send : jest.fn(),
        };
        const req = {
            user : {id : 1},
            params : {id : 2},
        };
        const next = jest.fn();

        // User.findOne을 대체처리
        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 () => {
        const res = {
            status : jest.fn(() => res),
            send : jest.fn(),
        };
        const req = {
            user : {id : 1},
            params : {id : 2},
        }
        const next = jest.fn();
        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 res = {
            status : jest.fn(() => res),
            send : jest.fn(),
        };
        const req = {
            user : {id : 1},
            params : {id : 2},
        };
        const next = jest.fn();
        const message = 'DB에러';
        User.findOne.mockReturnValue(Promise.reject(message));
        await follow(req, res, next);
        expect(next).toBeCalledWith(message)
    });

});

 

 ⇒ 컨트롤러와 서비스를 분리 후 서비스 단위 테스트 수행

  - 테스트의 우선순위 : 서비스 -> 컨트롤러

// services/user.js

const User = require('../models/user');

// service는 req, res를 몰라도 된다.
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
// 기존 비지니스 로직을 제외한 컨트롤러에 대한 처리

const User = require('../models/user');
const {follow } = require('../services/user');
exports.follow = async (req, res, next) => {
    try {
        const result = await follow(req.user.id, req.params.id);
        if(result === 'ok') {
            res.send('success');
        } else if(result === 'no user') {
            res.status(404).send('no user');
        }
    } catch (error) {
        console.error(error);
        next(error);
    }
};

 

// controllers/user.test.js
// service를 분리한 controller의 req, res 테스트

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('no user');
       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);
    });

})

 

11.3 테스트 커버리지

 1. 테스트 커버리지란?

  ⇒ 테스트를 진행한 전체 코드 중 테스트되고 있는 코드의 비율을 의미한다.

     테스트를 진행한 곳의 require된 부분에 대해서도 테스트 커버리지를 확인한다.

$ jest -coverage
-------------|---------|----------|---------|---------|-------------------                                                                                                                                                                                                
File         | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s                                                                                                                                                                                                 
-------------|---------|----------|---------|---------|-------------------
All files    |    90.9 |       90 |     100 |    90.9 |                  
 controller  |     100 |       75 |     100 |     100 |                  
  user.js    |     100 |       75 |     100 |     100 | 8                
 middlewares |     100 |      100 |     100 |     100 |                  
  index.js   |     100 |      100 |     100 |     100 |                  
 models      |   66.66 |      100 |     100 |   66.66 |                  
  user.js    |   66.66 |      100 |     100 |   66.66 | 42-47            
 services    |   85.71 |      100 |     100 |   85.71 |                  
  user.js    |   85.71 |      100 |     100 |   85.71 | 7                
-------------|---------|----------|---------|---------|-------------------
Test Suites: 2 failed, 2 passed, 4 total
Tests:       2 failed, 10 passed, 12 total
Snapshots:   0 total
Time:        0.875 s, estimated 1 s

 2. 테스트 커버리지 올리기

  ⇒ models/model.js의 커버리지를 올리기 위한 무의미한 테스트 수행

// 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 initiate 메서도 호출', () => {
        // return이 없기 때문에 undefined가 나온다.
        expect(User.initiate(sequelize)).toBe(undefined);
    });

    test('static associate 메서도 호출', () => {
        // return이 없기 때문에 undefined가 나온다.
        const db = {
            User : {
                hasMany : jest.fn(),
                belongsToMany : jest.fn(),
            },
            Post: {},
        }
        User.associate(db);
        // tobe와 toHaveBeenCalledWith은 동일하다.
        expect(db.User.hasMany).toHaveBeenCalledWith(undefined);
        expect(db.User.belongsToMany).toHaveBeenCalledTimes(2);
    });
})

 

  - 모델 테스트 추가 후 커버리지 재 확인

-------------|---------|----------|---------|---------|-------------------                                                                                                                                                                                                
File         | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s                                                                                                                                                                                                 
-------------|---------|----------|---------|---------|-------------------
All files    |   96.96 |       90 |     100 |   96.96 |                  
 controller  |     100 |       75 |     100 |     100 |                  
  user.js    |     100 |       75 |     100 |     100 | 8                
 middlewares |     100 |      100 |     100 |     100 |                  
  index.js   |     100 |      100 |     100 |     100 |                  
 models      |     100 |      100 |     100 |     100 |                  
  user.js    |     100 |      100 |     100 |     100 |                  
 services    |   85.71 |      100 |     100 |   85.71 |                  
  user.js    |   85.71 |      100 |     100 |   85.71 | 7                
-------------|---------|----------|---------|---------|-------------------

11.4 통합 테스트

 1. 통합 테스트 해보기

  ⇒ Supertest를 사용하여 통합테스트를 수행한다.

$ npm i -D supertest

 

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

   - 서버를 실행하지 않고 테스트 하기 위한 app.js의 listen을 server.js로 분리한다.(package.json 설정도 변경필요)

// server.js

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

 

// package.json
{
  "main": "server.js",
  "scripts": {
    "start" "nodemon server",
    ...
}

 

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

// beforeAll : 모든 테스트가 수행되기 전에 실행
// 테스트가 수행되면 회원가입이 진행되므로 force : true속성을 통한 데이터베이스 초기화를 한다.
// 테스트 결과는 일관성이 있어야 한다.
beforeAll(async () => {
    await sequelize.sync({force : true});
})

// beforeEach : 테스트가 수행되기 전 반복적으로 실행된다.
beforeEach(() => {});

// reuqest(app).post(주소)로 요청
// send로 data를 전송
describe('POST /login', () => {

    test('로그인 안 했으면 가입', (done) => {
        request(app).post('/auth/join')
            .send({
                email : 'ykji1003@hotmail.co.kr',
                nick : '김재익',
                password : 'cpfl1318',
            })
            .expect('Location','/')
            .expect(302, done);
    })

    test('회원가입이 되어 있는데 또 가입하는 경우', (done) => {
        request(app).post('/auth/join')
            .send({
                email : 'ykji1003@hotmail.co.kr',
                nick : '김재익',
                password : 'cpfl1318',
            })
            .expect('Location','/join?error=exist')
            .expect(302, done);
    });

    test('로그인 수행', (done) => {
       request(app).post('/auth/login')
           .send({
               email : 'ykji1003@hotmail.co.kr',
               password : 'cpfl1318',
           })
           .expect('Location', '/')
           // Promise가 아닐 경우 done을 넣어줘야 jset가 해당 테스트가 끝난지 인식할 수 있다.
           .expect(302, done);
    });

    test('가입되지 않은 회원인 경우.', (done) => {
        const message = encodeURIComponent('가입되지 않은 회원입니다.');
        request(app).post('/auth/login')
            .send({
                email : 'ykji1002@hotmail.co.kr',
                password : 'cpfl1318',
            })
            // 테스트 결과는 send 후 결과가 expect와 일치해야 정상적으로 통과된다.
            .expect('Location', `/?loginError=${message}`)
            .expect(302, done);
    });

    test('비밀번호가 틀린 회원의 경우', (done) => {
        const message = encodeURIComponent('비밀번호가 일치하지 않습니다.');
        request(app).post('/auth/login')
            .send({
                email : 'ykji1003@hotmail.co.kr',
                password : 'cpfl13181',
            })
            .expect('Location', `/?loginError=${message}`)
            .expect(302, done);
    });
});

describe('POST /join', () => {

    // request(app)를 함께 사용하여 로그인 된 상태를 유지한다.
    const agent = request.agent(app);

    beforeEach((done) => {
        agent.post('/auth/login')
            .send({
                email : 'ykji1003@hotmail.co.kr',
                password : 'cpfl1318',
            // 실행이 끝남을 알리기 위함
            }).end(done);
    });
    test('로그인이 되어 있으면 회원가입 진행이 안되어야 한다.', (done) => {
        const message = encodeURIComponent('로그인한 상태입니다.');

        agent.post('/auth/join')
            .send({
                email : 'ykji1003@hotmail.co.kr',
                nick : '김재익',
                password : 'cpfl1318',
            })
            .expect('Location',`/?error=${message}`)
            .expect(302, done);
    });
});

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

    // beforeEach가 테스트가 수행될 때 항상 실행되지만
    // agent를 독립적으로 사용하기 때문에 다른 request(app) 에이전트에 영향이 없다.
    const agent = request.agent(app);
    beforeEach((done) => {
       agent
           .post('/auth/login')
           .send({
               email : 'ykji1003@hotmail.co.kr',
               password : 'cpfl1318',
           }).end(done);
    });
    test('로그아웃 수행', (done) => {
       agent
           .get('/auth/logout')
           .expect('Location', '/')
           .expect(302, done);
    });

});
// afterAll : 모든 테스트가 수행 끝난 후 실행
afterAll(() => {});

// afterEach : 테스트가 수행 끝난 후 반복적으로 실행된다.
beforeEach(() => {});

11.5 부하테스트

 1. 부하 테스트란?

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

   - 서버가 몇 명의 동시 접속자를 수용할 수 있는지 예측하기가 매우 어렵다.

   - 실제 서비스 중이 아니라 개발 중일 때는 더 어렵다.

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

   - 부하 테스트를 통해 위 문제들을 미리 예측하여 예방할 수 있다.

$ npm i -D artillery
$ npm start

 2. Artillery 사용하기

  ⇒ 새 콘솔에서 다음 명령어를 입력한다.

// 100명의 사용자가 50번씩 요청을 보낸다.
$ npx artillery quick --count 100 -n 50 http://localhost:8001

// count : 가상의 사용자 수
// -n 옵션은 횟수

 

  ⇒ 결과보고서

   - 사용자 생성(vusers.created)

   - 테스트 성공(cusers.completed)

   - 요청 성공 횟수(http.codes.200)

   - 초당 요청 처리 횟수(http.requrest_rate)

   - 응답 지연 속도(http.respose_rate)

   - Min : 최소, Max : 최대, median : 중간 값

   - P95 : 하위 95%, P99 : 하위 99% (하위는 속도 순서를 의미)

   - Median과 P95가 많이 차이나지 않는게 좋은 상태이다.

http.codes.200: ................................................................ 5000
http.downloaded_bytes: ......................................................... 25085000
http.request_rate: ............................................................. 1128/sec
http.requests: ................................................................. 5000
http.response_time:
  min: ......................................................................... 3
  max: ......................................................................... 90
  mean: ........................................................................ 50.7
  median: ...................................................................... 51.9
  p95: ......................................................................... 71.5
  p99: ......................................................................... 77.5
http.responses: ................................................................ 5000
vusers.completed: .............................................................. 100
vusers.created: ................................................................ 100
vusers.created_by_name.0: ...................................................... 100
vusers.failed: ................................................................. 0
vusers.session_length:
  min: ......................................................................... 1915.4
  max: ......................................................................... 2734.2
  mean: ........................................................................ 2546.2
  median: ...................................................................... 2618.1
  p95: ......................................................................... 2725
  p99: ......................................................................... 2725

 3. 여러 페이지 요청 시나리오

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

   - target : 요청 도메인

   - Phases에서 duration : 몇 초 동안(30초)

   - arrivalRate : 매초 몇 명(20명)

   - flow : 사용자의 이동

   - url은 이동한 url

   - json은 서버로 전송한 데이터 값

{
  "config" : {
    "target" : "http://localhost:8001",
    "http" : {
      "timeout" : 30
    },
    "phases" : [{
      "duration" : 30,
      "arrivalRate" : 20
    }]
  },
  "scenarios" : [
    {
      "flow" : [
        { "get" : { "url" :  "/" }},
        { "post" : {
            "url" :  "/auth/login",
            "json" : {
              "email" : "ykj1003@hotmail.co.kr", "password" : "cpfl1318"
            },
            "followRedirect" : false
          }
        },
        {
          "get" : {
            "url" : "/hashtag?hashtag=강아지"
          }
        }
      ]
    }
  ]
}

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

$ npx artillery run loadtest.json

http.codes.200: ................................................................ 124
http.codes.302: ................................................................ 62
http.downloaded_bytes: ......................................................... 548390
http.request_rate: ............................................................. 82/sec
http.requests: ................................................................. 186
http.response_time:
  min: ......................................................................... 0
  max: ......................................................................... 4
  mean: ........................................................................ 1.4
  median: ...................................................................... 1
  p95: ......................................................................... 2
  p99: ......................................................................... 3
http.responses: ................................................................ 186
vusers.completed: .............................................................. 62
vusers.created: ................................................................ 62
  mean: ........................................................................ 8
  median: ...................................................................... 6.9
  p95: ......................................................................... 12.6
  p99: ......................................................................... 25.8

  ⇒ 요청 후반부가 될 수록 응답속도가 길어질 수 있다. 그럴 경우 서버 사양을 업그레이드하거나, 서버를 여러 개

     두거나 코드를 개선하여 부하를 줄일 수 있다.

   - 현재는 싱글코어만 사용하므로, 클러스터링 기법 도입을 시도해 볼 수 있다.

   - arrivalRate를 줄이거나 늘려서 어느정도 수용이 가능한지 부하 체크를 하는 것이 좋다.

   - 여러 번 테스트 해 평균치를 내라.

 5. 테스트 범위

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

   - 자신이 짠 코드는 최대한 많이 테스트하자.

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

   - 모킹해서 통과하더라도 실제 상황에서는 에러날 수 있음을 염두헤 두어야 한다.

 

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

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

 

ps. 좀더 세부적인 테스트를 위한 유료 테스트를 제공하는 소프트웨어

https://newrelic.com/kr

https://www.datadoghq.com/