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 = None
58        self.__rollbackImporter = RollbackImporter()
59        self.test_suite = None
60
61        #test discovery variables
62        self.directory_to_read = ''
63        self.top_level_dir = ''
64        self.test_file_glob_pattern = 'test*.py'
65
66        self.initGUI(*args, **kwargs)
67
68    def errorDialog(self, title, message):
69        "Override to display an error arising from GUI usage"
70        pass
71
72    def getDirectoryToDiscover(self):
73        "Override to prompt user for directory to perform test discovery"
74        pass
75
76    def runClicked(self):
77        "To be called in response to user choosing to run a test"
78        if self.running: return
79        if not self.test_suite:
80            self.errorDialog("Test Discovery", "You discover some tests first!")
81            return
82        self.currentResult = GUITestResult(self)
83        self.totalTests = self.test_suite.countTestCases()
84        self.running = 1
85        self.notifyRunning()
86        self.test_suite.run(self.currentResult)
87        self.running = 0
88        self.notifyStopped()
89
90    def stopClicked(self):
91        "To be called in response to user stopping the running of a test"
92        if self.currentResult:
93            self.currentResult.stop()
94
95    def discoverClicked(self):
96        self.__rollbackImporter.rollbackImports()
97        directory = self.getDirectoryToDiscover()
98        if not directory:
99            return
100        self.directory_to_read = directory
101        try:
102            # Explicitly use 'None' value if no top level directory is
103            # specified (indicated by empty string) as discover() explicitly
104            # checks for a 'None' to determine if no tld has been specified
105            top_level_dir = self.top_level_dir or None
106            tests = unittest.defaultTestLoader.discover(directory, self.test_file_glob_pattern, top_level_dir)
107            self.test_suite = tests
108        except:
109            exc_type, exc_value, exc_tb = sys.exc_info()
110            traceback.print_exception(*sys.exc_info())
111            self.errorDialog("Unable to run test '%s'" % directory,
112                             "Error loading specified test: %s, %s" % (exc_type, exc_value))
113            return
114        self.notifyTestsDiscovered(self.test_suite)
115
116    # Required callbacks
117
118    def notifyTestsDiscovered(self, test_suite):
119        "Override to display information about the suite of discovered tests"
120        pass
121
122    def notifyRunning(self):
123        "Override to set GUI in 'running' mode, enabling 'stop' button etc."
124        pass
125
126    def notifyStopped(self):
127        "Override to set GUI in 'stopped' mode, enabling 'run' button etc."
128        pass
129
130    def notifyTestFailed(self, test, err):
131        "Override to indicate that a test has just failed"
132        pass
133
134    def notifyTestErrored(self, test, err):
135        "Override to indicate that a test has just errored"
136        pass
137
138    def notifyTestSkipped(self, test, reason):
139        "Override to indicate that test was skipped"
140        pass
141
142    def notifyTestFailedExpectedly(self, test, err):
143        "Override to indicate that test has just failed expectedly"
144        pass
145
146    def notifyTestStarted(self, test):
147        "Override to indicate that a test is about to run"
148        pass
149
150    def notifyTestFinished(self, test):
151        """Override to indicate that a test has finished (it may already have
152           failed or errored)"""
153        pass
154
155
156class GUITestResult(unittest.TestResult):
157    """A TestResult that makes callbacks to its associated GUI TestRunner.
158    Used by BaseGUITestRunner. Need not be created directly.
159    """
160    def __init__(self, callback):
161        unittest.TestResult.__init__(self)
162        self.callback = callback
163
164    def addError(self, test, err):
165        unittest.TestResult.addError(self, test, err)
166        self.callback.notifyTestErrored(test, err)
167
168    def addFailure(self, test, err):
169        unittest.TestResult.addFailure(self, test, err)
170        self.callback.notifyTestFailed(test, err)
171
172    def addSkip(self, test, reason):
173        super(GUITestResult,self).addSkip(test, reason)
174        self.callback.notifyTestSkipped(test, reason)
175
176    def addExpectedFailure(self, test, err):
177        super(GUITestResult,self).addExpectedFailure(test, err)
178        self.callback.notifyTestFailedExpectedly(test, err)
179
180    def stopTest(self, test):
181        unittest.TestResult.stopTest(self, test)
182        self.callback.notifyTestFinished(test)
183
184    def startTest(self, test):
185        unittest.TestResult.startTest(self, test)
186        self.callback.notifyTestStarted(test)
187
188
189class RollbackImporter:
190    """This tricky little class is used to make sure that modules under test
191    will be reloaded the next time they are imported.
192    """
193    def __init__(self):
194        self.previousModules = sys.modules.copy()
195
196    def rollbackImports(self):
197        for modname in sys.modules.copy().keys():
198            if not modname in self.previousModules:
199                # Force reload when modname next imported
200                del(sys.modules[modname])
201
202
203##############################################################################
204# Tkinter GUI
205##############################################################################
206
207class DiscoverSettingsDialog(simpledialog.Dialog):
208    """
209    Dialog box for prompting test discovery settings
210    """
211
212    def __init__(self, master, top_level_dir, test_file_glob_pattern, *args, **kwargs):
213        self.top_level_dir = top_level_dir
214        self.dirVar = tk.StringVar()
215        self.dirVar.set(top_level_dir)
216
217        self.test_file_glob_pattern = test_file_glob_pattern
218        self.testPatternVar = tk.StringVar()
219        self.testPatternVar.set(test_file_glob_pattern)
220
221        simpledialog.Dialog.__init__(self, master, title="Discover Settings",
222                                     *args, **kwargs)
223
224    def body(self, master):
225        tk.Label(master, text="Top Level Directory").grid(row=0)
226        self.e1 = tk.Entry(master, textvariable=self.dirVar)
227        self.e1.grid(row = 0, column=1)
228        tk.Button(master, text="...",
229                  command=lambda: self.selectDirClicked(master)).grid(row=0,column=3)
230
231        tk.Label(master, text="Test File Pattern").grid(row=1)
232        self.e2 = tk.Entry(master, textvariable = self.testPatternVar)
233        self.e2.grid(row = 1, column=1)
234        return None
235
236    def selectDirClicked(self, master):
237        dir_path = filedialog.askdirectory(parent=master)
238        if dir_path:
239            self.dirVar.set(dir_path)
240
241    def apply(self):
242        self.top_level_dir = self.dirVar.get()
243        self.test_file_glob_pattern = self.testPatternVar.get()
244
245class TkTestRunner(BaseGUITestRunner):
246    """An implementation of BaseGUITestRunner using Tkinter.
247    """
248    def initGUI(self, root, initialTestName):
249        """Set up the GUI inside the given root window. The test name entry
250        field will be pre-filled with the given initialTestName.
251        """
252        self.root = root
253
254        self.statusVar = tk.StringVar()
255        self.statusVar.set("Idle")
256
257        #tk vars for tracking counts of test result types
258        self.runCountVar = tk.IntVar()
259        self.failCountVar = tk.IntVar()
260        self.errorCountVar = tk.IntVar()
261        self.skipCountVar = tk.IntVar()
262        self.expectFailCountVar = tk.IntVar()
263        self.remainingCountVar = tk.IntVar()
264
265        self.top = tk.Frame()
266        self.top.pack(fill=tk.BOTH, expand=1)
267        self.createWidgets()
268
269    def getDirectoryToDiscover(self):
270        return filedialog.askdirectory()
271
272    def settingsClicked(self):
273        d = DiscoverSettingsDialog(self.top, self.top_level_dir, self.test_file_glob_pattern)
274        self.top_level_dir = d.top_level_dir
275        self.test_file_glob_pattern = d.test_file_glob_pattern
276
277    def notifyTestsDiscovered(self, test_suite):
278        discovered = test_suite.countTestCases()
279        self.runCountVar.set(0)
280        self.failCountVar.set(0)
281        self.errorCountVar.set(0)
282        self.remainingCountVar.set(discovered)
283        self.progressBar.setProgressFraction(0.0)
284        self.errorListbox.delete(0, tk.END)
285        self.statusVar.set("Discovering tests from %s. Found: %s" %
286            (self.directory_to_read, discovered))
287        self.stopGoButton['state'] = tk.NORMAL
288
289    def createWidgets(self):
290        """Creates and packs the various widgets.
291
292        Why is it that GUI code always ends up looking a mess, despite all the
293        best intentions to keep it tidy? Answers on a postcard, please.
294        """
295        # Status bar
296        statusFrame = tk.Frame(self.top, relief=tk.SUNKEN, borderwidth=2)
297        statusFrame.pack(anchor=tk.SW, fill=tk.X, side=tk.BOTTOM)
298        tk.Label(statusFrame, width=1, textvariable=self.statusVar).pack(side=tk.TOP, fill=tk.X)
299
300        # Area to enter name of test to run
301        leftFrame = tk.Frame(self.top, borderwidth=3)
302        leftFrame.pack(fill=tk.BOTH, side=tk.LEFT, anchor=tk.NW, expand=1)
303        suiteNameFrame = tk.Frame(leftFrame, borderwidth=3)
304        suiteNameFrame.pack(fill=tk.X)
305
306        # Progress bar
307        progressFrame = tk.Frame(leftFrame, relief=tk.GROOVE, borderwidth=2)
308        progressFrame.pack(fill=tk.X, expand=0, anchor=tk.NW)
309        tk.Label(progressFrame, text="Progress:").pack(anchor=tk.W)
310        self.progressBar = ProgressBar(progressFrame, relief=tk.SUNKEN,
311                                       borderwidth=2)
312        self.progressBar.pack(fill=tk.X, expand=1)
313
314
315        # Area with buttons to start/stop tests and quit
316        buttonFrame = tk.Frame(self.top, borderwidth=3)
317        buttonFrame.pack(side=tk.LEFT, anchor=tk.NW, fill=tk.Y)
318
319        tk.Button(buttonFrame, text="Discover Tests",
320                  command=self.discoverClicked).pack(fill=tk.X)
321
322
323        self.stopGoButton = tk.Button(buttonFrame, text="Start",
324                                      command=self.runClicked, state=tk.DISABLED)
325        self.stopGoButton.pack(fill=tk.X)
326
327        tk.Button(buttonFrame, text="Close",
328                  command=self.top.quit).pack(side=tk.BOTTOM, fill=tk.X)
329        tk.Button(buttonFrame, text="Settings",
330                  command=self.settingsClicked).pack(side=tk.BOTTOM, fill=tk.X)
331
332        # Area with labels reporting results
333        for label, var in (('Run:', self.runCountVar),
334                           ('Failures:', self.failCountVar),
335                           ('Errors:', self.errorCountVar),
336                           ('Skipped:', self.skipCountVar),
337                           ('Expected Failures:', self.expectFailCountVar),
338                           ('Remaining:', self.remainingCountVar),
339                           ):
340            tk.Label(progressFrame, text=label).pack(side=tk.LEFT)
341            tk.Label(progressFrame, textvariable=var,
342                     foreground="blue").pack(side=tk.LEFT, fill=tk.X,
343                                             expand=1, anchor=tk.W)
344
345        # List box showing errors and failures
346        tk.Label(leftFrame, text="Failures and errors:").pack(anchor=tk.W)
347        listFrame = tk.Frame(leftFrame, relief=tk.SUNKEN, borderwidth=2)
348        listFrame.pack(fill=tk.BOTH, anchor=tk.NW, expand=1)
349        self.errorListbox = tk.Listbox(listFrame, foreground='red',
350                                       selectmode=tk.SINGLE,
351                                       selectborderwidth=0)
352        self.errorListbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=1,
353                               anchor=tk.NW)
354        listScroll = tk.Scrollbar(listFrame, command=self.errorListbox.yview)
355        listScroll.pack(side=tk.LEFT, fill=tk.Y, anchor=tk.N)
356        self.errorListbox.bind("<Double-1>",
357                               lambda e, self=self: self.showSelectedError())
358        self.errorListbox.configure(yscrollcommand=listScroll.set)
359
360    def errorDialog(self, title, message):
361        messagebox.showerror(parent=self.root, title=title,
362                             message=message)
363
364    def notifyRunning(self):
365        self.runCountVar.set(0)
366        self.failCountVar.set(0)
367        self.errorCountVar.set(0)
368        self.remainingCountVar.set(self.totalTests)
369        self.errorInfo = []
370        while self.errorListbox.size():
371            self.errorListbox.delete(0)
372        #Stopping seems not to work, so simply disable the start button
373        #self.stopGoButton.config(command=self.stopClicked, text="Stop")
374        self.stopGoButton.config(state=tk.DISABLED)
375        self.progressBar.setProgressFraction(0.0)
376        self.top.update_idletasks()
377
378    def notifyStopped(self):
379        self.stopGoButton.config(state=tk.DISABLED)
380        #self.stopGoButton.config(command=self.runClicked, text="Start")
381        self.statusVar.set("Idle")
382
383    def notifyTestStarted(self, test):
384        self.statusVar.set(str(test))
385        self.top.update_idletasks()
386
387    def notifyTestFailed(self, test, err):
388        self.failCountVar.set(1 + self.failCountVar.get())
389        self.errorListbox.insert(tk.END, "Failure: %s" % test)
390        self.errorInfo.append((test,err))
391
392    def notifyTestErrored(self, test, err):
393        self.errorCountVar.set(1 + self.errorCountVar.get())
394        self.errorListbox.insert(tk.END, "Error: %s" % test)
395        self.errorInfo.append((test,err))
396
397    def notifyTestSkipped(self, test, reason):
398        super(TkTestRunner, self).notifyTestSkipped(test, reason)
399        self.skipCountVar.set(1 + self.skipCountVar.get())
400
401    def notifyTestFailedExpectedly(self, test, err):
402        super(TkTestRunner, self).notifyTestFailedExpectedly(test, err)
403        self.expectFailCountVar.set(1 + self.expectFailCountVar.get())
404
405
406    def notifyTestFinished(self, test):
407        self.remainingCountVar.set(self.remainingCountVar.get() - 1)
408        self.runCountVar.set(1 + self.runCountVar.get())
409        fractionDone = float(self.runCountVar.get())/float(self.totalTests)
410        fillColor = len(self.errorInfo) and "red" or "green"
411        self.progressBar.setProgressFraction(fractionDone, fillColor)
412
413    def showSelectedError(self):
414        selection = self.errorListbox.curselection()
415        if not selection: return
416        selected = int(selection[0])
417        txt = self.errorListbox.get(selected)
418        window = tk.Toplevel(self.root)
419        window.title(txt)
420        window.protocol('WM_DELETE_WINDOW', window.quit)
421        test, error = self.errorInfo[selected]
422        tk.Label(window, text=str(test),
423                 foreground="red", justify=tk.LEFT).pack(anchor=tk.W)
424        tracebackLines =  traceback.format_exception(*error)
425        tracebackText = "".join(tracebackLines)
426        tk.Label(window, text=tracebackText, justify=tk.LEFT).pack()
427        tk.Button(window, text="Close",
428                  command=window.quit).pack(side=tk.BOTTOM)
429        window.bind('<Key-Return>', lambda e, w=window: w.quit())
430        window.mainloop()
431        window.destroy()
432
433
434class ProgressBar(tk.Frame):
435    """A simple progress bar that shows a percentage progress in
436    the given colour."""
437
438    def __init__(self, *args, **kwargs):
439        tk.Frame.__init__(self, *args, **kwargs)
440        self.canvas = tk.Canvas(self, height='20', width='60',
441                                background='white', borderwidth=3)
442        self.canvas.pack(fill=tk.X, expand=1)
443        self.rect = self.text = None
444        self.canvas.bind('<Configure>', self.paint)
445        self.setProgressFraction(0.0)
446
447    def setProgressFraction(self, fraction, color='blue'):
448        self.fraction = fraction
449        self.color = color
450        self.paint()
451        self.canvas.update_idletasks()
452
453    def paint(self, *args):
454        totalWidth = self.canvas.winfo_width()
455        width = int(self.fraction * float(totalWidth))
456        height = self.canvas.winfo_height()
457        if self.rect is not None: self.canvas.delete(self.rect)
458        if self.text is not None: self.canvas.delete(self.text)
459        self.rect = self.canvas.create_rectangle(0, 0, width, height,
460                                                 fill=self.color)
461        percentString = "%3.0f%%" % (100.0 * self.fraction)
462        self.text = self.canvas.create_text(totalWidth/2, height/2,
463                                            anchor=tk.CENTER,
464                                            text=percentString)
465
466def main(initialTestName=""):
467    root = tk.Tk()
468    root.title("PyUnit")
469    runner = TkTestRunner(root, initialTestName)
470    root.protocol('WM_DELETE_WINDOW', root.quit)
471    root.mainloop()
472
473
474if __name__ == '__main__':
475    if len(sys.argv) == 2:
476        main(sys.argv[1])
477    else:
478        main()
479