Skip to content

Commit 793a7fc

Browse files
committed
Merge pull request #84 from testdouble/better-tostring
Stringify objects better
2 parents a02a00e + 1b23320 commit 793a7fc

14 files changed

+222
-54
lines changed

docs/8-custom-matchers.md

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@ it's easy to define custom argument matchers to meet your specific needs as well
77
There's nothing magical about matchers. Any object passed into a `when()` or
88
`verify()` invocation that has a `__matches` function on it and returns truthy
99
when it matches and falsey when it doesn't is considered a matcher by
10-
testdouble.js. Anything without a `__matches` function will only match an
10+
testdouble.js. That said, we provide a `td.matchers.create()` helper for creating
11+
your own custom matchers in a way that'll help ensure your users will get better
12+
messages from `td.explain` calls and `td.verify` failures.
13+
14+
(For the record, arguments without a `__matches` property will only match an
1115
actual invocation if it passes lodash's deep
12-
[_.isEqual](https://lodash.com/docs#isEqual) test.
16+
[_.isEqual](https://lodash.com/docs#isEqual) test.)
1317

1418
The examples in this document assume you've aliased `testdouble` to `td`.
1519

@@ -20,13 +24,13 @@ the expected type of an argument matches the type of the argument actually passe
2024
to the test double function from the subject under test.
2125

2226
``` javascript
23-
isA = function(expected) {
24-
return {
25-
__matches: function(actual) {
26-
return actual instanceof expected;
27-
}
27+
isA = td.matchers.create({
28+
name: 'isA',
29+
matches: function(matcherArgs, actual) {
30+
var expected = matcherArgs[0]
31+
return actual instanceof expected
2832
}
29-
}
33+
})
3034
```
3135

3236
Once defined, the above function can be used in a test like this:
@@ -39,3 +43,24 @@ td.when(datePicker(isA(Date))).thenReturn('good')
3943
datePicker(new Date()) // 'good'
4044
datePicker(5) // undefined
4145
```
46+
47+
#### td.matchers.create API
48+
49+
The `create` function takes a configuration object with the following properties
50+
51+
* **matches(matcherArgs, actual)** - _required_ - a function that returns truthy
52+
when an `actual` argument satisfies the matcher's rules, given what the user
53+
passed to the matcher as `matcherArgs` when setting it up. For instance, if
54+
`td.when(func(isFooBar('foo','bar')).thenReturn('baz')` is configured, then
55+
`func('biz')` is invoked, then `isFooBar`'s `matches` function will be invoked
56+
with `matcherArgs` of `['foo','bar']` and `actual` of `'biz'`
57+
* **name** - _optional_ - a string name for better messages. A function can
58+
also be provided, which will be passed the user's `matcherArgs` and should return
59+
a string name
60+
* **onCreate(matcherInstance, matcherArgs)** - _optional_ - a function invoked
61+
whenever an instance of a matcher is created to give the matcher author an
62+
opportunity to mutate the matcher instance or have some other side effect. The
63+
`td.callback` functionality of the library depends on this option
64+
65+
For some examples of `td.matchers.create()` in action, check out the
66+
[built-in matchers](src/matchers/index.coffee) provided by testdouble.js.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@
4040
},
4141
"dependencies": {
4242
"lodash": "^3.10.1",
43-
"quibble": "^0.3.0"
43+
"quibble": "^0.3.0",
44+
"stringify-object-with-one-liners": "^1.0.0"
4445
},
4546
"devDependencies": {
4647
"browserify": "^11.0.1",

src/explain.coffee

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ _ = require('lodash')
33
store = require('./store')
44
callsStore = require('./store/calls')
55
stubbingsStore = require('./store/stubbings')
6-
stringifyArgs = require('./stringify-args')
6+
stringifyArgs = require('./stringify/arguments')
77

88
module.exports = (testDouble) ->
99
return nullDescription() unless store.for(testDouble, false)?

src/matchers/callback.coffee

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
_ = require('lodash')
2+
create = require('./create')
23

3-
module.exports = callback = (args...) ->
4-
args: args
5-
__testdouble_callback: true
6-
__matches: _.isFunction
4+
module.exports = callback = create
5+
name: 'callback'
6+
matches: (matcherArgs, actual) ->
7+
_.isFunction(actual)
8+
onCreate: (matcherInstance, matcherArgs) ->
9+
matcherInstance.args = matcherArgs
10+
matcherInstance.__testdouble_callback = true
711

12+
# Make callback itself quack like a matcher for its non-invoked use case.
13+
callback.__name = 'callback'
814
callback.__matches = _.isFunction
915

1016
callback.isCallback = (obj) ->

src/matchers/captor.coffee

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
create = require('./create')
2+
13
module.exports = ->
24
captor =
3-
capture: ->
4-
__matches: (actual) ->
5+
capture: create
6+
name: 'captor.capture'
7+
matches: (matcherArgs, actual) ->
58
captor.value = actual
69
return true

src/matchers/create.coffee

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
_ = require('lodash')
2+
stringifyArguments = require('../stringify/arguments')
3+
4+
module.exports = (config) ->
5+
(matcherArgs...) ->
6+
matcherInstance =
7+
__name: if _.isFunction(config.name)
8+
config.name(matcherArgs)
9+
else if config.name?
10+
"#{config.name}(#{stringifyArguments(matcherArgs)})"
11+
else
12+
"[Matcher for (#{stringifyArguments(matcherArgs)})]"
13+
__matches: (actualArg) ->
14+
config.matches(matcherArgs, actualArg)
15+
16+
config.onCreate?(matcherInstance, matcherArgs)
17+
18+
return matcherInstance

src/matchers/index.coffee

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
_ = require('lodash')
2-
captor = require('./captor')
2+
create = require('./create')
3+
stringifyArguments = require('../stringify/arguments')
34

45
module.exports =
5-
captor: captor
6+
create: create
7+
captor: require('./captor')
8+
9+
isA: create
10+
name: (matcherArgs) ->
11+
s = if matcherArgs[0]?.name?
12+
matcherArgs[0].name
13+
else
14+
stringifyArguments(matcherArgs)
15+
"isA(#{s})"
16+
matches: (matcherArgs, actual) ->
17+
type = matcherArgs[0]
618

7-
isA: (type) ->
8-
__matches: (actual) ->
919
if type == Number
1020
_.isNumber(actual)
1121
else if type == String
@@ -15,34 +25,40 @@ module.exports =
1525
else
1626
actual instanceof type
1727

18-
anything: ->
19-
__matches: -> true
28+
anything: create
29+
name: 'anything'
30+
matches: -> true
2031

21-
contains: (containings...) ->
22-
containsAllSpecified = (containing, actual) ->
23-
_.all containing, (val, key) ->
24-
return false unless actual?
25-
if _.isPlainObject(val)
26-
containsAllSpecified(val, actual[key])
27-
else
28-
_.eq(val, actual[key])
32+
contains: create
33+
name: 'contains'
34+
matches: (containings, actualArg) ->
35+
containsAllSpecified = (containing, actual) ->
36+
_.all containing, (val, key) ->
37+
return false unless actual?
38+
if _.isPlainObject(val)
39+
containsAllSpecified(val, actual[key])
40+
else
41+
_.eq(val, actual[key])
2942

30-
__matches: (actual) ->
3143
_.all containings, (containing) ->
3244
if _.isString(containing)
33-
_.include(actual, containing)
45+
_.include(actualArg, containing)
3446
else if _.isArray(containing)
35-
_.any actual, (actualElement) ->
47+
_.any actualArg, (actualElement) ->
3648
_.eq(actualElement, containing)
3749
else if _.isPlainObject(containing)
38-
containsAllSpecified(containing, actual)
50+
containsAllSpecified(containing, actualArg)
3951
else
4052
throw new Error("the contains() matcher only supports strings, arrays, and plain objects")
4153

42-
argThat: (predicate) ->
43-
__matches: (actual) ->
54+
argThat: create
55+
name: 'argThat'
56+
matches: (matcherArgs, actual) ->
57+
predicate = matcherArgs[0]
4458
predicate(actual)
4559

46-
not: (expected) ->
47-
__matches: (actual) ->
60+
not: create
61+
name: 'not'
62+
matches: (matcherArgs, actual) ->
63+
expected = matcherArgs[0]
4864
!_.eq(expected, actual)

src/stringify-args.coffee

Lines changed: 0 additions & 12 deletions
This file was deleted.

src/stringify/anything.coffee

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
_ = require('lodash')
2+
stringifyObject = require('stringify-object-with-one-liners')
3+
4+
module.exports = (anything) ->
5+
if _.isString(anything)
6+
if _.contains(anything, '\n')
7+
"\"\"\"\n#{anything}\n\"\"\""
8+
else
9+
"\"#{anything.replace(new RegExp('"', 'g'), '\\"')}\""
10+
else if anything?.__matches?
11+
anything.__name
12+
else
13+
stringifyObject anything,
14+
indent: ' '
15+
singleQuotes: false
16+
inlineCharacterLimit: 65

src/stringify/arguments.coffee

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
_ = require('lodash')
2+
stringifyAnything = require('./anything')
3+
4+
module.exports = (args, joiner = ", ", wrapper = "") ->
5+
_(args).map (arg) ->
6+
"#{wrapper}#{stringifyAnything(arg)}#{wrapper}"
7+
.join(joiner)

0 commit comments

Comments
 (0)