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 = 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): 277 discovered = test_suite.countTestCases() 278 self.runCountVar.set(0) 279 self.failCountVar.set(0) 280 self.errorCountVar.set(0) 281 self.remainingCountVar.set(discovered) 282 self.progressBar.setProgressFraction(0.0) 283 self.errorListbox.delete(0, tk.END) 284 self.statusVar.set("Discovering tests from %s. Found: %s" % 285 (self.directory_to_read, discovered)) 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() 478