Node.js 로 TDD 를 도전해보자

make it work, make it right, make it fast

개발자가 쓰는 코드는 우선 동작하는 것을 우선하며 그 순서대로 만들기 쉽다. 물론 틀린말은 아니지만, 단순히 코드 작성에만 우선한 코드는 반드시 문제를 만들기 마련이다. Kent Beck 이 말한 위의 문장은 TDD (Test Driven Development) 를 왜 해야하는지에 대한 좋은 답변이라고 생각한다.

작동하게 만들고, 제대로 만들고, 최적화 하자. @Kent Beck

가까이 하기엔 너무 먼 당신 TDD

가까이 하기엔 너무 먼 당신 TDD

TDD 이게 좋은 건 다들 알고 있는데 개발의 사고 방식 그 자체를 바꿔야 한다는 벽넘나 험난한 길에 모두들 정신줄을 놓고는 내가 무슨 TDD 야 그냥 하던대로 하자... 에 도달하고 만다. 다행히! Node.js 는 상대적으로 TDD 를 가볍게 시작할 수 있으니 이 기회에 한번 배워보도록 하자.

준비하기

우선 프로젝트를 생성하자.

$ mkdir node-tdd && cd node-tdd
$ npm init -f 

테스트 프레임워크로는 mocha 를 사용하고 Assertion 라이브러리는 chai 를 선택했다. supertest 를 통해서 리퀘스트를 보낼거니 역시 설치하고, 서버는 express 를 이용해 간단하게 만들자.

$ npm install --save express
$ npm install --save-dev mocha chai supertest

ES6 지원을 위해 babel 도 설치하자.

$ npm install --save-dev babel-cli babel-preset-node6 babel-register

Node v6 에서 아직 지원하지 않는 module loader 를 트랜스파일 해주기 위해서 다음과 같이 .babelrc 를 설정해준다.

$ vi .babelrc
{
  "presets": ["node6"]
}

여기까지 되었으면 이제 개발을 시작해보자.

시작하기

왠지 express 로 뭔가를 만들면 import express from 'express'; 부터 시작해야할 것 같지만, 이번엔 좀 다른 방식을 취해보자. 우리는 TDD ! 를 하는 사람들이니까!

우선 서버 시작과 테스트 러닝 스크립트를 다음과 같이 package.json 추가하자. 언제까지 node index.js 를 할텐가

  "scripts": {
    "start": "node ./node_modules/babel-cli/bin/babel-node.js index.js",
    "test": "node ./node_modules/mocha/bin/mocha --compilers js:babel-register --recursive ./**/*.spec.js"
  },

index.spec.js 에 아래 코드를 입력하자.

import request from 'supertest';
import { expect } from 'chai';

import app from './index';

describe('GET /', () => {
  it('should respond with text message "Hello World"', (done) => {
    request(app)
      .get('/')
      .expect(200)
      .end((err, res) => {
        if (err) {
          done(err);
          return;
        }

        expect(res.text).to.equal('Hello World');
        done();
      });
  });
});

모든 튜토리얼이 그렇듯 Hello World 를 리턴 할 것을 예상하고 간단한 테스트 코드를 작성하였다. appimport 하는 것을 알 수있는데, supertest 는 express instance 를 import 하여 테스트를 위한 request 를 보낼 수 있는 패키지이다.

다음은 index.js 에 아래 코드를 입력하자

import express from 'express';

const app = express();

app.get('/', (req, res) => {
  res.send('Hello World');
});

app.listen(3000, () => {
  console.log('Awesome TDD + ES6 app listening on port 3000!');
});

벌써 다 한것 같다 실행은 요렇게.

$ npm test

이런 결과가 나타날 것이다.

$ npm test

> [email protected] test /Users/KimSeokjun/Development/node-tdd-mocha
> node ./node_modules/mocha/bin/mocha --compilers js:babel-register --recursive ./**/*.spec.js



Awesome TDD + ES6 app listening on port 3000!
  GET /
Hello World
    ✓ should respond with text message "Hello World"


  1 passing (40ms)

여기까지가 기본적인 프로젝트 및 테스트 환경을 구축하였다. 물론 실제 서버는 npm start 등의 명령어로 구동시키면 된다. 여기에서는 babel-cli 를 설치하면 node_modules/babel-cli/bin/ 에 설치되는 babel-node.js 를 사용하였다. 단, 서비스 환경에서는 메모리를 많이 사용하는 모듈이기 때문에 (트랜스파일한 es5 코드를 메모리에 들고 있다) 반드시 빌드 한 후 배포하도록 하자.

더 만들자

POST API 를 하나 더 만들어서 테스트해보자.

{
  email: '[email protected]',
  password: 'abcd1234',
}

request body 에 위와 같은 내용을 담아 POST 요청을 보내면 김개똥씨 프로필 정보를 알려주는 로그인 API 가 있다고 가정하자. 리턴 값은 json 으로 아래와 같은 형태로 보내줄 것이다.

{
  id: 'vk94z0',
  email: '[email protected]',
  name: '김개똥',
  age: 20,
}

테스트 코드는 다음과 같이 작성할 수 있다.

describe('POST /login', () => {
  it('should respond with profile', (done) => {
    request(app)
      .post('/login')
      .send({
        email: '[email protected]',
        password: 'abcd1234',
      })
      .expect(200)
      .end((err, res) => {
        if (err) {
          done(err);
          return;
        }

        expect(res.body).has.all.keys([
          'id', 'email', 'name', 'age',
        ]);
        expect(res.body.id).to.equal('vk94z0');
        expect(res.body.name).to.equal('김개똥');
        expect(res.body.age).to.equal(20);
        done();
      });
  });
});

