blob: 09a20e28e78e7b2fb9f74b31eb2fcfe433f11fe8 [file] [log] [blame]
Michael Foord90efac72011-01-03 15:39:49 +00001#!/usr/bin/env python
2"""
3GUI framework and application for use with Python unit testing framework.
4Execute tests written using the framework provided by the 'unittest' module.
5
6Updated for unittest test discovery by Mark Roddy and Python 3
7support by Brian Curtin.
8
9Based on the original by Steve Purcell, from:
10
11 http://pyunit.sourceforge.net/
12
13Copyright (c) 1999, 2000, 2001 Steve Purcell
14This module is free software, and you may redistribute it and/or modify
15it under the same terms as Python itself, so long as this copyright message
16and disclaimer are retained in their original form.
17
18IN NO EVENT SHALL THE AUTHOR BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT,
19SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OF
20THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
21DAMAGE.
22
23THE AUTHOR SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT
24LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
25PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS,
26AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
27SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
28"""
29
30__author__ = "Steve Purcell (stephen_purcell@yahoo.com)"
Michael Foord90efac72011-01-03 15:39:49 +000031
32import sys
33import traceback
34import unittest
35
36import tkinter as tk
37from tkinter import messagebox
38from tkinter import filedialog
39from tkinter import simpledialog
40
41
42
43
44##############################################################################
45# GUI framework classes
46##############################################################################
47
48class BaseGUITestRunner(object):
49 """Subclass this class to create a GUI TestRunner that uses a specific
50 windowing toolkit. The class takes care of running tests in the correct
51 manner, and making callbacks to the derived class to obtain information
52 or signal that events have occurred.
53 """
54 def __init__(self, *args, **kwargs):
55 self.currentResult = None
56 self.running = 0
57 self.__rollbackImporter = None
58 self.__rollbackImporter = RollbackImporter()
59 self.test_suite = None
60
61 #test discovery variables
62 self.directory_to_read = ''
63 self.top_level_dir = ''
64 self.test_file_glob_pattern = 'test*.py'
65
66 self.initGUI(*args, **kwargs)
67
68 def errorDialog(self, title, message):
69 "Override to display an error arising from GUI usage"
70 pass
71
72 def getDirectoryToDiscover(self):
73 "Override to prompt user for directory to perform test discovery"
74 pass
75
76 def runClicked(self):
77 "To be called in response to user choosing to run a test"
78 if self.running: return
79 if not self.test_suite:
80 self.errorDialog("Test Discovery", "You discover some tests first!")
81 return
82 self.currentResult = GUITestResult(self)
83 self.totalTests = self.test_suite.countTestCases()
84 self.running = 1
85 self.notifyRunning()
86 self.test_suite.run(self.currentResult)
87 self.running = 0
88 self.notifyStopped()
89
90 def stopClicked(self):
91 "To be called in response to user stopping the running of a test"
92 if self.currentResult:
93 self.currentResult.stop()
94
95 def discoverClicked(self):
96 self.__rollbackImporter.rollbackImports()
97 directory = self.getDirectoryToDiscover()
98 if not directory:
99 return
100 self.directory_to_read = directory
101 try:
102 # Explicitly use 'None' value if no top level directory is
103 # specified (indicated by empty string) as discover() explicitly
104 # checks for a 'None' to determine if no tld has been specified
105 top_level_dir = self.top_level_dir or None
106 tests = unittest.defaultTestLoader.discover(directory, self.test_file_glob_pattern, top_level_dir)
107 self.test_suite = tests
108 except:
109 exc_type, exc_value, exc_tb = sys.exc_info()
110 traceback.print_exception(*sys.exc_info())
111 self.errorDialog("Unable to run test '%s'" % directory,
112 "Error loading specified test: %s, %s" % (exc_type, exc_value))
113 return
114 self.notifyTestsDiscovered(self.test_suite)
115
116 # Required callbacks
117
118 def notifyTestsDiscovered(self, test_suite):
119 "Override to display information about the suite of discovered tests"
120 pass
121
122 def notifyRunning(self):
123 "Override to set GUI in 'running' mode, enabling 'stop' button etc."
124 pass
125
126 def notifyStopped(self):
127 "Override to set GUI in 'stopped' mode, enabling 'run' button etc."
128 pass
129
130 def notifyTestFailed(self, test, err):
131 "Override to indicate that a test has just failed"
132 pass
133
134 def notifyTestErrored(self, test, err):
135 "Override to indicate that a test has just errored"
136 pass
137
138 def notifyTestSkipped(self, test, reason):
139 "Override to indicate that test was skipped"
140 pass
141
142 def notifyTestFailedExpectedly(self, test, err):
143 "Override to indicate that test has just failed expectedly"
144 pass
145
146 def notifyTestStarted(self, test):
147 "Override to indicate that a test is about to run"
148 pass
149
150 def notifyTestFinished(self, test):
151 """Override to indicate that a test has finished (it may already have
152 failed or errored)"""
153 pass
154
155
156class GUITestResult(unittest.TestResult):
157 """A TestResult that makes callbacks to its associated GUI TestRunner.
158 Used by BaseGUITestRunner. Need not be created directly.
159 """
160 def __init__(self, callback):
161 unittest.TestResult.__init__(self)
162 self.callback = callback
163
164 def addError(self, test, err):
165 unittest.TestResult.addError(self, test, err)
166 self.callback.notifyTestErrored(test, err)
167
168 def addFailure(self, test, err):
169 unittest.TestResult.addFailure(self, test, err)
170 self.callback.notifyTestFailed(test, err)
171
172 def addSkip(self, test, reason):
173 super(GUITestResult,self).addSkip(test, reason)
174 self.callback.notifyTestSkipped(test, reason)
175
176 def addExpectedFailure(self, test, err):
177 super(GUITestResult,self).addExpectedFailure(test, err)
178 self.callback.notifyTestFailedExpectedly(test, err)
179
180 def stopTest(self, test):
181 unittest.TestResult.stopTest(self, test)
182 self.callback.notifyTestFinished(test)
183
184 def startTest(self, test):
185 unittest.TestResult.startTest(self, test)
186 self.callback.notifyTestStarted(test)
187
188
189class RollbackImporter:
190 """This tricky little class is used to make sure that modules under test
191 will be reloaded the next time they are imported.
192 """
193 def __init__(self):
194 self.previousModules = sys.modules.copy()
195
196 def rollbackImports(self):
197 for modname in sys.modules.copy().keys():
198 if not modname in self.previousModules:
199 # Force reload when modname next imported
200 del(sys.modules[modname])
201
202
203##############################################################################
204# Tkinter GUI
205##############################################################################
206
207class DiscoverSettingsDialog(simpledialog.Dialog):
208 """
209 Dialog box for prompting test discovery settings
210 """
211
212 def __init__(self, master, top_level_dir, test_file_glob_pattern, *args, **kwargs):
213 self.top_level_dir = top_level_dir
214 self.dirVar = tk.StringVar()
215 self.dirVar.set(top_level_dir)
216
217 self.test_file_glob_pattern = test_file_glob_pattern
218 self.testPatternVar = tk.StringVar()
219 self.testPatternVar.set(test_file_glob_pattern)
220
221 simpledialog.Dialog.__init__(self, master, title="Discover Settings",
222 *args, **kwargs)
223
224 def body(self, master):
225 tk.Label(master, text="Top Level Directory").grid(row=0)
226 self.e1 = tk.Entry(master, textvariable=self.dirVar)
227 self.e1.grid(row = 0, column=1)
228 tk.Button(master, text="...",
229 command=lambda: self.selectDirClicked(master)).grid(row=0,column=3)
230
231 tk.Label(master, text="Test File Pattern").grid(row=1)
232 self.e2 = tk.Entry(master, textvariable = self.testPatternVar)
233 self.e2.grid(row = 1, column=1)
234 return None
235
236 def selectDirClicked(self, master):
237 dir_path = filedialog.askdirectory(parent=master)
238 if dir_path:
239 self.dirVar.set(dir_path)
240
241 def apply(self):
242 self.top_level_dir = self.dirVar.get()
243 self.test_file_glob_pattern = self.testPatternVar.get()
244
245class TkTestRunner(BaseGUITestRunner):
246 """An implementation of BaseGUITestRunner using Tkinter.
247 """
248 def initGUI(self, root, initialTestName):
249 """Set up the GUI inside the given root window. The test name entry
250 field will be pre-filled with the given initialTestName.
251 """
252 self.root = root
253
254 self.statusVar = tk.StringVar()
255 self.statusVar.set("Idle")
256
257 #tk vars for tracking counts of test result types
258 self.runCountVar = tk.IntVar()
259 self.failCountVar = tk.IntVar()
260 self.errorCountVar = tk.IntVar()
261 self.skipCountVar = tk.IntVar()
262 self.expectFailCountVar = tk.IntVar()
263 self.remainingCountVar = tk.IntVar()
264
265 self.top = tk.Frame()
266 self.top.pack(fill=tk.BOTH, expand=1)
267 self.createWidgets()
268
269 def getDirectoryToDiscover(self):
270 return filedialog.askdirectory()
271
272 def settingsClicked(self):
273 d = DiscoverSettingsDialog(self.top, self.top_level_dir, self.test_file_glob_pattern)
274 self.top_level_dir = d.top_level_dir
275 self.test_file_glob_pattern = d.test_file_glob_pattern
276
277 def notifyTestsDiscovered(self, test_suite):
Michael Foord32e1d832011-01-03 17:00:11 +0000278 discovered = test_suite.countTestCases()
Michael Foord90efac72011-01-03 15:39:49 +0000279 self.runCountVar.set(0)
280 self.failCountVar.set(0)
281 self.errorCountVar.set(0)
Michael Foord32e1d832011-01-03 17:00:11 +0000282 self.remainingCountVar.set(discovered)
Michael Foord90efac72011-01-03 15:39:49 +0000283 self.progressBar.setProgressFraction(0.0)
284 self.errorListbox.delete(0, tk.END)
Michael Foord32e1d832011-01-03 17:00:11 +0000285 self.statusVar.set("Discovering tests from %s. Found: %s" %
286 (self.directory_to_read, discovered))
Michael Foord90efac72011-01-03 15:39:49 +0000287 self.stopGoButton['state'] = tk.NORMAL
288
289 def createWidgets(self):
290 """Creates and packs the various widgets.
291
292 Why is it that GUI code always ends up looking a mess, despite all the
293 best intentions to keep it tidy? Answers on a postcard, please.
294 """
295 # Status bar
296 statusFrame = tk.Frame(self.top, relief=tk.SUNKEN, borderwidth=2)
297 statusFrame.pack(anchor=tk.SW, fill=tk.X, side=tk.BOTTOM)
298 tk.Label(statusFrame, width=1, textvariable=self.statusVar).pack(side=tk.TOP, fill=tk.X)
299
300 # Area to enter name of test to run
301 leftFrame = tk.Frame(self.top, borderwidth=3)
302 leftFrame.pack(fill=tk.BOTH, side=tk.LEFT, anchor=tk.NW, expand=1)
303 suiteNameFrame = tk.Frame(leftFrame, borderwidth=3)
304 suiteNameFrame.pack(fill=tk.X)
305
306 # Progress bar
307 progressFrame = tk.Frame(leftFrame, relief=tk.GROOVE, borderwidth=2)
308 progressFrame.pack(fill=tk.X, expand=0, anchor=tk.NW)
309 tk.Label(progressFrame, text="Progress:").pack(anchor=tk.W)
310 self.progressBar = ProgressBar(progressFrame, relief=tk.SUNKEN,
311 borderwidth=2)
312 self.progressBar.pack(fill=tk.X, expand=1)
313
314
315 # Area with buttons to start/stop tests and quit
316 buttonFrame = tk.Frame(self.top, borderwidth=3)
317 buttonFrame.pack(side=tk.LEFT, anchor=tk.NW, fill=tk.Y)
318
319 tk.Button(buttonFrame, text="Discover Tests",
320 command=self.discoverClicked).pack(fill=tk.X)
321
322
323 self.stopGoButton = tk.Button(buttonFrame, text="Start",
324 command=self.runClicked, state=tk.DISABLED)
325 self.stopGoButton.pack(fill=tk.X)
326
327 tk.Button(buttonFrame, text="Close",
328 command=self.top.quit).pack(side=tk.BOTTOM, fill=tk.X)
329 tk.Button(buttonFrame, text="Settings",
330 command=self.settingsClicked).pack(side=tk.BOTTOM, fill=tk.X)
331
332 # Area with labels reporting results
333 for label, var in (('Run:', self.runCountVar),
334 ('Failures:', self.failCountVar),
335 ('Errors:', self.errorCountVar),
336 ('Skipped:', self.skipCountVar),
337 ('Expected Failures:', self.expectFailCountVar),
338 ('Remaining:', self.remainingCountVar),
339 ):
340 tk.Label(progressFrame, text=label).pack(side=tk.LEFT)
341 tk.Label(progressFrame, textvariable=var,
342 foreground="blue").pack(side=tk.LEFT, fill=tk.X,
343 expand=1, anchor=tk.W)
344
345 # List box showing errors and failures
346 tk.Label(leftFrame, text="Failures and errors:").pack(anchor=tk.W)
347 listFrame = tk.Frame(leftFrame, relief=tk.SUNKEN, borderwidth=2)
348 listFrame.pack(fill=tk.BOTH, anchor=tk.NW, expand=1)
349 self.errorListbox = tk.Listbox(listFrame, foreground='red',
350 selectmode=tk.SINGLE,
351 selectborderwidth=0)
352 self.errorListbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=1,
353 anchor=tk.NW)
354 listScroll = tk.Scrollbar(listFrame, command=self.errorListbox.yview)
355 listScroll.pack(side=tk.LEFT, fill=tk.Y, anchor=tk.N)
356 self.errorListbox.bind("<Double-1>",
357 lambda e, self=self: self.showSelectedError())
358 self.errorListbox.configure(yscrollcommand=listScroll.set)
359
360 def errorDialog(self, title, message):
361 messagebox.showerror(parent=self.root, title=title,
362 message=message)
363
364 def notifyRunning(self):
365 self.runCountVar.set(0)
366 self.failCountVar.set(0)
367 self.errorCountVar.set(0)
368 self.remainingCountVar.set(self.totalTests)
369 self.errorInfo = []
370 while self.errorListbox.size():
371 self.errorListbox.delete(0)
372 #Stopping seems not to work, so simply disable the start button
373 #self.stopGoButton.config(command=self.stopClicked, text="Stop")
374 self.stopGoButton.config(state=tk.DISABLED)
375 self.progressBar.setProgressFraction(0.0)
376 self.top.update_idletasks()
377
378 def notifyStopped(self):
379 self.stopGoButton.config(state=tk.DISABLED)
380 #self.stopGoButton.config(command=self.runClicked, text="Start")
381 self.statusVar.set("Idle")
382
383 def notifyTestStarted(self, test):
384 self.statusVar.set(str(test))
385 self.top.update_idletasks()
386
387 def notifyTestFailed(self, test, err):
388 self.failCountVar.set(1 + self.failCountVar.get())
389 self.errorListbox.insert(tk.END, "Failure: %s" % test)
390 self.errorInfo.append((test,err))
391
392 def notifyTestErrored(self, test, err):
393 self.errorCountVar.set(1 + self.errorCountVar.get())
394 self.errorListbox.insert(tk.END, "Error: %s" % test)
395 self.errorInfo.append((test,err))
396
397 def notifyTestSkipped(self, test, reason):
398 super(TkTestRunner, self).notifyTestSkipped(test, reason)
399 self.skipCountVar.set(1 + self.skipCountVar.get())
400
401 def notifyTestFailedExpectedly(self, test, err):
402 super(TkTestRunner, self).notifyTestFailedExpectedly(test, err)
403 self.expectFailCountVar.set(1 + self.expectFailCountVar.get())
404
405
406 def notifyTestFinished(self, test):
407 self.remainingCountVar.set(self.remainingCountVar.get() - 1)
408 self.runCountVar.set(1 + self.runCountVar.get())
409 fractionDone = float(self.runCountVar.get())/float(self.totalTests)
410 fillColor = len(self.errorInfo) and "red" or "green"
411 self.progressBar.setProgressFraction(fractionDone, fillColor)
412
413 def showSelectedError(self):
414 selection = self.errorListbox.curselection()
415 if not selection: return
416 selected = int(selection[0])
417 txt = self.errorListbox.get(selected)
418 window = tk.Toplevel(self.root)
419 window.title(txt)
420 window.protocol('WM_DELETE_WINDOW', window.quit)
421 test, error = self.errorInfo[selected]
422 tk.Label(window, text=str(test),
423 foreground="red", justify=tk.LEFT).pack(anchor=tk.W)
424 tracebackLines = traceback.format_exception(*error)
425 tracebackText = "".join(tracebackLines)
426 tk.Label(window, text=tracebackText, justify=tk.LEFT).pack()
427 tk.Button(window, text="Close",
428 command=window.quit).pack(side=tk.BOTTOM)
429 window.bind('<Key-Return>', lambda e, w=window: w.quit())
430 window.mainloop()
431 window.destroy()
432
433
434class ProgressBar(tk.Frame):
435 """A simple progress bar that shows a percentage progress in
436 the given colour."""
437
438 def __init__(self, *args, **kwargs):
439 tk.Frame.__init__(self, *args, **kwargs)
440 self.canvas = tk.Canvas(self, height='20', width='60',
441 background='white', borderwidth=3)
442 self.canvas.pack(fill=tk.X, expand=1)
443 self.rect = self.text = None
444 self.canvas.bind('<Configure>', self.paint)
445 self.setProgressFraction(0.0)
446
447 def setProgressFraction(self, fraction, color='blue'):
448 self.fraction = fraction
449 self.color = color
450 self.paint()
451 self.canvas.update_idletasks()
452
453 def paint(self, *args):
454 totalWidth = self.canvas.winfo_width()
455 width = int(self.fraction * float(totalWidth))
456 height = self.canvas.winfo_height()
457 if self.rect is not None: self.canvas.delete(self.rect)
458 if self.text is not None: self.canvas.delete(self.text)
459 self.rect = self.canvas.create_rectangle(0, 0, width, height,
460 fill=self.color)
461 percentString = "%3.0f%%" % (100.0 * self.fraction)
462 self.text = self.canvas.create_text(totalWidth/2, height/2,
463 anchor=tk.CENTER,
464 text=percentString)
465
466def main(initialTestName=""):
467 root = tk.Tk()
468 root.title("PyUnit")
469 runner = TkTestRunner(root, initialTestName)
470 root.protocol('WM_DELETE_WINDOW', root.quit)
471 root.mainloop()
472
473
474if __name__ == '__main__':
475 if len(sys.argv) == 2:
476 main(sys.argv[1])
477 else:
478 main()