diff --git a/Doc/library/unittest.rst b/Doc/library/unittest.rst
index 3b49ea0cb50..ad6d31405e5 100644
--- a/Doc/library/unittest.rst
+++ b/Doc/library/unittest.rst
@@ -97,6 +97,13 @@ need to derive from a specific class.
A special-interest-group for discussion of testing, and testing tools,
in Python.
+ The script :file:`Tools/unittestgui/unittestgui.py` in the Python source distribution is
+ a GUI tool for test discovery and execution. This is intended largely for ease of use
+ for those new to unit testing. For production environments it is recommended that
+ tests be driven by a continuous integration system such as `Hudson `_
+ or `Buildbot `_.
+
+
.. _unittest-minimal-example:
Basic example
diff --git a/Misc/NEWS b/Misc/NEWS
index 4ee67ab84aa..ec870e7c083 100644
--- a/Misc/NEWS
+++ b/Misc/NEWS
@@ -107,6 +107,10 @@ Tools/Demos
demos have been removed, others integrated in documentation or a new
Tools/demo subdirectory.
+- Issue #10502: Addition of the unittestgui tool. Originally by Steve Purcell.
+ Updated for test discovery by Mark Roddy and Python 3 compatibility by
+ Brian Curtin.
+
What's New in Python 3.2 Beta 2?
================================
diff --git a/Tools/README b/Tools/README
index ff0d4a53208..355ba534f7b 100644
--- a/Tools/README
+++ b/Tools/README
@@ -34,8 +34,11 @@ scripts A number of useful single-file programs, e.g. tabnanny.py
test2to3 A demonstration of how to use 2to3 transparently in setup.py.
-unicode Tools used to generate unicode database files for
- Python 2.0 (by Fredrik Lundh).
+unicode Tools used to generate unicode database files (by Fredrik
+ Lundh).
+
+unittestgui A Tkinter based GUI test runner for unittest, with test
+ discovery.
world Script to take a list of Internet addresses and print
out where in the world those addresses originate from,
diff --git a/Tools/unittestgui/README.txt b/Tools/unittestgui/README.txt
new file mode 100644
index 00000000000..4d809df8705
--- /dev/null
+++ b/Tools/unittestgui/README.txt
@@ -0,0 +1,16 @@
+unittestgui.py is GUI framework and application for use with Python unit
+testing framework. It executes tests written using the framework provided
+by the 'unittest' module.
+
+Based on the original by Steve Purcell, from:
+
+ http://pyunit.sourceforge.net/
+
+Updated for unittest test discovery by Mark Roddy and Python 3
+support by Brian Curtin.
+
+For details on how to make your tests work with test discovery,
+and for explanations of the configuration options, see the unittest
+documentation:
+
+ http://docs.python.org/library/unittest.html#test-discovery
diff --git a/Tools/unittestgui/unittestgui.py b/Tools/unittestgui/unittestgui.py
new file mode 100644
index 00000000000..0c48b49c1bc
--- /dev/null
+++ b/Tools/unittestgui/unittestgui.py
@@ -0,0 +1,477 @@
+#!/usr/bin/env python
+"""
+GUI framework and application for use with Python unit testing framework.
+Execute tests written using the framework provided by the 'unittest' module.
+
+Updated for unittest test discovery by Mark Roddy and Python 3
+support by Brian Curtin.
+
+Based on the original by Steve Purcell, from:
+
+ http://pyunit.sourceforge.net/
+
+Copyright (c) 1999, 2000, 2001 Steve Purcell
+This module is free software, and you may redistribute it and/or modify
+it under the same terms as Python itself, so long as this copyright message
+and disclaimer are retained in their original form.
+
+IN NO EVENT SHALL THE AUTHOR BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT,
+SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OF
+THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
+DAMAGE.
+
+THE AUTHOR SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
+PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS,
+AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+"""
+
+__author__ = "Steve Purcell (stephen_purcell@yahoo.com)"
+__version__ = "$Revision: 1.7 $"[11:-2]
+
+import sys
+import traceback
+import unittest
+
+import tkinter as tk
+from tkinter import messagebox
+from tkinter import filedialog
+from tkinter import simpledialog
+
+
+
+
+##############################################################################
+# GUI framework classes
+##############################################################################
+
+class BaseGUITestRunner(object):
+ """Subclass this class to create a GUI TestRunner that uses a specific
+ windowing toolkit. The class takes care of running tests in the correct
+ manner, and making callbacks to the derived class to obtain information
+ or signal that events have occurred.
+ """
+ def __init__(self, *args, **kwargs):
+ self.currentResult = None
+ self.running = 0
+ self.__rollbackImporter = None
+ self.__rollbackImporter = RollbackImporter()
+ self.test_suite = None
+
+ #test discovery variables
+ self.directory_to_read = ''
+ self.top_level_dir = ''
+ self.test_file_glob_pattern = 'test*.py'
+
+ self.initGUI(*args, **kwargs)
+
+ def errorDialog(self, title, message):
+ "Override to display an error arising from GUI usage"
+ pass
+
+ def getDirectoryToDiscover(self):
+ "Override to prompt user for directory to perform test discovery"
+ pass
+
+ def runClicked(self):
+ "To be called in response to user choosing to run a test"
+ if self.running: return
+ if not self.test_suite:
+ self.errorDialog("Test Discovery", "You discover some tests first!")
+ return
+ self.currentResult = GUITestResult(self)
+ self.totalTests = self.test_suite.countTestCases()
+ self.running = 1
+ self.notifyRunning()
+ self.test_suite.run(self.currentResult)
+ self.running = 0
+ self.notifyStopped()
+
+ def stopClicked(self):
+ "To be called in response to user stopping the running of a test"
+ if self.currentResult:
+ self.currentResult.stop()
+
+ def discoverClicked(self):
+ self.__rollbackImporter.rollbackImports()
+ directory = self.getDirectoryToDiscover()
+ if not directory:
+ return
+ self.directory_to_read = directory
+ try:
+ # Explicitly use 'None' value if no top level directory is
+ # specified (indicated by empty string) as discover() explicitly
+ # checks for a 'None' to determine if no tld has been specified
+ top_level_dir = self.top_level_dir or None
+ tests = unittest.defaultTestLoader.discover(directory, self.test_file_glob_pattern, top_level_dir)
+ self.test_suite = tests
+ except:
+ exc_type, exc_value, exc_tb = sys.exc_info()
+ traceback.print_exception(*sys.exc_info())
+ self.errorDialog("Unable to run test '%s'" % directory,
+ "Error loading specified test: %s, %s" % (exc_type, exc_value))
+ return
+ self.notifyTestsDiscovered(self.test_suite)
+
+ # Required callbacks
+
+ def notifyTestsDiscovered(self, test_suite):
+ "Override to display information about the suite of discovered tests"
+ pass
+
+ def notifyRunning(self):
+ "Override to set GUI in 'running' mode, enabling 'stop' button etc."
+ pass
+
+ def notifyStopped(self):
+ "Override to set GUI in 'stopped' mode, enabling 'run' button etc."
+ pass
+
+ def notifyTestFailed(self, test, err):
+ "Override to indicate that a test has just failed"
+ pass
+
+ def notifyTestErrored(self, test, err):
+ "Override to indicate that a test has just errored"
+ pass
+
+ def notifyTestSkipped(self, test, reason):
+ "Override to indicate that test was skipped"
+ pass
+
+ def notifyTestFailedExpectedly(self, test, err):
+ "Override to indicate that test has just failed expectedly"
+ pass
+
+ def notifyTestStarted(self, test):
+ "Override to indicate that a test is about to run"
+ pass
+
+ def notifyTestFinished(self, test):
+ """Override to indicate that a test has finished (it may already have
+ failed or errored)"""
+ pass
+
+
+class GUITestResult(unittest.TestResult):
+ """A TestResult that makes callbacks to its associated GUI TestRunner.
+ Used by BaseGUITestRunner. Need not be created directly.
+ """
+ def __init__(self, callback):
+ unittest.TestResult.__init__(self)
+ self.callback = callback
+
+ def addError(self, test, err):
+ unittest.TestResult.addError(self, test, err)
+ self.callback.notifyTestErrored(test, err)
+
+ def addFailure(self, test, err):
+ unittest.TestResult.addFailure(self, test, err)
+ self.callback.notifyTestFailed(test, err)
+
+ def addSkip(self, test, reason):
+ super(GUITestResult,self).addSkip(test, reason)
+ self.callback.notifyTestSkipped(test, reason)
+
+ def addExpectedFailure(self, test, err):
+ super(GUITestResult,self).addExpectedFailure(test, err)
+ self.callback.notifyTestFailedExpectedly(test, err)
+
+ def stopTest(self, test):
+ unittest.TestResult.stopTest(self, test)
+ self.callback.notifyTestFinished(test)
+
+ def startTest(self, test):
+ unittest.TestResult.startTest(self, test)
+ self.callback.notifyTestStarted(test)
+
+
+class RollbackImporter:
+ """This tricky little class is used to make sure that modules under test
+ will be reloaded the next time they are imported.
+ """
+ def __init__(self):
+ self.previousModules = sys.modules.copy()
+
+ def rollbackImports(self):
+ for modname in sys.modules.copy().keys():
+ if not modname in self.previousModules:
+ # Force reload when modname next imported
+ del(sys.modules[modname])
+
+
+##############################################################################
+# Tkinter GUI
+##############################################################################
+
+class DiscoverSettingsDialog(simpledialog.Dialog):
+ """
+ Dialog box for prompting test discovery settings
+ """
+
+ def __init__(self, master, top_level_dir, test_file_glob_pattern, *args, **kwargs):
+ self.top_level_dir = top_level_dir
+ self.dirVar = tk.StringVar()
+ self.dirVar.set(top_level_dir)
+
+ self.test_file_glob_pattern = test_file_glob_pattern
+ self.testPatternVar = tk.StringVar()
+ self.testPatternVar.set(test_file_glob_pattern)
+
+ simpledialog.Dialog.__init__(self, master, title="Discover Settings",
+ *args, **kwargs)
+
+ def body(self, master):
+ tk.Label(master, text="Top Level Directory").grid(row=0)
+ self.e1 = tk.Entry(master, textvariable=self.dirVar)
+ self.e1.grid(row = 0, column=1)
+ tk.Button(master, text="...",
+ command=lambda: self.selectDirClicked(master)).grid(row=0,column=3)
+
+ tk.Label(master, text="Test File Pattern").grid(row=1)
+ self.e2 = tk.Entry(master, textvariable = self.testPatternVar)
+ self.e2.grid(row = 1, column=1)
+ return None
+
+ def selectDirClicked(self, master):
+ dir_path = filedialog.askdirectory(parent=master)
+ if dir_path:
+ self.dirVar.set(dir_path)
+
+ def apply(self):
+ self.top_level_dir = self.dirVar.get()
+ self.test_file_glob_pattern = self.testPatternVar.get()
+
+class TkTestRunner(BaseGUITestRunner):
+ """An implementation of BaseGUITestRunner using Tkinter.
+ """
+ def initGUI(self, root, initialTestName):
+ """Set up the GUI inside the given root window. The test name entry
+ field will be pre-filled with the given initialTestName.
+ """
+ self.root = root
+
+ self.statusVar = tk.StringVar()
+ self.statusVar.set("Idle")
+
+ #tk vars for tracking counts of test result types
+ self.runCountVar = tk.IntVar()
+ self.failCountVar = tk.IntVar()
+ self.errorCountVar = tk.IntVar()
+ self.skipCountVar = tk.IntVar()
+ self.expectFailCountVar = tk.IntVar()
+ self.remainingCountVar = tk.IntVar()
+
+ self.top = tk.Frame()
+ self.top.pack(fill=tk.BOTH, expand=1)
+ self.createWidgets()
+
+ def getDirectoryToDiscover(self):
+ return filedialog.askdirectory()
+
+ def settingsClicked(self):
+ d = DiscoverSettingsDialog(self.top, self.top_level_dir, self.test_file_glob_pattern)
+ self.top_level_dir = d.top_level_dir
+ self.test_file_glob_pattern = d.test_file_glob_pattern
+
+ def notifyTestsDiscovered(self, test_suite):
+ self.runCountVar.set(0)
+ self.failCountVar.set(0)
+ self.errorCountVar.set(0)
+ self.remainingCountVar.set(test_suite.countTestCases())
+ self.progressBar.setProgressFraction(0.0)
+ self.errorListbox.delete(0, tk.END)
+ self.statusVar.set("Discovering tests from %s" % self.directory_to_read)
+ self.stopGoButton['state'] = tk.NORMAL
+
+ def createWidgets(self):
+ """Creates and packs the various widgets.
+
+ Why is it that GUI code always ends up looking a mess, despite all the
+ best intentions to keep it tidy? Answers on a postcard, please.
+ """
+ # Status bar
+ statusFrame = tk.Frame(self.top, relief=tk.SUNKEN, borderwidth=2)
+ statusFrame.pack(anchor=tk.SW, fill=tk.X, side=tk.BOTTOM)
+ tk.Label(statusFrame, width=1, textvariable=self.statusVar).pack(side=tk.TOP, fill=tk.X)
+
+ # Area to enter name of test to run
+ leftFrame = tk.Frame(self.top, borderwidth=3)
+ leftFrame.pack(fill=tk.BOTH, side=tk.LEFT, anchor=tk.NW, expand=1)
+ suiteNameFrame = tk.Frame(leftFrame, borderwidth=3)
+ suiteNameFrame.pack(fill=tk.X)
+
+ # Progress bar
+ progressFrame = tk.Frame(leftFrame, relief=tk.GROOVE, borderwidth=2)
+ progressFrame.pack(fill=tk.X, expand=0, anchor=tk.NW)
+ tk.Label(progressFrame, text="Progress:").pack(anchor=tk.W)
+ self.progressBar = ProgressBar(progressFrame, relief=tk.SUNKEN,
+ borderwidth=2)
+ self.progressBar.pack(fill=tk.X, expand=1)
+
+
+ # Area with buttons to start/stop tests and quit
+ buttonFrame = tk.Frame(self.top, borderwidth=3)
+ buttonFrame.pack(side=tk.LEFT, anchor=tk.NW, fill=tk.Y)
+
+ tk.Button(buttonFrame, text="Discover Tests",
+ command=self.discoverClicked).pack(fill=tk.X)
+
+
+ self.stopGoButton = tk.Button(buttonFrame, text="Start",
+ command=self.runClicked, state=tk.DISABLED)
+ self.stopGoButton.pack(fill=tk.X)
+
+ tk.Button(buttonFrame, text="Close",
+ command=self.top.quit).pack(side=tk.BOTTOM, fill=tk.X)
+ tk.Button(buttonFrame, text="Settings",
+ command=self.settingsClicked).pack(side=tk.BOTTOM, fill=tk.X)
+
+ # Area with labels reporting results
+ for label, var in (('Run:', self.runCountVar),
+ ('Failures:', self.failCountVar),
+ ('Errors:', self.errorCountVar),
+ ('Skipped:', self.skipCountVar),
+ ('Expected Failures:', self.expectFailCountVar),
+ ('Remaining:', self.remainingCountVar),
+ ):
+ tk.Label(progressFrame, text=label).pack(side=tk.LEFT)
+ tk.Label(progressFrame, textvariable=var,
+ foreground="blue").pack(side=tk.LEFT, fill=tk.X,
+ expand=1, anchor=tk.W)
+
+ # List box showing errors and failures
+ tk.Label(leftFrame, text="Failures and errors:").pack(anchor=tk.W)
+ listFrame = tk.Frame(leftFrame, relief=tk.SUNKEN, borderwidth=2)
+ listFrame.pack(fill=tk.BOTH, anchor=tk.NW, expand=1)
+ self.errorListbox = tk.Listbox(listFrame, foreground='red',
+ selectmode=tk.SINGLE,
+ selectborderwidth=0)
+ self.errorListbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=1,
+ anchor=tk.NW)
+ listScroll = tk.Scrollbar(listFrame, command=self.errorListbox.yview)
+ listScroll.pack(side=tk.LEFT, fill=tk.Y, anchor=tk.N)
+ self.errorListbox.bind("",
+ lambda e, self=self: self.showSelectedError())
+ self.errorListbox.configure(yscrollcommand=listScroll.set)
+
+ def errorDialog(self, title, message):
+ messagebox.showerror(parent=self.root, title=title,
+ message=message)
+
+ def notifyRunning(self):
+ self.runCountVar.set(0)
+ self.failCountVar.set(0)
+ self.errorCountVar.set(0)
+ self.remainingCountVar.set(self.totalTests)
+ self.errorInfo = []
+ while self.errorListbox.size():
+ self.errorListbox.delete(0)
+ #Stopping seems not to work, so simply disable the start button
+ #self.stopGoButton.config(command=self.stopClicked, text="Stop")
+ self.stopGoButton.config(state=tk.DISABLED)
+ self.progressBar.setProgressFraction(0.0)
+ self.top.update_idletasks()
+
+ def notifyStopped(self):
+ self.stopGoButton.config(state=tk.DISABLED)
+ #self.stopGoButton.config(command=self.runClicked, text="Start")
+ self.statusVar.set("Idle")
+
+ def notifyTestStarted(self, test):
+ self.statusVar.set(str(test))
+ self.top.update_idletasks()
+
+ def notifyTestFailed(self, test, err):
+ self.failCountVar.set(1 + self.failCountVar.get())
+ self.errorListbox.insert(tk.END, "Failure: %s" % test)
+ self.errorInfo.append((test,err))
+
+ def notifyTestErrored(self, test, err):
+ self.errorCountVar.set(1 + self.errorCountVar.get())
+ self.errorListbox.insert(tk.END, "Error: %s" % test)
+ self.errorInfo.append((test,err))
+
+ def notifyTestSkipped(self, test, reason):
+ super(TkTestRunner, self).notifyTestSkipped(test, reason)
+ self.skipCountVar.set(1 + self.skipCountVar.get())
+
+ def notifyTestFailedExpectedly(self, test, err):
+ super(TkTestRunner, self).notifyTestFailedExpectedly(test, err)
+ self.expectFailCountVar.set(1 + self.expectFailCountVar.get())
+
+
+ def notifyTestFinished(self, test):
+ self.remainingCountVar.set(self.remainingCountVar.get() - 1)
+ self.runCountVar.set(1 + self.runCountVar.get())
+ fractionDone = float(self.runCountVar.get())/float(self.totalTests)
+ fillColor = len(self.errorInfo) and "red" or "green"
+ self.progressBar.setProgressFraction(fractionDone, fillColor)
+
+ def showSelectedError(self):
+ selection = self.errorListbox.curselection()
+ if not selection: return
+ selected = int(selection[0])
+ txt = self.errorListbox.get(selected)
+ window = tk.Toplevel(self.root)
+ window.title(txt)
+ window.protocol('WM_DELETE_WINDOW', window.quit)
+ test, error = self.errorInfo[selected]
+ tk.Label(window, text=str(test),
+ foreground="red", justify=tk.LEFT).pack(anchor=tk.W)
+ tracebackLines = traceback.format_exception(*error)
+ tracebackText = "".join(tracebackLines)
+ tk.Label(window, text=tracebackText, justify=tk.LEFT).pack()
+ tk.Button(window, text="Close",
+ command=window.quit).pack(side=tk.BOTTOM)
+ window.bind('', lambda e, w=window: w.quit())
+ window.mainloop()
+ window.destroy()
+
+
+class ProgressBar(tk.Frame):
+ """A simple progress bar that shows a percentage progress in
+ the given colour."""
+
+ def __init__(self, *args, **kwargs):
+ tk.Frame.__init__(self, *args, **kwargs)
+ self.canvas = tk.Canvas(self, height='20', width='60',
+ background='white', borderwidth=3)
+ self.canvas.pack(fill=tk.X, expand=1)
+ self.rect = self.text = None
+ self.canvas.bind('', self.paint)
+ self.setProgressFraction(0.0)
+
+ def setProgressFraction(self, fraction, color='blue'):
+ self.fraction = fraction
+ self.color = color
+ self.paint()
+ self.canvas.update_idletasks()
+
+ def paint(self, *args):
+ totalWidth = self.canvas.winfo_width()
+ width = int(self.fraction * float(totalWidth))
+ height = self.canvas.winfo_height()
+ if self.rect is not None: self.canvas.delete(self.rect)
+ if self.text is not None: self.canvas.delete(self.text)
+ self.rect = self.canvas.create_rectangle(0, 0, width, height,
+ fill=self.color)
+ percentString = "%3.0f%%" % (100.0 * self.fraction)
+ self.text = self.canvas.create_text(totalWidth/2, height/2,
+ anchor=tk.CENTER,
+ text=percentString)
+
+def main(initialTestName=""):
+ root = tk.Tk()
+ root.title("PyUnit")
+ runner = TkTestRunner(root, initialTestName)
+ root.protocol('WM_DELETE_WINDOW', root.quit)
+ root.mainloop()
+
+
+if __name__ == '__main__':
+ if len(sys.argv) == 2:
+ main(sys.argv[1])
+ else:
+ main()