diff --git a/tests/lester.lua b/tests/lester.lua new file mode 100644 index 0000000..d3f33b3 --- /dev/null +++ b/tests/lester.lua @@ -0,0 +1,599 @@ +--[[ +Minimal test framework for Lua. +lester - v0.1.5 - 18/Oct/2023 +Eduardo Bart - edub4rt@gmail.com +https://github.com/edubart/lester +Minimal Lua test framework. +See end of file for LICENSE. +]] + +--[[-- +Lester is a minimal unit testing framework for Lua with a focus on being simple to use. + +## Features + +* Minimal, just one file. +* Self contained, no external dependencies. +* Simple and hackable when needed. +* Use `describe` and `it` blocks to describe tests. +* Supports `before` and `after` handlers. +* Colored output. +* Configurable via the script or with environment variables. +* Quiet mode, to use in live development. +* Optionally filter tests by name. +* Show traceback on errors. +* Show time to complete tests. +* Works with Lua 5.1+. +* Efficient. + +## Usage + +Copy `lester.lua` file to a project and require it, +which returns a table that includes all of the functionality: + +```lua +local lester = require 'lester' +local describe, it, expect = lester.describe, lester.it, lester.expect + +-- Customize lester configuration. +lester.show_traceback = false + +-- Parse arguments from command line. +lester.parse_args() + +describe('my project', function() + lester.before(function() + -- This function is run before every test. + end) + + describe('module1', function() -- Describe blocks can be nested. + it('feature1', function() + expect.equal('something', 'something') -- Pass. + end) + + it('feature2', function() + expect.truthy(false) -- Fail. + end) + + local feature3_test_enabled = false + it('feature3', function() -- This test will be skipped. + expect.truthy(false) -- Fail. + end, feature3_test_enabled) + end) +end) + +lester.report() -- Print overall statistic of the tests run. +lester.exit() -- Exit with success if all tests passed. +``` + +## Customizing output with environment variables + +To customize the output of lester externally, +you can set the following environment variables before running a test suite: + +* `LESTER_QUIET="true"`, omit print of passed tests. +* `LESTER_COLOR="false"`, disable colored output. +* `LESTER_SHOW_TRACEBACK="false"`, disable traceback on test failures. +* `LESTER_SHOW_ERROR="false"`, omit print of error description of failed tests. +* `LESTER_STOP_ON_FAIL="true"`, stop on first test failure. +* `LESTER_UTF8TERM="false"`, disable printing of UTF-8 characters. +* `LESTER_FILTER="some text"`, filter the tests that should be run. + +Note that these configurations can be changed via script too, check the documentation. + +## Customizing output with command line arguments + +You can also customize output using command line arguments +if `lester.parse_args()` is called at startup. + +The following command line arguments are available: + +* `--quiet`, omit print of passed tests. +* `--no-quiet`, show print of passed tests. +* `--no-color`, disable colored output. +* `--no-show-traceback`, disable traceback on test failures. +* `--no-show-error`, omit print of error description of failed tests. +* `--stop-on-fail`, stop on first test failure. +* `--no-utf8term`, disable printing of UTF-8 characters. +* `--filter="some text"`, filter the tests that should be run. + +]] + +-- Returns whether the terminal supports UTF-8 characters. +local function is_utf8term() + local lang = os.getenv('LANG') + return (lang and lang:lower():match('utf%-?8$')) and true or false +end + +-- Returns whether a system environment variable is "true". +local function getboolenv(varname, default) + local val = os.getenv(varname) + if val == 'true' then + return true + elseif val == 'false' then + return false + end + return default +end + +-- The lester module. +local lester = { + --- Whether lines of passed tests should not be printed. False by default. + quiet = getboolenv('LESTER_QUIET', false), + --- Whether the output should be colorized. True by default. + color = getboolenv('LESTER_COLOR', true), + --- Whether a traceback must be shown on test failures. True by default. + show_traceback = getboolenv('LESTER_SHOW_TRACEBACK', true), + --- Whether the error description of a test failure should be shown. True by default. + show_error = getboolenv('LESTER_SHOW_ERROR', true), + --- Whether test suite should exit on first test failure. False by default. + stop_on_fail = getboolenv('LESTER_STOP_ON_FAIL', false), + --- Whether we can print UTF-8 characters to the terminal. True by default when supported. + utf8term = getboolenv('LESTER_UTF8TERM', is_utf8term()), + --- A string with a lua pattern to filter tests. Nil by default. + filter = os.getenv('LESTER_FILTER') or '', + --- Function to retrieve time in seconds with milliseconds precision, `os.clock` by default. + seconds = os.clock, +} + +-- Variables used internally for the lester state. +local lester_start = nil +local last_succeeded = false +local level = 0 +local successes = 0 +local total_successes = 0 +local failures = 0 +local total_failures = 0 +local skipped = 0 +local total_skipped = 0 +local start = 0 +local befores = {} +local afters = {} +local names = {} + +-- Color codes. +local color_codes = { + reset = string.char(27) .. '[0m', + bright = string.char(27) .. '[1m', + red = string.char(27) .. '[31m', + green = string.char(27) .. '[32m', + yellow = string.char(27) .. '[33m', + blue = string.char(27) .. '[34m', + magenta = string.char(27) .. '[35m', +} + +local quiet_o_char = string.char(226, 151, 143) + +-- Colors table, returning proper color code if color mode is enabled. +local colors = setmetatable({}, { __index = function(_, key) + return lester.color and color_codes[key] or '' +end}) + +--- Table of terminal colors codes, can be customized. +lester.colors = colors + +-- Parse command line arguments from `arg` table. +-- It `arg` is nil then the global `arg` is used. +function lester.parse_args(arg) + for _,opt in ipairs(arg or _G.arg) do + local name, value + if opt:find('^%-%-filter') then + name = 'filter' + value = opt:match('^%-%-filter%=(.*)$') + elseif opt:find('^%-%-no%-[a-z0-9-]+$') then + name = opt:match('^%-%-no%-([a-z0-9-]+)$'):gsub('-','_') + value = false + elseif opt:find('^%-%-[a-z0-9-]+$') then + name = opt:match('^%-%-([a-z0-9-]+)$'):gsub('-','_') + value = true + end + if value ~= nil and lester[name] ~= nil and (type(lester[name]) == 'boolean' or type(lester[name]) == 'string') then + lester[name] = value + end + end +end + +--- Describe a block of tests, which consists in a set of tests. +-- Describes can be nested. +-- @param name A string used to describe the block. +-- @param func A function containing all the tests or other describes. +function lester.describe(name, func) + if level == 0 then -- Get start time for top level describe blocks. + failures = 0 + successes = 0 + skipped = 0 + start = lester.seconds() + if not lester_start then + lester_start = start + end + end + -- Setup describe block variables. + level = level + 1 + names[level] = name + -- Run the describe block. + func() + -- Cleanup describe block. + afters[level] = nil + befores[level] = nil + names[level] = nil + level = level - 1 + -- Pretty print statistics for top level describe block. + if level == 0 and not lester.quiet and (successes > 0 or failures > 0) then + local io_write = io.write + local colors_reset, colors_green = colors.reset, colors.green + io_write(failures == 0 and colors_green or colors.red, '[====] ', + colors.magenta, name, colors_reset, ' | ', + colors_green, successes, colors_reset, ' successes / ') + if skipped > 0 then + io_write(colors.yellow, skipped, colors_reset, ' skipped / ') + end + if failures > 0 then + io_write(colors.red, failures, colors_reset, ' failures / ') + end + io_write(colors.bright, string.format('%.6f', lester.seconds() - start), colors_reset, ' seconds\n') + end +end + +-- Error handler used to get traceback for errors. +local function xpcall_error_handler(err) + return debug.traceback(tostring(err), 2) +end + +-- Pretty print the line on the test file where an error happened. +local function show_error_line(err) + local info = debug.getinfo(3) + local io_write = io.write + local colors_reset = colors.reset + local short_src, currentline = info.short_src, info.currentline + io_write(' (', colors.blue, short_src, colors_reset, + ':', colors.bright, currentline, colors_reset) + if err and lester.show_traceback then + local fnsrc = short_src..':'..currentline + for cap1, cap2 in err:gmatch('\t[^\n:]+:(%d+): in function <([^>]+)>\n') do + if cap2 == fnsrc then + io_write('/', colors.bright, cap1, colors_reset) + break + end + end + end + io_write(')') +end + +-- Pretty print the test name, with breadcrumb for the describe blocks. +local function show_test_name(name) + local io_write = io.write + local colors_reset = colors.reset + for _,descname in ipairs(names) do + io_write(colors.magenta, descname, colors_reset, ' | ') + end + io_write(colors.bright, name, colors_reset) +end + +--- Declare a test, which consists of a set of assertions. +-- @param name A name for the test. +-- @param func The function containing all assertions. +-- @param enabled If not nil and equals to false, the test will be skipped and this will be reported. +function lester.it(name, func, enabled) + -- Skip the test silently if it does not match the filter. + if lester.filter then + local fullname = table.concat(names, ' | ')..' | '..name + if not fullname:match(lester.filter) then + return + end + end + local io_write = io.write + local colors_reset = colors.reset + -- Skip the test if it's disabled, while displaying a message + if enabled == false then + if not lester.quiet then + io_write(colors.yellow, '[SKIP] ', colors_reset) + show_test_name(name) + io_write('\n') + else -- Show just a character hinting that the test was skipped. + local o = (lester.utf8term and lester.color) and quiet_o_char or 'o' + io_write(colors.yellow, o, colors_reset) + end + skipped = skipped + 1 + total_skipped = total_skipped + 1 + return + end + -- Execute before handlers. + for _,levelbefores in pairs(befores) do + for _,beforefn in ipairs(levelbefores) do + beforefn(name) + end + end + -- Run the test, capturing errors if any. + local success, err + if lester.show_traceback then + success, err = xpcall(func, xpcall_error_handler) + else + success, err = pcall(func) + if not success and err then + err = tostring(err) + end + end + -- Count successes and failures. + if success then + successes = successes + 1 + total_successes = total_successes + 1 + else + failures = failures + 1 + total_failures = total_failures + 1 + end + -- Print the test run. + if not lester.quiet then -- Show test status and complete test name. + if success then + io_write(colors.green, '[PASS] ', colors_reset) + else + io_write(colors.red, '[FAIL] ', colors_reset) + end + show_test_name(name) + if not success then + show_error_line(err) + end + io_write('\n') + else + if success then -- Show just a character hinting that the test succeeded. + local o = (lester.utf8term and lester.color) and quiet_o_char or 'o' + io_write(colors.green, o, colors_reset) + else -- Show complete test name on failure. + io_write(last_succeeded and '\n' or '', + colors.red, '[FAIL] ', colors_reset) + show_test_name(name) + show_error_line(err) + io_write('\n') + end + end + -- Print error message, colorizing its output if possible. + if err and lester.show_error then + if lester.color then + local errfile, errline, errmsg, rest = err:match('^([^:\n]+):(%d+): ([^\n]+)(.*)') + if errfile and errline and errmsg and rest then + io_write(colors.blue, errfile, colors_reset, + ':', colors.bright, errline, colors_reset, ': ') + if errmsg:match('^%w([^:]*)$') then + io_write(colors.red, errmsg, colors_reset) + else + io_write(errmsg) + end + err = rest + end + end + io_write(err, '\n\n') + end + io.flush() + -- Stop on failure. + if not success and lester.stop_on_fail then + if lester.quiet then + io_write('\n') + io.flush() + end + lester.exit() + end + -- Execute after handlers. + for _,levelafters in pairs(afters) do + for _,afterfn in ipairs(levelafters) do + afterfn(name) + end + end + last_succeeded = success +end + +--- Set a function that is called before every test inside a describe block. +-- A single string containing the name of the test about to be run will be passed to `func`. +function lester.before(func) + local levelbefores = befores[level] + if not levelbefores then + levelbefores = {} + befores[level] = levelbefores + end + levelbefores[#levelbefores+1] = func +end + +--- Set a function that is called after every test inside a describe block. +-- A single string containing the name of the test that was finished will be passed to `func`. +-- The function is executed independently if the test passed or failed. +function lester.after(func) + local levelafters = afters[level] + if not levelafters then + levelafters = {} + afters[level] = levelafters + end + levelafters[#levelafters+1] = func +end + +--- Pretty print statistics of all test runs. +-- With total success, total failures and run time in seconds. +function lester.report() + local now = lester.seconds() + local colors_reset = colors.reset + io.write(lester.quiet and '\n' or '', + colors.green, total_successes, colors_reset, ' successes / ', + colors.yellow, total_skipped, colors_reset, ' skipped / ', + colors.red, total_failures, colors_reset, ' failures / ', + colors.bright, string.format('%.6f', now - (lester_start or now)), colors_reset, ' seconds\n') + io.flush() + return total_failures == 0 +end + +--- Exit the application with success code if all tests passed, or failure code otherwise. +function lester.exit() + -- Collect garbage before exiting to call __gc handlers + collectgarbage() + collectgarbage() + os.exit(total_failures == 0, true) +end + +local expect = {} +--- Expect module, containing utility function for doing assertions inside a test. +lester.expect = expect + +--- Converts a value to a human-readable string. +-- If the final string not contains only ASCII characters, +-- then it is converted to a Lua hexdecimal string. +function expect.tohumanstring(v) + local s = tostring(v) + if s:find'[^ -~\n\t]' then -- string contains non printable ASCII + return '"'..s:gsub('.', function(c) return string.format('\\x%02X', c:byte()) end)..'"' + end + return s +end + +--- Check if a function fails with an error. +-- If `expected` is nil then any error is accepted. +-- If `expected` is a string then we check if the error contains that string. +-- If `expected` is anything else then we check if both are equal. +function expect.fail(func, expected) + local ok, err = pcall(func) + if ok then + error('expected function to fail', 2) + elseif expected ~= nil then + local found = expected == err + if not found and type(expected) == 'string' then + found = string.find(tostring(err), expected, 1, true) + end + if not found then + error('expected function to fail\nexpected:\n'..tostring(expected)..'\ngot:\n'..tostring(err), 2) + end + end +end + +--- Check if a function does not fail with a error. +function expect.not_fail(func) + local ok, err = pcall(func) + if not ok then + error('expected function to not fail\ngot error:\n'..expect.tohumanstring(err), 2) + end +end + +--- Check if a value is not `nil`. +function expect.exist(v) + if v == nil then + error('expected value to exist\ngot:\n'..expect.tohumanstring(v), 2) + end +end + +--- Check if a value is `nil`. +function expect.not_exist(v) + if v ~= nil then + error('expected value to not exist\ngot:\n'..expect.tohumanstring(v), 2) + end +end + +--- Check if an expression is evaluates to `true`. +function expect.truthy(v) + if not v then + error('expected expression to be true\ngot:\n'..expect.tohumanstring(v), 2) + end +end + +--- Check if an expression is evaluates to `false`. +function expect.falsy(v) + if v then + error('expected expression to be false\ngot:\n'..expect.tohumanstring(v), 2) + end +end + +--- Returns raw tostring result for a value. +local function rawtostring(v) + local mt = getmetatable(v) + if mt then + setmetatable(v, nil) + end + local s = tostring(v) + if mt then + setmetatable(v, mt) + end + return s +end + +-- Returns key suffix for a string_eq table key. +local function strict_eq_key_suffix(k) + if type(k) == 'string' then + if k:find('^[a-zA-Z_][a-zA-Z0-9]*$') then -- string is a lua field + return '.'..k + elseif k:find'[^ -~\n\t]' then -- string contains non printable ASCII + return '["'..k:gsub('.', function(c) return string.format('\\x%02X', c:byte()) end)..'"]' + else + return '["'..k..'"]' + end + else + return string.format('[%s]', rawtostring(k)) + end +end + +--- Compare if two values are equal, considering nested tables. +function expect.strict_eq(t1, t2, name) + if rawequal(t1, t2) then return true end + name = name or 'value' + local t1type, t2type = type(t1), type(t2) + if t1type ~= t2type then + return false, string.format("expected types to be equal for %s\nfirst: %s\nsecond: %s", + name, t1type, t2type) + end + if t1type == 'table' then + if getmetatable(t1) ~= getmetatable(t2) then + return false, string.format("expected metatables to be equal for %s\nfirst: %s\nsecond: %s", + name, expect.tohumanstring(t1), expect.tohumanstring(t2)) + end + for k,v1 in pairs(t1) do + local ok, err = expect.strict_eq(v1, t2[k], name..strict_eq_key_suffix(k)) + if not ok then + return false, err + end + end + for k,v2 in pairs(t2) do + local ok, err = expect.strict_eq(v2, t1[k], name..strict_eq_key_suffix(k)) + if not ok then + return false, err + end + end + elseif t1 ~= t2 then + return false, string.format("expected values to be equal for %s\nfirst:\n%s\nsecond:\n%s", + name, expect.tohumanstring(t1), expect.tohumanstring(t2)) + end + return true +end + +--- Check if two values are equal. +function expect.equal(v1, v2) + local ok, err = expect.strict_eq(v1, v2) + if not ok then + error(err, 2) + end +end + +--- Check if two values are not equal. +function expect.not_equal(v1, v2) + if expect.strict_eq(v1, v2) then + local v1s, v2s = expect.tohumanstring(v1), expect.tohumanstring(v2) + error('expected values to be not equal\nfirst value:\n'..v1s..'\nsecond value:\n'..v2s, 2) + end +end + +return lester + +--[[ +The MIT License (MIT) + +Copyright (c) 2021-2023 Eduardo Bart (https://github.com/edubart) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +]] diff --git a/tests/main.lua b/tests/main.lua new file mode 100644 index 0000000..10cde9f --- /dev/null +++ b/tests/main.lua @@ -0,0 +1,8 @@ +package.path = package.path .. ";../assets/.lua/?.lua" +lester = require "lester" +lester.show_traceback = false + +require "test-csexp" + +lester.report() +lester.exit() \ No newline at end of file diff --git a/tests/test-csexp.lua b/tests/test-csexp.lua new file mode 100644 index 0000000..edef1a7 --- /dev/null +++ b/tests/test-csexp.lua @@ -0,0 +1,47 @@ +local csexp = require "csexp" +local serialize, parse = csexp.serialize, csexp.parse + +local describe, it, expect = lester.describe, lester.it, lester.expect + +describe("csexp", function() + describe("serialize", function() + it("handles empty string", function() + expect.equal(serialize"", "0:") + end) + + it("handles nonempty string", function() + expect.equal(serialize"abcd", "4:abcd") + end) + + it("handles empty list", function() + expect.equal(serialize{}, "()") + end) + + it("converts non-string values", function() + expect.equal(serialize(12345), "5:12345") + end) + + it("inverts parse", function() + local original = "(10:abcdefghij(5:hello(5:world)7:goodbye))" + local obj = parse(original) + local serialized = serialize(obj) + expect.equal(serialized, original) + end) + end) + + describe("parse", function() + local rejects = { + ["malformed lists"] = "(5:hello5:world", + ["early eof in strings"] = "5:1234", + ["extra chars in strings"] = "5:123456", + ["extra chars after list"] = "()0:" + } + for description, value in pairs(rejects) do + it("rejects "..description, function() + expect.fail(function() + parse(value) + end) + end) + end + end) +end) \ No newline at end of file