Vue.js/Testing

From mediawiki.org

Vue.js encourages you to break up the elements of a UI into a common set of isolated, reusable components that are responsible for their own content and behavior. Additionally, these components rely on a virtual DOM and typically have little need for direct reliance on browser-specific APIs. UIs built with Vue.js are thus well-suited for unit-testing in a headless, Node.js-based environment. In many cases it is possible (and easy!) to test behaviors from the command-line that could previously only be captured in end-to-end, Selenium-style tests (clicks, key events, and other simulated user interactions).

This page is intended as a high-level guide for how to test Vue.js code in a MediaWiki environment. Many of the tools relied on here (Jest, vue-test-utils, etc.) have extensive documentation of their own which is worth studying as well.

Getting started[edit]

This guide will cover how to use Vue's official testing library with Jest in the context of a MediaWiki extension. Testing Vue code in Core should be similar but some adjustments may be needed.

Jest[edit]

Jest is a "batteries-included" test runner developed by Facebook. In addition to the jest test runner itself, the software includes an expectation library and extensive mocking capabilities (there is no need to use Sinon alongside Jest, for example). Jest also sets up a JSDOM environment automatically.

One of the biggest advantages of using Jest with Vue is that it is possible to test single-file Vue components (.vue files) without relying on a bundling tool like Webpack, by using the vue-jest pre-processor. Since some teams will be shipping ES5 Vue components that don't rely on Webpack, this should help to reduce unnecessary complexity in test setup.

Vue Test Utils[edit]

Vue Test Utils is the official unit testing utility library for Vue.js. The project website contains extensive documentation (both API-level and a more narrative "guide").

Setup[edit]

You will need to add the following libraries to package.json as devDependencies:

A standard test script in package.json might look like this:

"scripts": {
    "test": "grunt test && npm run test:unit",
    "test:unit": "jest"
}

In addition to these libraries and some sort of testing script, you will need to add two top-level files: jest.config.js (configuration properties) and jest.setup.js (code to be run before starting the test suite, to set up an appropriate environment).

Jest config file[edit]

This file defines Jest's configuration. Jest ships with reasonable defaults here, but there are couple of properties which are worth knowing about:

Vue-Jest configuration[edit]

The following properties should be added to get Jest to transform Vue single-file components:

module.exports = {
    // Vue-jest specific global options (described here: https://github.com/vuejs/vue-jest#global-jest-options)
    globals: {
        babelConfig: false,
        hideStyleWarn: true,
        experimentalCssCompile: true
    },
    
    // This and "transform" below are the most crucial for vue-jest:
    // https://github.com/vuejs/vue-jest#setup
    moduleFileExtensions: [
        "js",
        "json",
        "vue"
    ],
    
    transform: {
        ".*\\.(vue)$": "<rootDir>/node_modules/vue-jest"
    }
    
    // ... other config options, etc
};
Coverage[edit]

To generate Istanbul-style coverage reports, the following config properties are needed:

module.exports = {
    // other config props etc...

	// Indicates whether the coverage information should be collected while executing the test
	collectCoverage: true,
  
	// An array of glob patterns indicating a set of files for which coverage information should be collected
	collectCoverageFrom: [
	  "resources/**/*.(js|vue)"
	],
  
	// The directory where Jest should output its coverage files
	coverageDirectory: "coverage",
  
	// An array of regexp pattern strings used to skip coverage collection
	coveragePathIgnorePatterns: [,
	  "/node_modules/",
	  "resources/components/index.js", // these are examples; you may have init or index scaffolding
	  "resources/plugins/index.js",    // files which you don't want included in coverage
	  "resources/init.js",
	  "resources/vendor/"
	]
};
Specify setup file[edit]

See the next section for an example of how to use a setup file.

module.exports = {
    // other config...
    
    // The paths to modules that run some code to configure or set up the testing environment before each test
	setupFiles: [
	  "./jest.setup.js"
	],
};

Jest setup file[edit]

Jest allows you to define a setup file which is run before the start of the entire test suite. This is useful for setting up a MediaWiki browser environment, where a lot of objects tend to be exposed in the global namespace. Your setup file can load real code or Jest mock functions as needed. The example below sets up jQuery, OOJS, and OOUI using NPM packages and then stubs out a global MW object with methods that may need to exist during testing:

// Assign things to "global" here if you want them to be globally available during tests
global.$ = require( 'jquery' );
global.OO = require( 'oojs' );
require( 'oojs-ui' );
require( 'oojs-ui/dist/oojs-ui-wikimediaui.js' );

// Mock MW object
global.mw = {
    config:
        get: jest.fn()
    },
    user: {
        isAnon: jest.fn().mockReturnValue( true ),
    }
    // other mw properties as needed...
};

Simulating a MediaWiki environment[edit]

