A minimal, event-loop-native test harness for Tkinter GUI applications
Project description
tkintertester
A minimal, event-loop-native test harness for Tkinter GUI applications.
The Problem
Tkinter doesn't play well with traditional testing approaches:
-
Tk can't restart cleanly. Once you destroy a Tk root and try to create another in the same process, things break. This makes pytest/unittest fixtures that create and tear down Tk windows unreliable or impossible.
-
Tk runs a blocking event loop. You can't just "call" your GUI from test code and inspect it—that blocks the mainloop or requires threading/async complexity.
-
Tk silently swallows exceptions. Exceptions in callbacks get printed to stderr but don't propagate—your test keeps running even though something failed.
The Solution
Run tests inside the Tk event loop itself.
- One process, one thread, one
mainloop() - Tests execute as sequences of step functions scheduled via
root.after() - Step functions return control immediately—they never block
- The harness manages test progression, timeouts, and results
- Exceptions in callbacks are caught and fail the current test
Design Principles
- No async/await — step-based execution driven by return values
- No classes — global functions and dictionaries
- No simulation — tests run in the real Tk event loop
- Lightweight harness — doesn't own or track your widgets
Installation
pip install -e .
Quick Example
import tkinter
from tkinter import ttk
from tkintertester import harness
# Application state
app = {"count": 0, "toplevel": None}
widgets = {}
def entry():
"""Set up the app."""
app["count"] = 0
app["toplevel"] = tkinter.Toplevel(harness.g["root"])
widgets["label"] = ttk.Label(app["toplevel"], text="0")
widgets["label"].grid(row=0, column=0)
widgets["button"] = ttk.Button(
app["toplevel"], text="+1",
command=lambda: increment()
)
widgets["button"].grid(row=1, column=0)
def reset():
"""Reset between tests."""
app["toplevel"].destroy()
widgets.clear()
def increment():
app["count"] += 1
widgets["label"].config(text=str(app["count"]))
# Define a test
def test_increment():
def step_click():
widgets["button"].invoke()
return ("next", None)
def step_verify():
if widgets["label"].cget("text") == "1":
return ("success", None)
return ("fail", "Counter should be 1")
return [step_click, step_verify]
# Run
harness.add_test("Increment counter", test_increment())
harness.set_resetfn(reset)
harness.set_timeout(5000)
harness.run_host(entry, flags="x")
harness.print_results()
API Reference
Test Registration
harness.add_test(title, steps)
Register a test. steps is a list of nullary step functions.
Configuration
harness.set_timeout(timeout_ms)
Set the timeout for each test (default: 5000ms).
harness.set_resetfn(app_reset)
Set a function to call between tests (to reset/clean up UI state).
Running Tests
harness.run_host(app_entry, flags="")
Harness creates a hidden Tk root and owns the lifecycle. Runs all registered tests, calling app_entry() before each test and app_reset() (if set) after each test. After all tests complete:
- If
"x"in flags: exit mainloop - Otherwise: call
app_entry()one more time to transition into normal runtime
Flags:
"x"— exit after tests complete"s"— show results in a Tk window after tests complete
harness.attach_harness(root, flags="")
Attach the harness to an already-running application's root window. Tests run immediately. The "x" flag is not allowed (you can't exit an app you don't own).
Results
harness.get_results() # Returns formatted string
harness.print_results() # Prints to stdout
harness.write_results(filepath) # Writes to file
harness.show_results() # Displays in a Tk window
Accessing State
harness.tests # List of test dictionaries (with results after execution)
harness.g # Harness state dictionary (includes g["root"])
Step Function Contract
Each step is a nullary function that returns (action, value):
| Action | Value | Meaning |
|---|---|---|
"next" |
None |
Advance to next step immediately |
"next" |
int |
Advance after N milliseconds |
"wait" |
int |
Retry this step after N milliseconds |
"goto" |
int |
Jump to step at index N |
"success" |
None |
Test passed |
"success" |
int |
Test passed, finalize after N ms |
"fail" |
str |
Test failed with message |
If all steps complete without explicit "success" or "fail", the test is considered successful.
Exception Handling
The harness overrides root.report_callback_exception to catch exceptions in Tk callbacks. If an exception occurs during a test (in a button handler, after() callback, event binding, etc.), the test is immediately marked as failed with the exception traceback captured.
Test Lifecycle
With run_host():
- Harness creates a hidden Tk root and enters
mainloop() - For each test:
- Call
app_entry()(create windows, widgets) - Execute steps until success, failure, or timeout
- Record result in the test object
- Call
app_reset()if set (clean up for next test)
- Call
- After all tests:
- If
"s"flag: show results window - If
"x"flag: exit mainloop - Otherwise: call
app_entry()to transition into normal app runtime
- If
With attach_harness():
- Attach to existing root window
- Run tests immediately
- After all tests: app continues running (no exit)
Two-Track Testing Strategy
For larger applications (20+ files), split tests into two tracks:
- Track 1: Logic tests — Pure functions, data processing, validation. Use pytest normally; no Tk needed.
- Track 2: GUI tests — Widget behavior, user interactions. Use tkintertester.
This separation encourages keeping business logic out of GUI code.
Structuring Your App for Testing
Design your app with entry() and reset() functions. The CLI or main script sets up the root and calls entry. Tests use the harness.
# myapp/main.py
import tkinter as tk
g = {"count": 0}
widgets = {}
app = {"root": None, "toplevel": None}
def entry():
"""Create the UI. app['root'] must be set before calling."""
g["count"] = 0
app["toplevel"] = tk.Toplevel(app["root"])
widgets["label"] = tk.Label(app["toplevel"], text="0")
widgets["label"].grid(row=0, column=0)
widgets["button"] = tk.Button(
app["toplevel"], text="+1",
command=handle_when_user_clicks_increment
)
widgets["button"].grid(row=1, column=0)
def reset():
"""Tear down the UI between tests."""
app["toplevel"].destroy()
widgets.clear()
def handle_when_user_clicks_increment():
g["count"] += 1
widgets["label"].config(text=str(g["count"]))
Recommended Project Layout
Keep pytest tests and GUI tests in separate directories:
myproject/
src/
myapp/
__init__.py
main.py # has entry(), reset()
logic.py # pure functions, no Tk dependency
tests/ # pytest: logic tests (Track 1)
test_logic.py
guitests/ # tkintertester: GUI tests (Track 2)
test_main_window.py
pyproject.toml
Configure pytest to only discover tests in tests/:
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
A GUI test file imports the app and uses the harness:
# guitests/test_main_window.py
from tkintertester import harness
from myapp import main as app
def test_increment():
def step_click():
app.widgets["button"].invoke()
return ("next", None)
def step_verify():
if app.widgets["label"].cget("text") == "1":
return ("success", None)
return ("fail", "Counter should be 1")
return [step_click, step_verify]
def app_entry():
app.app["root"] = harness.g["root"]
app.entry()
harness.add_test("Increment counter", test_increment())
harness.set_resetfn(app.reset)
harness.run_host(app_entry, flags="x")
harness.print_results()
Running tests:
python -m pytest # runs logic tests (Track 1)
python guitests/test_main_window.py # runs GUI tests (Track 2)
Full Documentation
License
CC0 1.0 Universal — Public Domain
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tkintertester-0.1.0.tar.gz.
File metadata
- Download URL: tkintertester-0.1.0.tar.gz
- Upload date:
- Size: 14.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2742810e510092b3cb7c6a86f51254aa4d0d73f6293a8001078a0c7455038429
|
|
| MD5 |
d0dbe29da5a1f39515b8f05c63b2e859
|
|
| BLAKE2b-256 |
8d74c3c155d8e6ea1ed29f59bb1261b30b9a6681cee2be52e6bda2fa4febfd80
|
File details
Details for the file tkintertester-0.1.0-py3-none-any.whl.
File metadata
- Download URL: tkintertester-0.1.0-py3-none-any.whl
- Upload date:
- Size: 11.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a6a8781cc198e47cda970c9e516e97b9cba0ba67efce1ce728908ef7dbeb8f63
|
|
| MD5 |
0ab94651fc7eb9b2b02977030dbf8aa4
|
|
| BLAKE2b-256 |
c407c04f5520327fd13abd3b6dd2e1a079d0a76ac86d5b20124372fa76e46425
|