UnitTest in Nodejs
实战Nodejs单元测试

大纲

为什么要单元测试

明显没错的代码

connect-redis.js#L80

  RedisStore.prototype.get = function(sid, fn){
    sid = this.prefix + sid;
    this.client.get(sid, function(err, data){
      try {
        if (!data) return fn();
        fn(null, JSON.parse(data.toString()));
      } catch (err) {
        fn(err);
      } 
    });
  };

没明显错的代码

node-mysql/lib/query.js#L65

if (buffer) {
  row[field.name] += buffer.toString('utf-8');
} else {
  row[field.name] = null;
}

明显有错的代码

node0.6.6 - lib/http.js

Agent.prototype.removeSocket = function(s, name, host, port) {
  if (this.sockets[name]) {
    var index = this.sockets[name].indexOf(s);
    if (index !== -1) {
      this.sockets[name].splice(index, 1);
    }
  } else if (this.sockets[name] && this.sockets[name].length === 0) {
    // don't leak
    delete this.sockets[name];
    delete this.requests[name];
  }
  if (this.requests[name] && this.requests[name].length) {
    // If we have pending requests and a socket gets closed a new one
    // needs to be created to take over in the pool for the one that closed.
    this.createSocket(name, host, port).emit('free');
  }
};

为什么要单元测试

代码质量

代码质量如何度量?
如果没有测试你如何保证你的代码质量?

敏捷快速地适应需求

单元测试是否也能让产品经理看得懂?
单元测试是否也能成功一个产品需求的Case?

重构

你有足够信心在没有单元测试的情况下发布你的重构代码吗?
如何检测你重构的代码符合需要?

增强自信心

全是绿灯!
单元测试全部跑通!

眼花缭乱的Nodejs测试模块

unit test in npm

Testing / Spec Frameworks

BDD: behaviour-driven development

如何选择

Mocha ,我喜欢

Mocha's Features

Mocha 强大的特性列表

should.js 我应该

我承认,我是@TJ 忠实粉丝...
还有,我喜欢 should 的方式:

A cup of Mocha,
test cases should pass.

实战短址还原的单元测试

短址还原: urlrar
preview

代码目录,创建响应空文件

├─┬ lib/
│  └── urllib.js
├─┬ test/
│  ├─┬ support/
│  │  └── http.js (非常方便地测试 http 请求)
│  ├── app.test.js
│  ├── mocha.opts
│  └── urllib.test.js
├── app.js
├── index.html
├── Makefile
├── package.json
└── RERAME.md

Makefile

