Skip to content

Commit e858c8e

Browse files
authored
Merge pull request #458 from itamarst/456.custom-json-encoder-tests
Custom JSON encoder support in tests
2 parents 67e37c6 + 3a4b26c commit e858c8e

File tree

7 files changed

+128
-9
lines changed

7 files changed

+128
-9
lines changed

docs/source/generating/testing.rst

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,35 @@ Or we can simplify further by using ``assertHasMessage`` and ``assertHasAction``
233233
self.assertEqual(servers, [msg.message["server"] for msg in messages])
234234
235235
236+
Custom JSON encoding
237+
--------------------
238+
239+
Just like a ``FileDestination`` can have a custom JSON encoder, so can your tests, so you can validate your messages with that JSON encoder:
240+
241+
.. code-block:: python
242+
243+
from unittest import TestCase
244+
from eliot.json import EliotJSONEncoder
245+
from eliot.testing import capture_logging
246+
247+
class MyClass:
248+
def __init__(self, x):
249+
self.x = x
250+
251+
class MyEncoder(EliotJSONEncoder):
252+
def default(self, obj):
253+
if isinstance(obj, MyClass):
254+
return {"x": obj.x}
255+
return EliotJSONEncoder.default(self, obj)
256+
257+
class LoggingTests(TestCase):
258+
@capture_logging(None, encoder_=MyEncoder)
259+
def test_logging(self, logger):
260+
# Logged messages will be validated using MyEncoder....
261+
...
262+
263+
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.
264+
236265
Custom testing setup
237266
--------------------
238267

docs/source/news.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
What's New
22
==========
33

4+
1.13.0
5+
^^^^^^
6+
7+
Features:
8+
9+
* ``@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``.
10+
* Added support for Python 3.9.
11+
12+
Deprecation:
13+
14+
* Python 3.5 is no longer supported.
15+
416
1.12.0
517
^^^^^^
618

eliot/_output.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -262,8 +262,12 @@ class MemoryLogger(object):
262262
not mutate this list.
263263
"""
264264

265-
def __init__(self):
265+
def __init__(self, encoder=EliotJSONEncoder):
266+
"""
267+
@param encoder: A JSONEncoder subclass to use when encoding JSON.
268+
"""
266269
self._lock = Lock()
270+
self._encoder = encoder
267271
self.reset()
268272

269273
@exclusively
@@ -344,8 +348,7 @@ def _validate_message(self, dictionary, serializer):
344348
serializer.serialize(dictionary)
345349

346350
try:
347-
bytesjson.dumps(dictionary)
348-
pyjson.dumps(dictionary)
351+
pyjson.dumps(dictionary, cls=self._encoder)
349352
except Exception as e:
350353
raise TypeError("Message %s doesn't encode to JSON: %s" % (dictionary, e))
351354

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

eliot/testing.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from ._message import MESSAGE_TYPE_FIELD, TASK_LEVEL_FIELD, TASK_UUID_FIELD
2121
from ._output import MemoryLogger
2222
from . import _output
23+
from .json import EliotJSONEncoder
2324

2425
COMPLETED_STATUSES = (FAILED_STATUS, SUCCEEDED_STATUS)
2526

@@ -298,7 +299,9 @@ def swap_logger(logger):
298299
return previous_logger
299300

300301

301-
def validateLogging(assertion, *assertionArgs, **assertionKwargs):
302+
def validateLogging(
303+
assertion, *assertionArgs, encoder_=EliotJSONEncoder, **assertionKwargs
304+
):
302305
"""
303306
Decorator factory for L{unittest.TestCase} methods to add logging
304307
validation.
@@ -330,14 +333,16 @@ def assertFooLogging(self, logger):
330333
331334
@param assertionKwargs: Additional keyword arguments to pass to
332335
C{assertion}.
336+
337+
@param encoder_: C{json.JSONEncoder} subclass to use when validating JSON.
333338
"""
334339

335340
def decorator(function):
336341
@wraps(function)
337342
def wrapper(self, *args, **kwargs):
338343
skipped = False
339344

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

363368

364-
def capture_logging(assertion, *assertionArgs, **assertionKwargs):
369+
def capture_logging(
370+
assertion, *assertionArgs, encoder_=EliotJSONEncoder, **assertionKwargs
371+
):
365372
"""
366373
Capture and validate all logging that doesn't specify a L{Logger}.
367374
368375
See L{validate_logging} for details on the rest of its behavior.
369376
"""
370377

371378
def decorator(function):
372-
@validate_logging(assertion, *assertionArgs, **assertionKwargs)
379+
@validate_logging(
380+
assertion, *assertionArgs, encoder_=encoder_, **assertionKwargs
381+
)
373382
@wraps(function)
374383
def wrapper(self, *args, **kwargs):
375384
logger = kwargs["logger"]

eliot/tests/common.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,20 @@
33
"""
44