By using a Jest setup file (see above) it is possible to fake most of the properties/methods added to the global scope by MW Core, other extensions, etc. A lot of the mocking code from the mw-node-qunit library could be easily adapted here, for example.

Mocking resource modules[edit]

In addition to mocking out aspects of the MediaWiki browser environment, you may need to mock entire modules that are loaded via require() in ResourceLoader. Some Wikibase dependencies may need to be mocked in this way, for example. This is also useful if ResourceLoader exposes code with a module name that differs from the name of the equivalent NPM package (vue2 vs vue for example).

Example

Let's say that our code depends on a module that is provided by ResourceLoader at runtime:

var datamodel = require( 'wikibase.datamodel' ); 

In the test environment, we don't have access to ResourceLoader, so this will be interpreted as a node module. But our node_modules folder (which is where the call to require() will look when we run the tests) doesn't contain a module by this name.

Jest makes it easy to mock this module dependency by creating a __mocks__ folder in the same directory as where the module would normally be found (in this case, that would be in the root directory of the extension, adjacent to node_modules). Inside the __mocks__ folder, you can add a JS file with a name that matches the name of the module: __mocks__/wikibase.datamodel.js. Jest will automatically load this file instead of attempting to find the real module in node_modules.

The contents of the mock file could be anything. If we didn't care about what any of this code did in our tests and just needed to stub out a few dummy functions, we could so something like this:

// __mocks__/wikibase.datamodel.js
module.exports = {
	Statement: jest.fn(),
	Claim: jest.fn(),
	PropertyValueSnak: jest.fn(),
	EntityId: jest.fn()
};

More information about mocking modules in Jest can be found here: https://jestjs.io/docs/en/manual-mocks

Testing components[edit]

Example Tests[edit]

Using the Wrapper object

Say we have a simple App.vue component:

<!-- App.vue -->
<template>
    <div id="app">
        <h1>{{ data }}</h1>
        <p>This is a simple Vue component</p>
    </div>
</template>
<script>
module.exports = {
    name: 'App',
    
    data: function () {
        message: 'Hello world'
    }
};
</script>

Since Vue components rely on a virtual DOM, it is easy to test for the presence or absence of HTML elements, attributes, classes, etc. without relying on a browser environment:

// App.test.js
const VueTestUtils = require( '@vue/test-utils' );
const App = require( './App.vue' ); // Jest lets us require .vue files directly with vue-jest

describe( 'App', () => {
    it( 'contains an H1 element', () => {
        const wrapper = VueTestUtils.shallowMount( App );
        expect( wrapper.contains( 'h1' ) ).toBe( true );
    } );
} );

Most tests will involve a wrapper object, which is created by calling mount or shallowMount with the component being tested as an argument. This test uses the wrapper's built-in contains method to find an element that matches a given selector.


Testing async updates with Vue.nextTick

Here's a slightly more complex test for the same component that ensures the contents of the <h1> tag are always in sync with the value of the component's message data (local state).

// App.test.js
const VueTestUtils = require( '@vue/test-utils' );
const Vue = require( 'vue' ); // Importing Vue here so we can call Vue.nextTick
const App = require( './App.vue' );

describe( 'App', () => {
    it( 'updates the text of the H1 element when the "message" data changes', done => {
        const wrapper = VueTestUtils.shallowMount( App );
        wrapper.setData( { message: 'yo' } );
        Vue.nextTick( () => {
            expect( wrapper.find( 'h1' ).text() ).toBe( 'yo' );
            done();
        } );
    } );
} );

Vue batches DOM updates asynchronously for efficiency. To ensure that something happens after these updates are complete, use Vue.nextTick( callback ). Our assertion runs in this callback to ensure that Vue's DOM upates have completed before we look for the updated text.

Mount vs ShallowMount[edit]

Vue Test Utils provides two ways to mount components during testing: mount and shallowMount. Most of the time, shallowMount is preferable, because it allows you to mount a component in isolation from any child components it may contain (they are replaced with stubs). This approach is faster and lets us write tests that focus on a single component at a time.

In cases where you do want child components to be created alongside the parent, use the mount method instead.

More information on shallow rendering can be found here: https://vue-test-utils.vuejs.org/guides/#shallow-rendering

Simulating user interaction[edit]

The wrapper object exposes a trigger method that can be used to programmatically trigger DOM events. Here's an example of asserting that a particular method is called when a button is clicked:

<!-- App.vue -->
<template>
    <div id="app">
        <button v-on:click="foo">Click me</button>
    </div>
</template>
<script>
module.exports = {
    name: 'App'
    methods: {
        foo: function () {
            console.log( 'clicked!' );
        }
    }
};
</script>
// App.test.js
const VueTestUtils = require( '@vue/test-utils' );
const App = require( './App.vue' );

