Michael Foord | 90efac7 | 2011-01-03 15:39:49 +0000 | [diff] [blame] | 1 | #!/usr/bin/env python |
| 2 | """ |
| 3 | GUI framework and application for use with Python unit testing framework. |
| 4 | Execute tests written using the framework provided by the 'unittest' module. |
| 5 | |
| 6 | Updated for unittest test discovery by Mark Roddy and Python 3 |
| 7 | support by Brian Curtin. |
| 8 | |
| 9 | Based on the original by Steve Purcell, from: |
| 10 | |
| 11 | http://pyunit.sourceforge.net/ |
| 12 | |
| 13 | Copyright (c) 1999, 2000, 2001 Steve Purcell |
| 14 | This module is free software, and you may redistribute it and/or modify |
| 15 | it under the same terms as Python itself, so long as this copyright message |
| 16 | and disclaimer are retained in their original form. |
| 17 | |
| 18 | IN NO EVENT SHALL THE AUTHOR BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT, |
| 19 | SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OF |
| 20 | THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH |
| 21 | DAMAGE. |
| 22 | |
| 23 | THE AUTHOR SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT |
| 24 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A |
| 25 | PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS, |
| 26 | AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, |
| 27 | SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. |
| 28 | """ |
| 29 | |
| 30 | __author__ = "Steve Purcell (stephen_purcell@yahoo.com)" |
| 31 | __version__ = "$Revision: 1.7 $"[11:-2] |
| 32 | |
| 33 | import sys |
| 34 | import traceback |
| 35 | import unittest |
| 36 | |
| 37 | import tkinter as tk |
| 38 | from tkinter import messagebox |
| 39 | from tkinter import filedialog |
| 40 | from tkinter import simpledialog |
| 41 | |
| 42 | |
| 43 | |
| 44 | |
| 45 | ############################################################################## |
| 46 | # GUI framework classes |
| 47 | ############################################################################## |
| 48 | |
| 49 | class 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 | |
| 157 | class 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 | |
| 190 | class 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 | |
| 208 | class 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 | |
| 246 | class 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 Foord | 32e1d83 | 2011-01-03 17:00:11 +0000 | [diff] [blame^] | 279 | discovered = test_suite.countTestCases() |
Michael Foord | 90efac7 | 2011-01-03 15:39:49 +0000 | [diff] [blame] | 280 | self.runCountVar.set(0) |
| 281 | self.failCountVar.set(0) |
| 282 | self.errorCountVar.set(0) |
Michael Foord | 32e1d83 | 2011-01-03 17:00:11 +0000 | [diff] [blame^] | 283 | self.remainingCountVar.set(discovered) |
Michael Foord | 90efac7 | 2011-01-03 15:39:49 +0000 | [diff] [blame] | 284 | self.progressBar.setProgressFraction(0.0) |
| 285 | self.errorListbox.delete(0, tk.END) |
Michael Foord | 32e1d83 | 2011-01-03 17:00:11 +0000 | [diff] [blame^] | 286 | self.statusVar.set("Discovering tests from %s. Found: %s" % |
| 287 | (self.directory_to_read, discovered)) |
Michael Foord | 90efac7 | 2011-01-03 15:39:49 +0000 | [diff] [blame] | 288 | 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 | |
| 435 | class 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 | |
| 467 | def 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 | |
| 475 | if __name__ == '__main__': |
| 476 | if len(sys.argv) == 2: |
| 477 | main(sys.argv[1]) |
| 478 | else: |
| 479 | main() |