Unit Testing TkInter Apps (Shallow Thoughts)

Akkana's Musings on Open Source Computing and Technology, Science, and Nature.

Fri, 08 May 2026

Unit Testing TkInter Apps

I've been making a lot of tweaks lately to MetaPho, in particular its Python/TkInter based replacement for my C/GTK2 image viewer Pho.

Pho has always had quite a few modes: it can be fullscreen, in a window sized for the current image, or in a fixed-size window; images can be scaled to the window/screen size, or you can zoom in/out, or you can view them at full size (pixel for pixel). It's fairly common that when I fix a bug in one mode, it introduces a new bug in a different mode because of the way the scaling code works.

Ideally, in a complicated program, you guard against problems like that with automated tests. But that's hard to do in a GUI (graphical user interface) app. A window comes up, but how do you make it do different things? How do you check whether it's showing the right thing, or if it's the right size?

I've tried a couple times to find hints on how to unit test Python scripts in either Tk or GTK, but there's not much help available. I think most people just give up and don't test their GUIs — just as I've always given up.

This time, I decided to really dive in and see if I could write a TkInter unit test script for testing all those different TkPho modes. It wasn't easy, but now I have a basic framework that I should be able to use for other GUI apps as well.

Disclaimer: Most of this only works on Linux or other Posix systems on X11. I have no idea how to do most of this in a cross-platform way, and on Linux, I don't use Wayland so I don't know how that differs.

Also, I'm using unittest, but these methods should work in any Python testing framework.

Finally, I'm using the command-line app xdotool via Python's subprocess module for a lot of this. I tried to use Python libs like python-xlib, but mostly it was much easier in xdotool.

Bring up the Window as a Subprocess with a Special Class Name

You need to have the window running while you run your various tests. So it needs to be in another thread or subprocess. The easy way to do this is by forking a subprocess.

You'll need the X11 window id for the window that comes up. That turns out to be extremely hard to do. I had hoped to find the window corresponding to the subprocess ID, but it turns out none of xdotool nor python-xlib nor xprop can do this with a TkInter app, because they all rely on the X property _NET_WM_PID, which TkInter doesn't set, and despite trying many different methods, I was unable to set it manually. I even tried printing root.winfo_id() after creating the window, but it turns out winfo_id isn't the same as the window id that X11 apps like xdotool and xprop need. In fact, it was always the same value, 35651599 (0x220000f); I don't know what that number represents, but it's not the X window ID.

But what you can set in Tk is the class name, and then you can use xdotool (or python-xlib or your tool of choice) to find a window with that class name. So I added an optional argument to TkPhoWindow that lets you set a special class name, so the test will only find the test TkPhoWindow, not any other windows where you might be running pho.

