Skip to main content

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:

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

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

  3. 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():

  1. Harness creates a hidden Tk root and enters mainloop()
  2. 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)
  3. After all tests:
    • If "s" flag: show results window
    • If "x" flag: exit mainloop
    • Otherwise: call app_entry() to transition into normal app runtime

With attach_harness():

  1. Attach to existing root window
  2. Run tests immediately
  3. 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

docs/reference.md

License

CC0 1.0 Universal — Public Domain

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

tkintertester-0.1.0.tar.gz (14.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

tkintertester-0.1.0-py3-none-any.whl (11.2 kB view details)

Uploaded Python 3

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

Hashes for tkintertester-0.1.0.tar.gz
Algorithm Hash digest
SHA256 2742810e510092b3cb7c6a86f51254aa4d0d73f6293a8001078a0c7455038429
MD5 d0dbe29da5a1f39515b8f05c63b2e859
BLAKE2b-256 8d74c3c155d8e6ea1ed29f59bb1261b30b9a6681cee2be52e6bda2fa4febfd80

See more details on using hashes here.

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

Hashes for tkintertester-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 a6a8781cc198e47cda970c9e516e97b9cba0ba67efce1ce728908ef7dbeb8f63
MD5 0ab94651fc7eb9b2b02977030dbf8aa4
BLAKE2b-256 c407c04f5520327fd13abd3b6dd2e1a079d0a76ac86d5b20124372fa76e46425

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page