How Does Mocking Work Internally? A Deep Dive into MockK Internals

Sagar MalhotraSagar Malhotra
5 min read

If you’ve ever written unit tests in Kotlin, you’ve probably used MockK. This famous, powerful, expressive mocking library is designed specifically for Kotlin. It feels like magic: you defineevery { someFunction() } returns value, and suddenly that function doesn’t work the same way they were originally defined. Instead, they will obey your test scenarios and react as you want them to. But have you ever wondered how it works behind the scenes?

In this article, we’ll break down what happens inside MockK, how it hooks into your code, how it stores return values, and how it uses bytecode manipulation to do its job. By the end, you’ll understand the secret sauce that powers mocking libraries like MockK.

What Is Mocking?

In unit testing, mocking means replacing real objects with “fake” ones that simulate the behavior of the real ones. These mocks can:

  1. Return controlled data (using returns)

  2. Throw exceptions (using throws)

  3. Track which methods were called (using verify)

This lets you isolate the unit of your code and and simulate various edge cases.


Example:

val userService = mockk<UserService>()
every { userService.getUserName() } returns "Alice"

This “every” line tells MockK: “Whenever someone calls getUserName() on this mocked userService object, just return "Alice".”

But how does it know to do that?

Note: The function call should be on a mocked object, like here userService was a mocked object created using mockk(). If you use this directly on a non-mocked object, or a Static function of a class, or an Object class function, this means the function is not called from a mocked object, and that might cause your tests to fail. You will soon understand why.

MockK Internals:

1. Creating the Mock:

When you write mockk<UserService>(), MockK doesn’t just return a regular object using the constructor of the class. It uses powerful libraries behind the scenes to create a "mocked" version of your class:

  • ByteBuddy: Generates or Manipulate classes at runtime by manipulating bytecode.

  • Objenesis: Instantiates objects without calling its constructors.

Understanding the working of these libraries are out of scope for today.

This gives MockK full control over what happens when any method on the mock object is called.

These are only to target JVM, similar stuff is used for other targets.

2. Recording every { ... } :

When you write:

every { userService.getUserName() } returns "Alice"

MockK enters recording mode using CallRecorder.startStubbing and then:

  • It intercepts the method call (getUserName()).

  • Even though we called the method, it doesn’t actually run. Instead, it is recorded inside of CallRecorder.

This ever {} returns us an answerOpportunity which is a simple interface using which we can provide what type of Answerwe want from the call. You might use it returns("expected value") to make the function return a specific value , or throws(Exception()) to simulate an error. You can even use answers {... } to define a whole block of code that should run.

3. Storing the Behavior:

MockK stores all this info in a chain of internal classes:

  • CallRecorder→ Holds the current list of recorded calls.

  • Matchers → Helps match actual calls to recorded calls. (Multiple matchers are there to serve their unique purposes.)

  • Answer→ Holds the response to return when a match is found.

These are used when we get an actual call to the functions. But, how do we know this is called and how do we override the originally defined behavior?

4. Intercepting Calls at Runtime

Here’s where things get wild.

When you actually call this in your test scenario:

val name = userService.getUserName()

This method doesn’t behave like a normal function call. The call is intercepted at the bytecode level by MockK.

  • MockK has already replaced the method body with a custom handler using ByteBuddy.

  • Instead of running the real code, this handleInvocation function passes the method call details to gatewayAccess.callrecorder().call(invocation).

  • The checks the call against the CallRecorder.

  • If it finds a match, it returns the corresponding answer.

When you create a mock object, ByteBuddy dynamically generates a special version of that class. Then, when you define behavior using every {}, ByteBuddy modifies the code of this generated mock object. It essentially tells the methods in the mock object to not execute their original logic (they don't have any in a pure mock anyway) but instead to hand over control to MockK's internal mechanisms. This behind-the-scenes code manipulation is what allows MockK to intercept calls to your mock objects.

Invoking the Mocked Function:

When your code under test actually calls a function on your mock object, it looks like a normal function call. However, because of ByteBuddy’s magic, this call is intercepted within the mock object. The execution is redirected to MockK’s internal handler, which is connected to the CallRecorder for that specific mock and it directly responds only with the answer you provided.

5. Verifying Calls

MockK also lets you verify that certain calls were made:

verify { userService.getUserName() }

When this line runs:

  • MockK switches into verification mode.

  • It checks the list of all method calls that were made on mocked object (which it records automatically during the test).

  • It then matches them against what you asked to verify.

If the call happened, verification passes. Otherwise, it throws an error of NoSuchInvocationFound.

Overall, it can be imagined like:

Some special types:

  1. Static methods: They can be mocked directly by using SomeClass.someStatic() as this SomeClass is not a mock class and ByteBuddy don’t know how to manipulate this. So, we need to first tell it that first!
    - Use mockkStatic(SomeJavaClass::class) to intercept static methods.

  2. Top-level functions: Kotlin top-level functions are compiled into static methods in a hidden class named after the file (e.g., MyFileKt), so same stuff applies here.
    - UsemockkStatic("com.example.MyFileKt") to intercept and replace these static methods at runtime.

  3. Object class functions: Similar stuff here, but these object are singleton, so a slight change to respect behavior.
    - UsemockkObject(MyObject) to intercept method calls.

We specifically mocked these classes, son make sure to unmock them after you are done using to avoid unexpected testcase failures in other tests.

Conclusion:

These APIs can change in the future, but the core idea of mocking will remain same and is similar in other mocking libraries like Mockit,o which also uses ByteBuddy and Objenesis for under-the-hood stuff. Understanding these internal mechanisms can give you a deeper appreciation for how mocking libraries work and help you write more effective and reliable tests. So, the next time you use MockK, remember this article(and me), and the powerful libraries working behind the scenes to make your testcases pass!

0
Subscribe to my newsletter

Read articles from Sagar Malhotra directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Sagar Malhotra
Sagar Malhotra