실습 : Mocha 테스트 환경 구축하기




리팩터링 책을 읽으며, 테스트의 중요성을 크게 실감하고 있다.

그래서 뭅스터 서버를 리팩터링하며, TDD를 해보고자 한다.

node.js를 지원하는 테스트 프레임워크는 유명한 2대장 jest, mocha가 있는데,

단위 테스트보다 규모가 커질 때 jest가 버벅이는 현상이 자주 발생한다고 하여 mocha로 진행하고자 한다.

하지만 두가지 모두 명령어나 사용새가 비슷하니 하나만 익숙해져도 다른 것을 새로 배우는데는 큰 시간이 들 진 않을 것이라 생각한다.


Mocha 가 뭐야?

  • 테스트 러너를 지원하는 테스트 프레임워크
  • 자체 assertion은 지원하지 않는다.
    • (assertion은 에러가 없는 프로그램을 작성하기 위한 하나의 수법을 의미한다.)
    • node에서 자체 지원하는 assert 모듈을 사용해도 되나 mocha 공식 문서에서 추천하지 않는다.
    • 나는 chai 를 사용할거다😺 (체이닝을 통해 하나의 영어 문장으로 단일 테스트를 진행할 수 있어 직관적이다.)


mocha 설치하기

테스트 목적이니 다음과 같이 설정하자.

npm install mocha —-save-dev

assertion으로 chai를 사용할 것이기 때문에 chai도 설치한다.

npm install chai —-save-dev



유닛 테스트 실습하기

util.js에 문자열을 입력받아 첫번째 문자열을 대문자로 바꿔주는 capitialize 함수를 작성한 뒤, export하자.

// utils.js
function capitialize(str){
  return str.charAt(0).toUpperCase() + str.slice(1)
}

module.exports = {
  capitialize
}

mocha는 파일명.spec.js 처럼 파일명에 .spec이 들어가야 테스트를 실행시킬 수 있다.

// utils.spec.js
const should = require("should");
const utils = require("./utils");

describe("utils.js 모듈의 capitialize() 함수는", () => {
  it("문자열의 첫번째 문자를 대문자로 변환한다.", () => {
    const result = utils.capitialize("hello");
    result.should.be.equal(result, "Hello");
  });
});
  • describe() : 테스트 범위를 설정한다.
  • it() : 단위 테스트를 설정한다.
    • 인수로 done을 사용하면, 비동기 단위 테스트를 완료할 때 유용하게 쓰인다.


테스트 실행하기

다음 명령어를 통해 utils.spec.js를 실행시켜보자.

node_modules/.bin/mocha utils.spec.js

tdd결과

잘 성공했다고 나오고 있다.👍


describe 중첩해서 사용하기

describe는 중첩으로 사용할 수가 있다.

// utils.js
function capitialize(str) {
  return str.charAt(0).toUpperCase() + str.slice(1);
}

function add(num1, num2) {
  return num1 + num2
}

module.exports = {
  capitialize,
  add
};


// utils.spec.js
const should = require('chai').should();
const utils = require('./utils');

describe('# test', () => {
  describe('# utils.js 모듈의 capitialize() 함수는', () => {
    it('문자열의 첫번째 문자를 대문자로 변환한다.', () => {
      const result = utils.capitialize('hello');
      result.should.equal(result, 'Hello');
    });
    it('문자형이어 한다.', () => {
      utils.capitialize('hello').should.be.a('string');
    });
  });
  describe('# add 함수는', () => {
    it('두 수의 합은 반환한다.', () => {
      utils.add(1, 5).should.be.equal(7);
    });
  });
});

add 테스트를 보면 의도적으로 실패하는 코드를 작성해보았다.

tdd결과

위와 같이 실패한 케이스가 무엇이고, 기댓값과 실제값을 비교해줘서 어디서 에러가 났는지 파악하기도 쉽다.

다시 utils.add(1, 5).should.be.equal(7); 로 바꿔주면

tdd결과

3개의 describe에 대한 내역이 모두 성공했음을 알 수 있다!🥳


API 테스트 해보기

서버 프로토타입을 만들어보는게 주 미션이었기 때문에 이번에는 api 테스트를 진행해보자.

api 테스트는 더이상 유닛 테스트가 아니므로 supertest 라이브러리를 설치해보자

npm i supertest —save-dev

// index.js
const express = require('express');
const morgan = require('morgan');
const bodyParser = require('body-parser');

const app = express();

if(process.env.NODE_ENV !== 'test'){
  app.use(morgan('dev'));

}
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());

let users = [
  { id: 1, name: 'a' },
  { id: 2, name: 'b' },
  { id: 3, name: 'c' },
];

app.get('/users', (req, res) => {
  req.query.limit = req.query.limit || 10;
  const limit = +req.query.limit;
  if (Number.isNaN(limit)) return res.status(400).end();
  res.json(users.slice(0, limit));
});

app.get('/users/:id', (req, res) => {
  const id = +req.params.id;
  if (Number.isNaN(id)) return res.status(400).end();
  const user = users.find(user => user.id === id);
  if (!user) return res.status(404).end();
  res.json(user);
});

app.delete('/users/:id', (req, res) => {
  const id = +req.params.id;
  if (Number.isNaN(id)) return res.status(400).end();
  users = users.filter(user => user.id !== id);
  res.status(204).end();
});

