Technology Blogs by Members
Explore a vibrant mix of technical expertise, industry insights, and tech buzz in member blogs covering SAP products, technology, and events. Get in the mix!
cancel
Showing results for 
Search instead for 
Did you mean: 
mauriciolauffer
Product and Topic Expert
Product and Topic Expert
This is a blog series on exploring modern javascript testing frameworks in Fiori/UI5 context. In this one, we’ll see how to use Vitest to unit test Fiori/UI5 apps and libraries. We’re going to use the openui5-sample-app which already has QUnit tests, therefore we can convert them to Vitest and compare the results. I’m assuming you’re git cloning https://github.com/SAP/openui5-sample-app and creating a new branch to try it 🙂

 

Edit: the steps for Vitest and jsdom have been abstracted away in a new Vitest Environment: vitest-environment-ui5. I strongly recommend using it rather than manually configuring everything as showed in this blog post.

 

Vitest


"A blazing fast unit test framework powered by Vite."

https://vitest.dev

 

As Vitest is mostly a drop-in replacement for Jest (in terms of API), and we're going to use Happy DOM and jsdom again, and we'll test UI5 code, this blog is almost a copy and paste from the previous one. If people only want to check a given framework, they don't need to jump through all blogs to get the full picture. Get ready for a strong déjà vu feeling! The good news is Vitest much cleaner than Jest!

In Nodejs, pretty much everything comes from NPM. Go ahead and install Vitest as devDependencies.
$ npm install --save-dev vitest

 

Now, let’s update the package.json file with a script command to run our Vitest tests (not in watch mode, but the feature exists).
"type": "module",
"scripts": {
...
"test:nodejs" : "vitest run"
}

 

Create a new folder to host the tests, just to avoid mixing the new tests with the old ones. It wouldn’t be a problem, it’s just to make it easier to find the new tests. We’re naming the folder: unit-vitest. Vitest will execute all files matching *.test.js by default.

That’s all you need to start writing and running your tests. However, because we’re testing Fiori/UI5, we must have some sort of browser for DOM manipulation, and to load UI5 modules. As said in the overview blog, we’ll use Happy DOM and jsdom as our "fake browsers".

 

Using Happy DOM


Vitest also has a great feature called test environment which allows running Vitest in multiple test environments (node, jsdom, happy-dom, edge-runtime, custom) each with its own special setup. The main difference from the Jest implementation is that there are no special packages with wrappers and boilerplate. You need the regular Happy DOM or jsdom package, nothing else.

We’re going to use the Happy DOM environment, so we don’t need to manually start it up.

Install Happy DOM.
$ npm install --save-dev happy-dom

 

Now, let’s create the test file: webapp/test/unit-vitest/App.controller.happydom.test.js

Vitest test environment can be configured in a vitest.config.js file or in a docblock in the test file itself. I want to run Happy DOM and jsdom simultaneously, so I will use docblock and set it per file.

Add the docblock configuration at the top of the test file.
/**
* @vitest-environment happy-dom
*/

 

Because Vitest doesn't pollute globals as Jest does, we need to import what we want to use into the test file. Standard procedure in Nodejs.
import { describe, it, beforeAll, beforeEach, afterAll, afterEach, expect, vi } from 'vitest';

 

I like organizing my tests in multiple files and groups (test suites). In QUnit, we use QUnit.module to group tests in a file. In Vitest, we use describe. In QUnit, we use QUnit.test to create the test block. In Vitest, we use it or test (they’re the same).

Let’s start with a basic test which’s going to check whether Happy DOM has been loaded correctly. If it was, window and document will be available as globals.
describe('test suite happyDOM', function () {
let sap = {};

describe('Test happyDOM', function () {
it('test if happyDOM has been loaded', function () {
expect(window).toBeTruthy();
expect(document).toBeTruthy();
expect(document.body).toBeTruthy();
});
});
});

 

Let’s load sap-ui-core.js now. Remember, this isn’t a real browser, it’s Nodejs and it doesn’t understand UI5 AMD-like modules or HTML. Happy DOM cannot load files from the local system, it only loads them via HTTP requests. It requires a web server (local or remote) to fetch the files from. Make sure you execute $ npm start or $ ui5 serve beforehand to spin up your local web server.