You can also search on window name (what's shown in the titlebar), but that doesn't work for pho because pho updates the titlebar to show the name and size of the current image.

So create the window like this (adjusted for your GUI class):

        special_class_name = 'MySpecialGuiTest'

        pid = os.fork()
        if pid == 0:
            # Child process: create and show the window
            pwin = tkpho.tkPhoWindow(
                parent=None,
                class_name=special_class_name,
                img_list=["test/files/1.jpg", "test/files/2.jpg"]
            )
            pwin.run()
            os._exit(0)

Note: the os._exit(0) is so the subprocess doesn't fall through to run the test code again after you've closed the pho window. I also had to change the sys.exit(0) I'd previously used in PhoWindow.quit() to call os._exit(0) instead. Why? Because python unittest gives an error (and fails the test) if it ever sees you call sys.exit, and I couldn't find any cure for that.

How to Get the Window ID

Once you have a window with the special class name, getting a window id is simple. This comes right after fork() if pid != 0 (meaning you're in the parent process, where you'll be running the tests):

        time.sleep(1)
        window_id = int(subprocess.run(
            ["xdotool", "search", "--class", special_class_name],
            capture_output=True, text=True
        ).stdout)

The sleep(1) is to make sure the window is fully up and mapped before calling xdotool search. A full second is probably far more than is needed, but I'm not worried about speed in setting up the test so I haven't tried shorter intervals. If you're worried about speed, or if you have a window that's especially slow to start, you could print something on the program side when the window gets its first configure or draw event, and wait to read that from the testing side.

Getting Window Size and Title

Two things I know I want to check in my tests are the window size — to make sure pho does the right thing when switching between big and small images, or landscape and portrait, when it's in variable window size mode. Those are both straightforward with xdotool if you have the window id:

def get_window_size(window_id):
    result = subprocess.run(
        ["xdotool", "getwindowgeometry", str(window_id)],
        capture_output=True, text=True
    ).stdout
    geometry_line = [line for line in result.splitlines()
                     if line.strip().startswith("Geometry:")][0]
    width, height = geometry_line.split(":")[1].strip().split("x")
    width, height = int(width), int(height)
    return width, height

def get_window_title(window_id):
    result = subprocess.run(
        ["xdotool", "getwindowname", str(window_id)],
        capture_output=True, text=True
    ).stdout.strip()
    return result

Sending Key Events

Pho is almost entirely keyboard driven, so testing it requires sending key events. That, too, can be handled in xdotool:

def send_key(window_id, keyname, delay=1):
    """Send a key event to the window.
       keyname is something like "space" or "a".
       Key names:
       https://gitlab.com/nokun/gestures/-/wikis/xdotool-list-of-key-codes
    """
    subprocess.run(["xdotool", "key", "--window", str(window_id), keyname])
    time.sleep(1)

Again, sleeping for a full second is probably more than necessary — except in a few cases where you might want a longer delay, like if the key is going to cause the window to do something time consuming.

Sending 'q' to quit

However, there's one complication. At the end of my test, I wanted to send a 'q' to tell pho to close the window and exit:

        send_key(window_id, 'q')

It worked, and the pho window closed — but then I got an endless series of 'q' characters in the terminal where I'd run the test, until I typed some other character.

I'm still not quite sure why this happens. I thought maybe the pho window was getting the key down event, but then exited before it got the key up event, though you'd think xdotool would stop sending once the original window went away. xdotool does allow you to send an explicit key up:

    subprocess.run(["xdotool", "keyup", "--window", str(window_id), 'q'])
but that didn't help. It also has an event called type that's supposed to be more reliable, in terms of down/up, than key and keyup, but replacing key with type didn't help.

What eventually worked was to set focus explicitly to the pho window, send the key event to the focused window, then set focus back. I don't know why that works better; I guess it's just a weird quirk of xdotool.

        original_focus = subprocess.run(
            ["xdotool", "getwindowfocus"],
            capture_output=True, text=True
        ).stdout.strip()
        subprocess.run(["xdotool", "windowfocus", "--sync", str(window_id)])
        subprocess.run(["xdotool", "key", "--clearmodifiers", "q"])
        # restore focus
        subprocess.run(["xdotool", "windowfocus", "--sync", str(original_focus)])

I sometimes see errors at exit time about fork (GUI apps no longer like it when you fork, and there doesn't seem to be any way around that) or about unclosed sockets (that's coming from Xlib and I'm not sure what it's about) but they don't seem to get in the way of running the tests.

It's even possible to take a screenshot of the window and make sure it's showing what you expect. But I'll write about that separately.

I love having this test framework (you can see the actual test script here: test_tkpho.py). It was well worth the work of figuring out how to do it. Even with the small number of tkpho tests I've written so far, covering things that seemed straightforward, it's already uncovered several bugs that I hadn't noticed in normal daily use, some related to my sizing calculations, others to details like how TkInter behaves when you transition in and out of fullscreen mode. And since it's all too easy to introduce a regression in one mode when fixing a bug in another, I'm really looking forward to having GUI regression tests.

Tags: , ,
[ 13:55 May 08, 2026    More programming | permalink to this entry | ]

Comments via Disqus:

blog comments powered by Disqus