/ #VueJS #testing 

From AngularJS to Vue.js, CommonJS and Jest

The trials and tribulations of kicking off an AngularJS -> Vue.js migration

AngularJS was pretty groundbreaking. It’s still impressive to this day, packed with a router, an HTTP client, a dependency injection system and a bunch of other things I haven’t necessarily had the pleasure of dealing with. It also wraps most browser APIs as injectable services, that’s pretty cool. The downside is that it’s complicated: services, filters, injectors, directives, controllers, apps, components.

With the tools we have today, AngularJS isn’t as strong a way to do things any more. The fact that it’s in maintenance mode says it all. For a new application or new features, ie. for a project that not in maintenance mode, it’s doesn’t have the same niceties as other frameworks and libraries like Angular 2+, React or Vue. There should be a way not to rewrite the whole AngularJS application at once, here’s how I went about doing it:

Shipping some bundles 📦

Bundling the AngularJS app allows you to send a few files with everything needed to run the single page application. Includes using script are reduced to a few bundles of JavaScript (depending on having eg. a vendor bundle) and possibly a few of CSS.

Modernising the codebase using ES6 and beyond becomes possible with a transpilation step, and some bundlers even allow for loading of non-JS files into the JavaScript bundle which means templates and even assets can be sent down in the same payload.

Loading of JavaScript functionality not tied to browser APIs in a test environment using Node (+ JSDOM) becomes possible, giving you the ability to leverage tools such as Jest, AVA or Mocha (instead of Jasmine + Karma or even Protractor).

This means the controllers look more like the following:

const angular = require('angular');
function homeController(
  $location,
  otherService
) {
  const ctrl = this;
  // attach methods to ctrl
  return ctrl;
}
angular.module('myApp')
.controller('HomeController', [
  '$location',
  'otherService',
  homeController
]);
module.exports = {
  homeController
};

The above snippet leverages CommonJS which is Node’s default module system, its hallmarks are the use of require() and module.exports =.

To bundle the application, Webpack allows us to take the AngularJS codebase that leverages CommonJS and output a few application bundles. Templates can be require-ed using the right webpack loaders (html-loader). SCSS stylesheets and even Handlebar templates can also be compiled.

Why CommonJS instead of ES modules?

ES modules are the module format defined in the ECMAScript spec. They look like the following:

import angular from 'angular'
export function homeController() {}

The issue with ES modules are that they are static imports, ideally they shouldn’t have side-effects. Including something that does angular.module('some-name') seems pretty side-effectful, so CommonJS reflects this a bit more: require('./this-script-that-adds-something-to-angular').

Adding Vue 🖼️

