DEMO ClojureScript + Jest
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?
Quickstart
-
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
TODO
- runtime speed with more advanced compiler settings
- snapshot example
- execute multiple test files in same dir
- medium complexity reagent example
- async example
- run tests as extra mains (watch) - figwheel.main
- chain yarn tests to figwheel compilation
- execute all tests in the test dir
- example of running specific tests by file or name
- Structuring tests
- Clojure assertions
- test performance
- compilation errors
- figwheel does not always return informative messages: incorrectly import something
- Make react included the developer version
- Improve naming conventions for webpack and where the folders/files are located
- Troubleshooting externs
- ability to add externs without losing reagent losing its internal react reference
- move snapshot tests outside of target directory (Jest 24)
Issues
-
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 withjs
. For example, if we want to useexpect
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 asmodule_test.js
. In order to get Jest to recognize our files, we have to update the jest config. We do this in thepackage.json
. See the property calledtestMatch
in ourpackage.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 thatgoogle 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 agoog.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 havegoog
available, we still run into a problem with the module system. Yet, its not where you initially think it would be.goog.require
andgoog.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
andCLOSURE_BASE_PATH
. This will make it so when werequire
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 at1.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
andgoogle.closure.library
. To test this, I increase the compiler level to:simple
in thetest.cljs.edn
file. This will create a file intarget/public/cljs-out
calledtest-main.js
andremoves 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 theyarn test
script to execute thetest-main.js
file instead of our other file and also add in"**/*+(-main).js"
so Jest knows how to find thetest-main.js
file.Once the above is done, we can run
yarn test
and we find that our tests run at6.50s
(cljs jest +:simple
) v.13.25s
(cljs jest +:none
) for cold start and the second start is now down to1.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 at1.75s
and then each subsequent run will be around1.50s
. The issue with this one is that it seems that the google closure compiler is renaming.toBe
toh
, 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.
Breakdown
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.
Basic Jest Setup
-
Init package.json
yarn init -y
-
Add jest deps
yarn add --dev jest
Getting Started
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
Multiple Tests
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/*
Snapshot Testing
In order to reproduce the minimal react snapshot test as outlined in the Jest documentation you need to perform the following setup:
- Install test dependencies
- Setup webpack externs bundle
Step 1 Install Test Dependencies
-
Install dependencies
yarn add -D react react-dom create-react-class react-test-renderer
Step 2 Setup webpack externs bundle
-
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
Step 3 Write your react component test
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
Gotchas
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 globalReact
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
orreact-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
When to use Jest
I would lean on Jest for front end specific testing
- When we want to test our reagent components - do they render? does their behaviour work as expected?
- When we want to test screen integration tests