Reputation: 51877
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'?
Upvotes: 35
Views: 22697
Reputation: 157
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).
Upvotes: 3
Reputation: 36096
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()
Upvotes: 14
Reputation: 1477
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.
Upvotes: 2
Reputation: 14121
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.
Upvotes: 2