Testable Clojurescript apps – Using re-frame, devcards and convention to build web apps on solid ground

The world needs enlightenment

I was asked recently by a colleague - let's call him Stathis S for anonymity's sake... no wait, let's call him Mr S. Sideris - how I put front-end apps together. He wanted, in particular, to understand why he should consider using re-frame for a new project. I was in a similar situation a few years ago and after reading the re-frame introduction several times I still didn't quite get it so I went ahead without it, using just Reagent for rendering and my own code for managing all the data and state.

This worked fine at first, but we had growing pains and I found that some repetitive boilerplate code was emerging. At this point I went back to re-frame and this time it just clicked - it was the implementation we had been slowly approaching ourselves, but properly thought-out and refined.

For the things we have to learn before we can do them, we learn by doing them - Aristotle

It's not just about replacing our code with re-frame's, though - re-frame changed the way I thought about the app, how it was structured and presented opportunities to make the tests better. I've had the good fortune to build four new apps for a client over the last three years and now would like to share the approach refined over that time which promotes simple code, comprehensive testing and ability to scale (by which I mean add features) without it becoming a big tasty bowl of spaghetti.

With whimsy as our staff and japery as our compass, let us begin.

Gimme one vision

Imagine we are building a typeahead. There are currently 614 typeahead libraries available on Github, so what we need is a single universal typeahead library to supercede them all.

A typeahead typically involves several things:

We would like the typeahead to look something like the following, prejudiced as we are by the 614 typeahead implementations we've seen on our journey down the infinite scroll of the internet. If we make ours look broadly similar people will already know how to use it - a design principle called familiarity, if you're familiar with the term.

Before we get going, you'll need the following things:

You can use the figwheel-main template to get the first two things. The others are available on my eBay shop with discounted prices for readers of this blog post - use $DISCOUNT_CODE to get 20% off.

Isolate the view

You might think of your application being used as follows:

And you could test it like this:

There are problems here:

We would like to separate the eyebally bit out and sandbox it so that it doesn't pollute the rest of our code, which deals with pure computery data. Let's split the UI into two parts - the data model (mmmm, fresh!) and the view (full of the sticky goo found in eyeballs) which has only one job - representing the data to the user.

The spec

The boundaries in our code are a good place to test - the model deals purely with data and is testable by normal means and the view deals purely with HTML and requires a different testing methodology. It therefore makes sense to introduce checks at the boundary between these two, where the output of the model is consumed by the view. I can define this as a spec:

Which will look something like this:

(s/def ::query (s/nilable string?))
(s/def ::loading? boolean?)
(s/def ::value string?)
(s/def ::values (s/coll-of ::value))
(s/def ::suggestions (s/nilable (s/keys :req-un [::loading?]
                                        :opt-un [::values])))

(s/def ::component
  (s/keys :req-un [::query ::suggestions]))

Then I can build my model so that it outputs this data and my view so that it consumes this data.

A model

The re-frame documentation has colourful descriptions on how data flows like the water cycle. Your state of the world is used to render your app. The user, seeing the app, interacts with it and generates an event. The event is folded into the current state of the world to produce the next state of the world, which renders an updated view to the user and they can interact further. The whole cycle goes on forever, consuming events and rendering updates, growing like a black hole engulfing the universe, destabilising entire galaxies with its infinite gravity well and spewing radiation until eventually your Chrome tab runs out of memory.

For our typeahead the water cycle / black hole of death will deal with all the parts of our UI that don't involve goo.

We can encode this flow of data using re-frame's two main concepts - event handlers and subscriptions. If you are unfamiliar with these I suggest you first read the informative and entertaining re-frame documentation before returning here to pick up where you left off. I'll work on some of my side projects in the meantime. Watches 2 episodes of Better Call Saul... Welcome back! Shall we begin?

We have a few events that can occur: the user types something, we ask the server for some suggestions, the response from the server arrives and the user chooses a suggestion. Every time an event happens we store some data in the database. Note that all these events, with the exception of asking the server, happen asynchronously in real life.

