Liking cljdoc? Tell your friends :D

sinho

sinho provides a matcher called =* for writing tests with a high signal-to-noise ratio.

Works with Clojure, ClojureScript, Babashka, and ClojureDart.

Rationale

See this notebook about Signal-To-Noise Ratio of Software Tests.

Getting Started

sinho is available from Clojars. Add the following dependency to your deps.edn or project.clj:

Current Version

For Babashka, add to your bb.edn:

{:deps {me.lomin/sinho {:mvn/version "4.0.1"}}}

Examples

Direct usage

(ns me.lomin.sinho.example-test
  (:require [clojure.test :refer :all]
            [me.lomin.sinho.matcher :refer [=*]]))

(defn query-twitter-for [_user] ; fake implementation
  {:name           "Steven Collins"
   :twitter        "@lomin"
   :following      169
   :followers      122
   :person-of-note {{:name     "Borussia Dortmund"
                     :twitter  "@BlackYellow"
                     :folowing 146
                     :folowers 4579000}   :favorite
                    {:name      "FC Bayern English"
                     :twitter   "@FCBayernEN"
                     :following 49
                     :followers 11000000} :blocked}})

(deftest blocks-all-evil-test
         (is (=* {:twitter        "@lomin"
                  :person-of-note {{:twitter "@FCBayernEN"} :blocked}}
                 (query-twitter-for "@lomin"))))

(deftest failing-blocks-all-evil-test
         (is (=* {:twitter        "@lomin"
                  :person-of-note {{:instagram "@FCBayernEN"} :blocked}}
                 (query-twitter-for "@lomin"))))

As a nubank/matcher-combinator

(ns me.lomin.sinho.example-matcher-combinator-test
  (:require
   [clojure.test :as t :refer [deftest is]]
   [me.lomin.sinho.matcher-combinator :refer [=*]]))

(defn query-twitter-for [_user] ; fake implementation
  {:name "Steven Collins"
   :twitter "@lomin"
   :following 169
   :followers 122
   :person-of-note {{:name "Borussia Dortmund"
                     :twitter "@BlackYellow"
                     :folowing 146
                     :folowers 4579000} :favorite
                    {:name "FC Bayern English"
                     :twitter "@FCBayernEN"
                     :following 49
                     :followers 11000000} :blocked}})

(deftest blocks-all-evil-test-mc
  (is (match? (=* {:twitter "@lomin"
                   :person-of-note {{:twitter "@FCBayernEN"} :blocked}})
              (query-twitter-for "@lomin"))))

(deftest failing-blocks-all-evil-test-mc
    (is (match? (=* {:twitter "@lomin"
                     :person-of-note {{:instagram "@FCBayernEN"} :blocked}})
                (query-twitter-for "@lomin"))))

Public API

sinho's public API is five vars:

  • me.lomin.sinho.matcher/=*, the matcher;
  • me.lomin.sinho.matcher/to-diff2, which turns a diff into deep-diff2's records;
  • me.lomin.sinho.matcher-combinator/=*, the matcher for match?;
  • me.lomin.sinho.pred/pred and me.lomin.sinho.pred/named-pred, which wrap a predicate for an expected position.

Every other var and namespace is internal to sinho and may change in any release, even where it is public so that another sinho namespace can call it.

Platform Notes

The =* matcher API is identical across Clojure, ClojureScript, Babashka, and ClojureDart. There are a few differences to be aware of:

  • Babashka requires version 0.9.159 or later.
  • ClojureDart runs =*, pred and the diff on the Dart VM, with the same verdicts as the JVM and diffs that are = to the JVM's; a diff's collection types and the order of a large map can differ, as "How a diff is built" explains. (is (=* expected actual)) passes whatever the diff, because ClojureDart's is cannot be extended; write (is (= expected (=* expected actual))) instead. Records print as plain maps, so #matcher_combinators.model.Mismatch{:expected 2, :actual 3} prints as {:expected 2, :actual 3}. to-diff2 and the match? adapter me.lomin.sinho.matcher-combinator are absent, because neither deep-diff2 nor matcher-combinators' core loads on ClojureDart. bb test:cljd runs sinho's own tests there, with dart on the PATH.
  • Test output: On Clojure with Kaocha, =* failures render as deep-diff2 diffs. On ClojureScript and Babashka, you get standard clojure.test failure output; me.lomin.sinho.matcher/to-diff2 turns a diff into deep-diff2's records, but without Kaocha integration.

Predicate Matching with pred

New in 4.0. Use (pred f) to match values by predicate instead of equality:

