From b0d2b46a1c5c1ad5087b0a3f0494cb2958fcd793 Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Sat, 12 Aug 2023 22:18:41 +0200 Subject: [PATCH] Add integration test for GraalVM Native Image --- .github/workflows/build.yml | 19 ++ graal-native-image-test/README.md | 19 ++ graal-native-image-test/pom.xml | 169 ++++++++++++ .../Java17RecordReflectionTest.java | 186 +++++++++++++ .../gson/native_test/ReflectionTest.java | 256 ++++++++++++++++++ .../META-INF/native-image/reflect-config.json | 101 +++++++ gson/pom.xml | 3 +- pom.xml | 7 + 8 files changed, 759 insertions(+), 1 deletion(-) create mode 100644 graal-native-image-test/README.md create mode 100644 graal-native-image-test/pom.xml create mode 100644 graal-native-image-test/src/test/java/com/google/gson/native_test/Java17RecordReflectionTest.java create mode 100644 graal-native-image-test/src/test/java/com/google/gson/native_test/ReflectionTest.java create mode 100644 graal-native-image-test/src/test/resources/META-INF/native-image/reflect-config.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 93cde7b55a..d04018591e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,3 +24,22 @@ jobs: - name: Build with Maven # This also runs javadoc:jar to detect any issues with the Javadoc generated during release run: mvn --batch-mode --update-snapshots --no-transfer-progress verify javadoc:jar + + native-image-test: + name: "GraalVM Native Image test" + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: "Set up GraalVM" + uses: graalvm/setup-graalvm@v1 + with: + java-version: '17' + distribution: 'graalvm' + # According to documentation in graalvm/setup-graalvm this is used to avoid rate-limiting issues + github-token: ${{ secrets.GITHUB_TOKEN }} + cache: 'maven' + - name: Build and run tests + # Only run tests in `graal-native-image-test` (and implicitly build and run tests in `gson`), + # everything else is covered already by regular build job above + run: mvn test --batch-mode --update-snapshots --no-transfer-progress --activate-profiles native-image-test --projects graal-native-image-test --also-make diff --git a/graal-native-image-test/README.md b/graal-native-image-test/README.md new file mode 100644 index 0000000000..b3b2b353c2 --- /dev/null +++ b/graal-native-image-test/README.md @@ -0,0 +1,19 @@ +# graal-native-image-test + +This Maven module contains integration tests for using Gson in a GraalVM Native Image. + +Execution requires using GraalVM as JDK, and can be quite resource intensive. Native Image tests are therefore not enabled by default and the tests are only executed as regular unit tests. To run Native Image tests, make sure your `PATH` and `JAVA_HOME` environment variables point to GraalVM and then run: + +``` +mvn clean test --activate-profiles native-image-test +``` + +Technically it would also be possible to directly configure Native Image test execution for the `gson` module instead of having this separate Maven module. However, maintaining the reflection metadata for the unit tests would be quite cumbersome and would hinder future changes to the `gson` unit tests because many of them just happen to use reflection, without all of them being relevant for Native Image testing. + +## Reflection metadata + +Native Image creation requires configuring which class members are accessed using reflection, see the [GraalVM documentation](https://www.graalvm.org/22.3/reference-manual/native-image/metadata/#specifying-reflection-metadata-in-json). + +The file [`reflect-config.json`](./src/test/resources/META-INF/native-image/reflect-config.json) contains this reflection metadata. + +You can also run with `-Dagent=true` to let the Maven plugin automatically generate a metadata file, see the [plugin documentation](https://graalvm.github.io/native-build-tools/latest/maven-plugin.html#agent-support-running-tests). diff --git a/graal-native-image-test/pom.xml b/graal-native-image-test/pom.xml new file mode 100644 index 0000000000..f93812172f --- /dev/null +++ b/graal-native-image-test/pom.xml @@ -0,0 +1,169 @@ + + + + 4.0.0 + + + com.google.code.gson + gson-parent + 2.10.2-SNAPSHOT + + graal-native-image-test + + + + 11 + **/Java17* + + + + + com.google.code.gson + gson + ${project.parent.version} + + + + + org.junit.jupiter + junit-jupiter + 5.10.0 + test + + + + com.google.truth + truth + test + + + + + + + + com.github.siom79.japicmp + japicmp-maven-plugin + + + true + + + + org.codehaus.mojo + animal-sniffer-maven-plugin + + + true + + + + org.apache.maven.plugins + maven-jar-plugin + + + true + + + + org.apache.maven.plugins + maven-deploy-plugin + + + true + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + default-testCompile + test-compile + + testCompile + + + + ${excludeTestCompilation} + + + + + + + + + + + JDK17 + + [17,) + + + 17 + + + + + + native-image-test + + false + + + + + org.graalvm.buildtools + native-maven-plugin + 0.9.24 + true + + + test-native + + test + + + + + + + org.codehaus.plexus + plexus-utils + + 3.5.1 + + + + + + + + diff --git a/graal-native-image-test/src/test/java/com/google/gson/native_test/Java17RecordReflectionTest.java b/graal-native-image-test/src/test/java/com/google/gson/native_test/Java17RecordReflectionTest.java new file mode 100644 index 0000000000..3eb9634083 --- /dev/null +++ b/graal-native-image-test/src/test/java/com/google/gson/native_test/Java17RecordReflectionTest.java @@ -0,0 +1,186 @@ +/* + * Copyright (C) 2023 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.native_test; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.TypeAdapter; +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 org.junit.jupiter.api.Test; + +@SuppressWarnings("UnusedVariable") // workaround for https://github.com/google/error-prone/issues/2713 +class Java17RecordReflectionTest { + public record PublicRecord(int i) { + } + + @Test + void testPublicRecord() { + Gson gson = new Gson(); + PublicRecord r = gson.fromJson("{\"i\":1}", PublicRecord.class); + assertThat(r.i).isEqualTo(1); + } + + // Private record has implicit private canonical constructor + private record PrivateRecord(int i) { + } + + @Test + void testPrivateRecord() { + Gson gson = new Gson(); + PrivateRecord r = gson.fromJson("{\"i\":1}", PrivateRecord.class); + assertThat(r.i).isEqualTo(1); + } + + @Test + void testLocalRecord() { + record LocalRecordDeserialization(int i) { + } + + Gson gson = new Gson(); + LocalRecordDeserialization r = gson.fromJson("{\"i\":1}", LocalRecordDeserialization.class); + assertThat(r.i).isEqualTo(1); + } + + @Test + void testLocalRecordSerialization() { + record LocalRecordSerialization(int i) { + } + + Gson gson = new Gson(); + assertThat(gson.toJson(new LocalRecordSerialization(1))).isEqualTo("{\"i\":1}"); + } + + private record RecordWithSerializedName(@SerializedName("custom-name") int i) { + } + + @Test + void testSerializedName() { + Gson gson = new Gson(); + RecordWithSerializedName r = gson.fromJson("{\"custom-name\":1}", RecordWithSerializedName.class); + assertThat(r.i).isEqualTo(1); + + assertThat(gson.toJson(new RecordWithSerializedName(2))).isEqualTo("{\"custom-name\":2}"); + } + + private record RecordWithCustomConstructor(int i) { + @SuppressWarnings("unused") + RecordWithCustomConstructor { + i += 5; + } + } + + @Test + void testCustomConstructor() { + Gson gson = new Gson(); + RecordWithCustomConstructor r = gson.fromJson("{\"i\":1}", RecordWithCustomConstructor.class); + assertThat(r.i).isEqualTo(6); + } + + private record RecordWithCustomAccessor(int i) { + @SuppressWarnings("UnusedMethod") + @Override + public int i() { + return i + 5; + } + } + + @Test + void testCustomAccessor() { + Gson gson = new Gson(); + assertThat(gson.toJson(new RecordWithCustomAccessor(2))).isEqualTo("{\"i\":7}"); + } + + @JsonAdapter(RecordWithCustomClassAdapter.CustomAdapter.class) + private record RecordWithCustomClassAdapter(int i) { + private static class CustomAdapter extends TypeAdapter { + @Override + public RecordWithCustomClassAdapter read(JsonReader in) throws IOException { + return new RecordWithCustomClassAdapter(in.nextInt() + 5); + } + + @Override + public void write(JsonWriter out, RecordWithCustomClassAdapter value) throws IOException { + out.value(value.i + 6); + } + } + } + + @Test + void testCustomClassAdapter() { + Gson gson = new Gson(); + RecordWithCustomClassAdapter r = gson.fromJson("1", RecordWithCustomClassAdapter.class); + assertThat(r.i).isEqualTo(6); + + assertThat(gson.toJson(new RecordWithCustomClassAdapter(1))).isEqualTo("7"); + } + + private record RecordWithCustomFieldAdapter( + @JsonAdapter(RecordWithCustomFieldAdapter.CustomAdapter.class) + int i + ) { + private static class CustomAdapter extends TypeAdapter { + @Override + public Integer read(JsonReader in) throws IOException { + return in.nextInt() + 5; + } + + @Override + public void write(JsonWriter out, Integer value) throws IOException { + out.value(value + 6); + } + } + } + + @Test + void testCustomFieldAdapter() { + Gson gson = new Gson(); + RecordWithCustomFieldAdapter r = gson.fromJson("{\"i\":1}", RecordWithCustomFieldAdapter.class); + assertThat(r.i).isEqualTo(6); + + assertThat(gson.toJson(new RecordWithCustomFieldAdapter(1))).isEqualTo("{\"i\":7}"); + } + + private record RecordWithRegisteredAdapter(int i) { + } + + @Test + void testCustomAdapter() { + Gson gson = new GsonBuilder() + .registerTypeAdapter(RecordWithRegisteredAdapter.class, new TypeAdapter() { + @Override + public RecordWithRegisteredAdapter read(JsonReader in) throws IOException { + return new RecordWithRegisteredAdapter(in.nextInt() + 5); + } + + @Override + public void write(JsonWriter out, RecordWithRegisteredAdapter value) throws IOException { + out.value(value.i + 6); + } + }) + .create(); + + RecordWithRegisteredAdapter r = gson.fromJson("1", RecordWithRegisteredAdapter.class); + assertThat(r.i).isEqualTo(6); + + assertThat(gson.toJson(new RecordWithRegisteredAdapter(1))).isEqualTo("7"); + } +} diff --git a/graal-native-image-test/src/test/java/com/google/gson/native_test/ReflectionTest.java b/graal-native-image-test/src/test/java/com/google/gson/native_test/ReflectionTest.java new file mode 100644 index 0000000000..3a2a5f48eb --- /dev/null +++ b/graal-native-image-test/src/test/java/com/google/gson/native_test/ReflectionTest.java @@ -0,0 +1,256 @@ +/* + * Copyright (C) 2023 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.native_test; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.InstanceCreator; +import com.google.gson.TypeAdapter; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ReflectionTest { + private static class ClassWithDefaultConstructor { + private int i; + } + + @Test + void testDefaultConstructor() { + Gson gson = new Gson(); + + ClassWithDefaultConstructor c = gson.fromJson("{\"i\":1}", ClassWithDefaultConstructor.class); + assertThat(c.i).isEqualTo(1); + } + + private static class ClassWithCustomDefaultConstructor { + private int i; + + private ClassWithCustomDefaultConstructor() { + i = 1; + } + } + + @Test + void testCustomDefaultConstructor() { + Gson gson = new Gson(); + + ClassWithCustomDefaultConstructor c = gson.fromJson("{\"i\":2}", ClassWithCustomDefaultConstructor.class); + assertThat(c.i).isEqualTo(2); + + c = gson.fromJson("{}", ClassWithCustomDefaultConstructor.class); + assertThat(c.i).isEqualTo(1); + } + + private static class ClassWithoutDefaultConstructor { + private int i = -1; + + // Explicit constructor with args to remove implicit no-args default constructor + private ClassWithoutDefaultConstructor(int i) { + this.i = i; + } + } + + /** + * Tests deserializing a class without default constructor. + * + *

