【问题标题】:How do I run unittest on a Tkinter app?如何在 Tkinter 应用程序上运行 unittest?
【发布时间】:2011-05-04 06:49:27
【问题描述】:

我刚刚开始了解TDD,我正在使用 Tkinter GUI 开发一个程序。唯一的问题是,一旦调用了.mainloop() 方法,测试套件就会挂起,直到窗口关闭。

这是我的代码示例:

# server.py
import Tkinter as tk

class Server(tk.Tk):
    def __init__(self):
        tk.Tk.__init__(self)
        self.mainloop()

# test.py
import unittest
import server

class ServerTestCase(unittest.TestCase):
    def testClassSetup(self):
       server.Server()
       # and of course I can't call any server.whatever functions here

if __name__ == '__main__':
    unittest.main()

测试 Tkinter 应用程序的适当方法是什么?还是只是“不”?

【问题讨论】:

    标签: python unit-testing tdd tkinter


    【解决方案1】:

    此答案适用于 Python 3.7 及更高版本(任何版本都有异步方法)

    在您的 main.py 或您启动主 UI 的任何位置:

    def start_application() -> Application:
        root = tk.Tk()
        app = Application(master=root)
        app.load_settings()
        return app # will return the application without starting the main loop.
    
    if __name__=='__main__':
        start_application().mainloop()
    

    在你的tests.py:

    from myapp.main import start_application
    
    class TestGui(unittest.TestCase):
        
        # this will run on a separate thread.
        async def _start_app(self):
            self.app.mainloop()
        
        def setUp(self):
            self.app = start_application()
            self._start_app()
        
        def tearDown(self):
            self.app.destroy()
        
        def test_startup(self):
            title = self.app.winfo_toplevel().title()
            expected = 'The Application My Boss Wants Me To Make'
            self.assertEqual(title, expected)
    

    这不会显示任何内容,但它会通过。此外,预计会出现警告 显示我们没有等待_start_application。在这种情况下可以忽略这一点。 (如果您想成为多线程的忠实拥护者,那么您将不得不自己进行线程管理......恕我直言,单元测试的工作量太大了)。

    【讨论】:

    • start_application中的app不是Tkinter对象,所以Application没有mainloop属性。
    • start_application 中,应该返回root。在单元测试中一切都会按预期工作。
    【解决方案2】:

    底线:在导致 UI 事件的操作之后、需要该事件效果的后续操作之前使用以下代码抽取事件。


    IPython 提供了一个优雅的无线程解决方案,它的gui tk 魔术命令实现位于terminal/pt_inputhooks/tk.py

    它不是root.mainloop(),而是在循环中运行root.dooneevent(),每次迭代检查退出条件(交互输入到达)。这样,当 IPython 忙于处理命令时,偶数循环就不会运行。

    对于测试,无需等待外部事件,而且测试总是“忙碌”的,因此必须在“适当的时刻”手动(或半自动)运行循环。它们是什么?

    测试表明,如果没有事件循环,可以直接更改小部件(使用<widget>.tk.call() 和任何包装它的东西),但事件处理程序永远不会触发。因此,只要事件发生并且我们需要它的效果,就需要运行循环——即在任何更改某些内容的操作之后,在需要更改结果的操作之前。

    源自上述 IPython 过程的代码将是:

    def pump_events(root):
        while root.dooneevent(_tkinter.ALL_EVENTS|_tkinter.DONT_WAIT):
            pass
    

    这将处理(执行处理程序)所有待处理的事件,以及直接由此产生的所有事件。

    tkinter.Tk.dooneevent() 代表Tcl_DoOneEvent()。)


    作为旁注,改用这个:

    root.update()
    root.update_idletasks()
    

    不一定会这样做,因为这两个函数都不会处理所有种事件。由于每个处理程序都可能生成其他任意事件,因此我无法确定我已经处理了所有内容。


    这是一个测试用于编辑字符串值的简单弹出对话框的示例:

    class TKinterTestCase(unittest.TestCase):
        """These methods are going to be the same for every GUI test,
        so refactored them into a separate class
        """
        def setUp(self):
            self.root=tkinter.Tk()
            self.pump_events()
    
        def tearDown(self):
            if self.root:
                self.root.destroy()
                self.pump_events()
    
        def pump_events(self):
            while self.root.dooneevent(_tkinter.ALL_EVENTS | _tkinter.DONT_WAIT):
                pass
    
    class TestViewAskText(TKinterTestCase):
        def test_enter(self):
            v = View_AskText(self.root,value=u"йцу")
            self.pump_events()
            v.e.focus_set()
            v.e.insert(tkinter.END,u'кен')
            v.e.event_generate('<Return>')
            self.pump_events()
    
            self.assertRaises(tkinter.TclError, lambda: v.top.winfo_viewable())
            self.assertEqual(v.value,u'йцукен')
    
    
    # ###########################################################
    # The class being tested (normally, it's in a separate module
    # and imported at the start of the test's file)
    # ###########################################################
    
    class View_AskText(object):
        def __init__(self, master, value=u""):
            self.value=None
    
            top = self.top = tkinter.Toplevel(master)
            top.grab_set()
            self.l = ttk.Label(top, text=u"Value:")
            self.l.pack()
            self.e = ttk.Entry(top)
            self.e.pack()
            self.b = ttk.Button(top, text='Ok', command=self.save)
            self.b.pack()
    
            if value: self.e.insert(0,value)
            self.e.focus_set()
            top.bind('<Return>', self.save)
    
        def save(self, *_):
            self.value = self.e.get()
            self.top.destroy()
    
    
    if __name__ == '__main__':
        import unittest
        unittest.main()
    

    【讨论】:

    • 你从哪里导入View_AskText()?你能包括你的导入语句吗?
    • 这就像一个魅力。我建议将.pump_events() 添加到setUp()。否则,如果您使用tkinter.ttk,您可能会收到警告。
    • @StevenVascellaro View_AskText 与库存模块一起在文件开头导入。在导入时没有执行任何需要测试的代码(或者更确切地说,任何这样的逻辑都是实现细节,而不是公共接口的一部分),所以我在哪里导入它并不重要。如果我需要测试它,我可以检查最终结果或运行子进程。
    • 如果用户尝试运行示例,Unresolved reference 'View_AskText()' 将引发错误
    • @StevenVascellaro View_AskText 是正在测试的类。我没有包括它,因为它无关紧要。
    【解决方案3】:

    您可以做的一件事是在一个单独的线程中生成主循环并使用您的主线程来运行实际测试;照原样观看主循环线程。确保在进行断言之前检查 Tk 窗口的状态。

    多线程任何代码都很难。您可能希望将 Tk 程序分解为可测试的部分,而不是一次对整个事物进行单元测试(这实际上不是单元测试)。

    我最后建议至少在控制级别进行测试,如果您的程序没有降低的话,它将极大地帮助您。

    【讨论】:

    • 在考虑了几天(并在其他项目上工作)之后,这更有意义 - 拥有一个与 GUI 接口的处理类应该更容易测试,也许更容易也写。
    • Tk 事件循环需要在主线程中运行。所以使用生成的线程进行测试。
    • 不起作用。如果 mainloop() 正在另一个线程中运行,则小部件的任何 self.tk.call() 都会冻结。
    • @ankostis 所有 tkinter 调用(包括事件循环)都应该在您创建 Tk() 窗口的同一线程中运行。
    【解决方案4】:

    有一种称为猴子补丁的技术,您可以在运行时更改代码。

    您可以给 TK 类打猴子补丁,这样 mainloop 就不会真正启动程序。

    在你的 test.py 中有这样的东西(未经测试!):

    import tk
    class FakeTk(object):
        def mainloop(self):
            pass
    
    tk.__dict__['Tk'] = FakeTk
    import server
    
    def test_server():
        s = server.Server()
        server.mainloop() # shouldn't endless loop on you now...
    

    mock 这样的模拟框架让这变得不那么痛苦。

    【讨论】:

    • 这是最好的路线。我最终开始怀疑 Tk 在单元测试之间没有完全关闭。 (我使用标准库中的 unittest 模块。)如果该类是一个复杂的 tkinter 小部件,它确实会变得更复杂一些。在 TDD 中,您必须实例化对象以进行测试,同时猴子修补任何启动 Tk 的尝试。这甚至会影响__str____repr__ 的实现。
    • 如果不涉及 Tk 逻辑,您无法确定您的操作会对 UI 产生预期的影响。
    • 同意,但这不是单元测试。我会以另一种方式进行测试,也许使用自动化 UI 的脚本。
    • 测试表明,如果没有 mainloop() 运行,我可以直接更改小部件(使用 tk.calls 和任何包装它们的东西),但事件处理程序永远不会触发。因此,UI 以这种方式被破坏,因此对其进行测试毫无意义。
    • 你能提供一个例子来说明这将如何与mock一起工作吗?
    猜你喜欢
    • 1970-01-01
    • 2012-03-21
    • 1970-01-01
    • 2017-02-23
    • 2023-03-23
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多