UI5 core library has to be loaded before executing any Fiori/UI5 tests, therefore we’re going to use the beforeAll hook to load it before all tests are executed. Place beforeAll in describe 'test suite happyDOM' section.

Because UI5 uses a deprecated performance.timing API, which it’s not available in these fake browsers and doesn’t even exist in Nodejs, we’ll need to mock it out. We’re not using the mocking features here, We’re changing the window.performance object straight away.

Create a script tag which is our old UI5 bootstrap friend. Then, await for the UI5 core library to be loaded and available at window.sap. Last, but not least, use sap.ui.require to load the controller just like we do in QUnit tests. It’d be so much better if UI5 was built with ES modules rather than AMD-like 🙁
beforeAll(async () => {
//Mocking deprecated and not available window.performance.timing
window.performance.timing = {
fetchStart: Date.now(),
navigationStart: Date.now()
};

//Script tag to bootstrap UI5
window.happyDOM.setURL('http://localhost:8080/');
const scriptUi5Bootstrap = document.createElement('script');
scriptUi5Bootstrap.id = "sap-ui-bootstrap";
scriptUi5Bootstrap.src = "https://ui5.sap.com/resources/sap-ui-core.js";
scriptUi5Bootstrap.setAttribute('data-sap-ui-libs', "sap.m");
scriptUi5Bootstrap.setAttribute('data-sap-ui-compatVersion', "edge");
scriptUi5Bootstrap.setAttribute('data-sap-ui-async', "true");
scriptUi5Bootstrap.setAttribute('data-sap-ui-language', "en");
scriptUi5Bootstrap.setAttribute('data-sap-ui-resourceRoots', '{"sap.ui.demo.todo" : "../../"}');
scriptUi5Bootstrap.crossorigin = "anonymous";
document.body.appendChild(scriptUi5Bootstrap);

//Await UI5 to load, then use sap.ui.require to load your own UI5 files
await window.happyDOM.whenAsyncComplete();
await new Promise((resolve, reject) => {
sap = window.sap;
sap.ui.require([
"sap/ui/demo/todo/controller/App.controller"
], function () {
resolve();
}, function (err) {
reject(err);
});
});
});

 

You may have noticed we don’t have an ugly setTimeout to allow extra time for UI5 loading anymore. Because we’re not using Jest, we can safely use a Happy DOM function that returns a Promise which controls when all async operations have been completed (window.happyDOM.whenAsyncComplete). I told you Jest messing up with globals and auto-mocking wasn’t a good thing.

Let’s write some tests to check whether UI5 core library and our controller have been loaded. Place it in the describe 'Test happyDOM' section.
it('test if UI5 has been loaded', function () {
expect(sap).toBeTruthy();
expect(sap.ui.demo.todo.controller.App).toBeTruthy();
});

 

So far so good. Let’s convert those QUnit tests from webapp/test/unit/App.controller.js to Vitest. We’re creating a new nested describe section. As you can notice, the main differences are: using context variable rather than this, Vitest mock functions rather than sinon, assertions done with expect rather than assert, full SAP namespace because we’re not loading everything with sap.ui.require. We could, but I preferred not to. In this context, I think it’s easier to understand where everything is coming from.
describe('Test init state', function () {
beforeEach((context) => {
context.oAppController = new sap.ui.demo.todo.controller.App();
context.oViewStub = new sap.ui.base.ManagedObject({});
context.oJSONModelStub = new sap.ui.model.json.JSONModel({
todos: []
});
vi.spyOn(sap.ui.core.mvc.Controller.prototype, 'getView').mockReturnValue(context.oViewStub);
context.oViewStub.setModel(context.oJSONModelStub);
});

afterEach(() => {
vi.clearAllMocks();
});

it('Check controller initial state', (context) => {
// Act
context.oAppController.onInit();

// Assert
expect(context.oAppController.aSearchFilters).toEqual([]); //"Search filters have been instantiated empty"
expect(context.oAppController.aTabFilters).toEqual([]); //"Tab filters have been instantiated empty"

var oModel = context.oAppController.getView().getModel("view").getData();
expect(oModel).toEqual({ isMobile: sap.ui.Device.browser.mobile, filterText: undefined });
});
});

 

