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
Contributor
This is a blog series on exploring modern javascript testing frameworks in Fiori/UI5 context. In this one, we’ll see how to use Jest 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 Jest 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 🙂

 

Jest


"Jest is a delightful JavaScript Testing Framework with a focus on simplicity."

https://jestjs.io

 

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

 

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

 

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-jest. Jest 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


Jest has a great feature called test environment which allows running Jest in multiple test environments (node, jsdom, happy-dom, custom) each with its own special setup. We’re going to use the Happy DOM environment built for Jest, so we don’t need to manually start it up.

Install @happy-dom/jest-environment
$ npm install --save-dev @happy-dom/jest-environment

 

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

Jest test environment can be configured in a jest.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.
/**
* @jest-environment @happy-dom/jest-environment
*/

 

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 Jest, we use describe. In QUnit, we use QUnit.test to create the test block. In Jest, 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 = {};
let context = {};

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.

Before we continue, I want to mentioned that I managed to create a Babel plugin to transpile UI5 AMD-like modules to CommonJS modules. It'll transpile your UI5 files and Jest will be able to load them with native Nodejs require('./your_ui5_controller.js'). However, it'll fail because standard UI5 libraries aren't available as CommonJS modules. You can have a go, perhaps you'll find a workaround for this issue: https://github.com/mauriciolauffer/babel-plugin-transform-ui5-to-commonjs

Going back to our tests, 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 new Promise((resolve, reject) => {
setTimeout(() => {
sap = window.sap;
sap.ui.require([
"sap/ui/demo/todo/controller/App.controller"
], function () {
resolve();
}, function (err) {
reject(err);
});
}, 2000);
});
}, 20000);

 

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 Jest. We’re creating a new nested describe section. As you can notice, the main differences are: using context variable rather than this, Jest 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.oAppController = new sap.ui.demo.todo.controller.App();
context.oViewStub = new sap.ui.base.ManagedObject({});
context.oJSONModelStub = new sap.ui.model.json.JSONModel({
todos: []
});
jest.spyOn(sap.ui.core.mvc.Controller.prototype, 'getView').mockReturnValue(context.oViewStub);
context.oViewStub.setModel(context.oJSONModelStub);
});

afterEach(() => {
jest.clearAllMocks();
context = {};
});

it('Check controller initial state', () => {
// 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 });
});
});

 

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

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

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 new Promise((resolve, reject) => {
setTimeout(() => {
sap = window.sap;
sap.ui.require([
"sap/ui/demo/todo/controller/App.controller"
], function () {
resolve();
}, function (err) {
reject(err);
});
}, 2000);
});
}, 20000);

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.oAppController = new sap.ui.demo.todo.controller.App();
context.oViewStub = new sap.ui.base.ManagedObject({});
context.oJSONModelStub = new sap.ui.model.json.JSONModel({
todos: []
});
jest.spyOn(sap.ui.core.mvc.Controller.prototype, 'getView').mockReturnValue(context.oViewStub);
context.oViewStub.setModel(context.oJSONModelStub);
});

afterEach(() => {
jest.clearAllMocks();
context = {};
});

it('Check controller initial state', () => {
// 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 Jest 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-jest/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.
/**
* @jest-environment node
*/

 

Now, we configure jsdom. Import jsdom and 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.
const { JSDOM } = require('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 = {};
let context = {};

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

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 Jest testing. The differences are: how to start the fake browser and how to load UI5 code.

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

const { JSDOM } = require('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 = {};
let context = {};

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

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.oAppController = new sap.ui.demo.todo.controller.App();
context.oViewStub = new sap.ui.base.ManagedObject({});
context.oJSONModelStub = new sap.ui.model.json.JSONModel({
todos: []
});
jest.spyOn(sap.ui.core.mvc.Controller.prototype, 'getView').mockReturnValue(context.oViewStub);
context.oViewStub.setModel(context.oJSONModelStub);
});

afterEach(() => {
jest.clearAllMocks();
context = {};
});

it('Check controller initial state', () => {
// 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": "jest --coverage"
}

 

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


Jest results with code coverage


 

Jest doesn't like the way we loaded our UI5 controller (sap.ui.require) and couldn't instrument the code execution (neither Istanbul nor C8 works). Big problem here 😭

You may say I should've told you before to avoid all the work here. Fair enough. But I wanted to show that it's possible to test UI5 with Jest because a lot of people asked it. This is uncharted waters for Fiori/UI5 sailors. Perhaps, someone smarter than me will figure out code coverage in this scenario.

But don't worry, Vitest and Node Test Runner got you covered! They work just fine, we'll see it in the next blogs…

 

GitHub sample


If you want to check the end result, I have a branch implementing it: https://github.com/mauriciolauffer/openui5-sample-app/tree/jest
2 Comments
Labels in this area