The Problem
Shells are complex UIs with many interacting components. A change to one panel can break another. Without tests, regressions go unnoticed until users report them. Debugging without tools is painful.
The Naive Approach
Manual testing:
# Change code, restart shell, hope it works
quickshell -c ~/.config/quickshell/shell.qmlManual testing is slow, inconsistent, and doesn't scale. You can't manually test every state of every component after each change.
Think of testing as a safety net for a tightrope walker. You could walk without it (if you're confident). But one wrong step and you fall. The net catches you so you can try again. Tests catch regressions before they reach users.
Testing Approaches
1. Unit Testing with Qt Test (C++)
For C++ modules, use Qt Test:
#include <QtTest>
#include "AudioService.h"
class TestAudioService : public QObject {
Q_OBJECT
private slots:
void testVolumeRange() {
AudioService service;
service.setVolume(50);
QCOMPARE(service.volume(), 50);
service.setVolume(150);
QVERIFY(service.volume() <= 100); // Clamped
}
void testMuteToggle() {
AudioService service;
service.setMuted(true);
QVERIFY(service.isMuted());
service.toggleMute();
QVERIFY(!service.isMuted());
}
};
QTEST_MAIN(TestAudioService)2. QML Component Testing
Use qmltestrunner with Qt Quick Test:
// tst_clock.qml
import QtQuick
import QtTest
import "../components"
Item {
id: root
width: 200; height: 50
ClockWidget { id: clock }
TestCase {
name: "ClockWidget Tests"
when: windowShown
function test_clock_format() {
// Clock should display time in HH:MM format
var timeText = clock.text
verify(/^\d{2}:\d{2}$/.test(timeText),
"Clock should match HH:MM format, got: " + timeText)
}
function test_clock_updates() {
var before = clock.text
wait(1000) // Wait for 1 second
var after = clock.text
verify(before !== after,
"Clock should update every second")
}
}
}Run tests:
qmltestrunner -input tests/3. Service Mocking
For testing components that depend on services, create mock services:
// MockAudioService.qml
pragma Singleton
import QtQuick
QtObject {
property int volume: 50
property bool muted: false
property string defaultSink: "alsa_output.pci-0000_00_1f.3.analog-stereo"
function setVolume(v) { volume = Math.max(0, Math.min(100, v)) }
function toggleMute() { muted = !muted }
function setDefaultSink(s) { defaultSink = s }
}Use the mock by changing the import path:
QML2_IMPORT_PATH=mocks:$QML2_IMPORT_PATH qmltestrunner -input tests/Debugging Techniques
1. Visual Debug Overlay
// DebugOverlay.qml
Rectangle {
id: debugOverlay
visible: false
anchors.fill: parent; color: "#00000080"
property var selectedItem: null
Rectangle {
x: 8; y: 8; width: 400; height: 300; radius: 8; color: "#1e1e2e"
Column {
anchors.fill: parent; margins: 12; spacing: 4
Text { text: "Debug Inspector"; font.pixelSize: 14; font.bold: true; color: "#cdd6f4" }
Text { id: infoText; color: "#a6adc8"; font.pixelSize: 11; font.family: "monospace" }
Row { spacing: 8
Button { text: "Inspect"; onClicked: enableInspector() }
Button { text: "Reload"; onClicked: reloadShell() }
Button { text: "Close"; onClicked: debugOverlay.visible = false }
}
}
}
function enableInspector() {
// Highlight items on click
infoText.text = "Click on any element to inspect"
}
function reloadShell() {
// Reload QML without restarting
// This is compositor-dependent
infoText.text = "Reload requested"
}
}
// Toggle with F12
Item { Shortcut { sequence: "F12"; onActivated: debugOverlay.visible = !debugOverlay.visible } }2. Property Watch
// PropertyWatcher.qml
QtObject {
id: watcher
property var watched: ({})
function watch(obj, propertyName) {
var key = obj.toString() + "." + propertyName
watched[key] = obj
var getter = obj[propertyName]
obj[propertyName + "Changed"].connect(function() {
console.log("Property changed: " + propertyName + " = " + obj[propertyName])
logToFile(key + " = " + obj[propertyName])
})
}
}3. Error Boundaries
Wrap components that might crash:
// ErrorBoundary.qml
Item {
id: boundary
property Component content: null
property string fallbackMessage: "An error occurred"
Loader {
id: contentLoader
anchors.fill: parent
sourceComponent: boundary.content
onStatusChanged: {
if (status === Loader.Error) {
console.error("Error boundary caught:", contentLoader.errorString())
errorOverlay.visible = true
}
}
}
Rectangle {
id: errorOverlay
anchors.fill: parent; color: "#450000"; visible: false; radius: 8
Text { anchors.centerIn: parent; text: boundary.fallbackMessage; color: "#f38ba8" }
}
}Exercises
- Use Qt Test (C++) and qmltestrunner (QML) for unit testing
- Create mock services to test components in isolation
- Build debug overlay with inspector and reload capability
- Use error boundaries to handle component crashes gracefully