Welcome, guest | Sign In | My Account | Store | Cart

This recipe addresses the problems encountered when building robust tests for Tk menus. The software under test is a simple window with two menu items that each invoke a one button dialog box. All user visible text is imported from an external config.ini file. This scenario can lead to fragile test code because of the way TK's invoke(index) command has been implemented. (Tcl8.6.3/Tk8.6.3.)

Python, 112 lines
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
# module: acc.py

import codecs
import configparser
import os
from tkinter import *
from tkinter import messagebox


class AccountWindow(Tk):
    def __init__(self):
        super().__init__()
        self.config = configparser.ConfigParser()
        head, _ = os.path.split(__file__)
        path_ = os.path.normpath(os.path.join(head, 'config.ini'))
        with codecs.open(path_, 'r', 'utf8') as f:
            self.config.read_file(f)

        self.title(self.config.get('GUI Account', 'title'))
        self.option_add('*tearOff', False)
        self.name = 'account window'

        menubar = Menu(self, name='account menubar')
        self['menu'] = menubar
        menu_help = Menu(menubar, name='help menu')
        menu_help.add_command(
            label=self.config.get('GUI Account', 'help help'),
            command=self.help_)
        menu_help.add_command(
            label=self.config.get('GUI Account', 'help about'),
            command=self.about)
        menubar.add_cascade(
            menu=menu_help,
            label=self.config.get('GUI Account', 'menu help'))

    def help_(self):
        title = self.config.get('GUI Help', 'title')
        message = self.config.get('GUI Help', 'message')
        messagebox.showinfo(title, message, icon='question', parent=self)

    def about(self):
        title = self.config.get('GUI About', 'title')
        message = self.config.get('GUI About', 'message')
        messagebox.showinfo(title, message, parent=self)


def main():
    app = AccountWindow()
    app.mainloop()

if __name__ == '__main__':
    sys.exit(main())


######################################################################
# -*- coding: utf-8 -*-
# file: config.ini

[GUI Account]
title = Accounts
menu help = Help
help help = View Help
help about = About Accounts

[GUI About]
title = About
message = About text

[GUI Help]
title = Help
message = Help text


######################################################################
# -*- coding: utf-8 -*-
# test_acc.py

import unittest
import unittest.mock

import acc


class TestAccountWindow(unittest.TestCase):
    HELP_HELP = 'Help help'
    HELP_ABOUT = 'Help about'

    def setUp(self):
        self.config = ['',  # __init__() config calls, Window title.
                       self.HELP_HELP,  # Help menu item
                       self.HELP_ABOUT,  # About menu item
                       '']  # Help menu

    def test_menu_help_item_help(self):
        self.messagebox_helper(self.HELP_HELP, icon='question')

    def test_menu_help_item_about(self):
        self.messagebox_helper(self.HELP_ABOUT)

    @unittest.mock.patch('acc.configparser.ConfigParser.get', autospec=True)
    @unittest.mock.patch('acc.messagebox.showinfo', autospec=True)
    def messagebox_helper(
            self, menu_item, mock_messagebox, mock_get_value, **kwargs):
        self.config += ['title',  # app.messagebox config calls
                        'message']
        mock_get_value.side_effect = self.config
        self.app = acc.AccountWindow()
        self.app.children['account menubar'].children['help menu'].invoke(
            menu_item)
        mock_messagebox.assert_called_with(self.config[-2],
                                           self.config[-1],
                                           parent=self.app, **kwargs)

Please Note: This recipe was written while I was trying to grapple with the problems of applying TDD to tkinter. I no longer consider it good practice.

  • The approach ultimately failed because of unmockable side-effects that could not be cleared in the tearDown method. (tkinter predates TDD and was not originally designed with TDD in mind)
  • Test calls into tkinter were taking around 150 to 200 ms for each test unit.
  • Although the tests were useful in testing that tkinter's behavior match my understanding of tkinter they looked more like integration tests than unit tests. For my current approach see test_guidialogs.py and guidialogs.py.

dialog.py is based on Fredrik Lundh's dialogs.

Original Discussion

Unittests for Tk use Tk's invoke method. The menu bar and the menu are identified by their internal name but the menu item is identified by its index.

app.children['account menubar'].children['help menu'].invoke(1)

The problem with this code is that any change to the menu sequence will break the test. Tk does offer an alternative to the index which is to use the menu item's label. Now the line will look like this:

app.children['account menubar'].children['help menu'].invoke('About Accounts')

Unfortunately, this isn't much better. The 'About Accounts' label is read in from the configuration file. It was put there so the language seen by the user could be easily changed. When the French translator changes this to its French equivalent the test will break.

This recipe solves this problem by mocking the call to ConfigParser and supplying private test labels via mock's side_effect method.

mock_get_value.side_effect = self.config

The list supplied to side_effect is built in a manner which minimizes the chance of introducing errors when menu items are rearranged. The test fixture's setUp method builds the values that will be added by AccountWindow's .__init__ method. Each item in this list matches one call to ConfigParser's get method.

def setUp(self):
    self.config = ['',  # __init__() config calls, Window title.
                   self.HELP_HELP,  # Help menu item
                   self.HELP_ABOUT,  # About menu item
                   '']  # Help menu

The final values are added by each test via the messagebox_helper mehtod. The negative indexing of self.config in the messagebox_helper method isolates this code from any future changes to self.config in setUp.

    self.config += ['title',  # app.messagebox config calls
                    'message']
    ...
    mock_messagebox.assert_called_with(self.config[-2],
                                       self.config[-1],
                                       parent=app, **kwargs)

The Tk messagebox is mocked not only because testing Tk should not be part of this program's test suite but to prevent invoke from creating the message box and its parent window on the screen. The test would halt after creating the message box waiting for user interaction.

The helper method messagebox also demonstrates the correct sequencing of regular arguments, mock.patch decorator arguments, and keyword arguments.

@unittest.mock.patch('gui.acc.configparser.ConfigParser.get')
@unittest.mock.patch('gui.acc.messagebox.showinfo')
def messagebox_helper(
        self, menu_item, mock_messagebox, mock_get_value, **kwargs):

A note on the clunky looking messageboxes:

I believe the messagebox was provided as a convenience function with Tk and it has been used here for simplicity. Tk is rightly disliked for its clunky buttons which look out of place on contemporary desktops. Newer versions of Python include the ttk extension package which allows the programmer to easily create message boxes that automatically adopt the look and feel requirements of the target platform.