Back to Repositories

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

The test suite provides comprehensive coverage of Java 17 Record serialization and deserialization scenarios.

  • Custom name handling and serialization order
  • Primitive type handling and null value validation
  • Constructor behavior and exception cases
  • Accessor method interactions
  • Static field handling

Implementation Analysis

The testing approach uses JUnit4 framework with systematic validation of GSON’s Record handling capabilities.

Key patterns include:
  • Annotation processing (@SerializedName, @Expose, @JsonAdapter)
  • Exception handling verification
  • Custom serialization rules testing
  • Reflection access control validation

Technical Details

Testing infrastructure includes:
  • JUnit4 test runner
  • GSON builder configurations
  • Custom TypeAdapters and serializers
  • Reflection access filters
  • Truth assertion library for validations

Best Practices Demonstrated

The test suite exemplifies high-quality testing practices for Java serialization libraries.

  • Thorough edge case coverage
  • Explicit null handling validation
  • Comprehensive annotation testing
  • Clear test method organization
  • Proper exception verification

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");
  }
}