Skip to content
This repository was archived by the owner on Sep 14, 2024. It is now read-only.

Commit f792e5b

Browse files
authored
Merge pull request #142 from benbrimeyer/bbrimeyer/feature/extend-expectation
Add expect.extend
2 parents df00128 + 1ee20ce commit f792e5b

11 files changed

+359
-11
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
# TestEZ Changelog
22

33
## Unreleased Changes
4+
* Added `expect.extend` which allows projects to register their own, opinionated expectations that integrates into `expect`. ([#142](https://github.com/Roblox/testez/pull/142))
5+
* Modeled after [jest's implementation](https://jestjs.io/docs/en/expect#expectextendmatchers).
6+
* Matchers are functions that should return an object with with two keys, boolean `pass` and a string `message`
7+
* Like `context`, matchers introduced via `expect.extend` will be present on all nodes below the node that introduces the matchers.
8+
* Limitations:
9+
* `expect.extend` cannot be called from within `describe` blocks
10+
* Custom matcher names cannot overwrite pre-existing matchers, including default matchers and matchers introduces from previous `expect.extend` calls.
411

512
## 0.3.3 (2020-09-25)
613
* Remove the lifecycle hooks from the session tree. This prevents the `[?]` spam from the reporter not recognizing these nodes.

src/Expectation.lua

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ function Expectation.new(value)
7676
local self = {
7777
value = value,
7878
successCondition = true,
79-
condition = false
79+
condition = false,
80+
matchers = {},
81+
_boundMatchers = {},
8082
}
8183

8284
setmetatable(self, Expectation)
@@ -91,6 +93,31 @@ function Expectation.new(value)
9193
return self
9294
end
9395

96+
function Expectation.checkMatcherNameCollisions(name)
97+
if SELF_KEYS[name] or NEGATION_KEYS[name] or Expectation[name] then
98+
return false
99+
end
100+
101+
return true
102+
end
103+
104+
function Expectation:extend(matchers)
105+
self.matchers = matchers or {}
106+
107+
for name, implementation in pairs(self.matchers) do
108+
self._boundMatchers[name] = bindSelf(self, function(_self, ...)
109+
local result = implementation(self.value, ...)
110+
local pass = result.pass == self.successCondition
111+
112+
assertLevel(pass, result.message, 3)
113+
self:_resetModifiers()
114+
return self
115+
end)
116+
end
117+
118+
return self
119+
end
120+
94121
function Expectation.__index(self, key)
95122
-- Keys that don't do anything except improve readability
96123
if SELF_KEYS[key] then
@@ -99,12 +126,16 @@ function Expectation.__index(self, key)
99126

100127
-- Invert your assertion
101128
if NEGATION_KEYS[key] then
102-
local newExpectation = Expectation.new(self.value)
129+
local newExpectation = Expectation.new(self.value):extend(self.matchers)
103130
newExpectation.successCondition = not self.successCondition
104131

105132
return newExpectation
106133
end
107134

135+
if self._boundMatchers[key] then
136+
return self._boundMatchers[key]
137+
end
138+
108139
-- Fall back to methods provided by Expectation
109140
return Expectation[key]
110141
end
@@ -151,6 +182,9 @@ function Expectation:a(typeName)
151182
return self
152183
end
153184

185+
-- Make alias public on class
186+
Expectation.an = Expectation.a
187+
154188
--[[
155189
Assert that our expectation value is truthy
156190
]]
@@ -274,4 +308,4 @@ function Expectation:throw(messageSubstring)
274308
return self
275309
end
276310

277-
return Expectation
311+
return Expectation

src/ExpectationContext.lua

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
local Expectation = require(script.Parent.Expectation)
2+
local checkMatcherNameCollisions = Expectation.checkMatcherNameCollisions
3+
4+
local function copy(t)
5+
local result = {}
6+
7+
for key, value in pairs(t) do
8+
result[key] = value
9+
end
10+
11+
return result
12+
end
13+
14+
local ExpectationContext = {}
15+
ExpectationContext.__index = ExpectationContext
16+
17+
function ExpectationContext.new(parent)
18+
local self = {
19+
_extensions = parent and copy(parent._extensions) or {},
20+
}
21+
22+
return setmetatable(self, ExpectationContext)
23+
end
24+
25+
function ExpectationContext:startExpectationChain(...)
26+
return Expectation.new(...):extend(self._extensions)
27+
end
28+
29+
function ExpectationContext:extend(config)
30+
for key, value in pairs(config) do
31+
assert(self._extensions[key] == nil, string.format("Cannot reassign %q in expect.extend", key))
32+
assert(checkMatcherNameCollisions(key), string.format("Cannot overwrite matcher %q; it already exists", key))
33+
34+
self._extensions[key] = value
35+
end
36+
end
37+
38+
return ExpectationContext

src/TestPlan.lua

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,15 @@ local function newEnvironment(currentNode, extraEnvironment)
105105
env.fdescribe = env.describeFOCUS
106106
env.xdescribe = env.describeSKIP
107107

108-
env.expect = Expectation.new
108+
env.expect = setmetatable({
109+
extend = function(...)
110+
error("Cannot call \"expect.extend\" from within a \"describe\" node.")
111+
end,
112+
}, {
113+
__call = function(_self, ...)
114+
return Expectation.new(...)
115+
end,
116+
})
109117

110118
return env
111119
end

src/TestRunner.lua

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
state is contained inside a TestSession object.
77
]]
88

9-
local Expectation = require(script.Parent.Expectation)
109
local TestEnum = require(script.Parent.TestEnum)
1110
local TestSession = require(script.Parent.TestSession)
1211
local LifecycleHooks = require(script.Parent.LifecycleHooks)
@@ -17,8 +16,16 @@ local TestRunner = {
1716
environment = {}
1817
}
1918

20-
function TestRunner.environment.expect(...)
21-
return Expectation.new(...)
19+
local function wrapExpectContextWithPublicApi(expectationContext)
20+
return setmetatable({
21+
extend = function(...)
22+
expectationContext:extend(...)
23+
end,
24+
}, {
25+
__call = function(_self, ...)
26+
return expectationContext:startExpectationChain(...)
27+
end,
28+
})
2229
end
2330

2431
--[[
@@ -70,6 +77,8 @@ function TestRunner.runPlanNode(session, planNode, lifecycleHooks)
7077
errorMessage = messagePrefix .. message .. "\n" .. debug.traceback()
7178
end
7279

80+
testEnvironment.expect = wrapExpectContextWithPublicApi(session:getExpectationContext())
81+
7382
local context = session:getContext()
7483

7584
local nodeSuccess, nodeResult = xpcall(
@@ -174,4 +183,4 @@ function TestRunner.runPlanNode(session, planNode, lifecycleHooks)
174183
lifecycleHooks:popHooks()
175184
end
176185

177-
return TestRunner
186+
return TestRunner

src/TestSession.lua

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
local TestEnum = require(script.Parent.TestEnum)
1111
local TestResults = require(script.Parent.TestResults)
1212
local Context = require(script.Parent.Context)
13+
local ExpectationContext = require(script.Parent.ExpectationContext)
1314

1415
local TestSession = {}
1516

@@ -25,6 +26,7 @@ function TestSession.new(plan)
2526
results = TestResults.new(plan),
2627
nodeStack = {},
2728
contextStack = {},
29+
expectationContextStack = {},
2830
hasFocusNodes = false
2931
}
3032

@@ -98,12 +100,16 @@ end
98100
function TestSession:pushNode(planNode)
99101
local node = TestResults.createNode(planNode)
100102
local lastNode = self.nodeStack[#self.nodeStack] or self.results
101-
local lastContext = self.contextStack[#self.contextStack]
102-
local context = Context.new(lastContext)
103-
104103
table.insert(lastNode.children, node)
105104
table.insert(self.nodeStack, node)
105+
106+
local lastContext = self.contextStack[#self.contextStack]
107+
local context = Context.new(lastContext)
106108
table.insert(self.contextStack, context)
109+
110+
local lastExpectationContext = self.expectationContextStack[#self.expectationContextStack]
111+
local expectationContext = ExpectationContext.new(lastExpectationContext)
112+
table.insert(self.expectationContextStack, expectationContext)
107113
end
108114

109115
--[[
@@ -113,6 +119,7 @@ function TestSession:popNode()
113119
assert(#self.nodeStack > 0, "Tried to pop from an empty node stack!")
114120
table.remove(self.nodeStack, #self.nodeStack)
115121
table.remove(self.contextStack, #self.contextStack)
122+
table.remove(self.expectationContextStack, #self.expectationContextStack)
116123
end
117124

118125
--[[
@@ -123,6 +130,12 @@ function TestSession:getContext()
123130
return self.contextStack[#self.contextStack]
124131
end
125132

133+
134+
function TestSession:getExpectationContext()
135+
assert(#self.expectationContextStack > 0, "Tried to get expectationContext from an empty stack!")
136+
return self.expectationContextStack[#self.expectationContextStack]
137+
end
138+
126139
--[[
127140
Tells whether the current test we're in should be skipped.
128141
]]
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
-- luacheck: globals describe beforeAll expect
2+
3+
local noOptMatcher = function(_received, _expected)
4+
return {
5+
message = "",
6+
pass = true,
7+
}
8+
end
9+
10+
return function()
11+
beforeAll(function()
12+
expect.extend({
13+
customMatcher = noOptMatcher,
14+
})
15+
end)
16+
17+
describe("redefine matcher", function()
18+
beforeAll(function()
19+
expect.extend({
20+
-- This should throw since we are redefining the same matcher
21+
customMatcher = noOptMatcher,
22+
})
23+
end)
24+
end)
25+
end
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- luacheck: globals describe expect
2+
3+
local noOptMatcher = function(_received, _expected)
4+
return {
5+
message = "",
6+
pass = true,
7+
}
8+
end
9+
10+
return function()
11+
describe("SHOULD NOT work in a describe block", function()
12+
expect.extend({
13+
test = noOptMatcher,
14+
})
15+
end)
16+
end
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
-- luacheck: globals describe beforeAll expect it
2+
3+
local noOptMatcher = function(_received, _expected)
4+
return {
5+
message = "",
6+
pass = true,
7+
}
8+
end
9+
10+
return function()
11+
beforeAll(function()
12+
expect.extend({
13+
scope_0 = noOptMatcher,
14+
})
15+
end)
16+
17+
it("SHOULD inherit from previous beforeAll", function()
18+
assert(expect().scope_0, "should have scope_0")
19+
end)
20+
21+
describe("scope 1", function()
22+
beforeAll(function()
23+
expect.extend({
24+
scope_1 = noOptMatcher,
25+
})
26+
end)
27+
28+
it("SHOULD inherit from previous beforeAll", function()
29+
assert(expect().scope_1, "should have scope_1")
30+
end)
31+
32+
it("SHOULD inherit from previous root level beforeAll", function()
33+
assert(expect().scope_0, "should have scope_0")
34+
end)
35+
36+
it("SHOULD NOT inherit scope 2", function()
37+
assert(expect().scope_2 == nil, "should not have scope_0")
38+
end)
39+
40+
describe("scope 2", function()
41+
beforeAll(function()
42+
expect.extend({
43+
scope_2 = noOptMatcher,
44+
})
45+
end)
46+
47+
it("SHOULD inherit from previous beforeAll in scope 2", function()
48+
assert(expect().scope_2, "should have scope_2")
49+
end)
50+
51+
it("SHOULD inherit from previous beforeAll in scope 1", function()
52+
assert(expect().scope_1, "should have scope_1")
53+
end)
54+
55+
it("SHOULD inherit from previous beforeAll in scope 0", function()
56+
assert(expect().scope_0, "should have scope_0")
57+
end)
58+
end)
59+
end)
60+
61+
it("SHOULD NOT inherit from scope 1", function()
62+
assert(expect().scope_1 == nil, "should not have scope_1")
63+
end)
64+
end

0 commit comments

Comments
 (0)