Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
allendowney
GitHub Repository: allendowney/cpython
Path: blob/main/Tools/unittestgui/unittestgui.py
12 views
1
#!/usr/bin/env python3
2
"""
3
GUI framework and application for use with Python unit testing framework.
4
Execute tests written using the framework provided by the 'unittest' module.
5
6
Updated for unittest test discovery by Mark Roddy and Python 3
7
support by Brian Curtin.
8
9
Based on the original by Steve Purcell, from:
10
11
http://pyunit.sourceforge.net/
12
13
Copyright (c) 1999, 2000, 2001 Steve Purcell
14
This module is free software, and you may redistribute it and/or modify
15
it under the same terms as Python itself, so long as this copyright message
16
and disclaimer are retained in their original form.
17
18
IN NO EVENT SHALL THE AUTHOR BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT,
19
SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OF
20
THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
21
DAMAGE.
22
23
THE AUTHOR SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT
24
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
25
PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS,
26
AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
27
SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
28
"""
29
30
__author__ = "Steve Purcell ([email protected])"
31
32
import sys
33
import traceback
34
import unittest
35
36
import tkinter as tk
37
from tkinter import messagebox
38
from tkinter import filedialog
39
from tkinter import simpledialog
40
41
42
43
44
##############################################################################
45
# GUI framework classes
46
##############################################################################
47
48
class 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
155
class 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
188
class 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
206
class 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
244
class 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
433
class 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
465
def 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
473
if __name__ == '__main__':
474
if len(sys.argv) == 2:
475
main(sys.argv[1])
476
else:
477
main()
478
479