Introduction

A quick overview of Specmonstah's purpose and a short sweet working example.

Purpose

Specmonstah (Boston for "Specmonster") lets you write test fixtures that are clear, concise, and easy to maintain. It's great for dramatically reducing test boilerplate.

Say you want to test a scenario where a forum post has gotten three likes by three different users. You'd first have to create a hierarchy of records for the post, topic, topic category, and users. You have to make sure that all the foreign keys are correct (e.g. the post's :topic-id is set to the topic's :id) and that everything is inserted in the right order.

With Specmonstah, all you have to do is write code like this:

(insert {:like [[3]]})

and these records get inserted in a database (in the order displayed):

[[:user {:id 1 :username "T2TD3pAB79X5"}]
 [:user {:id 2 :username "ziJ9GnvNMOHcaUz"}]
 [:topic-category {:id 3 :created-by-id 2 :updated-by-id 2}]
 [:topic {:id 6
          :topic-category-id 3
          :title "4juV71q9Ih9eE1"
          :created-by-id 2
          :updated-by-id 2}]
 [:post {:id 10 :topic-id 6 :created-by-id 2 :updated-by-id 2}]
 [:like {:id 14 :post-id 10 :created-by-id 1}]
 [:like {:id 17 :post-id 10 :created-by-id 2}]
 [:user {:id 20 :username "b73Ts5BoO"}]
 [:like {:id 21 :post-id 10 :created-by-id 20}]]

Without Specmonstah, you'd have to write something like this to achieve the same result:

(let [user-1 (create-user! {:username "u1" :email "e1@example.co"})
      user-2 (create-user! {:username "u2" :email "e2@example.co"})
      user-3 (create-user! {:username "u3" :email "e3@example.co"})
      tc     (create-topic-category! {:created-by-id (:id user-1), :updated-by-id (:id user-1)})
      t      (create-topic! {:title             "topic"
                             :created-by-id     (:id user-1)
                             :updated-by-id     (:id user-1)
                             :topic-category-id (:id tc)})
      p      (create-post! {:topic-id (:id t), :created-by-id, (:id user-1), :updated-by-id (:id user-1)})]
  (create-like! {:user-id (:id user-1) :post-id (:id p)})
  (create-like! {:user-id (:id user-2) :post-id (:id p)})
  (create-like! {:user-id (:id user-3) :post-id (:id p)}))

Call me crazy, but I think (insert {:like [[3]]}) is better. The Specmonstah DSL communicates what's important about the scenario you're trying to test. It eliminates all the visual noise that results from having to type out the foreign key relationships. It's:

  • Clear - the intention of the code is not obscured by boilerplate

  • Concise - a compact DSL lets you elegantly express the data you need to work with in your test

  • Easy to maintain - Less code = less bugs. In the non-Specmonstah example you can imagine how easy it would be to introduce a typo or otherwise make a mistake that's hard to figure out

If you think writing code that's clear, concise, and easy to maintain is super cool 😎then read on! The next section will give you an example you can play with in the REPL. This guide also includes:

  • An infomercial that highlights more Specmonstah's cool features

  • A thorough tutorial that walks you through how to use Specmonstah

Short Sweet Example

We've got a big ol' tutorial to help you master Specmonstah, but if you're more the gimme fun now kind of person, then try out this little interactive example. First, clone Specmonstah:

git clone https://github.com/reifyhealth/specmonstah.git

Open examples/short-sweet/short_sweet.clj in your favorite editor and start a REPL. I've also included the code below in case for example you don't have access to a REPL because, say, you're in some kind of Taken situation and you only have access to a phone and you're using your precious battery life to go through this guide.

The first ~66 lines of code include all the setup necessary for the examples to run, followed by snippets to try out with example output. Definitely play with the snippets 😀 Can you generate multiple todos or todo lists?

(ns short-sweet
  (:require [reifyhealth.specmonstah.core :as sm]
            [reifyhealth.specmonstah.spec-gen :as sg]
            [clojure.spec.alpha :as s]
            [clojure.spec.gen.alpha :as gen]))

;;-------*****--------
;; Begin example setup
;;-------*****--------

;; ---
;; Define specs for our domain entities

;; The ::id should be a positive int, and to generate it we increment
;; the number stored in `id-seq`. This ensures unique ids and produces
;; values that are easier for humans to understand
(def id-seq (atom 0))
(s/def ::id (s/with-gen pos-int? #(gen/fmap (fn [_] (swap! id-seq inc)) (gen/return nil))))
(s/def ::not-empty-string (s/and string? not-empty #(< (count %) 10)))

(s/def ::username ::not-empty-string)
(s/def ::user (s/keys :req-un [::id ::username]))

(s/def ::created-by-id ::id)
(s/def ::content ::not-empty-string)
(s/def ::post (s/keys :req-un [::id ::created-by-id ::content]))

(s/def ::post-id ::id)
(s/def ::like (s/keys :req-un [::id ::post-id ::created-by-id]))

;; ---
;; The schema defines specmonstah `ent-types`, which roughly
;; correspond to db tables. It also defines the `:spec` for generting
;; ents of that type, and defines ent `relations` that specify how
;; ents reference each other
(def schema
  {:user {:prefix :u
          :spec   ::user}
   :post {:prefix    :p
          :spec      ::post
          :relations {:created-by-id [:user :id]}}
   :like {:prefix      :l
          :spec        ::like
          :relations   {:post-id       [:post :id]
                        :created-by-id [:user :id]}
          :constraints {:created-by-id #{:uniq}}}})

;; Our "db" is a vector of inserted records we can use to show that
;; entities are inserted in the correct order
(def mock-db (atom []))

(defn insert*
  "Simulates inserting records in a db by conjing values onto an
  atom. ent-tye is `:user`, `:post`, or `:like`, corresponding to the
  keys in the schema. `spec-gen` is the map generated by clojure.spec"
  [{:keys [data] :as db} {:keys [ent-type spec-gen]}]
  (swap! mock-db conj [ent-type spec-gen]))

(defn insert [query]
  (reset! id-seq 0)
  (reset! mock-db [])
  (-> (sg/ent-db-spec-gen {:schema schema} query)
      (sm/visit-ents-once :inserted-data insert*))
  ;; normally you'd return the expression above, but return nil for
  ;; the example to not produce overwhelming output
  nil)

;;-------*****--------
;; Begin snippets to try in REPL
;;-------*****--------

;; Return a map of user entities and their spec-generated data
(-> (sg/ent-db-spec-gen {:schema schema} {:user [[3]]})
    (sm/attr-map :spec-gen))

;; You can specify a username and id
(-> (sg/ent-db-spec-gen {:schema schema} {:user [[1 {:spec-gen {:username "Meeghan"
                                                                :id       100}}]]})
    (sm/attr-map :spec-gen))

;; Generating a post generates the user the post belongs to, with
;; foreign keys correct
(-> (sg/ent-db-spec-gen {:schema schema} {:post [[1]]})
    (sm/attr-map :spec-gen))

;; Generating a like also generates a post and user
(-> (sg/ent-db-spec-gen {:schema schema} {:like [[1]]})
    (sm/attr-map :spec-gen))


;; The `insert` function shows that records are inserted into the
;; simulate "database" (`mock-db`) in correct dependency order:
(insert {:like [[1]]})
@mock-db

Last updated