Skip to content

Commit

Permalink
Initial implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
mmelentiev-mail committed May 28, 2019
1 parent 8ef257f commit 101f408
Show file tree
Hide file tree
Showing 14 changed files with 895 additions and 3 deletions.
76 changes: 75 additions & 1 deletion README.md
Expand Up @@ -2,9 +2,83 @@

Tool for testing tarantool applications.

This rock is based on [https://github.com/bluebird75/luaunit](luanit) and additionaly provides:

- executable to run tests in directory or specific files,
- before/after suite hooks,
- before/after test group hooks,
- output capturing.

Please refer to [https://luaunit.readthedocs.io/en/latest/](luanit docs) for original features and examples.

## Requirements

- Tarantool (it requires tarantool-specific `fio` module and `ffi` from LuaJIT).

## Usage

Define tests.

```lua
-- test/feature_test.lua
local luatest = require('luatest')
local t = luatest.group('feature')

-- Define suite hooks. can be called multiple times to define hooks from different files
luatest.before_suite(function() ... end)
luatest.before_suite(function() ... end)

-- Hooks to run once for tests group
-- This hooks run always when test class is changed.
-- So it may run multiple times when --shuffle otion is used.
t.before_all = function() ... end
t.after_all = function() ... end

-- Hooks to run for each test in group
t.setup = function() ... end
t.teardown = function() ... end

-- Tests. All properties with name staring with `test` are treated as test cases.
t.test_example_1 = function() ... end
t.test_example_n = function() ... end

-- test/other_test.lua
local luatest = require('luatest')
local t = luatest.group('other')
-- ...
t.test_example_2 = function() ... end
t.test_example_m = function() ... end
```

Run them.

```
luatest # all in ./test direcroy
luatest test/feature_test.lua # by file
luatest test/integration # all within directory
luatest test/ -f # luaunit options can be passed after test path
luatest feature other.test_example_2 # by group or test name
```

If `luatest` executable does not appear in $PATH after installing the rock,
it can be found in `.rocks/bin/luatest`.

## Capturing output

By default runner captures all stdout/stderr output and shows it only for failed tests.
Capturing can be disabled with `-c` flag.

## Known issues

- When `before_all/after_all` hook fails with error, all other tests even from other classes
are not executed.
- Process hangs when there is a lot of output within single test.

## Development

Install luacheck with `luarocks install luacheck`.
- Install luacheck with `luarocks install luacheck`.
- Run it with `luacheck ./` before commiting changes.
- Run tests with `bin/luatest`.

## Contributing

Expand Down
4 changes: 4 additions & 0 deletions bin/luatest
@@ -0,0 +1,4 @@
#!/usr/bin/env tarantool

local runner = require('luatest').runner
os.exit(runner:run() and 0 or 1)
17 changes: 15 additions & 2 deletions luatest-scm-1.rockspec
Expand Up @@ -13,6 +13,19 @@ dependencies = {
'lua >= 5.1',
}
build = {
type = 'builtin',
modules = {}
type = 'none',
install = {
lua = {
['luatest'] = 'luatest/init.lua',
['luatest.capture'] = 'luatest/capture.lua',
['luatest.capturing'] = 'luatest/capturing.lua',
['luatest.hooks'] = 'luatest/hooks.lua',
['luatest.loader'] = 'luatest/loader.lua',
['luatest.luaunit'] = 'luatest/luaunit.lua',
['luatest.utils'] = 'luatest/utils.lua',
},
bin = {
['luatest'] = 'bin/luatest',
},
}
}
143 changes: 143 additions & 0 deletions luatest/capture.lua
@@ -0,0 +1,143 @@
-- Module to capture output. It works by replacing stdout and stderr file
-- descriptors with pipes inputs.

local ffi = require('ffi')

ffi.cdef([[
int pipe(int fildes[2]);
int dup(int oldfd);
int dup2(int oldfd, int newfd);
int fileno(struct FILE *stream);
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fildes, const void *buf, size_t nbyte);
]])

local function create_pipe()
local fildes = ffi.new('int[?]', 2)
if ffi.C.pipe(fildes) ~= 0 then
error('pipe call failed')
end
return fildes
end

-- Duplicate lua's io object to new fd.
local function dup_io(file)
local newfd = ffi.C.dup(ffi.C.fileno(file))
if newfd < 0 then
error('dup call failed')
end
return newfd
end

local READ_BUFFER_SIZE = 65536

local function read_fd(fd)
local buffer = ffi.new('char[?]', READ_BUFFER_SIZE)
local count = ffi.C.read(fd, buffer, READ_BUFFER_SIZE)
if count < 0 then
error('read pipe failed')
end
return ffi.string(buffer, count)
end

-- It's not possible to implement platform-independent select/poll using ffi
-- because of macros and constant usage. To avoid blocking read call we put
-- character to pipe and remove it from result.
local function read_pipe(pipe)
if ffi.C.write(pipe[1], ' ', 1) ~= 1 then
error('write to pipe failed')
end
local result = read_fd(pipe[0])
if result:len() < READ_BUFFER_SIZE then
return result:sub(1, -2)
end
local suffix = read_pipe(pipe)
if suffix:len() > 0 then
return result .. suffix
else
return result:sub(1, -2)
end
end

local Capture = {}

function Capture:new()
local object = {}
setmetatable(object, self)
self.__index = self
object.enabled = false
return object
end

-- Overwrite stdout and stderr fds with pipe inputs.
-- Original fds are copied into original_fds, to be able to restore them later.
function Capture:enable(raise)
if self.enabled then
if raise then
error('Already capturing')
end
return
end
if not self.pipes then
self.pipes = {stdout = create_pipe(), stderr = create_pipe()}
self.original_fds = {stdout = dup_io(io.stdout), stderr = dup_io(io.stderr)}
end
io.flush()
ffi.C.dup2(self.pipes.stdout[1], ffi.C.fileno(io.stdout))
ffi.C.dup2(self.pipes.stderr[1], ffi.C.fileno(io.stderr))
self.enabled = true
end

-- Restore original fds for stdout and stderr.
function Capture:disable(raise)
if not self.enabled then
if raise then
error('Not capturing')
end
return
end
io.flush()
ffi.C.dup2(self.original_fds.stdout, ffi.C.fileno(io.stdout))
ffi.C.dup2(self.original_fds.stderr, ffi.C.fileno(io.stderr))
self.enabled = false
end

-- Enable/disable depending on passed value.
function Capture:set_enabled(value)
if value then
self:enable()
else
self:disable()
end
end

-- Read from capture pipes and return results.
function Capture:flush()
io.flush()
return {
stdout = read_pipe(self.pipes.stdout),
stderr = read_pipe(self.pipes.stderr),
}
end

-- Run function with enabled/disabled capture and restore previous state.
-- In the case of error it prints error to original stdout.
function Capture:wrap(enabled, fn)
local old = self.enabled
local result = {xpcall(function()
self:set_enabled(enabled)
local result = fn()
return result
end, function(err)
self:disable()
io.stderr:write(tostring(err) .. '\n')
io.stderr:write(tostring(debug.traceback()) .. '\n')
end)}
self:set_enabled(old)
return unpack(result)
end

return Capture
61 changes: 61 additions & 0 deletions luatest/capturing.lua
@@ -0,0 +1,61 @@
local utils = require('luatest.utils')

-- Patch luaunit to capture output in tests and show it only for failed ones.
return function(lu, capture)
utils.patch(lu.LuaUnit, 'startSuite', function(super) return function(self, ...)
super(self, ...)
capture:enable()
capture:flush()
end end)

utils.patch(lu.LuaUnit, 'endSuite', function(super) return function(self, ...)
capture:flush()
capture:disable()
super(self, ...)
end end)

utils.patch(lu.LuaUnit, 'startTest', function(super) return function(self, ...)
capture:enable()
capture:flush()
super(self, ...)
end end)

utils.patch(lu.LuaUnit, 'endTest', function(super) return function(self, ...)
local node = self.result.currentNode
if capture.enabled then
node.capture = capture:flush()
end
capture:disable()
super(self, ...)
capture:enable()
end end)

utils.patch(lu.LuaUnit, 'startClass', function(super) return function(self, ...)
super(self, ...)
capture:enable()
capture:flush()
end end)

utils.patch(lu.LuaUnit, 'endClass', function(super) return function(self, ...)
super(self, ...)
capture:flush()
capture:disable()
end end)

local function print_capture(name, text)
if text and text:len() > 0 then
print('Captured ' .. name .. ':')
print(text)
print()
end
end

local TextOutput = lu.LuaUnit.outputType
utils.patch(TextOutput, 'displayOneFailedTest', function(super) return function(self, index, node)
super(self, index, node)
if node.capture then
print_capture('stdout', node.capture.stdout)
print_capture('stderr', node.capture.stderr)
end
end end)
end
40 changes: 40 additions & 0 deletions luatest/hooks.lua
@@ -0,0 +1,40 @@
local utils = require('luatest.utils')

-- suite hooks
local function define_suite_hooks(lu, type)
local hooks = {}
lu[type .. '_hooks'] = hooks

lu[type] = function(fn)
table.insert(hooks, fn)
end

lu['run_' .. type] = function()
for _, fn in ipairs(hooks) do
fn()
end
end
end

-- test group (class) hooks
local function run_class_callback(runner, className, type)
local classInstance = runner.testsContainer()[className]
local func = classInstance and classInstance[type]
return func and func()
end

-- Adds suite and test group hooks.
return function(lu)
define_suite_hooks(lu, 'before_suite')
define_suite_hooks(lu, 'after_suite')

utils.patch(lu.LuaUnit, 'startClass', function(super) return function(self, className)
super(self, className)
run_class_callback(self, className, 'before_all')
end end)

utils.patch(lu.LuaUnit, 'endClass', function(super) return function(self)
run_class_callback(self, self.lastClassName, 'after_all')
super(self)
end end)
end
1 change: 1 addition & 0 deletions luatest/init.lua
@@ -1,3 +1,4 @@
local luaunit = require('luatest.luaunit')
luaunit.runner = require('luatest.runner')

return luaunit

0 comments on commit 101f408

Please sign in to comment.