This should use JDK Unsafe, and would normally require specifying {@code "unsafeAllocated": true} + * in the reflection metadata for GraalVM, though for some reason it also seems to work without it? Possibly + * because GraalVM seems to have special support for Gson, see its class {@code com.oracle.svm.thirdparty.gson.GsonFeature}. + */ + @Test + void testClassWithoutDefaultConstructor() { + Gson gson = new Gson(); + + ClassWithoutDefaultConstructor c = gson.fromJson("{\"i\":1}", ClassWithoutDefaultConstructor.class); + assertThat(c.i).isEqualTo(1); + + c = gson.fromJson("{}", ClassWithoutDefaultConstructor.class); + // Class is instantiated with JDK Unsafe, so field keeps its default value instead of assigned -1 + assertThat(c.i).isEqualTo(0); + } + + @Test + void testInstanceCreator() { + Gson gson = new GsonBuilder() + .registerTypeAdapter(ClassWithoutDefaultConstructor.class, new InstanceCreator() { + @Override + public ClassWithoutDefaultConstructor createInstance(Type type) { + return new ClassWithoutDefaultConstructor(-2); + } + }) + .create(); + + ClassWithoutDefaultConstructor c = gson.fromJson("{\"i\":1}", ClassWithoutDefaultConstructor.class); + assertThat(c.i).isEqualTo(1); + + c = gson.fromJson("{}", ClassWithoutDefaultConstructor.class); + // Uses default value specified by InstanceCreator + assertThat(c.i).isEqualTo(-2); + } + + private static class ClassWithFinalField { + // Initialize with value which is not inlined by compiler + private final int i = nonConstant(); + + private static int nonConstant() { + return "a".length(); // = 1 + } + } + + @Test + void testFinalField() { + Gson gson = new Gson(); + + ClassWithFinalField c = gson.fromJson("{\"i\":2}", ClassWithFinalField.class); + assertThat(c.i).isEqualTo(2); + + c = gson.fromJson("{}", ClassWithFinalField.class); + assertThat(c.i).isEqualTo(1); + } + + private static class ClassWithSerializedName { + @SerializedName("custom-name") + private int i; + } + + @Test + void testSerializedName() { + Gson gson = new Gson(); + ClassWithSerializedName c = gson.fromJson("{\"custom-name\":1}", ClassWithSerializedName.class); + assertThat(c.i).isEqualTo(1); + + c = new ClassWithSerializedName(); + c.i = 2; + assertThat(gson.toJson(c)).isEqualTo("{\"custom-name\":2}"); + } + + @JsonAdapter(ClassWithCustomClassAdapter.CustomAdapter.class) + private static class ClassWithCustomClassAdapter { + private static class CustomAdapter extends TypeAdapter { + @Override + public ClassWithCustomClassAdapter read(JsonReader in) throws IOException { + return new ClassWithCustomClassAdapter(in.nextInt() + 5); + } + + @Override + public void write(JsonWriter out, ClassWithCustomClassAdapter value) throws IOException { + out.value(value.i + 6); + } + } + + private int i; + + private ClassWithCustomClassAdapter(int i) { + this.i = i; + } + } + + @Test + void testCustomClassAdapter() { + Gson gson = new Gson(); + ClassWithCustomClassAdapter c = gson.fromJson("1", ClassWithCustomClassAdapter.class); + assertThat(c.i).isEqualTo(6); + + assertThat(gson.toJson(new ClassWithCustomClassAdapter(1))).isEqualTo("7"); + } + + private static class ClassWithCustomFieldAdapter { + private static class CustomAdapter extends TypeAdapter { + @Override + public Integer read(JsonReader in) throws IOException { + return in.nextInt() + 5; + } + + @Override + public void write(JsonWriter out, Integer value) throws IOException { + out.value(value + 6); + } + } + + @JsonAdapter(ClassWithCustomFieldAdapter.CustomAdapter.class) + private int i; + + private ClassWithCustomFieldAdapter(int i) { + this.i = i; + } + + private ClassWithCustomFieldAdapter() { + this(-1); + } + } + + @Test + void testCustomFieldAdapter() { + Gson gson = new Gson(); + ClassWithCustomFieldAdapter c = gson.fromJson("{\"i\":1}", ClassWithCustomFieldAdapter.class); + assertThat(c.i).isEqualTo(6); + + assertThat(gson.toJson(new ClassWithCustomFieldAdapter(1))).isEqualTo("{\"i\":7}"); + } + + private static class ClassWithRegisteredAdapter { + private int i; + + private ClassWithRegisteredAdapter(int i) { + this.i = i; + } + } + + @Test + void testCustomAdapter() { + Gson gson = new GsonBuilder() + .registerTypeAdapter(ClassWithRegisteredAdapter.class, new TypeAdapter() { + @Override + public ClassWithRegisteredAdapter read(JsonReader in) throws IOException { + return new ClassWithRegisteredAdapter(in.nextInt() + 5); + } + + @Override + public void write(JsonWriter out, ClassWithRegisteredAdapter value) throws IOException { + out.value(value.i + 6); + } + }) + .create(); + + ClassWithRegisteredAdapter c = gson.fromJson("1", ClassWithRegisteredAdapter.class); + assertThat(c.i).isEqualTo(6); + + assertThat(gson.toJson(new ClassWithRegisteredAdapter(1))).isEqualTo("7"); + } + + @Test + void testGenerics() { + Gson gson = new Gson(); + + List list = gson.fromJson("[{\"i\":1}]", new TypeToken>() {}); + assertThat(list).hasSize(1); + assertThat(list.get(0).i).isEqualTo(1); + + @SuppressWarnings("unchecked") + List list2 = (List) gson.fromJson("[{\"i\":1}]", TypeToken.getParameterized(List.class, ClassWithDefaultConstructor.class)); + assertThat(list2).hasSize(1); + assertThat(list2.get(0).i).isEqualTo(1); + } +} diff --git a/graal-native-image-test/src/test/resources/META-INF/native-image/reflect-config.json b/graal-native-image-test/src/test/resources/META-INF/native-image/reflect-config.json new file mode 100644 index 0000000000..e5220767d3 --- /dev/null +++ b/graal-native-image-test/src/test/resources/META-INF/native-image/reflect-config.json @@ -0,0 +1,101 @@ +[ +{ + "name":"com.google.gson.native_test.ReflectionTest$ClassWithDefaultConstructor", + "allDeclaredFields":true, + "allDeclaredConstructors": true +}, +{ + "name":"com.google.gson.native_test.ReflectionTest$ClassWithCustomDefaultConstructor", + "allDeclaredFields":true, + "allDeclaredConstructors": true +}, +{ + "name":"com.google.gson.native_test.ReflectionTest$ClassWithoutDefaultConstructor", + "allDeclaredFields":true, + "allDeclaredConstructors": true +}, +{ + "name":"com.google.gson.native_test.ReflectionTest$ClassWithFinalField", + "allDeclaredFields":true, + "allDeclaredConstructors": true +}, +{ + "name":"com.google.gson.native_test.ReflectionTest$ClassWithSerializedName", + "allDeclaredFields":true, + "allDeclaredConstructors": true +}, +{ + "name":"com.google.gson.native_test.ReflectionTest$ClassWithCustomFieldAdapter", + "allDeclaredFields":true, + "allDeclaredConstructors": true +}, + +{ + "name":"com.google.gson.native_test.ReflectionTest$ClassWithCustomClassAdapter$CustomAdapter", + "allDeclaredConstructors": true +}, +{ + "name":"com.google.gson.native_test.ReflectionTest$ClassWithCustomFieldAdapter$CustomAdapter", + "allDeclaredConstructors": true +}, + + + +{ + "name":"com.google.gson.native_test.Java17RecordReflectionTest$PublicRecord", + "allDeclaredFields":true, + "allPublicMethods": true, + "allDeclaredConstructors": true +}, +{ + "name":"com.google.gson.native_test.Java17RecordReflectionTest$PrivateRecord", + "allDeclaredFields":true, + "allPublicMethods": true, + "allDeclaredConstructors": true +}, +{ + "name":"com.google.gson.native_test.Java17RecordReflectionTest$RecordWithSerializedName", + "allDeclaredFields":true, + "allPublicMethods": true, + "allDeclaredConstructors": true +}, +{ + "name":"com.google.gson.native_test.Java17RecordReflectionTest$RecordWithCustomConstructor", + "allDeclaredFields":true, + "allPublicMethods": true, + "allDeclaredConstructors": true +}, +{ + "name":"com.google.gson.native_test.Java17RecordReflectionTest$RecordWithCustomAccessor", + "allDeclaredFields":true, + "allPublicMethods": true, + "allDeclaredConstructors": true +}, +{ + "name":"com.google.gson.native_test.Java17RecordReflectionTest$RecordWithCustomFieldAdapter", + "allDeclaredFields":true, + "allPublicMethods": true, + "allDeclaredConstructors": true +}, +{ + "name":"com.google.gson.native_test.Java17RecordReflectionTest$1LocalRecordDeserialization", + "allDeclaredFields":true, + "allPublicMethods": true, + "allDeclaredConstructors": true +}, +{ + "name":"com.google.gson.native_test.Java17RecordReflectionTest$1LocalRecordSerialization", + "allDeclaredFields":true, + "allPublicMethods": true, + "allDeclaredConstructors": true +}, + +{ + "name":"com.google.gson.native_test.Java17RecordReflectionTest$RecordWithCustomClassAdapter$CustomAdapter", + "allDeclaredConstructors": true +}, +{ + "name":"com.google.gson.native_test.Java17RecordReflectionTest$RecordWithCustomFieldAdapter$CustomAdapter", + "allDeclaredConstructors": true +} +] diff --git a/gson/pom.xml b/gson/pom.xml index 0d6b4a660a..70443acd99 100644 --- a/gson/pom.xml +++ b/gson/pom.xml @@ -93,6 +93,7 @@ org.apache.maven.plugins maven-compiler-plugin + default-compile @@ -102,6 +103,7 @@ + default-testCompile test-compile @@ -135,7 +137,6 @@ org.apache.maven.plugins maven-surefire-plugin - 3.1.2 release