Have you noticed the context is not a local variable we had declared? It’s a parameter from the test suite functions itself, which means it belongs to that scope and nowhere else. It allows you to define fixtures and states. It’s kinda similar to how QUnit this is used in the original tests.

Done! Let’s check out how our UI5 Vitest Happy DOM test file looks like:
/**
* @vitest-environment happy-dom
*/

import { describe, it, beforeAll, beforeEach, afterAll, afterEach, expect, vi } from 'vitest';

describe('test suite happyDOM', function () {
let sap = {};

beforeAll(async () => {
//Mocking deprecated and not available window.performance.timing
window.performance.timing = {
fetchStart: Date.now(),
navigationStart: Date.now()
};

//Script tag to bootstrap UI5
window.happyDOM.setURL('http://localhost:8080/');
const scriptUi5Bootstrap = document.createElement('script');
scriptUi5Bootstrap.id = "sap-ui-bootstrap";
scriptUi5Bootstrap.src = "https://ui5.sap.com/resources/sap-ui-core.js";
scriptUi5Bootstrap.setAttribute('data-sap-ui-libs', "sap.m");
scriptUi5Bootstrap.setAttribute('data-sap-ui-compatVersion', "edge");
scriptUi5Bootstrap.setAttribute('data-sap-ui-async', "true");
scriptUi5Bootstrap.setAttribute('data-sap-ui-language', "en");
scriptUi5Bootstrap.setAttribute('data-sap-ui-resourceRoots', '{"sap.ui.demo.todo" : "../../"}');
scriptUi5Bootstrap.crossorigin = "anonymous";
document.body.appendChild(scriptUi5Bootstrap);

//Await UI5 to load, then use sap.ui.require to load your own UI5 files
await window.happyDOM.whenAsyncComplete();
await new Promise((resolve, reject) => {
sap = window.sap;
sap.ui.require([
"sap/ui/demo/todo/controller/App.controller"
], function () {
resolve();
}, function (err) {
reject(err);
});
});
});

describe('Test happyDOM', function () {
it('test if happyDOM has been loaded', function () {
expect(window).toBeTruthy();
expect(document).toBeTruthy();
expect(document.body).toBeTruthy();
});

it('test if UI5 has been loaded', function () {
expect(sap).toBeTruthy();
expect(sap.ui.demo.todo.controller.App).toBeTruthy();
});
});

describe('Test init state', function () {
beforeEach((context) => {
context.oAppController = new sap.ui.demo.todo.controller.App();
context.oViewStub = new sap.ui.base.ManagedObject({});
context.oJSONModelStub = new sap.ui.model.json.JSONModel({
todos: []
});
vi.spyOn(sap.ui.core.mvc.Controller.prototype, 'getView').mockReturnValue(context.oViewStub);
context.oViewStub.setModel(context.oJSONModelStub);
});

afterEach(() => {
vi.clearAllMocks();
});

it('Check controller initial state', (context) => {
// Act
context.oAppController.onInit();

// Assert
expect(context.oAppController.aSearchFilters).toEqual([]); //"Search filters have been instantiated empty"
expect(context.oAppController.aTabFilters).toEqual([]); //"Tab filters have been instantiated empty"

var oModel = context.oAppController.getView().getModel("view").getData();
expect(oModel).toEqual({ isMobile: sap.ui.Device.browser.mobile, filterText: undefined });
});
});
});

 

Feel free to convert all other QUnit tests if you like.

 

Using jsdom


There’s a Vitest test environment for jsdom, just like the one we used for Happy DOM. However, we’re not going to use it because jsdom has the capability to load local system files which isn’t available in the jsdom test environment. We're going to manually configure it.

Install jsdom.
$ npm install --save-dev jsdom

 

As I said, jsdom can load local system files, you don’t have to get it from a web server like in Happy DOM. You could, it’s possible, but we won’t. Let’s start preparing the HTML file that’ll be the UI5 bootstrap.

Create an HTML file: webapp/test/test-jsdom.html

