Back to index

python3.2  3.2.2
unittestgui.py
Go to the documentation of this file.
00001 #!/usr/bin/env python
00002 """
00003 GUI framework and application for use with Python unit testing framework.
00004 Execute tests written using the framework provided by the 'unittest' module.
00005 
00006 Updated for unittest test discovery by Mark Roddy and Python 3
00007 support by Brian Curtin.
00008 
00009 Based on the original by Steve Purcell, from:
00010 
00011   http://pyunit.sourceforge.net/
00012 
00013 Copyright (c) 1999, 2000, 2001 Steve Purcell
00014 This module is free software, and you may redistribute it and/or modify
00015 it under the same terms as Python itself, so long as this copyright message
00016 and disclaimer are retained in their original form.
00017 
00018 IN NO EVENT SHALL THE AUTHOR BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT,
00019 SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OF
00020 THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
00021 DAMAGE.
00022 
00023 THE AUTHOR SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT
00024 LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
00025 PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS,
00026 AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
00027 SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
00028 """
00029 
00030 __author__ = "Steve Purcell (stephen_purcell@yahoo.com)"
00031 __version__ = "$Revision: 1.7 $"[11:-2]
00032 
00033 import sys
00034 import traceback
00035 import unittest
00036 
00037 import tkinter as tk
00038 from tkinter import messagebox
00039 from tkinter import filedialog
00040 from tkinter import simpledialog
00041 
00042 
00043 
00044 
00045 ##############################################################################
00046 # GUI framework classes
00047 ##############################################################################
00048 
00049 class BaseGUITestRunner(object):
00050     """Subclass this class to create a GUI TestRunner that uses a specific
00051     windowing toolkit. The class takes care of running tests in the correct
00052     manner, and making callbacks to the derived class to obtain information
00053     or signal that events have occurred.
00054     """
00055     def __init__(self, *args, **kwargs):
00056         self.currentResult = None
00057         self.running = 0
00058         self.__rollbackImporter = None
00059         self.__rollbackImporter = RollbackImporter()
00060         self.test_suite = None
00061 
00062         #test discovery variables
00063         self.directory_to_read = ''
00064         self.top_level_dir = ''
00065         self.test_file_glob_pattern = 'test*.py'
00066 
00067         self.initGUI(*args, **kwargs)
00068 
00069     def errorDialog(self, title, message):
00070         "Override to display an error arising from GUI usage"
00071         pass
00072 
00073     def getDirectoryToDiscover(self):
00074         "Override to prompt user for directory to perform test discovery"
00075         pass
00076 
00077     def runClicked(self):
00078         "To be called in response to user choosing to run a test"
00079         if self.running: return
00080         if not self.test_suite:
00081             self.errorDialog("Test Discovery", "You discover some tests first!")
00082             return
00083         self.currentResult = GUITestResult(self)
00084         self.totalTests = self.test_suite.countTestCases()
00085         self.running = 1
00086         self.notifyRunning()
00087         self.test_suite.run(self.currentResult)
00088         self.running = 0
00089         self.notifyStopped()
00090 
00091     def stopClicked(self):
00092         "To be called in response to user stopping the running of a test"
00093         if self.currentResult:
00094             self.currentResult.stop()
00095 
00096     def discoverClicked(self):
00097         self.__rollbackImporter.rollbackImports()
00098         directory = self.getDirectoryToDiscover()
00099         if not directory:
00100             return
00101         self.directory_to_read = directory
00102         try:
00103             # Explicitly use 'None' value if no top level directory is
00104             # specified (indicated by empty string) as discover() explicitly
00105             # checks for a 'None' to determine if no tld has been specified
00106             top_level_dir = self.top_level_dir or None
00107             tests = unittest.defaultTestLoader.discover(directory, self.test_file_glob_pattern, top_level_dir)
00108             self.test_suite = tests
00109         except:
00110             exc_type, exc_value, exc_tb = sys.exc_info()
00111             traceback.print_exception(*sys.exc_info())
00112             self.errorDialog("Unable to run test '%s'" % directory,
00113                              "Error loading specified test: %s, %s" % (exc_type, exc_value))
00114             return
00115         self.notifyTestsDiscovered(self.test_suite)
00116 
00117     # Required callbacks
00118 
00119     def notifyTestsDiscovered(self, test_suite):
00120         "Override to display information about the suite of discovered tests"
00121         pass
00122 
00123     def notifyRunning(self):
00124         "Override to set GUI in 'running' mode, enabling 'stop' button etc."
00125         pass
00126 
00127     def notifyStopped(self):
00128         "Override to set GUI in 'stopped' mode, enabling 'run' button etc."
00129         pass
00130 
00131     def notifyTestFailed(self, test, err):
00132         "Override to indicate that a test has just failed"
00133         pass
00134 
00135     def notifyTestErrored(self, test, err):
00136         "Override to indicate that a test has just errored"
00137         pass
00138 
00139     def notifyTestSkipped(self, test, reason):
00140         "Override to indicate that test was skipped"
00141         pass
00142 
00143     def notifyTestFailedExpectedly(self, test, err):
00144         "Override to indicate that test has just failed expectedly"
00145         pass
00146 
00147     def notifyTestStarted(self, test):
00148         "Override to indicate that a test is about to run"
00149         pass
00150 
00151     def notifyTestFinished(self, test):
00152         """Override to indicate that a test has finished (it may already have
00153            failed or errored)"""
00154         pass
00155 
00156 
00157 class GUITestResult(unittest.TestResult):
00158     """A TestResult that makes callbacks to its associated GUI TestRunner.
00159     Used by BaseGUITestRunner. Need not be created directly.
00160     """
00161     def __init__(self, callback):
00162         unittest.TestResult.__init__(self)
00163         self.callback = callback
00164 
00165     def addError(self, test, err):
00166         unittest.TestResult.addError(self, test, err)
00167         self.callback.notifyTestErrored(test, err)
00168 
00169     def addFailure(self, test, err):
00170         unittest.TestResult.addFailure(self, test, err)
00171         self.callback.notifyTestFailed(test, err)
00172 
00173     def addSkip(self, test, reason):
00174         super(GUITestResult,self).addSkip(test, reason)
00175         self.callback.notifyTestSkipped(test, reason)
00176 
00177     def addExpectedFailure(self, test, err):
00178         super(GUITestResult,self).addExpectedFailure(test, err)
00179         self.callback.notifyTestFailedExpectedly(test, err)
00180 
00181     def stopTest(self, test):
00182         unittest.TestResult.stopTest(self, test)
00183         self.callback.notifyTestFinished(test)
00184 
00185     def startTest(self, test):
00186         unittest.TestResult.startTest(self, test)
00187         self.callback.notifyTestStarted(test)
00188 
00189 
00190 class RollbackImporter:
00191     """This tricky little class is used to make sure that modules under test
00192     will be reloaded the next time they are imported.
00193     """
00194     def __init__(self):
00195         self.previousModules = sys.modules.copy()
00196 
00197     def rollbackImports(self):
00198         for modname in sys.modules.copy().keys():
00199             if not modname in self.previousModules:
00200                 # Force reload when modname next imported
00201                 del(sys.modules[modname])
00202 
00203 
00204 ##############################################################################
00205 # Tkinter GUI
00206 ##############################################################################
00207 
00208 class DiscoverSettingsDialog(simpledialog.Dialog):
00209     """
00210     Dialog box for prompting test discovery settings
00211     """
00212 
00213     def __init__(self, master, top_level_dir, test_file_glob_pattern, *args, **kwargs):
00214         self.top_level_dir = top_level_dir
00215         self.dirVar = tk.StringVar()
00216         self.dirVar.set(top_level_dir)
00217 
00218         self.test_file_glob_pattern = test_file_glob_pattern
00219         self.testPatternVar = tk.StringVar()
00220         self.testPatternVar.set(test_file_glob_pattern)
00221 
00222         simpledialog.Dialog.__init__(self, master, title="Discover Settings",
00223                                      *args, **kwargs)
00224 
00225     def body(self, master):
00226         tk.Label(master, text="Top Level Directory").grid(row=0)
00227         self.e1 = tk.Entry(master, textvariable=self.dirVar)
00228         self.e1.grid(row = 0, column=1)
00229         tk.Button(master, text="...",
00230                   command=lambda: self.selectDirClicked(master)).grid(row=0,column=3)
00231 
00232         tk.Label(master, text="Test File Pattern").grid(row=1)
00233         self.e2 = tk.Entry(master, textvariable = self.testPatternVar)
00234         self.e2.grid(row = 1, column=1)
00235         return None
00236 
00237     def selectDirClicked(self, master):
00238         dir_path = filedialog.askdirectory(parent=master)
00239         if dir_path:
00240             self.dirVar.set(dir_path)
00241 
00242     def apply(self):
00243         self.top_level_dir = self.dirVar.get()
00244         self.test_file_glob_pattern = self.testPatternVar.get()
00245 
00246 class TkTestRunner(BaseGUITestRunner):
00247     """An implementation of BaseGUITestRunner using Tkinter.
00248     """
00249     def initGUI(self, root, initialTestName):
00250         """Set up the GUI inside the given root window. The test name entry
00251         field will be pre-filled with the given initialTestName.
00252         """
00253         self.root = root
00254 
00255         self.statusVar = tk.StringVar()
00256         self.statusVar.set("Idle")
00257 
00258         #tk vars for tracking counts of test result types
00259         self.runCountVar = tk.IntVar()
00260         self.failCountVar = tk.IntVar()
00261         self.errorCountVar = tk.IntVar()
00262         self.skipCountVar = tk.IntVar()
00263         self.expectFailCountVar = tk.IntVar()
00264         self.remainingCountVar = tk.IntVar()
00265 
00266         self.top = tk.Frame()
00267         self.top.pack(fill=tk.BOTH, expand=1)
00268         self.createWidgets()
00269 
00270     def getDirectoryToDiscover(self):
00271         return filedialog.askdirectory()
00272 
00273     def settingsClicked(self):
00274         d = DiscoverSettingsDialog(self.top, self.top_level_dir, self.test_file_glob_pattern)
00275         self.top_level_dir = d.top_level_dir
00276         self.test_file_glob_pattern = d.test_file_glob_pattern
00277 
00278     def notifyTestsDiscovered(self, test_suite):
00279         discovered = test_suite.countTestCases()
00280         self.runCountVar.set(0)
00281         self.failCountVar.set(0)
00282         self.errorCountVar.set(0)
00283         self.remainingCountVar.set(discovered)
00284         self.progressBar.setProgressFraction(0.0)
00285         self.errorListbox.delete(0, tk.END)
00286         self.statusVar.set("Discovering tests from %s. Found: %s" %
00287             (self.directory_to_read, discovered))
00288         self.stopGoButton['state'] = tk.NORMAL
00289 
00290     def createWidgets(self):
00291         """Creates and packs the various widgets.
00292 
00293         Why is it that GUI code always ends up looking a mess, despite all the
00294         best intentions to keep it tidy? Answers on a postcard, please.
00295         """
00296         # Status bar
00297         statusFrame = tk.Frame(self.top, relief=tk.SUNKEN, borderwidth=2)
00298         statusFrame.pack(anchor=tk.SW, fill=tk.X, side=tk.BOTTOM)
00299         tk.Label(statusFrame, width=1, textvariable=self.statusVar).pack(side=tk.TOP, fill=tk.X)
00300 
00301         # Area to enter name of test to run
00302         leftFrame = tk.Frame(self.top, borderwidth=3)
00303         leftFrame.pack(fill=tk.BOTH, side=tk.LEFT, anchor=tk.NW, expand=1)
00304         suiteNameFrame = tk.Frame(leftFrame, borderwidth=3)
00305         suiteNameFrame.pack(fill=tk.X)
00306 
00307         # Progress bar
00308         progressFrame = tk.Frame(leftFrame, relief=tk.GROOVE, borderwidth=2)
00309         progressFrame.pack(fill=tk.X, expand=0, anchor=tk.NW)
00310         tk.Label(progressFrame, text="Progress:").pack(anchor=tk.W)
00311         self.progressBar = ProgressBar(progressFrame, relief=tk.SUNKEN,
00312                                        borderwidth=2)
00313         self.progressBar.pack(fill=tk.X, expand=1)
00314 
00315 
00316         # Area with buttons to start/stop tests and quit
00317         buttonFrame = tk.Frame(self.top, borderwidth=3)
00318         buttonFrame.pack(side=tk.LEFT, anchor=tk.NW, fill=tk.Y)
00319 
00320         tk.Button(buttonFrame, text="Discover Tests",
00321                   command=self.discoverClicked).pack(fill=tk.X)
00322 
00323 
00324         self.stopGoButton = tk.Button(buttonFrame, text="Start",
00325                                       command=self.runClicked, state=tk.DISABLED)
00326         self.stopGoButton.pack(fill=tk.X)
00327 
00328         tk.Button(buttonFrame, text="Close",
00329                   command=self.top.quit).pack(side=tk.BOTTOM, fill=tk.X)
00330         tk.Button(buttonFrame, text="Settings",
00331                   command=self.settingsClicked).pack(side=tk.BOTTOM, fill=tk.X)
00332 
00333         # Area with labels reporting results
00334         for label, var in (('Run:', self.runCountVar),
00335                            ('Failures:', self.failCountVar),
00336                            ('Errors:', self.errorCountVar),
00337                            ('Skipped:', self.skipCountVar),
00338                            ('Expected Failures:', self.expectFailCountVar),
00339                            ('Remaining:', self.remainingCountVar),
00340                            ):
00341             tk.Label(progressFrame, text=label).pack(side=tk.LEFT)
00342             tk.Label(progressFrame, textvariable=var,
00343                      foreground="blue").pack(side=tk.LEFT, fill=tk.X,
00344                                              expand=1, anchor=tk.W)
00345 
00346         # List box showing errors and failures
00347         tk.Label(leftFrame, text="Failures and errors:").pack(anchor=tk.W)
00348         listFrame = tk.Frame(leftFrame, relief=tk.SUNKEN, borderwidth=2)
00349         listFrame.pack(fill=tk.BOTH, anchor=tk.NW, expand=1)
00350         self.errorListbox = tk.Listbox(listFrame, foreground='red',
00351                                        selectmode=tk.SINGLE,
00352                                        selectborderwidth=0)
00353         self.errorListbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=1,
00354                                anchor=tk.NW)
00355         listScroll = tk.Scrollbar(listFrame, command=self.errorListbox.yview)
00356         listScroll.pack(side=tk.LEFT, fill=tk.Y, anchor=tk.N)
00357         self.errorListbox.bind("<Double-1>",
00358                                lambda e, self=self: self.showSelectedError())
00359         self.errorListbox.configure(yscrollcommand=listScroll.set)
00360 
00361     def errorDialog(self, title, message):
00362         messagebox.showerror(parent=self.root, title=title,
00363                              message=message)
00364 
00365     def notifyRunning(self):
00366         self.runCountVar.set(0)
00367         self.failCountVar.set(0)
00368         self.errorCountVar.set(0)
00369         self.remainingCountVar.set(self.totalTests)
00370         self.errorInfo = []
00371         while self.errorListbox.size():
00372             self.errorListbox.delete(0)
00373         #Stopping seems not to work, so simply disable the start button
00374         #self.stopGoButton.config(command=self.stopClicked, text="Stop")
00375         self.stopGoButton.config(state=tk.DISABLED)
00376         self.progressBar.setProgressFraction(0.0)
00377         self.top.update_idletasks()
00378 
00379     def notifyStopped(self):
00380         self.stopGoButton.config(state=tk.DISABLED)
00381         #self.stopGoButton.config(command=self.runClicked, text="Start")
00382         self.statusVar.set("Idle")
00383 
00384     def notifyTestStarted(self, test):
00385         self.statusVar.set(str(test))
00386         self.top.update_idletasks()
00387 
00388     def notifyTestFailed(self, test, err):
00389         self.failCountVar.set(1 + self.failCountVar.get())
00390         self.errorListbox.insert(tk.END, "Failure: %s" % test)
00391         self.errorInfo.append((test,err))
00392 
00393     def notifyTestErrored(self, test, err):
00394         self.errorCountVar.set(1 + self.errorCountVar.get())
00395         self.errorListbox.insert(tk.END, "Error: %s" % test)
00396         self.errorInfo.append((test,err))
00397 
00398     def notifyTestSkipped(self, test, reason):
00399         super(TkTestRunner, self).notifyTestSkipped(test, reason)
00400         self.skipCountVar.set(1 + self.skipCountVar.get())
00401 
00402     def notifyTestFailedExpectedly(self, test, err):
00403         super(TkTestRunner, self).notifyTestFailedExpectedly(test, err)
00404         self.expectFailCountVar.set(1 + self.expectFailCountVar.get())
00405 
00406 
00407     def notifyTestFinished(self, test):
00408         self.remainingCountVar.set(self.remainingCountVar.get() - 1)
00409         self.runCountVar.set(1 + self.runCountVar.get())
00410         fractionDone = float(self.runCountVar.get())/float(self.totalTests)
00411         fillColor = len(self.errorInfo) and "red" or "green"
00412         self.progressBar.setProgressFraction(fractionDone, fillColor)
00413 
00414     def showSelectedError(self):
00415         selection = self.errorListbox.curselection()
00416         if not selection: return
00417         selected = int(selection[0])
00418         txt = self.errorListbox.get(selected)
00419         window = tk.Toplevel(self.root)
00420         window.title(txt)
00421         window.protocol('WM_DELETE_WINDOW', window.quit)
00422         test, error = self.errorInfo[selected]
00423         tk.Label(window, text=str(test),
00424                  foreground="red", justify=tk.LEFT).pack(anchor=tk.W)
00425         tracebackLines =  traceback.format_exception(*error)
00426         tracebackText = "".join(tracebackLines)
00427         tk.Label(window, text=tracebackText, justify=tk.LEFT).pack()
00428         tk.Button(window, text="Close",
00429                   command=window.quit).pack(side=tk.BOTTOM)
00430         window.bind('<Key-Return>', lambda e, w=window: w.quit())
00431         window.mainloop()
00432         window.destroy()
00433 
00434 
00435 class ProgressBar(tk.Frame):
00436     """A simple progress bar that shows a percentage progress in
00437     the given colour."""
00438 
00439     def __init__(self, *args, **kwargs):
00440         tk.Frame.__init__(self, *args, **kwargs)
00441         self.canvas = tk.Canvas(self, height='20', width='60',
00442                                 background='white', borderwidth=3)
00443         self.canvas.pack(fill=tk.X, expand=1)
00444         self.rect = self.text = None
00445         self.canvas.bind('<Configure>', self.paint)
00446         self.setProgressFraction(0.0)
00447 
00448     def setProgressFraction(self, fraction, color='blue'):
00449         self.fraction = fraction
00450         self.color = color
00451         self.paint()
00452         self.canvas.update_idletasks()
00453 
00454     def paint(self, *args):
00455         totalWidth = self.canvas.winfo_width()
00456         width = int(self.fraction * float(totalWidth))
00457         height = self.canvas.winfo_height()
00458         if self.rect is not None: self.canvas.delete(self.rect)
00459         if self.text is not None: self.canvas.delete(self.text)
00460         self.rect = self.canvas.create_rectangle(0, 0, width, height,
00461                                                  fill=self.color)
00462         percentString = "%3.0f%%" % (100.0 * self.fraction)
00463         self.text = self.canvas.create_text(totalWidth/2, height/2,
00464                                             anchor=tk.CENTER,
00465                                             text=percentString)
00466 
00467 def main(initialTestName=""):
00468     root = tk.Tk()
00469     root.title("PyUnit")
00470     runner = TkTestRunner(root, initialTestName)
00471     root.protocol('WM_DELETE_WINDOW', root.quit)
00472     root.mainloop()
00473 
00474 
00475 if __name__ == '__main__':
00476     if len(sys.argv) == 2:
00477         main(sys.argv[1])
00478     else:
00479         main()