- 1. Setup
- 1.1 Installing the required libraries
- 1.2 Folder structure
- 1.2.1 AllTests.js
- 1.2.2 unitTests.qunit.html
- 1.2.3 unitTests.qunit.js
- 2. Implementing unit tests
- 2.1 Basic structure of a test module
- 2.2 Simple Test using sinon.stub and sinon.spy
- 2.3 Testing functions with Return Values or Promises
- 2.4 Testing error cases and exceptions
- 2.5 Larger example with more branches
- 3. Running tests and coverage
- 3.1 Running tests
- 3.2 Test results in the QUnit interface
- 3.3 Enabling code coverage
- 3.4 Detailed view of individual files
- 3.5 Coverage reports
- 4. Conclusion
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.
For later test coverage analysis (see section 3. Running Tests & Coverage), an additional library should be included:
This library also needs to be added to the ui5.yaml under "server>customMiddleware>".
After adding the required libraries, they can be installed using:
npm install1.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
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
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:
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):
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:
The test run is then started using the following command:
npm run unit-testsOnce the tests are started, the browser opens and all tests (that are not commented out in AllTests.js) are executed automatically.
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:
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.
Once enabled, tests are re-run automatically and an interactive coverage overview is displayed below the test results.
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.
functions
Indicates which functions were called at least once.
branches
Shows whether different execution paths (e.g. if/else, try/catch) were actually tested.
3.4 Detailed view of individual files
Clicking a file opens a detailed coverage view.
green lines were executed
red lines were not covered
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:
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.