Bryan Lee
Bryan Lee

Musings about growth, software engineering, and tech.

Javascript

How to set up unit testing in NativeScript Vue

While the experience of working on mobile apps using NativeScript Vue has been for the most part pleasant, one of the roadblocks I quickly hit was unit testing. The Vue way of Jest + Vue Test Utils does not work out of the box with NativeScript Vue. Vanilla NativeScript supports unit testing via Karma and a choice of Jasmine, Mocha etc but they do not work well with NativeScript Vue as well. Without any official documentation from NativeScript Vue on unit testing, I’m documenting down my way of unit testing NativeScript Vue with Jest and Vue Test Utils.

Initial setup

Before we continue, please be warned that this guide was done based on the JavaScript flavor of NativeScript Vue. Your mileage may vary on TypeScript flavored projects. The first few steps are largely similar with how you would set up unit testing in vanilla Vue projects.

Let’s start by installing Jest. Jest is the test runner we will use to execute our unit tests.

npm i jest -D

Next, we install Vue Test Utils and other dependencies. Being able to work with Vue Test Utils is a huge plus because it has a ton of useful helpers that make working with Vue’s Single File Components painless.

npm i @vue/test-utils vue-jest babel-jest -D

We define a test script in our package.json.

{
  "scripts": {
    "test": "jest"
  }
}

We also define our configuration for Jest. We have a little extra in the moduleNameMapper section. This is because NativeScript Vue already has a couple of aliases defined in the Webpack config file. We want to replicate these aliases into Jest or your tests will fail if you are using them in your imports.

module.exports = {
  verbose: true,
  moduleFileExtensions: [
    'js',
    'json',
    'vue',
  ],
  moduleDirectories: [
    'node_modules',
  ],
  moduleNameMapper: {
    // Default NativeScript webpack aliases
    '~/(.*)$': '<rootDir>/app/$1',
    '@/(.*)$': '<rootDir>/app/$1',
    '^projectRoot/(.*)$': '<rootDir>/$1',
  },
  transform: {
    '^.+\\.js$': '<rootDir>/node_modules/babel-jest',
    '.*\\.(vue)$': 'vue-jest',
  },
  transformIgnorePatterns: [],
  collectCoverage: true,
  collectCoverageFrom: [
    'app/**/*.{js,vue}',
  ],
  coverageReporters: ['text-summary', 'html'],
};

With jest configured, we need to configure Babel for our test environment too. As of October 2019, the standard practice is to use @babel/preset-env. Let’s get that installed, and then open up our Babel config babel.config.js.

npm i -D babel-core@7.0.0-bridge.0 @babel/preset-env
// babel.config.js
module.exports = function (api) {
  api.cache(true)

  return {
    presets: [
      ['@babel/preset-env', { targets: { esmodules: true } }],
    ],
    env: {
      test: {
        presets: [['@babel/preset-env', {
          targets: { node: 'current' },
        }]],
      },
    },
  }
}

With the initial set up of Jest, Babel, and Vue Test Utils, let’s try writing our first basic unit test. In this guide, we’re basing it off nativescript-vue/vue-cli-template so there is already an App.vue component. Let’s create a spec file for App.vue in the same folder as the component.

// App.spec.js
import { shallowMount } from '@vue/test-utils';
import App from './App';

describe('App.vue', () => {
  it('mounts and renders', () => {
    const wrapper = shallowMount(App);

    expect(wrapper.html()).toBeTruthy();
  });
});

Try running the test now and you will quickly see it doesn’t work, yet.

Cannot find module 'vue' from 'vue-test-utils.js'

NativeScript Vue specific setup

We’re getting the error above because the Vue we are using in our project is nativescript-vue. vue itself is not installed in our project, hence Vue Test Utils is unable to resolve the module vue. NativeScript Vue components are also for the most part similar to standard Vue components which also means we can use standard Vue.js in our unit tests which will work much better with Jest. We’ll install standard Vue.js and the standard vue-template-compiler to be able to compile our single file components.

npm i vue vue-template-compiler -D

Run your tests again and this time you’ll see that they pass! Your App.vue was mounted and the tests executed sucessfully against it. However, you might notice a couple of warnings being output in the console now:

[Vue warn]: Unknown custom element: <Page> - did you register the component correctly? For recur
sive components, make sure to provide the "name" option.

[Vue warn]: Unknown custom element: <ActionBar> - did you register the component correctly? For recursive components, make sure to provide the "name" option.

