Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

node.js를 위한 비동기 BDD 프레임워크 Vows

얼마전에 BDD 프레임워크인 Jasmine에 대한 글을 포스팅했었는데 그 글에서도 밝혔듯이 상당히 전형적인 형태의 TDD프레임워크인 expresso를 바꿔보려고 Jasmine-node를 사용하기 위함이었습니다. Jasmine자체는 BDD작성 방법이나 assert형태등에서 상당히 맘에 들었었지만 막상 테스트 코드를 작성하다보니 Jasmine-node가 제대로 동작하지 않았습니다.

Jasmine-node가 개발이 현재는 멈춘것 같은데 이런저런 버그가 좀 있는듯 합니다. 오류가 있을 경우 오류를 보여주는 대신 Jasmine.Suite() required라고 나온다거나 테스트되는 코드에서 나오는 로그들이 제대로 출력되지 않는등 정확하진 않은데 문제가 좀 있는 것으로 보아서 별수 없이 다른 것을 찾고 있었고 node.js에서는 꽤 많이 알려진 Vows를 찾게 되었습니다.(사실 node.js에선 Vows가 더 유명한데 node.js에서만 쓸수 있는 녀석이라서 다른데서도 쓰기 위해서 Jasmine을 선택한 것이었는데 결국 다시 Vows를 사용하게 되었습니다.) 아래 내용은 Vows 공식 페이지의 내용을 기반으로 한 것입니다.


Vows
Vows는 node.js를 위한 비동기 BDD 프레임워크이고 node.js를 테스트하기 위해서 상당히 특화되어 있기 때문에 기존의 다른 BDD 혹은 TDD 프레임워크들과는 약간 다른 형식을 가지고 있습니다. Vows 홈페이지에서 말하는 바로는 node.js가 비동기를 기반으로 동작하기 때문에 Test도 그래야 한다는 것이고 I/O 테스트를 더 빨리 하기 위해서 동시적으로 실행하기 위함이라고 밝히고 있습니다.

Vows에서 Test Suite은 테스트들을 담고 있는 가장 큰 단위입니다. 관례상으로는 1개의 파일에 1개의 Test Suite을 갖는 것이고 아래와 같이 생성합니다.

var suite = vows.describe('Test Suite 이름');

테스트는 addBatch()라는 메서드를 이용해서 다음 처럼 Suite에 추가 시킵니다.

suite.addBatch({});

Suite에는 아래처럼 필요한 만큼 배치를 추가할 수 있고 추가된 순서대로 순차적으로 실행되기 때문에 순서대로 테스트하기를 원할 때 유용합니다.

suite.addBatch({/* 첫번째로 실행 */})
         .addBatch({/* 두번째로 실행 */})
         .addBatch({/* 세번째로 실행 */});

각 배치는 테스트하려는 상태를 설명하는 컨텍스트를 가지고 있습니다.


suite.addBatch({
  '컨텍스트 1': {},
  '컨텍스트 2': {}
});

각 컨텍스는 병렬로 수행되면서 비동기로 실행되기 때문에 테스트가 끝나는 순서는 알 수 없습니다. 각 컨텍스트는 아래와 같이 테스트 쌍(pair)인 topic과 vow를 담고 있습니다.


suite.addBatch({
 '컨텍스트 1': {
    topic: function () {/* 비동기적으로 무언가를 한다 */},
    'vow입니다': function (topic) {
      /* topic의 결과를 테스트한다 */
    }
  },
  '컨텍스트 2': {}
});

각 컨텍스트는 부모 컨텍스트의 실행이 끝나면 실행되는 서브 컨텍스트를 포함할 수 있습니다.


suite.addBatch({
 '컨텍스트 1': {
    topic: function () {/* 비동기적으로 무언가를 한다 */},
    'vow입니다': function (topic) {
      /* topic의 결과를 테스트한다 */
    },
    'A sub-context': {
      /* 위의 테스트가 끝났을 때 수행된다 */
    }
  },
  '컨텍스트 2': {
    /* 컨텍스트 1과 병렬로 수행된다 */
  }
});

