diff --git a/.busted b/.busted new file mode 100644 index 0000000..dbda303 --- /dev/null +++ b/.busted @@ -0,0 +1,10 @@ +local expect = require('expect') +local busted = require('busted') +expect.parameters.throw = busted.fail + +return { + default = { + ['auto-insulate'] = false, + lpath = './?.lua;' .. (require('lfs').currentdir()) .. '/?.lua;' + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e24872a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: ci + +on: + pull_request: + push: + branches: master + +jobs: + build: + name: Build and test + + strategy: + matrix: + lua-version: ["5.4", "5.3", "5.2", "5.1", "luajit"] + os: ["ubuntu-latest"] + include: + - os: "macos-latest" + lua-version: "5.4" + - os: "windows-latest" + lua-version: "luajit" + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@master + + - uses: leafo/gh-actions-lua@master + with: + luaVersion: ${{ matrix.lua-version }} + + - uses: hishamhm/gh-actions-luarocks@master + + - name: Build + run: | + luarocks make --only-deps + + - name: Test + run: | + luarocks lint expect-dev-1.rockspec + luarocks test \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5bd6961 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.src.rock \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2b7083c --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2022 Stéphane Veyret + +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 (including the next +paragraph) 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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b4971c --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# Expect - BDD expect notation for LUA tests + +Widely inspired by `chaijs`, `expect` aims to bring the behavior-driven development “expect” notation to LUA tests. + +```lua +expect(2 + 2).to.be.a('number').And.to.equal(4).but.Not.to.be.Nil() +``` + +# Installation + +You can install `expect` using LuaRocks with the command: + +```shell +luarocks install expect +``` + +# Usage + +In order to use `expect` in your tests, look at the [usage manual](doc/usage.md). + +If you want to write a new plugin, look at the [plugin manual](doc/plugin.md). + +# Credits + +Some parts of this projects are inspired/copied from: + +- The NodeJS `chaijs` project: https://github.com/chaijs/chai +- The LUA `luassert` project: https://github.com/lunarmodules/luassert diff --git a/doc/plugin.md b/doc/plugin.md new file mode 100644 index 0000000..7c6abe9 --- /dev/null +++ b/doc/plugin.md @@ -0,0 +1,183 @@ +It is rather easy to write plugins in order to extend the capabilities of `expect`. Anyway, `expect` will +prevent from adding the same feature several times, so plugin writers must carefully choose the name of the +features. Remember that features are case-insensitive, so you cannot add an `equal` feature, neither can you +add an `Equal` one. + +# Example + +The [core.lua](../expect/core.lua) file itself is a plugin containing the core features. You can use it as an +example to see how a plugin should be written. + +# Basic + +Your plugin should return a function taking `expect` as a single parameter. The function will add the +features (assertions) to `expect`. You can add diffent kinds of features: + +- properties, using `expect.addProperty(name[, fun])`, will add a feature callable as a property; you may do +assertions in it, but remember that it is an error to terminate a statement with a property in LUA, so it is +better to either do nothing (chainable word) or set a control data property; +- methods, using `expect.addMethod(name[, fun])`, will add a feature callable as a method, which may take +some parameters; +- mixed, using `expect.addChainableMethod(name[, fun[, fun]])`, will add a feature callable both as a method +(first provided callback) and as a property (second callback); both calls can have an action, but beware that +the action executed for the property usage is also executed when the feature is called as a method. + +If you don’t provide callbacks when using these functions, a no-op one will be used. + +```lua +local FailureMessage = require('expect.FailureMessage') + +return function(expect) + -- Add a no-op property (chaining word) + expect.addProperty('whatever') + + -- Add a property setting a flag on control data + expect.addProperty('fluffy', function(controlData) + controlData.fluffy = true + end) + + -- Add a method with a size parameter + expect.addMethod('longerThan', function(controlData, size) + controlData:checkType("string") -- Only applies to strings + if controlData.fluffy then -- See if this flag was previously set + size = size * 2 + end + local params = { -- Prepare parameters for failure messages + size = size + } + controlData:assert(controlData.actual:len() > size, FailureMessage('expected {#} to be longer than {size}', params), + FailureMessage('expected {#} to not be longer than {size}', params)) + end) + + -- Add a mixed feature + expect.addChainableMethod('theAnswerToLifeUniverseAndEverything', function(controlData) + controlData:assert(controlData.actual == 42, FailureMessage('expected {#} to be 42'), + FailureMessage('expected {#} to not be 42')) + end, function(controlData) + controlData.answer = 42 + end) +end +``` + +All this may be used this way: + +```lua +expect("a long string").to.be.fluffy.And.theAnswerToLifeUniverseAndEverything.but.longerThan(3) +expect(42).to.whatever.be.theAnswerToLifeUniverseAndEverything() +``` + +# API + +## ControlData + +The first parameter provided to a feature function is a `ControlData` object. This object can contain any +data needed for the assertion. The object is shallow-copied between each chained feature. If you add your own +property to this object, be careful to choose the name in order to prevent conflicts with other plugins. + +To create a `ControlData` object, simply call `ControlData(data)` where `data` is either a table or a +`ControlData` object which will be shallow-copied. But you probably do not need to directly create a +`ControlData` object, this kind of object is usually created through the `Expect` object. + +Not counting plugin additions, the `ControlData` object contains the following properties and functions: + +### actual + +This is the actual object being tested. Usually, this is the one provided to the `expect` function as first +parameter, but this can be modified by a feature. + +### negate + +This property is set to true if the user called the `not` feature earlier in the assertion. It can be either +`nil` or `false` otherwise. + +### checkType(expected[, checkNegation]) + +You may call this function to ensure that actual object is of appropriate type. If `checkNegation` is `true`, +then the check will be inverted if the assertion is negated. The function does not return any value but fails +if the type is not of expected type. + +### checkIfCallable([checkNegation]) + +This function can be used to ensure that the actual object is callable, i.e. either a function or a table +with a metatable defining a `__call` function. If `checkNegation` is `true`, then the check will be inverted +if the assertion is negated. The function does not return any value but fails if the actual object is not +callable. + +### assert(expr, positiveMsg[, negativeMsg[, level]]) + +Call this function to process your assertion. If a `negativeMsg` is provided (not `nil`), then the function +will check if the assertion is negated and invert its behavior accordingly. Otherwise, the function fails if +`expr` is false. + +- `positiveMsg` is the message to display (`FailureMessage`) if `expr` is false and the assertion is not +inverted; +- `negativeMsg` is the message to display (`FailureMessage`) if `expr` is true and the assertion is inverted; +- `level` is a the level of functions to be ignored when throwing the error in order to show the real source +of the error; you usually should not need to set it. + +### fail(message[, level]) + +This function make the assertion fail immediately, displaying the provided `message` (`FailureMessage`). The +`level` parameter is the level of functions to be ignored when throwing the error in order to show the real +source of the error; you usually should not need to set it. + +## Expect + +The `Expect` object is the one created by the `expect()` function and all consecutive features called. This +object does not contain any accessible data by itself, but it creates the `ControlData` object used when +calling the features, and it redirects every property request to the appropriate feature. + +An `Expect` object can be created by calling `Expect(data)`, where `data` is given to `ControlData` +constructor. + +## FailureMessage + +A `FailureMessage` object is used to provide a message composed of a pattern and parameters. The message will +only be constructed before being used, which prevent loosing time creating an unneeded string. Parameters may +be formatted, depending on the pattern, in order to be clearly readable by the end-user. + +To create a `FailureMessage` object, call `FailureMessage(pattern, parameters)` where `pattern` is the +pattern and `parameters` is a table containing the parameters: keys are the parameter names and values are +the values to show to end user. If a parameter is referenced in the pattern but is not in the `parameters` +table it will not be considered empty but `nil` (displayed as `(nil)`). + +The pattern is a string containing placeholders for the parameters in the format `{name}` where `name` is the +name of the parameter. This name should only be made of letters, bad things may happen otherwise. + +You do not need to provide `actual` to the parameters, it will be automatically added and can be displayed in +the message using the placeholder `{#}`. + +If you want to add content without formatting, you can add use the format `{!name}` for the placeholder. The +parameter `name` will then be displayed using a simple `tostring` and no complex formatting. + +Your pattern must not contain opening curly brace `{` except to indicate the start of a placeholder. If you +need to display an opening curly brace, add an empty placeholder `{}`. + +### FailureMessage:setActual(actual) + +This function is used internally to set the value of `actual`. You usually do not need to call it yourself. + +### FailureMessage:toString() + +This function is used internally to create the displayed message from the pattern and the parameters. This +function is called when calling `tostring(msg)` with a `FailureMessage` object. + +## DiffTable + +Objects of type `DiffTable` are containing an `initial` table and a `diffs` array to identify differences +with another table. To create a `DiffTable`, simply call `DiffTable(initial, diffs)`, but you usually should +not need to create such object as it is created automatically when comparing tables. + +A `DiffTable` object can be directly provided as a `FailureMessage` parameter and will be displayed with the +differences highlighted. + +### DiffTable.isInstance(item) + +This function can be called on any object in order to check if this object is a `DiffTable` object. The +result is `true` if it is the case, `false` otherwise. + +### DiffTable.compare(item1, item2) + +This function compares the two provided items. If they are tables, they will be deep compared. The result of +the comparison is a boolean (`true` if objects are same) and either a copy of each object or a `DiffTable` +instead of each object if it makes sense. diff --git a/doc/usage.md b/doc/usage.md new file mode 100644 index 0000000..26ea51c --- /dev/null +++ b/doc/usage.md @@ -0,0 +1,178 @@ +# Basic usage + +An expect test always starts by calling `expect` with the target (tested object) and terminates by a function +call. You can apply more than one test at once to the target. Assertions are written in natural language. + +```lua +local result = 21 * 2 +expect(result).to.be.a("number").that.equals(42) +``` + +You can also write arbitrary message to prepend to any failed assertion that might occur. + +```lua +local answer = 43; + +-- expected (number) 43 to equal (number) 42 +expect(answer).to.equal(42); + +-- topic [answer]: expected (number) 43 to equal (number) 42 +expect(answer, 'topic [answer]').to.equal(42); +``` + +All words after the `expect()` call are case-insensitive. This is useful for words which are reserved in LUA, +like `and` or `not`, which you can then write, for example, `And` or `Not`. + +## Language chains + +The following are chainable words (properties) to improve the readablity of your assertions: + +- also +- and +- at +- be +- been +- but +- does +- has +- have +- is +- that +- to +- with +- which + +## not + +Negates all assertions that follow in the chain. + +```lua +expect(function() end).to.Not.fail() +``` + +## deep + +Used to do deep comparison instead of strict ones. + +```lua +expect({a = 1}).to.deep.equal({a = 1}) +``` + +## a(type) + +Asserts that the target’s type is equal to the given string type. Types are case insensitive. + +```lua +expect(12).to.be.a('number') +expect('foo').to.be.a('string') +``` + +The alias `.an` can be used interchangeably with `.a`. + +## equal(value) + +Asserts that the target is strictly or deeply (if `deep` is used earlier in the chain) equal to the given +value. + +```lua +expect(6 + 6).to.equal(12) +expect('foo').to.equal('foo') +expect({}).to.Not.equal({}) +expect({}).to.deep.equal({}) +``` + +The alias `.equals` can be used interchangeably with `.equal`. + +## fail([err[, plain]]) + +When no arguments are provided, `.fail` invokes the target function and asserts that an error is thrown. + +```lua +expect(function() error("Failing") end).to.fail() +``` + +When one argument is provided, `.fail` invokes the target function and asserts that an error is thrown, +mathing the given argument. If this argument is of type string, the test is made using LUA function `find` +and a second boolean argument can be provided in order to consider the `err` as a plain string instead of a +LUA pattern. + +```lua +expect(function() error("Failing") end).to.failWith('Failing$') +expect(function() error("Failing") end).to.failWith('Fail', true) +``` + +Aliases `fails`, `error`, `failWith`, `failsWith` can be used interchangeably with `.fail`. + +## false() + +Asserts that the target is false. + +```lua +expect(false).to.be.False() +``` + +## match(pattern) + +Asserts that the target matches the given pattern. + +```lua +expect('foo').to.match('^f.o$') +``` + +## nil() + +Asserts that the target is nil. + +```lua +expect(nil).to.be.Nil() +``` + +## ok() + +Asserts that the target is truthy (i.e. neither `nil` nor `false`). + +```lua +expect("foo").to.be.ok() +``` + +## true() + +Asserts that the target is true. + +```lua +expect(true).to.be.True() +``` + +# Configuration + +Even if the `expect` module can be used as-is, it may also be configured to fit your needs. This can be done +in a file required before your tests. + +## Options + +In order to configure an option, simply set the appropriate value to `expect.parameters`. You can configure +the following options: + +* `throw` is a function used to indicate a failing test. It defaults to `error` and must support the same syntax. + +## Plugins + +You should refer to the plugin documentation to see how to use it with `expect`. This is usually done by +requiring the plugin with the `expect` object as parameter: + +```lua +local expect = require('expect') +require('expect-wonderful-plugin')(expect) +``` +## Busted + +As a configuration example, if you are using `busted`, you may write a `.busted` file containing: + +```lua +local expect = require('expect') +local busted = require('busted') +require('expect-wonderful-plugin')(expect) -- This will require the plugin +expect.parameters.throw = busted.fail -- This will make failures appear as failures, not errors + +return {} -- Return you busted configuration +``` diff --git a/expect-dev-1.rockspec b/expect-dev-1.rockspec new file mode 100644 index 0000000..91065f2 --- /dev/null +++ b/expect-dev-1.rockspec @@ -0,0 +1,28 @@ +rockspec_format = "3.0" +package = "expect" +version = "dev-1" +description = { + summary = "BDD expect notation for LUA tests", + homepage = "https://github.com/sveyret/expect", + license = "MIT", +} +source = { + url = "git+https://github.com/sveyret/expect" +} +test_dependencies = { + "busted", +} +build = { + type = "builtin", + modules = { + expect = "expect.lua", + ["expect.ControlData"] = "expect/ControlData.lua", + ["expect.DiffTable"] = "expect/DiffTable.lua", + ["expect.Expect"] = "expect/Expect.lua", + ["expect.FailureMessage"] = "expect/FailureMessage.lua", + ["expect.core"] = "expect/core.lua", + }, +} +test = { + type = "busted", +} \ No newline at end of file diff --git a/expect.lua b/expect.lua new file mode 100644 index 0000000..b022e4f --- /dev/null +++ b/expect.lua @@ -0,0 +1,135 @@ +local DiffTable = require('expect.DiffTable') +local FailureMessage = require('expect.FailureMessage') +local ControlData = require('expect.ControlData') +local Expect = require('expect.Expect') + +-- +-- Private functions +-- + +--- The global parameters used by assert. +local parameters = { + throw = error, + plugins = {} +} + +--- The feature functions available for the tests. +local features = {} + +--- A no-op function. +local function noop() +end + +-- +-- Overrides +-- + +--- Fail with a message. +--- @param message FailureMessage The failure message to display. +--- @param level integer|nil The level of the error. +function ControlData:fail(message, level) + local message = tostring(message:setActual(self.actual)) + parameters.throw(self.message and (self.message .. ': ' .. message) or message, (level or 1) + 2) +end + +--- Execute the feature. +--- @param key string The feature name. +--- @param controlData ControlData The data. +function Expect.executeFeature(key, controlData) + if features[key] then + return features[key](controlData) + end +end + +-- +-- Definition of the expect object +-- + +--- This is the only exported object of the library. For the end-user, it is the expect() function, but for plugins, the +--- object also exports usefull objects and functions. +--- @class expect +--- @field parameters table The global parameters. +--- @alias expect fun(actual: any) +--- @alias expect fun(actual: any, message: string) +--- @param actual any The actual object to test. +--- @param message string An optional message to identify the test. +local expect = setmetatable({ + parameters = parameters +}, { + __call = function(_, actual, message) + return Expect({ + actual = actual, + message = message + }) + end +}) + +--- Add a feature. +--- @param name string The name of the feature to add. +--- @param featureFunc fun(data: ControlData) The feature function. +local function addFeature(name, featureFunc) + name = name:lower() + if features[name] then + error('Plugin conflict: cannot set already existing feature ' .. name) + end + features[name] = featureFunc +end + +--- Add a feature function seen as a property. +--- @param name string The name of the feature to add. +--- @param propertyFunction nil|fun(data: ControlData): any The property function. If nil, simply chain to next feature. +function expect.addProperty(name, propertyFunction) + addFeature(name, function(controlData) + local result = (propertyFunction or noop)(controlData) + if result ~= nil then + return result + end + return Expect(controlData) + end) +end + +--- Add a feature function seen as a method. +--- @param name string The name of the feature to add. +--- @param methodFunction nil|fun(data: ControlData, ...:any): any The method function. If nil, simply chain to next feature. +function expect.addMethod(name, methodFunction) + addFeature(name, function(controlData) + return function(...) + local result = (methodFunction or noop)(controlData, ...) + if result ~= nil then + return result + end + return Expect(controlData) + end + end) +end + +--- Add a feature function seen as both a property and a method. +--- @param name string The name of the feature to add. +--- @param methodFunction nil|fun(data: ControlData, ...:any): any The method function. If nil, simply chain to next feature. +--- @param propertyFunction nil|fun(data: ControlData): any The property function. If nil, simply chain to next feature. +function expect.addChainableMethod(name, methodFunction, propertyFunction) + addFeature(name, function(controlData) + local result = (propertyFunction or noop)(controlData) + if result ~= nil then + return result + end + result = Expect(controlData) + getmetatable(result).__call = function(_, ...) + local callingResult = (methodFunction or noop)(controlData, ...) + if callingResult ~= nil then + return callingResult + end + return Expect(controlData) + end + return result + end) +end + +-- +-- Add the core plugin +-- + +--- Load the core feature functions +require('expect.core')(expect) + +return expect diff --git a/expect/ControlData.lua b/expect/ControlData.lua new file mode 100644 index 0000000..b19bf83 --- /dev/null +++ b/expect/ControlData.lua @@ -0,0 +1,66 @@ +local FailureMessage = require("expect.FailureMessage") + +--- Metatable for the ControlData objects. +local ControlDataMT = {} + +--- ControlData is an object provided to feature functions. +--- It contains miscellaneous data about the control being done, including `actual`, the real value being tested. +--- Usually, this kind of object is created through the Expect object and may not be used alone. +--- @class ControlData +--- @alias ControlData fun(data: ControlData|table) +--- @param data ControlData|table The data used to initialize object, will be shallow copied. +local ControlData = setmetatable({}, { + __call = function(_, data) + data = data or {} + local controlData = {} + for key, value in pairs(data) do + controlData[key] = value + end + return setmetatable(controlData, ControlDataMT) + end +}) +ControlDataMT.__index = ControlData + +--- Check if the actual object has the appropriate type. +--- @param expected string The expected type for the object. +--- @param checkNegation boolean|nil Indicate if negation (i.e. `not`) should be checked. +function ControlData:checkType(expected, checkNegation) + expected = expected:lower() + local params = { + article = string.find('aeiou', expected:sub(1, 1)) and 'an' or 'a', + expected = expected + } + self:assert(type(self.actual):lower() == expected, + FailureMessage('expected {#} to be {!article} {!expected}', params), + checkNegation and FailureMessage('expected {#} not to be {!article} {!expected}', params) or nil, 2) +end + +--- Check if the actual object is callable. +--- @param checkNegation boolean|nil Indicate if negation (i.e. `not`) should be checked. +function ControlData:checkIfCallable(checkNegation) + self:assert(type(self.actual):lower() == 'function' or type(getmetatable(self.actual).__call) ~= 'function', + FailureMessage('expected {#} to be callable'), + checkNegation and FailureMessage('expected {#} not to be callable') or nil, 2) +end + +--- Process an assertion on the control data. +--- @param expr boolean The expression to test, if false, an exception is thrown. +--- @param positiveMsg FailureMessage Failure message to display if test fails. +--- @param negativeMsg FailureMessage|nil Failure message to display if test succeed, but we want it to fail (using a `not`), nil to ignore negation. +--- @param level integer|nil The level of the error. +function ControlData:assert(expr, positiveMsg, negativeMsg, level) + if expr and self.negate and negativeMsg then + return self:fail(negativeMsg, (level or 1) + 1) + elseif not (expr or (self.negate and negativeMsg)) then + return self:fail(positiveMsg, (level or 1) + 1) + end +end + +--- Fail with a message. +--- @param message FailureMessage The failure message to display. +--- @param level integer|nil The level of the error. +function ControlData:fail(message, level) + error("Not implemented") -- Implemented externally +end + +return ControlData diff --git a/expect/DiffTable.lua b/expect/DiffTable.lua new file mode 100644 index 0000000..5be0717 --- /dev/null +++ b/expect/DiffTable.lua @@ -0,0 +1,108 @@ +--- The metatable used to identify DiffTable objects. +local DiffTableMT = {} + +--- A DiffTable object is actually a table containing some highlighted items used to show differences with +--- another table. +--- @class DiffTable +--- @field initial table The initial table. +--- @field diffs table The differences. +--- @alias DiffTable fun(initial: any, diffs: table|nil) +--- @param initial any The initial object. +--- @param diffs table|nil The differences, if any. +local DiffTable = setmetatable({}, { + __call = function(DiffTable, initial, diffs) + if type(initial) == 'table' and not DiffTable.isInstance(initial) and diffs then + return setmetatable({ + initial = initial, + diffs = diffs + }, DiffTableMT) + else + return initial + end + end +}) + +--- Indicate if the given item is an instance of DiffTable +--- @param time any The item to test. +--- @return boolean +function DiffTable.isInstance(item) + return type(item) == 'table' and getmetatable(item) == DiffTableMT +end + +--- Compare 2 objects, recursing into entries for tables. +--- @param item1 any The first object to compare. +--- @param item2 any The second object to compare. +--- @param cycles table A table to keep information on cycles. +--- @return boolean, table|nil +local function deepCompare(item1, item2, cycles) + -- Non-table types can be directly compared + if type(item1) ~= 'table' or type(item2) ~= 'table' then + return item1 == item2 + end + + -- Check using metatable + local mt1 = getmetatable(item1) + local mt2 = getmetatable(item2) + if mt1 and mt1 == mt2 and mt1.__eq then + return item1 == item2 + elseif rawequal(item1, item2) then + return true + end + + -- Handle recursive tables + cycles.item1[item1] = (cycles.item1[item1] or 0) + cycles.item2[item2] = (cycles.item2[item2] or 0) + if cycles.item1[item1] == 1 or cycles.item2[item2] == 1 then + cycles.threshold1 = cycles.item1[item1] + 1 + cycles.threshold2 = cycles.item2[item2] + 1 + end + if cycles.item1[item1] > cycles.threshold1 and cycles.item2[item2] > cycles.threshold2 then + return true + end + + cycles.threshold1 = cycles.item1[item1] + 1 + cycles.threshold2 = cycles.item2[item2] + 1 + + -- Compare table content + for k1, v1 in pairs(item1) do + local v2 = item2[k1] + if v2 == nil then + return false, {k1} + end + local same, diffs = deepCompare(v1, v2, cycles) + if not same then + diffs = diffs or {} + table.insert(diffs, k1) + return false, diffs + end + end + + -- Check that there are no extra key in second table + for k2 in pairs(item2) do + if item1[k2] == nil then + return false, {k2} + end + end + + cycles.item1[item1] = cycles.item1[item1] - 1 + cycles.item2[item2] = cycles.item2[item2] - 1 + + return true +end + +--- Compare 2 items which may, or may not, be tables. The result is the result of the comparison and the +--- items, as DiffTable objects if there are differences and if they are tables, unmodified otherwise. +--- @param item1 any The first object to compare. +--- @param item2 any The second object to compare. +--- @return boolean, any, any +function DiffTable.compare(item1, item2) + local same, diffs = deepCompare(item1, item2, { + item1 = {}, + item2 = {}, + threshold1 = 1, + threshold2 = 1 + }) + return same, DiffTable(item1, diffs), DiffTable(item2, diffs) +end + +return DiffTable diff --git a/expect/Expect.lua b/expect/Expect.lua new file mode 100644 index 0000000..9206863 --- /dev/null +++ b/expect/Expect.lua @@ -0,0 +1,27 @@ +local ControlData = require("expect.ControlData") + +--- Expect is the main object seen by the end-user. It provides all features as properties or functions and, when +--- requested, call them providing ControlData. +--- @class Expect +--- @alias Expect fun(data: ControlData|table) +--- @param data ControlData|table The data used to create control data. +local Expect = setmetatable({}, { + __call = function(Expect, data) + local controlData = ControlData(data) + return setmetatable({}, { + __index = function(_, key) + key = key:lower() + return Expect.executeFeature(key, controlData) + end + }) + end +}) + +--- Execute the feature. Internal use only. +--- @param key string The feature name. +--- @param controlData ControlData The data. +function Expect.executeFeature(key, controlData) + error("Not implemented") -- Implemented externally +end + +return Expect diff --git a/expect/FailureMessage.lua b/expect/FailureMessage.lua new file mode 100644 index 0000000..46f3c37 --- /dev/null +++ b/expect/FailureMessage.lua @@ -0,0 +1,272 @@ +local DiffTable = require('expect.DiffTable') + +--- Function used to show differences in red, if possible. +local color +do + local ok, term = pcall(require, 'term') + local isatty = io.type(io.stdout) == 'file' and ok and term.isatty(io.stdout) + if not isatty then + local isWindows = package.config:sub(1, 1) == '\\' + if isWindows and os.getenv('ANSICON') then + isatty = true + end + end + + color = function(c) + if isatty then + return term.colors.red(c) + else + return c + end + end +end + +--- Priority of key types when ordering tables. +local type_priorities = { + number = 1, + boolean = 2, + string = 3, + table = 4, + ['function'] = 5, + userdata = 6, + thread = 7 +} + +--- Indicate if key is in the array part of the table. +--- @param key any The key to check. +--- @param length number The length of the table. +--- @return boolean +local function is_in_array_part(key, length) + return type(key) == 'number' and 1 <= key and key <= length and math.floor(key) == key +end + +--- Get the key of the table, sorted. +--- @param t table The table to sort. +--- @return table, number +local function get_sorted_keys(t) + local keys = {} + local nkeys = 0 + + for key in pairs(t) do + nkeys = nkeys + 1 + keys[nkeys] = key + end + + local length = #t + + local function key_comparator(key1, key2) + local type1, type2 = type(key1), type(key2) + local priority1 = is_in_array_part(key1, length) and 0 or type_priorities[type1] or 8 + local priority2 = is_in_array_part(key2, length) and 0 or type_priorities[type2] or 8 + + if priority1 == priority2 then + if type1 == 'string' or type1 == 'number' then + return key1 < key2 + elseif type1 == 'boolean' then + return key1 -- put true before false + end + else + return priority1 < priority2 + end + end + + table.sort(keys, key_comparator) + return keys, nkeys +end + +--- The maximum depth to show a table. +local FORMAT_TABLE_MAX_DEPTH = 3 + +--- Format the provided table. +--- @param arg table The table to format. +--- @return string +local function format_table(arg) + local diffs = {} + if DiffTable.isInstance(arg) then + diffs = arg.diffs + arg = arg.initial + end + + local type_desc + if getmetatable(arg) == nil then + type_desc = '(' .. tostring(arg) .. ') ' + elseif not pcall(setmetatable, arg, getmetatable(arg)) then + -- cannot set same metatable, so it is protected, skip id + type_desc = '(table) ' + else + -- unprotected metatable, temporary remove the mt + local mt = getmetatable(arg) + setmetatable(arg, nil) + type_desc = '(' .. tostring(arg) .. ') ' + setmetatable(arg, mt) + end + + local cache = {} + local function ft(t, l, with_diffs) + if cache[t] and cache[t] > 0 then + return '{ ... recursive }' + end + + if next(t) == nil then + return '{ }' + end + + if l > math.max(FORMAT_TABLE_MAX_DEPTH, with_diffs and #diffs or 0) then + return '{ ... more }' + end + + local result = '{' + local keys, nkeys = get_sorted_keys(t) + + cache[t] = (cache[t] or 0) + 1 + local diff = diffs[#diffs - l + 1] + + for i = 1, nkeys do + local k = keys[i] + local v = t[k] + local use_diffs = with_diffs and k == diff + + if type(v) == 'table' then + v = ft(v, l + 1, use_diffs) + elseif type(v) == 'string' then + v = '\'' .. v .. '\'' + end + + local ch = use_diffs and '*' or '' + local indent = string.rep(' ', l * 2 - ch:len()) + local mark = (ch:len() == 0 and '' or color(ch)) + result = result .. string.format('\n%s%s[%s] = %s', indent, mark, tostring(k), tostring(v)) + end + + cache[t] = cache[t] - 1 + + return result .. ' }' + end + + return type_desc .. ft(arg, 1, true) +end + +--- Format the provided boolean value. +--- @param arg boolean The boolean value to format. +--- @return string +local function format_boolean(arg) + return string.format('(boolean) %s', tostring(arg)) +end + +--- Format the provided function. +--- @param arg fun(...: any): any The function to format. +--- @return string +local function format_function(arg) + local debug_info = debug.getinfo(arg) + return string.format('%s @ line %s in %s', tostring(arg), tostring(debug_info.linedefined), + tostring(debug_info.source)) +end + +--- Format a nil value. +--- @return string +local function format_nil() + return '(nil)' +end + +--- Format a number. +--- @param arg number The number to format. +--- @return string +local function format_number(arg) + local str + if arg ~= arg then + str = 'NaN' + elseif arg == 1 / 0 then + str = 'Inf' + elseif arg == -1 / 0 then + str = '-Inf' + else + str = string.format('%.20g', arg) + if math.type and math.type(arg) == 'float' and not str:find('[%.,]') then + str = str:gsub('%d+', '%0.0', 1) + end + end + return string.format('(number) %s', str) +end + +--- Format any type using `tostring`. +--- @param arg any The item to format. +--- @return string +local function format_simple(arg) + return string.format('(%s) \'%s\'', type(arg), tostring(arg)) +end + +--- Formatter used for each type. +local formatters = { + boolean = format_boolean, + ['function'] = format_function, + ['nil'] = format_nil, + number = format_number, + table = format_table +} + +--- Format the given parameter. +--- @param arg any The parameter to format. +--- @return string The formatted parameter. +local function formatParameter(arg) + local formatter = formatters[type(arg)] + return type(formatter) == 'function' and formatter(arg) or format_simple(arg) +end + +--- Metatable for the FailureMessage objects. +local FailureMessageMT = {} + +--- A failure message is composed of a pattern, containing some placeholder in the form {name}, where the name +--- is a key which should be found in the parameters table. If name is not in the parameters, it will be +--- considered nil, and not empty! The placeholder may also be in the form {!name}. In this case, the value +--- for the name should be a string and will be inserted in the pattern without formatting. In order to safely +--- insert a curly brace in the pattern, it can be escaped using an empty {} which will be converted into an +--- opening curly brace. The value for actual is specific and should be identified as {#}. +--- @class FailureMessage +--- @alias FailureMessage fun(pattern: string, parameters: table) +--- @param pattern string The pattern of the message. +--- @param parameters table The parameters for the message. +local FailureMessage = setmetatable({}, { + __call = function(_, pattern, parameters) + return setmetatable({ + pattern = pattern or '', + parameters = parameters or {} + }, FailureMessageMT) + end +}) + +--- Set the actual value. +--- @param actual any The actual value. +--- @return FailureMessage +function FailureMessage:setActual(actual) + self.parameters['#'] = actual + return self +end + +--- Convert the object into a string. +--- @return string +function FailureMessage:toString() + return self.pattern:gsub('{([^{}]*)}', function(name) + if name:len() == 0 then + return '{' + end + + local raw = name:sub(1, 1) + if raw == '!' then + name = name:sub(2) + else + raw = nil + end + + if raw then + return tostring(self.parameters[name]) + else + return formatParameter(self.parameters[name]) + end + end) +end + +--- Metatable content. +FailureMessageMT.__tostring = FailureMessage.toString +FailureMessageMT.__index = FailureMessage + +return FailureMessage diff --git a/expect/core.lua b/expect/core.lua new file mode 100644 index 0000000..fa053d2 --- /dev/null +++ b/expect/core.lua @@ -0,0 +1,127 @@ +local DiffTable = require('expect.DiffTable') +local FailureMessage = require('expect.FailureMessage') + +return function(expect) + -- Chainable words with no action + for _, key in pairs({'also', 'and', 'at', 'be', 'been', 'but', 'does', 'has', 'have', 'is', 'that', 'to', 'with', + 'which'}) do + expect.addProperty(key) + end + + -- Set negate flag, which inverts the meaning of all coming tests + expect.addProperty('not', function(controlData) + controlData.negate = true + end) + + -- Set deep flag + expect.addProperty('deep', function(controlData) + controlData.deep = true + end) + + -- Check object is of given type + local function expectAn(controlData, expected) + controlData:checkType(expected, true) + end + expect.addChainableMethod('a', expectAn) + expect.addChainableMethod('an', expectAn) + + -- Check object is truthy + expect.addMethod('ok', function(controlData) + controlData:assert(not not controlData.actual, FailureMessage('expected {#} to be truthy'), + FailureMessage('expected {#} to be falsy')) + end) + + -- Check object is true + expect.addMethod('true', function(controlData) + controlData:assert(controlData.actual == true, FailureMessage('expected {#} to be true'), + FailureMessage('expected {#} to be false')) + end) + + -- Check object is false + expect.addMethod('false', function(controlData) + controlData:assert(controlData.actual == false, FailureMessage('expected {#} to be false'), + FailureMessage('expected {#} to be true')) + end) + + -- Check object is nil + expect.addMethod('nil', function(controlData) + controlData:assert(controlData.actual == nil, FailureMessage('expected {#} to be nil'), + FailureMessage('expected {#} not to be nil')) + end) + + -- Check object is strictly or deeply equal to given value + local function expectEqual(controlData, expected) + if controlData.deep then + local same, actualObject, expectedObject = DiffTable.compare(controlData.actual, expected) + local params = { + actual = actualObject, + expected = expectedObject + } + controlData:assert(same, FailureMessage('expected {actual} to deeply equal {expected}', params), + FailureMessage('expected {actual} to not deeply equal {expected}', params)) + else + local params = { + expected = expected + } + controlData:assert(controlData.actual == expected, FailureMessage('expected {#} to equal {expected}', params), + FailureMessage('expected {#} to not equal {expected}', params)) + end + end + expect.addMethod('equal', expectEqual) + expect.addMethod('equals', expectEqual) + + -- Check object matches given pattern + local function expectMatch(controlData, pattern) + local params = { + pattern = pattern + } + controlData:assert(tostring(controlData.actual):match(pattern), + FailureMessage('expected {#} to match {!pattern}', params), + FailureMessage('expected {#} to not match {!pattern}', params)) + end + expect.addMethod('match', expectMatch) + expect.addMethod('matches', expectMatch) + + -- Check function throws an exception + local function expectFail(controlData, expectedErr, plain) + controlData:checkIfCallable() + local ok, actualErr = pcall(controlData.actual) + + if not ok and type(actualErr) == 'string' then + actualErr = actualErr:gsub('^.-:%d+: ', '', 1) + end + local expectedMsg = expectedErr == nil and '' or ' with error {expectedErr}' + local actualMsg = actualErr == nil and '' or ', but {actualErr} was thrown' + local params = { + expectedErr = expectedErr, + actualErr = actualErr + } + + if ok or expectedErr == nil then + controlData:assert(not ok, FailureMessage('expected {#} to fail, but it was successful'), + FailureMessage('expected {#} not to fail' .. actualMsg, params)) + elseif type(expectedErr) == 'string' and + (type(actualErr) == 'string' or type((getmetatable(actualErr) or {}).__tostring) == 'function') then + controlData:assert(tostring(actualErr):find(expectedErr, 1, plain) ~= nil, + FailureMessage('expected {#} to fail' .. expectedMsg .. actualMsg, params), + FailureMessage('expected {#} not to fail' .. expectedMsg, params)) + elseif type(expectedErr) == 'number' and type(actualErr) == 'string' then + controlData:assert(expectedErr == tonumber(actualErr), + FailureMessage('expected {#} to fail' .. expectedMsg .. actualMsg, params), + FailureMessage('expected {#} not to fail' .. expectedMsg, params)) + else + local same, expectedErr, actualErr = DiffTable.compare(expectedErr, actualErr) + params = { + expectedErr = expectedErr, + actualErr = actualErr + } + controlData:assert(same, FailureMessage('expected {#} to fail' .. expectedMsg .. actualMsg, params), + FailureMessage('expected {#} not to fail' .. expectedMsg, params)) + end + end + expect.addMethod('fail', expectFail) + expect.addMethod('fails', expectFail) + expect.addMethod('error', expectFail) + expect.addMethod('failWith', expectFail) + expect.addMethod('failsWith', expectFail) +end diff --git a/spec/expect/core_spec.lua b/spec/expect/core_spec.lua new file mode 100644 index 0000000..d13200c --- /dev/null +++ b/spec/expect/core_spec.lua @@ -0,0 +1,314 @@ +local expect = require('expect') + +local function case(name, testedFunction, failure, plain) + it('should ' .. (failure and 'fail ' or 'pass ') .. name, function() + if failure then + expect(testedFunction).to.failWith(failure, plain) + else + expect(testedFunction).to.Not.fail() + end + end) +end + +describe('expect', function() + for _, chainableWord in pairs({'a', 'also', 'and', 'at', 'be', 'been', 'but', 'does', 'has', 'have', 'is', 'that', + 'to', 'with', 'which'}) do + describe(chainableWord, function() + case('without changing the behavior of the test', function() + expect(1 + 1)[chainableWord].equal(2) + end) + end) + end + + describe('a', function() + describe('(positive)', function() + case('if target has the expected type', function() + expect('foo').to.be.a('string') + end) + + case('if target has another type', function() + expect(12).to.be.a('string') + end, 'expected %(number%) 12 to be a string$') + end) + + describe('(negative)', function() + case('if target has the expected type', function() + expect(12).to.Not.be.a('number') + end, 'expected %(number%) 12 not to be a number$') + + case('if target has another type', function() + expect('foo').to.Not.be.a('number') + end) + end) + end) + + describe('ok', function() + case('if target is truthy', function() + expect('foo').to.be.ok() + end) + + case('if target is falsy', function() + expect(nil).to.be.ok() + end, 'expected (nil) to be truthy', true) + + case('if target is truthy with negative test', function() + expect('foo').to.Not.be.ok() + end, 'expected (string) \'foo\' to be falsy', true) + end) + + describe('true', function() + case('if target is true', function() + expect(true).to.be.True() + end) + + case('if target is false', function() + expect(nil).to.be.True() + end, 'expected (nil) to be true', true) + + case('if target is true with negative test', function() + expect(true).to.Not.be.True() + end, 'expected (boolean) true to be false', true) + end) + + describe('false', function() + case('if target is false', function() + expect(false).to.be.False() + end) + + case('if target is true', function() + expect(true).to.be.False() + end, 'expected (boolean) true to be false', true) + + case('if target is false with negative test', function() + expect(false).to.Not.be.False() + end, 'expected (boolean) false to be true', true) + end) + + describe('nil', function() + case('if target is nil', function() + expect(nil).to.be.Nil() + end) + + case('if target is not nil', function() + expect('foo').to.be.Nil() + end, 'expected (string) \'foo\' to be nil', true) + + case('if target is nil with negative test', function() + expect(nil).to.Not.be.Nil() + end, 'expected (nil) not to be nil', true) + end) + + describe('equal', function() + describe('(positive)', function() + case('if objects are strictly the same', function() + expect('foo').to.equal('foo') + end) + + case('if objects are not the same', function() + expect({}).to.equal({}) + end, 'expected %(table: .*%) { } to equal %(table: .*%) { }$') + end) + + describe('(negative)', function() + case('if objects are strictly the same', function() + expect('foo').to.Not.equal('foo') + end, 'expected %(string%) \'foo\' to not equal %(string%) \'foo\'$') + + case('if objects are not the same', function() + expect(12).to.Not.equal('foo') + end) + end) + + describe('(deep)', function() + case('if objects are deeply equal', function() + expect({ + a = 1 + }).to.deep.equal({ + a = 1 + }) + end) + + case('with negative test if objects are deeply equal', function() + expect({ + a = 1 + }).to.Not.deep.equal({ + a = 1 + }) + end, 'expected %(table: .*%[a%] = 1.*to not deeply equal %(table: .*%[a%] = 1') + + case('if objects are not deeply equal', function() + expect({ + 'This should fail', + failure = { + deep = { + again = 'yes', + deeper = { + diff = 'none' + } + }, + here = { + again = 'yes', + deeper = { + diff = 'here' + } + } + } + }).to.deep.equal({ + 'This should fail', + failure = { + deep = { + again = 'yes', + deeper = { + diff = 'none' + } + }, + here = { + again = 'yes', + deeper = { + diff = 'there' + } + } + } + }) + end, 'expected %(table.*more.*%*.*%[diff%].*here.* to deeply equal %(table: .*%) .*more.*there') + end) + end) + + describe('match', function() + case('if target matches pattern', function() + expect('foo').to.match('f.o$') + end) + + case('if target does not match pattern', function() + expect('foo').to.match('bar') + end, 'expected (string) \'foo\' to match bar', true) + + case('if target matches pattern with negative test', function() + expect('foo').to.Not.match('f.o$') + end, 'expected (string) \'foo\' to not match f.o$', true) + end) + + describe('fail', function() + -- Override default, because this function cannot be tested by itself + local function case(name, testedFunction, failure) + it('should ' .. (failure and 'fail ' or 'pass ') .. name, function() + local ok, res = pcall(testedFunction) + if failure then + expect(ok, 'expected to fail').to.be.False() + expect(res).to.match(failure) + else + expect(ok, 'call result').to.be.True() + end + end) + end + + local function failingFunction() + error('Oh no! This function is failing!') + end + + local function successfulFunction() + end + + describe('(positive)', function() + case('with failing function', function() + expect(failingFunction).to.fail() + end) + + case('with function throwing matching error', function() + expect(failingFunction).to.failWith('is%sfailing') + end) + + case('with function throwing exact error', function() + expect(failingFunction).to.failWith('is failing', true) + end) + + case('with function throwing expected number as string', function() + expect(function() + error('12') + end).to.failWith(12) + end) + + case('with function throwing expected number', function() + expect(function() + error(12) + end).to.failWith(12) + end) + + case('with function throwing expected table', function() + expect(function() + error({ + 'item1', + key = 'value1' + }) + end).to.failWith({ + 'item1', + key = 'value1' + }) + end) + + case('with successful function', function() + expect(successfulFunction).to.fail() + end, 'expected function.* to fail, but it was successful$') + + case('with successful function, even if an error was specified', function() + expect(successfulFunction).to.failWith('any error') + end, 'expected function.* to fail, but it was successful$') + + case('with function throwing non matching error', function() + expect(failingFunction).to.failWith('is successful') + end, + 'expected function.* to fail with error %(string%) \'is successful\', but %(string%) \'Oh no! This function is failing!\' was thrown$') + + case('with function throwing wrong error', function() + expect(failingFunction).to.failWith('is%sfailing', true) + end, + 'expected function.* to fail with error %(string%) \'is%%sfailing\', but %(string%) \'Oh no! This function is failing!\' was thrown$') + + case('with function throwing wrong number as string', function() + expect(function() + error('12') + end).to.failWith(144) + end, 'expected function.* to fail with error %(number%) 144, but %(string%) \'12\' was thrown$') + + case('with function throwing wrong number', function() + expect(function() + error(12) + end).to.failWith(144) + end, 'expected function.* to fail with error %(number%) 144, but') -- Cannot test more, lua 5.1 throws a string anyway + + case('with function throwing wrong table', function() + expect(function() + error({ + 'This should fail', + failure = true + }) + end).to.failWith({ + 'This should fail', + failure = false + }) + end, 'expected function.* to fail with error %(table: .*%) .*false.*, but %(table: .*%) .*true.* was thrown$') + end) + + describe('(negative)', function() + case('with successful function', function() + expect(successfulFunction).to.Not.fail() + end) + + case('with successful function, even if an error was specified', function() + expect(successfulFunction).to.Not.failWith('any error') + end) + + case('with function throwing non matching error', function() + expect(failingFunction).to.Not.failWith('is successful') + end) + + case('with failing function', function() + expect(failingFunction).to.Not.fail() + end, 'expected function.* not to fail, but %(string%) \'Oh no! This function is failing!\' was thrown$') + + case('with function throwing matching error', function() + expect(failingFunction).to.Not.failWith('is failing') + end, 'expected function.* not to fail with error %(string%) \'is failing\'') + end) + end) +end) diff --git a/spec/expect_spec.lua b/spec/expect_spec.lua new file mode 100644 index 0000000..060e552 --- /dev/null +++ b/spec/expect_spec.lua @@ -0,0 +1,31 @@ +local expect = require('expect') + +local EXISTING_FEATURES = { + to = 'property', + equal = 'method', + a = 'chainable method' +} +local FEATURE_FUNCTIONS = { + property = 'addProperty', + method = 'addMethod', + ['chainable method'] = 'addChainableMethod' +} +describe('expect', function() + for name, category in pairs(EXISTING_FEATURES) do + for addedCategory, featureFunction in pairs(FEATURE_FUNCTIONS) do + it('should refuse addition of ' .. addedCategory .. ' “' .. name .. '” which has same name as existing ' .. + category, function() + expect(function() + expect[featureFunction](name) + end).to.failWith('Plugin conflict: cannot set already existing feature ' .. name:lower()) + end) + + it('should refuse addition of ' .. addedCategory .. ' “' .. name:upper() .. + '” conflicting with already existing ' .. category .. ' “' .. name .. '”', function() + expect(function() + expect[featureFunction](name) + end).to.failWith('Plugin conflict: cannot set already existing feature ' .. name:lower()) + end) + end + end +end)