• Stars
    star
    290
  • Rank 142,981 (Top 3 %)
  • Language
    Clojure
  • License
    MIT License
  • Created over 6 years ago
  • Updated 12 months ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

Integration testing framework using a state monad in the backend for building and composing flows

StateFlow

Clojars Project

StateFlow is a testing framework designed to support the composition and reuse of individual test steps.

Definitions

  • A flow is a sequence of steps or bindings.
  • A step is a primitive step or flow.
  • A binding is a vector of pairs of symbols and steps (or a :let with a vector of regular let-bindings)

Flows

A flow is a sequence of steps or bindings to be executed with some state as a reference. Use the flow macro to define a flow:

(flow <description> <step/bindings>*)

Once defined, you can run it with (state-flow.api/run* <options> (flow ...)).

You can think flows and the steps within them as functions of the state, e.g.

(fn [<state>] [<return-value>, <possibly-updated-state>])

Each step is executed in sequence, passing the state to the next step. The return value from running the flow is the return value of the last step that was run.

Primitive steps

Primitive steps are the fundamental building blocks of flows.

  • Return the application of a function f to the state.
(state-flow.api/get-state f)
  • Store the application of a function f to the state.
(state-flow.api/swap-state f)
  • Transform a value returned by a step
(state-flow.api/fmap xform <step-or-flow>)
  • Return a value
(state-flow.api/return v)
  • Invoke a no-arg function and return its result
(state-flow.api/invoke no-arg-fn)

Bindings

Bindings bind return values of steps to symbols you can use in other steps.

[(<symbol> <step>)+]

They are like let bindings but the symbol on the left binds to the return value of the step on the right.

[<symbol> <step-or-flow>]

You can also bind directly to values using the :let keyword:

[:let [<symbol> <non-step expression>]]

You can bind any number of symbols in a single binding vector, e.g.

[a     step-1
 b     step-2
 :let [c expression-1]
 d     step-3]

Running Flows

If you are using StateFlow for integration testing, the initial state is usually a representation of your service components, a system using Stuart Sierra's Component library or other similar facility. You can also run the same flow with different initial states, e.g.

(def a-flow (flow ...))

(defn build-initial-state [] { ... })
(state-flow.api/run* {:init build-initial-state} flow)

