Skip to content

Commit 6bcd7dd

Browse files
committed
fix: don't call methods on mock objects while recording
Recording a value called toString() on it. When the value is a mock, that call is an invocation as far as the mocking framework is concerned, and mocking frameworks keep thread-local state between one call and the next. Mockito collects argument matchers on a stack and binds them to the next invocation it sees, and when() applies to the most recent invocation. A toString() from the agent lands in the middle of that handshake, so Mockito attributes the matcher or the stubbing to the agent's call instead of the test's. The result is that the stubbing a test set up silently never applies, and the stubbed method returns the Java type default. A test that does when(service.getData()).thenReturn("expectedValue"); fails with "expected:<expectedValue> but was:<null>" under the agent and passes without it. Where all arguments use matchers, Mockito reports "2 matchers expected, 1 recorded" instead. Recognize mocks and record a placeholder rather than asking them for a value. Detection goes through the mocking framework's own API, because the class name isn't a reliable signal: Mockito's inline mock maker (the default since 5.0) mocks a class by retransforming it, so a mock of com.example.Foo is an instance of com.example.Foo. The lookup is resolved against the class loader of the object being inspected and cached per class, so it costs nothing when no mocking framework is on the class path. The new test project has a control task that runs the same tests with no agent attached, so a failure under the agent is attributable. Assisted-by: Claude:claude-opus-5
1 parent 8b59cf9 commit 6bcd7dd

10 files changed

Lines changed: 456 additions & 0 deletions

File tree

‎agent/src/main/java/com/appland/appmap/output/v1/Value.java‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.alibaba.fastjson.serializer.ToStringSerializer;
55
import com.appland.appmap.config.Properties;
66
import com.appland.appmap.util.Logger;
7+
import com.appland.appmap.util.MockDetector;
78
import org.apache.commons.lang3.StringUtils;
89

