diff --git a/docs/release_notes.adoc b/docs/release_notes.adoc index ee96a41d16..421c34a138 100644 --- a/docs/release_notes.adoc +++ b/docs/release_notes.adoc @@ -16,6 +16,7 @@ include::include.adoc[] * Fix argument mismatch descriptions for varargs methods by expanding varargs instead of reporting `` spockPull:2315[] * Fix Pattern flags being dropped when `java.util.regex.Pattern` instances are used in Spock regex conditions spockIssue:2298[] * Fix `MockitoMockMaker` throws NPE on null object spockIssue:2337[] +* Fix Mock does not intercept package-private methods in OSGi correctly spockIssue:2384[] === Breaking Changes diff --git a/spock-core/src/main/java/org/spockframework/mock/runtime/ByteBuddyMockFactory.java b/spock-core/src/main/java/org/spockframework/mock/runtime/ByteBuddyMockFactory.java index 583a5fef62..eebd3fa55a 100644 --- a/spock-core/src/main/java/org/spockframework/mock/runtime/ByteBuddyMockFactory.java +++ b/spock-core/src/main/java/org/spockframework/mock/runtime/ByteBuddyMockFactory.java @@ -29,7 +29,6 @@ import net.bytebuddy.dynamic.Transformer; import net.bytebuddy.dynamic.loading.ClassInjector; import net.bytebuddy.dynamic.loading.ClassLoadingStrategy; -import net.bytebuddy.dynamic.loading.MultipleParentClassLoader; import net.bytebuddy.dynamic.scaffold.TypeValidation; import net.bytebuddy.implementation.FieldAccessor; import net.bytebuddy.implementation.FixedValue; @@ -96,19 +95,36 @@ class ByteBuddyMockFactory { * A mock is considered local, if all additional interfaces of the mock (including {@link ISpockMockObject}) are * loadable by the target class' classloader. * - * @param targetClass The to-be-mocked class + * @param targetClass The to-be-mocked class * @param additionalInterfaces Additional interfaces of the to-be-mocked type * @return true, if this is a local mock. Otherwise false */ @VisibleForTesting static boolean isLocalMock(Class targetClass, Collection> additionalInterfaces) { - // Inspired by https://github.com/mockito/mockito/blob/99426415c0ceb30e55216c3934854528c83f410e/mockito-core/src/main/java/org/mockito/internal/creation/bytebuddy/SubclassBytecodeGenerator.java#L165-L166 - ClassLoader cl = new MultipleParentClassLoader.Builder() - .appendMostSpecific(targetClass) - .appendMostSpecific(additionalInterfaces) - .appendMostSpecific(ISpockMockObject.class) - .build(); - return cl == targetClass.getClassLoader(); + ClassLoader targetLoader = targetClass.getClassLoader(); + if (targetLoader == null) { + targetLoader = ClassLoader.getSystemClassLoader(); + } + Class spockMockClass = loadClassIfAvailableInClassLoader(targetLoader, ISpockMockObject.class); + if (spockMockClass != ISpockMockObject.class) { + //The ISpockMockObject is not visible to the targetLoader, so we can't be local. + return false; + } + for (Class ifClass : additionalInterfaces) { + Class ifClassOfTarget = loadClassIfAvailableInClassLoader(targetLoader, ifClass); + if (ifClassOfTarget != ifClass) { + return false; + } + } + return true; + } + + private static Class loadClassIfAvailableInClassLoader(ClassLoader loader, Class clazz) { + try { + return loader.loadClass(clazz.getName()); + } catch (ClassNotFoundException e) { + return null; + } } Object createMock(IMockMaker.IMockCreationSettings settings) { diff --git a/spock-specs/src/test/groovy/org/spockframework/smoke/mock/osgi/OsgiPackagePrivateMemberMockSpec.groovy b/spock-specs/src/test/groovy/org/spockframework/smoke/mock/osgi/OsgiPackagePrivateMemberMockSpec.groovy new file mode 100644 index 0000000000..a05fb2c01f --- /dev/null +++ b/spock-specs/src/test/groovy/org/spockframework/smoke/mock/osgi/OsgiPackagePrivateMemberMockSpec.groovy @@ -0,0 +1,54 @@ +/* + * Copyright 2026 the original author or authors. + * + * 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 + * https://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 org.spockframework.smoke.mock.osgi + + +import spock.lang.Issue +import spock.lang.Specification +import spock.util.EmbeddedSpecRunner + +class OsgiPackagePrivateMemberMockSpec extends Specification { + def runner = new EmbeddedSpecRunner(new OsgiTestClassLoader(this.class.classLoader)) + + @SuppressWarnings('UnnecessaryQualifiedReference') + @Issue("https://github.com/spockframework/spock/issues/2384") + def "OSGi package private mock shall be mockable with ByteBuddy"() { + when: + def res = runner.runSpecBody(""" + def mock = Mock(org.spockframework.smoke.mock.osgi.testclasses.PkgPrivateMemberClass, mockMaker: spock.mock.MockMakers.byteBuddy) { + packagePrivate() >> "mocked" + } + + def "mock is called when invoked from Java code"() { + when: + def result = new org.spockframework.smoke.mock.osgi.testclasses.InvocationFromJava(mock).invoke() + + then: + result == "mocked" + } + + def "mock is called when invoked directly from Groovy"() { + when: + def result = mock.packagePrivate() + + then: + result == "mocked" + } +""") + + then: + res.testsSucceededCount == 2 + } +} diff --git a/spock-specs/src/test/groovy/org/spockframework/smoke/mock/osgi/OsgiTestClassLoader.groovy b/spock-specs/src/test/groovy/org/spockframework/smoke/mock/osgi/OsgiTestClassLoader.groovy new file mode 100644 index 0000000000..4411ed79bc --- /dev/null +++ b/spock-specs/src/test/groovy/org/spockframework/smoke/mock/osgi/OsgiTestClassLoader.groovy @@ -0,0 +1,63 @@ +/* + * Copyright 2026 the original author or authors. + * + * 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 + * https://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 org.spockframework.smoke.mock.osgi + +import groovy.transform.CompileStatic + +import static java.util.Objects.requireNonNull + +/** + * Fakes an OSGi like ClassLoader env, by not using the parent ClassLoader semantics. + */ +@CompileStatic +class OsgiTestClassLoader extends ClassLoader { + private final ClassLoader hostCl + + OsgiTestClassLoader(ClassLoader hostCl) { + super(null) + this.hostCl = requireNonNull(hostCl) + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!name.startsWith("org.spockframework.smoke.mock.osgi.testclasses")) { + return hostCl.loadClass(name) + } + super.loadClass(name, resolve) + } + + @Override + protected Class findClass(String name) throws ClassNotFoundException { + def clsFilePath = name + clsFilePath = clsFilePath.replace(".", "/") + clsFilePath += ".class" + def is = hostCl.getResourceAsStream(clsFilePath) + if (is == null) { + throw new ClassNotFoundException(name) + } + def clsData = is.getBytes() + return defineClass(name, clsData, 0, clsData.length) + } + + @Override + URL getResource(String name) { + return hostCl.getResource(name) + } + + @Override + Enumeration getResources(String name) throws IOException { + return hostCl.getResources(name) + } +} diff --git a/spock-specs/src/test/groovy/org/spockframework/smoke/mock/osgi/testclasses/InvocationFromJava.java b/spock-specs/src/test/groovy/org/spockframework/smoke/mock/osgi/testclasses/InvocationFromJava.java new file mode 100644 index 0000000000..f79c96a229 --- /dev/null +++ b/spock-specs/src/test/groovy/org/spockframework/smoke/mock/osgi/testclasses/InvocationFromJava.java @@ -0,0 +1,27 @@ +/* + * Copyright 2026 the original author or authors. + * + * 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 + * https://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 org.spockframework.smoke.mock.osgi.testclasses; + +public class InvocationFromJava { + PkgPrivateMemberClass obj; + + public InvocationFromJava(PkgPrivateMemberClass obj) { + this.obj = obj; + } + + public Object invoke() { + return obj.packagePrivate(); + } +} diff --git a/spock-specs/src/test/groovy/org/spockframework/smoke/mock/osgi/testclasses/PkgPrivateMemberClass.java b/spock-specs/src/test/groovy/org/spockframework/smoke/mock/osgi/testclasses/PkgPrivateMemberClass.java new file mode 100644 index 0000000000..4a63b42e32 --- /dev/null +++ b/spock-specs/src/test/groovy/org/spockframework/smoke/mock/osgi/testclasses/PkgPrivateMemberClass.java @@ -0,0 +1,22 @@ +/* + * Copyright 2026 the original author or authors. + * + * 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 + * https://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 org.spockframework.smoke.mock.osgi.testclasses; + +public class PkgPrivateMemberClass { + + String packagePrivate() { + return "original"; + } +}