This part was surprisingly straightforward, to add Vue components to an AngularJS app ngVue is available (https://github.com/ngVue/ngVue). ngVue exposes functionality to wrap Vue components as an AngularJS directives.

The checklist goes like this:

  • npm i --save ngVue vue vue-loader (vue-loader is to load/compile .vue single file components)
  • add ngVue to the bundle: have require('ngVue') somewhere
  • Register ngVue with AngularJS angular.module('myApp', ['ngVue'])
  • Create a Vue component that is registered on the global Vue instance as a component js const myComponent = { template: '<div>My Component</div>' }; const MyVueComponent = Vue.component( 'my-component', MyComponent );
  • Register the component as an AngularJS directive js angular .module('myApp') .directive('myComponent', [ 'createVueComponent' , createVueComponent => createVueComponent(MyVueComponent) ]);
  • In an AngularJS template you can now use: <my-component v-props-my-data="ctrl.myData"></my-component> (vprops-* allows you to pass data and functions from AngularJS to Vue as props)

Full snippet that leverages webpack to load a single file component:

const angular = require('angular');
const { default: Vue } = require('vue');
const { default: MyComponent } = require('./my-component.vue');
const MyVueComponent = Vue.component('my-component', MyComponent)
angular
.module('myApp')
.directive('myComponent', [
  'createVueComponent' ,
  createVueComponent => createVueComponent(MyVueComponent)
]);

In order to load single file components like in the above example, vue-loader is required (see https://github.com/vuejs/vue-loader), depending on how webpack is set up in a project, it can also affect how you process CSS (since single file components contain CSS as well as JavaScript and templates).

Setting up Jest 🔧

Mock assets

.html, .scss, .svg needs to be dummied in your Jest config:

{
    "testRegex": ".*spec.js$",
    "moduleFileExtensions": [
      "js",
      "vue"
    ],
    "moduleNameMapper": {
      "\\.(html)$": "<rootDir>/src/mocks/template-mock.js"
    },
    "transform": {
      ".*\\.js$": "<rootDir>/node_modules/babel-jest",
      ".*\\.(vue)$": "<rootDir>/node_modules/vue-jest"
    },
    "collectCoverageFrom": [
      "src/**/*.{js,vue}"
    ]
}

CommonJS, Webpack and vue-jest woes

Webpack doesn’t care about CommonJS vs ESM, for all intents and purposes, Webpack treats them as the same thing. Here’s the catch: the recommended Jest plugin for Vue (vue-jest) handles require vs import of .vue files differently to Webpack. This is some sample code in a Vue component that imports another Vue single file component in CommonJS:

const MyOtherComponent = require('./my-other-component.vue').default;

export.default = {
  components: {
    MyOtherComponent
  }
};

The issue is the following:

  • For the Webpack build to work, you need to use const MyComponent = require('./my-component.vue').default or import MyComponent from './my-component.vue'.
  • For tests to pass, you need to do const MyComponent = require('./my-component.vue') or use import and transpile the modules using Babel
  • AngularJS controllers love this … transpiling ES modules through Babel breaks this somehow

Some solutions 🤔

  1. Use ES6 import/export for the Vue components and tests, add a specific extension (.mjs, .module.js), disable babel-jest on CommonJS files.
    Drawback: Coverage breaks due to the following issue (that is fixed now): https://github.com/istanbuljs/babel-plugin-istanbul/pull/141

  2. Monkey-patch using Jest inside your test: jest.setMock('./my-component.vue', { default: MyComponent });.
    Drawback: this is not a real fix, it makes the developer have to think about Vue vs bundled JavaScript vs JavaScript in test, which should appear the same in most situations.

  3. Rewrite the transformed code, using a custom pre-processor, so it behaves the same under Webpack and vue-jest.

Fixing vue-jest/Webpack CommonJS handling with a Jest preprocessor

The following preprocessor takes require('./relative-path').default and converts it to require('./relative-path') (which is what Webpack seems to do under the hood). To use the following preprocessor, replace the .vue-matching line in "transform" of Jest config with ".*\\.(vue)$": "<rootDir>/vue-preprocessor". Here is the full code for the preprocessor, walkthrough of the code/approach follows:

// vue-preprocessor.js
const vueJest = require('vue-jest');

const requireNonVendorDefaultRegex = /(require)\('\..*'\).default/g;

const rewriteNonVendorRequireDefault = code =>
  code.replace(requireNonVendorDefaultRegex, match =>
    match.replace('.default', '')
  );

module.exports = {
  process (src, filename, config, transformOptions) {
    const { code: rawCode, map } = vueJest.process(
      src,
      filename,
      config,
      transformOptions
    );
    const code = rewriteNonVendorRequireDefault(rawCode);
    return {
      code,
      map
    };
  }
};

At a high level, we process the code through vue-jest and then rewrite require('./relative-path').default to require('./relative-path'). This is done with the following:

  • /(require)\('\..*'\).default/g matches any require where the string arg starts with . ie it will match local require('./something-here') but not require of node modules (eg. required('vue')). A caveat is that this RegEx only works for single-quote requires… but that’s trivial to fix if the code uses double quotes.
  • String.replace with a function argument is leveraged to run a custom replace on each match of the previous RegEx. That’s done with match.replace('.default', '').

Thoughts on running Vue inside AngularJS 🏃‍

AngularJS is from a time before bundlers and a JavaScript module system. The only contemporary bundling tool to AngularJS target JavaScript applications is the Google Closure Compiler. For reference Browserify was released in 2011, webpack in 2012. AngularJS’ initial release was in 2010.

That’s why we ended up with things like script includes for each controller and each template (script type="ng-template").

Each script will call angular.module('app').{controller, directive, service}and each of those calls will register something on the global angular instance and can then be consumed elsewhere. This is brittle since code that should be co-located gets spread across the codebase and gets referenced with strings like 'HomeController'. All it takes is 1 typo and we’ve got a bug that won’t be detected until we get the app in a certain state…

With Vue.js, Webpack and Jest, we can bundle, test and build with more confidence. AngularJS was and still is great. What’s also great that we can migrate off it progressively, thanks to the ngVue team.

That means we get to keep the solid AngularJS working alongside new features written in Vue.

Cover photo by Justyn Warner on Unsplash