00001
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 Further information is available in the bundled documentation, and from
00007
00008 http://pyunit.sourceforge.net/
00009
00010 Copyright (c) 1999, 2000, 2001 Steve Purcell
00011 This module is free software, and you may redistribute it and/or modify
00012 it under the same terms as Python itself, so long as this copyright message
00013 and disclaimer are retained in their original form.
00014
00015 IN NO EVENT SHALL THE AUTHOR BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT,
00016 SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OF
00017 THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
00018 DAMAGE.
00019
00020 THE AUTHOR SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT
00021 LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
00022 PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS,
00023 AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
00024 SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
00025 """
00026
00027 __author__ = "Steve Purcell (stephen_purcell@yahoo.com)"
00028 __version__ = "$Revision: 2.0 $"[11:-2]
00029
00030 import unittest
00031 import sys
00032 import Tkinter
00033 import tkMessageBox
00034 import traceback
00035
00036 import string
00037 tk = Tkinter
00038
00039
00040
00041
00042
00043
00044 class BaseGUITestRunner:
00045 """Subclass this class to create a GUI TestRunner that uses a specific
00046 windowing toolkit. The class takes care of running tests in the correct
00047 manner, and making callbacks to the derived class to obtain information
00048 or signal that events have occurred.
00049 """
00050 def __init__(self, *args, **kwargs):
00051 self.currentResult = None
00052 self.running = 0
00053 self.__rollbackImporter = None
00054 apply(self.initGUI, args, kwargs)
00055
00056 def getSelectedTestName(self):
00057 "Override to return the name of the test selected to be run"
00058 pass
00059
00060 def errorDialog(self, title, message):
00061 "Override to display an error arising from GUI usage"
00062 pass
00063
00064 def runClicked(self):
00065 "To be called in response to user choosing to run a test"
00066 if self.running: return
00067 testName = self.getSelectedTestName()
00068 if not testName:
00069 self.errorDialog("Test name entry", "You must enter a test name")
00070 return
00071 if self.__rollbackImporter:
00072 self.__rollbackImporter.rollbackImports()
00073 self.__rollbackImporter = RollbackImporter()
00074 try:
00075 test = unittest.defaultTestLoader.loadTestsFromName(testName)
00076 except:
00077 exc_type, exc_value, exc_tb = sys.exc_info()
00078 apply(traceback.print_exception,sys.exc_info())
00079 self.errorDialog("Unable to run test '%s'" % testName,
00080 "Error loading specified test: %s, %s" % \
00081 (exc_type, exc_value))
00082 return
00083 self.currentResult = GUITestResult(self)
00084 self.totalTests = test.countTestCases()
00085 self.running = 1
00086 self.notifyRunning()
00087 test.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
00097
00098 def notifyRunning(self):
00099 "Override to set GUI in 'running' mode, enabling 'stop' button etc."
00100 pass
00101
00102 def notifyStopped(self):
00103 "Override to set GUI in 'stopped' mode, enabling 'run' button etc."
00104 pass
00105
00106 def notifyTestFailed(self, test, err):
00107 "Override to indicate that a test has just failed"
00108 pass
00109
00110 def notifyTestErrored(self, test, err):
00111 "Override to indicate that a test has just errored"
00112 pass
00113
00114 def notifyTestStarted(self, test):
00115 "Override to indicate that a test is about to run"
00116 pass
00117
00118 def notifyTestFinished(self, test):
00119 """Override to indicate that a test has finished (it may already have
00120 failed or errored)"""
00121 pass
00122
00123
00124 class GUITestResult(unittest.TestResult):
00125 """A TestResult that makes callbacks to its associated GUI TestRunner.
00126 Used by BaseGUITestRunner. Need not be created directly.
00127 """
00128 def __init__(self, callback):
00129 unittest.TestResult.__init__(self)
00130 self.callback = callback
00131
00132 def addError(self, test, err):
00133 unittest.TestResult.addError(self, test, err)
00134 self.callback.notifyTestErrored(test, err)
00135
00136 def addFailure(self, test, err):
00137 unittest.TestResult.addFailure(self, test, err)
00138 self.callback.notifyTestFailed(test, err)
00139
00140 def stopTest(self, test):
00141 unittest.TestResult.stopTest(self, test)
00142 self.callback.notifyTestFinished(test)
00143
00144 def startTest(self, test):
00145 unittest.TestResult.startTest(self, test)
00146 self.callback.notifyTestStarted(test)
00147
00148
00149 class RollbackImporter:
00150 """This tricky little class is used to make sure that modules under test
00151 will be reloaded the next time they are imported.
00152 """
00153 def __init__(self):
00154 self.previousModules = sys.modules.copy()
00155
00156 def rollbackImports(self):
00157 for modname in sys.modules.keys():
00158 if not self.previousModules.has_key(modname):
00159
00160 del(sys.modules[modname])
00161
00162
00163
00164
00165
00166
00167 _ABOUT_TEXT="""\
00168 PyUnit unit testing framework.
00169
00170 For more information, visit
00171 http://pyunit.sourceforge.net/
00172
00173 Copyright (c) 2000 Steve Purcell
00174 <stephen_purcell@yahoo.com>
00175 """
00176 _HELP_TEXT="""\
00177 Enter the name of a callable object which, when called, will return a \
00178 TestCase or TestSuite. Click 'start', and the test thus produced will be run.
00179
00180 Double click on an error in the listbox to see more information about it,\
00181 including the stack trace.
00182
00183 For more information, visit
00184 http://pyunit.sourceforge.net/
00185 or see the bundled documentation
00186 """
00187
00188 class TkTestRunner(BaseGUITestRunner):
00189 """An implementation of BaseGUITestRunner using Tkinter.
00190 """
00191 def initGUI(self, root, initialTestName):
00192 """Set up the GUI inside the given root window. The test name entry
00193 field will be pre-filled with the given initialTestName.
00194 """
00195 self.root = root
00196
00197 self.suiteNameVar = tk.StringVar()
00198 self.suiteNameVar.set(initialTestName)
00199 self.statusVar = tk.StringVar()
00200 self.statusVar.set("Idle")
00201 self.runCountVar = tk.IntVar()
00202 self.failCountVar = tk.IntVar()
00203 self.errorCountVar = tk.IntVar()
00204 self.remainingCountVar = tk.IntVar()
00205 self.top = tk.Frame()
00206 self.top.pack(fill=tk.BOTH, expand=1)
00207 self.createWidgets()
00208
00209 def createWidgets(self):
00210 """Creates and packs the various widgets.
00211
00212 Why is it that GUI code always ends up looking a mess, despite all the
00213 best intentions to keep it tidy? Answers on a postcard, please.
00214 """
00215
00216 statusFrame = tk.Frame(self.top, relief=tk.SUNKEN, borderwidth=2)
00217 statusFrame.pack(anchor=tk.SW, fill=tk.X, side=tk.BOTTOM)
00218 tk.Label(statusFrame, textvariable=self.statusVar).pack(side=tk.LEFT)
00219
00220
00221 leftFrame = tk.Frame(self.top, borderwidth=3)
00222 leftFrame.pack(fill=tk.BOTH, side=tk.LEFT, anchor=tk.NW, expand=1)
00223 suiteNameFrame = tk.Frame(leftFrame, borderwidth=3)
00224 suiteNameFrame.pack(fill=tk.X)
00225 tk.Label(suiteNameFrame, text="Enter test name:").pack(side=tk.LEFT)
00226 e = tk.Entry(suiteNameFrame, textvariable=self.suiteNameVar, width=25)
00227 e.pack(side=tk.LEFT, fill=tk.X, expand=1)
00228 e.focus_set()
00229 e.bind('<Key-Return>', lambda e, self=self: self.runClicked())
00230
00231
00232 progressFrame = tk.Frame(leftFrame, relief=tk.GROOVE, borderwidth=2)
00233 progressFrame.pack(fill=tk.X, expand=0, anchor=tk.NW)
00234 tk.Label(progressFrame, text="Progress:").pack(anchor=tk.W)
00235 self.progressBar = ProgressBar(progressFrame, relief=tk.SUNKEN,
00236 borderwidth=2)
00237 self.progressBar.pack(fill=tk.X, expand=1)
00238
00239
00240 buttonFrame = tk.Frame(self.top, borderwidth=3)
00241 buttonFrame.pack(side=tk.LEFT, anchor=tk.NW, fill=tk.Y)
00242 self.stopGoButton = tk.Button(buttonFrame, text="Start",
00243 command=self.runClicked)
00244 self.stopGoButton.pack(fill=tk.X)
00245 tk.Button(buttonFrame, text="Close",
00246 command=self.top.quit).pack(side=tk.BOTTOM, fill=tk.X)
00247 tk.Button(buttonFrame, text="About",
00248 command=self.showAboutDialog).pack(side=tk.BOTTOM, fill=tk.X)
00249 tk.Button(buttonFrame, text="Help",
00250 command=self.showHelpDialog).pack(side=tk.BOTTOM, fill=tk.X)
00251
00252
00253 for label, var in (('Run:', self.runCountVar),
00254 ('Failures:', self.failCountVar),
00255 ('Errors:', self.errorCountVar),
00256 ('Remaining:', self.remainingCountVar)):
00257 tk.Label(progressFrame, text=label).pack(side=tk.LEFT)
00258 tk.Label(progressFrame, textvariable=var,
00259 foreground="blue").pack(side=tk.LEFT, fill=tk.X,
00260 expand=1, anchor=tk.W)
00261
00262
00263 tk.Label(leftFrame, text="Failures and errors:").pack(anchor=tk.W)
00264 listFrame = tk.Frame(leftFrame, relief=tk.SUNKEN, borderwidth=2)
00265 listFrame.pack(fill=tk.BOTH, anchor=tk.NW, expand=1)
00266 self.errorListbox = tk.Listbox(listFrame, foreground='red',
00267 selectmode=tk.SINGLE,
00268 selectborderwidth=0)
00269 self.errorListbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=1,
00270 anchor=tk.NW)
00271 listScroll = tk.Scrollbar(listFrame, command=self.errorListbox.yview)
00272 listScroll.pack(side=tk.LEFT, fill=tk.Y, anchor=tk.N)
00273 self.errorListbox.bind("<Double-1>",
00274 lambda e, self=self: self.showSelectedError())
00275 self.errorListbox.configure(yscrollcommand=listScroll.set)
00276
00277
00278 def getSelectedTestName(self):
00279 return self.suiteNameVar.get()
00280
00281 def errorDialog(self, title, message):
00282 tkMessageBox.showerror(parent=self.root, title=title,
00283 message=message)
00284
00285 def notifyRunning(self):
00286 self.runCountVar.set(0)
00287 self.failCountVar.set(0)
00288 self.errorCountVar.set(0)
00289 self.remainingCountVar.set(self.totalTests)
00290 self.errorInfo = []
00291 while self.errorListbox.size():
00292 self.errorListbox.delete(0)
00293
00294
00295 self.stopGoButton.config(state=tk.DISABLED)
00296 self.progressBar.setProgressFraction(0.0)
00297 self.top.update_idletasks()
00298
00299 def notifyStopped(self):
00300 self.stopGoButton.config(state=tk.ACTIVE)
00301
00302 self.statusVar.set("Idle")
00303
00304 def notifyTestStarted(self, test):
00305 self.statusVar.set(str(test))
00306 self.top.update_idletasks()
00307
00308 def notifyTestFailed(self, test, err):
00309 self.failCountVar.set(1 + self.failCountVar.get())
00310 self.errorListbox.insert(tk.END, "Failure: %s" % test)
00311 self.errorInfo.append((test,err))
00312
00313 def notifyTestErrored(self, test, err):
00314 self.errorCountVar.set(1 + self.errorCountVar.get())
00315 self.errorListbox.insert(tk.END, "Error: %s" % test)
00316 self.errorInfo.append((test,err))
00317
00318 def notifyTestFinished(self, test):
00319 self.remainingCountVar.set(self.remainingCountVar.get() - 1)
00320 self.runCountVar.set(1 + self.runCountVar.get())
00321 fractionDone = float(self.runCountVar.get())/float(self.totalTests)
00322 fillColor = len(self.errorInfo) and "red" or "green"
00323 self.progressBar.setProgressFraction(fractionDone, fillColor)
00324
00325 def showAboutDialog(self):
00326 tkMessageBox.showinfo(parent=self.root, title="About PyUnit",
00327 message=_ABOUT_TEXT)
00328
00329 def showHelpDialog(self):
00330 tkMessageBox.showinfo(parent=self.root, title="PyUnit help",
00331 message=_HELP_TEXT)
00332
00333 def showSelectedError(self):
00334 selection = self.errorListbox.curselection()
00335 if not selection: return
00336 selected = int(selection[0])
00337 txt = self.errorListbox.get(selected)
00338 window = tk.Toplevel(self.root)
00339 window.title(txt)
00340 window.protocol('WM_DELETE_WINDOW', window.quit)
00341 test, error = self.errorInfo[selected]
00342 tk.Label(window, text=str(test),
00343 foreground="red", justify=tk.LEFT).pack(anchor=tk.W)
00344 tracebackLines = apply(traceback.format_exception, error + (10,))
00345 tracebackText = string.join(tracebackLines,'')
00346 tk.Label(window, text=tracebackText, justify=tk.LEFT).pack()
00347 tk.Button(window, text="Close",
00348 command=window.quit).pack(side=tk.BOTTOM)
00349 window.bind('<Key-Return>', lambda e, w=window: w.quit())
00350 window.mainloop()
00351 window.destroy()
00352
00353
00354 class ProgressBar(tk.Frame):
00355 """A simple progress bar that shows a percentage progress in
00356 the given colour."""
00357
00358 def __init__(self, *args, **kwargs):
00359 apply(tk.Frame.__init__, (self,) + args, kwargs)
00360 self.canvas = tk.Canvas(self, height='20', width='60',
00361 background='white', borderwidth=3)
00362 self.canvas.pack(fill=tk.X, expand=1)
00363 self.rect = self.text = None
00364 self.canvas.bind('<Configure>', self.paint)
00365 self.setProgressFraction(0.0)
00366
00367 def setProgressFraction(self, fraction, color='blue'):
00368 self.fraction = fraction
00369 self.color = color
00370 self.paint()
00371 self.canvas.update_idletasks()
00372
00373 def paint(self, *args):
00374 totalWidth = self.canvas.winfo_width()
00375 width = int(self.fraction * float(totalWidth))
00376 height = self.canvas.winfo_height()
00377 if self.rect is not None: self.canvas.delete(self.rect)
00378 if self.text is not None: self.canvas.delete(self.text)
00379 self.rect = self.canvas.create_rectangle(0, 0, width, height,
00380 fill=self.color)
00381 percentString = "%3.0f%%" % (100.0 * self.fraction)
00382 self.text = self.canvas.create_text(totalWidth/2, height/2,
00383 anchor=tk.CENTER,
00384 text=percentString)
00385
00386 def main(initialTestName=""):
00387 root = tk.Tk()
00388 root.title("PyUnit")
00389 runner = TkTestRunner(root, initialTestName)
00390 root.protocol('WM_DELETE_WINDOW', root.quit)
00391 root.mainloop()
00392
00393
00394 if __name__ == '__main__':
00395 import sys
00396 if len(sys.argv) == 2:
00397 main(sys.argv[1])
00398 else:
00399 main()