[pygobject] tests: add a pytest hook for handling unhandled exception in closures



commit e00e38f9c44568f7fab643a069f86c576011ddcc
Author: Christoph Reiter <reiter christoph gmail com>
Date:   Fri Feb 16 08:49:38 2018 +0100

    tests: add a pytest hook for handling unhandled exception in closures
    
    In PyGObject when an exception is raised in a closure called from C then
    the error gets passed to sys.excepthook (on the main thread at least)
    and the error is by default printed to stdout. Since pytest by default
    hides stdout, errors can be easily missed now.
    
    To make these errors more visible add a test wrapper which checks
    sys.excepthook for unhandled exceptions and reraises them.
    This makes the tests fail and as a bonus also shows the right
    stack trace instead of just the error message.

 tests/Makefile.am      |  1 +
 tests/compathelper.py  |  5 +++++
 tests/conftest.py      | 31 +++++++++++++++++++++++++++++++
 tests/test_glib.py     | 13 +++++++++++++
 tests/test_mainloop.py | 28 ++++++++--------------------
 tests/test_option.py   | 18 +++++++-----------
 6 files changed, 65 insertions(+), 31 deletions(-)
---
diff --git a/tests/Makefile.am b/tests/Makefile.am
index 216aca5c..f04610ab 100644
--- a/tests/Makefile.am
+++ b/tests/Makefile.am
@@ -148,6 +148,7 @@ EXTRA_DIST = \
        test_resulttuple.py \
        test_unknown.py \
        test_ossig.py \
+       conftest.py \
        __init__.py \
        gi/__init__.py \
        gi/overrides/__init__.py \
diff --git a/tests/compathelper.py b/tests/compathelper.py
index d4f4d27c..4ed38944 100644
--- a/tests/compathelper.py
+++ b/tests/compathelper.py
@@ -33,9 +33,14 @@ if sys.version_info >= (3, 0):
     from io import StringIO
     StringIO
     PY3 = True
+
+    def reraise(tp, value, tb):
+        raise tp(value).with_traceback(tb)
 else:
     _long = long
     _basestring = basestring
     from StringIO import StringIO
     StringIO
     PY2 = True
+
+    exec("def reraise(tp, value, tb):\n raise tp, value, tb")
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..f69405d4
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+
+import sys
+
+import pytest
+
+from .compathelper import reraise
+
+
+@pytest.hookimpl(hookwrapper=True)
+def pytest_runtest_call(item):
+    """A pytest hook which takes over sys.excepthook and raises any uncaught
+    exception (with PyGObject this happesn often when we get called from C,
+    like any signal handler, vfuncs tc)
+    """
+
+    assert sys.excepthook is sys.__excepthook__
+
+    exceptions = []
+
+    def on_hook(type_, value, tback):
+        exceptions.append((type_, value, tback))
+
+    sys.excepthook = on_hook
+    try:
+        yield
+    finally:
+        assert sys.excepthook in (on_hook, sys.__excepthook__)
+        sys.excepthook = sys.__excepthook__
+        if exceptions:
+            reraise(*exceptions[0])
diff --git a/tests/test_glib.py b/tests/test_glib.py
index 7a782e9c..8f481947 100644
--- a/tests/test_glib.py
+++ b/tests/test_glib.py
@@ -10,12 +10,25 @@ import os.path
 import warnings
 import subprocess
 
+import pytest
 from gi.repository import GLib
 from gi import PyGIDeprecationWarning
 
 
 class TestGLib(unittest.TestCase):
 
+    @pytest.mark.xfail(strict=True)
+    def test_pytest_capture_error_in_closure(self):
+        # this test is supposed to fail
+        ml = GLib.MainLoop()
+
+        def callback():
+            ml.quit()
+            raise Exception("expected")
+
+        GLib.idle_add(callback)
+        ml.run()
+
     @unittest.skipIf(os.name == "nt", "no bash on Windows")
     def test_find_program_in_path(self):
         bash_path = GLib.find_program_in_path('bash')
diff --git a/tests/test_mainloop.py b/tests/test_mainloop.py
index 1c1b1227..2d9fbd57 100644
--- a/tests/test_mainloop.py
+++ b/tests/test_mainloop.py
@@ -3,13 +3,14 @@
 from __future__ import absolute_import
 
 import os
-import sys
 import select
 import signal
 import unittest
 
 from gi.repository import GLib
 
+from .helper import capture_exceptions
+
 
 class TestMainLoop(unittest.TestCase):
 
@@ -35,25 +36,12 @@ class TestMainLoop(unittest.TestCase):
         os.write(pipe_w, b"Y")
         os.close(pipe_w)
 
-        def excepthook(type, value, traceback):
-            self.assertTrue(type is Exception)
-            self.assertEqual(value.args[0], "deadbabe")
-        sys.excepthook = excepthook
-        try:
-            got_exception = False
-            try:
-                loop.run()
-            except:
-                got_exception = True
-        finally:
-            sys.excepthook = sys.__excepthook__
-
-        #
-        # The exception should be handled (by printing it)
-        # immediately on return from child_died() rather
-        # than here. See bug #303573
-        #
-        self.assertFalse(got_exception)
+        with capture_exceptions() as exc:
+            loop.run()
+
+        assert len(exc) == 1
+        assert exc[0].type is Exception
+        assert exc[0].value.args[0] == "deadbabe"
 
     @unittest.skipUnless(hasattr(os, "fork"), "no os.fork available")
     def test_sigint(self):
diff --git a/tests/test_option.py b/tests/test_option.py
index 33a12882..2854508b 100644
--- a/tests/test_option.py
+++ b/tests/test_option.py
@@ -3,7 +3,6 @@
 from __future__ import absolute_import
 
 import unittest
-import sys
 
 # py3k has StringIO in a different module
 try:
@@ -14,9 +13,10 @@ except ImportError:
 
 from gi.repository import GLib
 
+from .helper import capture_exceptions
+
 
 class TestOption(unittest.TestCase):
-    EXCEPTION_MESSAGE = "This callback fails"
 
     def setUp(self):
         self.parser = GLib.option.OptionParser("NAMES...",
@@ -30,7 +30,7 @@ class TestOption(unittest.TestCase):
 
     def _create_group(self):
         def option_callback(option, opt, value, parser):
-            raise Exception(self.EXCEPTION_MESSAGE)
+            raise Exception("foo")
 
         group = GLib.option.OptionGroup(
             "unittest", "Unit test options", "Show all unittest options",
@@ -104,14 +104,10 @@ class TestOption(unittest.TestCase):
 
     def test_standard_error(self):
         self._create_group()
-        sio = StringIO()
-        old_stderr = sys.stderr
-        sys.stderr = sio
-        try:
+
+        with capture_exceptions() as exc:
             self.parser.parse_args(
                 ["test_option.py", "--callback-failure-test"])
-        finally:
-            sys.stderr = old_stderr
 
-        assert (sio.getvalue().split('\n')[-2] ==
-                "Exception: " + self.EXCEPTION_MESSAGE)
+        assert len(exc) == 1
+        assert exc[0].value.args[0] == "foo"


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]