Mocks
A mock is a global function replaced for a dynamic extent. with-mocks swaps
fdefinitions for its body, records every call, and restores them however the
body exits. The syntax is flet's; verification is is with three functions,
so a failed verification reads like every other failure.
Mocks live in mock.lisp. Because with-mocks replaces global definitions, a
test that uses it must be :serial t (see Parallel).
(with-mocks (binding...) body...)
binding ::= name ; records calls, accepts anything, returns NIL
| (name lambda-list stub-body...) ; a stub; CALL-ORIGINAL is bound inside
A first mock
(deftest (welcome-email :serial t)
(with-mocks ((fetch-user (id) (make-user :name "Kim" :email "kim@example.com"))
send-email)
(notify-user 42)
(is (called-p 'fetch-user 42))
(is (called-p 'send-email "kim@example.com" (any 'string)))
(is (= 1 (call-count 'send-email)))))
fetch-user is a stub that returns a value; send-email is a bare name that
records its calls and returns NIL.
Verification
| Call | Meaning |
|---|---|
(calls &optional name) |
with name, its argument lists oldest first; without, (name . args) for every mock, in call order |
(call-count name) |
how many times name was called |
(called-p name pattern...) |
some recorded call matched pattern; with no pattern, called at all |
(call-original args...) |
inside a stub body, the function the mock replaced |
(any &optional type) |
a matcher: any argument of type |
(satisfying predicate) |
a matcher: any argument the predicate accepts |
A pattern element matches an argument when it is a matcher that accepts it, or
when the two are equal; the argument count must match.
(is (called-p 'fetch-user 42)) ; exact
(is (called-p 'fetch-user (any 'integer))) ; any integer
(is (called-p 'fetch-user (satisfying #'evenp)))
(is (equal '(connect query disconnect) (mapcar #'first (calls)))) ; call order
calls, call-count and called-p signal an error for a name that is not
currently mocked, so a typo is loud. After the body exits, the mocks and their
records are gone.
Patterns
;; consecutive answers from the call count
(deftest (retries-twice-then-succeeds :serial t)
(with-mocks ((fetch-url (url)
(if (< (call-count 'fetch-url) 3)
(error "timed out: ~a" url)
"<html>")))
(is (string= "<html>" (fetch-with-retries "http://x")))
(is (= 3 (call-count 'fetch-url)))))
;; a spy: record calls but run the real function
(deftest (cache-computes-once :serial t)
(with-mocks ((hash (&rest args) (apply #'call-original args)))
(cached-hash "a")
(cached-hash "a")
(is (= 1 (call-count 'hash)))))
Nested with-mocks share the outermost call log; an inner mock of a name
already mocked shadows the outer one for its extent.
Generic functions and setf
A generic function is replaced wholesale for the extent, and call-original
dispatches normally. A (setf foo) function can be mocked too:
(with-mocks ((area (shape) (* 2 (call-original shape)))) ; wrap the generic
...)
(with-mocks ((setf configuration)) ; a setf function
(setf (configuration) new)
(is (called-p '(setf configuration) new)))
A mock object for a CLOS protocol is a subclass with defmethods —
(defclass fake-db (db) ()) — plus with-mocks on the generic function when
calls should be recorded.
What can and cannot be mocked
- Mocking a name with no global function is an error.
- Mocking a name in a locked package (e.g.
cl:car) is an error, with the advice to call it through a function of your own and mock that. - A warning, not an error, if the name has a compiler macro or is declaimed
inline: some callers may bypass the mock.
From Mockito
| Mockito | assay |
|---|---|
thenReturn / thenThrow |
the stub body |
| consecutive answers | a closure over call-count |
InOrder, verifyNoMoreInteractions |
(calls) compared with equal |
ArgumentCaptor |
(calls 'name) |
spy |
a stub that applies call-original |
| a mock object | a subclass with defmethods |
Next: Output Format.