Before reading this post, it might be helpful to check out my previous (or other) posts on the topic:
BONUS Want to see it all working? Check out the gitlab repo with a fully-built example
This post is serving as a part 3 of this series, exploring how I’ve started integrating close-to-DOM level testing in my components.
tldr; use vue-server-renderer
to easily (and quickly) test static component rendering in a just-outside-dom context. Any component that renders correctly on the client should at least render properly on the server (especially if you’re aiming for an isomorphic app).
“End-to-end”/“Acceptance” tests are the most important tests of any user-facing application, as far as I’m concerned. It doesn’t matter how many of your unit/integration tests pass, if someone can’t use your UI to make anything happen. Also, by virtue of a test being E2E, it necessarily tests the backend as well, in the ultimate black-box and true-to-life sense of the concept. The most important parts of your app should have at least one E2E test each (especially if that action is related to you getting paid).
The question of “how much to test” is also a very important consideration. Do you need to test that a library like jQuery is doing it’s job? Probably not. Do you need to test that VueJS is properly making reactivity changes you expect? Maybe (some might say definitely, some might say no).
vue-server-renderer
More indepth documentation can be found in the vue-server-renderer documentation, but here’s the code, roughly:
var renderer = require('vue-server-renderer').createRenderer();
...
// Render the component
renderer.renderToString(vm, function(err, html) {
if (err) throw err;
// Check generated HTML here
});
Benefits
Downsides
Wanting a bit of a closer approximation the browser context, I considered adding selenium or maybe phantom, or maybe even switching to another test runner like Karma but they all just seemed far too heavy. I wanted something that was ALMOST an actual browser DOM, but just enough to different that I didn’t have to deal with unwieldly APIs and a steep learning curve. jsdom fit the bill perfectly. Maybe I’ll revisit this in the future as karma seems to be industry standard (and I assume it’s so for a reason), but I just can’t take the time to try and grok all of what it does at the moment.
After this, the test looks somewhat like this (raw):
... inside the test...
// Render the component
renderer.renderToString(vm, function(err, html) {
if (err) throw err;
// Start up JSDOM and check the element
jsdom.env(html, function(err, window) {
if (err) throw err;
// Get element
var elem = window.document.getElementsByClassName("alert-notification-component")[0];
var content = elem.textContent.trim();
content.should.not.be.empty();
content.should.containEql('×'); // × in the template
content.should.containEql(TEST_ALERT_ERROR.message);
done();
});
});
As you can see, this really just takes the HTML that was generated by the vue-server-renderer
and puts it on a jsdom
-managed page. Then I can use DOM interfaces like window
and document
to do testing that resembles what a in-browser test would look like.
After some refactoring, the whole tests ends up looking like:
describe("rendering", function() {
it("properly renders a single alert", (done) => {
TestUtil.renderComponentWithData(Vue, Component, INIT_FIXTURE_TEST_ERROR)
.then(TestUtil.getComponentElementByClass("alert-notification-component"))
.then(e => {
var content = e.textContent.trim();
content.should.not.be.empty();
content.should.containEql('×'); // × in the component for closing the notification
content.should.containEql(INIT_FIXTURE_TEST_ERROR.propsData.alert.message);
done();
})
.catch(done);
});
});
To briefly explain the refactoring that has happened, I’ve added a method that does all the requisite setup, and returns a promise that resolves to the window object provided by JSPM. I’ve also added a helper function getComponentElementByClass
which takes the window object, gets the element specified, and returns JUST the element.
The rest of the test looks identical to what was there before.
Again, this approach (vue-server-renderer
+ jsdom
) seemed to only be cable of doing very basic DOM testing, thisgs like clicking buttons, etc didn’t work properly, for example. It also added quite a bit of runtime to each test, roughly 500ms.
At this point, I looked again at Karma, just to make sure this complexity was still something I could stomach vs just jumping in and learning Karma. Once again I felt that Karma was still too heavy and just not worth the cost for me yet. I felt that I had wrangled the present complexity well enough that Karma would still be adding significantly more overhead. There was also this significant feeling that all the pieces were right in front of me, all I needed to do was to put them together in the right way.
In particular, when hacking around jsdom
, I tried:
jsdom
with the src
optionNOTE - This problem was exacerbated by the fact that my modules are written in ES2016, this meant I NEEDED transpilation, even for in-memory builds (can’t just load the code with nodejs
’s require()
).
After lots of twiddling and hacking, I realized that using jspm
to build an in-memory bundle and load THAT on to the page was a great way to test every component – it made sure their dependencies were satisfied (as is the job of jspm), and made it easy for me to actually load a html fragment that USED what I knew was being imported.
After looking through the jspm documentation for how to do something like an in-memory bundle as well as the systemjs documentation on builders, I hit on a winning process. It goes something like this:
jsdom.env
NOTE #6 This is more specific to how I have the components set up, you can technically define components as JUST the object and register them later, you don’t necessarily need to import “vue” when defining components
NOTE Note that vue doesn’t respond to just <HTMLElement>.click()
function. You need to dispatch a mouse event from the correct component (see code).
Here’s the code, before refactoring:
describe("interaction", function() {
this.timeout(10000);
it("should $emit a destroy event when the x is clicked", function(done) {
// Get an instance of the JSPM builder, get a source for the component
var builder = new JSPM.Builder();
// Build the minimal bundle for the component
builder.buildStatic(COMPONENT_JS_PATH, { minify: true, globalName: 'Module'})
.then(output => {
// Render the element completely: Load SystemJS, use it to load vue and the component
jsdom.env({
html: '',
src: [output.source],
done: function(err, window) {
if (err) throw err;
// Grab the loaded element, component, and vue fn
var vueFn = window.Module.default.vue;
var comp = vueFn.extend(window.Module.default.component);
// Attempt to manually mount component into the element
var compInstance = new comp(INIT_FIXTURE_TEST_ERROR);
// Create off-page element
var vm = compInstance.$mount();
var elem = vm.$el;
// Watch for destroy to be called
vm.$on('destroy', function(alert, alertIdx) {
// ensure that the $emit-ed destroy contains the alert and alert ID we expect
alert.should.be.deepEqual(INIT_FIXTURE_TEST_ERROR.propsData.alert);
alertIdx.should.be.eql(INIT_FIXTURE_TEST_ERROR.propsData.alertIdx);
done();
});
// Click on the X, ensure it triggers a destroy,
elem.firstChild.dispatchEvent(
new window.MouseEvent("click", { bubbles: true, cancelable: true, view: elem })
);
}
});
})
.catch(done);
});
});
After some refactoring:
describe("interaction", function() {
this.timeout(10000);
it("should $emit a destroy event when the x is clicked", function(done) {
// Render module in JSDOM
TestUtil.renderModuleInJSDOM(COMPONENT_JS_PATH, "Component")
.then(TestUtil.instantiateComponentFromLoadedGlobal("Component", INIT_FIXTURE_TEST_ERROR))
.then(elemInfo => {
// Watch for destroy to be called
elemInfo.vm.$on('destroy', function(alert, alertIdx) {
// ensure that the $emit-ed destroy contains the alert and alert ID we expect
alert.should.be.deepEqual(INIT_FIXTURE_TEST_ERROR.propsData.alert);
alertIdx.should.be.eql(INIT_FIXTURE_TEST_ERROR.propsData.alertIdx);
done();
});
// Click on the X, ensure it triggers a destroy,
TestUtil.jsdomClick(elemInfo.elem.firstChild, elemInfo.window);
})
.catch(done);
});
});
As you can see, I’ve factored out the tedious logic into some readable functions that return promises and make it easier to isolate just the important parts of the tests.
So this approach works great, and gets me very close to full-featured browser testing with less cost (in complexity, and runtime, JSDOM is quite bit faster).
The issues however, seems to be with mocha
. The following things perplexed me:
setup()
block seemed to be global, not tied to files, and didn’t run before describes at the same level (only it
). This made my otherwise very readable test suite awkward.
--delay
, but this felt like just a hackmocha
create an invisible root suite? That smells like bad design to me.I was perplexed at these things, and I think the relative ease of mocha
everywhere else actually compounded my frustration… mocha
is such a simple tool that normally does the right thing, why is it so frustratingly difficult about this one point?
I started looking around, and of course, I found an even simpler library, tape
. It was introduced to me by way of a blog post explaining someone else’s switch from mocha.
The only obvious problem with tape
right off the bat was that the way to extend it is to wrap it (there is no plugin system built into tape, and one will not be added, because of complexity). Of course, extending things in this way makes for shitty (nonexistent) composability. I raised an issue, which was responded to faily quickly, and was reassured that part of tapes value was this simplicity, which is why this wasn’t being pursued.
Hopefully someone someday (me? maybe) might take on the complexity of creating composable wrappers for tape, so someone can wrap tape in more than one way (my issue was the inability to wrap more than one functionality into tape at a time, quality of wrapper code aside).