If you compare with the qunit.html files, you’ll notice the absence of /qunit/** and /thirdparty/** scripts, no qunit DIV tags, and a local function to be called when the UI5 onInit event is triggered. The onUi5Boot loads our controller via sap.ui.require and executes a function (onUi5ModulesLoaded) which will be declared, and executed, in the Nodejs test file. Remember, we won’t run this HTML in the browser, jsdom will load the file and does its magic.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenUI5 Todo App</title>
</head>
<body class="sapUiBody">
<script id="sap-ui-bootstrap" src="https://ui5.sap.com/resources/sap-ui-core.js"
data-sap-ui-libs="sap.m"
data-sap-ui-compatVersion="edge"
data-sap-ui-async="true"
data-sap-ui-language="en"
data-sap-ui-oninit="onUi5Boot()"
data-sap-ui-resourceRoots='{
"sap.ui.demo.todo": "../"
}' crossorigin="anonymous">
</script>
<script>
function onUi5Boot() {
sap.ui.require([
"sap/ui/demo/todo/controller/App.controller",
], function () {
if (window.onUi5ModulesLoaded) {
window.onUi5ModulesLoaded();
}
});
}
</script>
</body>
</html>

 

Create the test file: webapp/test/unit-vitest/App.controller.jsdom.test.js

By default, the test environment is set to node, and we’re not using jsdom test environment, so we don’t have to do anything. But we’ll set it via docblock just to make it visible to anybody unaware of it.

Add the docblock configuration at the top of the test file.
/**
* @vitest-environment node
*/

 

Now, we configure jsdom. Import vitest and jsdom. In the previous blog, we used require for Jest doesn't like ESM very much as Babel auto-transpile everything on the fly. Vitest doesn't do it, no Babel here. Then, we are fine using ES modules and its import syntax.

Create a function to encapsulate its instantiation (buildFromFile). We’ll use JSDOM.fromFile, not JSDOM.fromURL, to load our HTML file from the system. As we’re preparing the test to run without a local web server, we must set some properties: resources, referrer and runScripts. By default, jsdom doesn’t load any resources declared in HTML documents, even local ones, it also doesn’t execute script tags. You must explicitly say so.

Same as in Happy DOM, we need to mock some window APIs used by UI5 that aren't available in jsdom. Again, we won't use mock features, we'll overwrite them.
import { describe, it, beforeAll, beforeEach, afterAll, afterEach, expect, vi } from 'vitest';
import { JSDOM } from 'jsdom';

function buildFromFile() {
const options = {
resources: 'usable',
referrer: "https://ui5.sap.com/",
runScripts: 'dangerously',
pretendToBeVisual: true,
beforeParse: (jsdomWindow) => {
// Patch window.matchMedia because it doesn't exist in JSDOM
jsdomWindow.matchMedia = function () {
return {
matches: false,
addListener: function () { },
removeListener: function () { }
};
};
// Patch window.performance.timing because it doesn't exist in nodejs nor JSDOM
jsdomWindow.performance.timing = {
fetchStart: Date.now(),
navigationStart: Date.now(),
};
}
};
return JSDOM.fromFile('webapp/test/test-jsdom.html', options);
};

 

As usual, UI5 core library has to be loaded before executing any Fiori/UI5 tests, therefore we’re going to use the beforeAll hook to load it before all tests are executed. Place beforeAll in describe 'test suite JSDOM' section. We’ll call the function buildFromFile to start jsdom and await for the function window.onUi5ModulesLoaded to be executed from our HTML file once UI5 core has been initiated.

We want to close our jsdom browser after all tests have been executed. Similar to beforeAll, we use the afterAll hook. Then, we write some basic tests to make sure everything has been loaded as expected.
describe('test suite JSDOM', function () {
let dom = {};
let window = {};
let sap = {};

beforeAll(async () => {
dom = await buildFromFile();
window = dom.window;
await new Promise((resolve) => {
window.onUi5ModulesLoaded = () => {
sap = window.sap;
resolve();
};
});
});

afterAll(() => {
window.close();
});

describe('Test JSDOM', function () {
it('test if JSDOM has been loaded', function () {
expect(window).toBeTruthy();
expect(window.document).toBeTruthy();
expect(window.document.body).toBeTruthy();
});

it('test if UI5 has been loaded', function () {
expect(sap).toBeTruthy();
expect(sap.ui.demo.todo.controller.App).toBeTruthy();
});
});
});

 

Now, it’s time to convert those QUnit tests again. This block is exactly the same as the one created in the Happy DOM file. Why? Because after starting jsdom and Happy DOM you have a fake browser with a window API available, and everything else is just Vitest testing. The differences are: how to start the fake browser and how to load UI5 code.

