Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Testing an isolated custom JsonDeserializer in Java

So for this little program I'm writing I'm looking to parse Twitter's tweet stream. Im using the Gson library which works nice. Gson couldn't parse Twitters created_at datetime field, so I had to write a custom JsonDserializer that needs to be registered with the parser through the GsonBuilderas follows:

new GsonBuilder().registerTypeAdatapter(DateTime.class, <myCustomDeserializerType>)

Now my deserializer works well, and I am able to parse Twitter's stream.

However, I'm trying to cover as much of my program with unit tests, so this custom deserializer should be included.

Since a good unit test is a nicely isolated test, I do not want to register it with a Gson object after which I would parse a json string. What I do want is to create an instance of my deserializer and just pass a generic string representing a datetime, so that I could test the deserializer without it being integrated with anything else.

The signature of the deserialize method of a JsonDeserializer is as follows:

deserialize(JsonElement jsonElement, Type type, JsonDeserializationContext jsonDeserializationContext)

Let's say I want to parse the following data: 'Mon Mar 27 14:09:47 +0000 2017'. How would I have to transform my input data in order to correctly test my deserializer.

I'm not looking for code that actually parses this date, I already have that part covered. I'm asking how I can meet the deserialize method's signature so that I can simulate it's use in a Gson it is used in.

like image 787
Glubus Avatar asked Dec 24 '22 19:12

Glubus


1 Answers

JsonSerializer and JsonDeserializer are tightly bound to Gson JSON tree model and a specific Gson configuration (de)serialization context that provides a set of types that can be (de)serialized. Because of this, accomplishing unit tests for JsonSerializer and JsonDeserializer are rather hard than easy.

Consider the following JSON document somewhere in your src/test/resources/.../zoned-date-time.json:

"Mon Mar 27 14:09:47 +0000 2017"

This is a perfectly valid JSON document, and it has nothing except of a single string for simplicity. A date/time formatter for the format above can be implemented in Java 8 as follows:

final class CustomPatterns {

    private CustomPatterns() {
    }

    private static final Map<Long, String> dayOfWeek = ImmutableMap.<Long, String>builder()
            .put(1L, "Mon")
            .put(2L, "Tue")
            .put(3L, "Wed")
            .put(4L, "Thu")
            .put(5L, "Fri")
            .put(6L, "Sat")
            .put(7L, "Sun")
            .build();

    private static final Map<Long, String> monthOfYear = ImmutableMap.<Long, String>builder()
            .put(1L, "Jan")
            .put(2L, "Feb")
            .put(3L, "Mar")
            .put(4L, "Apr")
            .put(5L, "May")
            .put(6L, "Jun")
            .put(7L, "Jul")
            .put(8L, "Aug")
            .put(9L, "Sep")
            .put(10L, "Oct")
            .put(11L, "Nov")
            .put(12L, "Dec")
            .build();

    static final DateTimeFormatter customDateTimeFormatter = new DateTimeFormatterBuilder()
            .appendText(DAY_OF_WEEK, dayOfWeek)
            .appendLiteral(' ')
            .appendText(MONTH_OF_YEAR, monthOfYear)
            .appendLiteral(' ')
            .appendValue(DAY_OF_MONTH, 1, 2, NOT_NEGATIVE)
            .appendLiteral(' ')
            .appendValue(HOUR_OF_DAY, 2)
            .appendLiteral(':')
            .appendValue(MINUTE_OF_HOUR, 2)
            .appendLiteral(':')
            .appendValue(SECOND_OF_MINUTE, 2)
            .appendLiteral(' ')
            .appendOffset("+HHMM", "+0000")
            .appendLiteral(' ')
            .appendValue(YEAR)
            .toFormatter();

}

Now consider the following JSON deserializer for ZonedDateTime:

final class ZonedDateTimeJsonDeserializer
        implements JsonDeserializer<ZonedDateTime> {

    private static final JsonDeserializer<ZonedDateTime> zonedDateTimeJsonDeserializer = new ZonedDateTimeJsonDeserializer();

    private ZonedDateTimeJsonDeserializer() {
    }

    static JsonDeserializer<ZonedDateTime> getZonedDateTimeJsonDeserializer() {
        return zonedDateTimeJsonDeserializer;
    }

    @Override
    public ZonedDateTime deserialize(final JsonElement jsonElement, final Type type, final JsonDeserializationContext context)
            throws JsonParseException {
        try {
            final String s = context.deserialize(jsonElement, String.class);
            return ZonedDateTime.parse(s, customDateTimeFormatter);
        } catch ( final DateTimeParseException ex ) {
            throw new JsonParseException(ex);
        }
    }

}

Note that I'm deserialiazing a string via the context by intention to accent that more complex JsonDeserializer instances may depend on it heavily. Now let's make some JUnit tests to test it:

public final class ZonedDateTimeJsonDeserializerTest {

    private static final TypeToken<ZonedDateTime> zonedDateTimeTypeToken = new TypeToken<ZonedDateTime>() {
    };

    private static final ZonedDateTime expectedZonedDateTime = ZonedDateTime.of(2017, 3, 27, 14, 9, 47, 0, UTC);

