1#!/usr/bin/env python3 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 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): 278 discovered = test_suite.countTestCases() 279 self.runCountVar.set(0) 280 self.failCountVar.set(0) 281 self.errorCountVar.set(0) 282 self.remainingCountVar.set(discovered) 283 self.progressBar.setProgressFraction(0.0) 284 self.errorListbox.delete(0, tk.END) 285 self.statusVar.set("Discovering tests from %s. Found: %s" % 286 (self.directory_to_read, discovered)) 287 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() 479