Spy — a test object that wraps a real instance and records information about each call: which methods were called, with what arguments, and how many times. Unlike a mock, a spy uses the real implementation of the wrapped object — calls go through the actual code, and the spy only records the facts. After the test is executed, the developer checks the spy’s records: “Was the sendAnalytics method called three times?”. Learn more in the Android Testing Guide.
Key Takeaways
Spy — a wrapper around a real object that intercepts all method calls and records them. The real logic of the object executes: if the method saves data, computes a value, or makes a request — everything happens as usual. Additionally, the spy records metadata: method name, arguments, call count, execution time. The term is part of the Meszaros (2007) classification and is described in detail in Martin Fowler’s article “Mocks Aren’t Stubs”.
Key Difference from a Mock — a mock completely replaces the object with a test stub; all methods do nothing by default. A spy wraps an existing object: all methods work as usual by default, but are also recorded. This distinction is fundamental: a mock isolates the tested code from reality, while a spy preserves reality and allows you to observe it. The choice between them depends on what is being tested.
A spy is the right choice — if the tested code modifies the state of a real object, and the test must both verify the result (state) and ensure that calls were made in the correct order. A mock is not suitable because it does not execute the real implementation. A stub is not suitable because it does not record calls. A spy is the only test double that simultaneously preserves real logic and provides information about calls.
Mock — complete isolation. If the test should not depend on the real object’s implementation (for example, a database or network client), use a mock. A mock ensures that no call reaches the real component. This is safe and predictable. The downside: a mock does not execute real logic, so if the tested code relies on a return value, it must be explicitly configured via when/stub.
Spy — real logic + observation. If the tested code interacts with an object whose logic is important for the test, and not just data — use a spy. For example, an AnalyticsTracker that collects events and periodically sends them. The test verifies that events were added to the buffer, and after sending, the buffer was cleared. A mock cannot verify this because it does not execute the tracker’s real logic.
| Scenario | Spy | Mock |
|---|---|---|
| Real logic needed | Yes | No (stub) |
| Call verification | Yes (count, arguments) | Yes (count, arguments) |
| Partial stubbing | Yes (some methods spy, others stub) | No (all methods stubs) |
| Risk of side effects | High (real code) | None |
| Speed | Lower (real logic) | Higher (stubs) |
| Readability | Lower (harder to understand what’s real) | Higher (everything is explicit) |
Spy for everything — using a spy instead of a mock for all tests is a mistake. A spy executes real code that may have side effects: writing to a file, sending HTTP, changing global state. If the tested module calls a method of a spy object that makes an HTTP request, the test becomes an integration test, not a unit test. Rule: if a spy wraps an object with I/O operations — it’s no longer a unit test. Use mocks to isolate I/O, and spies only for in-memory objects without external effects.
Mockito.spy() — the classic way to create a spy in Java/Kotlin projects. spy() takes a real object and returns a wrapper. All calls are delegated to the real object by default, and the results are recorded. After the test, you can use verify() to check the call count and arguments. For methods that should return test data, use doReturn/when — this is called “partial mocking”.
class AnalyticsReporterTest {
private val realTracker = AnalyticsTracker()
private val spyTracker = Mockito.spy(realTracker)
fun test_event_tracked() {
val event = AnalyticsEvent("login")
spyTracker.track(event)
Mockito.verify(spyTracker).track(event)
assertEquals(1, spyTracker.getBufferedCount())
}
fun test_track_with_exception() {
Mockito.doThrow(RuntimeException("network"))
.when(spyTracker).flush()
spyTracker.track(AnalyticsEvent("login"))
assertTrue(spyTracker.hasPendingEvents())
}
}
MockK.spyk() — an alternative for Kotlin projects with better support for coroutines and sealed classes. MockK.spyk() creates a spy, analogous to Mockito.spy(). It supports coVerify for suspend functions and every for partial stubbing. Unlike Mockito, MockK does not support spies for final classes (all classes in Kotlin are final by default) — you need to either open the class (open) or use an interface.
class LoginUseCaseTest {
private val realRepo = UserRepository()
private val spyRepo = spyk(realRepo)
private val useCase = LoginUseCase(spyRepo)
fun test_login_calls_save() = runTest {
every { spyRepo.getUser(any()) } returns User("test")
val result = useCase.login("test", "pass")
coVerify { spyRepo.saveLoginTime(any()) }
assertTrue(result.isSuccess)
}
}
Partial stubbing — a powerful but dangerous technique. You can create a spy of an object and override (stub) only some methods, leaving the rest real. Example: a spy repository where getUser() returns test data, while saveUser() actually saves to an in-memory list. This allows you to combine the benefits of stubs (controlled data) and spies (real logic). The downside: test readability suffers — it’s not obvious which methods are real and which are stubs.
OCMock for Objective-C — a library that supports creating spy objects via niceMock. OCMock intercepts method calls using the Objective-C runtime and records them. After the test, verify is called. OCMock supports spies for any object (all methods in Objective-C are dynamic), which gives it an advantage over Swift, where spies are only possible through protocols.
// Creating a spy for a real object
AnalyticsTracker *realTracker = [[AnalyticsTracker alloc] init];
AnalyticsTracker *spy = [OCMockObject partialMockForObject:realTracker];
// Running the test
[spy trackEvent:@"login"];
// Verification
[[spy verify] trackEvent:@"login"];
XCTAssertEqual([realTracker eventCount], 1);
Swift protocol-based spy — Swift does not have Objective-C runtime reflection, so spies are created manually. A test structure implements a protocol and internally calls the real object while simultaneously recording calls. This requires more code, but is fully controllable and type-safe. Manual spies do not require external libraries and do not use runtime — everything is checked at compile time.
protocol AnalyticsProtocol {
func trackEvent(name: String)
}
final class SpyAnalytics: AnalyticsProtocol {
private let real: AnalyticsProtocol
private var events: [String] = []
init(real: AnalyticsProtocol) {
self.real = real
}
func trackEvent(name: String) {
events.append(name)
real.trackEvent(name: name)
}
func verifyTracked(name: String) -> Bool {
return events.contains(name)
}
}
When to use OCMock vs manual spy — for Objective-C code, use OCMock (less boilerplate). For Swift, manual spies through protocols are preferred. A manual spy gives full control over call recording, requires no reflection, and works with value types (structs). The only downside is that you need to keep the spy class code synchronized with the protocol when adding new methods.
Analytics verification — the most common use case for spies. In production code, analytics calls are scattered throughout the application: login, logout, purchase, error. The test creates a spy wrapper for AnalyticsTracker, executes a scenario (login, view product, add to cart, purchase), and checks that all required events were sent in the correct order. A mock is not suitable because AnalyticsTracker contains buffering and sending logic.
Timers and schedulers — testing code that uses Handler (Android) or Timer (iOS) is difficult due to real time. A spy wrapper for Scheduler records which tasks were scheduled and with what delay. The test creates a spy of the real Handler, performs an action, and checks that Handler.postDelayed(runnable, delay) was called with the correct delay. The real task is not executed — the spy intercepts and records the call.
Logging and debug information — in production, logs may be disabled or written to a file. A spy wrapper for Logger records all messages in an in-memory list that the test checks after execution. This allows verifying that the correct message is written on error without cluttering the console. Manual spies for Logger are especially useful on iOS, where OSLog has no test API.
Call order verification — some scenarios require a strict order of operations: open connection, send data, close connection. Mockito allows checking the order via InOrder.verify(). A spy does the same but preserves real execution. If both the order and the result of each step (the connection actually opened) matter — use a spy, not a mock.
Frequently Asked Questions
Spy wraps a real object and executes its logic, additionally recording calls. Mock completely replaces the object with a stub — no real logic is executed. A spy preserves behavior, a mock does not. Choose a spy when the real object’s work matters; choose a mock when you need to isolate the test from an external dependency.
When a spy wrapper leads to real I/O operations. If a spy wraps an object that writes to a file, sends HTTP, or reads from disk — the test is no longer a unit test. Second case: the test only checks the return value without caring about calls — here a stub is sufficient, and a spy is redundant. Third: the code relies on the internal state of the spy — this is a fragile test.
Yes, via spyk() — the equivalent of Mockito.spy(). MockK.spyk() creates a spy around a real object, supports every for partial stubbing and coVerify/coroutinesVerify for suspend functions. Limitation: does not work with final classes (needs open or interface). For Java classes, MockK also supports spyk() but requires the @MockKJvmInline annotation.
Technically, no. Mock is a stub that contains no real implementation. A spy, by definition, wraps a real object. In Mockito, you cannot turn a mock into a spy. But you can do the opposite: create a spy and override some methods via doReturn/when (partial mocking). This gives behavior similar to a mock for selected methods of the spy object.
Yes, it does. In Swift, there is no dynamic proxying like in Java/Kotlin. To create a spy, you need a protocol that both the production class and the spy class implement. A Swift protocol-based spy is a manual implementation that takes a real object, delegates calls to it, and records metadata. Alternative: the Cuckoo library, which generates spy classes via SourceKit.
Summary
We will develop a mobile application turnkey
IT Sectr creates iOS and Android applications for startups and businesses since 2017. We will advise you and propose the best solution.
Read also