How can I get Clojure :pre & :post to report their failing value?
Asked Answered
M

4

34
(defn string-to-string [s1] 
  {:pre  [(string? s1)]
   :post [(string? %)]}
  s1)

I like :pre and :post conditions, they allow me to figure out when I have put "square pegs in round holes" more quickly. Perhaps it is wrong, but I like using them as a sort of poor mans type checker. This isn't philosophy though, this is a simple question.

It seems in the above code that I should easily be able to determine that s1 is a function argument in the :pre condition. Similarily, % in the :post condition is always the function return value.

What I would like is to print the value of s1 or % when either of these respective conditions fail within the AssertionError. So I get something like

(string-to-string 23)

AssertionError Assert failed: (string? s1) 
(pr-str s1) => 23 

With the AssertionError containing a single line for every variable that was identified as being from the function argument list and that was referenced in the failing test. I would also like something similar when the return value of the function fails the :post condition.

This would make it trivial to quickly spot how I misused a function when trying to diagnose from the AssertionError. It would at least let me know if the value is nil or an actual value (which is the most common error I make).

I have some ideas that this could be done with a macro, but I was wondering if there was any safe and global way to basically just redefine what (defn and (fn and friends do so that :pre and :post would also print the value(s) that lead to the test failing.

Mcquade answered 18/7, 2014 at 21:33 Comment(3)
I'm probably missing something about your question, but is try .. catch allowed in :pre or :post? Couldn't you report a problem that way?Aryanize
I assume any valid form is fine in :pre and :post (don't know for sure though). I would point out that the forms in :pre and :post seem to be inserted into an assert. So, you could (try (catch...)), but then that end up being a (assert (try (catch...))); which is ok (although kind of noisy & also could only print before the actual assertion). My goal though is to actually modify the underlying AssertError itself though, and just add in the relevant symbols and (pr-str values) as a message in the AssertionError.Mcquade
A clojure spec example : clojure.org/guides/spec#_using_spec_for_validationSamiel
T
30

You could wrap your predicate with the is macro from clojure.test

(defn string-to-string [s1] 
  {:pre  [(is (string? s1))]
   :post [(is (string? %))]}
 s1)

Then you get:

(string-to-string 10)
;FAIL in clojure.lang.PersistentList$EmptyList@1 (scratch.clj:5)
;expected: (string? s1)
;actual: (not (string? 10))
Thurston answered 19/7, 2014 at 3:57 Comment(3)
Pretty good, I like that. It is a reasonable compromise that gets me 90% there. Still, I think it would be nice if in Clojure 1.7+, they just made it automatically print out the value of all immediate variables (recognizing that refs, lazy sequences, and probalby other things I am not thinking about might cause problems).Mcquade
As is this is a bit clumsy both to code and to look at in the output, I think this could be made simpler by clojure's core itself. Maybe clojure.spec has something neater than :pre to offer.Lucilucia
I am simply getting Assert failed: (is (not (neg? %))). What did I miss?Smattering
H
14

@octopusgrabbus kind of hinted at this by proposing (try ... (catch ...)), and you mentioned that that might be too noisy, and is still wrapped in an assert. A simpler and less noisy variant of this would be a simple (or (condition-here) (throw-exception-with-custom-message)) syntax, like this:

(defn string-to-string [s1] 
  {:pre  [(or (string? s1)
              (throw (Exception. (format "Pre-condition failed; %s is not a string." s1))))]
   :post [(or (string? %)
              (throw (Exception. (format "Post-condition failed; %s is not a string." %))))]}
  s1)

This essentially lets you use pre- and post-conditions with custom error messages -- the pre- and post-conditions are still checked like they normally would be, but your custom exception is evaluated (and thus thrown) before the AssertionError can happen.

Hiles answered 21/7, 2014 at 21:18 Comment(0)
S
4

Something like below where clojure spec is explaining the problem? This will throw an assertion error which you can catch.

 (defn string-to-string [s1] 
  {:pre [ (or (s/valid?  ::ur-spec-or-predicate s1) 
              (s/explain ::ur-spec-or-predicate s1)]}
  s1)
Sims answered 5/1, 2018 at 12:48 Comment(2)
Wouldn’t that be always true?Veii
@Veii No, this works fine as s/explain seems to always return nil (at least in Clojure 1.10.1). But you are right, this answer relies on implementation details which could change any time. I'd recommend using an if construct instead, although this can get a bit unwieldy.Bootie
L
4

Clojure spec can be used to assert on arguments, yielding an exception on invalid input with data explaining why the failure occurred (assertion checking has to be turned on):

(require '[clojure.spec.alpha :as s])

;; "By default assertion checking is off - this can be changed at the REPL
;;  with s/check-asserts or on startup by setting the system property
;;  clojure.spec.check-asserts=true"
;;
;; quoted from https://clojure.org/guides/spec#_using_spec_for_validation
(s/check-asserts true)

(defn string-to-string [s1] 
  {:pre  [(s/assert string? s1)]
   :post [(s/assert string? %)]}
  s1)

(string-to-string nil) => #error{:cause "Spec assertion failed\nnil - failed: string?\n",
                                 :data #:clojure.spec.alpha{:problems [{:path [], :pred clojure.core/string?, :val nil, :via [], :in []}],
                                                            :spec #object[clojure.core$string_QMARK___5395 0x677b8e13 "clojure.core$string_QMARK___5395@677b8e13"],
                                                            :value nil,
                                                            :failure :assertion-failed}}

The [:data :value] key in the exception shows you the failing value. The [:data :problems] key shows you why spec thinks the value is invalid. (In this example the problem is straightfoward, but this explanation gets very useful when you have nested maps and multiple specs composed together.)

One important caveat is that s/assert when given valid input returns that input, yet the :pre and :post conditions check for truthiness. If the validation conditions you need consider falsy values to be valid, then you need to adjust your validation expression, otherwise s/assert will succeed, but the truthiness check in :pre or :post will fail.

(defn string-or-nil-to-string [s1]
  {:pre [(s/assert (s/or :string string? :nil nil?) s1)]
   :post [(s/assert string? %)]}
  (str s1))

(string-or-nil-to-string nil) => AssertionError

Here's what I use to avoid that problem:

(defn string-or-nil-to-string [s1]
  {:pre [(do (s/assert (s/or :string string? :nil nil?) s1) true)]
   :post [(s/assert string? %)]}
  (str s1))

(string-or-nil-to-string nil) => ""

Edit: enable assertion checking.

Lloyd answered 27/4, 2020 at 23:5 Comment(1)
You can also set the check-asserts in the deps.edn like this: :aliases {:dev {:jvm-opts ["-Dclojure.spec.check-asserts=true"]}}Mayst

© 2022 - 2024 — McMap. All rights reserved.