(require '[me.lomin.sinho.pred :refer [pred named-pred]])

;; Type check in a map
(=* {:name (pred string?)
     :age  (pred #(< 0 % 200))}
    {:name "Alice" :age 30 :email "a@b.com"})
;; => match (returns expected)

;; Named predicate for readable failure messages
(=* (named-pred "positive?" pos?) -5)
;; => #Mismatch{:expected #Pred{:label "positive?"}, :actual -5}

Bare functions in expected positions are compared by value equality, not interpreted as predicates. This eliminates the ambiguity between "predicate-on-actual" and "literal function-value equality."

Migration from 2.x to 4.0

  1. Wrap inline predicates with pred. If you previously used bare functions in expected positions expecting predicate behavior, wrap them:

    ;; 2.x (ambiguous)
    (=* string? actual)
    
    ;; 4.0 (unambiguous)
    (=* (pred string?) actual)
    
  2. Diff shapes may differ on ambiguous sets. If your tests pinned specific diff shapes for sets of structurally-similar compound elements, the 4.0 greedy matching may produce a different (but still correct) diff. Update those assertions or switch to verdict-level assertions ((= expected (=* ...))).

  3. Stop using the specter navigator vars. sinho no longer depends on specter, and the vars whose values were specter navigators, such as me.lomin.sinho.diff/children-nav, are gone. The CHANGELOG lists them.

  4. Use only the public API. The vars outside "Public API" are gone or private, and diffs no longer carry :me.lomin.sinho.matcher/count-seq metadata. The CHANGELOG lists the vars.

  5. Performance is dramatically better. Map comparison is O(n) for atom keys (down from O(n!) worst case). Set comparison is O(n^3) for verdicts (down from factorial). Sequential comparison is O(m*n) (down from exponential).

How a diff is built

=* finds the differences between expected and actual as a sequence of paths, each a pair of a path into expected and a path into actual. It cannot apply them one after another: a change can invalidate the paths that follow it, as when a map key is replaced by its Missing record and a later path still names the old key. So =* sorts the paths, merges them into one tree in which paths share their common prefix, and applies the tree to expected in a single pass. Each leaf replaces the value it reaches with a Mismatch, Missing or Unexpected record built from the value its path reaches in actual.

An insertion gives the collection type that specter 1.1.4 gave. Into a vector on the JVM and Babashka, that is what subvec returns. On ClojureScript a PersistentVector becomes a Subvec, and a Subvec becomes a PersistentVector, through the rebuild sinho once added to specter's InsertBeforeIndex, so successive insertions alternate between the two. On ClojureDart, every insertion gives a PersistentVector. Into a seq, an insertion at index 0 conses onto a list, a queue, a Cons or nil on the JVM and Babashka, onto a list, a Cons or nil on ClojureDart, which has no queue, and onto any seq on ClojureScript; every other insertion concatenates and gives a LazySeq.

A diff is built from the caller's own values. A vector, map, set or record that the diff changes in place keeps its kind and metadata, except a sorted set or sorted map whose comparator cannot order a replaced element or key, which becomes a hash set or hash map with the same metadata. A seq whose element the diff changes becomes a LazySeq, and a collection an insertion rebuilds gets the type above; neither keeps metadata. The diff itself carries only :sinho/cost metadata. Replacing a key of a map of more than eight entries goes through assoc, which turns an array map into a hash map on every host but ClojureDart, which has no array map; the diff then lists that map's entries in another order. Verdicts never depend on this order.

Running the tests

Each dialect's suite runs through a Babashka task:

DialectCommandNeeds on the PATH
Clojurebb test:cljclojure
ClojureScriptbb test:nodeclojure, node
Babashkabb test:bbbb 0.9.159 or later
ClojureDartbb test:cljdclojure, dart

bb test runs the Clojure, ClojureScript and Babashka suites, and bb test:browser serves the ClojureScript tests at http://localhost:9158/. bb test:clj passes its arguments to Kaocha.

bb test:bb runs every *_test.cljc namespace under test/cljc. bb test:cljd runs the same namespaces, except those in bb.edn's cljd-unsupported-test-namespaces, whose dependencies do not load on ClojureDart; name namespaces after it to run only those. It deletes test/cljd-out before compiling, because dart test runs every compiled test file there, and output left from an earlier compile would run as well.

About

Sinho (신호) means signal in Korean.

Can you improve this documentation? These fine people already did:
Steven Collins & lomin
Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
←Move to previous article
→Move to next article
Ctrl+/Jump to the search field
× close