910
/**
@@ -117,6 +118,11 @@ public Value freeze() {
117118
if (this.value != null) {
118119
if (Properties.DisableValue) {
119120
this.value = "< disabled >";
121+
} else if (MockDetector.isMock(this.value)) {
122+
// Calling toString() on a mock is an interaction, and it consumes the
123+
// mocking framework's pending thread-local state. That breaks the
124+
// user's stubbing, so record a placeholder instead.
125+
this.value = MockDetector.MOCK_VALUE;
120126
} else {
121127
try {
122128
this.value = this.value.toString();
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package com.appland.appmap.util;
2+
3+
import java.lang.reflect.Method;
4+
import java.util.ArrayList;
5+
import java.util.Arrays;
6+
import java.util.HashSet;
7+
import java.util.List;
8+
import java.util.Set;
9+
10+
import org.tinylog.TaggedLogger;
11+
12+
import com.appland.appmap.config.AppMapConfig;
13+
14+
/**
15+
* Recognizes mock objects, so that recording them doesn't call their methods.
16+
*
17+
* <p>
18+
* Calling a method on a mock is an interaction as far as the mocking framework
19+
* is concerned, and mocking frameworks keep thread-local state between one call
20+
* and the next. Mockito, for example, collects argument matchers on a
21+
* thread-local stack and binds them to the next invocation it sees. A stray
22+
* {@code toString()} from the agent binds that matcher to the wrong call, so
23+
* the stubbing the test set up never applies and the test fails with a value
24+
* the user never configured.
25+
*
26+
* <p>
27+
* Detection goes through the mocking framework's own API, because the class
28+
* name isn't a reliable signal: Mockito's inline mock maker mocks a class by
29+
* retransforming it, so a mock of {@code com.example.Foo} is an instance of
30+
* {@code com.example.Foo}.
31+
*/
32+
public class MockDetector {
33+
private static final TaggedLogger logger = AppMapConfig.getLogger(null);
34+
35+
/** Placeholder recorded in place of a mock's value. */
36+
public static final String MOCK_VALUE = "[mocked]";
37+
38+
/**
39+
* Types no mock maker will mock, so an instance of one is never a mock.
40+
*
41+
* <p>
42+
* These are the values AppMap records most often, and skipping the lookup for
43+
* them keeps the common path as cheap as it was before mock detection
44+
* existed. The list is deliberately conservative -- a type wrongly listed
45+
* here would reintroduce the bug this class exists to prevent -- and matches
46+
* what Mockito's {@code MockMaker.isTypeMockable} rejects. Note that enums
47+
* are absent on purpose: Mockito mocks those quite happily.
48+
*/
49+
private static final Set<Class<?>> NEVER_MOCKABLE = new HashSet<Class<?>>(Arrays.asList(
50+
String.class,
51+
Class.class,
52+
Boolean.class,
53+
Byte.class,
54+
Character.class,
55+
Short.class,
56+
Integer.class,
57+
Long.class,
58+
Float.class,
59+
Double.class));
60+
61+
/**
62+
* Mockito's mock-detection API, or null if it can't be reached for a given
63+
* class. Cached per class, so applications with no mocking framework on the
64+
* class path pay one {@link ClassValue} read and nothing else.
65+
*/
66+
private static final ClassValue<Method[]> MOCKITO = new ClassValue<Method[]>() {
67+
@Override
68+
protected Method[] computeValue(Class<?> type) {
69+
for (ClassLoader loader : candidateLoaders(type)) {
70+
Method[] api = resolve(loader);
71+
if (api != null) {
72+
return api;
73+
}
74+
}
75+
return null;
76+
}
77+
};
78+
79+
/**
80+
* Class loaders that might be able to see Mockito, most specific first.
81+
*
82+
* <p>
83+
* The object's own loader isn't enough. The inline mock maker mocks a class
84+
* by retransforming it, so a mock can be an instance of any mockable type --
85+
* including a bootstrap type like {@code java.util.ArrayList}, whose loader
86+
* can't see Mockito at all.
87+
*
88+
* <p>
89+
* Only loaders that are fixed for the lifetime of the JVM are considered,
90+
* because the result is cached per class. Including the thread context loader
91+
* would make the cached answer depend on whichever thread happened to record
92+
* the first value of that type.
93+
*/
94+
private static List<ClassLoader> candidateLoaders(Class<?> type) {
95+
List<ClassLoader> loaders = new ArrayList<ClassLoader>(3);
96+
addLoader(loaders, type.getClassLoader());
97+
addLoader(loaders, MockDetector.class.getClassLoader());
98+
try {
99+
addLoader(loaders, ClassLoader.getSystemClassLoader());
100+
} catch (Throwable t) {
101+
// Can happen very early in startup, or under a security manager.
102+
logger.debug(t, "couldn't get the system class loader");
103+
}
104+
return loaders;
105+
}
106+
107+
/** Adds a loader, skipping the bootstrap loader and duplicates. */
108+
private static void addLoader(List<ClassLoader> loaders, ClassLoader loader) {
109+
// The bootstrap loader can't see a mocking framework, so there's no point
110+
// asking it.
111+
if (loader != null && !loaders.contains(loader)) {
112+
loaders.add(loader);
113+
}
114+
}
115+
116+
private static Method[] resolve(ClassLoader loader) {
117+
try {
118+
Method mockingDetails = Class.forName("org.mockito.Mockito", false, loader)
119+
.getMethod("mockingDetails", Object.class);
120+
Method isMock = Class.forName("org.mockito.MockingDetails", false, loader)
121+
.getMethod("isMock");
122+
return new Method[] {mockingDetails, isMock};
123+
} catch (ClassNotFoundException e) {
124+
// Mockito isn't visible from this loader. Nothing to do, and not worth
125+
// logging: it's the normal case outside of tests.
126+
return null;
127+
} catch (Throwable t) {
128+
// A Mockito we don't recognize. Degrade to treating objects as
129+
// non-mocks rather than failing.
130+
logger.debug(t, "couldn't find Mockito's mock detection API");
131+
return null;
132+
}
133+
}
134+
135+
private MockDetector() {
136+
}
137+
138+
/**
139+
* Checks whether an object is a mock. Only inspects the mocking framework's
140+
* bookkeeping; never calls a method on {@code o} itself.
141+
*
142+
* @param o the object to check, may be null
143+
* @return true if {@code o} is known to be a mock
144+
*/
145+
public static boolean isMock(Object o) {
146+
if (o == null) {
147+
return false;
148+
}
149+
150+
Class<?> type = o.getClass();
151+
if (type.isArray() || NEVER_MOCKABLE.contains(type)) {
152+
return false;
153+
}
154+
155+
Method[] api = MOCKITO.get(type);
156+
if (api == null) {
157+
return false;
158+
}
159+
160+
try {
161+
Object details = api[0].invoke(null, o);
162+
return details != null && Boolean.TRUE.equals(api[1].invoke(details));
163+
} catch (Throwable t) {
164+
// If we can't tell, say no: recording a value is less important than not
165+
// guessing wrong about the user's objects.
166+
logger.debug(t, "failed to check whether {} is a mock", type.getName());
167+
return false;
168+
}
169+
}
170+
}

