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