(state-flow.api/run* {:init (constantly {:service-system (atom nil))} flow)

Composing Flows

Flows follow the Composite Pattern: a single flow has the same interface as a collection of flows.

You can compose flows by nesting them in other flows:

(flow "do many things"
  (flow "do one thing" ,,,)
  (flow "do another thing" ,,,))

Use state-flow.api/for when you have a flow that you'd like to apply to different inputs with the same outcome, e.g.

(flow "even? returns true for even numbers"
  (flow/for [x (filter even? (range 10))]
    (match? even? x)))

Failing Fast

By default, a flow continues to be evaluated even if an assertion fails. The :fail-fast? option to state-flow.api/run* can be used if you would like to stop evaluation after the first assertion failure.

(state-flow.api/run* {:fail-fast? true}
  (flow "evaluation stops after `failing-flow-b`"
    flow-a
    failing-flow-b
    flow-c))

Flow Example

Suppose our system state is made out of a map with {:value <value>}. We can make a flow that just fetches the value bound to :value.

(require '[state-flow.api :as flow :refer [flow]])
(def get-value (flow "get-value" (flow/get-state :value)))
(flow/run* {:init (constantly {:value 4})} get-value)
; => [4 {:value 4}]

Primitive steps have the same underlying structure as flows and can be passed directly to run*:

(def get-value (flow/get-state :value))
(flow/run* {:init (constantly {:value 4})} get-value)
; => [4 {:value 4}]

We can use state-flow.api/swap-state to modify the state. Here's a primitive that increments the value:

(def inc-value (flow/swap-state update :value inc))
(flow/run* {:init (constantly {:value 4})} inc-value)
; => [{:value 4} {:value 5}]

Bindings enable us to compose simple flows into more complex flows. If, instead of returning the value, we wanted to return the value multiplied by two, we could do it like this:

(def double-value
  (flow "get double value"
    [value get-value]
    (flow/return (* value 2))))
(flow/run* {:init (constantly {:value 4})} double-value)
; => [8 {:value 4}]

Or we could increment the value first and then return it doubled:

(def inc-and-double-value
  (flow "increment and double value"
    inc-value
    [value get-value]
    (flow/return (* value 2))))
(flow/run* {:init (constantly {:value 4})} inc-and-double-value)
; => [10 {:value 5}]

clojure.test and matcher-combinators

We use the defflow and match? macros to build clojure.test tests out of flows.

state-flow.api/defflow defines a test (using deftest) that will execute the flow with the parameters that we set.

state-flow.assertions.matcher-combinators/match? produces a flow that will make an assertion, which will be reported via clojure.test when used within a defflow. It uses the nubank/matcher-combinators library for the actual check and failure messages. match? asks for:

  • the expected value, or a matcher-combinators matcher
    • if you supply a value, matcher-combintators will apply its defaults
  • the actual value, or a step which will produce it
    • if you supply a value, match? will wrap it in (state-flow.api/return <value>)
  • optional map of options with:
    • :times-to-try (default 1)
    • :sleep-time (default 200)

Here are some very simple examples of tests defined using defflow:

(defflow my-flow
  (match? 1 1)
  (match? {:a 1} {:a 1 :b 2}))

Wrap them in flows to get descriptions when the expected and actual values need some explanation:

(deftest fruits-and-veggies
  (flow "surprise! Tomatoes are fruits!"
    (match? #{:tomato} (fruits #{:tomato :potato}))))

Or with custom parameters:

(defflow my-flow {:init aux.init! :runner (comp run* s/with-fn-validation)}
  (match? 1 1))
(defflow my-flow {:init (constantly {:value 1
                                     :map {:a 1 :b 2}})}
  [value (flow/get-state :value)]
  (match? 1 value)
  (flow "uses matcher-combinator embeds"
    (match? {:b 2} (flow/get-state :map)))

:times-to-try and :sleep-time

By default, match? will evaluate actual only once. For tests with asynchrony/concurrency concerns, you can direct match? to try up to :times-to-try times, waiting :sleep-time between each try. It will keep trying until it produces a value that matches the expected expression, up to :times-to-try.

(defflow add-data
  (flow "try up to 5 times with 250 ms between each try (total 1000ms)"
    (produce-message-that-causes-database-update)
    (match? expected-data-in-database
            (fetch-data)
            {:times-to-try 5
             :sleep-time 250})))

NOTE: about upgrading to state-flow-2.2.4

We introduced state-flow.api/match? in state-flow-2.2.4, and deprecated state-flow.cljtest/match? in that release. The signature for the old version was (match? <description> <actual> <expected>). We removed the description because it was quite common for the description to add no context that wasn't already made clear by the expected and actual values.

We also reversed the order of expected and actual in order to align with the match? function in the matcher-combinators library and with clojure.test's (is (= expected actual)).

We also added a script to help refactor this for you. Here's how you use it:

# if you don't already have the state-flow repo cloned
git clone https://github.com/nubank/state-flow.git
;; or
git clone [email protected]:nubank/state-flow.git
;; then
cd state-flow

# if you already have the state-flow repo cloned
cd state-flow
git co master
git pull

# the rest is the same either way
lein pom # needed for tools.deps to recognize this repo as a `:local/root` dependency
./bin/refactor-match.sh --help
;; now follow the instructions

Note that if you have a defflow defined in a different namespace, and it depends on state-flow.api/defflow, you may need to require it in that namespace.

Midje Support

We use verify to write midje tests with StateFlow. verify is a function that of three arguments: a description, a value or step, and another value or midje checker. It produces a step that, when executed, verifies that the second argument matches the third argument. It replicates the functionality of a fact from midje. In fact, if a simple value is passed as second argument, what it does is simply call fact internally when the flow is executed.

verify returns a step that will make the check and return something. If the second argument is a value, it will return this argument. If the second argument is itself a step, it will return the last return value of the step that was passed. This makes it possible to use the result of verify on a later part of the flow execution if that is desired.

Say we have a step for making a POST request that stores data in datomic (store-data-request), and we also have a step that fetches this data from db (fetch-data). We want to check that after we make the POST, the data is persisted:

(:require
  [state-flow.api :refer [flow]]
  [state-flow.midje :refer [verify]])

(defn stores-data-in-db
  [data]
  (flow "save data"
    (store-data-request data)
    [saved-data (fetch-data)]
    (verify "data is stored in db"
      saved-data
      expected-data)))

Writing Helpers

Test helpers specific to your domain can make state-flow tests more readable and intention-revealing. When writing them, we recommend that you start with state-flow functions in the state-flow.api namespace. If, for example, you're testing a webapp, you might want a request helper like this:

(defflow users
  (flow "fetch registered users"
    (http-helpers/request {:method :post
                           :uri "/users"
                           :body {:user/first-name "David"}})
    [users (http-helpers/request {:method :get
                                  :uri "/users"})]
    (match? ["David"]
            (map :user/first-name users)))

Presuming that you have an :http-component key in the initial state, the http-helpers/request helper could be implemented something like this:

(ns http-helpers
  (:require [my-app.http :as http]
            [state-flow.api :as flow :refer [flow]]))

(defn request [req]
  (flow "make request"
    [http (flow/get-state :http-component)]
    (flow/return (http/request http req)))

This produces a step that can be used in a flow, as above.

funcool.cats

state-flow is built on the funcool.cats library, which supports monads in Clojure. state-flow exposes some, but not all, cats functions as its own API. As mentioned above, we recommend that you stick with state-flow functions as much as possible, however, if the available functions do not suit your need for a helper, you can always drop down to functions directly in the cats library.

Tooling

Emacs + cider

Add "defflow" to the list defined by cider-test-defining-forms to enable commands like cider-test-run-test for flows defined with defflow.

See https://docs.cider.mx/cider/testing/running_tests.html#_configuration

More Repositories

1

fklearn

fklearn: Functional Machine Learning
Jupyter Notebook
1,463
star
2

workspaces

Live development environment for Clojurescript
Clojure
487
star
3

matcher-combinators

Library for creating matcher combinator to compare nested data structures
Clojure
457
star
4

morse

A graphical, interactive tool for browsing Clojure data
JavaScript
326
star
5

nuvigator

A powerful routing abstraction over Flutter Navigator, with nested Navigator and Deeplinks
Dart
274
star
6

k8s-api

Kubernetes client, data driven
Clojure
149
star
7

docopt.clj

Clojure implementation of the docopt description language
Clojure
77
star
8

mockfn

A mocking library for Clojure.
Clojure
74
star
9

nodely

Clojure
67
star
10

clj-github

A Clojure library for interacting with the github developer API
Clojure
56
star
11

selvage

Testing at the edges with a world-transition style integration test framework
Clojure
53
star
12

i18next

Dart
44
star
13

vessel

A containerization tool for Clojure applications
Clojure
42
star
14

clj-github-app

A library to implement GitHub Apps in Clojure.
Clojure
41
star
15

node-modules-boilerplate

Demo project for Frontend Meetup scheduled to October 26th
JavaScript
40
star
16

nixpkgs

Nix Packages collection used in Nubank
Nix
32
star
17

clj-owasp

28
star
18

emidje

Test runner, report viewer and formatting tool for Midje within Emacs
Emacs Lisp
26
star
19

ordnungsamt

helping maintain order and consistency across code-bases
Clojure
25
star
20

diversidados-curso-ds

Curso de data science - Diversidados
Jupyter Notebook
22
star
21

umschreiben-clj

Rewrite utilities for refactoring clojure files
Clojure
17
star
22

midje-nrepl

nREPL middleware to interact with Midje
Clojure
13
star
23

clj-github-mock

An emulator of the github api implemented in clojure to test your github app
Clojure
11
star
24

clj-bigpanda

A clojure library for BigPanda Alerts and Deployments
Clojure
11
star
25

mobile-book-club

Ruby
10
star
26

robocode-clojure

Template project for building a Robocode Robot in Clojure
Clojure
10
star
27

new-grad-template

Clojure
9
star
28

hack-her-way

Repositório para o programa de contratamento de mulheres em segurança do N
7
star
29

cljnvim

A Neovim config to develop woth Clojure!
Nix
4
star
30

mobile-pairing-exercises

Base project for Nubank's mobile pairing exercises
Swift
3
star
31

facturama-clojure-sdk

Clojure library for developing with Facturama's REST APIs
Clojure
2
star
32

desafio-cocoaheads

2
star
33

tekton-watcher

Watcher for Tekton APIs
Clojure
2
star
34

juntos-separados

Conversations media
1
star
35

prismatic-graphql

Library to create Prismatic schema from GraphQL queries
Clojure
1
star
36

spec-spi-code

Código referente a especificação de SPI
Java
1
star