Skip to content

Commit

Permalink
Merge pull request #458 from itamarst/456.custom-json-encoder-tests
Browse files Browse the repository at this point in the history
Custom JSON encoder support in tests
  • Loading branch information
itamarst authored Dec 15, 2020
2 parents 67e37c6 + 3a4b26c commit e858c8e
Show file tree
Hide file tree
Showing 7 changed files with 128 additions and 9 deletions.
29 changes: 29 additions & 0 deletions docs/source/generating/testing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,35 @@ Or we can simplify further by using ``assertHasMessage`` and ``assertHasAction``
self.assertEqual(servers, [msg.message["server"] for msg in messages])
Custom JSON encoding
--------------------

Just like a ``FileDestination`` can have a custom JSON encoder, so can your tests, so you can validate your messages with that JSON encoder:

.. code-block:: python
from unittest import TestCase
from eliot.json import EliotJSONEncoder
from eliot.testing import capture_logging
class MyClass:
def __init__(self, x):
self.x = x
class MyEncoder(EliotJSONEncoder):
def default(self, obj):
if isinstance(obj, MyClass):
return {"x": obj.x}
return EliotJSONEncoder.default(self, obj)
class LoggingTests(TestCase):
@capture_logging(None, encoder_=MyEncoder)
def test_logging(self, logger):
# Logged messages will be validated using MyEncoder....
...
Notice that the hyphen after ``encoder_`` is deliberate: by default keyword arguments are passed to the assertion function (the first argument to ``@capture_logging``) so it's marked this way to indicate it's part of Eliot's API.

Custom testing setup
--------------------

Expand Down
12 changes: 12 additions & 0 deletions docs/source/news.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
What's New
==========

1.13.0
^^^^^^

Features:

* ``@capture_logging`` and ``MemoryLogger`` now support specifying a custom JSON encoder. By default they now use Eliot's encoder. This means tests can now match the encoding used by a ``FileDestination``.
* Added support for Python 3.9.

Deprecation:

* Python 3.5 is no longer supported.

1.12.0
^^^^^^

Expand Down
11 changes: 8 additions & 3 deletions eliot/_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,12 @@ class MemoryLogger(object):
not mutate this list.
"""

def __init__(self):
def __init__(self, encoder=EliotJSONEncoder):
"""
@param encoder: A JSONEncoder subclass to use when encoding JSON.
"""
self._lock = Lock()
self._encoder = encoder
self.reset()

@exclusively
Expand Down Expand Up @@ -344,8 +348,7 @@ def _validate_message(self, dictionary, serializer):
serializer.serialize(dictionary)

try:
bytesjson.dumps(dictionary)
pyjson.dumps(dictionary)
pyjson.dumps(dictionary, cls=self._encoder)
except Exception as e:
raise TypeError("Message %s doesn't encode to JSON: %s" % (dictionary, e))

Expand Down Expand Up @@ -462,6 +465,8 @@ def to_file(output_file, encoder=EliotJSONEncoder):
Add a destination that writes a JSON message per line to the given file.
@param output_file: A file-like object.
@param encoder: A JSONEncoder subclass to use when encoding JSON.
"""
Logger._destinations.add(FileDestination(file=output_file, encoder=encoder))

Expand Down
17 changes: 13 additions & 4 deletions eliot/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from ._message import MESSAGE_TYPE_FIELD, TASK_LEVEL_FIELD, TASK_UUID_FIELD
from ._output import MemoryLogger
from . import _output
from .json import EliotJSONEncoder

COMPLETED_STATUSES = (FAILED_STATUS, SUCCEEDED_STATUS)

Expand Down Expand Up @@ -298,7 +299,9 @@ def swap_logger(logger):
return previous_logger


def validateLogging(assertion, *assertionArgs, **assertionKwargs):
def validateLogging(
assertion, *assertionArgs, encoder_=EliotJSONEncoder, **assertionKwargs
):
"""
Decorator factory for L{unittest.TestCase} methods to add logging
validation.
Expand Down Expand Up @@ -330,14 +333,16 @@ def assertFooLogging(self, logger):
@param assertionKwargs: Additional keyword arguments to pass to
C{assertion}.
@param encoder_: C{json.JSONEncoder} subclass to use when validating JSON.
"""

def decorator(function):
@wraps(function)
def wrapper(self, *args, **kwargs):
skipped = False

