cl-hamcrest
2024-10-12
A set of helpers to make your unittests more readable by using Hamcrest assertions.
Implementation of Hamcrest for Common Lisp
HAMCREST ASDF System Details
- Description: A set of helpers to make your unittests more readable by using Hamcrest assertions.
- Licence: New
BSD
License - Author: Alexander Artemenko
- Bug tracker: https://github.com/40ants/cl-hamcrest/issues
- Source control: GIT
- Depends on: alexandria, cl-ppcre, iterate, split-sequence
Installation
You can install this library from Quicklisp, but you want to receive updates quickly, then install it from Ultralisp.org:
(ql-dist:install-dist "http://dist.ultralisp.org/"
:prompt nil)
(ql:quickload :hamcrest)
Introduction
This is an implementation of Hamcrest for Common Lisp.
It simplifes unittests and make them more readable. Hamcrest uses idea of pattern-matching, to construct matchers from different pieces and to apply them to the data.
Here is a simple example
(assert-that
log-item
(has-plist-entries :|@message| "Some"
:|@timestamp| _)
(hasnt-plist-keys :|@fields|))
Why not pattern-matching library?
You may ask: "Why dont use a pattern-matching library, like Optima?"
Here is another example from another library log4cl-json
, where I want
to check that some fields in plist have special values and other key is not
present. Here is the data:
(defvar log-item '(:|@message| "Some"
:|@timestamp| 122434342
;; this field is wrong and
;; shouldn't be here
:|@fields| nil))
With Optima I could write this code to match the data:
(ok (ematch
log-item
((and (guard (property :|@message| m)
(equal m "Some"))
(property :|@timestamp| _)
(not (property :|@fields| _)))
t))
"Log entry has message, timestamp, but not fields")
But error message will be quite cumbersome:
× Aborted due to an error in subtest "Simple match"
Raised an error Can't match ((:|@fields| NIL :|@timestamp|
"2017-01-03T16:42:00.991444Z" :|@message|
"Some")) with ((COMMON-LISP:AND
(GUARD
(PROPERTY :|@message| M)
(EQUAL M "Some"))
(PROPERTY :|@timestamp|
_)
(NOT
(PROPERTY :|@fields|
_)))). (expected: :NON-ERROR)
CL-HAMCREST is more concise and clear
With cl-hamcrest
test becomes more readable:
(assert-that
log-item
(has-plist-entries :|@message| "Some"
:|@timestamp| _)
(hasnt-plist-keys :|@fields|))
As well, as output about the failure:
× Key :|@fields| is present in object, but shouldn't
That is because cl-hamcrest
tracks the context and works
together with testing framework, to output all information
to let you understand where the problem is.
Why not just use Prove's assertions?
To draw a full picture, here is test, written in plain Prove's assertions:
(ok (member :|@message| log-item))
(is (getf log-item :|@message|)
"Some")
(ok (member :|@timestamp| log-item))
(ok (not (member :|@fields| log-item)))
And it's output:
✓ (:|@message| "Some") is expected to be T
✓ "Some" is expected to be "Some"
✓ (:|@timestamp| "2017-01-03T16:57:17.988810Z" :|@message| "Some") is expected to be T
× NIL is expected to be T
is not as clear, if you'll try to figure out
what does NIL is expected to be T
mean.
Description of all supported matchers, you can find in the
Matchers library
section.
Matchers library
Here you will find all matchers, supported by CL-HAMCREST
, grouped by
their purpose.
Object matchers
This kind of matchers checks some sort of properties on an object. In
this case objects are not only the CLOS
objects but also, hashmaps,
alists and property lists.
macro hamcrest/matchers:has-plist-entries
&rest entries
Matches plist entries:
TEST> (let ((obj '(:foo :bar)))
(assert-that obj
(has-plist-entries :foo "bar"
:blah "minor")))
× Key :FOO has :BAR value, but "bar" was expected
This way you can test any number of plist's entries.
macro hamcrest/matchers:hasnt-plist-keys
&rest keys
Checks if given keys are missing from an object:
TEST> (let ((obj '(:foo "bar")))
(assert-that obj
(hasnt-plist-keys :blah :minor)))
✓ Keys :BLAH, :MINOR are absent
Assertion fails if at least one key is present in the object:
TEST> (let ((obj '(:foo "bar")))
(assert-that obj
(hasnt-plist-keys :blah :foo)))
× Key :FOO is present in object, but shouldn't.
macro hamcrest/matchers:has-alist-entries
&rest entries
Matches alist entries:
TEST> (let ((obj '((:the-key . "value"))))
(assert-that obj
(has-alist-entries :the-key "value")))
✓ Has alist entries:
:THE-KEY = "value"
TEST> (let ((obj '((:the-key . "value"))))
(assert-that obj
(has-alist-entries :the-key "value"
:missing-key "value")))
× Key :MISSING-KEY is missing
TEST> (let ((obj '((:the-key . "value"))))
(assert-that obj
(has-alist-entries :the-key "other-value")))
× Key :THE-KEY has "value" value, but "other-value" was expected
macro hamcrest/matchers:has-hash-entries
&rest entries
Matches hash entries:
TEST> (let ((obj (make-hash-table)))
(setf (gethash 'the-key obj) "value")
(assert-that obj
(has-hash-entries 'the-key "value")))
✓ Has hash entries:
THE-KEY = "value"
TEST> (let ((obj (make-hash-table)))
(setf (gethash 'the-key obj) "value")
(assert-that obj
(has-hash-entries 'missing-key "value")))
× Key MISSING-KEY is missing
TEST> (let ((obj (make-hash-table)))
(setf (gethash 'the-key obj) "value")
(assert-that obj
(has-hash-entries 'the-key "other-value")))
× Key THE-KEY has "value" value, but "other-value" was expected
macro hamcrest/matchers:has-properties
&rest entries
Matches object properties:
TEST> (defvar the-object)
THE-OBJECT
TEST> (setf (getf the-object :tags) '(one two))
TEST> (assert-that 'the-object
(has-properties :tags '(one two)))
✓ Has properties:
:TAGS = (ONE TWO)
TEST> (assert-that 'the-object
(has-properties :tags 'wrong-value))
× Property :TAGS has (ONE TWO) value, but WRONG-VALUE was expected
TEST> (assert-that 'the-object
(has-properties :missing-property '(one two)))
× Property :MISSING-PROPERTY is missing
macro hamcrest/matchers:has-slots
&rest entries
Matches object slots:
TEST> (defstruct task
title
description)
TEST> (defvar task (make-task :title "The title "))
TEST> (assert-that task
(has-slots 'title "The title "))
✓ Has slots:
TITLE = "The title "
TEST> (assert-that task
(has-slots 'title "Wrong title "))
× Slot TITLE has "The title " value, but "Wrong title " was expected
TEST> (assert-that task
(has-slots 'description nil))
✓ Has slots:
DESCRIPTION = NIL
function hamcrest/matchers:has-type
expected-type
Checks if a list have specivied length.
TEST> (matcher-description (has-type 'cons))
"Has type CONS"
TEST> (funcall (has-type 'cons) 100500)
; Debugger entered on #<ASSERTION-ERROR 100500 has type (INTEGER 0 4611686018427387903), but CONS was expected>
Sequence matchers
function hamcrest/matchers:has-length
expected-length
Checks if a list have specivied length.
TEST> (assert-that 'nil (has-length 0))
✓ Has length of 0
TEST> (assert-that '(a b c d) (has-length 4))
✓ Has length of 4
TEST> (assert-that '(a b c d) (has-length 100500))
× List (A B C D) has length of 4, but 100500 was expected
macro hamcrest/matchers:contains
&rest entries
Checks if each item from a list matches to given matchers.
Contains can accept as raw values, as another matchers:
TEST> (assert-that '(:foo
(a b c)
d)
(contains :foo
(has-length 3)
'd))
✓ Contains all given values
Given list should have a length equal to count of matchers:
TEST> (assert-that '(:foo
(a b c)
d)
(contains :foo))
× Expected value is shorter than result
You can ignore value of some list items, by using (any)
matcher:
TEST> (assert-that '(:foo
(a b c)
d)
(contains :foo (any) (any)))
✓ Contains all given values
macro hamcrest/matchers:contains-in-any-order
&rest entries
Same as contains
, but items in the sequence can be in any order:
TEST> (assert-that '(:foo
(a b c)
d)
(contains-in-any-order
(has-length 3)
'd
:foo))
✓ Contains all given values
Boolean matchers
function hamcrest/matchers:any
Assertion is passed regardles of value of the object:
TEST> (assert-that 1 (any))
✓ Any value if good enough
TEST> (assert-that "the-string" (any))
✓ Any value if good enough
TEST> (assert-that 'the-symbol (any))
✓ Any value if good enough
TEST> (assert-that '(1 2 3) (any))
✓ Any value if good enough
function hamcrest/matchers:has-all
&rest matchers
Makes a matcher which groups another matchers with AND
logic.
This way we can check if plist has one key and hasn't another.
And if all matchers succeed, then has-all
succeed as well:
TEST> (assert-that '(:foo "bar")
(has-all (has-plist-entries :foo "bar")
(hasnt-plist-keys :blah)))
✓ All checks are passed
If at least one check is failed, then has-all
fails too:
TEST> (assert-that '(:foo "bar" :blah "minor")
(has-all (has-plist-entries :foo "bar")
(hasnt-plist-keys :blah)))
× Key :BLAH is present in object, but shouldn't
Utility functions
function hamcrest/matchers:_
Symbol _ should be used as is not as a function.
function hamcrest/matchers:matcher-description
fn
Returns description of a given matcher function.
Can be used to print nested matchers in a nicely indented, human readable way:
TEST> (matcher-description (has-length 100500))
"Has length of 100500 "
TEST> (matcher-description (contains
(has-plist-entries :foo "bar ")
(has-plist-entries :foo "minor ")))
"Contains all given values "
TEST> (matcher-description (has-plist-entries
:foo "bar "
:blah (has-hash-entries :minor "again ")))
"Has plist entries:
:FOO = "bar"
:BLAH = Has hash entries:
:MINOR = "again""
function hamcrest/matchers:matcher-form
fn
Returns description of a given matcher function.
Can be used to print nested matchers in a nicely indented, human readable way:
TEST> (matcher-description (has-length 100500))
"Has length of 100500 "
TEST> (matcher-description (contains
(has-plist-entries :foo "bar ")
(has-plist-entries :foo "minor ")))
"Contains all given values "
TEST> (matcher-description (has-plist-entries
:foo "bar "
:blah (has-hash-entries :minor "again ")))
"Has plist entries:
:FOO = "bar"
:BLAH = Has hash entries:
:MINOR = "again""
condition hamcrest/matchers:assertion-error
(error)
reader hamcrest/matchers:assertion-error-reason
(assertion-error) (:reason)
reader hamcrest/matchers:assertion-context
(assertion-error) (= '(copy-list *context*))
function hamcrest/matchers:assertion-error-reason-with-context
condition &key (indent-spaces 2)
Returns a multiline string where error reason is nested into the context like that:
Item with index 1:
Alist entry with key :NAME
Alist entry with key :FIRST
is required
Parameter :indent-spaces could be specified to control number of spaces for each indentation level.
Integration with Prove
CL-HAMCREST
has integration with Prove.
TEST> (ql:quickload :hamcrest-prove)
TEST> (use-package :hamcrest.prove)
TEST> (let ((obj (make-hash-table)))
(assert-that
obj
(has-hash-entries :foo :bar)))
× Key :FOO is missing
T
TEST> (let ((obj (make-hash-table)))
(setf (gethash :foo obj) :bar)
(assert-that
obj
(has-hash-entries :foo :bar)))
✓ Has hash entries:
:FOO = :BAR
T
TEST> (let ((obj (make-hash-table)))
(setf (gethash :foo obj) :bar)
(assert-that
obj
(has-hash-entries :foo :some-value)))
× Key :FOO has :BAR value, but :SOME-VALUE was expected
T
This is the simple case, but nested objects can be checked too.
All available matchers are described in the hamcrest-docs/matchers:@matchers section.
macro hamcrest/prove:assert-that
value &rest matchers
Main macro to test values agains matchers.
Roadmap
- Logical matchers:
any-of
– Matches if any of the given matchers evaluate to True.is-not
– Inverts the given matcher to its logical negation (think if
we need it, and how to show the results, here are results how it works in PyHamcrest – it just sees that matcher returned True and raises Assertion error with full object's content and matcher's description with prepended 'not' particle).
- Object matchers:
- Add
hasnt-some-keys
matchers, corresponding to
has-some-entries
.
- Make
has-alist-entries
work with keys other than keyword
right now it uses eql
to compare keys.
- Sequence matchers:
is-in
– Matches if evaluated object is present in a given sequence.- Other features:
- Use uniq CommonLisp feature to restart signaled conditions to collect
all problems with data when there are few problems with keys.