8000 mini.nvim/lua/mini/test.lua at main · augtheo/mini.nvim · GitHub
[go: up one dir, main page]

Skip to content

Latest commit

 

History

History
2319 lines (2007 loc) · 85.3 KB

test.lua

File metadata and controls

2319 lines (2007 loc) · 85.3 KB
--- *mini.test* Test Neovim plugins
--- *MiniTest*
---
--- MIT License Copyright (c) 2022 Evgeni Chasnovski
---
--- ==============================================================================
---
--- Features:
--- - Test action is defined as a named callable entry of a table.
---
--- - Helper for creating child Neovim process which is designed to be used in
--- tests (including taking and verifying screenshots). See
--- |MiniTest.new_child_neovim()| and |Minitest.expect.reference_screenshot()|.
---
--- - Hierarchical organization of tests with custom hooks, parametrization,
--- and user data. See |MiniTest.new_set()|.
---
--- - Emulation of 'Olivine-Labs/busted' interface (`describe`, `it`, etc.).
---
--- - Predefined small yet usable set of expectations (`assert`-like functions).
--- See |MiniTest.expect|.
---
--- - Customizable definition of what files should be tested.
---
--- - Test case filtering. There are predefined wrappers for testing a file
--- (|MiniTest.run_file()|) and case at a location like current cursor position
--- (|MiniTest.run_at_location()|).
---
--- - Customizable reporter of output results. There are two predefined ones:
--- - |MiniTest.gen_reporter.buffer()| for interactive usage.
--- - |MiniTest.gen_reporter.stdout()| for headless Neovim.
---
--- - Customizable project specific testing script.
---
--- - Works on Unix (Linux, MacOS, etc.) and Windows.
---
--- What it doesn't support:
--- - Parallel execution. Due to idea of limiting implementation complexity.
---
--- - Mocks, stubs, etc. Use child Neovim process and manually override what is
--- needed. Reset child process it afterwards.
---
--- - "Overly specific" expectations. Tests for (no) equality and (absence of)
--- errors usually cover most of the needs. Adding new expectations is a
--- subject to weighing its usefulness against additional implementation
--- complexity. Use |MiniTest.new_expectation()| to create custom ones.
---
--- For more information see:
--- - 'TESTING.md' file for a hands-on introduction based on examples.
---
--- - Code of this plugin's tests. Consider it to be an example of intended
--- way to use 'mini.test' for test organization and creation.
---
--- # Workflow
---
--- - Organize tests in separate files. Each test file should return a test set
--- (explicitly or implicitly by using "busted" style functions).
---
--- - Write test actions as callable entries of test set. Use child process
--- inside test actions (see |MiniTest.new_child_neovim()|) and builtin
--- expectations (see |MiniTest.expect|).
---
--- - Run tests. This does two steps:
--- - *Collect*. This creates single hierarchical test set, flattens into
--- array of test cases (see |MiniTest-test-case|) while expanding with
--- parametrization, and possibly filters them.
--- - *Execute*. This safely calls hooks and main test actions in specified
--- order while allowing reporting progress in asynchronous fashion.
--- Detected errors means test case fail; otherwise - pass.
---
--- # Setup ~
---
--- This module needs a setup with `require('mini.test').setup({})` (replace
--- `{}` with your `config` table). It will create global Lua table `MiniTest`
--- which you can use for scripting or manually (with `:lua MiniTest.*`).
---
--- See |MiniTest.config| for available config settings.
---
--- You can override runtime config settings locally to buffer inside
--- `vim.b.minitest_config` which should have same structure as `MiniTest.config`.
--- See |mini.nvim-buffer-local-config| for more details.
---
--- To stop module from showing non-error feedback, set `config.silent = true`.
---
--- # Comparisons ~
---
--- - Testing infrastructure from 'nvim-lua/plenary.nvim':
--- - Executes each file in separate headless Neovim process with customizable
--- 'init.vim' file. While 'mini.test' executes everything in current
--- Neovim process encouraging writing tests with help of manually
--- managed child Neovim process (see |MiniTest.new_child_neovim()|).
--- - Tests are expected to be written with embedded simplified versions of
--- 'Olivine-Labs/busted' and 'Olivine-Labs/luassert'. While 'mini.test'
--- uses concepts of test set (see |MiniTest.new_set()|) and test case
--- (see |MiniTest-test-case|). It also can emulate bigger part of
--- "busted" framework.
--- - Has single way of reporting progress (shows result after every case
--- without summary). While 'mini.test' can have customized reporters
--- with defaults for interactive and headless usage (provide more
--- compact and user-friendly summaries).
--- - Allows parallel execution, while 'mini.test' does not.
--- - Allows making mocks, stubs, and spies, while 'mini.test' does not in
--- favor of manually overwriting functionality in child Neovim process.
---
--- Although 'mini.test' supports emulation of "busted style" testing, it will
--- be more stable to use its designed approach of defining tests (with
--- `MiniTest.new_set()` and explicit table fields). Couple of reasons:
--- - "Busted" syntax doesn't support full capabilities offered by 'mini.test'.
--- Mainly it is about parametrization and supplying user data to test sets.
--- - It is an emulation, not full support. So some subtle things might not
--- work the way you expect.
---
--- Some hints for converting from 'plenary.nvim' tests to 'mini.test':
--- - Rename files from "***_spec.lua" to "test_***.lua" and put them in
--- "tests" directory.
--- - Replace `assert` calls with 'mini.test' expectations. See |MiniTest.expect|.
--- - Create main test set `T = MiniTest.new_set()` and eventually return it.
--- - Make new sets (|MiniTest.new_set()|) from `describe` blocks. Convert
--- `before_each()` and `after_each` to `pre_case` and `post_case` hooks.
--- - Make test cases from `it` blocks.
---
--- # Highlight groups ~
---
--- * `MiniTestEmphasis` - emphasis highlighting. By default it is a bold text.
--- * `MiniTestFail` - highlighting of failed cases. By default it is a bold
--- text with `vim.g.terminal_color_1` color (red).
--- * `MiniTestPass` - highlighting of passed cas 8BD0 es. By default it is a bold
--- text with `vim.g.terminal_color_2` color (green).
---
--- To change any highlight group, modify it directly with |:highlight|.
---
--- # Disabling ~
---
--- To disable, set `vim.g.minitest_disable` (globally) or `vim.b.minitest_disable`
--- (for a buffer) to `true`. Considering high number of different scenarios
--- and customization intentions, writing exact rules for disabling module's
--- functionality is left to user. See |mini.nvim-disabling-recipes| for common
--- recipes.
-- Module definition ==========================================================
local MiniTest = {}
local H = {}
--- Module setup
---
---@param config table|nil Module config table. See |MiniTest.config|.
---
---@usage >lua
--- require('mini.test').setup() -- use default config
--- -- OR
--- require('mini.test').setup({}) -- replace {} with your config table
--- <
MiniTest.setup = function(config)
-- Export module
_G.MiniTest = MiniTest
-- Setup config
config = H.setup_config(config)
-- Apply config
H.apply_config(config)
-- Define behavior
H.create_autocommands()
-- Create default highlighting
H.create_default_hl()
end
--stylua: ignore start
--- Module config
---
--- Default values:
---@eval return MiniDoc.afterlines_to_code(MiniDoc.current.eval_section)
MiniTest.config = {
-- Options for collection of test cases. See `:h MiniTest.collect()`.
collect = {
-- Temporarily emulate functions from 'busted' testing framework
-- (`describe`, `it`, `before_each`, `after_each`, and more)
emulate_busted = true,
-- Function returning array of file paths to be collected.
-- Default: all Lua files in 'tests' directory starting with 'test_'.
find_files = function()
return vim.fn.globpath('tests', '**/test_*.lua', true, true)
end,
-- Predicate function indicating if test case should be executed
filter_cases = function(case) return true end,
},
-- Options for execution of test cases. See `:h MiniTest.execute()`.
execute = {
-- Table with callable fields `start()`, `update()`, and `finish()`
reporter = nil,
-- Whether to stop execution after first error
stop_on_error = false,
},
-- Path (relative to current directory) to script which handles project
-- specific test running
script_path = 'scripts/minitest.lua',
-- Whether to disable showing non-error feedback
silent = false,
}
--minidoc_afterlines_end
--stylua: ignore end
-- Module data ================================================================
--- Table with information about current state of test execution
---
--- Use it to examine result of |MiniTest.execute()|. It is reset at the
--- beginning of every call.
---
--- At least these keys are supported:
--- - <all_cases> - array with all cases being currently executed. Basically,
--- an input of `MiniTest.execute()`.
--- - <case> - currently executed test case. See |MiniTest-test-case|. Use it
--- to customize execution output (like adding custom notes, etc).
MiniTest.current = { all_cases = nil, case = nil }
-- Module functionality =======================================================
--- Create test set
---
--- Test set is one of the two fundamental data structures. It is a table that
--- defines hierarchical test organization as opposed to sequential
--- organization with |MiniTest-test-case|.
---
--- All its elements are one of three categories:
--- - A callable (object that can be called; function or table with `__call`
--- metatble entry) is considered to define a test action. It will be called
--- with "current arguments" (result of all nested `parametrize` values, read
--- further). If it throws error, test has failed.
--- - A test set (output of this function) defines nested structure. Its
--- options during collection (see |MiniTest.collect()|) will be extended
--- with options of this (parent) test set.
--- - Any other elements are considered helpers and don't directly participate
--- in test structure.
---
--- Set options allow customization of test collection and execution (more
--- details in `opts` description):
--- - `hooks` - table with elements that will be called without arguments at
--- predefined stages of test execution.
--- - `parametrize` - array defining different arguments with which main test
--- actions will be called. Any non-trivial parametrization will lead to
--- every element (even nested) be "multiplied" and processed with every
--- element of `parametrize`. This allows handling many different combination
--- of tests with little effort.
--- - `data` - table with user data that will be forwarded to cases. Primary
--- objective is to be used for customized case filtering.
---
--- Notes:
--- - Preferred way of adding elements is by using syntax `T[name] = element`.
--- This way order of added elements will be preserved. Any other way won't
--- guarantee any order.
--- - Supplied options `opts` are stored in `opts` field of metatable
--- (`getmetatable(set).opts`).
---
---@param opts table|nil Allowed options:
--- - <hooks> - table with fields:
--- - <pre_once> - executed before first filtered node.
--- - <pre_case> - 10000 executed before each case (even nested).
--- - <post_case> - executed after each case (even nested).
--- - <post_once> - executed after last filtered node.
--- - <parametrize> - array where each element is itself an array of
--- parameters to be appended to "current parameters" of callable fields.
--- Note: don't use plain `{}` as it is equivalent to "parametrization into
--- zero cases", so no cases will be collected from this set. Calling test
--- actions with no parameters is equivalent to `{{}}` or not supplying
--- `parametrize` option at all.
--- - <data> - user data to be forwarded to cases. Can be used for a more
--- granular filtering.
---@param tbl table|nil Initial test items (possibly nested). Will be executed
--- without any guarantees on order.
---
---@return table A single test set.
---
---@usage >lua
--- -- Use with defaults
--- T = MiniTest.new_set()
--- T['works'] = function() MiniTest.expect.equality(1, 1) end
---
--- -- Use with custom options. This will result into two actual cases: first
--- -- will pass, second - fail.
--- T['nested'] = MiniTest.new_set({
--- hooks = { pre_case = function() _G.x = 1 end },
--- parametrize = { { 1 }, { 2 } }
--- })
---
--- T['nested']['works'] = function(x)
--- MiniTest.expect.equality(_G.x, x)
--- end
--- <
MiniTest.new_set = function(opts, tbl)
opts = opts or {}
tbl = tbl or {}
-- Keep track of new elements order. This allows to iterate through elements
-- in order they were added.
local metatbl = { class = 'testset', key_order = vim.tbl_keys(tbl), opts = opts }
metatbl.__newindex = function(t, key, value)
table.insert(metatbl.key_order, key)
rawset(t, key, value)
end
return setmetatable(tbl, metatbl)
end
--- Test case
---
--- An item of sequential test organization, as opposed to hierarchical with
--- test set (see |MiniTest.new_set()|). It is created as result of test
--- collection with |MiniTest.collect()| to represent all necessary information
--- of test execution.
---
--- Execution of test case goes by the following rules:
--- - Call functions in order:
--- - All elements of `hooks.pre` from first to last without arguments.
--- - Field `test` with arguments unpacked from `args`.
--- - All elements of `hooks.post` from first to last without arguments.
--- - Error in any call gets appended to `exec.fails`, meaning error in any
--- hook will lead to test fail.
--- - State (`exec.state`) is changed before every call and after last call.
---
---@class Test-case
---
---@field args table Array of arguments with which `test` will be called.
---@field data table User data: all fields of `opts.data` from nested test sets.
---@field desc table Description: array of fields from nested test sets.
---@field exec table|nil Information about test case execution. Value of `nil` means
--- that this particular case was not (yet) executed. Has following fields:
--- - <fails> - array of strings with failing information.
--- - <notes> - array of strings with non-failing information.
--- - <state> - state of test execution. One of:
--- - 'Executing <name of what is being executed>' (during execution).
--- - 'Pass' (no fails, no notes).
--- - 'Pass with notes' (no fails, some notes).
--- - 'Fail' (some fails, no notes).
--- - 'Fail with notes' (some fails, some notes).
---@field hooks table Hooks to be executed as part of test case. Has fields
--- <pre> and <post> with arrays to be consecutively executed before and
--- after execution of `test`.
---@field test function|table Main callable object representing test action.
---@tag MiniTest-test-case
--- Skip rest of current callable execution
---
--- Can be used inside hooks and main test callable of test case. Note: at the
--- moment implemented as a specially handled type of error.
---
---@param msg string|nil Message to be added to current case notes.
MiniTest.skip = function(msg)
H.cache.error_is_from_skip = true
error(msg or 'Skip test', 0)
end
--- Add note to currently executed test case
---
--- Appends `msg` to `exec.notes` field of |MiniTest.current.case|.
---
---@param msg string Note to add.
MiniTest.add_note = function(msg)
local case = MiniTest.current.case
case.exec = case.exec or {}
case.exec.notes = case.exec.notes or {}
table.insert(case.exec.notes, msg)
end
--- Register callable execution after current callable
---
--- Can be used inside hooks and main test callable of test case.
---
---@param f function|table Callable to be executed after current callable is
--- finished executing (regardless of whether it ended with error or not).
MiniTest.finally = function(f) H.cache.finally = f end
--- Run tests
---
--- - Try executing project specific script at path `opts.script_path`. If
--- successful (no errors), then stop.
--- - Collect cases with |MiniTest.collect()| and `opts.collect`.
--- - Execute collected cases with |MiniTest.execute()| and `opts.execute`.
---
---@param opts table|nil Options with structure similar to |MiniTest.config|.
--- Absent values are inferred from there.
MiniTest.run = function(opts)
if H.is_disabled() then return end
-- Try sourcing project specific script first
local success = H.execute_project_script(opts)
if success then return end
-- Collect and execute
opts = H.get_config(opts)
local cases = MiniTest.collect(opts.collect)
MiniTest.execute(cases, opts.execute)
end
--- Run specific test file
---
--- Basically a |MiniTest.run()| wrapper with custom `collect.find_files` option.
---
---@param file string|nil Path to test file. By default a path of current buffer.
---@param opts table|nil Options for |MiniTest.run()|.
MiniTest.run_file = function(file, opts)
file = vim.fn.fnamemodify(file or vim.api.nvim_buf_get_name(0), ':p:.')
local stronger_opts = { collect = { find_files = function() return { file } end } }
opts = vim.tbl_deep_extend('force', opts or {}, stronger_opts)
MiniTest.run(opts)
end
--- Run case(s) covering location
---
--- Try filtering case(s) covering location, meaning that definition of its
--- main `test` action (as taken from builtin `debug.getinfo`) is located in
--- specified file and covers specified line. Note that it can result in
--- multiple cases if they come from parametrized test set (see `parametrize`
--- option in |MiniTest.new_set()|).
---
--- Basically a |MiniTest.run()| wrapper with custom `collect.find_files` option.
---
---@param location table|nil Table with fields <file> (path to file) and <line>
--- (line number in that file). Default is taken from current cursor position.
MiniTest.run_at_location = function(location, opts)
if location == nil then
local cur_file = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(0), ':.')
local cur_pos = vim.api.nvim_win_get_cursor(0)
location = { file = cur_file, line = cur_pos[1] }
end
local stronger_opts = {
collect = {
find_files = function() return { location.file } end,
filter_cases = function(case)
local info = debug.getinfo(case.test)
return info.short_src == location.file
and info.linedefined <= location.line
and location.line <= info.lastlinedefined
end,
},
}
opts = vim.tbl_deep_extend('force', opts or {}, stronger_opts)
MiniTest.run(opts)
end
--- Collect test cases
---
--- Overview of collection process:
--- - If `opts.emulate_busted` is `true`, temporary make special global
--- functions (removed at the end of collection). They can be used inside
--- test files to create hierarchical structure of test cases.
--- - Source each file from array output of `opts.find_files`. It should output
--- a test set (see |MiniTest.new_set()|) or `nil` (if "busted" style is used;
--- test set is created implicitly).
--- - Combine all test sets into single set with fields equal to its file path.
--- - Convert from hierarchical test configuration to sequential: from single
--- test set to array of test cases (see |MiniTest-test-case|). Conversion is
--- done in the form of "for every table element do: for every `parametrize`
--- element do: ...". Details:
--- - If element is a callable, construct test case with it being main
--- `test` action. Description is appended with key of element in current
--- test set table. Hooks, arguments, and data are taken from "current
--- nested" ones. Add case to output array.
--- - If element is a test set, process it in similar, recursive fashion.
--- The "current nested" information is expanded:
--- - `args` is extended with "current element" from `parametrize`.
--- - `desc` is appended with element key.
--- - `hooks` are appended to their appropriate places. `*_case` hooks
--- will be inserted closer to all child cases than hooks from parent
--- test sets: `pre_case` at end, `post_case` at start.
--- - `data` is extended via |vim.tbl_deep_extend()|.
--- - Any other element is not processed.
--- - Filter array with `opts.filter_cases`. Note that input case doesn't contain
--- all hooks, as `*_once` hooks will be added after filtration.
--- - Add `*_once` hooks to appropriate cases.
---
---@param opts table|nil Options controlling case collection. Possible fields:
--- - <emulate_busted> - whether to emulate 'Olivine-Labs/busted' interface.
--- It emulates these global functions: `describe`, `it`, `setup`, `teardown`,
--- `before_each`, `after_each`. Use |MiniTest.skip()| instead of `pending()`
--- and |MiniTest.finally()| instead of `finally`.
--- - <find_files> - function which when called without arguments returns
--- array with file paths. Each file should be a Lua file returning single
--- test set or `nil`.
--- - <filter_cases> - function which when called with single test case
--- (see |MiniTest-test-case|) returns `false` if this case should be filtered
--- out; `true` otherwise.
---
---@return table Array of test cases ready to be used by |MiniTest.execute()|.
MiniTest.collect = function(opts)
opts = vim.tbl_deep_extend('force', H.get_config().collect, opts or {})
-- Make single test set
local set = MiniTest.new_set()
for _, file in ipairs(opts.find_files()) do
-- Possibly emulate 'busted' with current file. This allows to wrap all
-- implicit cases from that file into single set with file's name.
if opts.emulate_busted then
set[file] = MiniTest.new_set()
H.busted_emulate(set[file])
end
-- Execute file
local ok, t = pcall(dofile, file)
-- Catch errors
if not ok then
local msg = string.format('Sourcing %s resulted into following error: %s', vim.inspect(file), t)
H.error(msg)
end
local is_output_correct = (opts.emulate_busted and vim.tbl_count(set[file]) > 0) or H.is_instance(t, 'testset')
if not is_output_correct then
local msg = string.format(
[[%s does not define a test set. Did you return `MiniTest.new_set()` or created 'busted' tests?]],
vim.inspect(file)
)
H.error(msg)
end
-- If output is test set, always use it (even if 'busted' tests were added)
if H.is_instance(t, 'testset') then set[file] = t end
end
H.busted_deemulate()
-- Convert to test cases. This also creates separate aligned array of hooks
-- which should be executed once regarding test case. This is needed to
-- correctly inject those hooks after filtering is done.
local raw_cases, raw_hooks_once = H.set_to_testcases(set)
-- Filter cases (at this stage don't have injected `hooks_once`)
local cases, hooks_once = {}, {}
for i, c in ipairs(raw_cases) do
if opts.filter_cases(c) then
table.insert(cases, c)
table.insert(hooks_once, raw_hooks_once[i])
end
end
-- Inject `hooks_once` into appropriate cases
H.inject_hooks_once(cases, hooks_once)
return cases
end
--- Execute array of test cases
---
--- Overview of execution process:
--- - Reset `all_cases` in |MiniTest.current| with `cases` input.
--- - Call `reporter.start(cases)` (if present).
--- - Execute each case in natural array order (aligned with their integer
--- keys). Set `MiniTest.current.case` to currently executed case. Detailed
--- test case execution is described in |MiniTest-test-case|. After any state
--- change, call `reporter.update(case_num)` (if present), where `case_num` is an
--- integer key of current test case.
--- - Call `reporter.finish()` (if present).
---
--- Notes:
--- - Execution is done in asynchronous fashion with scheduling. This allows
--- making meaningful progress report during execution.
--- - This function doesn't return anything. Instead, it updates `cases` in
--- place with proper `exec` field. Use `all_cases` at |MiniTest.current| to
--- look at execution result.
---
---@param cases table Array of test cases (see |MiniTest-test-case|).
---@param opts table|nil Options controlling case collection. Possible fields:
--- - <reporter> - table with possible callable fields `start`, `update`,
--- `finish`. Default: |MiniTest.gen_reporter.buffer()| in interactive
--- usage and |MiniTest.gen_reporter.stdout()| in headless usage.
--- - <stop_on_error> - whether to stop execution (see |MiniTest.stop()|)
--- after first error. Default: `false`.
MiniTest.execute = function(cases, opts)
vim.validate({ cases = { cases, 'table' } })
MiniTest.current.all_cases = cases
-- Verify correct arguments
if #cases == 0 then
H.message('No cases to execute.')
return
end
opts = vim.tbl_deep_extend('force', H.get_config().execute, opts or {})
local reporter = opts.reporter or (H.is_headless and MiniTest.gen_reporter.stdout() or MiniTest.gen_reporter.buffer())
if type(reporter) ~= 'table' then
H.message('`opts.reporter` should be table or `nil`.')
return
end
opts.reporter = reporter
-- Start execution
H.cache = { is_executing = true }
vim.schedule(function() H.exec_callable(reporter.start, cases) end)
for case_num, cur_case in ipairs(cases) do
-- Schedule execution in async fashion. This allows doing other things
-- while tests are executed.
local schedule_step = H.make_step_scheduler(cur_case, case_num, opts)
vim.schedule(function() MiniTest.current.case = cur_case end)
for i, hook_pre in ipairs(cur_case.hooks.pre) do
schedule_step(hook_pre, 'hook_pre', [[Executing 'pre' hook #]] .. i)
end
schedule_step(function() cur_case.test(unpack(cur_case.args)) end, 'case', 'Executing test')
for i, hook_post in ipairs(cur_case.hooks.post) do
schedule_step(hook_post, 'hook_post', [[Executing 'post' hook #]] .. i)
end
-- Finalize state
schedule_step(nil, 'finalize', function() return H.case_final_state(cur_case) end)
end
vim.schedule(function() H.exec_callable(reporter.finish) end)
-- Use separate call to ensure that `reporter.finish` error won't interfere
vim.schedule(function() H.cache.is_executing = false end)
end
--- Stop test execution
---
---@param opts table|nil Options with fields:
--- - <close_all_child_neovim> - whether to close all child neovim processes
--- created with |MiniTest.new_child_neovim()|. Default: `true`.
MiniTest.stop = function(opts)
opts = vim.tbl_deep_extend('force', { close_all_child_neovim = true }, opts or {})
-- Register intention to stop execution
H.cache.should_stop_execution = true
-- Possibly stop all child Neovim processes
if not opts.close_all_child_neovim then return end
for _, child in ipairs(H.child_neovim_registry) do
pcall(child.stop)
end
H.child_neovim_registry = {}
end
--- Check if tests are being executed
---
---@return boolean
MiniTest.is_executing = function() return H.cache.is_executing == true end
-- Expectations ---------------------------------------------------------------
--- Table with expectation functions
---
--- Each function has the following behavior:
--- - Silently returns `true` if expectation is fulfilled.
--- - Throws an informative error with information helpful for debugging.
---
--- Mostly designed to be used within 'mini.test' framework.
---
---@usage >lua
--- local x = 1 + 1
--- MiniTest.expect.equality(x, 2) -- passes
--- MiniTest.expect.equality(x, 1) -- fails
--- <
MiniTest.expect = {}
--- Expect equality of two objects
---
--- Equality is tested via |vim.deep_equal()|.
---
---@param left any First object.
---@param right any Second object.
MiniTest.expect.equality = function(left, right)
if vim.deep_equal(left, right) then return true end
local context = string.format('Left: %s\nRight: %s', vim.inspect(left), vim.inspect(right))
H.error_expect('equality', context)
end
--- Expect no equality of two objects
---
--- Equality is tested via |vim.deep_equal()|.
---
---@param left any First object.
---@param right any Second object.
MiniTest.expect.no_equality = function(left, right)
if not vim.deep_equal(left, right) then return true end
local context = string.format('Object: %s', vim.inspect(left))
H.error_expect('*no* equality', context)
end
--- Expect function call to raise error
---
---@param f function|table Callable to be tested for raising error.
---@param pattern string|nil Pattern which error message should match.
--- Use `nil` or empty string to not test for pattern matching.
---@param ... any Extra arguments with which `f` will be called.
MiniTest.expect.error = function(f, pattern, ...)
vim.validate({ pattern = { pattern, 'string', true } })
local ok, err = pcall(f, ...)
err = err or ''
local has_matched_error = not ok and string.find(err, pattern or '') ~= nil
if has_matched_error then return true end
local matching_pattern = pattern == nil and '' or (' matching pattern %s'):format(vim.inspect(pattern))
local subject = 'error' .. matching_pattern
local context = ok and 'Observed no error' or ('Observed error: ' .. err)
H.error_expect(subject, context)
end
--- Expect function call to not raise error
---
---@param f function|table Callable to be tested for raising error.
---@param ... any Extra arguments with which `f` will be called.
MiniTest.expect.no_error = function(f, ...)
local ok, err = pcall(f, ...)
err = err or ''
if ok then return true end
H.error_expect('*no* error', 'Observed error: ' .. err)
end
--- Expect equality to reference screenshot
---
---@param screenshot table|nil Array with screenshot information. Usually an output
--- of `child.get_screenshot()` (see |MiniTest-child-neovim.get_screenshot()|).
--- If `nil`, expectation passed.
---@param path string|nil Path to reference screenshot. If `nil`, constructed
--- automatically in directory 'tests/screenshots' from current case info and
--- total number of times it was called inside current case. If there is no
--- file at `path`, it is created with content of `screenshot`.
---@param opts table|nil Options:
--- - <force> `(boolean)` - whether to forcefully create reference screenshot.
--- Temporary useful during test writing. Default: `false`.
--- - <ignore_lines> `(table)` - array of line numbers to ignore during compare.
--- Default: `nil` to check all lines.
MiniTest.expect.reference_screenshot = function(screenshot, path, opts)
if screenshot == nil then return true end
opts = vim.tbl_deep_extend('force', { force = false }, opts or {})
H.cache.n_screenshots = H.cache.n_screenshots + 1
if path == nil then
-- Sanitize path. Replace any control characters, whitespace, OS specific
-- forbidden characters with '-' (with some useful exception)
local linux_forbidden = [[/]]
local windows_forbidden = [[<>:"/\|?*]]
local pattern = string.format('[%%c%%s%s%s]', vim.pesc(linux_forbidden), vim.pesc(windows_forbidden))
local replacements = setmetatable({ ['"'] = "'" }, { __index = function() return '-' end })
local name = H.case_to_stringid(MiniTest.current.case):gsub(pattern, replacements)
-- Don't end with whitespace or dot (forbidden on Windows)
name = name:gsub('[%s%.]$', '-')
path = 'tests/screenshots/' .. name
-- Deal with multiple screenshots
if H.cache.n_screenshots > 1 then path = path .. string.format('-%03d', H.cache.n_screenshots) end
end
-- If there is no readable screenshot file, create it. Pass with note.
if opts.force or vim.fn.filereadable(path) == 0 then
local dir_path = vim.fn.fnamemodify(path, ':p:h')
vim.fn.mkdir(dir_p 10BC8 ath, 'p')
H.screenshot_write(screenshot, path)
MiniTest.add_note('Created reference screenshot at path ' .. vim.inspect(path))
return true
end
local reference = H.screenshot_read(path)
-- Compare
local are_same, cause = H.screenshot_compare(reference, screenshot, opts)
if are_same then return true end
local subject = 'screenshot equality to reference at ' .. vim.inspect(path)
local context = string.format('%s\nReference:\n%s\n\nObserved:\n%s', cause, tostring(reference), tostring(screenshot))
H.error_expect(subject, context)
end
--- Create new expectation function
---
--- Helper for writing custom functions with behavior similar to other methods
--- of |MiniTest.expect|.
---
---@param subject string|function|table Subject of expectation. If callable,
--- called with expectation input arguments to produce string value.
---@param predicate function|table Predicate callable. Called with expectation
--- input arguments. Output `false` or `nil` means failed expectation.
---@param fail_context string|function|table Information about fail. If callable,
--- called with expectation input arguments to produce string value.
---
---@return function Expectation function.
---
---@usage >lua
--- local expect_truthy = MiniTest.new_expectation(
--- 'truthy',
--- function(x) return x end,
--- function(x) return 'Object: ' .. vim.inspect(x) end
--- )
--- <
MiniTest.new_expectation = function(subject, predicate, fail_context)
return function(...)
if predicate(...) then return true end
local cur_subject = vim.is_callable(subject) and subject(...) or subject
local cur_context = vim.is_callable(fail_context) and fail_context(...) or fail_context
H.error_expect(cur_subject, cur_context)
end
end
-- Reporters ------------------------------------------------------------------
--- Table with pre-configured report generators
---
--- Each element is a function which returns reporter - table with callable
--- `start`, `update`, and `finish` fields.
MiniTest.gen_reporter = {}
--- Generate buffer reporter
---
--- This is a default choice for interactive (not headless) usage. Opens a window
--- with dedicated non-terminal buffer and updates it with throttled redraws.
---
--- Opened buffer has the following helpful Normal mode mappings:
--- - `<Esc>` - stop test execution if executing (see |MiniTest.is_executing()|
--- and |MiniTest.stop()|). Close window otherwise.
--- - `q` - same as `<Esc>` for convenience and compatibility.
---
--- General idea:
--- - Group cases by concatenating first `opts.group_depth` elements of case
--- description (`desc` field). Groups by collected files if using default values.
--- - In `start()` show some stats to know how much is scheduled to be executed.
--- - In `update()` show symbolic overview of current group and state of current
--- case. Each symbol represents one case and its state:
--- - `?` - case didn't finish executing.
--- - `o` - pass.
--- - `O` - pass with notes.
--- - `x` - fail.
--- - `X` - fail with notes.
--- - In `finish()` show all fails and notes ordered by case.
---
---@param opts table|nil Table with options. Used fields:
--- - <group_depth> - number of first elements of case description (can be zero)
--- used for grouping. Higher values mean higher granularity of output.
--- Default: 1.
--- - <throttle_delay> - minimum number of milliseconds to wait between
--- redrawing. Reduces screen flickering but not amount of computations.
--- Default: 10.
--- - <window> - definition of window to open. Can take one of the forms:
--- - Callable. It is called expecting output to be target window id
--- (current window is used if output is `nil`). Use this to open in
--- "normal" window (like `function() vim.cmd('vsplit') end`).
--- - Table. Used as `config` argument in |nvim_open_win()|.
--- Default: table for centered floating window.
MiniTest.gen_reporter.buffer = function(opts)
-- NOTE: another choice of implementing this is to use terminal buffer
-- `vim.api.nvim_open_term()`.
-- Pros:
-- - Renders ANSI escape sequences (mostly) correctly, i.e. no need in
-- replacing them with Neovim range highlights.
-- - This reporter and `stdout` one can share more of a codebase.
-- Cons:
-- - Couldn't manage to implement "redraw on every update".
-- - Extra steps still are needed in order to have richer output information.
-- This involves ANSI sequences that move cursor, which have same issues as
-- in `stdout`, albeit easier to overcome:
-- - Handling of scroll.
-- - Hard wrapping of lines leading to need of using window width.
opts = vim.tbl_deep_extend(
'force',
{ group_depth = 1, throttle_delay = 10, window = H.buffer_reporter.default_window_opts() },
opts or {}
)
local buf_id, win_id
local is_valid_buf_win = function() return vim.api.nvim_buf_is_valid(buf_id) and vim.api.nvim_win_is_valid(win_id) end
-- Helpers
local set_cursor = function(line)
vim.api.nvim_win_set_cursor(win_id, { line or vim.api.nvim_buf_line_count(buf_id), 0 })
end
-- Define "write from cursor line" function with throttled redraw
local latest_draw_time = 0
local replace_last = function(n_replace, lines, force)
H.buffer_reporter.set_lines(buf_id, lines, -n_replace - 1, -1)
-- Throttle redraw to reduce flicker
local cur_time = vim.loop.hrtime()
local is_enough_time_passed = (cur_time - latest_draw_time) > opts.throttle_delay * 1000000
if is_enough_time_passed or force then
vim.cmd('redraw')
latest_draw_time = cur_time
end
end
-- Create reporter functions
local res = {}
local all_cases, all_groups, latest_group_name
res.start = function(cases)
-- Set up buffer and window
buf_id, win_id = H.buffer_reporter.setup_buf_and_win(opts.window)
-- Set up data (taking into account possible not first time run)
all_cases = cases
all_groups = H.overview_reporter.compute_groups(cases, opts.group_depth)
latest_group_name = nil
-- Write lines
local lines = H.overview_reporter.start_lines(all_cases, all_groups)
replace_last(1, lines)
set_cursor()
end
res.update = function(case_num)
if not is_valid_buf_win() then return end
local case, cur_group_name = all_cases[case_num], all_groups[case_num].name
-- Update symbol
local state = type(case.exec) == 'table' and case.exec.state or nil
all_groups[case_num].symbol = H.reporter_symbols[state]
local n_replace = H.buffer_reporter.update_step_n_replace(latest_group_name, cur_group_name)
local lines = H.buffer_reporter.update_step_lines(case_num, all_cases, all_groups)
replace_last(n_replace, lines)
set_cursor()
latest_group_name = cur_group_name
end
res.finish = function()
if not is_valid_buf_win() then return end
-- Cache final cursor position to overwrite 'Current case state' header
local start_line = vim.api.nvim_buf_line_count(buf_id) - 1
-- Force writing lines
local lines = H.overview_reporter.finish_lines(all_cases)
replace_last(2, lines, true)
set_cursor(start_line)
end
return res
end
--- Generate stdout reporter
---
--- This is a default choice for headless usage. Writes to `stdout`. Uses
--- coloring ANSI escape sequences to make pretty and informative output
--- (should work in most modern terminals and continuous integration providers).
---
--- It has same general idea as |MiniTest.gen_reporter.buffer()| with slightly
--- less output (it doesn't overwrite previous text) to overcome typical
--- terminal limitations.
---
---@param opts table|nil Table with options. Used fields:
--- - <group_depth> - number of first elements of case description (can be zero)
--- used for grouping. Higher values mean higher granularity of output.
--- Default: 1.
--- - <quit_on_finish> - whether to quit after finishing test execution.
--- Default: `true`.
MiniTest.gen_reporter.stdout = function(opts)
opts = vim.tbl_deep_extend('force', { group_depth = 1, quit_on_finish = true }, opts or {})
local write = function(text)
text = type(text) == 'table' and table.concat(text, '\n') or text
io.stdout:write(text)
io.flush()
end
local all_cases, all_groups, latest_group_name
local default_symbol = H.reporter_symbols[nil]
local res = {}
res.start = function(cases)
-- Set up data
all_cases = cases
all_groups = H.overview_reporter.compute_groups(cases, opts.group_depth)
-- Write lines
local lines = H.overview_reporter.start_lines(all_cases, all_groups)
write(lines)
end
res.update = function(case_num)
local cur_case = all_cases[case_num]
local cur_group_name = all_groups[case_num].name
0