kwargs["logger"] = logger = MemoryLogger()
kwargs["logger"] = logger = MemoryLogger(encoder=encoder_)
self.addCleanup(check_for_errors, logger)
# TestCase runs cleanups in reverse order, and we want this to
# run *before* tracebacks are checked:
Expand All @@ -361,15 +366,19 @@ def wrapper(self, *args, **kwargs):
validate_logging = validateLogging


def capture_logging(assertion, *assertionArgs, **assertionKwargs):
def capture_logging(
assertion, *assertionArgs, encoder_=EliotJSONEncoder, **assertionKwargs
):
"""
Capture and validate all logging that doesn't specify a L{Logger}.
See L{validate_logging} for details on the rest of its behavior.
"""

def decorator(function):
@validate_logging(assertion, *assertionArgs, **assertionKwargs)
@validate_logging(
assertion, *assertionArgs, encoder_=encoder_, **assertionKwargs
)
@wraps(function)
def wrapper(self, *args, **kwargs):
logger = kwargs["logger"]
Expand Down
14 changes: 14 additions & 0 deletions eliot/tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@
"""

from io import BytesIO
from json import JSONEncoder


class CustomObject(object):
"""Gets encoded to JSON."""


class CustomJSONEncoder(JSONEncoder):
"""JSONEncoder that knows about L{CustomObject}."""

def default(self, o):
if isinstance(o, CustomObject):
return "CUSTOM!"
return JSONEncoder.default(self, o)


class FakeSys(object):
Expand Down
22 changes: 22 additions & 0 deletions eliot/tests/test_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from .._validation import ValidationError, Field, _MessageSerializer
from .._traceback import write_traceback
from ..testing import assertContainsFields
from .common import CustomObject, CustomJSONEncoder


class MemoryLoggerTests(TestCase):
Expand Down Expand Up @@ -122,6 +123,27 @@ def test_JSON(self):
)
self.assertRaises(TypeError, logger.validate)

@skipUnless(np, "NumPy is not installed.")
def test_EliotJSONEncoder(self):
"""
L{MemoryLogger.validate} uses the EliotJSONEncoder by default to do
encoding testing.
"""
logger = MemoryLogger()
logger.write({"message_type": "type", "foo": np.uint64(12)}, None)
logger.validate()

def test_JSON_custom_encoder(self):
"""
L{MemoryLogger.validate} will use a custom JSON encoder if one was given.
"""
logger = MemoryLogger(encoder=CustomJSONEncoder)
logger.write(
{"message_type": "type", "custom": CustomObject()},
None,
)
logger.validate()

def test_serialize(self):
"""
L{MemoryLogger.serialize} returns a list of serialized versions of the
Expand Down
32 changes: 30 additions & 2 deletions eliot/tests/test_testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@

from __future__ import unicode_literals

from unittest import SkipTest, TestResult, TestCase
from unittest import SkipTest, TestResult, TestCase, skipUnless

try:
import numpy as np
except ImportError:
np = None

from ..testing import (
issuperset,
Expand All @@ -25,7 +30,8 @@
from .._message import Message
from .._validation import ActionType, MessageType, ValidationError, Field
from .._traceback import write_traceback
from .. import add_destination, remove_destination, _output
from .. import add_destination, remove_destination, _output, log_message
from .common import CustomObject, CustomJSONEncoder


class IsSuperSetTests(TestCase):
Expand Down Expand Up @@ -740,6 +746,28 @@ def runTest(self, logger):
)


class JSONEncodingTests(TestCase):
"""Tests for L{capture_logging} JSON encoder support."""

@skipUnless(np, "NumPy is not installed.")
@capture_logging(None)
def test_default_JSON_encoder(self, logger):
"""
L{capture_logging} validates using L{EliotJSONEncoder} by default.
"""
# Default JSON encoder can't handle NumPy:
log_message(message_type="hello", number=np.uint32(12))

@capture_logging(None, encoder_=CustomJSONEncoder)
def test_custom_JSON_encoder(self, logger):
"""
L{capture_logging} can be called with a custom JSON encoder, which is then
used for validation.
"""
# Default JSON encoder can't handle this custom object:
log_message(message_type="hello", object=CustomObject())


MESSAGE1 = MessageType(
"message1", [Field.forTypes("x", [int], "A number")], "A message for testing."
)
Expand Down

0 comments on commit e858c8e

Please sign in to comment.