Search code examples
unit-testingclojureclojure.specmidjeclojure-testing

Is it common for people to test their clojure.spec specs?


I'm learning Clojure, all by myself and I've been working on a simple toy project to create a Kakebo (japanese budgeting tool) for me to learn. First I will work on a CLI, then an API.

Since I'm just begining, I've been able to "grok" specs, which seems to be a great tool in clojure for validation. So, my questions are:

  1. People test their own written specs?
  2. I tested mine like the following code. Advice on get this better?

As I understand, there are ways to automatically test functions with generative testing, but for the bare bones specs, is this sort of test a good practice?

Specs file:

(ns kakebo.specs
  (:require [clojure.spec.alpha :as s]))


(s/def ::entry-type #{:income :expense})
(s/def ::expense-type #{:fixed :basic :leisure :culture :extras})
(s/def ::income-type #{:salary :investment :reimbursement})
(s/def ::category-type (s/or ::expense-type ::income-type))
(s/def ::money (s/and double? #(> % 0.0)))
(s/def ::date (java.util.Date.))
(s/def ::item string?)
(s/def ::vendor (s/nilable string?))
(s/def ::entry (s/keys :req [::entry-type ::date ::item ::category-type ::vendor ::money]))

Tests file:

(ns kakebo.specs-test
  (:require [midje.sweet :refer :all]
            [clojure.spec.alpha :as s]
            [kakebo.specs :refer :all]))

(facts "money"
       (fact "bigger than zero"
             (s/valid? :kakebo.specs/money 100.0) => true
             (s/valid? :kakebo.specs/money -10.0) => false)
       (fact "must be double"
             (s/valid? :kakebo.specs/money "foo") => false
             (s/valid? :kakebo.specs/money 1) => false))

(facts "entry types"
       (fact "valid types"
             (s/valid? :kakebo.specs/entry-type :income) => true
             (s/valid? :kakebo.specs/entry-type :expense) => true
             (s/valid? :kakebo.specs/entry-type :fixed) => false))

(facts "expense types"
       (fact "valid types"
             (s/valid? :kakebo.specs/expense-type :fixed) => true))

As a last last question, why can't I access the specs if I try the following import:

(ns specs-test
  (:require [kakebo.specs :as ks]))

(fact "my-fact" (s/valid? :ks/money 100.0) => true)

Solution

  • I personally would not write tests at that are tightly coupled to the code whether I'm using spec or not. That's almost a test for each line of code - which can be hard to maintain.

    There are a couple of what look to be mistakes in the specs:

    ;; this will not work, you probably meant to say the category type 
    ;; is the union of the expense and income types
    (s/def ::category-type (s/or ::expense-type ::income-type))
    
    ;; this will not work, you probably meant to check if that the value 
    ;; is an instance of the Date class
    (s/def ::date (java.util.Date.))
    

    You can really get a lot out of spec by composing the atomic specs you have there into higher level specs that do the heavy lifting in your application. I would test these higher level specs, but often they may be behind regular functions and the specs may not be exposed at all.

    For example, you've defined entry as a composition of other specs:

    (s/def ::entry (s/keys :req [::entry-type ::date ::item ::category-type ::vendor ::money]))
    

    This works for verifying all required data is present and for generating tests that use this data, but there are some transitive dependencies within the data such as :expense can not be of type :salary so we can add this to the entry spec:

    ;; decomplecting the entry types
    (def income-entry? #{:income})
    (def expense-entry? #{:expense})
    (s/def ::entry-type (clojure.set/union expense-entry? income-entry?))
    
    ;; decomplecting the category types
    (def expense-type? #{:fixed :basic :leisure :culture :extras})
    (def income-type? #{:salary :investment :reimbursement})
    (s/def ::category-type (clojure.set/union expense-type? income-type?))
    
    (s/def ::money (s/and double? #(> % 0.0)))
    (s/def ::date (partial instance? java.util.Date))
    (s/def ::item string?)
    (s/def ::vendor (s/nilable string?))
    
    (s/def ::expense
      (s/cat ::entry-type expense-entry?
             ::category-type expense-type?))
    
    (s/def ::income
      (s/cat ::entry-type income-entry?
             ::category-type income-type?))
    
    (defn expense-or-income? [m]
      (let [data (map m [::entry-type ::category-type])]
        (or (s/valid? ::expense data)
            (s/valid? ::income data))))
    
    (s/def ::entry
      (s/and
       expense-or-income?
       (s/keys :req [::entry-type ::date ::item
                     ::category-type ::vendor ::money])))
    
    

    Depending on the app or even the context you may have different specs that describe the same data. Above I combined expense and income into entry which may be good for output to a report or spreadsheet but in another area of the app you may want to keep them completely separate for data validation purposes; which is really where I use spec the most - at the boundaries of the system such as user input, database calls, etc.

    Most of the tests I have for specs are in the area of validating data going into the application. The only time I test single specs is if they have business logic in them and not just data type information.