Technology Blog Posts by Members
cancel
Showing results for 
Search instead for 
Did you mean: 

In this post, I share practical experience with QUnit tests from my UI5 projects.
This post is aimed at developers who are already familiar with SAP UI5 and just getting started with QUnit. It was originally intended as an onboarding guide, but I noticed that information on this topic is quite scattered.
The examples in this post are kept close to real project code and can also be used as a reference when implementing similar test cases.

 

1. Setup

1.1 Installing the required libraries

To implement and execute QUnit tests in a UI5 application, just a few libraries are needed.
We just add them to the dependencies section of the application's package.json.

Depending on the project, either a specific version or simply latest can be used.

2bf0122c-3d55-455e-9ff0-7f420cfa5216.png

 

 

 

 

 

 

 

 

 

 

 

 

For later test coverage analysis (see section 3. Running Tests & Coverage), an additional library should be included:

fc07ecd0-4e3d-40a4-acc5-db89aaeec475.png

 

 

 

 

 

 

This library also needs to be added to the ui5.yaml under "server>customMiddleware>".

0c5b668b-f789-4c0f-b720-2c3759930ba2.png

 

 

After adding the required libraries, they can be installed using:

npm install

 

1.2 Folder structure

The folder structure for the unit tests is quite simple and is set up as follows:

- webapp
   - test
      - unit
         AllTests.js
         unitTests.qunit.html
         unitTests.qunit.js

d4c4ece3-f37d-4551-937b-eb15392cf5d0.png

 

 

 

 

 

 

 

 

 

 

 

 

 

 

The actual test files should ideally follow a structure similar to the application itself.
This not a rule -> just for readability
For example, if unit tests are required for BaseController.js, a corresponding folder controller and a file BaseController.js should be created inside the test directory:

- webapp
   - controller
      BaseController.js

   - test
      - unit
         - controller
            BaseController.js

21df0360-5d37-40a8-9ee4-560ab966e762.png

 

 

 

 

 

 

 

1.2.1 AllTests.js

All QUnit tests to be executed are included in the define section of this file.
It is a central place where we can enable or disable tests for execution.

sap.ui.define([
    "./controller/BaseController",
    // "./controller/OtherController",
    // "./controller/OtherController2",
    // "./services/SampleService",
    // "./util/SampleFormatter",
], function () {
    "use strict";
});

(Tip)

When working on unit tests for a specific controller an running them frequently, tests for other controllers can be commented out here, so they are not executed each time.

 

1.2.2 unitTests.qunit.html

Short and simple: we start our tests using this file.
All required libraries for test execution are included here.

<!DOCTYPE html>
<html>
<head>
    <title>Unit tests for Template</title>
    <meta charset="utf-8">

    <script id="sap-ui-bootstrap"
        src="../../../../resources/sap-ui-core.js"
        data-sap-ui-resourceroots='{
            "app.unittester": "../"
        }'
        data-sap-ui-async="true"
        data-sap-ui-preload="async">
    </script>

    <link rel="stylesheet" type="text/css" href="../../../../resources/sap/ui/thirdparty/qunit-2.css">

    <script src="../../../../resources/sap/ui/thirdparty/qunit-2.js"></script>
    <script src="../../../../resources/sap/ui/qunit/qunit-junit.js"></script>
    <script src="../../../../resources/sap/ui/thirdparty/sinon.js"></script>
    <script src="../../../../resources/sap/ui/thirdparty/sinon-qunit.js"></script>

    <script src="../../../../resources/sap/ui/qunit/qunit-coverage-istanbul.js"
        data-sap-ui-cover-only="app/unittester/"
        data-sap-ui-cover-never="app/unittester/test, app/unittester/localServices, app/unittester/services">
    </script>

    <script src="../../../../resources/sap/ui/thirdparty/sinon.js"></script>
    <script src="../../../../resources/sap/ui/thirdparty/sinon-qunit.js"></script>

    <script src="unitTests.qunit.js"></script>
</head>

<body>
    <div id="qunit"></div>
    <div id="qunit-fixture"></div>
</body>
</html>

Just a few notes on the most important scripts that are included here (because we will use them later).

