diff --git a/afterburner-tests/README.md b/afterburner-tests/README.md
new file mode 100644
index 00000000..1c165ab4
--- /dev/null
+++ b/afterburner-tests/README.md
@@ -0,0 +1,105 @@
+# jackson-module-afterburner-tests
+
+Classpath-mode integration tests for `jackson-module-afterburner`. **This module is
+test-only and is never published.**
+
+## Why this module exists
+
+Afterburner's most valuable optimizations come from bytecode injection:
+`PropertyMutatorCollector`, `PropertyAccessorCollector`, and `CreatorOptimizer`
+generate custom mutator / accessor / instantiator classes in the *target bean's
+package* and install them on the bean's `ClassLoader`. This only works when the
+target package is not sealed.
+
+In Jackson 3.x every module descriptor — including afterburner's own test module
+— is a named JPMS module, and the JVM automatically seals every package inside a
+named module. As a result, every POJO defined inside `tools.jackson.module.afterburner`
+test sources fails the `MyClassLoader.canAddClassInPackageOf` check, and afterburner
+silently falls back to plain reflection. That means **none** of afterburner's
+injection paths are exercised by the in-tree afterburner test suite — the optimized
+code path is effectively dead weight in CI even though the tests appear to pass.
+
+This module closes that gap. It lives alongside the afterburner module but
+deliberately:
+
+- has **no `module-info.java`** in either main or test sources,
+- is configured (`useModulePath=false`) to run its tests on the classpath,
+- and therefore loads its test POJOs into the **unnamed module**, whose packages
+ are not sealed.
+
+Afterburner's injection pipeline runs on those POJOs normally, and the tests here
+verify via reflection on databind internals that each property was actually
+replaced with an `OptimizedSettableBeanProperty` / `OptimizedBeanPropertyWriter`
+and each default-creator POJO got an `OptimizedValueInstantiator`.
+
+## Do not add a `module-info.java` here
+
+Adding a module descriptor would put these test POJOs back into a named module,
+re-seal their packages, and silently turn this whole module into dead weight —
+same as the in-tree afterburner tests.
+
+If a future cleanup pass wants to "unify" the project layout by making every
+module JPMS-consistent, this one is the exception. Read this file before doing
+that.
+
+## Do not publish this module
+
+Several parent-POM plugin bindings are unbound or skipped in `pom.xml` so that
+`mvn install` on this module produces no jar, no SBOM, no Gradle module metadata,
+and no OSGi bundle. The module exists purely to run tests. If you rename an
+execution override in `pom.xml`, re-verify with:
+
+```
+./mvnw help:effective-pom -pl afterburner-tests
+```
+
+A typo in an execution id turns into silent dead config and leaves stale
+artifacts in `target/` rather than a build error.
+
+## What is covered
+
+- `PropertyMutatorCollector` — int, long, boolean, and reference-type
+ specializations for both public-field and setter-method access
+ (`MutatorSpecializationsTest`).
+- `PropertyAccessorCollector` — writer replacement on the serializer side
+ (`SerializerInjectionTest`).
+- `CreatorOptimizer` — default-constructor and static-factory positive cases,
+ plus property-based `@JsonCreator` negative case (`CreatorOptimizerTest`).
+- `MyClassLoader.defineClass` — transitively exercised by all of the above.
+
+## What is *not* covered
+
+- Private-class guard (`_classLoader != null && isPrivate(beanClass)`).
+- `setUseValueClassLoader(false)` toggle.
+- Concurrent deserializer construction.
+- Fallback path when afterburner correctly refuses to optimize (e.g. a bean
+ whose package really is sealed — the in-tree afterburner tests inadvertently
+ cover this).
+- GraalVM native-image disable path in `AfterburnerModule.setupModule`.
+
+## Known findings from this module
+
+### Afterburner parent-classloader caching silently broken on Java 9+ (#348)
+
+While writing `GeneratedClassCachingTest`, we discovered that Afterburner's
+parent-classloader cache — the mechanism that's supposed to make two
+independent `ObjectMapper` instances share a single generated mutator class
+per POJO — does not work on Java 9+ unless the JVM is launched with
+`--add-opens java.base/java.lang=ALL-UNNAMED`. `MyClassLoader` reflects into
+`ClassLoader#findLoadedClass` and `ClassLoader#defineClass`, both protected
+members of `java.lang.ClassLoader`; on modern JDKs the reflective access
+throws `InaccessibleObjectException`, which Afterburner catches silently and
+falls back to defining each generated class in a fresh throwaway loader.
+
+That's why this module's `pom.xml` sets
+`--add-opens java.base/java.lang=ALL-UNNAMED` on the surefire `argLine`.
+Without it, `testSameBeanAcrossMappersReusesSameMutatorClass` fails with two
+distinct `Class>` instances carrying identical fully-qualified names — the
+exact fingerprint of the leak. This is **not a test-environment quirk**; it
+affects every real Afterburner user on Java 9+. See issue #348 for the fix
+plan.
+
+## References
+
+- PR #347: rationale for the module's structure and initial coverage
+- Issue #348: afterburner classloader caching regression (surfaced here)
diff --git a/afterburner-tests/pom.xml b/afterburner-tests/pom.xml
new file mode 100644
index 00000000..d6abb965
--- /dev/null
+++ b/afterburner-tests/pom.xml
@@ -0,0 +1,150 @@
+
+ 4.0.0
+
+ tools.jackson.module
+ jackson-modules-base
+ 3.2.0-SNAPSHOT
+
+
+ jackson-module-afterburner-tests
+ Jackson module: Afterburner integration tests
+ jar
+
+ Classpath-mode integration tests for jackson-module-afterburner. This module
+intentionally has no module-info.java so that its test POJOs live in the unnamed module, where
+their packages are not sealed-by-module. That lets Afterburner exercise its bytecode injection
+paths (PropertyMutatorCollector, CreatorOptimizer, PropertyAccessorCollector) — paths that are
+gated off when the target bean sits in a named JPMS module. This module is test-only and is
+never published.
+
+
+
+ tools.jackson.module
+ jackson-module-afterburner
+ ${project.version}
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+
+
+ default-jar
+ none
+
+
+
+
+ org.apache.maven.plugins
+ maven-install-plugin
+
+ true
+
+
+
+ org.apache.maven.plugins
+ maven-deploy-plugin
+
+ true
+
+
+
+
+ org.apache.felix
+ maven-bundle-plugin
+
+
+ bundle-manifest
+ none
+
+
+ default-install
+ none
+
+
+
+
+
+ org.cyclonedx
+ cyclonedx-maven-plugin
+
+ true
+
+
+
+
+ org.gradlex
+ gradle-module-metadata-maven-plugin
+
+
+ default
+ none
+
+
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+
+
+ report
+ none
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+ false
+ @{argLine} --add-opens java.base/java.lang=ALL-UNNAMED
+
+
+
+
+
diff --git a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/AfterburnerInjectionTestBase.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/AfterburnerInjectionTestBase.java
new file mode 100644
index 00000000..1c759cbb
--- /dev/null
+++ b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/AfterburnerInjectionTestBase.java
@@ -0,0 +1,183 @@
+package tools.jackson.module.afterburner.inject;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+import tools.jackson.databind.BeanDescription;
+import tools.jackson.databind.DeserializationConfig;
+import tools.jackson.databind.SerializationConfig;
+import tools.jackson.databind.ValueDeserializer;
+import tools.jackson.databind.ValueSerializer;
+import tools.jackson.databind.deser.SettableBeanProperty;
+import tools.jackson.databind.deser.ValueDeserializerModifier;
+import tools.jackson.databind.deser.bean.BeanDeserializer;
+import tools.jackson.databind.json.JsonMapper;
+import tools.jackson.databind.module.SimpleModule;
+import tools.jackson.databind.ser.BeanPropertyWriter;
+import tools.jackson.databind.ser.ValueSerializerModifier;
+import tools.jackson.module.afterburner.AfterburnerModule;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+// Test utilities for verifying that Afterburner's bytecode injection pipeline actually
+// ran on a given POJO. Because OptimizedSettableBeanProperty and its serializer-side
+// counterpart are package-private inside afterburner, these checks rely on reflection
+// and simple-name matching rather than compile-time type references.
+abstract class AfterburnerInjectionTestBase
+{
+ /** Builds a mapper with AfterburnerModule + a capture hook that remembers
+ * the built deserializer and serializer for each bean class we touch. */
+ protected static Harness newHarness() {
+ return new Harness();
+ }
+
+ protected static final class Harness {
+ private final ConcurrentMap, ValueDeserializer>> desers = new ConcurrentHashMap<>();
+ private final ConcurrentMap, ValueSerializer>> sers = new ConcurrentHashMap<>();
+ final JsonMapper mapper;
+
+ Harness() {
+ SimpleModule capture = new SimpleModule("capture") {
+ private static final long serialVersionUID = 1L;
+ @Override
+ public void setupModule(SetupContext ctxt) {
+ super.setupModule(ctxt);
+ ctxt.addDeserializerModifier(new ValueDeserializerModifier() {
+ private static final long serialVersionUID = 1L;
+ @Override
+ public ValueDeserializer> modifyDeserializer(
+ DeserializationConfig cfg, BeanDescription.Supplier ref,
+ ValueDeserializer> d) {
+ desers.put(ref.getBeanClass(), d);
+ return d;
+ }
+ });
+ ctxt.addSerializerModifier(new ValueSerializerModifier() {
+ private static final long serialVersionUID = 1L;
+ @Override
+ public ValueSerializer> modifySerializer(
+ SerializationConfig cfg, BeanDescription.Supplier ref,
+ ValueSerializer> s) {
+ sers.put(ref.getBeanClass(), s);
+ return s;
+ }
+ });
+ }
+ };
+ this.mapper = JsonMapper.builder()
+ .addModule(new AfterburnerModule())
+ .addModule(capture)
+ .build();
+ }
+
+ ValueDeserializer> deserFor(Class> cls) {
+ ValueDeserializer> d = desers.get(cls);
+ assertNotNull(d, "no deserializer captured for " + cls.getName());
+ return d;
+ }
+
+ ValueSerializer> serFor(Class> cls) {
+ ValueSerializer> s = sers.get(cls);
+ assertNotNull(s, "no serializer captured for " + cls.getName());
+ return s;
+ }
+ }
+
+ /** Returns the `_propsByIndex` array from a bean deserializer. */
+ protected static SettableBeanProperty[] propsOf(ValueDeserializer> deser) {
+ if (!(deser instanceof BeanDeserializer)) {
+ throw new AssertionError("not a BeanDeserializer: " + deser.getClass().getName());
+ }
+ return (SettableBeanProperty[]) reflectField(deser, "_propsByIndex");
+ }
+
+ /** Returns the BeanPropertyWriter[] from a bean serializer, as a list. */
+ protected static List writersOf(ValueSerializer> ser) {
+ BeanPropertyWriter[] arr = (BeanPropertyWriter[]) reflectField(ser, "_props");
+ List out = new ArrayList<>(arr.length);
+ for (BeanPropertyWriter w : arr) {
+ out.add(w);
+ }
+ return out;
+ }
+
+ /** Walks the class chain of {@code instance} looking for a declared field
+ * named {@code fieldName}, sets it accessible, and returns its value.
+ * Throws a descriptive AssertionError if the field isn't found, picking
+ * the hypothesis (databind rename vs caller passed the wrong receiver)
+ * based on the class's package. */
+ protected static Object reflectField(Object instance, String fieldName) {
+ Class> origClass = instance.getClass();
+ Class> c = origClass;
+ while (c != null) {
+ try {
+ Field f = c.getDeclaredField(fieldName);
+ f.setAccessible(true);
+ return f.get(instance);
+ } catch (NoSuchFieldException ignore) {
+ c = c.getSuperclass();
+ } catch (IllegalAccessException e) {
+ throw new AssertionError("cannot read field '" + fieldName + "' on "
+ + origClass.getName(), e);
+ }
+ }
+ // Field genuinely not found anywhere in the class chain. Give the caller
+ // a hypothesis to start from rather than a bare "not found".
+ String pkg = origClass.getPackageName();
+ String hint;
+ if (pkg.startsWith("tools.jackson.databind")
+ || pkg.startsWith("tools.jackson.module.afterburner")) {
+ hint = "databind or afterburner may have renamed or removed it;"
+ + " update " + AfterburnerInjectionTestBase.class.getSimpleName()
+ + " to match.";
+ } else {
+ hint = "this looks like the wrong receiver type — '" + fieldName
+ + "' is an internal Jackson field and the caller passed an"
+ + " instance of " + origClass.getName() + ".";
+ }
+ throw new AssertionError("field '" + fieldName + "' not found on "
+ + origClass.getName() + " (walked up full class chain) — " + hint);
+ }
+
+ /** True if `prop`'s class chain contains Afterburner's OptimizedSettableBeanProperty. */
+ protected static boolean isOptimizedProperty(SettableBeanProperty prop) {
+ return afterburnerClassChainIncludes(prop.getClass(), "OptimizedSettableBeanProperty");
+ }
+
+ /** True if `writer`'s class chain contains Afterburner's OptimizedBeanPropertyWriter. */
+ protected static boolean isOptimizedWriter(BeanPropertyWriter writer) {
+ return afterburnerClassChainIncludes(writer.getClass(), "OptimizedBeanPropertyWriter");
+ }
+
+ /** Walks the superclass chain of {@code cls} looking for a class whose simple
+ * name is {@code simpleName}. Used to recognize Afterburner's package-private
+ * optimized types without importing them. */
+ protected static boolean classChainIncludes(Class> cls, String simpleName) {
+ Class> c = cls;
+ while (c != null) {
+ if (simpleName.equals(c.getSimpleName())) {
+ return true;
+ }
+ c = c.getSuperclass();
+ }
+ return false;
+ }
+
+ /** Like {@link #classChainIncludes} but additionally requires the matched
+ * class to live inside an afterburner package. Guards against false positives
+ * from unrelated classes that happen to share a simple name. */
+ protected static boolean afterburnerClassChainIncludes(Class> cls, String simpleName) {
+ Class> c = cls;
+ while (c != null) {
+ if (simpleName.equals(c.getSimpleName())
+ && c.getPackageName().startsWith("tools.jackson.module.afterburner")) {
+ return true;
+ }
+ c = c.getSuperclass();
+ }
+ return false;
+ }
+}
diff --git a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/CreatorOptimizerTest.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/CreatorOptimizerTest.java
new file mode 100644
index 00000000..4f7c8f27
--- /dev/null
+++ b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/CreatorOptimizerTest.java
@@ -0,0 +1,117 @@
+package tools.jackson.module.afterburner.inject;
+
+import org.junit.jupiter.api.Test;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import tools.jackson.databind.deser.ValueInstantiator;
+import tools.jackson.databind.deser.bean.BeanDeserializer;
+import tools.jackson.databind.deser.std.StdValueInstantiator;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+// Covers CreatorOptimizer's main branches:
+//
+// 1. Default public constructor -> creator replaced with generated subclass.
+// 2. Public static factory method -> creator replaced with generated subclass
+// (the same generated-bytecode path, different stack manipulation).
+// 3. Property-based @JsonCreator -> CreatorOptimizer bails out
+// (canCreateFromObjectWith() is true); the plain StdValueInstantiator stays.
+//
+// Together these guard against (a) CreatorOptimizer silently breaking for one
+// of its two supported paths, and (b) CreatorOptimizer accidentally running on
+// an unsupported shape (which would turn into a deserialization failure at
+// runtime rather than a clean fallback).
+public class CreatorOptimizerTest extends AfterburnerInjectionTestBase
+{
+ public static class DefaultCtorBean {
+ public int a;
+ public String b;
+ }
+
+ public static class FactoryMethodBean {
+ public int a;
+ public String b;
+
+ // Private default ctor forces databind to pick up the static factory.
+ // The @JsonCreator annotation with no args is how databind recognizes a
+ // no-arg static method as a "default creator" — without the annotation
+ // databind would fall through to looking for a default constructor.
+ private FactoryMethodBean() { }
+
+ @JsonCreator
+ public static FactoryMethodBean create() {
+ return new FactoryMethodBean();
+ }
+ }
+
+ public static class PropertyCreatorBean {
+ public final int a;
+ public final String b;
+
+ @JsonCreator
+ public PropertyCreatorBean(@JsonProperty("a") int a, @JsonProperty("b") String b) {
+ this.a = a;
+ this.b = b;
+ }
+ }
+
+ private final Harness h = newHarness();
+
+ @Test
+ public void testCreatorReplacedForDefaultCtor() throws Exception
+ {
+ DefaultCtorBean bean = h.mapper.readValue("{\"a\":1,\"b\":\"hi\"}", DefaultCtorBean.class);
+ assertEquals(1, bean.a);
+ assertEquals("hi", bean.b);
+
+ ValueInstantiator inst = instantiatorFor(DefaultCtorBean.class);
+ // OptimizedValueInstantiator is the abstract base CreatorOptimizer produces
+ // subclasses of — it can only appear in the chain if CreatorOptimizer ran.
+ assertTrue(classChainIncludes(inst.getClass(), "OptimizedValueInstantiator"),
+ "CreatorOptimizer did not replace the ValueInstantiator; got "
+ + inst.getClass().getName());
+ }
+
+ @Test
+ public void testCreatorReplacedForStaticFactory() throws Exception
+ {
+ FactoryMethodBean bean = h.mapper.readValue("{\"a\":1,\"b\":\"hi\"}",
+ FactoryMethodBean.class);
+ assertEquals(1, bean.a);
+ assertEquals("hi", bean.b);
+
+ ValueInstantiator inst = instantiatorFor(FactoryMethodBean.class);
+ assertTrue(classChainIncludes(inst.getClass(), "OptimizedValueInstantiator"),
+ "CreatorOptimizer did not replace the ValueInstantiator for a static"
+ + " factory creator; got " + inst.getClass().getName());
+ }
+
+ @Test
+ public void testCreatorNotReplacedForPropertyBasedCreator() throws Exception
+ {
+ // Deserialization must still work end-to-end, just without CreatorOptimizer
+ // wrapping (it bails out because canCreateFromObjectWith() is true).
+ PropertyCreatorBean bean = h.mapper.readValue("{\"a\":1,\"b\":\"hi\"}",
+ PropertyCreatorBean.class);
+ assertEquals(1, bean.a);
+ assertEquals("hi", bean.b);
+
+ ValueInstantiator inst = instantiatorFor(PropertyCreatorBean.class);
+ assertFalse(classChainIncludes(inst.getClass(), "OptimizedValueInstantiator"),
+ "CreatorOptimizer should NOT wrap a property-based @JsonCreator POJO,"
+ + " but the generated class " + inst.getClass().getName()
+ + " extends OptimizedValueInstantiator");
+ assertEquals(StdValueInstantiator.class, inst.getClass(),
+ "fallback should be the plain StdValueInstantiator; got "
+ + inst.getClass().getName());
+ }
+
+ private ValueInstantiator instantiatorFor(Class> cls) {
+ BeanDeserializer bd = (BeanDeserializer) h.deserFor(cls);
+ ValueInstantiator inst = (ValueInstantiator) reflectField(bd, "_valueInstantiator");
+ assertNotNull(inst);
+ return inst;
+ }
+}
diff --git a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/GeneratedClassCachingTest.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/GeneratedClassCachingTest.java
new file mode 100644
index 00000000..a71ecfb7
--- /dev/null
+++ b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/GeneratedClassCachingTest.java
@@ -0,0 +1,120 @@
+package tools.jackson.module.afterburner.inject;
+
+import org.junit.jupiter.api.Test;
+
+import tools.jackson.databind.deser.SettableBeanProperty;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+// Caching semantics for Afterburner's generated mutator classes. A regression in
+// this area would not break functional behavior but would waste class metaspace
+// and pin classloaders — so the only way to catch it is to directly inspect the
+// generated-class identity.
+//
+// What's asserted:
+//
+// 1. Within a single mapper, all OptimizedSettableBeanProperty instances for
+// the *same* POJO share the *same* BeanPropertyMutator class. Afterburner
+// generates one mutator class per POJO and reuses it across every
+// optimized property of that POJO.
+//
+// 2. Across two independent mappers (each with its own AfterburnerModule
+// instance), the mutator class for the same POJO is the *same* Class
+// instance. This proves the generated class is cached on the bean's
+// parent classloader and not regenerated per mapper — a regression would
+// cause classloader bloat under load as short-lived mappers each leaked a
+// distinct generated class.
+//
+// 3. Two *different* POJOs — even ones with identical field shapes — get
+// distinct generated mutator classes. Afterburner keys cache entries on
+// the bean class name, not on bytecode shape. A regression that conflated
+// them would silently deserialize one POJO's JSON into the other's fields.
+public class GeneratedClassCachingTest extends AfterburnerInjectionTestBase
+{
+ public static class FooBean {
+ public int a;
+ public String b;
+ }
+
+ // Structurally identical to FooBean, intentionally — same field names and
+ // types. Afterburner must still generate a distinct mutator for it because
+ // the generated class name is derived from the bean class name.
+ public static class BarBean {
+ public int a;
+ public String b;
+ }
+
+ @Test
+ public void testMutatorSharedAcrossPropertiesOfSameBean() throws Exception
+ {
+ Harness h = newHarness();
+ h.mapper.readValue("{\"a\":1,\"b\":\"x\"}", FooBean.class);
+
+ SettableBeanProperty[] props = propsOf(h.deserFor(FooBean.class));
+ assertEquals(2, props.length);
+
+ Object mutatorA = reflectField(props[0], "_propertyMutator");
+ Object mutatorB = reflectField(props[1], "_propertyMutator");
+ assertNotNull(mutatorA);
+ assertNotNull(mutatorB);
+ assertSame(mutatorA.getClass(), mutatorB.getClass(),
+ "all properties of the same POJO should share one mutator class; got "
+ + mutatorA.getClass().getName() + " vs "
+ + mutatorB.getClass().getName());
+ }
+
+ @Test
+ public void testSameBeanAcrossMappersReusesSameMutatorClass() throws Exception
+ {
+ Harness h1 = newHarness();
+ Harness h2 = newHarness();
+
+ h1.mapper.readValue("{\"a\":1,\"b\":\"x\"}", FooBean.class);
+ h2.mapper.readValue("{\"a\":2,\"b\":\"y\"}", FooBean.class);
+
+ Class> mutatorClass1 = mutatorClassFor(h1, FooBean.class);
+ Class> mutatorClass2 = mutatorClassFor(h2, FooBean.class);
+
+ assertSame(mutatorClass1, mutatorClass2,
+ "two independent mappers should share the same cached mutator class "
+ + "for FooBean; got " + mutatorClass1.getName() + " vs "
+ + mutatorClass2.getName() + " — afterburner may be regenerating "
+ + "classes instead of caching them on the parent classloader");
+ }
+
+ @Test
+ public void testDifferentBeansGetDistinctMutatorClasses() throws Exception
+ {
+ Harness h = newHarness();
+ h.mapper.readValue("{\"a\":1,\"b\":\"x\"}", FooBean.class);
+ h.mapper.readValue("{\"a\":2,\"b\":\"y\"}", BarBean.class);
+
+ Class> fooMutator = mutatorClassFor(h, FooBean.class);
+ Class> barMutator = mutatorClassFor(h, BarBean.class);
+
+ assertNotSame(fooMutator, barMutator,
+ "structurally identical but distinct POJOs must get distinct mutator "
+ + "classes (otherwise their property ordering could collide); "
+ + "both got " + fooMutator.getName());
+
+ // Extra sanity: the generated class names should reflect the bean class names.
+ assertTrue(fooMutator.getName().contains("FooBean"),
+ "expected FooBean's mutator class name to mention FooBean; got "
+ + fooMutator.getName());
+ assertTrue(barMutator.getName().contains("BarBean"),
+ "expected BarBean's mutator class name to mention BarBean; got "
+ + barMutator.getName());
+ }
+
+ /** Reads the `_propertyMutator` field off the first property of the captured
+ * deserializer and returns its runtime class. */
+ private static Class> mutatorClassFor(Harness h, Class> beanClass) {
+ SettableBeanProperty[] props = propsOf(h.deserFor(beanClass));
+ assertTrue(props.length > 0, "no properties for " + beanClass.getSimpleName());
+ Object mutator = reflectField(props[0], "_propertyMutator");
+ assertNotNull(mutator,
+ "property '" + props[0].getName() + "' has no _propertyMutator — "
+ + "afterburner did not install a generated mutator");
+ return mutator.getClass();
+ }
+}
diff --git a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/MutatorSpecializationsTest.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/MutatorSpecializationsTest.java
new file mode 100644
index 00000000..391f0779
--- /dev/null
+++ b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/MutatorSpecializationsTest.java
@@ -0,0 +1,117 @@
+package tools.jackson.module.afterburner.inject;
+
+import org.junit.jupiter.api.Test;
+
+import tools.jackson.databind.deser.SettableBeanProperty;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+// Verifies each of PropertyMutatorCollector's primitive-type specializations — int,
+// long, boolean, and object/String — runs end-to-end for both public-field access
+// and setter-method access. A regression in any individual code-generation path
+// (e.g. a bad ByteBuddy template, a wrong descriptor, a missing bytecode branch)
+// should surface here.
+public class MutatorSpecializationsTest extends AfterburnerInjectionTestBase
+{
+ public static class IntFieldBean {
+ public int value;
+ }
+ public static class LongFieldBean {
+ public long value;
+ }
+ public static class BooleanFieldBean {
+ public boolean value;
+ }
+ public static class StringFieldBean {
+ public String value;
+ }
+
+ public static class IntSetterBean {
+ private int value;
+ public int getValue() { return value; }
+ public void setValue(int v) { this.value = v; }
+ }
+ public static class LongSetterBean {
+ private long value;
+ public long getValue() { return value; }
+ public void setValue(long v) { this.value = v; }
+ }
+ public static class BooleanSetterBean {
+ private boolean value;
+ public boolean isValue() { return value; }
+ public void setValue(boolean v) { this.value = v; }
+ }
+ public static class StringSetterBean {
+ private String value;
+ public String getValue() { return value; }
+ public void setValue(String v) { this.value = v; }
+ }
+
+ private final Harness h = newHarness();
+
+ @Test
+ public void testIntField() throws Exception {
+ IntFieldBean b = h.mapper.readValue("{\"value\":42}", IntFieldBean.class);
+ assertEquals(42, b.value);
+ assertAllPropsOptimized(IntFieldBean.class);
+ }
+
+ @Test
+ public void testLongField() throws Exception {
+ LongFieldBean b = h.mapper.readValue("{\"value\":9999999999}", LongFieldBean.class);
+ assertEquals(9999999999L, b.value);
+ assertAllPropsOptimized(LongFieldBean.class);
+ }
+
+ @Test
+ public void testBooleanField() throws Exception {
+ BooleanFieldBean b = h.mapper.readValue("{\"value\":true}", BooleanFieldBean.class);
+ assertTrue(b.value);
+ assertAllPropsOptimized(BooleanFieldBean.class);
+ }
+
+ @Test
+ public void testStringField() throws Exception {
+ StringFieldBean b = h.mapper.readValue("{\"value\":\"hi\"}", StringFieldBean.class);
+ assertEquals("hi", b.value);
+ assertAllPropsOptimized(StringFieldBean.class);
+ }
+
+ @Test
+ public void testIntSetter() throws Exception {
+ IntSetterBean b = h.mapper.readValue("{\"value\":42}", IntSetterBean.class);
+ assertEquals(42, b.getValue());
+ assertAllPropsOptimized(IntSetterBean.class);
+ }
+
+ @Test
+ public void testLongSetter() throws Exception {
+ LongSetterBean b = h.mapper.readValue("{\"value\":9999999999}", LongSetterBean.class);
+ assertEquals(9999999999L, b.getValue());
+ assertAllPropsOptimized(LongSetterBean.class);
+ }
+
+ @Test
+ public void testBooleanSetter() throws Exception {
+ BooleanSetterBean b = h.mapper.readValue("{\"value\":true}", BooleanSetterBean.class);
+ assertTrue(b.isValue());
+ assertAllPropsOptimized(BooleanSetterBean.class);
+ }
+
+ @Test
+ public void testStringSetter() throws Exception {
+ StringSetterBean b = h.mapper.readValue("{\"value\":\"hi\"}", StringSetterBean.class);
+ assertEquals("hi", b.getValue());
+ assertAllPropsOptimized(StringSetterBean.class);
+ }
+
+ private void assertAllPropsOptimized(Class> cls) {
+ SettableBeanProperty[] props = propsOf(h.deserFor(cls));
+ assertTrue(props.length > 0, "no properties for " + cls.getSimpleName());
+ for (SettableBeanProperty p : props) {
+ assertTrue(isOptimizedProperty(p),
+ cls.getSimpleName() + "." + p.getName() + " not optimized: "
+ + p.getClass().getName());
+ }
+ }
+}
diff --git a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/SerializerInjectionTest.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/SerializerInjectionTest.java
new file mode 100644
index 00000000..d7b9afb6
--- /dev/null
+++ b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/SerializerInjectionTest.java
@@ -0,0 +1,54 @@
+package tools.jackson.module.afterburner.inject;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import tools.jackson.databind.ser.BeanPropertyWriter;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+// Verifies Afterburner's serializer-side bytecode injection runs end-to-end:
+// PropertyAccessorCollector replaces each BeanPropertyWriter with an
+// OptimizedBeanPropertyWriter subclass whose generated accessor reads the field
+// directly instead of going through reflection. This is the only test in this
+// module that touches the serializer side; deser-side injection is covered by
+// MutatorSpecializationsTest.
+public class SerializerInjectionTest extends AfterburnerInjectionTestBase
+{
+ public static class SerPojo {
+ public int intField;
+ public long longField;
+ public boolean boolField;
+ public String stringField;
+
+ public SerPojo() { }
+
+ public SerPojo(int i, long l, boolean b, String s) {
+ intField = i; longField = l; boolField = b; stringField = s;
+ }
+ }
+
+ @Test
+ public void testWriterInjectionRuns() throws Exception
+ {
+ Harness h = newHarness();
+
+ // Emit JSON through the optimized serializer and sanity-check the output.
+ String json = h.mapper.writeValueAsString(new SerPojo(1, 2L, true, "x"));
+ assertTrue(json.contains("\"intField\":1"));
+ assertTrue(json.contains("\"longField\":2"));
+ assertTrue(json.contains("\"boolField\":true"));
+ assertTrue(json.contains("\"stringField\":\"x\""));
+
+ // Every writer on the serializer side should be Afterburner-optimized.
+ List writers = writersOf(h.serFor(SerPojo.class));
+ assertEquals(4, writers.size());
+ for (BeanPropertyWriter w : writers) {
+ assertTrue(isOptimizedWriter(w),
+ "ser writer '" + w.getName() + "' not optimized (is "
+ + w.getClass().getName() + "); PropertyAccessorCollector"
+ + " did not run on this POJO");
+ }
+ }
+}
diff --git a/pom.xml b/pom.xml
index 5de73cbd..ea8ef52d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -24,6 +24,8 @@ not datatype, data format, or JAX-RS provider modules.
afterburner
+
+ afterburner-tests
android-record
blackbird
guice
diff --git a/release-notes/VERSION b/release-notes/VERSION
index 9691ec62..2f172c75 100644
--- a/release-notes/VERSION
+++ b/release-notes/VERSION
@@ -28,7 +28,10 @@ Active maintainers:
`java.lang.IllegalStateException`: Multiple definitions of method getBody found
(reported by @bxvs888)
(fix by @cowtowncoder, w/ Claude code)
-#343: Improve Afterburner testing, access checks
+#343: (afterburner) Improve Afterburner testing, access checks
+ (fix by @cowtowncoder, w/ Claude code)
+#347: (afterburner) Added module "afterburner-tests" for better Afterburner-in-classpath
+ testing
(fix by @cowtowncoder, w/ Claude code)
- Removed project Android SDK level overrides, use defaults (ASDK 34)
(affects `android-records` module's verification; was validating ASDK 26)