cl-hamcrest

2023-02-15

A set of helpers to make your unittests more readable by using Hamcrest assertions.

Upstream URL

github.com/40ants/cl-hamcrest

Author

Alexander Artemenko

License

New BSD License
README

Implementation of Hamcrest for Common Lisp

HAMCREST ASDF System Details

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.


[generated by 40ANTS-DOC]

Dependencies (8)

  • 40ants-asdf-system
  • alexandria
  • ci
  • cl-ppcre
  • iterate
  • prove
  • rove
  • split-sequence
  • GitHub
  • Quicklisp