> ## Content Index
> Fetch the complete content index at: https://www.smallstepsystems.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Developing a QML Keypad with TDD
- URL: https://www.smallstepsystems.com/developing-a-qml-keypad-with-tdd/
- Published: 2021-11-18T17:41:17.000Z
- Updated: 2026-01-03T21:59:21.000Z
- Author: Burkhard Stubert

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.](https://storage.ghost.io/c/c7/ee/c7ee1cf5-b86f-491b-aab7-20ad081598e1/content/images/2025/11/tdd-qml-keypad.png)

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](https://doc.qt.io/qt-6/qtquicktest-index.html?ref=smallstepsystems.com) 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](https://github.com/bstubert/tdd-training-add-ons?ref=smallstepsystems.com).

## 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](https://doc.qt.io/qt-6/qt-add-qml-module.html?ref=smallstepsystems.com)`, 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](https://doc.qt.io/qt-6/qquicktest.html?ref=smallstepsystems.com#QUICK%5FTEST%5FMAIN)` is for QML tests what `[QTEST_MAIN](https://doc.qt.io/qt-6/qtest.html?ref=smallstepsystems.com#QTEST%5FMAIN)` 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](http://blog.wingman-sw.com/tdd-guided-by-zombies?ref=smallstepsystems.com) 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](https://storage.ghost.io/c/c7/ee/c7ee1cf5-b86f-491b-aab7-20ad081598e1/content/images/2025/11/screenshot-2021-11-18-at-16-54-54.png)

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.