    @Test
    public void testDeserializeIndirectlyViaAutomaticTypeAdapterBinding()
            throws IOException {
        final JsonDeserializer<ZonedDateTime> unit = getZonedDateTimeJsonDeserializer();
        final Gson gson = new GsonBuilder()
                .registerTypeAdapter(ZonedDateTime.class, unit)
                .create();
        try ( final JsonReader jsonReader = getPackageResourceJsonReader(ZonedDateTimeJsonDeserializerTest.class, "zoned-date-time.json") ) {
            final ZonedDateTime actualZonedDateTime = gson.fromJson(jsonReader, ZonedDateTime.class);
            assertThat(actualZonedDateTime, is(expectedZonedDateTime));
        }
    }

    @Test
    public void testDeserializeIndirectlyViaManualTypeAdapterBinding()
            throws IOException {
        final JsonDeserializer<ZonedDateTime> unit = getZonedDateTimeJsonDeserializer();
        final Gson gson = new Gson();
        final TypeAdapterFactory typeAdapterFactory = newFactoryWithMatchRawType(zonedDateTimeTypeToken, unit);
        final TypeAdapter<ZonedDateTime> dateTypeAdapter = typeAdapterFactory.create(gson, zonedDateTimeTypeToken);
        try ( final JsonReader jsonReader = getPackageResourceJsonReader(ZonedDateTimeJsonDeserializerTest.class, "zoned-date-time.json") ) {
            final ZonedDateTime actualZonedDateTime = dateTypeAdapter.read(jsonReader);
            assertThat(actualZonedDateTime, is(expectedZonedDateTime));
        }
    }

    @Test
    public void testDeserializeDirectlyWithMockedContext()
            throws IOException {
        final JsonDeserializer<ZonedDateTime> unit = getZonedDateTimeJsonDeserializer();
        final JsonDeserializationContext mockContext = mock(JsonDeserializationContext.class);
        when(mockContext.deserialize(any(JsonElement.class), eq(String.class))).thenAnswer(iom -> {
            final JsonElement jsonElement = (JsonElement) iom.getArguments()[0];
            return jsonElement.getAsJsonPrimitive().getAsString();
        });
        final JsonParser parser = new JsonParser();
        try ( final JsonReader jsonReader = getPackageResourceJsonReader(ZonedDateTimeJsonDeserializerTest.class, "zoned-date-time.json") ) {
            final JsonElement jsonElement = parser.parse(jsonReader);
            final ZonedDateTime actualZonedDateTime = unit.deserialize(jsonElement, ZonedDateTime.class, mockContext);
            assertThat(actualZonedDateTime, is(expectedZonedDateTime));
        }
        verify(mockContext).deserialize(any(JsonPrimitive.class), eq(String.class));
        verifyNoMoreInteractions(mockContext);
    }

}

Note that each test here requires some Gson configuration to be built in order to let the deserialization context work, or the latter must be mocked. Pretty much to test a simple unit.

An alternative to the JSON tree model in Gson is stream-oriented type adapters that that do not require the entire JSON tree to be constructed, so you can easily read or write directly from/to JSON streams making your (de)serialization faster and less memory consuming. Especially, for simple cases like what trivial string<==>FooBar conversions are.

final class ZonedDateTimeTypeAdapter
        extends TypeAdapter<ZonedDateTime> {

    private static final TypeAdapter<ZonedDateTime> zonedDateTimeTypeAdapter = new ZonedDateTimeTypeAdapter().nullSafe();

    private ZonedDateTimeTypeAdapter() {
    }

    static TypeAdapter<ZonedDateTime> getZonedDateTimeTypeAdapter() {
        return zonedDateTimeTypeAdapter;
    }

    @Override
    public void write(final JsonWriter out, final ZonedDateTime zonedDateTime) {
        throw new UnsupportedOperationException();
    }

    @Override
    public ZonedDateTime read(final JsonReader in)
            throws IOException {
        try {
            final String s = in.nextString();
            return ZonedDateTime.parse(s, customDateTimeFormatter);
        } catch ( final DateTimeParseException ex ) {
            throw new JsonParseException(ex);
        }
    }

}

And here is a simple unit test for the type adapter above:

public final class ZonedDateTimeTypeAdapterTest {

    private static final ZonedDateTime expectedZonedDateTime = ZonedDateTime.of(2017, 3, 27, 14, 9, 47, 0, UTC);

    @Test(expected = UnsupportedOperationException.class)
    public void testWrite() {
        final TypeAdapter<ZonedDateTime> unit = getZonedDateTimeTypeAdapter();
        unit.toJsonTree(expectedZonedDateTime);
    }

    @Test
    public void testRead()
            throws IOException {
        final TypeAdapter<ZonedDateTime> unit = getZonedDateTimeTypeAdapter();
        try ( final Reader reader = getPackageResourceReader(ZonedDateTimeTypeAdapterTest.class, "zoned-date-time.json") ) {
            final ZonedDateTime actualZonedDateTime = unit.fromJson(reader);
            assertThat(actualZonedDateTime, is(expectedZonedDateTime));
        }
    }

}

For simple cases I would definitely go with type adapters however they may be somewhat harder to implement. You could also refer the Gson unit tests for more information.

like image 75
Lyubomyr Shaydariv Avatar answered Jan 10 '23 06:01

Lyubomyr Shaydariv