NativeScript Vue introduces a fair amount of new components that are registered globally. These aren’t available in our test environment, so let’s stub them. Back in our jest.config.js file, let’s add an entry for a global setup file to be executed before tests. We will define our stubs in this setup file.

// jest.config.js
module.exports = {
  setupFiles: [
    '<rootDir>/jest/nativescript-vue-stubs.js',
  ]
}

Create the setup file, which we’ve defined to be in the jest directory, which we also would need to create. We’ll add components that we need stubbed out into the Vue Test Utils config.

// jest/nativescript-vue-stubs.js
import { config } from '@vue/test-utils';

const NSElements = [
  'ActionBar',
  'ActionItem',
  'FormattedString',
  'GridLayout',
  'HtmlView',
  'NavigationButton',
  'Page',
  'StackLayout',
  'TabView',
  'TabViewItem',
  'TextField',
];

NSElements.forEach((ele) => {
  config.stubs[ele] = true;
});

Should you come across any other console errors related to NativeScript Vue components, you can add them to the NSElements array in this file to stub them out.

Working with TNS Core Modules

Your project will most likely also be accessing the native runtimes and have platform-specific code. For example, one common pattern would be checking whether the current platform is Android or iOS. Taking App.vue as an example, your Vue component might look something like this:

// App.vue
<template>
  <Page>
    <ActionBar title="Welcome to NativeScript-Vue!"/>
    <GridLayout columns="*" rows="*">
      <Label class="message" :text="msg" col="0" row="0"/>

      <Label v-if="isIOS" text="This text is only visible on iOS" />
    </GridLayout>
  </Page>
</template>

<script >
  import { isIOS } from 'tns-core-modules/platform';

  export default {
    data() {
      return {
        msg: 'Hello World!',
        isIOS,
      }
    }
  }
</script>

The native runtimes of NativeScript Vue have their individual platform specific file extensions of .ios.js and .android.js. We’ll need to configure Jest to find the tns-core-modules and to recognize the native runtime JS files as modules. Add the native runtime file extensions in your jest.config.js file:

// jest.config.js
module.exports = {
  moduleFileExtensions: [
    'android.js',
    'ios.js',
    'js',
    'json',
    'vue',
  ],
}

NativeScript plugins: __extends is not defined

If you use other NativeScript plugins, you may also get this error __extends is not defined. This is because some plugins were written in TypeScript and did not have the TypeScript helpers included during compilation. This is easy to get around with by defining them globally before your tests run. I have included a couple of the more common ones that I required in my project. Should you need more, the TSLib Github repo is a good resource to adapt your own.

Create another file in your Jest folder: jest/typescript-helpers.js:

global.__extends = (this && this.__extends) || function (d, b) {
  for (let p in b) if (b.hasOwnProperty(p)) d[p] = b[p];
  function __() { this.constructor = d; }
  d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
};

global.__assign = (this && this.__assign) || Object.assign || function (t) {
  for (var s, i = 1, n = arguments.length; i < n; i++) {
    s = arguments[i];
    for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];
  }
  return t;
};

global.__rest = (this && this.__rest) || function (s, e) {
  var t = {};
  for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
    t[p] = s[p];
  if (s != null && typeof Object.getOwnPropertySymbols === "function") {
    for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
      if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) {
        t[p[i]] = s[p[i]];
      }
    }
  }
  return t;
}

global.__decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
  return c > 3 && r && Object.defineProperty(target, key, r), r;
};

Make sure you include this in your Jest config’s setupFiles:

setupFiles: [
  '<rootDir>/jest/typescript-helpers.js',
  '<rootDir>/jest/nativescript-vue-stubs.js',
],

Triggering events on NativeScript components

Remember that we have been stubbing out all the NativeScript Vue components. One common pattern would be to trigger a tap on a component to test the function executed. Since they are stubs, you will need to emit a tap event instead of triggering it:

// This will not work
wrapper.find('actionitem-stub').trigger('tap');

// Instead, emit an from the stub's ViewModel
wrapper.find('actionitem-stub').vm.emit('ta');

Happy Testing

With all these done, you should be able to enjoy a smooth development experience writing unit tests for your NativeScript Vue app. I have also created an example repo of a very basic setup of a NativeScript Vue app with Jest and Vue Test Utils configured. You are free to use it however you’d like, even as a base for your next project. I hope you’ve found this guide useful. If there’s something not covered here, please feel free too to reach out to me.

comments powered by Disqus