;; user types something (and we ask the server for some suggestions)
(re-frame/reg-event-fx
 ::on-query
 (fn [{:keys [db]} [_ q]]
   (merge
    {:db (-> db
             (assoc-in [:typeahead :query] q)
             (assoc-in [:typeahead :suggestions] {:loading? true}))}
    (if (string/blank? q)
      {:dispatch [::on-suggestions []]}
      {:dispatch [::server/fetch-suggestions q [::on-suggestions]]}))))

;; server responds with some suggestions
(re-frame/reg-event-db
 ::on-suggestions
 (fn [db [_ suggestions]]
   (assoc-in db [:typeahead :suggestions] {:loading? false
                                           :suggestions suggestions})))

;; user chooses a suggestion
(re-frame/reg-event-db
 ::on-choose
 (fn [db [_ suggestion]]
   (assoc-in db [:typeahead] {:query suggestion})))

Now we need a way to get the data out of our re-frame database so that our view can render it. That's what subscriptions are for:

(re-frame/reg-sub
 ::suggestions
 (fn [db [_ query]]
   (get-in db [:typeahead :suggestions])))

(re-frame/reg-sub
 ::query
 (fn [db [_ query]]
   (get-in db [:typeahead :query])))

(re-frame/reg-sub
 ::typeahead
 (fn []
   [(re-frame/subscribe [::query])
    (re-frame/subscribe [::suggestions])])
 (fn [[query suggestions]]
   {:query query
    :suggestions suggestions}))

Now that all our data eggs are in the re-frame basket we can start to write meaningful tests.

Model tests

We can use our spec to assert that, regardless of what the user types in or what the server returns, the main ::typeahead subscription always returns data that satisfies it.

(defn- stub-server-response [expected-request response]
  (re-frame/reg-event-fx
   ::server/fetch-suggestions
   (fn [_ [_ q callback]]
     (is (= expected-request q))
     {:dispatch (conj callback response)})))

(deftest querying-test
  (run-test-sync
   (stub-server-response "foo" ["bar"])

   (let [t (re-frame/subscribe [::model/typeahead])]
     (testing "user types something"
       (re-frame/dispatch [::model/on-query "foo"])

       (is (= {:query "foo"
               :suggestions {:loading? false
                             :values ["bar"]}} @t))
       (is (s/valid? ::spec/component @t))))))

This demonstrates not only that my model code outputs data that fits the spec, but also makes a hard-to-test asynchronous call to the server behave synchronously using the stub. This is a feature of day8/re-frame-test and is really useful in making complicated models easily testable. It also lets me test the transient state of the data being requested but no response received:

(deftest loading-suggestions-test
  (run-test-sync

   (re-frame/reg-event-fx
    ::server/fetch-suggestions
    (fn [_ [_ q callback]]
      (is (= "foo" q))
      ;; do not dispatch callback in order to observe the state before the server has returned a response
      {}))

   (let [t (re-frame/subscribe [::model/typeahead])]
     (testing "user types something"

       (re-frame/dispatch [::model/on-query "foo"])

       (is (= {:query "foo"
               :suggestions {:loading? true}} @t))
       (is (s/valid? ::spec/component @t))))))

Tests don't need to run asynchronously with complicated locking or suffer from race conditions. Now we have covered the following parts in testing:

The goo

The view is where our data meets a random load of HTML which will hopefully present the data in a way that makes sense to the user. The best way of testing that is to just render something and have a look at it! Let's write some Reagent code to manifest our vision in html tag form:

