apache/trafficserver — pull request #131 by duderino (2014-10-20 03:41:05+00:00)
Title: Add initial functional test framework with example test case.

Hi guys,

This is a first stab at a functional test framework. My intention is to stimulate discussion for the test framework talk at the ATS Summit. I won't be shattered if this pull request is never be merged.

This currently consists of a bit of framework code and an example functional test that uses the framework.

All code is written in nodejs. That may be the most controversial part.

# prerequisites
* Install nodejs. Example:

$ wget http://nodejs.org/dist/v0.10.32/node-v0.10.32.tar.gz
$ tar xvfz node-v0.10.32.tar.gz
$ cd node-v0.10.32
$ ./configure
$ make
$ sudo make install
$ node -v
v0.10.32
$ npm -v
1.4.28

* Install the mocha unit test framework. Example:

$ sudo npm install mocha -g

# tests/framework/atstf.js:

This is the test framework's process manager. The process manager is responsible for starting and stoping the various processes used by the test suite.

It is driven by a JSON config file that describes the processes used by the test case. Individual test cases can also read this config file to get server ports, hostnames, etc.

## configure it

The process manager currently supports the following process types:

* ats. This can start the traffic_server in the local source repository or a traffic_server anywhere on the local file system. The traffic_server will look for its config files in the 'root' directory and put its log files there too.

Example:

{
"servers": {
"ats1": {
"type": "ats",
"root": "ats1",
"interfaces": {
"http": {
"type": "http",
"hostname": "localhost",
"port": 8080
}
}
}
}
}

* origin. A simple HTTP/1.1 origin with behavior determined by the test JSON config file. Its behavior can be configured on a per method, per abs_path basis. The following behavior can currently be configured:
1. response status code
1. response headers
1. number of chunks to send in the response (only chunked xfer encoding currently supported)
1. size of each chunk
1. byte value of each byte in chunk
1. milliseconds to wait before sending the first chunk
1. milliseconds to wait before sending all subsequent chunks

Example:

{
"servers": {
"origin1": {
"type": "origin",
"interfaces": {
"http": {
"type": "http",
"hostname": "localhost",
"port": 8082
}
},
"actions": {
"GET": {
"/foo/bar": {
"status_code": 200,
"headers": {
},
"delay_first_chunk_millis": 0,
"chunk_size_bytes": 1024,
"num_chunks": 10,
"delay_between_chunk_millis": 0,
"chunk_byte_value": 99
}
}
}
}
}
}

* node. An adhoc nodejs script. When the configurable origin doesn't give you the control you need, you can whip out a little nodejs origin. Or perhaps you could introduce a nodejs proxy that simulates a slow connection?

Example:

{
"servers": {
"origin2": {
"type": "node",
"script": "origin.js",
"args": [
"origin8082.json"
],
"interfaces": {
"http": {
"type": "http",
"port": 8082
}
}
}
}
}

* other. Any executable that exits when you send it a SIGTERM, exits 0 on success, and non-zero on error.

Example:

{
"servers": {
"origin3": {
"type": "other",
"executable": "/usr/local/apache2/bin/httpd",
"args": [
"-d", "/usr/local/apache2"
]
}
}
}

## use it

This is how you can instantiate the process manager:

var repo_root = '../..';

var tf = require(repo_root + '/tests/framework/atstf.js').atstf_new({
ats_path: repo_root + '/proxy/traffic_server',
origin_path: repo_root + '/tests/framework/origin.js',
config_path: './config.json',
log_cb: function(thresh, data) {
console.error(thresh + ': ' + data);
}
});

