Two Patterns for Unit Testing Vue Apps

Feb 5, 2020

At Mastering JS, we do our best to test every example in all of our tutorials. That way we can be confident that our content is up to date when major releases happen, or when we decide to rewrite a tutorial. That means we need to automatically test all our Vue examples as well. In general, there are two patterns we use for unit testing our code examples:

With Server-Side Rendering in Node.js

When unit testing, you first need to define what you consider a "unit." There's some debate as to what is a unit when it comes to writing Vue code: do individual methods count as units? How about computed properties? We at Mastering JS tend to err on the side of testing code closer to how the end user will interact with it, so we consider a Vue component a unit.

Vue enjoys excellent support for Node.js and server-side rendering. Unlike some other frameworks, instantiating a Vue component in Node.js doesn't require any outside libraries or special customization. Just call new Vue() and you get a Vue instance.

const Vue = require('vue');
const { renderToString } = require('vue-server-renderer').createRenderer();

const app = new Vue({
  data: () => ({ count: 0 }),
  methods: {
    increment: function() { ++this.count; }
  },
  template: `
    <div>
      <div id="clicks">Clicks: {{count}}</div>
      <button v-on:click="increment()">Increment</button>
    </div>
  `
});

let res = await renderToString(app);
assert.ok(res.includes('Clicks: 0'));

// `app` is reactive in Node
app.count = 2;
res = await renderToString(app);
assert.ok(res.includes('Clicks: 2'));

// You can also call methods in Node
app.$options.methods.increment.call(app);
res = await renderToString(app);
assert.ok(res.includes('Clicks: 3'));

The benefit of using Node.js for unit tests is minimal setup and overhead. The only outside libraries you need are a testing framework like Mocha and vue-server-renderer. You can also do a surprising amount with Vue in Node.js: you can $emit events, change data, call methods, trigger lifecycle hooks, etc.

What you can't do with Node.js is interact with actual DOM elements, unless you use another outside library. In the above example, you can call the method that v-on:click triggers, but you can't actually trigger a click event.

With Scaffolding in Puppeteer

Testing Vue apps with Puppeteer is another alternative. The benefit of using Puppeteer is that you get a fully fledged browser to work with. You can interact with your component using vanilla JavaScript APIs like click() and document.querySelector().

The key idea behind how we test Vue with Puppeteer is Puppeteer's setContent() function. If you can bundle everything your component needs, you can put that JavaScript into a minimal HTML page, and put it in Puppeteer.

const puppeteer = require('puppeteer');

// Since your Vue app is running in a real browser, you would need
// webpack or browserify to build a bundle if you use `require()`
const createComponent = function() {
  return new Vue({
    data: () => ({ count: 0 }),
    methods: {
      increment: function() { ++this.count; }
    },
    template: `
      <div>
        <div id="clicks">Clicks: {{count}}</div>
        <button v-on:click="increment()">Increment</button>
      </div>
    `
  });
};

const js = createComponent.toString();
const htmlScaffold = `
  <html>
    <body>
      <script src="https://unpkg.com/vue/dist/vue.js"></script>

      <div id="content"></div>

      <script type="text/javascript">
        const app = (${js})();
        app.$mount('#content');
      </script>
    </body>
  </html>
`;

// Launch a new browser and make it render the above HTML.
// You can set `headless: false` to interact with the real browser.
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
await page.setContent(htmlScaffold);

// Interact with the component via `evaluate()`
let content = await page.evaluate(() => {
  return document.querySelector('#clicks').innerHTML.trim()
});
assert.equal(content, 'Clicks: 0');

await page.evaluate(() => document.querySelector('button').click());

content = await page.evaluate(() => {
  return document.querySelector('#clicks').innerHTML.trim()
});
assert.equal(content, 'Clicks: 1');

// Clean up
await browser.close();

Testing with Puppeteer is great because you're testing in a real browser, which means this test is as realistic as you can get without pointing and clicking yourself. Also, if you disable headless mode, you can actually watch the test run.

The downside of testing with Puppeteer is that you need to handle bundling on your own. The above example doesn't need to bundle because it doesn't use require(), but you would need to use Webpack or Browserify if your code uses require(). Even if you use ES6 imports, getting scaffolding right can be tricky.

Conclusion

Vue makes it easy to test components in isolation using Node.js or Puppeteer. Unit testing with Node.js is easier because it requires less setup, but you can't test real browser interactions. On the other hand, testing with Puppeteer requires more scaffolding, but makes your tests more realistic.


More Vue Tutorials