Tutorial 16 - On pleasing TDD practitioners
February 17, 2018 · View on GitHub
In the previous tutorial we looked at the problem of using a
single JVM to run multiple boot tasks supporting Bret Victor's
Immediate Feedback Principle and the Test Driven Development (TDD)
workflow. While we are approaching that objective, we have ended up
with a few questions about the tdd task we defined. Namely, is it
possible to define the tdd task so that it is parameterizable, while
also keeping some sane defaults for typical usage?
In this tutorial we're going to answer that question by introducing
boot's
Task Options Domain Specific Language.
Preamble
To start working from the end of the previous tutorial, assuming you've git installed, do as follows
git clone https://github.com/magomimmo/modern-cljs.git
cd modern-cljs
git checkout se-tutorial-15
Introduction
While I have never started a new program by first writing a failing
test, that has more to do with my age than with my opinion about
TDD. That said, the workflow induced from the tdd task we ended up
in the previous tutorial would be criticized by any TDD
practitioner. We'd like to please them a little bit more than at
present.
Task Options Domain Specific Language
As you can read from the Wiki page cited above, boot is a servant of
many owners and it has to please them all:
boottasks are intended to be used in many ways, including from the command line, from a REPL, in the project's build.boot file, and from regular Clojure namespaces. This requires support for two conceptually different calling conventions: with command line options and optargs, and as s-expressions in Clojure with function arguments. Furthermore, since task definitions are the API betweenbootand the user, it's important that they provide good usage and help documentation in both environments.
Command-Line options and optargs are Unix terms that any programmer should know about, while s-expression is a term that is specific to the LISP's community. But we don't want to bother you with long explanations about these things. So, let's be pragmatic and get directly into our needs.
Short requirements
At the moment in our project we only have the test/cljc test
directory, but as soon as the project gets more complicated we'll
probably need to add more testing namespaces specifically dedicated to
CLJ or CLJS.
Even though you may organize and name your testing namespaces arbitrarily, it's considered good practices to keep any testing namespaces specific to CLJ separated from the ones specific to CLJS. We also want to keep both of those separated from testing namespaces which are portable between CLJ and CLJS as well.
At some point you'll probably end up with a directory layout something like the following:
test/
├── clj
├── cljc
└── cljs
This mimics the directory layout for the source code:
src/
├── clj
├── cljc
└── cljs
Be prepared for changes
To be prepared for any testing scenario, while keeping the more common
ones as simple as possible (e.g., boot tdd), we need to design our
task definitions from top to bottom.
For example, we would like to be able to call the tdd task by
passing to it one or more test directories to be added to the
classpath of the project. Something like this:
boot tdd -t test/cljc
or this:
boot tdd -t test/clj -t test/cljs -t test/cljc
I know, you'd prefer something like the following form:
boot tdd -t test/clj:test/cljs:test/cljc
The task options DSL offers direct support for the former mode, named
multiple options. If
you prefer (like I do) the colon separated directories mode, you
have to parse the command arguments yourself.
A dummy task
Let's temporarily create a dummy task in the build.boot to see how
this could work:
(deftask dummy
"A dummy task"
[t dirs PATH #{str} ":source-paths"]
*opts*)
Here we're using the task options inside the argument vector of the task definition.
[t dirs PATH #{str} ":source-path"]
t: is the shortname eg: boot dummy -tdirs: is the longname eg: boot dummy --dirsPATH: is the optarg. When provided, indicates that the option expects an argument to be passed at call time;- #{str}: is the CLJ type hint for the passed argument. Here the type hint is saying that the arguments will be interpreted as a set of strings;
":source-path": is the description that will be incorporated into command line help output.
The dummy task does not do anything. If you call it at the terminal,
it does not return anything interesting:
boot dummy
boot dummy -t test/cljc
boot dummy -t test/cljc -t test/cljs
boot dummy -t test/cljc -t test/cljs -t test/clj
However, if you do not pass an argument to the -t option, it will return an
error, because the optarg PATH has been provided in the definition.
boot dummy -t
clojure.lang.ExceptionInfo: java.lang.IllegalArgumentException: Missing required argument for "-t PATH"
data: {:file
"/var/folders/17/1jg3ghkx73q4jtgw4z500www0000gp/T/boot.user2289187480377868390.clj",
:line 29}
java.util.concurrent.ExecutionException: java.lang.IllegalArgumentException: Missing required argument for "-t PATH"
java.lang.IllegalArgumentException: Missing required argument for "-t PATH"
boot.core/construct-tasks core.clj: 682
...
clojure.core/apply core.clj: 630
boot.core/boot/fn core.clj: 712
clojure.core/binding-conveyor-fn/fn core.clj: 1916 ...
We can even ask for the help documentation:
boot dummy -h
A dummy task
Options:
-h, --help Print this help info.
-t, --dirs PATH Conj PATH onto :source-paths
Now let's now call the dummy task from the CLJ REPL:
cd /path/to/modern-cljs
boot repl
...
boot.user=>
boot.user=> (dummy)
{}
boot.user=> (dummy "-t" "test/cljs")
{:dirs #{"test/cljs"}}
boot.user=> (dummy "-t" "test/clj" "-t" "test/cljs")
{:dirs #{"test/clj" "test/cljs"}}
boot.user=> (dummy "-t" "test/clj" "-t" "test/cljs" "-t" "test/cljc")
{:dirs #{"test/clj" "test/cljs" "test/cljc"}}
As you see, the value assigned to the :dirs keyword in the *opts*
map is a set of string, exactly like the DSL #{str} type hint we
used in the dummy task definition says. To proceed with the next
step, stop the boot process.
Update build.boot
We now know enough to start updating the testing task. Considering that
all the testing task does is add paths to the :source-paths
environment variable, we'll change its name to add-source-paths as
well:
(deftask add-source-paths
"Add paths to :source-paths environment variable"
[t dirs PATH #{str} ":source-paths"]
(merge-env! :source-paths dirs)
identity)
NOTE 1: Here I used the
merge-env!function instead ofset-env!since I think it makes the code easier to be read.
Boot's tasks are like
Ring
handlers, but instead of taking a request map and returning a response
map, they take and return a
fileset object. add-source-paths
does not do anything special. It only merges the :source-paths value
with the command-line argumment and returns the identity function to make it a
composable task.
Now substitute the newly defined add-source-paths task for the
previous testing task in the tdd task definition:
(deftask tdd
"Launch a TDD Environment"
[]
(comp
(serve :handler 'modern-cljs.core/app
:resource-root "target"
:reload true)
(add-source-paths :dirs #{"test/cljc"}) ; old name was testing
(watch)
(reload :ws-host "localhost")
(cljs-repl)
(test-cljs :out-file "main.js"
:js-env :phantom
:namespaces '#{modern-cljs.shopping.validators-test}
:update-fs? true)
(test :namespaces '#{modern-cljs.shopping.validators-test})
(target :dir #{"target"})))
We need to do one more thing to please a tdd user: add the
same task option to tdd as well, so that we can pass a
test directory on the command line and have it added
to the :source-paths variable.
(deftask tdd
"Launch a customizable TDD Environment"
[t dirs PATH #{str} "test paths"]
(let [dirs (or dirs #{"test/cljc" "test/clj" "test/cljs"})]
(comp
(serve :handler 'modern-cljs.core/app
:resource-root "target"
:reload true)
(add-source-paths :dirs dirs)
(watch)
(reload :ws-host "localhost")
(cljs-repl)
(test-cljs :out-file "main.js"
:js-env :phantom
:namespaces '#{modern-cljs.shopping.validators-test}
:update-fs? true)
(test :namespaces '#{modern-cljs.shopping.validators-test})
(target :dir #{"target"}))))
NOTE 2: The
-tdefault value is now#{"test/cljc" "test/clj" "test/cljs"}. In boot versions previous to 2.7.1 even if none of these directories exist or contains any testing namespace, the internalmerge-env!function will not complain, but from 2.7.1 they must exist, or an exception java.nio.file.NoSuchFileException will be thrown
Note how we used the DSL to define the same -t option as for the
add-source-paths task. The only difference for the tdd task is the
description "test paths". Moreover, we used the let and or forms to give it
a default value, before finally passing the argument to the
internal add-source-paths function. This is the idiomatic way to use task
options.
Before using the newly updated tdd task, let see its help text:
boot tdd -h
Launch a customizable TDD Environment
Options:
-h, --help Print this help info.
-t, --dirs PATH Conj PATH onto test paths
Now call it from the command line:
boot tdd -t test/cljc
...
Compiling ClojureScript...
WARNING: Replacing ClojureScript compiler option :main with automatically set value.
• main.js
Running cljs tests...
Testing modern-cljs.shopping.validators-test
Ran 1 tests containing 13 assertions.
0 failures, 0 errors.
Testing modern-cljs.shopping.validators-test
Ran 1 tests containing 13 assertions.
0 failures, 0 errors.
Elapsed time: 25.077 sec
The report is still working as expected. To proceed with the next
step, stop the boot process.
Appetite comes with eating
Now that we have learned something new, we'd like to use it to make the
tdd task even more customizable. There are more things in the tdd
task that may be treated as task options.
Let's start with the simplest ones.
Start small, grow fast
To have a clearer idea of the customization options available to
the tdd task, let's review the help documentation for
each subtask it uses internally.
Serve task options
The following is the serve task's help text:
boot serve -h
Start a web server on localhost, serving resources and optionally a directory.
Listens on port 3000 by default.
Options:
-h, --help Print this help info.
-d, --dir PATH PATH sets the directory to serve; created if doesn't exist.
-H, --handler SYM SYM sets the ring handler to serve.
-i, --init SYM SYM sets a function to run prior to starting the server.
-c, --cleanup SYM SYM sets a function to run after the server stops.
-r, --resource-root ROOT ROOT sets the root prefix when serving resources from classpath.
-p, --port PORT PORT sets the port to listen on. (Default: 3000).
-k, --httpkit Use Http-kit server instead of Jetty
-s, --silent Silent-mode (don't output anything)
-t, --ssl Serve via Jetty SSL connector on localhost on default port 3443 using cert from ./boot-http-keystore.jks
-T, --ssl-props SSL SSL sets override default SSL properties e.g. "{:port 3443, :keystore "boot-http-keystore.jks", :key-password "p@ssw0rd"}".
-R, --reload Reload modified namespaces on each request.
-n, --nrepl REPL REPL sets nREPL server parameters e.g. "{:port 3001, :bind "0.0.0.0"}".
-N, --not-found SYM SYM sets a ring handler for requested resources that aren't in your directory. Useful for pushState.
Some of those serve task options could be useful for tdd as well:
-d, --dir PATH-H, --handler SYM-r, --resource-root ROOT-p, --port PORT-k, --httpkit
In the context of the current project, I'm interested in the -p task
option. But I'd like to test the project on the famous asynchronous
http-kit web server as well.
Watch task options
Following is the watch task's help:
boot watch -h
Call the next handler when source files change.
Options:
-h, --help Print this help info.
-q, --quiet Suppress all output from running jobs.
-v, --verbose Print which files have changed.
-M, --manual Use a manual trigger instead of a file watcher.
-d, --debounce MS MS sets debounce time (how long to wait for filesystem events) in milliseconds.
-i, --include REGEX Conj REGEX onto the set of regexes the paths of changed files must match for watch to fire.
-e, --exclude REGEX Conj REGEX onto the set of regexes the paths of changed files must not match for watch to fire.
Here the -v task option is the only one I'm currently interested in.
Reload task options
Following is the reload task's help:
boot reload -h
Live reload of page resources in browser via websocket.
The default configuration starts a websocket server on a random available
port on localhost.
Open-file option takes three arguments: line number, column number, relative
file path. You can use positional arguments if you need different order.
Arguments shouldn't have spaces.
Examples:
vim --remote +norm%sG%s| %s
emacsclient -n +%s:%s %s
Client options can also be set in .cljs.edn file, using property :boot-reload, e.g.
:boot-reload {:on-jsload frontend.core/reload}
Options:
-h, --help Print this help info.
-b, --ids BUILD_IDS Conj [BUILD IDS] onto only inject reloading into these builds (= .cljs.edn files)
-i, --ip ADDR ADDR sets the IP address for the websocket server to listen on. (optional).
-p, --port PORT PORT sets the port the websocket server listens on. (optional).
--ws-port PORT PORT sets the port the websocket will connect to. (optional).
-w, --ws-host WSADDR WSADDR sets the websocket host clients connect to. Defaults to current host. (optional).
-s, --secure Flag to indicate whether the client should connect via wss. Defaults to false.
-j, --on-jsload SYM SYM sets the callback to call when JS files are reloaded. (client, optional).
--asset-host HOST HOST sets the asset-host where to load files from. Defaults to host of opened page. (client, optional).
-a, --asset-path PATH PATH sets sets the output directory for temporary files used during compilation. (optional).
-c, --cljs-asset-path PATH PATH sets the actual asset path. This is added to the start of reloaded urls. (optional).
-o, --open-file COMMAND COMMAND sets the command to run when warning or exception is clicked on HUD. Passed to format. (optional).
-v, --disable-hud Toggle to disable HUD. Defaults to false (visible).
-t, --target-path VAL VAL sets target path to load files from, used WHEN serving files using file: protocol. (optional).
--only-by-re REGEXES Conj REGEXES onto vector of path regexes (for `boot.core/by-re`) to restrict reloads to only files within these paths (optional).
Even if there are some very interesting options available for the future
(e.g. -j, --on-jsload SYM), at the
moment I'm not interested in exposing any of them for the tdd task.
Cljs-repl task options
Following is the cljs-repl task's help:
boot cljs-repl -h
Start a ClojureScript REPL server.
The default configuration starts a websocket server on a random available
port on localhost.
Options:
-h, --help Print this help info.
-b, --ids BUILD_IDS Conj [BUILD IDS] onto only inject reloading into these builds (= .cljs.edn files)
-i, --ip ADDR Set the IP address for the server to listen on to ADDR.
-n, --nrepl-opts NREPL_OPTS Set options passed to the repl task to NREPL_OPTS.
-p, --port PORT Set the port the websocket server listens on to PORT.
-w, --ws-host WSADDR Set the (optional) websocket host address to pass to clients to WSADDR.
-s, --secure Flag to indicate whether the client should connect via wss. Defaults to false.
As with the previous reload task, at the moment I'm not interested in
exposing any of those options for the tdd task.
Test-cljs task options
The following is the cljs-test task's help text:
boot test-cljs -h
Run cljs.test tests via the engine of your choice.
The --namespaces option specifies the namespaces to test. The default is to
run tests in all namespaces found in the project.
Options:
-h, --help Print this help info.
-e, --js-env VAL Set the environment to run tests within, eg. slimer, phantom, node,
or rhino to VAL.
-n, --namespaces NS Conj NS onto namespaces whose tests will be run. All tests will be run if
ommitted.
-s, --suite-ns NS Set test entry point. If this is not provided, a namespace will be
generated to NS.
-O, --optimizations LEVEL Set the optimization level to LEVEL.
-o, --out-file VAL Set output file for test script to VAL.
-c, --cljs-opts VAL Set compiler options for CLJS to VAL.
-u, --update-fs? Only if this is set does the next task's filset include
and generated or compiled cljs from the tests.
-x, --exit? Exit immediately with reporter's exit code.
Now, here are a bunch of interesting task options we wish to expose for the tdd task:
-e, --js-env VALto choose the JS engine to be used as test bed;-n, --namespaces NSto choose the test namespace to run test in-O, --optimizations LEVELto run the tests with different CLJS optimizations (i.e.none,whitespace,simpleandadvanced;o, --out-file VALto choose the name of the JS file generated by the CLJS compiler.
Test task options
Following is the test task's help:
boot test -h
Run clojure.test tests in a pod. Throws on test errors or failures.
The --namespaces option specifies the namespaces to test. The default is to
run tests in all namespaces found in the project.
The --exclusions option specifies the namespaces to exclude from testing.
The --filters option specifies Clojure expressions that are evaluated with %
bound to a Var in a namespace under test. All must evaluate to true for a Var
to be considered for testing by clojure.test/test-vars.
The --junit-output-to option specifies the path to a directory relative to the
target directory where a junit xml file for each test namespace will be
generated by using the clojure.test.junit facility. When present it will make
the target to be synced even when there are test errors or failures
Options:
-h, --help Print this help info.
-c, --clojure VERSION VERSION sets the version of Clojure for testing.
-n, --namespaces NAMESPACE Conj NAMESPACE onto the set of namespace symbols to run tests in.
-e, --exclusions NAMESPACE Conj NAMESPACE onto the set of namespace symbols to be excluded from test.
-f, --filters EXPR Conj EXPR onto the set of expressions to use to filter namespaces.
-X, --exclude REGEX REGEX sets the filter for excluded namespaces.
-I, --include REGEX REGEX sets the filter for included namespaces.
-r, --requires REQUIRES Conj REQUIRES onto extra namespaces to pre-load into the pool of test pods for speed.
-s, --shutdown FN Conj FN onto functions to be called prior to pod shutdown
-S, --startup FN Conj FN onto functions to be called at pod startup
-j, --junit-output-to JUNITOUT JUNITOUT sets the directory where a junit formatted report will be generated for each ns.
For the tdd task, at the moment we are interested only in the -n, --namespaces NAMESPACE option. Note that the test task's
default behavior when you do not specify a namespace with the -n
option is different from that of the corresponding test-cljs task.
While test-cljs will run only namespaces containing
tests, test would run any project's namespaces, without filtering
out the ones not containing tests.
Task options list for tdd
To summarize, this is the list of the candidate options tasks we'd
like to expose for the tdd task as its options:
servetask options-p, --port PORT-k, --httpkit
watchtask options-v, --verbose
cljs-testtask options-e, --js-env VAL-n, --namespaces NS-O, --optimizations LEVEL-o, --out-file VAL
testtask options-n, --namespaces NAMESPACE
There are a total of 8 new task options to be exposed for the tdd task. Two of
them, namely -k and -v, are
flags (boolean options). You
recognize this kind of option from the fact that they do not have an
optarg like the others (e.g., PORT, VAL, NS, EXPR, etc.).
Dummy task again
Let's see how a boolean task option works by exploiting the dummy
task again.
(deftask dummy
"A dummy task"
[t dirs PATH #{str} ":source-paths"
v verbose bool "print which files have changed"
k httpkit bool "use httt-kit web server instead of jetty"]
*opts*)
Start the REPL
boot repl
...
boot.user=>
and evaluate few dummy calls:
boot.user=> (dummy)
{}
boot.user=> (dummy "-t" "test/cljc")
{:dirs #{"test/cljc"}}
boot.user=> (dummy "-t" "test/cljc" "-v")
{:dirs #{"test/cljc"}, :verbose true}
boot.user=> (dummy "-t" "test/cljc" "-v" "-k")
{:dirs #{"test/cljc"}, :verbose true, :httpkit true}
As you see, a boolean task option works like a switch. To proceed with the next step, stop the REPL.
Add flag task options to tdd
We are now ready to add the -k and -v options to the tdd task definition.
(deftask tdd
"Launch a customizable TDD Environment"
[t dirs PATH #{str} "test paths"
k httpkit bool "Use http-kit web server instead of jetty"
v verbose bool "Print which files have changed"]
(let [dirs (or dirs #{"test/cljc" "test/clj" "test/cljs"})]
(comp
(serve :handler 'modern-cljs.core/app
:resource-root "target"
:reload true
:httpkit httpkit)
(add-source-paths :dirs dirs)
(watch :verbose verbose)
(reload :ws-host "localhost")
(cljs-repl)
(test-cljs :out-file "main.js"
:js-env :phantom
:namespaces '#{modern-cljs.shopping.validators-test}
:update-fs? true)
(test :namespaces '#{modern-cljs.shopping.validators-test})
(target :dir #{"target"}))))
Note that this time we just passed down the boolean options as read
from the DSL machinery. This is because in a boolean context nil is
equivalent to false.
Let's now see -k and -v at work:
boot tdd -k -v
Retrieving http-kit-2.1.18.jar from https://clojars.org/repo/
...
Started HTTP Kit on http://localhost:3000
...
◉ :cp modern_cljs/login.cljs
◉ :cp modern_cljs/login.clj
◉ :cp shopping.html
◉ :cp modern_cljs/templates/shopping.clj
◉ :cp css/styles.css
◉ :cp modern_cljs/shopping/validators.cljc
◉ :cp index.html
◉ :cp adzerk/boot_reload.cljs
◉ :cp modern_cljs/core.clj
◉ :cp modern_cljs/login/validators.cljc
◉ :cp modern_cljs/shopping/validators_test.cljc
◉ :cp adzerk/boot_cljs_repl.cljs
◉ :cp modern_cljs/shopping.cljs
◉ :cp modern_cljs/remotes.clj
...
Running cljs tests...
Testing modern-cljs.shopping.validators-test
Ran 1 tests containing 13 assertions.
0 failures, 0 errors.
Testing modern-cljs.shopping.validators-test
Ran 1 tests containing 13 assertions.
0 failures, 0 errors.
Elapsed time: 27.700 sec
This is the first time we have explicitly used the http-kit asynchronous
web server and as you see the tdd task first downloaded and then
started it.
NOTE 3: Actually the
reloadtasks internally useshttp-kitto establish a websocket connection with the browser, but it uses an older release.
The -v option correctly instructed the watch subtask
to set its mode to verbose. If you now modify one of the watched
file (e.g., modern_cljs/shopping/validators_test.cljc), you'll see
the watch verbose option at work:
(deftest validate-shopping-form-test
(testing "Shopping Form Validation"
(testing "/ Happy Path"
(are [expected actual] (= expected actual)
nil (validate-shopping-form "" "0" "0" "0") ;; force a bug
...))
...))
◉ :cp modern_cljs/shopping/validators_test.cljc
...
Compiling ClojureScript...
WARNING: Replacing ClojureScript compiler option :main with automatically set value.
• main.js
Running cljs tests...
...
Elapsed time: 2.543 sec
Correct the forced bug and the watch subtask will trigger the
recompilation and rerun the tests.
(deftest validate-shopping-form-test
(testing "Shopping Form Validation"
(testing "/ Happy Path"
(are [expected actual] (= expected actual)
nil (validate-shopping-form "1" "0" "0" "0") ;; correct the bug
...))
...))
◉ :cp modern_cljs/shopping/validators_test.cljc
Writing suite.cljs...
Writing main.cljs.edn...
Compiling ClojureScript...
WARNING: Replacing ClojureScript compiler option :main with automatically set value.
• main.js
Running cljs tests...
Testing modern-cljs.shopping.validators-test
Ran 1 tests containing 13 assertions.
0 failures, 0 errors.
Testing modern-cljs.shopping.validators-test
Ran 1 tests containing 13 assertions.
0 failures, 0 errors.
Elapsed time: 2.130 sec
Now stop the boot process and rerun the tdd task without any
option set.
boot tdd
...
Started Jetty on http://localhost:3000
...
Elapsed time: 28.143 sec
Now we are again using jetty, and we don't have a
verbose report from the watch subtask. To proceed with the next
step, stop the boot process.
Add a web server port
The -p option is very easy to use as well.
(deftask tdd
"Launch a customizable TDD Environment"
[...
p port PORT int "The web server port to listen on (default: 3000)"]
(let [...]
(comp
(serve ...
:port port)
...)))
I will leave it to you to verify the working of the port option
(note the int type hint) when starting the tdd task. Before
proceeding to the next step, stop the boot process.
Add CLJS compilation options
As we saw in a previous paragraph, the cljs-test subtask has a
couple of compiler options we're interested in:
-o, --out-file VALto set name of the JS output file generated by the CLJS compiler-O, --optimizations LEVELto set the compiler optimization options (i.e.,none,whitespace,simpleandadvanced).
Another option is the -e, --js-env VAL option to choose the JS engine
to run the tests with. We'll leave the -n, --namespaces NS option to for
later, because this option is critical.
We're not going to explain the details of those options, because they work
like the previous ones. Here is the updated tdd task definition:
(deftask tdd
"Launch a customizable TDD Environment"
[e testbed ENGINE kw "The JS testbed engine (default phantom)"
k httpkit bool "Use http-kit web server (default jetty)"
o output-to NAME str "The JS output file name for test (default main.js)"
O optimizations LEVEL kw "The optimization level (default none)"
p port PORT int "The web server port to listen on (default 3000)"
t dirs PATH #{str} "Test paths (default test/clj test/cljs test/cljc)"
v verbose bool "Print which files have changed (default false)"]
(let [dirs (or dirs #{"test/cljc" "test/clj" "test/cljs"})
output-to (or output-to "main.js")
testbed (or testbed :phantom)]
(comp
(serve :handler 'modern-cljs.core/app
:resource-root "target"
:reload true
:httpkit httpkit
:port port)
(add-source-paths :dirs dirs)
(watch :verbose verbose)
(reload :ws-host "localhost")
(cljs-repl)
(test-cljs :out-file output-to
:js-env testbed
:namespaces '#{modern-cljs.shopping.validators-test}
:update-fs? true
:optimizations optimizations)
(test :namespaces '#{modern-cljs.shopping.validators-test})
(target :dir #{"target"}))))
Please note how we exploited the idiomatic way of using the let
and or forms to set default arguments for the dirs, output-to and
the testbed options.
You may now test the tdd task experiment with the various options
at the command line.
Below we call the tdd task without any options,
to demonstrate that we can still use the simplest form of the command.
boot tdd
...
Started Jetty on http://localhost:3000
Starting file watcher (CTRL-C to quit)...
...
Compiling ClojureScript...
WARNING: Replacing ClojureScript compiler option :main with automatically set value.
• main.js
Running cljs tests...
Testing modern-cljs.shopping.validators-test
Ran 1 tests containing 13 assertions.
0 failures, 0 errors.
Testing modern-cljs.shopping.validators-test
Ran 1 tests containing 13 assertions.
0 failures, 0 errors.
Elapsed time: 29.043 sec
Before proceeding to the next step, stop the boot process.
A short digression on CLJS compiler optimizations
We have not yet talked much about CLJS compiler
optimizations. Not long ago, the CLJS compiler optimization
management was cumbersome. The most annoying optimization option to use
was none, because it required us to explicitly link in
a bunch of Google Closure JS libs in the HTML pages. At the same time
the none optimization was also the fastest option for compiling CLJS
code into JS code, which was a bit contradictory.
Moreover, the source-map option was not available until recently.
Before that, debugging CLJS code by setting breakpoints in the
generated JS code quickly became a PITA.
These incidental complexities are now gone. You still have none,
whitespace, simple and advanced optimization modes available, but the CLJS
compiler is
now able to handle on its own
the addition of the needed Google Closure Libraries when you set the
optimization mode to none.
The source-map feature, which is activated by default with the
none optimization mode, can also be set for the other optimization modes.
As a result, debugging CLJS code using the
development tools available in your browser is now as simple as with plain JS code.
The best description of CLJS compiler optimization modes I have found
is the one available in the cljs help:
boot cljs -h
...
Available --optimization levels (default 'none'):
* none No optimizations. Bypass the Closure compiler completely.
* whitespace Remove comments, unnecessary whitespace, and punctuation.
* simple Whitespace + local variable and function parameter renaming.
* advanced Simple + aggressive renaming, inlining, dead code elimination.
Source maps can be enabled via the --source-map flag. This provides information the
browser needs to map locations in the compiled JavaScript to the corresponding
locations in the original ClojureScript source files.
...
The most intriguing compiler optimization mode (or level) is the
advanced one. Do you remember, in
Tutorial 6 - The Easy Made Complex and the Simple Made Easy,
we introduced the :export metadata to allow the init function to
be called from JS scripts in an HTML page? The reason for the
:export metadata (i.e., ^) was to protect the init function from
being aggressively renamed by the CLJS compiler when using the advanced
optimization mode.
The work performed by the Google Closure Compiler with
advanced optimizations is quite awesome by itself. Recently, a new
modules option
has been added, which breaks up the generated JS file into multiple small pieces,
so that Single Page Applications (SPA) are able to download only
the parts of the resulting JS code required by a given application.
We're not going to explain right now how this new modules option works, but
we'll come back to it in a later tutorial.
For the moment, we suggest that you use the -O flag to test
the various CLJS compiler optimizations for the tdd task:
boot tdd -O whitespace
...
Writing main.cljs.edn...
Compiling ClojureScript...
WARNING: Replacing ClojureScript compiler option :main with automatically set value.
• main.js
Running cljs tests...
Testing modern-cljs.shopping.validators-test
Ran 1 tests containing 13 assertions.
0 failures, 0 errors.
Testing modern-cljs.shopping.validators-test
Ran 1 tests containing 13 assertions.
0 failures, 0 errors.
Elapsed time: 34.868 sec
Visit the Shopping Form URL to
verify that the calculator is still working. Now open a new terminal
and look at the size of the main.js file in the target
directory:
# from a new terminal
cd /path/to/modern-cljs
ls -lah target/main.js
-rw-r--r-- 1 mimmo staff 2.1M Dec 15 00:27 target/main.js
More than 2M, including the cljs.test lib and the tests
themselves. Stop the boot process and restart it by setting the
advanced optimization option:
boot tdd -O advanced
...
Writing suite.cljs...
Writing main.cljs.edn...
Compiling ClojureScript...
WARNING: Replacing ClojureScript compiler option :main with automatically set value.
• main.js
Running cljs tests...
Testing modern-cljs.shopping.validators-test
Ran 1 tests containing 13 assertions.
0 failures, 0 errors.
Testing modern-cljs.shopping.validators-test
Ran 1 tests containing 13 assertions.
0 failures, 0 errors.
Elapsed time: 43.847 sec
Verify again that the
Shopping Form is still working
as expected. Now check the size of the main.js file generated
using advanced optimization:
ls -lah target/main.js
-rw-r--r-- 1 mimmo staff 387K Dec 15 00:34 target/main.js
Less the 400K, included the cljs.test lib and the tests themselves.
And we haven't even compressed the file via gzip yet. Not bad!
Before proceeding with the next step, stop the boot process.
Test Namespaces
We expressly left the -n, --namespace NAMESPACE option as the last
to be examined. This option occurs in both the test task, specific
to CLJ, and the test-cljs task, specific to CLJS.
Let's review the help text for both the test and the test-cljs tasks:
boot test -h
Run clojure.test tests in a pod.
The --namespaces option specifies the namespaces to test. The default is to
run tests in all namespaces found in the project.
...
Options:
-h, --help Print this help info.
-n, --namespaces NAMESPACE Conj NAMESPACE onto the set of namespace symbols to run tests in.
...
boot test-cljs -h
Run cljs.test tests via the engine of your choice.
The --namespaces option specifies the namespaces to test. The default is to
run tests in all namespaces found in the project.
Options:
...
-n, --namespaces NS Conj NS onto namespaces whose tests will be run. All tests will be run if
ommitted.
...
As we noted previously, if you do not specify one or more test namespaces,
their behavior is different, and is something we have to live with
until the test task is eventually updated to align with the test-cljs task.
Although their behavior is not exactly the same, the two tasks both conj the
optional test namespaces onto the original set of testing namespaces.
This is easily verified with the task option as follows:
(deftask tdd
"Launch a customizable TDD Environment"
[...
n namespaces NS #{sym} "the set of namespace symbols to run tests in"]
(let [...]
(comp
...
(test-cljs ...
:namespaces namespaces)
(test :namespaces namespaces)
(target :dir #{"target"}))))
Let's see if this simple solution works by calling the tdd task with the
portable modern-cljs.shopping.validators-test test namespace and
then without the -n option:
boot tdd -n modern-cljs.shopping.validators-test
...
Compiling ClojureScript...
WARNING: Replacing ClojureScript compiler option :main with automatically set value.
• main.js
Running cljs tests...
Testing modern-cljs.shopping.validators-test
Ran 1 tests containing 13 assertions.
0 failures, 0 errors.
Testing modern-cljs.shopping.validators-test
Ran 1 tests containing 13 assertions.
0 failures, 0 errors.
Elapsed time: 31.878 sec
This test, aside form the time it takes for the very first CLJS
compilation, worked like a charm. Now stop the boot process and
restart it without passing to it any options:
boot tdd
Starting reload server on ws://localhost:50274
Writing boot_reload.cljs...
Writing boot_cljs_repl.cljs...
2015-12-23 15:56:34.780:INFO::clojure-agent-send-off-pool-0: Logging initialized @10971ms
2015-12-23 15:56:40.121:INFO:oejs.Server:clojure-agent-send-off-pool-0: jetty-9.2.10.v20150310
2015-12-23 15:56:40.165:INFO:oejs.ServerConnector:clojure-agent-send-off-pool-0: Started ServerConnector@6cd12d4{HTTP/1.1}{0.0.0.0:3000}
2015-12-23 15:56:40.167:INFO:oejs.Server:clojure-agent-send-off-pool-0: Started @16359ms
Started Jetty on http://localhost:3000
Starting file watcher (CTRL-C to quit)...
nREPL server started on port 50275 on host 127.0.0.1 - nrepl://127.0.0.1:50275
Writing clj_test/suite.cljs...
Writing main.cljs.edn...
Compiling ClojureScript...
WARNING: Replacing ClojureScript compiler option :main with automatically set value.
• main.js
Running cljs tests...
Testing modern-cljs.shopping.validators-test
Ran 1 tests containing 13 assertions.
0 failures, 0 errors.
Testing modern-cljs.core
Testing modern-cljs.login
Testing modern-cljs.login.validators
Testing modern-cljs.remotes
Testing modern-cljs.shopping.validators
Testing modern-cljs.shopping.validators-test
Testing modern-cljs.templates.shopping
Ran 1 tests containing 13 assertions.
0 failures, 0 errors.
Elapsed time: 33.700 sec
As you see, the different behavior of test-cljs and test when
called without specifying any namespace is now evident: the test
task is wasting time in running tests even in namespaces which do not
contain any test.
Let's now see what happens if you create a failing test in the
validators-test namespace as we've done before:
Writing clj_test/suite.cljs...
Writing main.cljs.edn...
Compiling ClojureScript...
WARNING: Replacing ClojureScript compiler option :main with automatically set value.
• main.js
Running cljs tests...
Testing modern-cljs.shopping.validators-test
FAIL in (validate-shopping-form-test) (:)
Shopping Form Validation / Happy Path
expected: (= nil (validate-shopping-form "" "0" "0" "0"))
actual: (not (= nil {:quantity ["Quantity can't be empty" "Quantity has to be an integer number" "Quantity can't be negative"]}))
Ran 1 tests containing 13 assertions.
1 failures, 0 errors.
Testing modern-cljs.core
Testing modern-cljs.login
Testing modern-cljs.login.validators
Testing modern-cljs.remotes
Testing modern-cljs.shopping.validators
Testing modern-cljs.shopping.validators-test
FAIL in (validate-shopping-form-test) (validators_test.cljc:9)
Shopping Form Validation / Happy Path
expected: nil
actual: {:quantity
["Quantity can't be empty"
"Quantity has to be an integer number"
"Quantity can't be negative"]}
diff: + {:quantity
["Quantity can't be empty"
"Quantity has to be an integer number"
"Quantity can't be negative"]}
Testing modern-cljs.templates.shopping
Ran 1 tests containing 13 assertions.
1 failures, 0 errors.
clojure.lang.ExceptionInfo: Some tests failed or errored
data: {:test 1, :pass 12, :fail 1, :error 0, :type :summary}
clojure.core/ex-info core.clj: 4593
adzerk.boot-test/eval548/fn/fn/fn boot_test.clj: 73
crisptrutski.boot-cljs-test/return-fileset/fn/fn boot_cljs_test.clj: 113
crisptrutski.boot-cljs-test/eval689/fn/fn/fn boot_cljs_test.clj: 98
adzerk.boot-cljs/eval267/fn/fn/fn boot_cljs.clj: 200
adzerk.boot-cljs/eval225/fn/fn/fn boot_cljs.clj: 134
crisptrutski.boot-cljs-test/eval660/fn/fn/fn boot_cljs_test.clj: 66
crisptrutski.boot-cljs-test/capture-fileset/fn/fn boot_cljs_test.clj: 106
adzerk.boot-cljs-repl/eval490/fn/fn/fn boot_cljs_repl.clj: 171
boot.task.built-in/fn/fn/fn/fn built_in.clj: 284
boot.task.built-in/fn/fn/fn/fn built_in.clj: 281
adzerk.boot-reload/eval390/fn/fn/fn/fn boot_reload.clj: 120
adzerk.boot-reload/eval390/fn/fn/fn boot_reload.clj: 119
boot.task.built-in/fn/fn/fn/fn/fn/fn built_in.clj: 233
boot.task.built-in/fn/fn/fn/fn/fn built_in.clj: 233
boot.task.built-in/fn/fn/fn/fn built_in.clj: 230
pandeiro.boot-http/eval313/fn/fn/fn boot_http.clj: 83
boot.core/run-tasks core.clj: 701
boot.core/boot/fn core.clj: 711
clojure.core/binding-conveyor-fn/fn core.clj: 1916
...
Elapsed time: 9.093 sec
We see the same behavior. The test-cljs worked as expected, while test
examined all of the project's namespaces.
Correct the failing test and you'll again see the same behavior. Now,
let's see if the current tdd configuration is able to
manage a new test namespace while it's running.
Create a new validators_test.cljc portable (i.e., .cljc) file in
the test/cljc/modern_cljs/login test directory. Define a very simple
unit test with a single assertion for the user-credential-errors
validator we defined in a
previous tutorial.
(ns modern-cljs.login.validators-test
(:require [modern-cljs.login.validators :refer [user-credential-errors]]
#?(:clj [clojure.test :refer [deftest are testing]]
:cljs [cljs.test :refer-macros [deftest are testing]])))
(deftest user-credential-errors-test
(testing "Login Form Validation"
(testing "/ Happy Path"
(are [expected actual] (= expected actual)
nil (user-credential-errors "me@me.com" "weak1")))))
As soon as you save the file the test machinery get triggered.
As you see, the test machinery behavior is still unacceptable from a
performance point of view, but at least the newly defined unit test
for the login form validator got seen and correctly evaluated by
both the CLJ and CLJS engines.
Give up?
I am not a TDD practitioner, but if I were, I would consider it
unacceptable to be forced to restart the development environment every
time I added a new unit test file. Unfortunately, this is what the
tdd task requires if we launch it by specifying the initial test
namespaces using the -n option. I would also find it unacceptable
waiting so long for test results, as currently occurs when the test
behavior of tdd is called without specifying any test namespaces to
run.
For the moment, we have to accept a tradeoff:
- Be explicit with the test namespaces to run by
specifying them on the command line with the
-n - Stop and restart the
tddtask when you need to add a new test namespace.
boot tdd -n modern-cljs.shopping.validators-test -n modern-cljs.login.validators-test
...
Compiling ClojureScript...
WARNING: Replacing ClojureScript compiler option :main with automatically set value.
• main.js
Running cljs tests...
Testing modern-cljs.login.validators-test
Testing modern-cljs.shopping.validators-test
Ran 2 tests containing 14 assertions.
0 failures, 0 errors.
Testing modern-cljs.login.validators-test
Testing modern-cljs.shopping.validators-test
Ran 2 tests containing 14 assertions.
0 failures, 0 errors.
Elapsed time: 24.895 sec
Add login test assertion
With boot still running, let's add more assertions to the only test
defined in the modern-cljs.login.validators-test namespace. Open the
corresponding file test/cljc/modern_cljs/login/validators_test.cljc
and add a few assertions to the user-credential-errors-test
function:
(ns modern-cljs.login.validators-test
(:require [modern-cljs.login.validators :refer [user-credential-errors]]
#?(:clj [clojure.test :refer [deftest are testing]]
:cljs [cljs.test :refer-macros [deftest are testing]])))
(deftest user-credential-errors-test
(testing "Login Form Validation"
(testing "/ Happy Path"
(are [expected actual] (= expected actual)
nil (user-credential-errors "me@me.com" "weak1")))
(testing "/ Email presence"
(are [expected actual] (= expected actual)
"Email can't be empty."
(first (:email (user-credential-errors "" "")))
"Email can't be empty."
(first (:email (user-credential-errors "" nil)))
"Email can't be empty."
(first (:email (user-credential-errors "" "weak1")))
"Email can't be empty."
(first (:email (user-credential-errors "" "weak")))
"Email can't be empty."
(first (:email (user-credential-errors nil "")))
"Email can't be empty."
(first (:email (user-credential-errors nil nil)))
"Email can't be empty."
(first (:email (user-credential-errors nil "weak1")))
"Email can't be empty."
(first (:email (user-credential-errors nil "weak")))))
(testing "/ Password presence"
(are [expected actual] (= expected actual)
"Password can't be empty."
(first (:password (user-credential-errors "" "")))
"Password can't be empty."
(first (:password (user-credential-errors nil "")))
"Password can't be empty."
(first (:password (user-credential-errors "me@me.com" "")))
"Password can't be empty."
(first (:password (user-credential-errors "me" "")))
"Password can't be empty."
(first (:password (user-credential-errors "" nil)))
"Password can't be empty."
(first (:password (user-credential-errors nil nil)))
"Password can't be empty."
(first (:password (user-credential-errors "me@me.com" nil)))
"Password can't be empty."
(first (:password (user-credential-errors "me" nil)))))
(testing "/ Email validity"
(are [expected actual] (= expected actual)
"The provided email is invalid."
(first (:email (user-credential-errors "me" "")))
"The provided email is invalid."
(first (:email (user-credential-errors "me.me" nil)))
"The provided email is invalid."
(first (:email (user-credential-errors "me@me" "weak")))
"The provided email is invalid."
(first (:email (user-credential-errors "me.me@me" "weak1")))))
(testing "/ Password validity"
(are [expected actual] (= expected actual)
"The provided password is invalid"
(first (:password (user-credential-errors nil "weak")))
"The provided password is invalid"
(first (:password (user-credential-errors "" "lessweak")))
"The provided password is invalid"
(first (:password (user-credential-errors nil "lessweak")))
"The provided password is invalid"
(first (:password (user-credential-errors nil "toolongforthat")))))))
When you save the file, tdd will recompile the file and rerun the
tests, counting 38 assertions in 2 tests each.
Specific CLJ test in a portable test namespace
Let's now add a CLJ-only test to the portable test-namespace; i.e. the test that an email address has a valid domain:
(ns modern-cljs.login.validators-test
(:require [modern-cljs.login.validators :as v :refer [user-credential-errors]]
#?( :clj [clojure.test :refer [deftest are testing]]
:cljs [cljs.test :refer-macros [deftest are testing]] )))
#?( :clj (deftest email-domain-errors-test
(testing "Email domain existence"
(are [expected actual] (= expected actual)
"The domain of the email doesn't exist."
(first (:email (v/email-domain-errors "me@googlenospam.com")))))))
In order to continue to share the modern-cljs.login.validators
namespace between CLJ and CLJS, we added the v alias. This way we
can call the email-domain-errors function which is defined in the
modern-cljs.login.validators portable namespace for CLJ only. Of
course, we also had to use the #? reader conditional since this test
only works on the JVM.
Here is the result in the running tdd task:
Writing suite.cljs...
Writing main.cljs.edn...
Compiling ClojureScript...
WARNING: Replacing ClojureScript compiler option :main with automatically set value.
• main.js
Running cljs tests...
Testing modern-cljs.login.validators-test
Testing modern-cljs.shopping.validators-test
Ran 2 tests containing 38 assertions.
0 failures, 0 errors.
Testing modern-cljs.login.validators-test
Testing modern-cljs.shopping.validators-test
Ran 3 tests containing 39 assertions.
0 failures, 0 errors.
Elapsed time: 4.024 sec
The CLJS test namespaces still have 2 tests containing 38 assertions, while the the CLJ test namespaces now have 3 tests containing 39 assertions.
To proceed to the next step, stop any related boot process.
Code clean up
We are almost done. There is one more thing I would like to do:
cleaning up the tdd code by introducing a global map for all the
defaults we used, and adding the above two test-namespaces as defaults
so that the tdd task can be called without specifying any command
line options.
Here is the reworked build.boot file:
(set-env!
:source-paths #{"src/clj" "src/cljs" "src/cljc"}
:resource-paths #{"html"}
:dependencies '[ [org.clojure/clojure "1.8.0"] ; add CLJ
[org.clojure/clojurescript "1.9.473"] ; add CLJS
[org.clojure/tools.nrepl "0.2.12"] ; needed by bREPL
[adzerk/boot-cljs "1.7.228-2"]
[adzerk/boot-test "1.2.0"]
[adzerk/boot-reload "0.5.1"]
[adzerk/boot-cljs-repl "0.3.0"] ; add bREPL
[com.cemerick/piggieback "0.2.1"] ; needed by bREPL
[compojure "1.5.2"] ; for routing
[crisptrutski/boot-cljs-test "0.2.1-SNAPSHOT"]
[enlive "1.1.6"]
[hiccups "0.3.0"]
[javax.servlet/javax.servlet-api "3.1.0"]
[pandeiro/boot-http "0.7.6"]
[weasel "0.7.0"] ; needed by bREPL
[org.clojars.magomimmo/domina "2.0.0-SNAPSHOT"]
[org.clojars.magomimmo/valip "0.4.0-SNAPSHOT"]
[org.clojars.magomimmo/shoreleave-remote-ring "0.3.3"]
[org.clojars.magomimmo/shoreleave-remote "0.3.1"] ] )
(require '[adzerk.boot-cljs :refer [cljs]]
'[adzerk.boot-reload :refer [reload]]
'[adzerk.boot-cljs-repl :refer [cljs-repl start-repl]]
'[adzerk.boot-test :refer [test]]
'[crisptrutski.boot-cljs-test :refer [test-cljs]]
'[pandeiro.boot-http :refer [serve]] )
(def defaults {:test-dirs #{"test/cljc" "test/clj" "test/cljs"}
:output-to "main.js"
:testbed :phantom
:namespaces '#{modern-cljs.shopping.validators-test
modern-cljs.login.validators-test}} )
(deftask add-source-paths
"Add paths to :source-paths environment variable"
[t dirs PATH #{str} ":source-paths"]
(merge-env! :source-paths dirs)
identity)
(deftask tdd
"Launch a customizable TDD Environment"
[e testbed ENGINE kw "the JS testbed engine (default phantom)"
k httpkit bool "Use http-kit web server (default jetty)"
n namespaces NS #{sym} "the set of namespace symbols to run tests in"
o output-to NAME str "the JS output file name for test (default main.js)"
O optimizations LEVEL kw "the optimization level (default none)"
p port PORT int "the web server port to listen on (default 3000)"
t dirs PATH #{str} "test paths (default test/clj test/cljs test/cljc)"
v verbose bool "Print which files have changed (default false)"]
(let [dirs (or dirs (:test-dirs defaults))
output-to (or output-to (:output-to defaults))
testbed (or testbed (:testbed defaults))
namespaces (or namespaces (:namespaces defaults))]
(comp
(serve :handler 'modern-cljs.core/app
:resource-root "target"
:reload true
:httpkit httpkit
:port port)
(add-source-paths :dirs dirs)
(watch :verbose verbose)
(reload :ws-host "localhost")
(cljs-repl)
(test-cljs :out-file output-to
:js-env testbed
:namespaces namespaces
:update-fs? true
:optimizations optimizations)
(test :namespaces namespaces)
(target :dir #{"target"}))))
(deftask dev
"Launch immediate feedback dev environment"
[]
(comp
(serve :handler 'modern-cljs.core/app ; ring handler
:resource-root "target" ; root classpath
:reload true) ; reload ns
(watch)
(reload)
(cljs-repl) ;; before cljs
(cljs)
(target :dir #{"target"})))
Note that we used a map to set the defaults for the various options:
(def defaults {:test-dirs #{"test/cljc" "test/clj" "test/cljs"}
:output-to "main.js"
:testbed :phantom
:namespaces '#{modern-cljs.shopping.validators-test
modern-cljs.login.validators-test}})
We also set default values using let and or:
(deftask tdd
"Launch a customizable TDD Environment"
[e testbed ENGINE kw "the JS testbed engine (default phantom)"
k httpkit bool "Use http-kit web server (default jetty)"
n namespaces NS #{sym} "the set of namespace symbols to run tests in"
o output-to NAME str "the JS output file name for test (default main.js)"
O optimizations LEVEL kw "the optimization level (default none)"
p port PORT int "the web server port to listen on (default 3000)"
t dirs PATH #{str} "test paths (default test/clj test/cljs test/cljc)"
v verbose bool "Print which files have changed (default false)"]
(let [dirs (or dirs (:test-dirs defaults))
output-to (or output-to (:output-to defaults))
testbed (or testbed (:testbed defaults))
namespaces (or namespaces (:namespaces defaults))]
(comp
(...)))
There is one last thing to consider. The time taken by the tdd task
to run the unit tests may be judged unacceptably long by a strict TDD
practitioner, since it requires more than 1-10 seconds. In the tdd
task, most of the time is spent in the test task to process
namespaces which don't contain any tests, and also internally in the
test-cljs task which starts a new instance of PhantomJS every time
it runs. We're not going to worry about solving these problems in
this tutorial, but we can remember these points for future
improvements.
That's it for now. Stop any boot related process and reset the git
branch.
git reset --hard
Next Step - Tutorial 17 - REPLing with Enlive
In the next tutorial we're going to integrate the validators for the Shopping Calculator into the corresponding WUI in such a way that the user will be notified with the corresponding help messages when they enter invalid values in the form.
Copyright © Mimmo Cosenza, 2012-16. Released under the Eclipse Public License, the same as Clojure.