• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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