Let’s check out how our UI5 Vitest jsdom test file looks like:
/**
* @vitest-environment node
*/

import { describe, it, beforeAll, beforeEach, afterAll, afterEach, expect, vi } from 'vitest';
import { JSDOM } from 'jsdom';

function buildFromFile() {
const options = {
resources: 'usable',
referrer: "https://ui5.sap.com/",
runScripts: 'dangerously',
pretendToBeVisual: true,
beforeParse: (jsdomWindow) => {
// Patch window.matchMedia because it doesn't exist in JSDOM
jsdomWindow.matchMedia = function () {
return {
matches: false,
addListener: function () { },
removeListener: function () { }
};
};
// Patch window.performance.timing because it doesn't exist in nodejs nor JSDOM
jsdomWindow.performance.timing = {
fetchStart: Date.now(),
navigationStart: Date.now(),
};
}
};
return JSDOM.fromFile('webapp/test/test-jsdom.html', options);
};

describe('test suite JSDOM', function () {
let dom = {};
let window = {};
let sap = {};

beforeAll(async () => {
dom = await buildFromFile();
window = dom.window;
await new Promise((resolve) => {
window.onUi5ModulesLoaded = () => {
sap = window.sap;
resolve();
};
});
});

afterAll(() => {
window.close();
});

describe('Test JSDOM', function () {
it('test if JSDOM has been loaded', function () {
expect(window).toBeTruthy();
expect(window.document).toBeTruthy();
expect(window.document.body).toBeTruthy();
});

it('test if UI5 has been loaded', function () {
expect(sap).toBeTruthy();
expect(sap.ui.demo.todo.controller.App).toBeTruthy();
});
});

describe('Test init state', function () {
beforeEach((context) => {
context.oAppController = new sap.ui.demo.todo.controller.App();
context.oViewStub = new sap.ui.base.ManagedObject({});
context.oJSONModelStub = new sap.ui.model.json.JSONModel({
todos: []
});
vi.spyOn(sap.ui.core.mvc.Controller.prototype, 'getView').mockReturnValue(context.oViewStub);
context.oViewStub.setModel(context.oJSONModelStub);
});

afterEach(() => {
vi.clearAllMocks();
});

it('Check controller initial state', (context) => {
// Act
context.oAppController.onInit();

// Assert
expect(context.oAppController.aSearchFilters).toEqual([]); //"Search filters have been instantiated empty"
expect(context.oAppController.aTabFilters).toEqual([]); //"Tab filters have been instantiated empty"

var oModel = context.oAppController.getView().getModel("view").getData();
expect(oModel).toEqual({ isMobile: sap.ui.Device.browser.mobile, filterText: undefined });
});
});
});

 

Executing the tests


Alright! We have configured everything and created our tests. Let's see what happens when we run it. The first thing you may notice is that all test files are executed in parallel by default, each file is executed in its own process, e.g., 3 files spawn 3 processes. You also have the option to run the test suites, within a file, in parallel. By default, they run in sequence.
$ npm run test:nodejs

 

Code coverage


We've just executed our tests, and everything is working fine. However, we want to know our code coverage. Code coverage is a metric used to understand how much of your code is tested. Everybody loves it!

Let's add another entry to the scripts section in the package.json file.
"scripts": {
...
"test:nodejs:coverage": "vitest run --coverage"
}

 

To collect coverage information, you have to install the provider to be used, either C8 (native Nodejs code coverage) or Istanbul (instrumented code coverage). C8 is the default option, and the evolution of Istanbul, therefore we'll use it.
$ npm install --save-dev @vitest/coverage-v8

 

Run the command.
$ npm run test:nodejs:coverage

 


Vitest results with code coverage


 

It works!!! We’ve got code coverage! However, it only works with jsdom. Can you guess why? Correct! Because Happy DOM loaded our UI5 controller via HTTP request! If we use jsdom as we did with Happy DOM, we'd get the same results: no code coverage whatsoever. That's why we're using JSDOM.fromFile.

In the next blog in this series, we'll see the native new kid on the block: Node Test Runner.

 

GitHub sample


If you want to check the end result, I have a branch implementing it: https://github.com/mauriciolauffer/openui5-sample-app/tree/vitest

 
5 Comments
Labels in this area