QUnit

<link rel="stylesheet" type="text/css" href="../../../../resources/sap/ui/thirdparty/qunit-2.css">
<script src="../../../../resources/sap/ui/thirdparty/qunit-2.js"></script>
<script src="../../../../resources/sap/ui/qunit/qunit-junit.js"></script>

(Roughly summarized) The actual QUnit framework is included here.

sinon and sinon-qunit

<script src="../../../../resources/sap/ui/thirdparty/sinon.js"></script>
<script src="../../../../resources/sap/ui/thirdparty/sinon-qunit.js"></script>

These libraries enable spying, stubbing and mocking dependencies.

qunit-coverage-istanbul.js (Code Coverage Integration)

<script src="../../../../resources/sap/ui/qunit/qunit-coverage-istanbul.js"
        data-sap-ui-cover-only="app/unittester/"
        data-sap-ui-cover-never="[
           app/unittester/test, 
           app/unittester/localServices, 
           app/unittester/services
        ]">
</script>

This is required later for code coverage.

(Note) In this post, we use Istanbul for code coverage.
Of course, other options such as Karma can also be used if desired.

Important attributes:

  • data-sap-ui-cover-only
    files to be included in the coverage
  • data-sap-ui-cover-never
    files and directories to be excluded from coverage

 

1.2.3 unitTests.qunit.js

This is the central entry point for the unit tests.
Here we only include the tests from AllTests.js in the define section and start the test run.

QUnit.config.autostart = false;

sap.ui.getCore().attachInit(function () {
    "use strict";

    sap.ui.require([
        "app/unittester/test/unit/AllTests"
    ], function () {
        QUnit.start();
    });
});

2. Implementing unit tests

In this example, we will look at some unit tests for the BaseController as well as the basic structure.
In addition, we will see how sinon can be used to handle dependencies.

2.1 Basic structure of a test module

Each module has its own tests and provides lifecycle hooks such as beforeEach and afterEach.
The modules can be structured freely, depending on the requirements.

We start with a test module for BaseController.js and implement the module in the test file.
(webapp>test>unit>controller>BaseController)

sap.ui.define([
	"app/unittester/controller/BaseController",
	"sap/ui/thirdparty/sinon",
	"sap/ui/thirdparty/sinon-qunit",
], function(BaseController) {
	"use strict";
	
	/**
	* Module
	*/
	QUnit.module("Module", {
	   beforeEach: function() {
		  this.oBaseController = new BaseController();
	   },
	   afterEach: function() {
		  this.oBaseController.destroy();
	   }
	});
	
});

First, we include the actual BaseController and the two sinon libraries in the define statement.

After that, the module itself:
The lifecycle functions of the module are executed before and after each test.
In order to test the functions of the BaseController, we instatiate it before the test and destroy it afterwards.

2.2 Simple Test using sinon.stub and sinon.spy

In our first test, we will take the getRouter function from our BaseController.

getRouter: function() {
	return this.getOwnerComponent().getRouter();
},

We only want to test whether the function can be executed.

However, it has two dependencies:
this.getOwnerComponent and its function getRouter.

In order to test getRouter without having to instantiate all dependencies, we will "simulate" them with sinon.

This would look like the following:

sap.ui.define([
	"app/unittester/controller/BaseController",
	"sap/ui/thirdparty/sinon",
	"sap/ui/thirdparty/sinon-qunit",
], function(BaseController) {
	"use strict";
	
	/**
	* Module
	*/
	QUnit.module("Module", {
	   beforeEach: function() {
		  this.oBaseController = new BaseController();
	   },
	   afterEach: function() {
		  this.oBaseController.destroy();
	   }
	});
	
	/**
	* BaseController - Check getRouter
	*/
	QUnit.test("BaseController - Check getRouter", function(assert) {
	   sinon.stub(this.oBaseController, "getOwnerComponent").returns({
		  getRouter: sinon.stub()
	   });

	   const fnSpy = sinon.spy(this.oBaseController, "getRouter");
	   this.oBaseController.getRouter();
	   assert.ok(fnSpy.calledOnce, "Check getRouter successful");
	});
	
});

The function flow:

