blob: 4fc5930adbbfc7747486ac4ef15b2cb879c6f1d5 [file] [log] [blame]
Serhiy Storchakab992a0e2014-01-16 17:15:49 +02001#!/usr/bin/env python3
Michael Foord90efac72011-01-03 15:39:49 +00002"""
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
Michael Foord90efac72011-01-03 15:39:49 +000057 self.__rollbackImporter = RollbackImporter()
58 self.test_suite = None
59
60 #test discovery variables
61 self.directory_to_read = ''
62 self.top_level_dir = ''
63 self.test_file_glob_pattern = 'test*.py'
64
65 self.initGUI(*args, **kwargs)
66
67 def errorDialog(self, title, message):
68 "Override to display an error arising from GUI usage"
69 pass
70
71 def getDirectoryToDiscover(self):
72 "Override to prompt user for directory to perform test discovery"
73 pass
74
75 def runClicked(self):
76 "To be called in response to user choosing to run a test"
77 if self.running: return
78 if not self.test_suite:
79 self.errorDialog("Test Discovery", "You discover some tests first!")
80 return
81 self.currentResult = GUITestResult(self)
82 self.totalTests = self.test_suite.countTestCases()
83 self.running = 1
84 self.notifyRunning()
85 self.test_suite.run(self.currentResult)
86 self.running = 0
87 self.notifyStopped()
88
89 def stopClicked(self):
90 "To be called in response to user stopping the running of a test"
91 if self.currentResult:
92 self.currentResult.stop()
93
94 def discoverClicked(self):
95 self.__rollbackImporter.rollbackImports()
96 directory = self.getDirectoryToDiscover()
97 if not directory:
98 return
99 self.directory_to_read = directory
100 try:
101 # Explicitly use 'None' value if no top level directory is
102 # specified (indicated by empty string) as discover() explicitly
103 # checks for a 'None' to determine if no tld has been specified
104 top_level_dir = self.top_level_dir or None
105 tests = unittest.defaultTestLoader.discover(directory, self.test_file_glob_pattern, top_level_dir)
106 self.test_suite = tests
107 except:
108 exc_type, exc_value, exc_tb = sys.exc_info()
109 traceback.print_exception(*sys.exc_info())
110 self.errorDialog("Unable to run test '%s'" % directory,
111 "Error loading specified test: %s, %s" % (exc_type, exc_value))
112 return
113 self.notifyTestsDiscovered(self.test_suite)
114
115 # Required callbacks
116
117 def notifyTestsDiscovered(self, test_suite):
118 "Override to display information about the suite of discovered tests"
119 pass
120
121 def notifyRunning(self):
122 "Override to set GUI in 'running' mode, enabling 'stop' button etc."
123 pass
124
125 def notifyStopped(self):
126 "Override to set GUI in 'stopped' mode, enabling 'run' button etc."
127 pass
128
129 def notifyTestFailed(self, test, err):
130 "Override to indicate that a test has just failed"
131 pass
132
133 def notifyTestErrored(self, test, err):
134 "Override to indicate that a test has just errored"
135 pass
136
137 def notifyTestSkipped(self, test, reason):
138 "Override to indicate that test was skipped"
139 pass
140
141 def notifyTestFailedExpectedly(self, test, err):
142 "Override to indicate that test has just failed expectedly"
143 pass
144
145 def notifyTestStarted(self, test):
146 "Override to indicate that a test is about to run"
147 pass
148
149 def notifyTestFinished(self, test):
150 """Override to indicate that a test has finished (it may already have
151 failed or errored)"""
152 pass
153
154
155class GUITestResult(unittest.TestResult):
156 """A TestResult that makes callbacks to its associated GUI TestRunner.
157 Used by BaseGUITestRunner. Need not be created directly.
158 """
159 def __init__(self, callback):
160 unittest.TestResult.__init__(self)
161 self.callback = callback
162
163 def addError(self, test, err):
164 unittest.TestResult.addError(self, test, err)
165 self.callback.notifyTestErrored(test, err)
166
167 def addFailure(self, test, err):
168 unittest.TestResult.addFailure(self, test, err)
169 self.callback.notifyTestFailed(test, err)
170
171 def addSkip(self, test, reason):
172 super(GUITestResult,self).addSkip(test, reason)
173 self.callback.notifyTestSkipped(test, reason)
174
175 def addExpectedFailure(self, test, err):
176 super(GUITestResult,self).addExpectedFailure(test, err)
177 self.callback.notifyTestFailedExpectedly(test, err)
178
179 def stopTest(self, test):
180 unittest.TestResult.stopTest(self, test)
181 self.callback.notifyTestFinished(test)
182
183 def startTest(self, test):
184 unittest.TestResult.startTest(self, test)
185 self.callback.notifyTestStarted(test)
186
187
188class RollbackImporter:
189 """This tricky little class is used to make sure that modules under test
190 will be reloaded the next time they are imported.
191 """
192 def __init__(self):
193 self.previousModules = sys.modules.copy()
194
195 def rollbackImports(self):
196 for modname in sys.modules.copy().keys():
197 if not modname in self.previousModules:
198 # Force reload when modname next imported
199 del(sys.modules[modname])
200
201
202##############################################################################
203# Tkinter GUI
204##############################################################################
205
206class DiscoverSettingsDialog(simpledialog.Dialog):
207 """
208 Dialog box for prompting test discovery settings
209 """
210
211 def __init__(self, master, top_level_dir, test_file_glob_pattern, *args, **kwargs):
212 self.top_level_dir = top_level_dir
213 self.dirVar = tk.StringVar()
214 self.dirVar.set(top_level_dir)
215
216 self.test_file_glob_pattern = test_file_glob_pattern
217 self.testPatternVar = tk.StringVar()
218 self.testPatternVar.set(test_file_glob_pattern)
219
220 simpledialog.Dialog.__init__(self, master, title="Discover Settings",
221 *args, **kwargs)
222
223 def body(self, master):
224 tk.Label(master, text="Top Level Directory").grid(row=0)
225 self.e1 = tk.Entry(master, textvariable=self.dirVar)
226 self.e1.grid(row = 0, column=1)
227 tk.Button(master, text="...",
228 command=lambda: self.selectDirClicked(master)).grid(row=0,column=3)
229
230 tk.Label(master, text="Test File Pattern").grid(row=1)
231 self.e2 = tk.Entry(master, textvariable = self.testPatternVar)
232 self.e2.grid(row = 1, column=1)
233 return None
234
235 def selectDirClicked(self, master):
236 dir_path = filedialog.askdirectory(parent=master)
237 if dir_path:
238 self.dirVar.set(dir_path)
239
240 def apply(self):
241 self.top_level_dir = self.dirVar.get()
242 self.test_file_glob_pattern = self.testPatternVar.get()
243
244class TkTestRunner(BaseGUITestRunner):
245 """An implementation of BaseGUITestRunner using Tkinter.
246 """
247 def initGUI(self, root, initialTestName):
248 """Set up the GUI inside the given root window. The test name entry
249 field will be pre-filled with the given initialTestName.
250 """
251 self.root = root
252
253 self.statusVar = tk.StringVar()
254 self.statusVar.set("Idle")
255
256 #tk vars for tracking counts of test result types
257 self.runCountVar = tk.IntVar()
258 self.failCountVar = tk.IntVar()
259 self.errorCountVar = tk.IntVar()
260 self.skipCountVar = tk.IntVar()
261 self.expectFailCountVar = tk.IntVar()
262 self.remainingCountVar = tk.IntVar()
263
264 self.top = tk.Frame()
265 self.top.pack(fill=tk.BOTH, expand=1)
266 self.createWidgets()
267
268 def getDirectoryToDiscover(self):
269 return filedialog.askdirectory()
270
271 def settingsClicked(self):
272 d = DiscoverSettingsDialog(self.top, self.top_level_dir, self.test_file_glob_pattern)
273 self.top_level_dir = d.top_level_dir
274 self.test_file_glob_pattern = d.test_file_glob_pattern
275
276 def notifyTestsDiscovered(self, test_suite):
Michael Foord32e1d832011-01-03 17:00:11 +0000277 discovered = test_suite.countTestCases()
Michael Foord90efac72011-01-03 15:39:49 +0000278 self.runCountVar.set(0)
279 self.failCountVar.set(0)
280 self.errorCountVar.set(0)
Michael Foord32e1d832011-01-03 17:00:11 +0000281 self.remainingCountVar.set(discovered)
Michael Foord90efac72011-01-03 15:39:49 +0000282 self.progressBar.setProgressFraction(0.0)
283 self.errorListbox.delete(0, tk.END)
Michael Foord32e1d832011-01-03 17:00:11 +0000284 self.statusVar.set("Discovering tests from %s. Found: %s" %
285 (self.directory_to_read, discovered))
Michael Foord90efac72011-01-03 15:39:49 +0000286 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()