How do I run unittest on a Tkinter app?

Question:

I’ve just begun learning about TDD, and I’m developing a program using a Tkinter GUI. The only problem is that once the .mainloop() method is called, the test suite hangs until the window is closed.

Here is an example of my code:

# server.py
import Tkinter as tk

class Server(tk.Tk):
    def __init__(self):
        tk.Tk.__init__(self)
        self.mainloop()

# test.py
import unittest
import server

class ServerTestCase(unittest.TestCase):
    def testClassSetup(self):
       server.Server()
       # and of course I can't call any server.whatever functions here

if __name__ == '__main__':
    unittest.main()

What is the appropriate way of testing Tkinter apps? Or is it just ‘dont’?

Asked By: Wayne Werner

||

Answers:

There is a technique called monkey-patching, whereby you change code at runtime.

You could monkey-patch the TK class, so that mainloop doesn’t actually start the program.

Something like this in your test.py (untested!):

import tk
class FakeTk(object):
    def mainloop(self):
        pass

tk.__dict__['Tk'] = FakeTk
import server

def test_server():
    s = server.Server()
    server.mainloop() # shouldn't endless loop on you now...

A mocking framework like mock makes this a lot less painful.

Answered By: Ryan Ginstrom

One thing you can do is spawn the mainloop in a separate thread and use your main thread to run the actual tests; watch the mainloop thread as it were. Make sure you check the state of the Tk window before doing your asserts.

Multithreading any code is hard. You may want to break your Tk program down into testable pieces instead of unit testing the entire thing at once (which really isn’t unit testing).

I would finally suggest testing at least at the control level if not lower for your program, it will help you tremendously.

Answered By: Scott

Bottom line: pump the events with the below code after an action that causes a UI event, before a later action that needs the effect of that event.


IPython provides an elegant solution without threads it its gui tk magic command implementation that’s located in terminal/pt_inputhooks/tk.py.

Instead of root.mainloop(), it runs root.dooneevent() in a loop, checking for exit condition (an interactive input arriving) each iteration. This way, the even loop doesn’t run when IPython is busy processing a command.

With tests, there’s no external event to wait for, and the test is always "busy", so one has to manually (or semi-automatically) run the loop at "appropriate moments". What are they?

Testing shows that without an event loop, one can change the widgets directly (with <widget>.tk.call() and anything that wraps it), but event handlers never fire. So, the loop needs to be run whenever an event happens and we need its effect — i.e. after any operation that changes something, before an operation that needs the result of the change.

The code, derived from the aforementioned IPython procedure, would be:

def pump_events(root):
    while root.dooneevent(_tkinter.ALL_EVENTS|_tkinter.DONT_WAIT):
        pass

That would process (execute handlers for) all pending events, and all events that would directly result from those.

(tkinter.Tk.dooneevent() delegates to Tcl_DoOneEvent().)


As a side note, using this instead:

root.update()
root.update_idletasks()

would not necessarily do the same because neither function processes all kinds of events. Since every handler may generate other arbitrary events, this way, I can’t be sure that I’ve processed everything.


Here’s an example that tests a simple popup dialog for editing a string value:

class TKinterTestCase(unittest.TestCase):
    """These methods are going to be the same for every GUI test,
    so refactored them into a separate class
    """
    def setUp(self):
        self.root=tkinter.Tk()
        self.pump_events()

    def tearDown(self):
        if self.root:
            self.root.destroy()
            self.pump_events()

    def pump_events(self):
        while self.root.dooneevent(_tkinter.ALL_EVENTS | _tkinter.DONT_WAIT):
            pass

class TestViewAskText(TKinterTestCase):
    def test_enter(self):
        v = View_AskText(self.root,value=u"йцу")
        self.pump_events()
        v.e.focus_set()
        v.e.insert(tkinter.END,u'кен')
        v.e.event_generate('<Return>')
        self.pump_events()

        self.assertRaises(tkinter.TclError, lambda: v.top.winfo_viewable())
        self.assertEqual(v.value,u'йцукен')


# ###########################################################
# The class being tested (normally, it's in a separate module
# and imported at the start of the test's file)
# ###########################################################

class View_AskText(object):
    def __init__(self, master, value=u""):
        self.value=None

        top = self.top = tkinter.Toplevel(master)
        top.grab_set()
        self.l = ttk.Label(top, text=u"Value:")
        self.l.pack()
        self.e = ttk.Entry(top)
        self.e.pack()
        self.b = ttk.Button(top, text='Ok', command=self.save)
        self.b.pack()

        if value: self.e.insert(0,value)
        self.e.focus_set()
        top.bind('<Return>', self.save)

    def save(self, *_):
        self.value = self.e.get()
        self.top.destroy()


if __name__ == '__main__':
    import unittest
    unittest.main()
Answered By: ivan_pozdeev

This answer is for Python 3.7 and up (whatever versions have async methods)

In your main.py or wherever you start your main UI:

def start_application() -> Application:
    root = tk.Tk()
    app = Application(master=root)
    app.load_settings()
    return app # will return the application without starting the main loop.

if __name__=='__main__':
    start_application().mainloop()

And in your tests.py:

from myapp.main import start_application

class TestGui(unittest.TestCase):
    
    # this will run on a separate thread.
    async def _start_app(self):
        self.app.mainloop()
    
    def setUp(self):
        self.app = start_application()
        self._start_app()
    
    def tearDown(self):
        self.app.destroy()
    
    def test_startup(self):
        title = self.app.winfo_toplevel().title()
        expected = 'The Application My Boss Wants Me To Make'
        self.assertEqual(title, expected)

This won’t show anything but it will pass. In addition, expect a warning to be
shown saying we didn’t await _start_application. This can be ignored in this context. (If you wanted to be a stickler for multi-threading then you’ll have to do your own thread management…imho it’s too much work for unit testing).

Answered By: Dash2TheDot
Categories: questions Tags: , , ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.