관리 메뉴

진취적 삶

11 노드 서비스 테스트하기 본문

개발 도서/Node.js 교과서

11 노드 서비스 테스트하기

hp0724 2023. 7. 14. 11:47

테스트 기법 중 유닛테스트 ,통합 테스트 ,부하 테스트 ,테스트 커버리지 체크를 해보자 .

11.1 테스트 준비하기

jest 패키지 설치

npm i -D jset 

pacakge.json

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

npm test 테스트 코드 실행 가능

파일명에 test나 spec 이 들어간 파일들을 모두 찾아 실행한다.

// 첫번째 인수는 테스트 설명 , 두번째 인수는 테스트 내용
test(`1+1은 2 입니다`, () => {
  expect(1 - 1).toEqual(2);
});
// 첫번째 인수는 테스트 설명 , 두번째 인수는 테스트 내용
test(`1+1은 2 입니다`, () => {
  expect(1 + 1).toEqual(3);
});
FAIL  middlewares/index.test.js
  × 1+1은 2 입니다 (2 ms)
                                                                                                                                                
  ● 1+1은 2 입니다                                                                                                                              
                                                                                                                                                
    expect(received).toEqual(expected) // deep equality

    Expected: 3
    Received: 2

      1 | // 첫번째 인수는 테스트 설명 , 두번째 인수는 테스트 내용
      2 | test(`1+1은 2 입니다`, () => {
    > 3 |   expect(1 + 1).toEqual(3);
        |                 ^
      4 | });
      5 |

11.2 유닛 테스트

const { describe } = require("../../../10/nodebird-api/models/user");
const { isLoggedIn, isNotLoggedIn } = require("./");

describe("isLoggedIn", () => {
  test("로그인 되어 있으면 isLoggedIn 이 next를 호출해야함 ", () => {});
  test("로그인 되어 있지 않으면 isLoggedIn 이 어를 응답해야함", () => {});
});

describe("isNotLoggedIn", () => {
  test("로그인되어 있으면 isNotLoggedIn이 에러를 응답해야합", () => {});
  test("로그인되어 있지 않으면 isNotLoggedIn이 next를 호출해야함", () => {});
});

describe 는 테스트를 그룹화 해주는 역할

테스트 할때는 과감하게 가짜 객체와 함수를 만들어 넣으면 된다.

테스트 코드의 객체가 실제 익스프레스 객체가 아니어도 된다.

가짜 객체, 가짜 함수를 넣는 행위를 모킹이라고 한다.

const { isLoggedIn, isNotLoggedIn } = require("./");

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("로그인 되어 있지 않으면 isLoggedInd이 error를 호출해야함", () => {
    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);
  });
});

a함수를 모킹할때 는 jest.fn 메서드를 사용한다. 함수의 반환 값 저장은 jest.fn(()⇒반환값)을 사용

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

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

작은 단위의 함수나 모듈이 의도된 대로 정확히 작동하는지 테스트 하는것을 유닛 테스트

jest.mock("../models/user");
const { follow } = require("./user");
const User = require("../models/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);
  });
});

a 모킹할 모듈의 경로를 인수로 넣고 그 모듈을 불러온다.

11.3 테스트 커버리지

전체 코드 중에서 테스트 되고 있는 코드의 비율과 테스트 되고 있지 않은 코드의 위치를 알려주는 기능을 커버리지 기능이라 한다.

package.json 에 추가

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

npm run covergae  

models / user test

const Sequelize = require("sequelize");
const User = require("./user");
const config = require("../config/config.json")["test"];
const sequelize = new Sequelize(
  config.database,
  config.username,
  config.password,
  config
);

describe("User 모델", () => {
  test("static initiate 메서드 호출", () => {
    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);
  });
});
PASS  models/user.test.js
-------------|---------|----------|---------|---------|-------------------
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 |                   
 middlewares |     100 |      100 |     100 |     100 |                   
  index.js   |     100 |      100 |     100 |     100 | 
 models      |     100 |      100 |     100 |     100 | 
  user.js    |     100 |      100 |     100 |     100 | 
-------------|---------|----------|---------|---------|-------------------

Test Suites: 3 passed, 3 total
Tests:       9 passed, 9 total
Snapshots:   0 total
Time:        1.283 s, estimated 2 s

커버리지 상승

11.4 통합 테스트

하나의 라우터를 통째로 테스트

실제 서비스 중인 데이터 베이스에 테스트용 데이터가 들어가면 안 되므로 , 테스트용 데이터 베이스를 따로 만드는것이 좋다.

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

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

describe("POST/login", () => {
  test("로그인 수행 ", (done) => {
    request(app)
      .post("/auth/login")
      .send({ email: "hp980724@gmail.com", password: "password" })

      .expect("Location", "/")
      .expect(302, done);
  });
});

beforeAll : 모든 테스트를 실행하기 전에 수행해야 할 코드를 넣는 공간이다.

sequelize.sync(): 데이터베이스에 테이블 생성

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: "hp980724@gmail.com",
        nick: "suha",
        password: "nodejs",
      })
      .expect("Location", "/")
      .expect(302, done);
  });
});

describe("POST/login", () => {
  const agent = request.agent(app);
  beforeEach((done) => {
    agent
      .post("/auth/login")
      .send({ email: "hp980724@gmail.com", password: "nodejs" })
      .end(done);
  });
  test("이미 로그인 했으면 redirect /", (done) => {
    const message = encodeURIComponent("로그인한 상태입니다");
    agent
      .post("/auth/join")
      .send({ email: "hp980724@gmail.com", nick: "suha", password: "nodejs" })
      .expect("Location", `/?error=${message}`)
      .expect(302, done);
  });
});