While the process manager can be used with any nodejs test framework, the following examples use the [mocha](http://visionmedia.github.io/mocha/) unit test framework.

The process manager should be started before any test cases are run, and stopped after all finish:

describe('Example', function() {
before(function(done) {
tf.start(done);
});

after(function(done) {
tf.stop(done);
});

it('test_case', function() {
...
});
});

# Example

## tests/example/config.json

In our example we ask the process manager to start three processes for us; 2 distinct ATS processes (ats1, ats2) listening on different ports and a configurable origin (origin1). ats1 is configured to proxy to origin1, but ats2 is not.

{
"servers": {
"ats1": {
"type": "ats",
"root": "ats1",
"interfaces": {
"http": {
"type": "http",
"hostname": "localhost",
"port": 8080
}
}
},
"ats2": {
"type": "ats",
"root": "ats2",
"interfaces": {
"http": {
"type": "http",
"hostname": "localhost",
"port": 8081
}
}
},
"origin1": {
"type": "origin",
"interfaces": {
"http": {
"type": "http",
"hostname": "localhost",
"port": 8082
}
},
"actions": {
"GET": {
"/foo/bar": {
"status_code": 200,
"headers": {
},
"delay_first_chunk_millis": 0,
"chunk_size_bytes": 1024,
"num_chunks": 10,
"delay_between_chunk_millis": 0,
"chunk_byte_value": 99
}
}
}
}
}
}

## tests/example/test.js

Our example test case is pretty contrived. It asserts that ats1 correctly proxies every byte in every chunk from origin1. It also asserts that ats2 returns a 404 for the same request.

Note that everything here is asynchronous. All test cases are passed a 'done' callback and have to invoke it (i.e., done()) to tell the mocha test framework that the test case has finished. Mocha will mark the test as failed if done isn't called in 2 seconds, but this timeout can be increased.

var log = function(thresh, data) {
// print to stderr to keep distinct from generate test report
console.error(thresh + ': ' + data);
};

var repo_root = '../..';
var tf = require(repo_root + '/tests/framework/atstf.js').atstf_new({
ats_path: repo_root + '/proxy/traffic_server',
origin_path: repo_root + '/tests/framework/origin.js',
config_path: './config.json',
log_cb: log
});
var assert = require("assert");
var client = require('http').request;

// See http://visionmedia.github.io/mocha for details on this test framework.

describe('Example', function() {
before(function(done) {
tf.start(done);
});

after(function(done) {
tf.stop(done);
});

it('ats1', function(done) {
// Where is ats1?
var host = tf.config.servers.ats1.interfaces.http.hostname;
var port = tf.config.servers.ats1.interfaces.http.port;

// Path to origin1
var path = '/foo/bar';

// What is origin1 configured to send?
var action = tf.config.servers.origin1.actions.GET[path];
var status_code = action.status_code;
var bytes_to_receive = action.chunk_size_bytes * action.num_chunks;
var chunk_byte_value = action.chunk_byte_value;

var req = client(
{
hostname: host,
port: port,
path: path,
method: 'GET'
},
function(res) {
log('DEBUG', 'STATUS: ' + res.statusCode);
log('DEBUG', 'HEADERS: ' + JSON.stringify(res.headers));

var bytes_received = 0;

assert.equal(status_code, res.statusCode);

res.on('data', function(chunk) {
for (var i = 0; i < chunk.length; ++i) {
assert.equal(chunk_byte_value, chunk[i]);
}

bytes_received += chunk.length;

if (bytes_received >= bytes_to_receive) {
// End test (or timeout and fail)
done();
}
});
});

req.on('error', function(e) {
assert.fail(e, null, 'socket error');
});

req.end();
});

it('ats2', function(done) {
var host = tf.config.servers.ats2.interfaces.http.hostname;
var port = tf.config.servers.ats2.interfaces.http.port;
var path = '/foo/bar';

var req = client(
{
hostname: host,
port: port,
path: path,
method: 'GET'
},
function(res) {
log('DEBUG', 'STATUS: ' + res.statusCode);
log('DEBUG', 'HEADERS: ' + JSON.stringify(res.headers));

assert.equal(404, res.statusCode);

done();
});

req.on('error', function(e) {
assert.fail(e, null, 'socket error');
});

req.end();
});
});

Note also how the test case accesses the process manager's JSON config file to learn the hostnames and ports of ats1 and ats2. As long as the test cases refrain from hardcoding connection details they can be turned into remote test suites with only config changes.

The main trick is making the process manager not start the ats processes. This can be accomplished either by not starting the process manager at all or by telling the process manager not to start a specific process with a 'spawn: false'. Example:

{
"servers": {
"ats1": {
"type": "ats",
"root": "ats1",
"spawn": false,
"interfaces": {
"http": {
"type": "http",
"hostname": "ats1.example.com",
"port": 8080
}
}
}
}
}

## tests/example/GNUmakefile

NB: This trivial makefile should be replaced with its automake equivalent.

test:
@mocha --reporter tap test.js > report.tap

clean:
@rm -rf ats1/var ats2/var report.xml

The call to mocha is here. Note the '--reporter'. The 'tap' reporter is compatible with jenkins and my experience has been that its more robust than the 'xunit' reporter (which generates junit XML reports).

An example tap report:

1..2
ok 1 Example ats1
ok 2 Example ats2
# tests 2
# pass 2
# fail 0

## tests/example/ats1

This is the root directory for ats1. Each ATS process needs a distinct root directory.

Checked in under the root directory are the full config files for ats1. The layout should look familiar to anyone who has configured ATS before.

Example:

$ tail ats1/etc/trafficserver/remap.config
map /foo/bar http://localhost:8082/foo/bar

The var directory will also be created here. Every run will wipe out this directory and recreate it so prior test runs won't affect subsequent test runs. If a test fails it may help to look at the local logs.

Example:

$ tail ats1/var/log/trafficserver/diags.log
[Oct 19 16:54:18.852] Server {0x7fc248e86800} NOTE: cache enabled

## tests/example/ats2

You get the idea...