55
from io import BytesIO
6+
from json import JSONEncoder
7+
8+
9+
class CustomObject(object):
10+
"""Gets encoded to JSON."""
11+
12+
13+
class CustomJSONEncoder(JSONEncoder):
14+
"""JSONEncoder that knows about L{CustomObject}."""
15+
16+
def default(self, o):
17+
if isinstance(o, CustomObject):
18+
return "CUSTOM!"
19+
return JSONEncoder.default(self, o)
620

721

822
class FakeSys(object):

eliot/tests/test_output.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from .._validation import ValidationError, Field, _MessageSerializer
3333
from .._traceback import write_traceback
3434
from ..testing import assertContainsFields
35+
from .common import CustomObject, CustomJSONEncoder
3536

3637

3738
class MemoryLoggerTests(TestCase):
@@ -122,6 +123,27 @@ def test_JSON(self):
122123
)
123124
self.assertRaises(TypeError, logger.validate)
124125

126+
@skipUnless(np, "NumPy is not installed.")
127+
def test_EliotJSONEncoder(self):
128+
"""
129+
L{MemoryLogger.validate} uses the EliotJSONEncoder by default to do
130+
encoding testing.
131+
"""
132+
logger = MemoryLogger()
133+
logger.write({"message_type": "type", "foo": np.uint64(12)}, None)
134+
logger.validate()
135+
136+
def test_JSON_custom_encoder(self):
137+
"""
138+
L{MemoryLogger.validate} will use a custom JSON encoder if one was given.
139+
"""
140+
logger = MemoryLogger(encoder=CustomJSONEncoder)
141+
logger.write(
142+
{"message_type": "type", "custom": CustomObject()},
143+
None,
144+
)
145+
logger.validate()
146+
125147
def test_serialize(self):
126148
"""
127149
L{MemoryLogger.serialize} returns a list of serialized versions of the

eliot/tests/test_testing.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44

55
from __future__ import unicode_literals
66

7-
from unittest import SkipTest, TestResult, TestCase
7+
from unittest import SkipTest, TestResult, TestCase, skipUnless
8+
9+
try:
10+
import numpy as np
11+
except ImportError:
12+
np = None
813

914
from ..testing import (
1015
issuperset,
@@ -25,7 +30,8 @@
2530
from .._message import Message
2631
from .._validation import ActionType, MessageType, ValidationError, Field
2732
from .._traceback import write_traceback
28-
from .. import add_destination, remove_destination, _output
33+
from .. import add_destination, remove_destination, _output, log_message
34+
from .common import CustomObject, CustomJSONEncoder
2935

3036

3137
class IsSuperSetTests(TestCase):
@@ -740,6 +746,28 @@ def runTest(self, logger):
740746
)
741747

742748

749+
class JSONEncodingTests(TestCase):
750+
"""Tests for L{capture_logging} JSON encoder support."""
751+
752+
@skipUnless(np, "NumPy is not installed.")
753+
@capture_logging(None)
754+
def test_default_JSON_encoder(self, logger):
755+
"""
756+
L{capture_logging} validates using L{EliotJSONEncoder} by default.
757+
"""
758+
# Default JSON encoder can't handle NumPy:
759+
log_message(message_type="hello", number=np.uint32(12))
760+
761+
@capture_logging(None, encoder_=CustomJSONEncoder)
762+
def test_custom_JSON_encoder(self, logger):
763+
"""
764+
L{capture_logging} can be called with a custom JSON encoder, which is then
765+
used for validation.
766+
"""
767+
# Default JSON encoder can't handle this custom object:
768+
log_message(message_type="hello", object=CustomObject())
769+
770+
743771
MESSAGE1 = MessageType(
744772
"message1", [Field.forTypes("x", [int], "A number")], "A message for testing."
745773
)

0 commit comments

Comments
 (0)