Skip to content

bpo-39385: Add an assertNoLogs context manager to unittest.TestCase (GH-18067) #18067

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jul 1, 2020
Merged
21 changes: 21 additions & 0 deletions Doc/library/unittest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,9 @@ Test cases
| :meth:`assertLogs(logger, level) | The ``with`` block logs on *logger* | 3.4 |
| <TestCase.assertLogs>` | with minimum *level* | |
+---------------------------------------------------------+--------------------------------------+------------+
| :meth:`assertNoLogs(logger, level) | The ``with`` block does not log on | 3.10 |
| <TestCase.assertNoLogs>` | *logger* with minimum *level* | |
+---------------------------------------------------------+--------------------------------------+------------+

.. method:: assertRaises(exception, callable, *args, **kwds)
assertRaises(exception, *, msg=None)
Expand Down Expand Up @@ -1121,6 +1124,24 @@ Test cases

.. versionadded:: 3.4

.. method:: assertNoLogs(logger=None, level=None)

A context manager to test that no messages are logged on
the *logger* or one of its children, with at least the given
*level*.

If given, *logger* should be a :class:`logging.Logger` object or a
:class:`str` giving the name of a logger. The default is the root
logger, which will catch all messages.

If given, *level* should be either a numeric logging level or
its string equivalent (for example either ``"ERROR"`` or
:attr:`logging.ERROR`). The default is :attr:`logging.INFO`.

Unlike :meth:`assertLogs`, nothing will be returned by the context
manager.

.. versionadded:: 3.10

There are also other methods used to perform more specific checks, such as:

Expand Down
28 changes: 22 additions & 6 deletions Lib/unittest/_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,19 @@ def emit(self, record):


class _AssertLogsContext(_BaseTestCaseContext):
"""A context manager used to implement TestCase.assertLogs()."""
"""A context manager for assertLogs() and assertNoLogs() """

LOGGING_FORMAT = "%(levelname)s:%(name)s:%(message)s"

def __init__(self, test_case, logger_name, level):
def __init__(self, test_case, logger_name, level, no_logs):
_BaseTestCaseContext.__init__(self, test_case)
self.logger_name = logger_name
if level:
self.level = logging._nameToLevel.get(level, level)
else:
self.level = logging.INFO
self.msg = None
self.no_logs = no_logs

def __enter__(self):
if isinstance(self.logger_name, logging.Logger):
Expand All @@ -54,16 +55,31 @@ def __enter__(self):
logger.handlers = [handler]
logger.setLevel(self.level)
logger.propagate = False
if self.no_logs:
return
return handler.watcher

def __exit__(self, exc_type, exc_value, tb):
self.logger.handlers = self.old_handlers
self.logger.propagate = self.old_propagate
self.logger.setLevel(self.old_level)

if exc_type is not None:
# let unexpected exceptions pass through
return False
if len(self.watcher.records) == 0:
self._raiseFailure(
"no logs of level {} or higher triggered on {}"
.format(logging.getLevelName(self.level), self.logger.name))

if self.no_logs:
# assertNoLogs
if len(self.watcher.records) > 0:
self._raiseFailure(
"Unexpected logs found: {!r}".format(
self.watcher.output
)
)

else:
# assertLogs
if len(self.watcher.records) == 0:
self._raiseFailure(
"no logs of level {} or higher triggered on {}"
.format(logging.getLevelName(self.level), self.logger.name))
12 changes: 10 additions & 2 deletions Lib/unittest/case.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,6 @@ def __exit__(self, exc_type, exc_value, tb):
self._raiseFailure("{} not triggered".format(exc_name))



class _OrderedChainMap(collections.ChainMap):
def __iter__(self):
seen = set()
Expand Down Expand Up @@ -788,7 +787,16 @@ def assertLogs(self, logger=None, level=None):
"""
# Lazy import to avoid importing logging if it is not needed.
from ._log import _AssertLogsContext
return _AssertLogsContext(self, logger, level)
return _AssertLogsContext(self, logger, level, no_logs=False)

def assertNoLogs(self, logger=None, level=None):
""" Fail unless no log messages of level *level* or higher are emitted
on *logger_name* or its children.

This method must be used as a context manager.
"""
from ._log import _AssertLogsContext
return _AssertLogsContext(self, logger, level, no_logs=True)

def _getAssertEqualityFunc(self, first, second):
"""Get a detailed comparison function for the types of the two args.
Expand Down
75 changes: 75 additions & 0 deletions Lib/unittest/test/test_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -1681,6 +1681,81 @@ def testAssertLogsFailureMismatchingLogger(self):
with self.assertLogs('foo'):
log_quux.error("1")

def testAssertLogsUnexpectedException(self):
# Check unexpected exception will go through.
with self.assertRaises(ZeroDivisionError):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use multiple context managers at once (in this test and all others):

        with self.assertRaises(ZeroDivisionError), \
                self.assertLogs():
            raise ZeroDivisionError("Unexpected")

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I realize that a lot of other existing tests in this module use nested with, and none uses , \. For consistencies maybe the nested with should be kept?

with self.assertLogs():
raise ZeroDivisionError("Unexpected")

def testAssertNoLogsDefault(self):
with self.assertRaises(self.failureException) as cm:
with self.assertNoLogs():
log_foo.info("1")
log_foobar.debug("2")
self.assertEqual(
str(cm.exception),
"Unexpected logs found: ['INFO:foo:1']",
)

def testAssertNoLogsFailureFoundLogs(self):
with self.assertRaises(self.failureException) as cm:
with self.assertNoLogs():
log_quux.error("1")
log_foo.error("foo")

self.assertEqual(
str(cm.exception),
"Unexpected logs found: ['ERROR:quux:1', 'ERROR:foo:foo']",
)

def testAssertNoLogsPerLogger(self):
with self.assertNoStderr():
with self.assertLogs(log_quux):
with self.assertNoLogs(logger=log_foo):
log_quux.error("1")

def testAssertNoLogsFailurePerLogger(self):
# Failure due to unexpected logs for the given logger or its
# children.
with self.assertRaises(self.failureException) as cm:
with self.assertLogs(log_quux):
with self.assertNoLogs(logger=log_foo):
log_quux.error("1")
log_foobar.info("2")
self.assertEqual(
str(cm.exception),
"Unexpected logs found: ['INFO:foo.bar:2']",
)

def testAssertNoLogsPerLevel(self):
# Check per-level filtering
with self.assertNoStderr():
with self.assertNoLogs(level="ERROR"):
log_foo.info("foo")
log_quux.debug("1")

def testAssertNoLogsFailurePerLevel(self):
# Failure due to unexpected logs at the specified level.
with self.assertRaises(self.failureException) as cm:
with self.assertNoLogs(level="DEBUG"):
log_foo.debug("foo")
log_quux.debug("1")
self.assertEqual(
str(cm.exception),
"Unexpected logs found: ['DEBUG:foo:foo', 'DEBUG:quux:1']",
)

def testAssertNoLogsUnexpectedException(self):
# Check unexpected exception will go through.
with self.assertRaises(ZeroDivisionError):
with self.assertNoLogs():
raise ZeroDivisionError("Unexpected")

def testAssertNoLogsYieldsNone(self):
with self.assertNoLogs() as value:
pass
self.assertIsNone(value)

def testDeprecatedMethodNames(self):
"""
Test that the deprecated methods raise a DeprecationWarning. See #9424.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
A new test assertion context-manager, :func:`unittest.assertNoLogs` will
ensure a given block of code emits no log messages using the logging module.
Contributed by Kit Yan Choi.