위의 내용을 종합해 보면 아래와 같은 형태가 됩니다.


vows.describe('Array').addBatch({  // 배치
  'An array': {    // 컨텍스트
    'with 3 elements': {    // 서브 컨텍스트
      topic: [1, 2, 3],    // Topic
      'has a length of 3': function (topic) {  // Vow
        assert.equal(topic.length, 3);
      }
    },
    'with zero elements': {    // 서브 컨텍스트
      topic: [],    // Topic
      'has a length of 0': function (topic) { // Vow
        assert.equal(topic.length, 0);
      },
      'returns *undefined*, when `pop()`ed': function (topic) {
        assert.isUndefined(topic.pop());
      }
    }
  }
});


Topic
Vows를 이해하려면 우선 Topic를 이해해야 합니다. 다른 테스트프레임워크와 크게 차이가 나는 부분으로 topic은 테스트 대상과 테스트를 분리할 수 있도록 합니다.


{ topic: 42,
  'should be equal to 42': function (topic) {
    assert.equal (topic, 42);
  }
}


위의 소스는 간단한 컨텍스트의 예제입니다. topic에 값을 주면 vow의 파라미터로 파라미터로 topic의 값을 전달 받아서 assert를 할 수 있습니다.


{ topic: function () { return 42 },
  'should be equal to 42': function (topic) {
    assert.equal (topic, 42);
  }
}

topic은 함수가 될 수도 있는데 같은 예제를 다시 작성하면 위와 같습니다. 마찬가지로 vow가 파라미터로 topic함수의 리턴값을 받습니다.


{ topic: function () { return 42 },
  'should be a number': function (topic) {
    assert.isNumber (topic);
  },
  'should be equal to 42': function (topic) {
    assert.equal (topic, 42);
  }
}

만약 위처럼 topic에 여러개의 vow가 있다면 topic은 단 한번만 실행되고 각 vow가 모두 topic의 결과를 전달 받습니다.


{ topic: new(DataStore),
  'should respond to `get()` and `put()`': function (store) {
    assert.isFunction (store.get);
    assert.isFunction (store.put);
  },
  'calling `get(42)`': {
    topic: function (store) { return store.get(42) },
    'should return the object with id 42': function (topic) {
      assert.equal (topic.id, 42);
    }
  }
}

위의 소스에서 처럼 topic은 부모 topic의 결과를 받을 수 있기 때문에 필요한 대로 계층화 해서 사용할 수 있습니다. 위에서 calling get(42) 서브 컨텍스트에 있는 topic은 부모 topic의 값인 new(DataStore)를 전달 받습니다.  바로위의 topic의 결과부터 아규먼트로 순서대로 받게 되고 아래와 같은 형태가 될 수 있습니다.


topic: function (a, /* 부모 topic */
                 b, /* 부모의 부모 topic */
                 c  /* 부모의 부모의 부모 topic */) {}


Suite 실행하기
아래와 같이 run 메서드를 이용해서 Test Suite를 실행할 수 있습니다.

vows.describe('subject').addBatch({/* ... */}).run();

run() 메서드에는 callback함수를 등록할 수 있고 모든 테스트가 종료되었을때 콜백이 실행되는데 아래와 같은 테스트 결과가 콕백의 파라미터로 전달됩니다.

{ honored: 145,
  broken:    4,
  errored:   1,
  pending:   0,
  total:   150,
  time:  5.491
};

이렇게 작성된 Test Suite는 아래와 같이 node명령어를 사용해서 실행할 수 있습니다.

$ node subject-test.js


Suite를 외부로 내보내기
테스트파일이 많아지게 되면 한번에 여러개의 테스트파일들을 실행할 필요가 있어집니다. Vows에서는 vows라는 테스트러너를 제공하고 있고 테스트러너를 외부에서 사용할 수 있도록 내보내야 합니다.

// subject-test.js
vows.describe('subject').addBatch({/* ... */}).export(module);

가장 간단한 방법은 위처럼 node.js의 module객체를 사용해서 외부로 내보내는 것입니다.

$ vows subject-test.js

