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 Node Test Runner (aka node:test module) 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 Node Test Runner 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 🙂

 

Node Test Runner


"The node:test module facilitates the creation of JavaScript tests."

https://nodejs.org/api/test.html

 

This blog is going to follow the same structure from the previous blogs, we're going to use Happy DOM and jsdom again, and we'll test UI5 code. As note:test is a Node built-in module, we don't need to install an external test runner.

Let’s update the package.json file with a script command to run our tests (not in watch mode, the feature exists in experimental status). Node Test Runner will execute all *.js files inside test folder unless you tell it to look somewhere else. So, we'll call node with the new --test flag and tell it to get our tests from the folder webapp/test/unit-node (which will be created in the next step).
"type": "module",​
"scripts": {
...
"test:nodejs" : "node --test webapp/test/unit-node"
}

 

Create a new folder to host the tests, don't mix the new tests with the old ones otherwise all of them'll be executed. We’re naming the folder: unit-node.

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


Node Test Runner doesn't have anything similar to the test environments we have seen in Jest and Vitest. You must manually start your javascript browser-like. No need for docblocks then.

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

Let’s create the test file: webapp/test/unit-node/App.controller.happydom.test.js

Because node:test is just another nodejs built-in module, it doesn't pollute globals as Jest does, we need to import what we want to use from the module into the test file. Standard procedure in Nodejs, same as we did with Vitest. There're a couple of extra things we need to import, the node:assert module and happy-dom (no test environment to do it for us).
import { describe, it, before, beforeEach, after, afterEach, mock } from 'node:test';
import assert from 'node:assert';
import { Window } from 'happy-dom';

 

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 Node Test Runner, we use describe. In QUnit, we use QUnit.test to create the test block. In Node Test Runner, we use it or test (they’re the same).

Now, we have to manually start Happy DOM. We need it ready before executing any test, so we’re going to use the before hook to start it before all tests are executed. Jest and Vitest hooks are called beforeAll, somebody decided to drop the All sufix just to see whether we're paying attention.

Then, we can add some basic test to check whether Happy DOM has been loaded correctly. If it was, the window local variable will be populated.
describe('test suite happyDOM', function () {
let window = {};
let sap = {};

before(async () => {
//Start Happy DOM
window = new Window({ url: 'http://localhost:8080/' });
});

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

 

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 before hook in describe 'test suite happyDOM' section again.

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 🙁
before(async () => {
//Start Happy DOM
window = new Window({ url: 'http://localhost:8080/' });
// Patch window.performance.timing because it doesn't exist in nodejs nor happyDOM
window.performance.timing = {
fetchStart: Date.now(),
navigationStart: Date.now()
};

const scriptUi5Bootstrap = window.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";
window.document.body.appendChild(scriptUi5Bootstrap);
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 () {
assert.ok(sap);
assert.ok(sap.ui.demo.todo.controller.App);
});

 

So far so good. Let’s convert those QUnit tests from webapp/test/unit/App.controller.js to Node Test Runner. We’re creating a new nested describe section. As you can notice, the main differences are: using context rather than this, node:test mock functions rather than sinon, 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', async 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: []
});
mock.method(sap.ui.core.mvc.Controller.prototype, "getView", () => context.oViewStub);
context.oViewStub.setModel(context.oJSONModelStub);
});

afterEach(() => {
mock.reset();
});

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

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

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

 

Have you noticed the assertions are the same as in QUnit? In node:test we use assert rather then expect as in Jest and Vitest. Also, the context is not a local variable, it’s a parameter from the test suite functions, 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 Node Test Runner Happy DOM test file looks like:
import { describe, it, before, beforeEach, after, afterEach, mock } from 'node:test';
import assert from 'node:assert';
import { Window } from 'happy-dom';

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

before(async () => {
//Start Happy DOM
window = new Window({ url: 'http://localhost:8080/' });
// Patch window.performance.timing because it doesn't exist in nodejs nor happyDOM
window.performance.timing = {
fetchStart: Date.now(),
navigationStart: Date.now()
};

const scriptUi5Bootstrap = window.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";
window.document.body.appendChild(scriptUi5Bootstrap);
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', async function () {
it('test if happyDOM has been loaded', function () {
assert.ok(window);
assert.ok(window.document);
assert.ok(window.document.body);
});

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

describe('Test init state', async 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: []
});
mock.method(sap.ui.core.mvc.Controller.prototype, "getView", () => context.oViewStub);
context.oViewStub.setModel(context.oJSONModelStub);
});

afterEach(() => {
mock.reset();
});

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

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

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

 

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

 

Using jsdom


You already know Node Test Runner doesn’t have any test environment as in Jest and Vitest. But we didn't use it for jsdom anyways, so the setup won't be different.

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-node/App.controller.jsdom.test.js

Now, we configure jsdom. Import node:test, node:assert and jsdom. Pure ECMAScript Modules, no need for CommonJS.

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, before, beforeEach, after, afterEach, mock } from 'node:test';
import assert from 'node:assert';
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 before hook to load it before all tests are executed. Place before 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 before, we use the after 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 = {};

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

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

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

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

 

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

Let’s check out how our UI5 Node Test Runner jsdom test file looks like:
import { describe, it, before, beforeEach, after, afterEach, mock } from 'node:test';
import assert from 'node:assert';
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 = {};

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

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

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

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

describe('Test init state', async 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: []
});
mock.method(sap.ui.core.mvc.Controller.prototype, "getView", () => context.oViewStub);
context.oViewStub.setModel(context.oJSONModelStub);
});

afterEach(() => {
mock.reset();
});

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

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

var oModel = context.oAppController.getView().getModel("view").getData();
assert.deepEqual(oModel, { 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 sequence by default, unlike Jest and Vitest where the default is in parallel. You also have the option to run the tests in parallel setting the concurrency parameter.
$ 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!

Pay attention to the fact code coverage is still experimental 😞

Let's add another entry to the scripts section in the package.json file.
"scripts": {
...
"test:nodejs:coverage": "node --test --experimental-test-coverage webapp/test/unit-node-test-runner"
}

 

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

 


Node Test Runner results with code coverage


 

It works!!! We’ve got code coverage! Actually, it works better than expected as we've got code coverage for both Happy DOM and jsdom! We also got an unexpected coverage for the test files themselves. Which is weird, but I liked (I guess?). For now, you cannot remove specific files or directories from the coverage report.

 

GitHub sample


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

 
2 Comments
Labels in this area