Qix-/better-exceptions

How to use better-exceptions with unittest?

Closed this issue · 16 comments

Issuehunt badges

I want to use better-exceptions to show error when using unittest. Here is my example:

test.py:

import better_exceptions
import unittest

better_exceptions.hook()


class MyTestCase(unittest.TestCase):
    def add(self, a, b):
        better_exceptions.hook()

        return a + b

    def test_add(self,):
        better_exceptions.hook()

        r1 = self.add(1, 2)
        r2 = self.add(2, "1")
        self.assertTrue(r1, r2)


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

shell:

$ export BETTER_EXCEPTIONS=1
$ echo $BETTER_EXCEPTIONS
1
$ python3 test.py
E
======================================================================
ERROR: test_add (__main__.MyTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test.py", line 17, in test_add
    r2 = self.add(2, "1")
  File "test.py", line 11, in add
    return a + b
TypeError: unsupported operand type(s) for +: 'int' and 'str'

----------------------------------------------------------------------
Ran 1 test in 0.004s

FAILED (errors=1)

Is there any way to use better-exceptions to print exception stack?


IssueHunt Summary

ocavue ocavue has been rewarded.

Backers (Total: $40.00)

Submitted pull Requests


Tips


IssueHunt has been backed by the following sponsors. Become a sponsor

Hi @ocavue.

The .hook() call is of no use here, because the unittest module formats exceptions using traceback as you can see here: https://github.com/python/cpython/blob/master/Lib/unittest/result.py#L185

I don't know if this is possible to change this behavior.

Found a simple but not perfect solution:

import better_exceptions
import unittest
import sys


def wrap_test(func):
    def new_func(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except Exception as e:
            exc, value, tb = sys.exc_info()
            print(better_exceptions.format_exception(exc, value, tb))
            raise e

    return new_func


class MyTestCase(unittest.TestCase):
    def add(self, a, b):
        return a + b

    @wrap_test
    def test_add(self,):
        r1 = self.add(1, 2)
        r2 = self.add(2, "1")
        self.assertTrue(r1, r2)


if __name__ == "__main__":
    unittest.main()
$ python3 test2.py
Traceback (most recent call last):
  File "test2.py", line 9, in new_func
    return func(*args, **kwargs)
           │     │       └ {}
           │     └ (<__main__.MyTestCase testMethod=test_add>,)
           └ <function MyTestCase.test_add at 0x7fceeb6febf8>
  File "test2.py", line 25, in test_add
    r2 = self.add(2, '1')
         └ <__main__.MyTestCase testMethod=test_add>
  File "test2.py", line 20, in add
    return a + b
           │   └ '1'
           └ 2
TypeError: unsupported operand type(s) for +: 'int' and 'str'

E
======================================================================
ERROR: test_add (__main__.MyTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test2.py", line 13, in new_func
    raise e
  File "test2.py", line 9, in new_func
    return func(*args, **kwargs)
  File "test2.py", line 25, in test_add
    r2 = self.add(2, "1")
  File "test2.py", line 20, in add
    return a + b
TypeError: unsupported operand type(s) for +: 'int' and 'str'

----------------------------------------------------------------------
Ran 1 test in 0.014s

FAILED (errors=1)
Qix- commented

@Delgan how good/bad would it be to provide an option to .hook() to override the built-in TracebackException class?

@Qix- Honestly, I don't know, I don't have a strong opinion about this.

The sys.excepthook is intended to be replaced, this only impacts the stderr output of an application which going to crash anyway. So, there is not much risk to monkeypatch it.
Overriding a whole built-in class like TracebackException is more intrusive. However, better_exceptions also provides a patch for logging and I don't see how patching TracebackException would cause troubles. So, as it seems to be more convenient for better_exceptions end users, this would probably be considered as an improvement with unnoticeable side-effects.

TracebackException accepts others argument not implemented by better_exceptions (limit, lookup_lines, capture_locals). Overriding the class would render them no-op, but if the patch is done using an explicit option in hook(), this should not surprise user.

Also, some users may require the same improvement for unit tests run using pytest, but I don't think we are able to patch exception formatting in this case.

@ocavue In the meantime, you can also try this solution:

import unittest
import better_exceptions

def patch(self, err, test):
    return better_exceptions.format_exception(*err)

unittest.result.TestResult._exc_info_to_string = patch
Qix- commented

^ That seems like the better approach in terms of hooking, even if it's using an undocumented method. If that works @ocavue an you let us know?

Although it uses an undocumented method, it seems to work well on all python versions. So I think this solution is great and we should write it in README.

( I used docker-compose to test it )


v3.6_1  | E
v3.6_1  | ======================================================================
v3.6_1  | ERROR: test_add (__main__.MyTestCase)
v3.6_1  | ----------------------------------------------------------------------
v3.6_1  | Traceback (most recent call last):
v3.6_1  |   File "/usr/local/lib/python3.6/unittest/case.py", line 59, in testPartExecutor
v3.6_1  |     yield
v3.6_1  |   File "/usr/local/lib/python3.6/unittest/case.py", line 605, in run
v3.6_1  |     testMethod()
v3.6_1  |     └ <bound method MyTestCase.test_add of <__main__.MyTestCase testMethod=test_add>>
v3.6_1  |   File "/workdir/test.py", line 11, in test_add
v3.6_1  |     r2 = self.add(2, "1")
v3.6_1  |          └ <__main__.MyTestCase testMethod=test_add>
v3.6_1  |   File "/workdir/test.py", line 7, in add
v3.6_1  |     return a + b
v3.6_1  |            │   └ '1'
v3.6_1  |            └ 2
v3.6_1  | TypeError: unsupported operand type(s) for +: 'int' and 'str'
v3.6_1  |
v3.6_1  | ----------------------------------------------------------------------
v3.6_1  | Ran 1 test in 0.014s
v3.6_1  |
v3.6_1  | FAILED (errors=1)
test_better_exception_v3.6_1 exited with code 1
v3.4_1  | E
v3.4_1  | ======================================================================
v3.4_1  | ERROR: test_add (__main__.MyTestCase)
v3.4_1  | ----------------------------------------------------------------------
v3.4_1  | Traceback (most recent call last):
v3.4_1  |   File "/usr/local/lib/python3.4/unittest/case.py", line 58, in testPartExecutor
v3.4_1  |     yield
v3.4_1  |   File "/usr/local/lib/python3.4/unittest/case.py", line 580, in run
v3.4_1  |     testMethod()
v3.4_1  |     └ <bound method MyTestCase.test_add of <__main__.MyTestCase testMethod=test_add>>
v3.4_1  |   File "/workdir/test.py", line 11, in test_add
v3.4_1  |     r2 = self.add(2, "1")
v3.4_1  |          └ <__main__.MyTestCase testMethod=test_add>
v3.4_1  |   File "/workdir/test.py", line 7, in add
v3.4_1  |     return a + b
v3.4_1  |            │   └ '1'
v3.4_1  |            └ 2
v3.4_1  | TypeError: unsupported operand type(s) for +: 'int' and 'str'
v3.4_1  |
v3.4_1  | ----------------------------------------------------------------------
v3.4_1  | Ran 1 test in 0.015s
v3.4_1  |
v3.4_1  | FAILED (errors=1)
v3.7_1  | E
v3.7_1  | ======================================================================
v3.7_1  | ERROR: test_add (__main__.MyTestCase)
v3.7_1  | ----------------------------------------------------------------------
v3.7_1  | Traceback (most recent call last):
v3.7_1  |   File "/usr/local/lib/python3.7/unittest/case.py", line 59, in testPartExecutor
v3.7_1  |     yield
v3.7_1  |   File "/usr/local/lib/python3.7/unittest/case.py", line 615, in run
v3.7_1  |     testMethod()
v3.7_1  |     └ <bound method MyTestCase.test_add of <__main__.MyTestCase testMethod=test_add>>
v3.7_1  |   File "/workdir/test.py", line 11, in test_add
v3.7_1  |     r2 = self.add(2, "1")
v3.7_1  |          └ <__main__.MyTestCase testMethod=test_add>
v3.7_1  |   File "/workdir/test.py", line 7, in add
v3.7_1  |     return a + b
v3.7_1  |            │   └ '1'
v3.7_1  |            └ 2
v3.7_1  | TypeError: unsupported operand type(s) for +: 'int' and 'str'
v3.7_1  |
v3.7_1  | ----------------------------------------------------------------------
v3.7_1  | Ran 1 test in 0.014s
v3.7_1  |
v3.7_1  | FAILED (errors=1)
v3.8_1  | E
v3.8_1  | ======================================================================
v3.8_1  | ERROR: test_add (__main__.MyTestCase)
v3.8_1  | ----------------------------------------------------------------------
v3.8_1  | Traceback (most recent call last):
v3.8_1  |   File "/usr/local/lib/python3.8/unittest/case.py", line 59, in testPartExecutor
v3.8_1  |     yield
v3.8_1  |   File "/usr/local/lib/python3.8/unittest/case.py", line 642, in run
v3.8_1  |     testMethod()
v3.8_1  |     └ <bound method MyTestCase.test_add of <__main__.MyTestCase testMethod=test_add>>
v3.8_1  |   File "/workdir/test.py", line 11, in test_add
v3.8_1  |     r2 = self.add(2, "1")
v3.8_1  |          └ <__main__.MyTestCase testMethod=test_add>
v3.8_1  |   File "/workdir/test.py", line 7, in add
v3.8_1  |     return a + b
v3.8_1  |            │   └ '1'
v3.8_1  |            └ 2
v3.8_1  | TypeError: unsupported operand type(s) for +: 'int' and 'str'
v3.8_1  |
v3.8_1  | ----------------------------------------------------------------------
v3.8_1  | Ran 1 test in 0.045s
v3.8_1  |
v3.8_1  | FAILED (errors=1)
v3.5_1  | E
v3.5_1  | ======================================================================
v3.5_1  | ERROR: test_add (__main__.MyTestCase)
v3.5_1  | ----------------------------------------------------------------------
v3.5_1  | Traceback (most recent call last):
v3.5_1  |   File "/usr/local/lib/python3.5/unittest/case.py", line 59, in testPartExecutor
v3.5_1  |     yield
v3.5_1  |   File "/usr/local/lib/python3.5/unittest/case.py", line 605, in run
v3.5_1  |     testMethod()
v3.5_1  |     └ <bound method MyTestCase.test_add of <__main__.MyTestCase testMethod=test_add>>
v3.5_1  |   File "/workdir/test.py", line 11, in test_add
v3.5_1  |     r2 = self.add(2, "1")
v3.5_1  |          └ <__main__.MyTestCase testMethod=test_add>
v3.5_1  |   File "/workdir/test.py", line 7, in add
v3.5_1  |     return a + b
v3.5_1  |            │   └ '1'
v3.5_1  |            └ 2
v3.5_1  | TypeError: unsupported operand type(s) for +: 'int' and 'str'
v3.5_1  |
v3.5_1  | ----------------------------------------------------------------------
v3.5_1  | Ran 1 test in 0.056s
v3.5_1  |
v3.5_1  | FAILED (errors=1)
v2.7_1  | E
v2.7_1  | ======================================================================
v2.7_1  | ERROR: test_add (__main__.MyTestCase)
v2.7_1  | ----------------------------------------------------------------------
v2.7_1  | Traceback (most recent call last):
v2.7_1  |   File "/usr/local/lib/python2.7/unittest/case.py", line 329, in run
v2.7_1  |     testMethod()
v2.7_1  |     └ <bound method MyTestCase.test_add of <__main__.MyTestCase testMethod=test_add>>
v2.7_1  |   File "/workdir/test.py", line 11, in test_add
v2.7_1  |     r2 = self.add(2, "1")
v2.7_1  |          └ <__main__.MyTestCase testMethod=test_add>
v2.7_1  |   File "/workdir/test.py", line 7, in add
v2.7_1  |     return a + b
v2.7_1  |            │   └ '1'
v2.7_1  |            └ 2
v2.7_1  | TypeError: unsupported operand type(s) for +: 'int' and 'str'
v2.7_1  |
v2.7_1  | ----------------------------------------------------------------------
v2.7_1  | Ran 1 test in 0.013s
v2.7_1  |
v2.7_1  | FAILED (errors=1)

If you guys agree with me, I can create a PR with some tests about this hooking, to make sure that future python nightly version doesn't break this behavior.

Qix- commented

@ocavue yes please. :)

@issuehunt has funded $40.00 to this issue.


Interesting bot.

I have already posted a PR for this issue: #77

@ocavue @Qix- does anyone need my help or this issue was almost fixed ??? I see the PR #77 not merged yet.... what are we waiting for?? it was reviewed and approved right?

I think the reason why #77 is not merged yet is that I didn't click the "Slove Conversation" button before. 😂

"Solve Conversation" button ??? i never know github has such feature :P

I forgot what its name is. I think it has the word "solve" (not slove) in it

@Qix- has rewarded $28.00 to @ocavue. See it on IssueHunt

  • 💰 Total deposit: $40.00
  • 🎉 Repository reward(20%): $8.00
  • 🔧 Service fee(10%): $4.00