Validating Java 17 Record Serialization Implementation in google/gson
This test suite validates Java 17 Record functionality in the GSON library, focusing on serialization and deserialization behaviors. The tests cover various aspects of Record handling including custom naming, field annotations, and primitive type conversions.
Test Coverage Overview
Implementation Analysis
Technical Details
Best Practices Demonstrated
google/gson
gson/src/test/java/com/google/gson/functional/Java17RecordTest.java
/*
* Copyright (C) 2022 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.gson.functional;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.assertThrows;
import com.google.gson.ExclusionStrategy;
import com.google.gson.FieldAttributes;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonIOException;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
import com.google.gson.ReflectionAccessFilter.FilterResult;
import com.google.gson.TypeAdapter;
import com.google.gson.annotations.Expose;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import java.io.IOException;
import java.lang.reflect.Type;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
@RunWith(JUnit4.class)
public final class Java17RecordTest {
private final Gson gson = new Gson();
@Test
public void testFirstNameIsChosenForSerialization() {
RecordWithCustomNames target = new RecordWithCustomNames("v1", "v2");
// Ensure name1 occurs exactly once, and name2 and name3 don't appear
assertThat(gson.toJson(target)).isEqualTo("{\"name\":\"v1\",\"name1\":\"v2\"}");
}
@Test
public void testMultipleNamesDeserializedCorrectly() {
assertThat(gson.fromJson("{'name':'v1'}", RecordWithCustomNames.class).a).isEqualTo("v1");
// Both name1 and name2 gets deserialized to b
assertThat(gson.fromJson("{'name': 'v1', 'name1':'v11'}", RecordWithCustomNames.class).b)
.isEqualTo("v11");
assertThat(gson.fromJson("{'name': 'v1', 'name2':'v2'}", RecordWithCustomNames.class).b)
.isEqualTo("v2");
assertThat(gson.fromJson("{'name': 'v1', 'name3':'v3'}", RecordWithCustomNames.class).b)
.isEqualTo("v3");
}
@Test
public void testMultipleNamesInTheSameString() {
// The last value takes precedence
assertThat(
gson.fromJson(
"{'name': 'foo', 'name1':'v1','name2':'v2','name3':'v3'}",
RecordWithCustomNames.class)
.b)
.isEqualTo("v3");
}
private record RecordWithCustomNames(
@SerializedName("name") String a,
@SerializedName(
value = "name1",
alternate = {"name2", "name3"})
String b) {}
@Test
public void testSerializedNameOnAccessor() {
record LocalRecord(int i) {
@SerializedName("a")
@Override
public int i() {
return i;
}
}
var exception = assertThrows(JsonIOException.class, () -> gson.getAdapter(LocalRecord.class));
assertThat(exception)
.hasMessageThat()
.isEqualTo(
"@SerializedName on method '" + LocalRecord.class.getName() + "#i()' is not supported");
}
@Test
public void testFieldNamingStrategy() {
record LocalRecord(int i) {}
Gson gson = new GsonBuilder().setFieldNamingStrategy(f -> f.getName() + "-custom").create();
assertThat(gson.toJson(new LocalRecord(1))).isEqualTo("{\"i-custom\":1}");
assertThat(gson.fromJson("{\"i-custom\":2}", LocalRecord.class)).isEqualTo(new LocalRecord(2));
}
@Test
public void testUnknownJsonProperty() {
record LocalRecord(int i) {}
// Unknown property 'x' should be ignored
assertThat(gson.fromJson("{\"i\":1,\"x\":2}", LocalRecord.class)).isEqualTo(new LocalRecord(1));
}
@Test
public void testDuplicateJsonProperties() {
record LocalRecord(Integer a, Integer b) {}
String json = "{\"a\":null,\"a\":2,\"b\":1,\"b\":null}";
// Should use value of last occurrence
assertThat(gson.fromJson(json, LocalRecord.class)).isEqualTo(new LocalRecord(2, null));
}
@Test
public void testConstructorRuns() {
record LocalRecord(String s) {
LocalRecord {
s = "custom-" + s;
}
}
LocalRecord deserialized = gson.fromJson("{\"s\": null}", LocalRecord.class);
assertThat(deserialized).isEqualTo(new LocalRecord(null));
assertThat(deserialized.s()).isEqualTo("custom-null");
}
/** Tests behavior when the canonical constructor throws an exception */
@Test
public void testThrowingConstructor() {
record LocalRecord(String s) {
@SuppressWarnings("StaticAssignmentOfThrowable")
static final RuntimeException thrownException = new RuntimeException("Custom exception");
@SuppressWarnings("unused")
LocalRecord {
throw thrownException;
}
}
// TODO: Adjust this once Gson throws more specific exception type
var e = assertThrows(RuntimeException.class, () -> gson.fromJson("{\"s\":\"value\"}", LocalRecord.class));
assertThat(e)
.hasMessageThat()
.isEqualTo(
"Failed to invoke constructor '"
+ LocalRecord.class.getName()
+ "(String)' with args [value]");
assertThat(e).hasCauseThat().isSameInstanceAs(LocalRecord.thrownException);
}
@Test
public void testAccessorIsCalled() {
record LocalRecord(String s) {
@Override
public String s() {
return "accessor-value";
}
}
assertThat(gson.toJson(new LocalRecord(null))).isEqualTo("{\"s\":\"accessor-value\"}");
}
/** Tests behavior when a record accessor method throws an exception */
@Test
public void testThrowingAccessor() {
record LocalRecord(String s) {
@SuppressWarnings("StaticAssignmentOfThrowable")
static final RuntimeException thrownException = new RuntimeException("Custom exception");
@Override
public String s() {
throw thrownException;
}
}
var e = assertThrows(JsonIOException.class, () -> gson.toJson(new LocalRecord("a")));
assertThat(e)
.hasMessageThat()
.isEqualTo("Accessor method '" + LocalRecord.class.getName() + "#s()' threw exception");
assertThat(e).hasCauseThat().isSameInstanceAs(LocalRecord.thrownException);
}
/** Tests behavior for a record without components */
@Test
public void testEmptyRecord() {
record EmptyRecord() {}
assertThat(gson.toJson(new EmptyRecord())).isEqualTo("{}");
assertThat(gson.fromJson("{}", EmptyRecord.class)).isEqualTo(new EmptyRecord());
}
/**
* Tests behavior when {@code null} is serialized / deserialized as record value; basically makes
* sure the adapter is 'null-safe'
*/
@Test
public void testRecordNull() throws IOException {
record LocalRecord(int i) {}
TypeAdapter<LocalRecord> adapter = gson.getAdapter(LocalRecord.class);
assertThat(adapter.toJson(null)).isEqualTo("null");
assertThat(adapter.fromJson("null")).isNull();
}
@Test
public void testPrimitiveDefaultValues() {
RecordWithPrimitives expected =
new RecordWithPrimitives("s", (byte) 0, (short) 0, 0, 0, 0, 0, '\0', false);
assertThat(gson.fromJson("{'aString': 's'}", RecordWithPrimitives.class)).isEqualTo(expected);
}
@Test
public void testPrimitiveJsonNullValue() {
String s = "{'aString': 's', 'aByte': null, 'aShort': 0}";
var e =
assertThrows(JsonParseException.class, () -> gson.fromJson(s, RecordWithPrimitives.class));
assertThat(e)
.hasMessageThat()
.isEqualTo(
"null is not allowed as value for record component 'aByte' of primitive type; at path"
+ " $.aByte");
}
/**
* Tests behavior when JSON contains non-null value, but custom adapter returns null for primitive
* component
*/
@Test
public void testPrimitiveAdapterNullValue() {
Gson gson =
new GsonBuilder()
.registerTypeAdapter(
byte.class,
new TypeAdapter<Byte>() {
@Override
public Byte read(JsonReader in) throws IOException {
in.skipValue();
// Always return null
return null;
}
@Override
public void write(JsonWriter out, Byte value) {
throw new AssertionError("not needed for test");
}
})
.create();
String s = "{'aString': 's', 'aByte': 0}";
var exception =
assertThrows(JsonParseException.class, () -> gson.fromJson(s, RecordWithPrimitives.class));
assertThat(exception)
.hasMessageThat()
.isEqualTo(
"null is not allowed as value for record component 'aByte' of primitive type; at path"
+ " $.aByte");
}
private record RecordWithPrimitives(
String aString,
byte aByte,
short aShort,
int anInt,
long aLong,
float aFloat,
double aDouble,
char aChar,
boolean aBoolean) {}
/** Tests behavior when value of Object component is missing; should default to null */
@Test
public void testObjectDefaultValue() {
record LocalRecord(String s, int i) {}
assertThat(gson.fromJson("{\"i\":1}", LocalRecord.class)).isEqualTo(new LocalRecord(null, 1));
}
/**
* Tests serialization of a record with {@code static} field.
*
* <p>Important: It is not documented that this is officially supported; this test just checks the
* current behavior.
*/
@Test
public void testStaticFieldSerialization() {
// By default Gson should ignore static fields
assertThat(gson.toJson(new RecordWithStaticField())).isEqualTo("{}");
Gson gson =
new GsonBuilder()
// Include static fields
.excludeFieldsWithModifiers(0)
.create();
String json = gson.toJson(new RecordWithStaticField());
assertThat(json).isEqualTo("{\"s\":\"initial\"}");
}
/**
* Tests deserialization of a record with {@code static} field.
*
* <p>Important: It is not documented that this is officially supported; this test just checks the
* current behavior.
*/
@Test
public void testStaticFieldDeserialization() {
// By default Gson should ignore static fields
RecordWithStaticField unused = gson.fromJson("{\"s\":\"custom\"}", RecordWithStaticField.class);
assertThat(RecordWithStaticField.s).isEqualTo("initial");
Gson gson =
new GsonBuilder()
// Include static fields
.excludeFieldsWithModifiers(0)
.create();
String oldValue = RecordWithStaticField.s;
try {
RecordWithStaticField obj = gson.fromJson("{\"s\":\"custom\"}", RecordWithStaticField.class);
assertThat(obj).isNotNull();
// Currently record deserialization always ignores static fields
assertThat(RecordWithStaticField.s).isEqualTo("initial");
} finally {
RecordWithStaticField.s = oldValue;
}
}
private record RecordWithStaticField() {
@SuppressWarnings("NonFinalStaticField")
static String s = "initial";
}
@Test
public void testExposeAnnotation() {
record RecordWithExpose(@Expose int a, int b) {}
Gson gson = new GsonBuilder().excludeFieldsWithoutExposeAnnotation().create();
String json = gson.toJson(new RecordWithExpose(1, 2));
assertThat(json).isEqualTo("{\"a\":1}");
}
@Test
public void testFieldExclusionStrategy() {
record LocalRecord(int a, int b, double c) {}
Gson gson =
new GsonBuilder()
.setExclusionStrategies(
new ExclusionStrategy() {
@Override
public boolean shouldSkipField(FieldAttributes f) {
return f.getName().equals("a");
}
@Override
public boolean shouldSkipClass(Class<?> clazz) {
return clazz == double.class;
}
})
.create();
assertThat(gson.toJson(new LocalRecord(1, 2, 3.0))).isEqualTo("{\"b\":2}");
}
@Test
public void testJsonAdapterAnnotation() {
record Adapter() implements JsonSerializer<String>, JsonDeserializer<String> {
@Override
public String deserialize(
JsonElement json, Type typeOfT, JsonDeserializationContext context) {
return "deserializer-" + json.getAsString();
}
@Override
public JsonElement serialize(String src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive("serializer-" + src);
}
}
record LocalRecord(@JsonAdapter(Adapter.class) String s) {}
assertThat(gson.toJson(new LocalRecord("a"))).isEqualTo("{\"s\":\"serializer-a\"}");
assertThat(gson.fromJson("{\"s\":\"a\"}", LocalRecord.class))
.isEqualTo(new LocalRecord("deserializer-a"));
}
@Test
public void testClassReflectionFilter() {
record Allowed(int a) {}
record Blocked(int b) {}
Gson gson =
new GsonBuilder()
.addReflectionAccessFilter(
c -> c == Allowed.class ? FilterResult.ALLOW : FilterResult.BLOCK_ALL)
.create();
String json = gson.toJson(new Allowed(1));
assertThat(json).isEqualTo("{\"a\":1}");
var exception = assertThrows(JsonIOException.class, () -> gson.toJson(new Blocked(1)));
assertThat(exception)
.hasMessageThat()
.isEqualTo(
"ReflectionAccessFilter does not permit using reflection for class "
+ Blocked.class.getName()
+ ". Register a TypeAdapter for this type or adjust the access filter.");
}
@Test
public void testReflectionFilterBlockInaccessible() {
Gson gson =
new GsonBuilder().addReflectionAccessFilter(c -> FilterResult.BLOCK_INACCESSIBLE).create();
var exception = assertThrows(JsonIOException.class, () -> gson.toJson(new PrivateRecord(1)));
assertThat(exception)
.hasMessageThat()
.isEqualTo(
"Constructor 'com.google.gson.functional.Java17RecordTest$PrivateRecord(int)' is not"
+ " accessible and ReflectionAccessFilter does not permit making it accessible."
+ " Register a TypeAdapter for the declaring type, adjust the access filter or"
+ " increase the visibility of the element and its declaring type.");
exception = assertThrows(JsonIOException.class, () -> gson.fromJson("{}", PrivateRecord.class));
assertThat(exception)
.hasMessageThat()
.isEqualTo(
"Constructor 'com.google.gson.functional.Java17RecordTest$PrivateRecord(int)' is not"
+ " accessible and ReflectionAccessFilter does not permit making it accessible."
+ " Register a TypeAdapter for the declaring type, adjust the access filter or"
+ " increase the visibility of the element and its declaring type.");
assertThat(gson.toJson(new PublicRecord(1))).isEqualTo("{\"i\":1}");
assertThat(gson.fromJson("{\"i\":2}", PublicRecord.class)).isEqualTo(new PublicRecord(2));
}
private record PrivateRecord(int i) {}
public record PublicRecord(int i) {}
/**
* Tests behavior when {@code java.lang.Record} is used as type for serialization and
* deserialization.
*/
@Test
public void testRecordBaseClass() {
record LocalRecord(int i) {}
assertThat(gson.toJson(new LocalRecord(1), Record.class)).isEqualTo("{}");
var exception = assertThrows(JsonIOException.class, () -> gson.fromJson("{}", Record.class));
assertThat(exception)
.hasMessageThat()
.isEqualTo(
"Abstract classes can't be instantiated! Adjust the R8 configuration or register an"
+ " InstanceCreator or a TypeAdapter for this type. Class name: java.lang.Record\n"
+ "See https://github.com/google/gson/blob/main/Troubleshooting.md#r8-abstract-class");
}
}