‎agent/test/mockito/.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
tmp/
2+
build/
3+
.gradle/

‎agent/test/mockito/appmap.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
name: mockito
2+
packages:
3+
- path: com.example.mockito

‎agent/test/mockito/build.gradle‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import org.gradle.api.tasks.testing.logging.TestLogEvent
2+
3+
plugins {
4+
id 'java'
5+
}
6+
7+
repositories {
8+
mavenCentral()
9+
}
10+
11+
def buildAppmapJar = "$System.env.AGENT_JAR"
12+
def buildAnnotationJar = "$System.env.ANNOTATION_JAR"
13+
14+
def agentJar = findProperty("agentJar") ?: buildAppmapJar
15+
def annotationJar = findProperty("annotationJar") ?: buildAnnotationJar
16+
17+
// Mockito 5 requires Java 11. On Java 8 stay on 4.x, where the default mock
18+
// maker is the subclass one rather than the inline one.
19+
//
20+
// Don't go below 5.19 on the Mockito 5.x line: the Byte Buddy bundled with
21+
// earlier releases rejects Java 25 class files ("Java 25 (69) is not supported
22+
// by the current version of Byte Buddy"), which the inline mock maker hits as
23+
// soon as it mocks anything.
24+
def jdkVersion = System.getProperty('java.version').split('\\.')[0] as Integer
25+
def mockitoVersion = findProperty('mockitoVersion') ?: (jdkVersion <= 8 ? '4.11.0' : '5.19.0')
26+
27+
dependencies {
28+
testImplementation 'junit:junit:4.13.2'
29+
testImplementation "org.mockito:mockito-core:${mockitoVersion}"
30+
testImplementation files(annotationJar)
31+
}
32+
33+
def commonTestConfig = {
34+
testClassesDirs = sourceSets.test.output.classesDirs
35+
classpath = sourceSets.test.runtimeClasspath
36+
37+
testLogging {
38+
events TestLogEvent.STANDARD_OUT
39+
exceptionFormat = 'full'
40+
}
41+
}
42+
43+
// Control task: the same tests with no agent attached. These tests have to
44+
// pass without AppMap, otherwise a failure under AppMap proves nothing.
45+
tasks.register('test_noagent', Test) {
46+
description = 'Runs the Mockito tests with no AppMap agent (control)'
47+
group = 'verification'
48+
49+
configure commonTestConfig
50+
}
51+
52+
tasks.register('test_appmap', Test) {
53+
description = 'Runs the Mockito tests under the AppMap agent'
54+
group = 'verification'
55+
56+
configure commonTestConfig
57+
58+
systemProperty 'appmap.config.file', "$projectDir/appmap.yml"
59+
60+
jvmArgs += [
61+
System.env.JAVA_OUTPUT_OPTIONS,
62+
"-javaagent:${agentJar}",
63+
"-Djava.util.logging.config.file=${System.env.JUL_CONFIG}"
64+
]
65+
}

