Skip to content

Commit 081b450

Browse files
committed
Fix Mock does not intercept package-private methods in OSGi correctly
We now do a real search in the mock targets ClassLoader to check if the Spock classes and additional interfaces are visible instead of asking the Parent ClassLoader hierarchy, which does not work in an OSGi environment. Fixes #2384
1 parent db681f8 commit 081b450

6 files changed

Lines changed: 186 additions & 9 deletions

File tree

docs/release_notes.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ include::include.adoc[]
1616
* Fix argument mismatch descriptions for varargs methods by expanding varargs instead of reporting `<too few arguments>` spockPull:2315[]
1717
* Fix Pattern flags being dropped when `java.util.regex.Pattern` instances are used in Spock regex conditions spockIssue:2298[]
1818
* Fix `MockitoMockMaker` throws NPE on null object spockIssue:2337[]
19+
* Fix Mock does not intercept package-private methods in OSGi correctly spockIssue:2384[]
1920

2021
=== Breaking Changes
2122

spock-core/src/main/java/org/spockframework/mock/runtime/ByteBuddyMockFactory.java

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
import net.bytebuddy.dynamic.Transformer;
3030
import net.bytebuddy.dynamic.loading.ClassInjector;
3131
import net.bytebuddy.dynamic.loading.ClassLoadingStrategy;
32-
import net.bytebuddy.dynamic.loading.MultipleParentClassLoader;
3332
import net.bytebuddy.dynamic.scaffold.TypeValidation;
3433
import net.bytebuddy.implementation.FieldAccessor;
3534
import net.bytebuddy.implementation.FixedValue;
@@ -96,19 +95,33 @@ class ByteBuddyMockFactory {
9695
* A mock is considered local, if all additional interfaces of the mock (including {@link ISpockMockObject}) are
9796
* loadable by the target class' classloader.
9897
*
99-
* @param targetClass The to-be-mocked class
98+
* @param targetClass The to-be-mocked class
10099
* @param additionalInterfaces Additional interfaces of the to-be-mocked type
101100
* @return true, if this is a local mock. Otherwise false
102101
*/
103102
@VisibleForTesting
104103
static boolean isLocalMock(Class<?> targetClass, Collection<Class<?>> additionalInterfaces) {
105-
// Inspired by https://github.com/mockito/mockito/blob/99426415c0ceb30e55216c3934854528c83f410e/mockito-core/src/main/java/org/mockito/internal/creation/bytebuddy/SubclassBytecodeGenerator.java#L165-L166
106-
ClassLoader cl = new MultipleParentClassLoader.Builder()
107-
.appendMostSpecific(targetClass)
108-
.appendMostSpecific(additionalInterfaces)
109-
.appendMostSpecific(ISpockMockObject.class)
110-
.build();
111-
return cl == targetClass.getClassLoader();
104+
ClassLoader targetLoader = targetClass.getClassLoader();
105+
Class<?> spockMockClass = loadClassIfAvailableInClassLoader(targetLoader, ISpockMockObject.class);
106+
if (spockMockClass != ISpockMockObject.class) {
107+
//The ISpockMockObject is not visible to the targetLoader, so we can't be local.
108+
return false;
109+
}
110+
for (Class<?> ifClass : additionalInterfaces) {
111+
Class<?> ifClassOfTarget = loadClassIfAvailableInClassLoader(targetLoader, ifClass);
112+
if (ifClassOfTarget != ifClass) {
113+
return false;
114+
}
115+
}
116+
return true;
117+
}
118+
119+
private static Class<?> loadClassIfAvailableInClassLoader(ClassLoader loader, Class<?> clazz) {
120+
try {
121+
return loader.loadClass(clazz.getName());
122+
} catch (ClassNotFoundException e) {
123+
return null;
124+
}
112125
}
113126

114127
Object createMock(IMockMaker.IMockCreationSettings settings) {
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Copyright 2026 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* https://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*
14+
*/
15+
package org.spockframework.smoke.mock.osgi
16+
17+
18+
import spock.lang.Issue
19+
import spock.lang.Specification
20+
import spock.util.EmbeddedSpecRunner
21+
22+
class OsgiPackagePrivateMemberMockSpec extends Specification {
23+
def runner = new EmbeddedSpecRunner(new OsgiTestClassLoader(this.class.classLoader))
24+
25+
@SuppressWarnings('UnnecessaryQualifiedReference')
26+
@Issue("https://github.com/spockframework/spock/issues/2384")
27+
def "OSGi package private mock shall be mockable with ByteBuddy"() {
28+
when:
29+
def res = runner.runSpecBody("""
30+
def mock = Mock(org.spockframework.smoke.mock.osgi.testclasses.PkgPrivateMemberClass) {
31+
packagePrivate() >> "mocked"
32+
}
33+
34+
def "mock is called when invoked from Java code"() {
35+
when:
36+
def result = new org.spockframework.smoke.mock.osgi.testclasses.InvocationFromJava(mock).invoke()
37+
38+
then:
39+
result == "mocked"
40+
}
41+
42+
def "mock is called when invoked directly from Groovy"() {
43+
when:
44+
def result = mock.packagePrivate()
45+
46+
then:
47+
result == "mocked"
48+
}
49+
""")
50+
51+
then:
52+
res.testsSucceededCount == 2
53+
}
54+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright 2026 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* https://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*
14+
*/
15+
package org.spockframework.smoke.mock.osgi
16+
17+
import groovy.transform.CompileStatic
18+
19+
import static java.util.Objects.requireNonNull
20+
21+
/**
22+
* Fakes an OSGi like ClassLoader env, by not using the parent ClassLoader semantics.
23+
*/
24+
@CompileStatic
25+
class OsgiTestClassLoader extends ClassLoader {
26+
private final ClassLoader hostCl
27+
28+
OsgiTestClassLoader(ClassLoader hostCl) {
29+
super(null)
30+
this.hostCl = requireNonNull(hostCl)
31+
}
32+
33+
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
34+
if (!name.startsWith("org.spockframework.smoke.mock.osgi.testclasses")) {
35+
return hostCl.loadClass(name)
36+
}
37+
super.loadClass(name, resolve)
38+
}
39+
40+
@Override
41+
protected Class<?> findClass(String name) throws ClassNotFoundException {
42+
def clsFilePath = name
43+
clsFilePath = clsFilePath.replace(".", "/")
44+
clsFilePath += ".class"
45+
def is = hostCl.getResourceAsStream(clsFilePath)
46+
if (is == null) {
47+
throw new ClassNotFoundException(name)
48+
}
49+
def clsData = is.getBytes()
50+
return defineClass(name, clsData, 0, clsData.length)
51+
}
52+
53+
URL getResource(String name) {
54+
return hostCl.getResource(name)
55+
}
56+
57+
Enumeration<URL> getResources(String name) throws IOException {
58+
return hostCl.getResources(name)
59+
}
60+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Copyright 2026 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* https://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*
14+
*/
15+
package org.spockframework.smoke.mock.osgi.testclasses;
16+
17+
public class InvocationFromJava {
18+
PkgPrivateMemberClass obj;
19+
20+
public InvocationFromJava(PkgPrivateMemberClass obj) {
21+
this.obj = obj;
22+
}
23+
24+
public Object invoke() {
25+
return obj.packagePrivate();
26+
}
27+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*
2+
* Copyright 2026 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* https://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*
14+
*/
15+
package org.spockframework.smoke.mock.osgi.testclasses;
16+
17+
public class PkgPrivateMemberClass {
18+
19+
String packagePrivate() {
20+
return "original";
21+
}
22+
}

0 commit comments

Comments
 (0)