Lightning-Fast Testing For ClojureScript React Components
by Danny Bell
Here's a riddle: where should you unit test your React components?
The browser is an OK choice. It's probably "easy," in that you are already writing browser-targeted code. But for unit tests that are going to be run thousands of times, you lose a lot of cycles moving pixels around.
Well, alright, you could run them on NodeJS. React has a TestRenderer utility letting you render a self-contained component tree, independent of any DOM. Here is where we run into a problem: all the ClojureScript wrappers around React watch atoms, and they only set up their watches when mounted onto a DOM. TestRenderer is convenient, but a DOM it ain't. So we have nowhere to mount (and then test) our components.
So we don't want to pay for a DOM, but...we need one.
Enter jsdom, a NodeJS implementation of the DOM. jsdom differs from Phantom or Slimer in that it doesn't "render" (at least, not in the layout sense. In the *React* sense, it updates just fine). You can take screenshots with Phantom, but this will be sleeker.
A Bridge Too Far
Before we begin, let's consider what we're contemplating here. A minute ago, we were humble ClojureScript developers, churning out interactions with Reagent and API calls. Now we're talking about:
- switching from our normal browser mindset to NodeJS
- pulling in a NodeJS library
- making a build for our tests that can run in jsdom
- injecting our tests into jsdom's sandbox
- and it would be nice if we could get everything running automatically
The first order of business is setting up a mock project:
Next we'll want to make some basic components to test. Make a new file browserless-test/src/cljs/browserless-test/components.cljs:
Great, it's practically a todo app! On to testing.
Let's take a breather and survey. We are building a bridge from our source, to our tests, to our build system, to NodeJS, to jsdom. What should we work on first?
We are Clojure developers. We like REPLs. So NodeJS it is.
If we don't already have them: brew install node/npm (apt-get install node/npm on Linux)
We can now install jsdom. Create a package.json in the main directory:
We could have included jsdom and some version number in the dependencies, but why settle for less than the best? Grab the latest version of the library:
(The --save-dev option saves the downloaded version metadata to package.json)
Trouble is, we don't have any code to run yet! Wasn't there something about tests?
We are close to joining the two ends of the chain. The next step from our code-runner is the code-builder, or, our build tools.
Leiningen and project.clj
We're going to want a separate :test build in our project.clj, otherwise we'd have our tests in the production build. It would also be nice if our :test build compiled to one file to make it easy to inject.
The relevant portion of project.clj:
This is a good place to mention James Leonis's excellent treatment of :cljsbuild in project.clj. Here are the takeaways for our project, in order of keys:
- The compiler will look in the same directories as other builds, but also "test/cljs"
- The output file will begin execution in the browserless-test.components-test-runner namespace (we need to write that)
- The output file will be placed in target/cljsbuild/dom-test/test.js, relative to the project directory. This is the path we should place in our loadAndAppend function in our NodeJS script.
- The output-dir...well, what is the output-dir? Didn't we just set a destination for our output? output-dir is just a directory the compiler uses for extra files. The gotcha here is that it must be different from other builds.
- :optimizations are set to simple. This is passed to the Closure compiler, and will combine all compiled js into one file. This is very important because our "way in" to running code in jsdom's DOM is through direct text injection: We're literally setting the contents of a <script> tag. The alternative—a bunch of dependency-ordered calls to the "server" for different .js files—would be much harder to marshal.
Did you notice anything missing? We have no :target :nodejs entry, even though test.js will be running on NodeJS. jsdom has a sandbox to emulate the browser, so our tests target the browser.
We are now in a position to write some tests, which will be compiled, injected, and run. We promised Leiningen that we'd start something off from test/cljs/browserless_test/components_test_runner.cljs, so let's open that up:
It looks...like a normal test file. In the interest of space, we won't say much about the actual tests. Instead, let's try something interesting.
If we create a dummy .html file in our project's root directory:
and then go to our project.clj and change the output-to value of our :js-dom-test build:
Once we restart any running lein autocompile to adjust to the new entry, we can navigate to the appropriate file:///home/.../browserless-test/index.html URL and see our tests rendered in front of our eyes. A natural workflow suggests itself: develop tests with the aid of the browser then run them headlessly. We can enhance this with tools like figwheel or devcards, but that's beyond the scope of this article.
There remains but one more piece: to get our tests running on every compile.
cljsbuild has a nifty build option called :notify-command. Given a shell command (as a vector of strings, shown below), cljsbuild will call it after every compile, successful or not.
Replace the last line in node-unit-tests.js with this:
Restart lein cljsbuild auto js-dom-test, and you're off to the races.
Danny Bell always wanted to use the Force, but he settled for Clojure instead. He's worked for multiple startups in online video, enterprise systems management, distance education, insurance, financial modeling, credit, and bespoke monitoring.