Jest and CLJS. I always wondered why I never saw anyone using Jest with CLJS. As ClojureScript developers we lean heavily on React and the React ecosystem. Why not Jest?
Install deps
yarn
Build Dependencies
yarn webpack
will create a
./dist/index_test_bundle.js
file in your root directory
Compile tests
clj -A:cljs-tests
Run tests
yarn test
Jest globals are not available automatically
When writing tests in Jest, you are provided many global functions like test
, expect
etc. To write this in ClojureScript, we have to use some JS interop. This means that in order to access any of the jest globals, we have to prefix them with js
. For example, if we want to use expect
we would write, in our ClojureScript, js/expect
.
Jest is going to replace CLJ test library
This is not so much an issue, as a point of clarification. Clojure provides an awesome test library. Its simple, small and works. With this in mind, by going with Jest, at least to my knowledge at this point, we would move over whole sale, for the front end related code, to Jest.
Jest won't recognize how the compiler names files
Out of the box, Jest expects that the files it receives be formatted as module.test.js
. However, base on how we write CLJ/S files in closure, our files will be outputted as module_test.js
. In order to get Jest to recognize our files, we have to update the jest config. We do this in the package.json
. See the property called testMatch
in our package.json
Teaching jest how to load google closure libraries
There are several ways to get this two work, the two I explored ranged from easy to really have to build a proper mental model:
Solution 1: Run :simple
optimizations which puts everything into one test and now we don't have to worry about anything. The downside is I am wondering if there are performance implications. We would have to test this 1:1 with the JS version and see what happens.
Solution 2: Actually overwrite and import in a Node friendly way
The first thing is we have to make goog
available everywhere. The second thing is that google closure
's module system (goog.provide
, goog.require
etc) has an initial assumption which should be considered when trying to get this part to work: that its running in an HTML file. What this means, and I am skirting around some other stuff to keep this brief, is that when a file has a goog.require
line in it, closure is going to try to write a script to the HTML. This script will then import the contents of the required file.
The resolve the first item, we do this by loading, and evaluating, the contents of base.js
into the current runtime. Now that we have goog
available, we still run into a problem with the module system. Yet, its not where you initially think it would be. goog.require
and goog.provide
work. However, if you try to access something you required, it won't be available. This is because, as noted above, the native behaviour of the require is to write a script.
In order to make it work, we have to overwrite some of the variables in google closure. Specifically, we are going to overwrite CLOSURE_IMPORT_SCRIPT
and CLOSURE_BASE_PATH
. This will make it so when we require
something, its not going to try to write a script to the DOM, its going to use Nodes require and put all the JS we need into our context.
Note that you do not have to set the
CLOSURE_BASE_PATH
var. You could just prefix a relative path in front ofsrc
in the require. However, this is cleaner asCLOSURE_BASE_PATH
is used inbase.js
to build thesrc
we use inCLOSURE_IMPORT_SCRIPT
. With this said, keep in mind that if you are setting this on a different project and your paths are not the same as mine, the rule of thumb is thatCLOSURE_BASE_PATH
has to eventually lead to wherebase.js
lives.
Runtime speed
The first time I ran jest + cljs, I noticed that the time to run was much longer than I remembered when just using Jest and vanilla JS. So I put together another demo to just see vanilla jest run speed. The difference in first run speeds: 1.69s.
(vanilla) v. 13.25s
(cljs jest). The second run speeds however are dramatically improved at 1.50s
(vanilla) v.2.09s
(cljs jest).
My theory is that the reason for this happening is because of the fact that we are loading cljs.core
and google.closure.library
. To test this, I increase the compiler level to :simple
in the test.cljs.edn
file. This will create a file in target/public/cljs-out
called test-main.js
and removes whitespace and shortens local variable names
(2100 line file). The result will be all of your JS in one file vs. spread across multiple files. We also have to update the yarn test
script to execute the test-main.js
file instead of our other file and also add in "**/*+(-main).js"
so Jest knows how to find the test-main.js
file.
Once the above is done, we can run yarn test
and we find that our tests run at 6.50s
(cljs jest + :simple
) v. 13.25s
(cljs jest + :none
) for cold start and the second start is now down to 1.75s
. Right on. Could we save more time?
The answer is yes. We can run the compiler with :advanced
(21 loc) and we can get jest to run initially at 1.75s
and then each subsequent run will be around 1.50s
. The issue with this one is that it seems that the google closure compiler is renaming .toBe
to h
, so I had to manually change this, but in truth, I doubt anyone is going to need to run this in advanced mode and just knowing this can work and the time savings are available to us is fine for now.
I am not saying that CLJS compiled JS is faster here, I am just noting that there are a lot of libraries and extra code that come with it, but there are ways to improve the performance. For local development, running things with :none
is fine. However, if you are running for a CI/CD flow, we might run with :simple
to get some speed improvements? No idea. Just food for thought.
The following are setup steps that I included in the event someone wants to see how I came to the current implementation. You don't need to run through these yourself, its mostly as a reminder for myself.
Init package.json
yarn init -y
Add jest deps
yarn add --dev jest
If we start with the getting started section of Jest start by converting the Jest test to CLJS:
demo.utils
;; js
function sum(a, b) {
return a + b;
}
;; cljs
(defn sum [a b]
(+ a b))
demo.utils_test
;; js
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3);
});
;; cljs
(js/test
"Adds 1 + 2 to equal 3"
(.. (js/expect (utils/sum 1 2)) (js/toBe 3)))
Notice we are using the
js/
namespace, these are globals so it must be done
Before we can run jest against our tests, we have to compile our clojurescript. To make things easier we will use figwheel.main
create a figwheel build
see test.cljs.dev
create a compile-tests alias in deps.edn
;; ...
:aliases {:cljs-tests {:main-opts ["-m" "figwheel.main" "--build test"]}}
tells figwheel to use the
test.cljs.dev
build we identified above
Run figwheel
clj -A:cljs-tests
Run jest
yarn test
In the getting started section we only had one file with tests. This means that our yarn test
command was simple. We just told it to run the one file we had. However, as your project scales you are going to want to tell it how to run more than just one file. This section will explain how to scale to more than one file in the same dir. So lets update our package.json
npm test
script to look like this:
jest --verbose target/public/cljs-out/test/demo/*
In order to reproduce the minimal react snapshot test as outlined in the Jest documentation you need to perform the following setup:
Install dependencies
yarn add -D react react-dom create-react-class react-test-renderer
Install webpack
yarn webpack
Configure test.cljs.edn
:npm-deps false
:infer-externs true
:foreign-libs [{:file "dist/index_test_bundle.js"
:provides ["react"
"react-dom"
"create-react-class"
"renderer"]
:global-exports {react React
react-dom ReactDOM
create-react-class createReactClass
renderer renderer}}]}
Create your externs bundle
import React from "react";
import ReactDom from "react-dom";
import createReactClass from "create-react-class";
import renderer from "react-test-renderer";
window.React = React;
window.ReactDOM = ReactDom;
window.createReactClass = createReactClass;
window.renderer = renderer;
Compile your webpack externs bundle
yarn webpack
The javascript version of Jest's example snapshot test looks like this:
import React from "react";
import Link from "../Link.react";
import renderer from "react-test-renderer";
it("renders correctly", () => {
const tree = renderer
.create(<Link page="http://www.facebook.com">Facebook</Link>)
.toJSON();
expect(tree).toMatchSnapshot();
});
Convert the above into ClojureScript:
(ns demo.component-test
(:require [demo.component :as component]
[reagent.core :as r]
[renderer]))
(js/it
"Render correctly"
(fn []
(let [button [component/button
{:class "test-class"
:type "button"}]
tree (.. renderer (create (r/as-element button)) (toJSON))]
(.. (js/expect tree) (toMatchSnapshot)))))
And now we can run our tests
yarn test
This section is going to review a bunch of the problems I faced when working through the above:
which version of React?
When you use the webpack externs bundle in tests Reagent will also be using the same version as in your externs bundle. The reason to mention this is because if you happen to be writing your main application without an externs bundle you may be on a different version then the one you are testing with in Jest. So how does this work?
Webpack externs will create a global instance of React
. This version is going to be picked up by Reagent. So how can you verify this? One way:
(js/console.log "After checks")
(js/console.log (.. js/React -version))
(js/console.log "Before test checks")
(js/console.log (.. js/reagent -impl -template -global$module$react))
All I am saying is be careful to take note of what you are using because if you are using an older version of Reagent then you are testing you can and will get some weird inconsistencies.
Why do I need to install react
and friends?
react-test-renderer
references a global React
namespace (along with the other 3 libraries we installed in step 1). If you do not include these, react-test-renderer
will not work. Is this a problem? Generally, no. Unless of course the question above is an issue then this could be a problem.
Do I have to manually build my externs in test.cljs.edn
?
No. Figwheel can dynamically generate this for you if you add the following to the meta information section of your test.cljs.edn
file
:npm {:bundles {"dist/index_test_bundle.js" "src/js/index.js"}}}
For more information see the official figwheel npm setup guide
I am seeing weird errors about call of undefined?
Assuming you got Jest working on its own, these are likely Reagent
or react-test-renderer
not being back to find their dependencies. To resolve see step 1 and 2 and carefully read the messages.
Stale tests failing?
sometimes when you change a files name, stale tests can be left behind and jest will try to test them anyways. In this case, just clear the target
directory and trying compiling from scratch. The important takeaway is that this is not Jest failing. This is an issue with compilation.
Capitalization / spelling of externs
Don't capitalize React. It will not be found. This is another reason for defining your own externs in test.cljs.edn
. You want to control the names used.
The following message happens in jest when you trying rendering a reagent component incorrectly.
But there is more wrong with this. we don't see the react message inline. Lets make this a developer version of react.
reason for this is when you pass reagent component -> react component
snapshots will be stored beside the tests
the issue that that target dir is getting rewritten all the time so syou get a message like:
1 snapshot obsolete.
The good news is that this should be resolved in jest 24
I would lean on Jest for front end specific testing