이제 npm test 명령어로 테스트를 실행한다.

Awesome TDD + ES6 app listening on port 3000!
  GET /
    ✓ should respond with text message "Hello World"

  POST /login
    1) should respond with profile


  1 passing (48ms)
  1 failing

  1) POST /login should respond with profile:
     Error: expected 200 "OK", got 404 "Not Found"
      at Test._assertStatus (node_modules/supertest/lib/test.js:266:12)
      at Test._assertFunction (node_modules/supertest/lib/test.js:281:11)
      at Test.assert (node_modules/supertest/lib/test.js:171:18)
      at Server.assert (node_modules/supertest/lib/test.js:131:12)
      at emitCloseNT (net.js:1549:8)
      at _combinedTickCallback (internal/process/next_tick.js:71:11)
      at process._tickCallback (internal/process/next_tick.js:98:9)

아직 실제로 앱에서 /login route 를 설정해주지 않았기 때문에 위와 같은 404 에러로 테스트에 실패한다. 그러면 이제 실제 앱에서
관련 코드를 작성해보자.

우선 express 에서 request body 를 파싱해주기 위해 body-parser 미들웨어를 설치하자.

$ npm install --save body-parser

index.js 는 아래와 같이 수정한다.

import bodyParser from 'body-parser';

const app = express();

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

login 코드는 다음과 같다.

app.post('/login', (req, res) => {
  if (!req.body) {
    res.status(400).send('Bad Request');
    return;
  }

  if (req.body.email !== '[email protected]' || req.body.password !== 'abcd1234') {
    res.status(401).send('Unauthorized');
    return;
  }

  res.send({
    id: 'vk94z0',
    email: '[email protected]',
    name: '김개똥',
    age: 20,
  });
});

자 이제 다시 npm test 를 돌릴 시간이다.

Awesome TDD + ES6 app listening on port 3000!
  GET /
    ✓ should respond with text message "Hello World"

  POST /login
    1) should respond with profile


  1 passing (47ms)
  1 failing

  1) POST /login should respond with profile:
     Error: expected 200 "OK", got 401 "Unauthorized"
      at Test._assertStatus (node_modules/supertest/lib/test.js:266:12)
      at Test._assertFunction (node_modules/supertest/lib/test.js:281:11)
      at Test.assert (node_modules/supertest/lib/test.js:171:18)
      at Server.assert (node_modules/supertest/lib/test.js:131:12)
      at emitCloseNT (net.js:1549:8)
      at _combinedTickCallback (internal/process/next_tick.js:71:11)
      at process._tickCallback (internal/process/next_tick.js:98:9)

안된다... 왜 안되지..? 왜 안될까..? 코드를 잘 살펴보았더니 오타가 있었다. dogpoo 라고 해야 하는걸 dogpee 로 작성하였다.

  if (req.body.email !== '[email protected]' || req.body.password !== 'abcd1234') {

위와 같이 수정하고 다시한번 테스트를 수행해보자.

Awesome TDD + ES6 app listening on port 3000!
  GET /
    ✓ should respond with text message "Hello World"

  POST /login
    ✓ should respond with profile


  2 passing (57ms)

드디어 우리가 만든 로그인 기능이 작동하는 것을 볼 수 있었다. 물론 로그인 API 를 이렇게 만들어서는 안되겠지만, 어쨋든 정상작동은 확인할 수 있었다.

TDD 생각보다 쉽다.

red-green-refactor

TDD 는 일반적으로 red, green, refactor 라고 하는 세 단계를 거쳐 이루어진다.

  1. RED 실패하는 테스트를 만들어라.
  2. GREEN 테스트에 통과하도록 코드를 작성하라.
  3. REFACTOR 불필요한 코드를 삭제하라.

이 글에서는 refactor 단계는 생략하였지만, 기본적으로 red-green 의 절차를 따라 코드를 작성해보았다. 이른바 Integration Test 라고 불리는 UI 테스트는 이보다 훨씬 복잡하고 준비해야 할 것들도 많아서 적용하기 쉽지 않지만, 서버측 코드나, 공통 유틸리티 기능과 같은 것들을 생각보다 간단하게 TDD 를 실천할 수 있다.

TDD 는 개발 속도가 느려진다들 말한다. 과연 실제로 그럴까?

그 어떤 소프트웨어 프로젝트이건 코드 타이핑 자체가 병목인 경우는 존재하지 않는다. (산드로 만쿠소, 소프트웨어 장인)

소프트웨어 개발 속도에 영향을 끼치는 것은 많이 있다. 개발 환경 설정이라거나, DB 설계, 버그, 레거시 코드 기타 등등. 하지만 과연 생각해보자. 키보드 타이핑 10줄 더한다고 더 오래 걸렸는가? TDD 는 단위테스트를 통해서 설계를 좀더 간결하게 유지시켜주고, 리팩토링을 장려한다. 테스트코드만 제대로 작성해도 생기는 많은 문제점을 미연에 방지할 수 있다.

급하게 작성된 코드를 어찌어찌 돌리는 것이 결코 개발자가 하는 일이 아니다. 유지보수가 가능한 코드를 작성하고, 버그가 생겨날 여지를 줄이고, 높은 가독성을 추구하는 것이 개발자의 역할이다. 오늘 내가 쓴 코드는 내일의 레거시가 된다. 개발은 일종의 협업 예술에 가깝다고 생각한다. 오늘 내가 쓴 코드로 다른 사람이 고통받는 일은 줄여나가도록 하자. 그 가장 좋은 그리고 쉬운 방법이 바로 TDD 가 아닐까 생각한다.

Appendix

소스코드