In line 24, we create a stub for this.oBaseController (our controller instance) and listen for calls to the getOwnerComponent function.
As soon as this function is executed, a return value is "simulated" that provides another simulated function, getRouter.

After that, we create a spy for this.oBaseController, that listens for calls to the getRouter function.

Then comes the actual function call in line 29.

Finally, we use an assertion where we check (via fnSpy.calledOnce) whether getRouter was executed at least once within the function.

And that's basically it. Every following test has the same structure (sometimes more or less complex).

Running the test:
If we look at the coverage here now (more on this later), we can see that the function was executed exactly once:

Citrix.DesktopViewer.App_Ap7HtUUpEr.png

 

 

 

2.3 Testing functions with Return Values or Promises

Many controller methods are asynchronous and return a Promise.

These tests are implemented using async and await.
Using await ensures that the test waits properly for execution to finish.

Example: onStartEditMode

onStartEditMode: async function () {
	try {
		this._fireSetViewBusy(true);
		await this.oDraftManager.onEdit();
	} catch (oError) {
		this.evaluateException(oError);				
	} finally {
		this._fireSetViewBusy(false);				
	}						
},

The corresponding test:

QUnit.test("BaseController - Start edit mode", async function (assert) {
	sinon.stub(this.oBaseController, "_fireSetViewBusy");
	sinon.stub(this.oBaseController, "evaluateException");

	this.oBaseController.oDraftManager = {
		onEdit: sinon.stub().returns(Promise.resolve(true))
	};		

	const fnSpy = sinon.spy(this.oBaseController, "onStartEditMode");
	await this.oBaseController.onStartEditMode();
	assert.ok(fnSpy.calledOnce, "onStartEditMode executed successfully");
});

2.4 Testing error cases and exceptions

Error cases are just as important as successful scenarios.

In this example, we provoke an error by not stubbing any dependencies, so the function runs into the error path.
This verifies that the error handling logic is executed correctly.

Example function onFullScreenPressed:

onFullScreenPressed: function() {
	try {
		this._fireSetLayout("MidColumnFullScreen");
		this.getOwnerComponent().getModel("util").setProperty(`/ViewControl/Layout`, "MidColumnFullScreen");
		return true;
	} catch (oError) {
		this.evaluateException(oError);
		return false;
	}
},

The test:

QUnit.test("BaseController - Check onFullScreenPressed Exception", async function(assert) {
	assert.notOk(await this.oBaseController.onFullScreenPressed(), "Check onFullScreenPressed Exception successful");
});

2.5 Larger example with more branches

A slightly larger example is the onCancel function, which is a bit more complex. 

The function checks whether changes exist in the current draft.

Several paths (branches):
Changes exist: the user is shown a dialog, where they can decide whether to discard the draft
No changes: the draft is discarded
Error case: an error message is shown

onCancel:

onCancel: async function(oEvent) {
	try {
		const oData = this.getView().getBindingContext().getObject();

		//are there changes?
		if (oData.DraftEntityCreationDateTime.toString() !== oData.DraftEntityLastChangeDateTime.toString()) {
			const oSource = oEvent.getSource();
			this._openDiscardDraftDialog(oSource);
			return true;
		} else {
			//no changes, so discard the draft
			return Promise.resolve(await this.onDiscardDraft());
		}
	} catch (oError) {
		this.evaluateException(oError);
		return false;
	}
},

We will now test all paths to achieve full coverage.

1. onCancel with draft changes

/**
 * BaseController - Check onCancel (with draft changes)
 */
QUnit.test("BaseController - Check onCancel - with draft changes", async function(assert) {
	this.oBaseController.onDiscardDraft = sinon.stub().returns(Promise.resolve(true));
	sinon.stub(this.oBaseController, "_openDiscardDraftDialog");
	sinon.stub(this.oBaseController, "getView").returns({
		getBindingContext: sinon.stub().returns({
			getObject: sinon.stub().returns({
				DraftEntityCreationDateTime: 1,
				DraftEntityLastChangeDateTime: 2
			})
		})
	});
	const oMockButton = new Button();
	const oEvent = {
		getSource: function() {
			return oMockButton;
		}
	};

	assert.ok(await this.oBaseController.onCancel(oEvent), "Check onCancel (with draft changes) successful");
});