‎agent/test/mockito/mockito.bats‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#!/usr/bin/env bats
2+
3+
load ../helper
4+
5+
setup_file() {
6+
export AGENT_JAR="$(find_agent_jar)"
7+
export ANNOTATION_JAR="$(find_annotation_jar)"
8+
_configure_logging
9+
}
10+
11+
setup() {
12+
cd "$(dirname "$BATS_TEST_FILENAME")"
13+
rm -rf tmp/appmap
14+
}
15+
16+
# The tests have to pass with no agent attached, otherwise a failure under the
17+
# agent doesn't tell us anything.
18+
@test "control: mockito tests pass with no agent" {
19+
run gradlew cleanTest test_noagent
20+
assert_success
21+
}
22+
23+
# Recording a mock used to call toString() on it, which Mockito counts as an
24+
# invocation. That stole pending argument matchers ("2 matchers expected, 1
25+
# recorded") and silently broke stubbing, so stubbed calls returned the Java
26+
# type default.
27+
@test "recording does not disturb mockito" {
28+
run gradlew cleanTest test_appmap
29+
assert_success
30+
refute_output --partial "InvalidUseOfMatchersException"
31+
}
32+
33+
# The flip side of the fix: a mock's value is a placeholder, because asking the
34+
# mock for its value is what caused the trouble.
35+
@test "a mock is recorded as a placeholder, not by calling it" {
36+
run gradlew cleanTest test_appmap --tests '*MockRecordingTest.testRecordingAMockAddsNoInteractions'
37+
assert_success
38+
39+
output="$(< tmp/appmap/junit/com_example_mockito_MockRecordingTest_testRecordingAMockAddsNoInteractions.appmap.json)"
40+
41+
# The mock really is recorded as a parameter, so this isn't vacuous...
42+
assert_json_contains \
43+
'[.events[] | select(.method_id=="identify") | .parameters[0].class] | first' 'MockitoMock'
44+
# ...and its value came from us, not from the mock.
45+
assert_json_eq \
46+
'[.events[] | select(.method_id=="identify") | .parameters[0].value] | first' '[mocked]'
47+
}

‎agent/test/mockito/settings.gradle‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
plugins {
2+
// Apply the foojay-resolver plugin to allow automatic download of JDKs
3+
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.4.0'
4+
}
5+
6+
rootProject.name = 'mockito'
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.example.mockito;
2+
3+
/** A public collaborator interface, the most common thing a project mocks. */
4+
public interface Calculator {
5+
int add(int a, int b);
6+
7+
String describe(String label);
8+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.example.mockito;
2+
3+
/**
4+
* Instrumented app code that handles mocks. AppMap records the parameters and
5+
* return values of these methods, which is how it comes into contact with the
6+
* mocks a test passes around.
7+
*/
8+
public class Service {
9+
private final Calculator calculator;
10+
11+
public Service(Calculator calculator) {
12+
this.calculator = calculator;
13+
}
14+
15+
public int compute(int a, int b) {
16+
return calculator.add(a, b);
17+
}
18+
19+
/**
20+
* Takes a mock and returns an int, so it can be called from inside the
21+
* argument list of a stubbed or verified call.
22+
*/
23+
public int tag(Calculator other) {
24+
return other == calculator ? 2 : 0;
25+
}
26+
27+
/** Like tag(), for a collaborator that isn't a Calculator. */
28+
public int tagAny(Object other) {
29+
return other == null ? 0 : 2;
30+
}
31+
32+
/** Takes a mock, so AppMap records a mock as a parameter. */
33+
public String identify(Calculator other) {
34+
return other == calculator ? "same" : "different";
35+
}
36+
}

0 commit comments

Comments
 (0)