Meaningful error message for Clojure.Spec validation in :pre
Asked Answered
M

4

24

I used the last days to dig deeper into clojure.spec in Clojure and ClojureScript.

Until now I find it most useful, to use specs as guards in :pre and :post in public functions that rely on data in a certain format.

(defn person-name [person]
  {:pre [(s/valid? ::person person)]
   :post [(s/valid? string? %)]}
  (str (::first-name person) " " (::last-name person)))

The issue with that approach is, that I get a java.lang.AssertionError: Assert failed: (s/valid? ::person person) without any information about what exactly did not met the specification.

Has anyone an idea how to get a better error message in :pre or :post guards?

I know about conform and explain*, but that does not help in those :pre or :post guards.

Mohican answered 17/6, 2016 at 15:43 Comment(0)
G
12

In newer alphas, there is now s/assert which can be used to assert that an input or return value matches a spec. If valid, the original value is returned. If invalid, an assertion error is thrown with the explain result. Assertions can be turned on or off and can even optionally be omitted from the compiled code entirely to have 0 production impact.

(s/def ::first-name string?)
(s/def ::last-name string?)
(s/def ::person (s/keys :req [::first-name ::last-name]))
(defn person-name [person]
  (s/assert ::person person)
  (s/assert string? (str (::first-name person) " " (::last-name person))))

(s/check-asserts true)

(person-name 10)
=> CompilerException clojure.lang.ExceptionInfo: Spec assertion failed
val: 10 fails predicate: map?
:clojure.spec/failure  :assertion-failed
 #:clojure.spec{:problems [{:path [], :pred map?, :val 10, :via [], :in []}], :failure :assertion-failed}
Glossary answered 22/7, 2016 at 5:24 Comment(7)
It would have been nice to be able to use s/assert or s/explain* in the pre: and :post "hooks" too...Bootee
You can use them in pre and post?Glossary
Nothing happens when I have this (failing) s/assert in (defn f [x] {:pre [(s/assert string? x)]} (println x)) But s/explain do capture it so that is fine for me. ( 1.9.0-alpha13 )Bootee
Do you have assertions enabled? You'll need to either call clojure.github.io/clojure/branch-master/… or to set the clojure.spec.check-asserts system property to enable them.Glossary
You are right. (s/assert) works with assertions enabled. My problem originated from me mixing up (s/assert string? x) with (assert string? x) (from clojure.core). The latter needs to be written (assert (string? x)) to kick in.Bootee
@AlexMiller the link describing check-asserts now 404s, but it is described here: clojure.org/guides/spec#_using_spec_for_validationOecd
Updated link: clojure.github.io/spec.alpha/…Glossary
I
6

I think the idea is that you use spec/instrument to validate function input and output rather than pre and post conditions.

There's a good example toward the bottom of this blog post: http://gigasquidsoftware.com/blog/2016/05/29/one-fish-spec-fish/ . Quick summary: you can define a spec for a function, including both input and return values using the :args and :ret keys (thus replacing both pre and post conditions), with spec/fdef, instrument it, and you get output similar to using explain when it fails to meet spec.

Minimal example derived from that link:

(spec/fdef your-func
    :args even?
    :ret  string?)


(spec/instrument #'your-func)

And that's equivalent to putting a precondition that the function has an integer argument and a postcondition that it returns a string. Except you get much more useful errors, just like you're looking for.

More details in the official guide: https://clojure.org/guides/spec ---see under the heading "Spec'ing functions".

Ionia answered 18/6, 2016 at 3:2 Comment(4)
Sounds reasonable. Will try that.Mohican
This will not check return values though, so instrument only replaces :pre. The idea is you use test.check to test that your functions map input to output correctly. Then you instrument and test your functions in integration through normal tests. Finally, you use s/assert wherever you want production guards, such as mentioned in Alex Miller's answer.Burford
:ret values can also be instrumented using orchestra. github.com/jeaye/orchestraLambda
I strongly recommend using orchestra as @Lambda said. If you run a repl/refesh as part of a reloaded workflow, don't forget to re-instrument all your fns every time. Pair orchestra with expound for fast, pretty failures.Grosz
T
2

Without taking into account if you should use pre and post conditions to validate function arguments, there is a way to print somewhat clearer messages from pre and post conditions by wrapping your predicate with clojure.test/is, as suggested in the answer below:

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

So then your code could look like this:

(ns pre-post-messages.core
  (:require [clojure.spec :as s]
            [clojure.test :as t]))

(defn person-name [person]
  {:pre [(t/is (s/valid? ::person person))]
   :post [(t/is (s/valid? string? %))]}
  (str (::first-name person) " " (::last-name person)))

(def try-1
  {:first-name "Anna Vissi"})

(def try-2
  {::first-name "Anna"
   ::last-name "Vissi"
   ::email "[email protected]"})

(s/def ::person (s/keys :req [::first-name ::last-name ::email]))

Evaluating

pre-post-messages.core> (person-name  try-2)

would produce

"Anna Vissi"

and evaluating

pre-post-messages.core> (person-name  try-1)

would produce

FAIL in () (core.clj:6)

expected: (s/valid? :pre-post-messages.core/person person)

  actual: (not (s/valid? :pre-post-messages.core/person {:first-name "Anna Vissi"}))

AssertionError Assert failed: (t/is (s/valid? :pre-post-messages.core/person person))  pre-post-messages.core/person-name (core.clj:5)
Tamikatamiko answered 18/6, 2016 at 18:41 Comment(2)
I'm not to sure about requiring test clojure.test namespace in production code. But this seems to be a viable option, to get meaningful message from :pre guards.Mohican
@Mohican The problem with clojure.test/is expressions in production code seems to be a bit special: Actually running the tests (via lein test) messes up the counters. Not only are the is expressions in the deftest expressions counted toward the test pass/fail totals, but any is called by production code is counted too. Otherwise, one could consider clojure.test/is as just another function returning boolean, generating output written via the overridable clojure.test/report. After all, one can use e.g. Hamcrest matchers in production Java code too, not only in JUnit classes.Garbanzo
F
2

This is useful when you don't want to use s/assert, or can not enable s/check-assserts. Improving on MicSokoli's answer:

:pre simply cares that the values returned are all truthy, so we can convert the return value "Success!\n" to true (for strictness) and throw an error with the explanation and the input data in case the output is not successful.

(defn validate [spec input]
    (let [explanation (s/explain-str spec input)]
        (if (= explanation "Success!\n") 
            true
            (throw (ex-info explanation {:input input}))))

A variation of this could be this one, but it would run the spec twice:

(defn validate [spec input]
    (if (s/valid? spec input) 
        true
        (throw (ex-info (s/explain spec input) {:input input}))))

Usage:

(defn person-name [person]
  {:pre [(validate ::person person)]}
  (str (::first-name person) " " (::last-name person)))
Frulla answered 30/10, 2019 at 1:36 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.