describe( 'App', () => {
    it( 'calls the foo method when button is clicked', () => {
        const wrapper = VueTestUtils.shallowMount( App );
        const button = wrapper.find( 'button' );
        const spy = jest.spyOn( wrapper.vm, 'foo' );  // wrapper.vm is the actual Vue instance that we just mounted
        
        button.trigger( 'click' );
        expect( spy ).toHaveBeenCalled();
    } );
} );

Testing plugins[edit]

If the component you are testing relies on features injected by a global plugin (like the i18n plugin included in core), you can create a local modified copy of the Vue constructor by using the createLocalVue method from Vue Test Utils. This is also useful for testing components that rely on Vuex or Vue-router.

// App.test.js
const VueTestUtils = require( '@vue/test-utils' );
const App = require( './App.vue' );
const myPlugin = require( './plugins/myPlugin.js')
const localVue = VueTestUtils.createLocalVue();

localVue.use( myPlugin );

describe( 'App', () => {
    it( 'does something important...', () => {
        const wrapper = VueTestUtils.shallowMount( App, { localVue } );
        // rest of the test, etc...
    } );
} );

Testing Vuex stores[edit]

The contents of a Vuex store are really just plain JavaScript functions and objects. This makes it relatively straightforward to test Vuex code in a Vue application. Tests for a Vuex store should primarily focus on mutations and actions, since that's where most of the logic will likely reside in any given store.

Testing Mutations and Getters[edit]

Mutations lend themselves to unit testing because they must be synchronous and they should not have any side-effects. They always take a state object as their first argument and an optional payload as a second argument.

Here's an example of a basic mutation test in Jest:

// mutations.js
module.exports = {
    increment: function ( state ) {
        state.count++;    
    }
};
// mutations.test.js
const mutations = require( './mutations' );

describe( 'mutations', () => {
    let state;
    
    beforeEach( () => {
        state = {
            count: 1
        };
    } );
    
    describe( 'increment', () => {
        it( 'increases the count by 1', () => {
            mutations.increment( state );
            expect( state.count ).toBe( 2 );
        } );
    } ); 
} );

Tests for Getters will work more or less the same way since getter functions simply return a new value based on existing values in the state.

Testing Actions[edit]

Actions will often house some of the more complex business logic in an application: API requests, interactions with other services, etc. Actions can be asynchronous as well. However, at the end of the day any given action should be responsible for committing one or more mutations (the only way data in the store can actually be changed). One way to keep action tests from becoming too complex is to focus on these commits, and to use mocks to represent external systems that lie outside the scope of the current test.

Here's an example of what a test for an asynchronous action that fetches data from some API.

// actions.js
module.exports = {
    getUserData: function ( context ) {
        var query = { /* some query data */ };
      
        // set a "pending" state before the request is sent
        context.commit( "setPending", true );
      
        // This action returns a promise so that components or other actions which
        // dispatch it can chain their own logic to it.
        // For the purposes of this example, assume a global "api" object exists here
        return api.get( query ).then( function ( response ) {
            var userData = response.userData;
            context.commit( "setUserData", userData );
        } ).catch( function ( error ) {
            context.commit( "showErrorMessage", error.message );
        } ).finally( function () {
            context.commit( "setPending", false );
        } );
    }  
};

To test this action in isolation, we can use Jest mocks to simulate the API under various conditions (successful request, failed request, etc). The goal of the test is to ensure that the correct mutations are called in the correct circumstances.

// actions.test.js
const actions = require( './actions.js' );
const fixtureData = require( './userData.json' );

// assume our jest setup file mocks the global API object from above
mockApi = global.api;

describe( 'actions', () => {
    let context;
    
    beforeEach( () => {
        // mock the context object so we can inspect what mutations get called
        context = {
            commit: jest.fn();
        };
        
        // jest mock functions can respond asynchronously if needed
        mockApi.get = jest.fn().mockResolvedValue( fixtureData );
    } );
    
    describe( 'getUserData', () => {
        it( 'sets the pending state when called', () => {
            actions.getUserData( context );
            expect( context.commit ).toHaveBeenCalledWith( 'setPending', true );
        } );
        
        it( 'sets the userData when request is successful', done => {
            actions.getUserData( context ).then( () => {
                expect( context.commit ).toHaveBeenCalledWith( 'setUserData', fixtureData );
            } );
        } );
        
        it( 'shows an error message when the request fails', done => {
            // temporarily override our API mock to force failure state
            mockApi.get = jest.fn().mockRejectedValue( { message: 'foo' } );
            
            actions.getUserData( context ).catch( () => {
                expect( context.commit ).toHaveBeenCalledWith( 'showErrorMessage', 'foo' );
            } );
        } );
    } );
} );

More information about testing mutations, getters, and actions can be found here: https://vue-test-utils.vuejs.org/guides/using-with-vuex.html#testing-getters-mutations-and-actions-separately

Further Reading[edit]