위처럼 테스트러너를 이용해서 테스트를 실행할 수 있습니다.

$ vows test/*

위와 같이 실행하면 test/폴더에 있는 모든 테스트를 실행할 수 있으며 아래와 같은 옵션을 사용해서 다양한 형태의 결과를 리포팅 받을 수 있습니다.

  • -v, --verbose : Verbose 모드
  • -w, --watch : Watch 모드
  • -m STRING : 문자열 매칭: 제목에 STRING이 있는 테스트만 실행
  • -r REGEXP : 정규식 매칭: 제목이 REGEXP에 맞는 테스트만 실행
  • --json : JSON 리포터를 사용
  • --spec : Spec 리포터를 사용
  • --dot-matrix : Dot-Matrix 리포터를 사용
  • --version : 버전을 보여준다
  • -s, --silent : 리포팅하지 않는다
  • --help : 도움말을 보여준다
Test Sute은 다음과 같이 exports에 추가하는 방식으로도 내보낼 수 있는데 이는 일반적인 node.js에서 객체를 내보내는 것과 같은 방법이다.

exports.suite1 = vows.describe('suite one');
exports.suite2 = vows.describe('suite two');


비동기 테스트 작성하기
node.js에서는 대부분의 I/O를 비동기로 실행하기 때문에 비동기 함수에 대한 테스트는 return값 대신에 콜백함수로 전달되기 때문에 앞에서 본 내용만으로는 테스트 할 수 없습니다.


{ topic: function () {
    fs.stat('~/FILE', this.callback);
  },
  'can be accessed': function (err, stat) {
    assert.isNull   (err);        // 오류 없음
    assert.isObject (stat);     // stat 객체를 가지고 있음
  },
  'is not empty': function (err, stat) {
    assert.isNotZero (stat.size); // 파일사이즈가 0보다 크다
  }
}

위의 예제에서 보는것처럼 this.callback을 사용해서 topic에서 비동기에 대한 테스트를 할 수 있도록 지원되고 있습니다. this.callback이 호출되면 topic에서 리턴하는 것처럼 각 vow에 파라미터로 전달이 됩니다. 이는 비동기 함수에 대한 호출과 콜백의 의존성을 완전히 없앨 수 있게 만들어줍니다. 단 this.callback을 사용하는 topic은 반드시 아무것도 리턴하지 않아야 합니다.


{ topic: function () {
    var promise = new(events.EventEmitter);

    fs.stat('~/FILE', function (e, res) {
        if (e) { promise.emit('error', e) }
        else   { promise.emit('success', res) }
    });
    return promise;
  },
  'can be accessed': function (err, stat) {
    assert.isNull   (err);        // 오류 없음
    assert.isObject (stat);     // stat 객체를 가지고 있음
  },
  'is not empty': function (err, stat) {
    assert.isNotZero (stat.size); // 파일사이즈가 0보다 크다
  }
}

또한 Vows는 promise기반의 비동기도 지원하기 때문에 위와같이 이벤트기반으로도 테스트를 작성할 수 있습니다. 위의 예제에서는 vow가 error이나 success이벤트가 발생했을때 실행됩니다.


테스트의 실행 순서

{ topic: function () {
    fs.stat('~/FILE', this.callback);
  },
  '`fs.stat`가 성공한 후에': {
    topic: function (stat) {
      fs.open('~/FILE', "r", stat.mode, this.callback);
    },
    '`fs.open`가 성공한 후에': {
      topic: function (fd, stat) {
        fs.read(fd, stat.size, 0, "utf8", this.callback);
      },
      '`fs.read`의 결과로 파일의 내용을 얻는다': function (data) {
        assert.isString (data);
      }
    }
  }
}

앞의 내용을 종합하면 각 topic의 부모들의 topic을 전달받으면서 순차적으로 실행될 수 있습니다.


{ '/dev/stdout': {
    topic:    function () { path.exists('/dev/stdout', this.callback) },
    'exists': function (result) { assert.isTrue(result) }
  },
  '/dev/tty': {
    topic:    function () { path.exists('/dev/tty', this.callback) },
    'exists': function (result) { assert.isTrue(result) }
  },
  '/dev/null': {
    topic:    function () { path.exists('/dev/null', this.callback) },
    'exists': function (result) { assert.isTrue(result) }
  }
}

위의 테스트 같은 경우는 모든 컨텍스나 병렬로 수행되면서 임의의 순서대로 종료되기 때문에 서로 의존관계를 갖지 않아야 합니다. Test Suite는 마지막 테스트가 종료되었을때 끝이납니다. 정리하면 형제관계의 컨텍스트는 병렬로 실행되지만 중첩된 컨텍스트는 순차적으로 실행됩니다.


Assertion
Vows는 node.js에 내장된 assert모듈을 추가적인 유용한 함수와 리포팅으로 확장해서 제공하고 있습니다. 테스트를 할때는 항상 더 명확한 assertion 함수를 사용하는 것이 좋습니다.

var ary = [1, 2, 3];

예를 들어 위와 같은 배열을 테스트 한다고 할 때

assert.equal(ary.length, 5);

assert.equal을 이용해서 위와 같이 assert를 하면 아래와 같은 결과를 받을 것입니다.

expected 5, got 3

만약 아래처럼 좀더 명확한 assert를 사용하게 되면,

assert.length(ary, 5);

아래와 같은 더 명확한 리포팅을 받을 수 있습니다.

expected [1, 2, 3] to have 5 elements

다양한 Vows의 assertion함수들에 대해서는 [레퍼런스 문서](http://vowsjs.org/#reference)를 참고하면 됩니다.


매크로
같은 패턴의 테스트가 반복될 경우에는 추상화를 사용하면 훨씬 편리하게 테스트를 작성할 수 있습니다.


{ topic: function () {
    client.get('/resources/42', this.callback);
  },
  'should respond with a 200 OK': function (e, res) {
    assert.equal (res.status, 200);
  }
}

HTTP의 상태코드를 확인하는 위와같은 테스트가 있다고 하겠습니다. 잘 만든 테스트이지만 위와같은 테스트가 수십개가 있다면 이야기가 달라집니다. 이를 매크로를 이용해서 변경해 보겠습니다.


function assertStatus(code) {
    return function (e, res) {
        assert.equal (res.status, code);
    };
}

위 함수는 응답의 상태코드를 넘겨받은 상태코드와 맞는지 테스트해주는 함수를 리턴합니다. 위 매크로를 함수에서 앞의 테스트를 다시 작성하면 다음과 같이 됩니다.


{ topic: function () {
    client.get('/resources/42', this.callback);
  },
  'should respond with a 200 OK': assertStatus(200)
}

훨씬 간결해 졌습니다.


var api = {
    get: function (path) {
        return function () {
            client.get(path, this.callback);
        };
    }
};

topic부분도 매크로를 이용하기 위해서 API를 호출하는 위와 같은 함수를 작성했습니다.


{ topic: api.get('/resources/42'),
  'should respond with a 200 OK': assertStatus(200)
}

이제 테스트를 다시 작성하면 위처럼 간결해 집니다. 물론 이는 topic과 vow를 한꺼번에 리턴해주는 매크로를 작성해서 더욱 간단하게 만들수 있습니다.


Conclusion
Vows는 node.js에서는 꽤 인기 있는 테스트 프레임웍이고 약간 작성해본 결과 비동기가 상당히 많은 node.js에서는 꽤 편리하게 테스트를 작성할 수 있는 구조를 가지고 있습니다. 하지만 기존의 테스트프레임워크와는 관례가 상당히 다른 구조를 취하고 있기 때문에 익숙해 지고 구조를 파악하는데 약간의 시간은 걸리는 듯 합니다. 매크로 같은 경우도 분편히 편리함을 주기는 하지만 복잡한 중첩 관계에서 잘못사용하면 테스트의 난해함을 줄 수도 있을것 같습니다. 아직까지는 구조가 잘 적응안되서 헤메고 있기는 한데 어쨌든 당분간은 Vows를 사용해서 테스트를 작성할 듯 합니다.
2011/08/07 23:26 2011/08/07 23:26