Although Qt comes with a unit-test framework QuickTest for QML code, hardly any QML GUIs are unit-tested, let alone developed with TDD (test-driven development). One reason is that many developers regard unit tests for GUI code as a waste of time. Another reason is that QuickTest is poorly documented. My post will change this with a walkthrough how to test-drive a QML keypad.
Keypad Requirements
With the keypad below, drivers of ROPA sugar beet harvesters can change ECU parameters. Here, they can change the speed of the conveyor belt transporting the sugar beets from the harvester bunker to a tractor trailer. The ECU parameters are C++ objects passed between C++ and QML. When the user accepts the new value, the changed parameter is sent to the ECU (Electronic Control Unit).
The list of requirements for the keypad is pretty long. And it’s not even complete. For example, the requirements for changing the sign of the displayed number or for entering floating-point numbers are missing.
- When the user opens the keypad, it displays the current value of the parameter and enters the initial state.
- When the user presses one of the digit keys in the initial state, the keypad clears the value, shows the digit and enters the entry state.
- When the user presses one of the digit keys in the entry state, the keypad appends the digit to the displayed value and stays in the entry state.
- When the user presses the Accept button at the top right, the keypad is closed and passes the changed parameter value to the caller.
- When the user presses the Reject button at the top left, the keypad is closed without changing the parameter value.
- When the user presses the clear button “C”, the keypad deletes the rightmost digit of the displayed value and goes to the entry state.
- When the user presses the “Reset” button, the keypad displays the default value of the parameter and goes to the entry state.
- When the user presses one of the increment or decrement keys (e.g., -10 or +10), the keypad increments or decrements the displayed value accordingly.
- When the displayed value is outside the range given by the minimum (e.g., 0) and maximum (e.g., 65535) of the parameter value and shown to the left and to the right of the displayed value, the displayed value is highlighted in red.
Such a formidable list of requirements is a clear sign that we are developing serious code and not just some colourful buttons. As serious developers, we’ll apply our best coding practices. We’ll apply TDD to writing QML GUI code.
Qt provides Qt Quick Test unit-testing QML code. Unfortunately, it’s poorly documented. The example test checks that 2 + 2 equals 4. We don’t learn how to write the CMakeLists.txt
files for QML tests, how to make mouse clicks work in tests, or how to access the digit buttons used in the keypad. My post will explain this and how to test-drive the implementation of the keypad.
The source code for this post is available in the directory examples/qml-keypad of the Github repository tdd-training-add-ons.
Setting Up a QML Test Case
The project qml-keypad
is set up like a real-life QML application. The main QML application is in the directory main
. The main QML file imports our custom controls from the QML module EmUse.Controls
. It also instantiates the Keypad
, which is the QML component under test. The QML module with the custom controls can be found in imports/src/EmUse/Controls/
. The tests for the custom controls are located in subdirectories of imports/tests
. The test for the keypad, for example, is in imports/tests/keypad
.
# Directory layout of project qml-keypad
main/
main.cpp
main.qml
imports/
src/EmUse/Controls/
Keypad.qml
tests/keypad/
tst_keypad.cpp
tst_keypad.qml
The CMakeLists.txt
of the QML module EmUse.Controls
and its tests rely heavily on qt_add_qml_module
, which was introduced in Qt 6.2. Let us look at the CMakeLists.txt
file of the QML module first.
# File: imports/src/EmUse/Controls/CMakeLists.txt
qt_add_qml_module(${PROJECT_NAME}
URI EmUse.Controls
VERSION 0.1
RESOURCE_PREFIX /imports
QML_FILES Keypad.qml
)
add_library(EmUse::Controls ALIAS ${PROJECT_NAME})
The command qt_add_qml_module
stores the file Keypad.qml
at the path qrc:/imports/EmUse/Controls/Keypad.qml
in the Qt resource file system. The resource path is the concatenation of the RESOURCE_PREFIX
and of the URI
, where dots are replaced by slashes. Among other things, the command creates the qrc
, qmltypes
and qmldir
files and compiles the QML files into C++ files.
The build produces two shared libraries for the QML module: the backing library libEmUseControls.so
and the plugin library libEmUseControlsplugin.so
. When the QML engine sees the statement import EmUse.Controls,
it loads the plugin library, which in turn loads the backing library. We make these libraries known to the build under the alias EmUse::Controls
.
The CMakeLists.txt
file of the main application adds the alias EmUse::Controls
to its target_link_libraries
. The main function main()
adds qrc:/imports
as an import path:
// File: main/main.cpp
QQmlApplicationEngine appEngine;
appEngine.addImportPath(u"qrc:/imports"_qs);
The test case for the keypad could import the module EmUse.Controls
in the same way as the main application. However, we wouldn’t test the keypad in isolation any more but with all its direct and indirect dependencies. This approach would reduce the controllability and observability of the tests. Writing tests would become more difficult.
We use a different approach with the test application that instantiates a Keypad
item and runs tests on this instance. The CMakeLists.txt
file looks as follows:
# File: imports/tests/keypad/CMakeLists.txt
project(TestKeypad)
find_package(Qt6 COMPONENTS Quick QuickTest REQUIRED)
add_executable(${PROJECT_NAME}
tst_keypad.cpp
)
set_source_files_properties(
../../src/EmUse/Controls/Keypad.qml PROPERTIES
QT_RESOURCE_ALIAS Keypad.qml)
qt_add_qml_module(${PROJECT_NAME}
URI EmUse.Tests.Keypad
VERSION 0.1
QML_FILES tst_enter_number.qml ../../src/EmUse/Controls/Keypad.qml
)
target_link_libraries(${PROJECT_NAME}
PRIVATE Qt6::Quick Qt6::QuickTest)
The test executable or test runner is in tst_keypad.cpp
and links against the Qt libraries Qt6::Quick
and Qt6::QuickTest
. The test case with the test functions is in tst_enter_number.qml
. The test runner executes all test cases whose filenames start with tst_
.
The command set_source_files_properties
makes the real source file ../../src/EmUse/Controls/Keypad.qml
known as the Qt resource file Keypad.qml
. The command qt_add_qml_module
places the source files Keypad.qml
and tst_enter_number.qml
in the Qt resource path qrc:/EmUse/Tests/Keypad
. These two commands perform the following mapping.
Real file system -> Qt resource file system
../../src/EmUse/Controls/Keypad.qml -> qrc:/EmUse/Tests/Keypad/Keypad.qml.
tst_enter_number.qml -> qrc:/EmUse/Tests/Keypad/tst_enter_number.qml
The test runner doesn’t need any further help to find the QML files. Its source code is simple.
// File: imports/tests/keypad/tst_keypad.cpp
#include <QtQuickTest>
QUICK_TEST_MAIN(QmlKeypad)
QUICK_TEST_MAIN
is for QML tests what QTEST_MAIN
and its variants are for Qt/C++ tests. QmlKeypad
is the name of the test suite comprising all the QML test cases (files starting with tst_).
Showing the QML test case is past due now. Here is the source code of tst_enter_number.qml
.
# File: imports/tests/keypad/tst_enter_number.qml
import QtQuick
import QtTest
TestCase
{
name: "EnterIntegerNumber"
Keypad
{
id: keypad
}
function test_setup()
{
verify(false, "All set up! Now write a proper test");
}
}
The name of the test case is EnterIntegerNumber
. It is used in the test output. The test function above, for example, shows up as QmlKeypad::EnterIntegerNumber::test_setup
in the test output.
The test case instantiates the component under test Keypad
and provides a test function test_setup
. We’ll add more meaningful tests in the next sections. For now, we only want to see whether our test setup works.
When we build the project QmlKeypad
, QtCreator will provide two run configurations: KeypadApp
and TestKeypad
. When we run the configuration TestKeypad
, QtCreator’s Application Output pane will show the following messages:
********* Start testing of QmlKeypad *********
Config: Using QtTest library 6.2.0, Qt 6.2.0 (arm64-little_endian-lp64 shared (dynamic) release build; by Clang 12.0.5 (clang-1205.0.22.11) (Apple)), macos 11.4
PASS : QmlKeypad::EnterIntegerNumber::initTestCase()
FAIL! : QmlKeypad::EnterIntegerNumber::test_setup() 'All set up! Now write a proper test' returned FALSE. ()
Loc: [.../tests/keypad/EmUse/Tests/Keypad/tst_enter_number.qml(17)]
PASS : QmlKeypad::EnterIntegerNumber::cleanupTestCase()
Totals: 2 passed, 1 failed, 0 skipped, 0 blacklisted, 46ms
********* Finished testing of QmlKeypad *********
As expected the test fails and suggests to write a proper test.
When we run the configuration KeypadApp
, the application will show an edit field with the text 933
. We won’t write tests for the visual appearance. We run the application when we add visual elements and check the appearance manually. We run the test suite when we add behaviour like pressing a digit key.
The code at this point is available under the tag tdd-qml-setup.
Pressing Key1 Zero, One and Many Times
We use James Grenning’s ZOMBIES approach to get rolling with TDD. We start with the first three letters:
- Zero – The button
key1
is pressed zero times. Check that nothing is displayed after instantiatingKeypad
. - One – The button
key1
is pressed once. Check that “1” is displayed in the keypad. - Many – The button
key1
is pressed two or more times. Check that “11”, “111” and so on are displayed in the keypad.
Pressing Key1 Zero Times
Our first test test_pressKey1ZeroTimes
checks that the number keypad.value
entered by the user and the number valueDisplay.text
displayed by the keypad are both the empty string.
function test_pressKey1ZeroTimes()
{
compare(keypad.value, "")
var valueDisplay = findChild(keypad, "valueDisplay")
compare(valueDisplay.text, "")
}
The test case fails, because the component Keypad
does not contain a property value
. Hence, we add this property to Keypad
.
property string value: ""
Now the test fails, because valueDisplay
is null
. The call findChild(keypad, "valueDisplay")
recursively searches the child tree of the keypad
item for an item with the object name "valueDisplay"
. We set the property objectName in the keypad’s TextField accordingly.
TextField
{
id: valueDisplay
objectName: "valueDisplay"
...
}
Now the test passes. Besides checking the initial condition of the keypad, the test forced us to define the interface of the keypad, the property value
, and to make the displayed number valueDisplay.text
available for checks.
Pressing Key1 Once
Our second test test_pressKey1
simulates the user clicking on the key button "1"
once and checks that the keypad displays the value “1”.
function test_pressKey1OneTime()
{
var key1 = findChild(keypad, "key1")
var valueDisplay = findChild(keypad, "valueDisplay")
mouseClick(key1)
compare(keypad.value, "1")
compare(valueDisplay.text, "1")
}
The test fails with the following error message.
TypeError: mouseClick requires an Item or Window type
This isn’t surprising, because the keypad lacks a button completely. We add the button below the TextField
in Keypad.qml
and assign it the object name "key1"
.
TextField { ... }
Button
{
id: key1
objectName: "key1"
text: "1"
}
We add a button with the object name "key1"
after the TextField
for displaying the entered value in Keypad.qml
. The test case fails with a different error message.
test_pressKey1OneTime() window not shown
The error message says that test_pressKey1
was run before the Keypad
was shown in a window. The mouse click goes into the void because there is no visible item yet to click on. As a remedy, we instantiate Keypad
inside a Window
in the same way as in main.qml
. We also tell the test case to execute the tests only after the window is shown, that is, when: windowShown
. Here is the relevant code.
// File: tst_enter_number.qml
TestCase
{
name: "EnterIntegerNumber"
when: windowShown
Window
{
width: 400
height: 300
visible: true
Keypad
{
id: keypad
anchors.fill: parent
}
}
...
All the error messages are gone. The test fails, because the key button doesn’t change the value yet when clicked. The simplest way to make the test pass is to set value
to “1” when the button key1
is clicked.
Button
{
objectName: "key1"
text: "1"
onClicked: value = "1"
}
The check compare(keypad.value, "1")
passes, whereas the check compare(valueDisplay.text, "1")
fails. The first test test_pressKey1ZeroTimes
fails as well. test_pressKey1ZeroTimes
is executed after test_pressKey1OneTime
. Hence, keypad.value
is “1” when test_pressKey1ZeroTimes
runs. Our tests depend on the execution order!
We fix this by introducing an init
function that initialises the Keypad
properties properly. We move the checks from test_pressKey1ZeroTimes
into init
and remove test_pressKey1ZeroTimes
. The QML TestCase
component calls the init
function before each test function.
function init()
{
keypad.value = ""
compare(keypad.value, "")
var valueDisplay = findChild(keypad, "valueDisplay")
compare(valueDisplay.text, "")
}
The checks in init
pass. The check of the displayed value in test_pressKey1ZeroTimes
still fails. We fix this by binding the entered value
to the text
property of the TextField
.
TextField
{
id: valueDisplay
text: value
...
The test passes now. The KeypadApp
displays 1 instead of nothing when we click the button key1
.
Pressing Key1 Many Times
No matter how often we press the button key1
in the KeypadApp
, the displayed value is always 1. This leads to the following test.
function test_pressKey1TwoTimes()
{
var key1 = findChild(keypad, "key1")
var valueDisplay = findChild(keypad, "valueDisplay")
mouseClick(key1)
mouseClick(key1)
compare(keypad.value, "11")
compare(valueDisplay.text, "11")
}
The test fails, because the displayed value is still “1” instead of “11”. We append “1” to value
when the button key1
is clicked. Then our new test passes.
Button
{
objectName: "key1"
text: "1"
onClicked: value += "1"
}
Entering Integer Numbers
We lay out the buttons for the digit, plus-minus and the clear keys in a grid layout with three columns. The plus-minus and clear keys are disabled. The beginning of the grid looks like this.
TextField { id: valueDisplay; ... }
GridLayout
{
columns: 3
Button
{
objectName: "key1"
text: "1"
onClicked: value += "1"
}
Button
{
objectName: "key2"
text: "2"
onClicked: value += "2"
}
Button
{
objectName: "key3"
text: "3"
onClicked: value += "3"
}
...
We make the test functions more readable by storing the results of the findChild
calls in member properties of TestCase
. We do this for all 10 digit buttons and the valueDisplay
.
property QtObject valueDisplay: findChild(keypad, "valueDisplay")
property QtObject key1: findChild(keypad, "key1")
property QtObject key2: findChild(keypad, "key2")
...
We replace the two existing tests by tests for entering the numbers 12007, 38398 and 43566. These numbers cover all digits. The test test_enterNumber12007
would look as follows. The other tests look similar.
function test_enterNumber12007()
{
mouseClick(key1)
mouseClick(key2)
mouseClick(key0)
mouseClick(key0)
mouseClick(key7)
compare(keypad.value, "12007")
compare(valueDisplay.text, "12007")
}
The code at this point is available under the tag tdd-qml-enter-number.
Accepting and Cancelling Entered Numbers
Before the user enters a new number with the keypad, the client of the Keypad
(the TestCase
in our case) stores the current value of the parameter in the property acceptedValue
. When the user accepts the entered number, the client stores the new number keypad.value
in the property acceptedValue
. On a harvester, the client would close the keypad and send the new number to the responsible ECU so that the ECU can change the parameter accordingly. When the user rejects or cancels the entered number, the client doesn’t change acceptedValue
.
The tests for accepting and cancelling an entered number look as follows. Checking for the empty string when cancelling is not quite right, but it helps to get the code in place. We’ll come to this a little bit later.
function test_acceptEnteredNumber()
{
mouseClick(key4)
mouseClick(key3)
mouseClick(ok)
compare(acceptedValue, "43")
}
function test_cancelEnteredNumber()
{
mouseClick(key4)
mouseClick(key3)
mouseClick(cancel)
compare(acceptedValue, "")
}
We add the Cancel and OK button at the bottom of the keypad. When the Cancel button is clicked, it emits the signal rejected
from the Keypad
. Similarly, the OK button emits the signal accepted
. They are defined at the beginning of Keypad
.
signal accepted()
signal rejected()
...
GridLayout { ... }
RowLayout
{
Button
{
objectName: "cancel"
text: "Cancel"
onClicked: rejected()
}
Button
{
objectName: "ok"
text: "OK"
onClicked: accepted()
}
}
The Keypad
instance in the TestCase
catches the signals and sets acceptedValue
accordingly.
property string acceptedValue: ""
Window
{
Keypad
{
...
onAccepted: acceptedValue = keypad.value
onRejected: acceptedValue = ""
}
...
The new and the old tests pass. It’s time to initialise acceptedValue
with the current parameter value (here "77"
) instead of the empty string and keypad.value
with acceptedValue
. We do this in the init
function of the TestCase
.
function init()
{
acceptedValue = "77"
keypad.value = acceptedValue
compare(keypad.value, acceptedValue)
compare(valueDisplay.text, acceptedValue)
}
This change makes all tests fail because all the entered numbers are now prefixed with "77"
. We fix test_cancelEnteredNumber
by changing the final check to compare(acceptedValue, "77")
and by removing the onRejected
handler in Keypad.
The other tests require a little bit more work. When the first digit button is pressed, keypad.value
is assigned the pressed digit. From then on, the digits are appended to keypad.value
again. We introduce the function addDigit
to handle these two cases.
function addDigit(digit)
{
if (!wasDigitEntered)
{
wasDigitEntered = true
value = digit
return
}
value += digit
}
The property wasDigitEntered
is initialised with false
when Keypad
is created. We must do this explicitly in the init
function of the TestCase
. When addDigit
is called for the first time, wasDigitEntered
is set to true and value
is assigned the clicked digit
. From then on, wasDigitEntered
is true
and the digits are appended to value
.
We make the onClick
handlers call the new function addDigit
. For example, button key4
looks like this.
Button
{
objectName: "key4"
text: "4"
onClicked: addDigit("4")
}
All the tests pass again. The code at this point is available under the tag tdd-qml-accept-cancel.
Ignoring Leading Zeros
The user must not be able to enter numbers with leading zeros like “00”, “04” and “007”. The keypad shall display these numbers as “0”, “4” and “7”, respectively. Here is the test for clicking key0
twice.
function test_ignoreLeadingZeros_00()
{
mouseClick(key0)
mouseClick(key0)
mouseClick(ok)
compare(acceptedValue, "0")
}
The new test fails. The actual value is “00” and the expected value “0”. Leading zeros arise, if the entered value is “0” and the user presses any digit button. The resulting number is the digit from the button pressed. We replace value
by the digit pressed, which is the if-case in addDigit
. Only the if-condition changes.
function addDigit(digit)
{
if (!wasDigitEntered || value == "0")
{
wasDigitEntered = true
value = digit
return
}
value += digit
}
All tests pass. The solution looks too simple to be right. So, we test “04” and “007”. These tests also pass. Our doubts were unfounded.
The code at this point is available under the tag tdd-qml-ignore-zeros.
The Keypad So Far
The keypad application looks as follows.
If we enter the number 396 and press Cancel, the keypad will still display 568. If we enter 443 and press OK, the keypad will display 443. So, we can try out the functionality we just developed using TDD.
We implemented the first five requirements from our test list using TDD on QML code. That’s pretty good! Requirements 6 and 7 (clear and reset button) are straightforward. Requirements 8 and 9 (increment & decrement and range check) are more challenging, but not too much when TDD guides us.
Of course, the visual appearance needs some serious improvement. Testing the visual appearance would be futile. Our unit tests help us make sure that we don’t break the functionality while changing the appearance.
Most importantly, we have a proper set up for test-driving QML code. And the keypad example demonstrates that applying TDD to GUI code is not only possible but makes a lot of sense.
Nice article, thanks for sharing!
You shouldn’t need to need to instantiate a Window, as QUICK_TEST_MAIN ensures that each tst_*.qml file gets its own QQuickView, and so “when: windowShown” should be enough.
I’ve created https://bugreports.qt.io/browse/QTBUG-107817 to track the documentation issues.
Hi Mitch,
I just removed the
Window
around theKeypad
in tst_enter_number.qml. Then, all tests fail – both with Qt 6.2.4 and Qt 6.3.1. I am a bit at a loss here. That’s where good documentation would come in handy 😉Thanks,
Burkhard
Thanks for the example.
I am a bit bugged by the need to set objectNames, in addition to the id’s you already have in QML. That’s code that would not be there but for the test itself. That makes these tests a bit too invasive in my taste. A tool like Squish deals with this in a more elegant way I think, allowing for different ways to identify your items that are generally not as invasive as you show in this example.
Hi André,
I think setting objectNames is a negligible price to pay for a free QML unit-test framework. If Squish found a way to use QML id’s, it’s probably also available in the free Qt version. Your ideas are welcome.
Cheers,
Burkhard
I disagree it’s trivial; it’s another thing to keep in sync, and it duplicates information already there. It is possible to read the id’s using the Qt API (QQmlContext::nameForObject), but Squish uses a whole range of techniques to allow you to recognize which object is which (position, parent, text, etc. And yes, if really needed, you can explicitly set an objectName and use that.)
Having said all that, there is actually another issue with your example. When I teach QML courses, I always stress to move logic to C++. In this case, I think you should have created a C++ helper that contains the logic for your keypad. The QML instantiates an instance of the KeyPadLogic helper object, binds the display to it’s displayString property and uses a property alias for to expose the value. It has some Q_INVOKABLES for input, which you call from the buttons onClicked handlers. All that is left on the QML side is to bother about the layout and other presentation aspects, while the logic for the whole thing is contained in a C++ object you can easily unit test using any unit testing framework you prefer (QtTest, Google Test, whatever). The whole thing will perform better, testing is easier, you get type safe code (unlike the JS you need to keep it all in QML)…
IMO the integration at that point is so trivial that a unit test on the QML level isn’t needed any more. Application-level squish testing is enough, together with unit tests for the logic that lives in C++.
Hi André,
Thanks for pointing out how to access the id’s instead of the object names.
I fully agree with your advice “to move logic to C++”. My rule of thumb is that you should not write more than 1-3 lines of JavaScript code. The QML layer should be as thin as possible – calling C++ functions immediately.
My goal for this post was slightly different. I wanted to find out how difficult it is to test QML code and hence complete GUIs with QtQuick Test – instead of with Squish. And I think that Qt Quick Test could be a cheap and viable alternative to Squish.
As you pointed out, there doesn’t remain much to test, as the QML layer extremely thin. Do you really need Squish for that? – Most likely not. Do you need Qt Quick Test? – Probably not. Can I do all that in C++ (think BDD)? – Yes, most likely. Whenever it’s difficult to write the tests in C++, there is most likely too much business logic in the GUI.
Cheers,
Burkhard
Comments are closed.