blob: 0c48b49c1bc639eedec98b5d5338d60ab5e8dcbb [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):
279 self.runCountVar.set(0)
280 self.failCountVar.set(0)
281 self.errorCountVar.set(0)
282 self.remainingCountVar.set(test_suite.countTestCases())
283 self.progressBar.setProgressFraction(0.0)
284 self.errorListbox.delete(0, tk.END)
285 self.statusVar.set("Discovering tests from %s" % self.directory_to_read)
286 self.stopGoButton['state'] = tk.NORMAL
287
288 def createWidgets(self):
289 """Creates and packs the various widgets.
290
291 Why is it that GUI code always ends up looking a mess, despite all the
292 best intentions to keep it tidy? Answers on a postcard, please.
293 """
294 # Status bar
295 statusFrame = tk.Frame(self.top, relief=tk.SUNKEN, borderwidth=2)
296 statusFrame.pack(anchor=tk.SW, fill=tk.X, side=tk.BOTTOM)
297 tk.Label(statusFrame, width=1, textvariable=self.statusVar).pack(side=tk.TOP, fill=tk.X)
298
299 # Area to enter name of test to run
300 leftFrame = tk.Frame(self.top, borderwidth=3)
301 leftFrame.pack(fill=tk.BOTH, side=tk.LEFT, anchor=tk.NW, expand=1)
302 suiteNameFrame = tk.Frame(leftFrame, borderwidth=3)
303 suiteNameFrame.pack(fill=tk.X)
304
305 # Progress bar
306 progressFrame = tk.Frame(leftFrame, relief=tk.GROOVE, borderwidth=2)
307 progressFrame.pack(fill=tk.X, expand=0, anchor=tk.NW)
308 tk.Label(progressFrame, text="Progress:").pack(anchor=tk.W)
309 self.progressBar = ProgressBar(progressFrame, relief=tk.SUNKEN,
310 borderwidth=2)
311 self.progressBar.pack(fill=tk.X, expand=1)
312
313
314 # Area with buttons to start/stop tests and quit
315 buttonFrame = tk.Frame(self.top, borderwidth=3)
316 buttonFrame.pack(side=tk.LEFT, anchor=tk.NW, fill=tk.Y)
317
318 tk.Button(buttonFrame, text="Discover Tests",
319 command=self.discoverClicked).pack(fill=tk.X)
320
321
322 self.stopGoButton = tk.Button(buttonFrame, text="Start",
323 command=self.runClicked, state=tk.DISABLED)
324 self.stopGoButton.pack(fill=tk.X)
325
326 tk.Button(buttonFrame, text="Close",
327 command=self.top.quit).pack(side=tk.BOTTOM, fill=tk.X)
328 tk.Button(buttonFrame, text="Settings",
329 command=self.settingsClicked).pack(side=tk.BOTTOM, fill=tk.X)
330
331 # Area with labels reporting results
332 for label, var in (('Run:', self.runCountVar),
333 ('Failures:', self.failCountVar),
334 ('Errors:', self.errorCountVar),
335 ('Skipped:', self.skipCountVar),
336 ('Expected Failures:', self.expectFailCountVar),
337 ('Remaining:', self.remainingCountVar),
338 ):
339 tk.Label(progressFrame, text=label).pack(side=tk.LEFT)
340 tk.Label(progressFrame, textvariable=var,
341 foreground="blue").pack(side=tk.LEFT, fill=tk.X,
342 expand=1, anchor=tk.W)
343
344 # List box showing errors and failures
345 tk.Label(leftFrame, text="Failures and errors:").pack(anchor=tk.W)
346 listFrame = tk.Frame(leftFrame, relief=tk.SUNKEN, borderwidth=2)
347 listFrame.pack(fill=tk.BOTH, anchor=tk.NW, expand=1)
348 self.errorListbox = tk.Listbox(listFrame, foreground='red',
349 selectmode=tk.SINGLE,
350 selectborderwidth=0)
351 self.errorListbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=1,
352 anchor=tk.NW)
353 listScroll = tk.Scrollbar(listFrame, command=self.errorListbox.yview)
354 listScroll.pack(side=tk.LEFT, fill=tk.Y, anchor=tk.N)
355 self.errorListbox.bind("<Double-1>",
356 lambda e, self=self: self.showSelectedError())
357 self.errorListbox.configure(yscrollcommand=listScroll.set)
358
359 def errorDialog(self, title, message):
360 messagebox.showerror(parent=self.root, title=title,
361 message=message)
362
363 def notifyRunning(self):
364 self.runCountVar.set(0)
365 self.failCountVar.set(0)
366 self.errorCountVar.set(0)
367 self.remainingCountVar.set(self.totalTests)
368 self.errorInfo = []
369 while self.errorListbox.size():
370 self.errorListbox.delete(0)
371 #Stopping seems not to work, so simply disable the start button
372 #self.stopGoButton.config(command=self.stopClicked, text="Stop")
373 self.stopGoButton.config(state=tk.DISABLED)
374 self.progressBar.setProgressFraction(0.0)
375 self.top.update_idletasks()
376
377 def notifyStopped(self):
378 self.stopGoButton.config(state=tk.DISABLED)
379 #self.stopGoButton.config(command=self.runClicked, text="Start")
380 self.statusVar.set("Idle")
381
382 def notifyTestStarted(self, test):
383 self.statusVar.set(str(test))
384 self.top.update_idletasks()
385
386 def notifyTestFailed(self, test, err):
387 self.failCountVar.set(1 + self.failCountVar.get())
388 self.errorListbox.insert(tk.END, "Failure: %s" % test)
389 self.errorInfo.append((test,err))
390
391 def notifyTestErrored(self, test, err):
392 self.errorCountVar.set(1 + self.errorCountVar.get())
393 self.errorListbox.insert(tk.END, "Error: %s" % test)
394 self.errorInfo.append((test,err))
395
396 def notifyTestSkipped(self, test, reason):
397 super(TkTestRunner, self).notifyTestSkipped(test, reason)
398 self.skipCountVar.set(1 + self.skipCountVar.get())
399
400 def notifyTestFailedExpectedly(self, test, err):
401 super(TkTestRunner, self).notifyTestFailedExpectedly(test, err)
402 self.expectFailCountVar.set(1 + self.expectFailCountVar.get())
403
404
405 def notifyTestFinished(self, test):
406 self.remainingCountVar.set(self.remainingCountVar.get() - 1)
407 self.runCountVar.set(1 + self.runCountVar.get())
408 fractionDone = float(self.runCountVar.get())/float(self.totalTests)
409 fillColor = len(self.errorInfo) and "red" or "green"
410 self.progressBar.setProgressFraction(fractionDone, fillColor)
411
412 def showSelectedError(self):
413 selection = self.errorListbox.curselection()
414 if not selection: return
415 selected = int(selection[0])
416 txt = self.errorListbox.get(selected)
417 window = tk.Toplevel(self.root)
418 window.title(txt)
419 window.protocol('WM_DELETE_WINDOW', window.quit)
420 test, error = self.errorInfo[selected]
421 tk.Label(window, text=str(test),
422 foreground="red", justify=tk.LEFT).pack(anchor=tk.W)
423 tracebackLines = traceback.format_exception(*error)
424 tracebackText = "".join(tracebackLines)
425 tk.Label(window, text=tracebackText, justify=tk.LEFT).pack()
426 tk.Button(window, text="Close",
427 command=window.quit).pack(side=tk.BOTTOM)
428 window.bind('<Key-Return>', lambda e, w=window: w.quit())
429 window.mainloop()
430 window.destroy()
431
432
433class ProgressBar(tk.Frame):
434 """A simple progress bar that shows a percentage progress in
435 the given colour."""
436
437 def __init__(self, *args, **kwargs):
438 tk.Frame.__init__(self, *args, **kwargs)
439 self.canvas = tk.Canvas(self, height='20', width='60',
440 background='white', borderwidth=3)
441 self.canvas.pack(fill=tk.X, expand=1)
442 self.rect = self.text = None
443 self.canvas.bind('<Configure>', self.paint)
444 self.setProgressFraction(0.0)
445
446 def setProgressFraction(self, fraction, color='blue'):
447 self.fraction = fraction
448 self.color = color
449 self.paint()
450 self.canvas.update_idletasks()
451
452 def paint(self, *args):
453 totalWidth = self.canvas.winfo_width()
454 width = int(self.fraction * float(totalWidth))
455 height = self.canvas.winfo_height()
456 if self.rect is not None: self.canvas.delete(self.rect)
457 if self.text is not None: self.canvas.delete(self.text)
458 self.rect = self.canvas.create_rectangle(0, 0, width, height,
459 fill=self.color)
460 percentString = "%3.0f%%" % (100.0 * self.fraction)
461 self.text = self.canvas.create_text(totalWidth/2, height/2,
462 anchor=tk.CENTER,
463 text=percentString)
464
465def main(initialTestName=""):
466 root = tk.Tk()
467 root.title("PyUnit")
468 runner = TkTestRunner(root, initialTestName)
469 root.protocol('WM_DELETE_WINDOW', root.quit)
470 root.mainloop()
471
472
473if __name__ == '__main__':
474 if len(sys.argv) == 2:
475 main(sys.argv[1])
476 else:
477 main()