agent 만들어서 하나 이상의 요청에서 재사용 가능

beforeEach 는 각각의 테스트 실행에 앞서 먼저 실행되는 부분이다.

테스트 후 데이터 베이스에 데이터가 남아있으면 다음 테스트에 영향을 미칠수 있다

테스트 종료시 데이터를 정리하는 코드를 추가해야한다.

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: "hp980724@gmail.com",
        nick: "suha",
        password: "nodejs",
      })
      .expect("Location", "/")
      .expect(302, done);
  });
});

describe("POST/join", () => {
  const agent = request.agent(app);
  beforeEach((done) => {
    agent
      .post("/auth/login")
      .send({ email: "hp980724@gmail.com", password: "nodejs" })
      .end(done);
  });
  test("이미 로그인 했으면 redirect /", (done) => {
    const message = encodeURIComponent("로그인한 상태");
    agent
      .post("/auth/join")
      .send({ email: "hp980724@gmail.com", nick: "suha", password: "nodejs" })
      .expect("Location", `/?error=${message}`)
      .expect(302, done);
  });
});
//데이터 정리 테이블 다시 생성 
afterAll(async () => {
  await sequelize.sync({ force: true });
});

총 라우터 테스트

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: "hp980724@gmail.com",
        nick: "suha",
        password: "nodejs",
      })
      .expect("Location", "/")
      .expect(302, done);
  });
});

describe("POST/join", () => {
  const agent = request.agent(app);
  beforeEach((done) => {
    agent
      .post("/auth/login")
      .send({ email: "hp980724@gmail.com", password: "nodejs" })
      .end(done);
  });
  test("이미 로그인 했으면 redirect /", (done) => {
    const message = encodeURIComponent("로그인한 상태");
    agent
      .post("/auth/join")
      .send({ email: "hp980724@gmail.com", nick: "suha", password: "nodejs" })
      .expect("Location", `/?error=${message}`)
      .expect(302, done);
  });
});

describe("POST/login", () => {
  test("가입되지 않은 회원", (done) => {
    const message = encodeURIComponent("가입되지 않은 회원");
    request(app)
      .post("/auth/login")
      .send({ email: "hp980724@gmail.com", password: "nodejs" })
      .expect("Location", `?loginError=${message}`)
      .expect(302, done);
  });
  test("로그인 수행", (done) => {
    request(app)
      .post("/auth/login")
      .send({ email: "hp980724@gmail.com", password: "nodejs" })
      .expect("Location", "/")
      .expect(302, done);
  });

  test("비밀번호 틀림", (done) => {
    const message = encodeURIComponent("비밀번호가 틀림");
    request(app)
      .post("/auth/login")
      .send({ email: "hp980724@gmail.com", password: "wrong" })
      .expect("Location", `?loginError=${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: "hp980724@gmail.com",
        password: "nodejs",
      })
      .end(done);
  });

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

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

11.5 부하 테스트

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

내 서버의 몇 명의 동시 접속자나 일일 사용자를 수용할수 있는지 예측하는 일은 어렵다.

하드웨어의 제약 때문에 OOM (out of memory) 발생 가능

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

—count : 가상의 사용자

-n : 요청 횟수

100명이 50번 요청 5000번의 요청이 서버로 전달

All VUs finished. Total time: 11 seconds

--------------------------------
Summary report @ 18:55:42(+0900)
--------------------------------

http.codes.200: ................................................................ 5000
http.request_rate: ............................................................. 296/sec
http.requests: ................................................................. 5000
http.response_time:
  min: ......................................................................... 12
  max: ......................................................................... 441
  median: ...................................................................... 159.2
  p95: ......................................................................... 237.5
  p99: ......................................................................... 320.6
http.responses: ................................................................ 5000
vusers.completed: .............................................................. 100
vusers.created: ................................................................ 100
vusers.created_by_name.0: ...................................................... 100
vusers.failed: ................................................................. 0
vusers.session_length:
  min: ......................................................................... 7585.1
  max: ......................................................................... 8387.7
  median: ...................................................................... 8186.6
  p95: ......................................................................... 8352
  p99: ......................................................................... 8352

http.response_time p95는 하위 95 %의 값 p99는 하위 99%의 값이다.

median과 p95의 차이가 크지 않은것이 좋다 .

실제 서비스에 부하테스트를 할 경우 서버가 중지되는 문제가 생길수도있다.

실제 서버와 같은 사양의 서버를 만든 후 , 그 서버에 부하 테스트를 진행하는것이 좋다.

{
  "config": {
    "target": "http:localhost:8001",
    "http": {
      "timeout": 30
    },
    "phase": [
      {
        "duration": 30,
        "arrivalRate": 20
      }
    ]
  },
  "scenarios": [
    {
      "flow": [
        {
          "get": {
            "url": "/"
          }
        },
        {
          "post": {
            "url": "/auth/login",
            "json": {
              "email": "hp980724@gmail.com",
              "password": "nodejs"
            },
            "followRedirect": false
          }
        },
        {
          "get": {
            "url": "/hashtag?hashtag=nodebird"
          }
        }
      ]
    }
  ]
}

x타켓 현재 서버 잡고 phase에서 30초동안 사용자 20명

timeout 30초

가상 사용자들이 어떤 동작을 할지 시나리오에 적기

npx artillery run loadtest.json

datadog 나 newrelic 같은 상용 서비스를 적용해보면 좋다.