Developing a QML Keypad with TDD

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).

QML keypad on a harvester to change ECU parameters like the speed of the conveyor belt to empty the bunker on a trailer.
QML keypad to change ECU parameters

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.

  1. When the user opens the keypad, it displays the current value of the parameter and enters the initial state.
  2. 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.
  3. 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.
  4. When the user presses the Accept button at the top right, the keypad is closed and passes the changed parameter value to the caller.
  5. When the user presses the Reject button at the top left, the keypad is closed without changing the parameter value.
  6. When the user presses the clear button “C”, the keypad deletes the rightmost digit of the displayed value and goes to the entry state.
  7. When the user presses the “Reset” button, the keypad displays the default value of the parameter and goes to the entry state.
  8. 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.
  9. 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 instantiating Keypad.
  • 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.

Simple QML keypad developed with TDD

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.

Leave a Reply

Your email address will not be published.

Scroll to top