2. onCancel without changes

/**
 * BaseController - Check onCancel (no draft changes)
 */
QUnit.test("BaseController - Check onCancel - no changes", async function(assert) {
	this.oBaseController.onDiscardDraft = sinon.stub().returns(Promise.resolve(true));
	sinon.stub(this.oBaseController, "getView").returns({
		getBindingContext: sinon.stub().returns({
			getObject: sinon.stub().returns({
				DraftEntityCreationDateTime: 1,
				DraftEntityLastChangeDateTime: 1
			})
		})
	});

	assert.ok(await this.oBaseController.onCancel(), "Check onCancel (no draft changes) successful");
});

3. onCancel error case

/**
 * BaseController - Check onCancel Exception
 */
QUnit.test("BaseController - Check onCancel Exception", async function(assert) {
	assert.notOk(await this.oBaseController.onCancel(), "Check onCancel Exception successful");
});

When we now run the tests, we can see that we have covered all paths (branches):

Citrix.DesktopViewer.App_89tf2RbxCT.png

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

3. Running tests and coverage

Unit tests can be executed in different ways.
In practice, running them via an npm script has proven to be the most convenient option.

3.1 Running tests

Unit tests can be started using a script defined in package.json:

ead89be6-66ce-46de-ba9e-71ee5911ac1e.png

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

The test run is then started using the following command:

npm run unit-tests

Once the tests are started, the browser opens and all tests (that are not commented out in AllTests.js) are executed automatically.

chrome_ESZgy2uDlt.png

 

 

 

 

 

 

 

 

 

 

 

3.2 Test results in the QUnit interface

After the tests have been executed, an overview is displayed in the browser showing which tests were successful an which were not.
This is also indicated by colors and shown together with an error message.

Example:

Citrix.DesktopViewer.App_x1y8OK1r6d.png

 

 

 

 

 

 

 

 

 

Failed tests can also be inspected in the browser console.

 

3.3 Enabling code coverage

Code coverage can be enabled using the checkbox "Enable coverage" in the QUnit toolbar.

chrome_wCUCF5JgZH.png

 

 


Once enabled, tests are re-run automatically and an interactive coverage overview is displayed below the test results.

chrome_8BNiBACVoC.png

 

 

 

In the coverage overview, we can see the tested controllers and the information about the coverage itself.

statements and lines
Shows which lines of code were executed during the tests.
Citrix.DesktopViewer.App_0wXwB0uyW2.png

 

 

Citrix.DesktopViewer.App_HBOPFpsmIf.png

 

 

 

functions
Indicates which functions were called at least once.

Citrix.DesktopViewer.App_9BZibtM57b.png

 

 

branches
Shows whether different execution paths (e.g. if/else, try/catch) were actually tested.

Citrix.DesktopViewer.App_ytqFExFz8B.png

 

 

 

3.4 Detailed view of individual files

Clicking a file opens a detailed coverage view.


b133915d-24cb-42aa-bded-f293d2c098e4.png

 

 

 

 

 

 

 

 

 

 

green lines were executed
red lines were not covered

991f915c-863c-48bf-9760-32060dfd6041.png

 

 

 

 

 

 

3.5 Coverage reports

In addition to the browser view, a coverage report is generated under:
tmp/coverage-reports/html/index.html

Alternatevly, it can also be found in the IDE's project folder:

1e4624f5-8474-419b-a54c-40e0d2aad0d7.png

 

 

 

 

 

 

 

 

 

 

The report is updated automatically, can be opened in the browser independently.

4. Conclusion

With QUnit, we mainly focus on isolated controller logic and function behavior. 
Therefore keeping functions cleanly encapsulated and dependencies low during development leads to much easier unit tests

A note on coverage:
I know that coverage percentages are often defined as targets.
I have also seen that the temptation to push these numbers can be quite high by starting to test trivial functions (e.g. getters or setters). This usually backfires sooner or later.
It makes much more sense to focus on testing critical logic, error cases and especially different paths and branches.

3 Comments
Labels in this area