SRC = $(shell find lib -type f -name "*.js")
TESTS = test/*.js
TESTTIMEOUT = 5000
REPORTER = spec

test:
  @NODE_ENV=test ./node_modules/.bin/mocha \
    --reporter $(REPORTER) --timeout $(TESTTIMEOUT) $(TESTS)

.PHONY: test

运行测试

$ make test

mocha.opts

自定义mocha更多参数,例如自动引用一些测试依赖的模块

--require node_modules/should
--require test/support/http.js

方便地进行 http 测试

test/support/http.js

app.request()
.get('/foo')
.set('x-userid', 'mk2')
.end(function(res) {
  res.should.be.ok;
  res.statusCode.should.equal(200);
  res.should.status(200);
  res.body.should.be.an.instanceof(Buffer);
  res.headers.should.be.a('object');
  res.should.have.header('X-Power-By', 'Nodejs');
  res.should.have.not.header('Set-Cookie');
});

确定需求和应用功能

需求

应用功能

行为驱动开发: 实现 “主页面显示介绍和表单”

直接写测试吧:test/app.test.js

var app = require('../app');

describe('urlrar app', function() {
  before(function(done) {
    app.listen(0, done);
  });

将需求变成测试用例

  it('GET / should show the title, a form and a text input', function(done) {
    app.request().get('/').end(function(res) {
      res.should.status(200);
      res.should.header('X-Power-By', 'Nodejs');
      var body = res.body.toString();
      // 主页面显示介绍和表单
      body.should.include('<title>Shorten URL Expand</title>');
      body.should.include('<form');
      body.should.include('</form>');
      body.should.include('<input');
      done();
    });
  });
});

疯了吧?!直接运行测试

$ make test

first test error

实现app.js

var http = require('http');
var parse = require('url').parse;
var fs = require('fs');

var indexHtml = fs.readFileSync('./index.html');

var app = http.createServer(function(req, res) {
  res.setHeader('X-Power-By', 'Nodejs');
  var info = parse(req.url, true);
  if (info.pathname === '/') {
    res.setHeader('Content-Type', 'text/html');
    res.end(indexHtml);
  } 
});

module.exports = app;

再次运行测试

$ make test

index page run success

将应用API和404页面完成

  it('GET /api should have an api', function(done) {
    app.request().get('/api').end(function(res) {
      res.should.status(200);
      res.should.header('X-Power-By', 'Nodejs');
      done();
    });
  });
  it('GET /other should not found the page', function(done) {
    app.request().get('/noexists').end(function(res) {
      res.should.status(404);
      res.should.header('X-Power-By', 'Nodejs');
      res.body.toString().should.equal('Page Not Found!');
      done();
    });
  });

3 more tests

实现还原功能

lib/urllib.js 模块来处理

使用方式将大致想象为如下:

var urllib = require('./lib/urllib');
urllib.expand(shortenURL, function(err, longURL, redirectCount) {
  // go on...
});

urllib.test.js

Test Cases

var mapping = [ 
  [ 'http://www.baidu.com/', 'http://www.baidu.com/' ],
  [ 'http://t.cn/StVkqS', 'http://nodejs.org/community/' ],
  [ 'http://url.cn/48JGfK', 'http://baike.baidu.com/view/6341048.htm' ],
  [ 'http://t.cn/aK1IFu', 'http://v.youku.com/v_show/id_XMjc2MjY1NjEy.html' ],
   // 2 times redirect
  [ 'http://url.cn/3OMI3O', 'http://v.youku.com/v_show/id_XMjc2MjY1NjEy.html', 2 ],
  [ 'http://luo.bo/17221/', 'http://luo.bo/17221/' ],
  [ 'http://t.itc.cn/LLHD6', 'http://app.chrome.csdn.net/work_detail.php?id=57' ],
];

正常使用方式测试

var desc = 'should expand ' + mapping.length + ' shorten urls success';
it(desc, function(done) {
  var counter = 0;
  mapping.forEach(function(map) {
    urllib.expand(map[0], function(err, longurl, redirectCounter) {
      should.not.exist(err);
      map[1].should.equal(longurl);
      if (map[2]) {
        redirectCounter.should.equal(map[2]);
      }
      if (++counter === mapping.length) {
        done();
      }
    })
  })
})

urllib.js#expand()实现

exports.expand = function(url, callback) {
  var info = parse(url);
  var options = {
    hostname: info.hostname,
    path: info.path,
    method: 'HEAD'
  };
  var request = info.protocol === 'https:' ? 
    https.request : http.request;
  var req = request(options);

urllib.js#expand()实现2

  if (callback.__redirectCounter === undefined) {
    callback.__redirectCounter = 0;
  }
  req.on('response', function(res) {
    if (res.statusCode === 301 || res.statusCode === 302) {
      var location = res.headers['location'];
      if (++callback.__redirectCounter > exports.maxRedirect) {
        return callback(null, location, callback.__redirectCounter);
      }
      return exports.expand(location, callback);
    }
    callback(null, url, callback.__redirectCounter);
  });
  req.end();
};

exports.maxRedirect = 5;

非法输入参数情况

it('should return empty string when shorturl set wrong', function(done) {
  urllib.expand('', function(err, longurl) {
    should.not.exist(err);
    should.not.exist(longurl);
    done();
  })
});

it('should throw error when pass null', function() {
  try {
    urllib.expand();
  } catch(e) {
    e.name.should.equal('TypeError');
    e.message.should.equal('undefined is not a function');
  }
  (function() {
    urllib.expand();
  }).should.throw();
  (function() {
    urllib.expand(null);
  }).should.throw();
});

服务器异常怎么办?

  describe('#expand() server Error', function() {
    var app = http.createServer(function(req, res) {
      res.destroy();
    });

    before(function(done) {
      app.listen(0, done);
    });

    it('should return error when server error', function(done) {
      var url = 'http://localhost:' + app.address().port + '/foo';
      urllib.expand(url, function(err, longurl) {
        should.exist(err);
        err.should.be.an.instanceof(Error);
        err.message.should.equal('connect ECONNREFUSED');
        done();
      });
    });
  });

处理异常

  var req = request(options);
  req.on('error', function(err) {
    callback(err, url, callback.__redirectCounter);
  });
  req.on('response', function(res) {
    // ...

实现 API 功能

测试先行

it('GET /api?u=http://t.cn/StVkqS should worked', function(done) {
  app.request()
  .get('/api?u=http://t.cn/StVkqS')
  .end(function(res) {
    res.should.status(200);
    res.body.toString().should.equal('http://nodejs.org/community/');
    done();
  });
});

实现代码

var app = http.createServer(function(req, res) {
  // ...

  if (info.pathname === '/api') {
    var query = info.query;
    if (!query.u) {
      return res.end('`u` argument required.')
    }
    urllib.expand(query.u, function(err, longurl) {
      if (query.cb) {
        longurl = query.cb + '(' + JSON.stringify(longurl) + ')';
      }
      res.end(longurl);
    });
    return;
  }

  // ...
});

绿灯通行

$ make test

test all pass

更多好的示例

QA === 知乎者也

/

#