app.post('/users', (req, res) => {
  const { name } = req.body;
  if (!name) return res.status(400).end();
  if (users.find(user => user.name === name)) return res.status(409).end();
  const id = users.length + 1;
  const user = { id, name };
  users = [...users, user];
  res.status(201).json(user);
});

app.put('/users/:id', (req, res) => {
  const id = +req.params.id;
  if (Number.isNaN(id)) return res.status(400).end();
  const { name } = req.body;
  if (!name) return res.status(400).end();
  const user = users.find(user => user.id === id);
  if (!user) return res.status(404).end();
  const isConflict = users.find(user => user.name === name);
  if (isConflict) return res.status(409).end();
  user.name = name;
  users = [...users.filter(user => user.id !== id), user];
  res.json(user);
});

// supertest 환경에서는 app.listen이 중복코드이기 때문에 없어도 된다.

module.exports = app;


//index.spec.js
const request = require('supertest');
// eslint-disable-next-line no-unused-vars
const should = require('chai').should();
const app = require('./index');

describe('GET /users는', () => {
  describe('성공시', () => {
    it('유저 객체를 담은 배열로 응답한다.', done => {
      request(app)
        .get('/users')
        .end((err, res) => {
          res.body.should.be.instanceOf(Array);
          done();
        });
    });
    it('최대 limit 갯수만큼 응답한다.', done => {
      request(app)
        .get('/users?limit=2')
        .end((err, res) => {
          res.body.should.have.lengthOf(2);
          done();
        });
    });
  });
  describe('실패시', () => {
    it('limit이 숫자형이 아니면 400을 응답한다', done => {
      request(app).get('/users?limit=two').expect(400).end(done);
    });
  });
});

describe('GET /users/1은', () => {
  describe('성공시', () => {
    it('id가 1인 유저 객체를 반환한다', done => {
      request(app)
        .get('/users/1')
        .end((err, res) => {
          res.body.should.have.property('id', 1);
          done();
        });
    });
  });
  describe('실패시', () => {
    it('id가 숫자형이 아니면 400을 응답한다', done => {
      request(app).get('/users/one').expect(400).end(done);
    });
    it('id로 유저를 찾을 수 없는 경우 404로 응답한다.', done => {
      request(app).get('/users/999').expect(404).end(done);
    });
  });
});

describe('DELETE /users/1은', () => {
  describe('성공시', () => {
    it('204를 응답한다.', done => {
      request(app).delete('/users/1').expect(204).end(done);
    });
  });
  describe('실패시', () => {
    it('id가 숫자형이 아니면 400을 응답한다', done => {
      request(app).get('/users/one').expect(400).end(done);
    });
  });
});

describe('POST /users은', () => {
  describe('성공시', () => {
    const name = 'jorang';
    let body;
    before(done => {
      request(app)
        .post('/users')
        .send({ name })
        .expect(201)
        .end((err, res) => {
          body = res.body;
          done();
        });
    });
    it('생성된 유저 객체를 반환한다', () => {
      body.should.have.property('id');
    });
    it('입력한 name을 반환한다', () => {
      body.should.have.property('name', name);
    });
  });
  describe('실패시', () => {
    it('name 파라미터 누락시 400을 반환한다.', done => {
      request(app).post('/users').send({}).expect(400).end(done);
    });
    it('name이 중복일 경우 409를 반환한다.', done => {
      request(app).post('/users').send({ name: 'jorang' }).expect(409).end(done);
    });
  });
});

describe('PUT /users은', () => {
  describe('성공시', () => {
    it('변경된 name을 응답한다.', done => {
      const name = 'cute';
      request(app)
        .put('/users/3')
        .send({ name })
        .expect(201)
        .end((err, res) => {
          res.body.should.have.property('name', name);
          done();
        });
    });
  });
  describe('실패시', () => {
    it('정수가 아닌 id일 경우 400을 응답한다.', done => {
      request(app).put('/users/one').expect(400).end(done);
    });
    it('name이 없을 경우 400을 응답한다.', done => {
      request(app).put('/users/4').send({}).expect(400).end(done);
    });
    it('없는 유저일 경우 404를 응답한다.', done => {
      request(app).put('/users/999').send({ name: 'abc' }).expect(404).end(done);
    });
    it('이름이 중복일 경우 409를 응답한다.', done => {
      request(app).put('/users/2').send({ name: 'cute' }).expect(409).end(done);
    });
  });
});

tdd결과


나의 회고 🤔

TDD를 적용한 게 처음이라 위의 테스트를 작성하는 것도 많은 시간이 걸렸다.😭

하지만 api 테스트는 테스트를 작성하기 전에는,

  • postman으로 해당 api 호출
  • console.log 찍어가며 값 확인
  • 수정하고 다시 위의 과정 반복…또 반복…무한 반복

위의 과정을 거쳤기 때문에 정말 많은 시간을 할애했다.

하지만 테스트를 작성해보니 postman을 사용하지 않아도 되니 기능 추가 및 기능 변경시 아주 유용하게 사용될 것 같다.

체감상으로는 유닛 테스트때보다 더 테스트의 이점을 잘 나타내주는 부분인 것 같다.😳

이제 TDD를 적용하기 위한 프로토타입 테스트는 완료했으니, 실제 뭅스터 프로젝트에 TDD를 적용하며 리팩토링 및 기능 추가를 해보자.