diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c21391..dc9e821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ # TestEZ Changelog ## Unreleased Changes +* Added `expect.extend` which allows projects to register their own, opinionated expectations that integrates into `expect`. ([#142](https://github.com/Roblox/testez/pull/142)) + * Modeled after [jest's implementation](https://jestjs.io/docs/en/expect#expectextendmatchers). + * Matchers are functions that should return an object with with two keys, boolean `pass` and a string `message` + * Like `context`, matchers introduced via `expect.extend` will be present on all nodes below the node that introduces the matchers. + * Limitations: + * `expect.extend` cannot be called from within `describe` blocks + * Custom matcher names cannot overwrite pre-existing matchers, including default matchers and matchers introduces from previous `expect.extend` calls. ## 0.3.3 (2020-09-25) * Remove the lifecycle hooks from the session tree. This prevents the `[?]` spam from the reporter not recognizing these nodes. diff --git a/src/Expectation.lua b/src/Expectation.lua index 2b58ff6..96dc2c7 100644 --- a/src/Expectation.lua +++ b/src/Expectation.lua @@ -76,7 +76,9 @@ function Expectation.new(value) local self = { value = value, successCondition = true, - condition = false + condition = false, + matchers = {}, + _boundMatchers = {}, } setmetatable(self, Expectation) @@ -91,6 +93,31 @@ function Expectation.new(value) return self end +function Expectation.checkMatcherNameCollisions(name) + if SELF_KEYS[name] or NEGATION_KEYS[name] or Expectation[name] then + return false + end + + return true +end + +function Expectation:extend(matchers) + self.matchers = matchers or {} + + for name, implementation in pairs(self.matchers) do + self._boundMatchers[name] = bindSelf(self, function(_self, ...) + local result = implementation(self.value, ...) + local pass = result.pass == self.successCondition + + assertLevel(pass, result.message, 3) + self:_resetModifiers() + return self + end) + end + + return self +end + function Expectation.__index(self, key) -- Keys that don't do anything except improve readability if SELF_KEYS[key] then @@ -99,12 +126,16 @@ function Expectation.__index(self, key) -- Invert your assertion if NEGATION_KEYS[key] then - local newExpectation = Expectation.new(self.value) + local newExpectation = Expectation.new(self.value):extend(self.matchers) newExpectation.successCondition = not self.successCondition return newExpectation end + if self._boundMatchers[key] then + return self._boundMatchers[key] + end + -- Fall back to methods provided by Expectation return Expectation[key] end @@ -151,6 +182,9 @@ function Expectation:a(typeName) return self end +-- Make alias public on class +Expectation.an = Expectation.a + --[[ Assert that our expectation value is truthy ]] @@ -274,4 +308,4 @@ function Expectation:throw(messageSubstring) return self end -return Expectation \ No newline at end of file +return Expectation diff --git a/src/ExpectationContext.lua b/src/ExpectationContext.lua new file mode 100644 index 0000000..b55f53c --- /dev/null +++ b/src/ExpectationContext.lua @@ -0,0 +1,38 @@ +local Expectation = require(script.Parent.Expectation) +local checkMatcherNameCollisions = Expectation.checkMatcherNameCollisions + +local function copy(t) + local result = {} + + for key, value in pairs(t) do + result[key] = value + end + + return result +end + +local ExpectationContext = {} +ExpectationContext.__index = ExpectationContext + +function ExpectationContext.new(parent) + local self = { + _extensions = parent and copy(parent._extensions) or {}, + } + + return setmetatable(self, ExpectationContext) +end + +function ExpectationContext:startExpectationChain(...) + return Expectation.new(...):extend(self._extensions) +end + +function ExpectationContext:extend(config) + for key, value in pairs(config) do + assert(self._extensions[key] == nil, string.format("Cannot reassign %q in expect.extend", key)) + assert(checkMatcherNameCollisions(key), string.format("Cannot overwrite matcher %q; it already exists", key)) + + self._extensions[key] = value + end +end + +return ExpectationContext diff --git a/src/TestPlan.lua b/src/TestPlan.lua index 6529ec2..7b34064 100644 --- a/src/TestPlan.lua +++ b/src/TestPlan.lua @@ -105,7 +105,15 @@ local function newEnvironment(currentNode, extraEnvironment) env.fdescribe = env.describeFOCUS env.xdescribe = env.describeSKIP - env.expect = Expectation.new + env.expect = setmetatable({ + extend = function(...) + error("Cannot call \"expect.extend\" from within a \"describe\" node.") + end, + }, { + __call = function(_self, ...) + return Expectation.new(...) + end, + }) return env end diff --git a/src/TestRunner.lua b/src/TestRunner.lua index 33018d8..1038139 100644 --- a/src/TestRunner.lua +++ b/src/TestRunner.lua @@ -6,7 +6,6 @@ state is contained inside a TestSession object. ]] -local Expectation = require(script.Parent.Expectation) local TestEnum = require(script.Parent.TestEnum) local TestSession = require(script.Parent.TestSession) local LifecycleHooks = require(script.Parent.LifecycleHooks) @@ -17,8 +16,16 @@ local TestRunner = { environment = {} } -function TestRunner.environment.expect(...) - return Expectation.new(...) +local function wrapExpectContextWithPublicApi(expectationContext) + return setmetatable({ + extend = function(...) + expectationContext:extend(...) + end, + }, { + __call = function(_self, ...) + return expectationContext:startExpectationChain(...) + end, + }) end --[[ @@ -70,6 +77,8 @@ function TestRunner.runPlanNode(session, planNode, lifecycleHooks) errorMessage = messagePrefix .. message .. "\n" .. debug.traceback() end + testEnvironment.expect = wrapExpectContextWithPublicApi(session:getExpectationContext()) + local context = session:getContext() local nodeSuccess, nodeResult = xpcall( @@ -174,4 +183,4 @@ function TestRunner.runPlanNode(session, planNode, lifecycleHooks) lifecycleHooks:popHooks() end -return TestRunner \ No newline at end of file +return TestRunner diff --git a/src/TestSession.lua b/src/TestSession.lua index 1094285..285e11c 100644 --- a/src/TestSession.lua +++ b/src/TestSession.lua @@ -10,6 +10,7 @@ local TestEnum = require(script.Parent.TestEnum) local TestResults = require(script.Parent.TestResults) local Context = require(script.Parent.Context) +local ExpectationContext = require(script.Parent.ExpectationContext) local TestSession = {} @@ -25,6 +26,7 @@ function TestSession.new(plan) results = TestResults.new(plan), nodeStack = {}, contextStack = {}, + expectationContextStack = {}, hasFocusNodes = false } @@ -98,12 +100,16 @@ end function TestSession:pushNode(planNode) local node = TestResults.createNode(planNode) local lastNode = self.nodeStack[#self.nodeStack] or self.results - local lastContext = self.contextStack[#self.contextStack] - local context = Context.new(lastContext) - table.insert(lastNode.children, node) table.insert(self.nodeStack, node) + + local lastContext = self.contextStack[#self.contextStack] + local context = Context.new(lastContext) table.insert(self.contextStack, context) + + local lastExpectationContext = self.expectationContextStack[#self.expectationContextStack] + local expectationContext = ExpectationContext.new(lastExpectationContext) + table.insert(self.expectationContextStack, expectationContext) end --[[ @@ -113,6 +119,7 @@ function TestSession:popNode() assert(#self.nodeStack > 0, "Tried to pop from an empty node stack!") table.remove(self.nodeStack, #self.nodeStack) table.remove(self.contextStack, #self.contextStack) + table.remove(self.expectationContextStack, #self.expectationContextStack) end --[[ @@ -123,6 +130,12 @@ function TestSession:getContext() return self.contextStack[#self.contextStack] end + +function TestSession:getExpectationContext() + assert(#self.expectationContextStack > 0, "Tried to get expectationContext from an empty stack!") + return self.expectationContextStack[#self.expectationContextStack] +end + --[[ Tells whether the current test we're in should be skipped. ]] diff --git a/tests/failing/expectExtend-context-duplicate.spec.lua b/tests/failing/expectExtend-context-duplicate.spec.lua new file mode 100644 index 0000000..9db1987 --- /dev/null +++ b/tests/failing/expectExtend-context-duplicate.spec.lua @@ -0,0 +1,25 @@ +-- luacheck: globals describe beforeAll expect + +local noOptMatcher = function(_received, _expected) + return { + message = "", + pass = true, + } +end + +return function() + beforeAll(function() + expect.extend({ + customMatcher = noOptMatcher, + }) + end) + + describe("redefine matcher", function() + beforeAll(function() + expect.extend({ + -- This should throw since we are redefining the same matcher + customMatcher = noOptMatcher, + }) + end) + end) +end diff --git a/tests/failing/expectExtend-describe.spec.lua b/tests/failing/expectExtend-describe.spec.lua new file mode 100644 index 0000000..a4de6d4 --- /dev/null +++ b/tests/failing/expectExtend-describe.spec.lua @@ -0,0 +1,16 @@ +-- luacheck: globals describe expect + +local noOptMatcher = function(_received, _expected) + return { + message = "", + pass = true, + } +end + +return function() + describe("SHOULD NOT work in a describe block", function() + expect.extend({ + test = noOptMatcher, + }) + end) +end diff --git a/tests/passing/expectExtend-context.spec.lua b/tests/passing/expectExtend-context.spec.lua new file mode 100644 index 0000000..39605b8 --- /dev/null +++ b/tests/passing/expectExtend-context.spec.lua @@ -0,0 +1,64 @@ +-- luacheck: globals describe beforeAll expect it + +local noOptMatcher = function(_received, _expected) + return { + message = "", + pass = true, + } +end + +return function() + beforeAll(function() + expect.extend({ + scope_0 = noOptMatcher, + }) + end) + + it("SHOULD inherit from previous beforeAll", function() + assert(expect().scope_0, "should have scope_0") + end) + + describe("scope 1", function() + beforeAll(function() + expect.extend({ + scope_1 = noOptMatcher, + }) + end) + + it("SHOULD inherit from previous beforeAll", function() + assert(expect().scope_1, "should have scope_1") + end) + + it("SHOULD inherit from previous root level beforeAll", function() + assert(expect().scope_0, "should have scope_0") + end) + + it("SHOULD NOT inherit scope 2", function() + assert(expect().scope_2 == nil, "should not have scope_0") + end) + + describe("scope 2", function() + beforeAll(function() + expect.extend({ + scope_2 = noOptMatcher, + }) + end) + + it("SHOULD inherit from previous beforeAll in scope 2", function() + assert(expect().scope_2, "should have scope_2") + end) + + it("SHOULD inherit from previous beforeAll in scope 1", function() + assert(expect().scope_1, "should have scope_1") + end) + + it("SHOULD inherit from previous beforeAll in scope 0", function() + assert(expect().scope_0, "should have scope_0") + end) + end) + end) + + it("SHOULD NOT inherit from scope 1", function() + assert(expect().scope_1 == nil, "should not have scope_1") + end) +end diff --git a/tests/passing/expectExtend-illegalWrites.spec.lua b/tests/passing/expectExtend-illegalWrites.spec.lua new file mode 100644 index 0000000..04d4458 --- /dev/null +++ b/tests/passing/expectExtend-illegalWrites.spec.lua @@ -0,0 +1,54 @@ +-- luacheck: globals describe beforeAll expect it + +local noOptMatcher = function(_received, _expected) + return { + message = "", + pass = true, + } +end + +local ERROR_CANNOT_OVERWRITE = "Cannot overwrite matcher" + +return function() + describe("attempt to overwrite default", function() + beforeAll(function() + local success, message = pcall(function() + expect.extend({ + -- This should throw since `ok` is a default matcher + ok = noOptMatcher, + }) + end) + + assert(success == false, "should have thrown") + assert(message:match(ERROR_CANNOT_OVERWRITE), string.format("\nUnexpected error:\n%s", message)) + end) + end) + + describe("attempt to overwrite never", function() + beforeAll(function() + local success, message = pcall(function() + expect.extend({ + -- This should throw since `never` is a default matcher + never = noOptMatcher, + }) + end) + + assert(success == false, "should have thrown") + assert(message:match(ERROR_CANNOT_OVERWRITE), string.format("\nUnexpected error:\n%s", message)) + end) + end) + + describe("attempt to overwrite self", function() + beforeAll(function() + local success, message = pcall(function() + expect.extend({ + -- This should throw since `a` is a default matcher + a = noOptMatcher, + }) + end) + + assert(success == false, "should have thrown") + assert(message:match(ERROR_CANNOT_OVERWRITE), string.format("\nUnexpected error:\n%s", message)) + end) + end) +end diff --git a/tests/passing/expectExtend-matchers.spec.lua b/tests/passing/expectExtend-matchers.spec.lua new file mode 100644 index 0000000..f6fc9fb --- /dev/null +++ b/tests/passing/expectExtend-matchers.spec.lua @@ -0,0 +1,80 @@ +-- luacheck: globals describe beforeAll expect it + +local customEqualMatcher = function(received, expected) + local pass = received == expected + if pass then + return { + message = "custom failure message (not)", + pass = true, + } + else + return { + message = "custom failure message", + pass = false, + } + end +end + +return function() + it("SHOULD have custom matcher when defined in `it` block", function() + expect.extend({ + customEqual = customEqualMatcher, + }) + + assert(expect().customEqual, "customEqual should exist") + end) + + describe("WHEN defining custom matcher in describe block", function() + beforeAll(function() + expect.extend({ + customEqual = customEqualMatcher, + }) + end) + + describe("WHEN NOT inverting the expression", function() + it("SHOULD pass as expected", function() + expect("hello").customEqual("hello") + end) + + it("SHOULD fail as expected", function() + local success = pcall(function() + expect("hello").customEqual("world") + end) + + assert(success == false, "did not fail like expected") + end) + end) + + describe("WHEN inverting the expression", function() + it("SHOULD pass as expected", function() + expect("hello").never.customEqual("world") + end) + + it("SHOULD fail as expected", function() + local success = pcall(function() + expect("hello").never.customEqual("hello") + end) + + assert(success == false, "did not fail like expected") + end) + end) + + describe("WHEN chain within other matchers", function() + it("SHOULD work as expected when the first to execute", function() + expect("hello") + .to.customEqual("hello") + .to.equal("hello") + .to.be.ok() + .to.never.equal("foobar") + end) + + it("SHOULD work as expected when the last to execute", function() + expect("hello") + .to.equal("hello") + .to.be.ok() + .to.never.equal("foobar") + .to.customEqual("hello") + end) + end) + end) +end