(defn- suggestions [{:keys [loading? values] :as any?}]
  (when any?
    [:div.suggestions
     (if loading?
       [:div.loading "Loading suggestions..."]
       (if (seq values)
         [:ul
          (map-indexed
           (fn [i v]
             [:li {:key (str i v)
                   :on-click #(re-frame/dispatch [::model/on-choose v])} v])
           values)]
         "No suggestions found, please try another search"))]))

(defn typeahead-component [model]
  [:div.typeahead
   [:input {:type "text"
            :value (:query model)
            :placeholder "Start typing to see suggestions"
            :on-change #(re-frame/dispatch [::model/on-query (.. % -target -value)])}]
   [suggestions (:suggestions model)]])

(s/fdef typeahead-component
        :args (s/cat :model ::spec/component))

(defn typeahead []
  (let [model (re-frame/subscribe [::model/typeahead])]
    (fn []
      [typeahead-component @model])))

Notice how simple it is - no changing of the data as it gets passed around, just pure transforming functions to represent the data directly in HTML.

View tests

I use devcards to exercise the view code, and for assertions I use my gooey eyes. Devcards makes this practical because it allows me to render many permutations of data, so that I can build a screen showing all my visual edge cases in one go. I know I am using realistic data in these cards because I can check it against my spec, and I can ensure this by turning on instrumentation so that it will blow up if my test data drifts from the spec.

(defcard-rg typeahead
  [:div
   [:h1 [:i "Initial state"]]
   [typeahead* {:query nil
                :suggestions nil}]

   [:h1 [:i "Loading suggestions"]]
   [typeahead* {:query "my computer is a"
                :suggestions {:loading? true}}]

   [:h1 [:i "Some suggestions"]]
   [typeahead* {:query "my computer is a"
                :suggestions {:loading? false
                              :values ["My computer is acting weird" "My computer is a potato"]}}]

   [:h1 [:i "No suggestions"]]
   [typeahead* {:query "bz"
                :suggestions {:loading? false
                              :values []}}]])

I can now go one step further and try to render randomly generated data using the spec:

(defcard-rg generated-typeahead
  (let [models (gen/sample (s/gen ::spec/component))]
    [:div
     (map-indexed (fn [i m]
            [:div {:key i}
             [:pre (pr-str m)]
             [typeahead* m]])
          models)]))

I quickly found that I had React problems with duplicate keys if the same suggestion appeared multiple times. It also forced me to consider what to do if a suggestion was an empty string, because that looks really weird:

With some fixes in place for that, I now know that

Automatic for the people

Reviewing devcards by eye before a release isn't too onerous, but it's still easy to forget and then harder still to track down which change broke the UI. Automating this step is the natural way to go, and with a combination of chrome headless and imagemagick it's possible to capture screenshots and diff devcards against reference versions. I have an implementation of this which I hope to make open source in the future.

998... 999...

The final pieces of the puzzle are at the server/model boundary. If we knew what sort of data the server might return then we could prove our model can handle it, and if our model can handle it we know our UI can display it.

Some API technologies are self-describing - among them GraphQL and Swagger (OpenAPI). I have written data generation libraries for both of these: lacinia-gen and martian-test respectively, allowing you to generate data that your server will return as an input to your model tests.

We now know that

Leading to proof by transitive relation that we can render everything properly, our application works as expected and we can add "full stack testing" to our CV.

As a bonus, we also know that:

Ahh, the bonus, the deep bonus. I don't know if you heard me counting, I did over a thousand.

Conclusion

With some separation of concerns, introduction of code boundaries and assertions made at each boundary we can improve the way we build and test our apps. It's a bit like how a ship is built - each compartment is watertight and small enough that if any one of them is breached it can be sealed off to stop the ship sinking.

On that rather apt metaphor for software development, I'll just recap on the libraries and tools used:

The code examples are from a toy project called re-partee that I created to demonstrate the principles in this blog post.

Published: 2018-11-22

Privacy policy

The case for adding ClojureScript to your project jon
by Jon Pither
Published: 2015-06-29
Experiences adopting GraphQL on a Clojure/script project oly
by Oliver Hine
Published: 2018-03-02
If you know how to do it fky
by Frankie Sardo
Published: 2017-04-20