18 minutes read

In the world of software testing, mocking is an essential technique used to simulate real objects in a controlled environment. This is particularly useful when you need to test components that interact with external systems like databases or servers. MockK is a popular library for mocking in Kotlin, allowing developers to create mocks and stubs for their unit tests.

What is Mocking?

Mocking is the process of creating a fake version of an object or system that mimics the behavior of real objects. This allows developers to test their code in isolation, without relying on external dependencies. By simulating these dependencies, you can focus on testing the logic of your code rather than dealing with the complexities of external systems, improving the speed of the tests, since they do not use the real object, but the simulated one (for example, if we want to simulate the connection to a database or a remote API.

Advantages of mocking

  • Isolation of Code: Ensures tests aren't influenced by external dependencies, making them reliable.

  • Reduced Complexity: Avoids complex test setups.

  • Control over Behaviors: Precisely define mock behaviors under various conditions.

Introducing MockK

MockK is a robust and flexible mocking library tailored for Kotlin. It offers Kotlin-specific features, such as handling coroutines and extension functions, and provides a concise and intuitive API. MockK supports Kotlin's features, including coroutines and extension functions, is actively maintained, and has a growing community.

Setting Up MockK

To include MockK in your project, add it as a dependency. Use the following for Gradle:

[versions]
mockk = "1.14.5"

[libraries]
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
testImplementation(libs.mockk)

You can check the last version here

MockK Example

Next, let's look at how to mock a server request. Suppose you have a WeatherService class that fetches weather data from an external API. In this example, we demonstrate how to use MockK to simulate and verify interactions with an external API. The goal is to test the WeatherService class independently from its dependencies by mocking the ApiClient interface. This helps in ensuring that the WeatherService behaves correctly without needing a real API call.

// Assuming these classes are defined somewhere in your codebase
data class Weather(val condition: String, val temperature: Int)

// Interface for the API client that the WeatherService depends on.
interface ApiClient {
    fun fetchWeather(city: String): Weather
}

// WeatherService uses the ApiClient to fetch weather data for a given city.
class WeatherService(private val apiClient: ApiClient) {
    fun getWeather(city: String): Weather {
        return apiClient.fetchWeather(city)
    }
}

You can mock the ApiClient to simulate a server response.

// Unit test for WeatherService using MockK to mock the ApiClient dependency.
class WeatherServiceTest {

    private val mockApiClient = mockk<ApiClient>() // Create a mock instance of ApiClient
    private val weatherService = WeatherService(mockApiClient) // Inject the mock into WeatherService

    @Test
    fun `test getWeather returns correct weather data`() {
        // Given: Set up the mock behavior and test data
        val city = "New York"
        val expectedWeather = Weather("Sunny", 25)

        every { mockApiClient.fetchWeather(city) } returns expectedWeather // Define how mock should behave
        
         // When: Call the method under test
        val actualWeather = weatherService.getWeather(city)
        
        // Then: Verify the result
        assertEquals(expectedWeather, actualWeather)
        
        // Verify: Ensure that the interactions with the mock were as expected
        verify { mockApiClient.fetchWeather(city) }
    }
}

In this example, you use mockk() to create a mock instance of ApiClient. First, define the mock and inject it into the object under test, WeatherService. Then, use the every {} block to specify the mock's behavior. Next, call the service method that relies on the mock to simulate the expected behavior. Use assertions to verify the outcome. You can also check method calls and their parameters using verify {}. This ensures that your test fails when the expected interactions don't occur.

Using Argument Matchers in MockK

Argument matchers in MockK allow you to set up flexible and precise conditions for your mocked methods. They help you define how the mocks should behave with different argument patterns. Here's a breakdown of several types of argument matchers and their usage:

Matching Specific Values:

By default, MockK verifies argument values by using the equals() method, which corresponds to == in Kotlin.

Example:

every { myMockObject.myMethod("foo") } returns 42

eq is an argument matcher used when you want to match an exact, specific value. It's essential when you need precision in mocking a method's response based on a known input. In MockK, eq is always used as the default argument matcher.

Example:

every { myMockObject.myMethod(eq("foo")) } returns 42

Here, myMockObject.myMethod will return 42 only when it is called with the exact string "foo".

Matching Any Value:

any matches any value of the expected type, regardless of the actual content. It’s useful when the specific value doesn’t matter for the test case. You can mix literal values with any and eq without issue.

Example:

every { myMockObject.myMethod(any()) } returns 42

In this case, myMethod returns 42 for any argument passed to it.

Matching Null Values:

isNull checks specifically for null arguments and ensures that the mocked method behaves accordingly.

Example:

every { myMockObject.myMethod(isNull<String>()) } returns 42  

This setup will return 42 if myMethod is called with null.

Matching Ranges:

range allows you to specify a range of acceptable values that a method might receive.

Example:

every { myMockObject.myMethod(range(10, 20)) } returns 42

myMethod will return 42 for any value between 10 and 20, inclusive.

Matching Collections:

match gives a way to assert that a collection satisfies certain conditions, providing flexibility when testing methods that handle lists or sets.

Example:

every { myMockObject.myMethod(match { it.contains("foo") && it.contains("bar") }) } returns 42

This will return 42 if the method is passed a collection that contains both "foo" and "bar".

Throwing Exceptions:

You can also configure your mock to throw exceptions, allowing you to test error handling in your application by simulating error conditions.

Example:

every { myMockObject.myMethod(any()) } throws RuntimeException("An error occurred")

This setup causes myMethod to throw a RuntimeException whenever called, regardless of the argument.

Return Unit:

If a function returns Unit, you can use the justRun construct:

Example:

justRun { myMockObject.myMethod(any()) }

Other ways to write justRun { myMockObject.myMethod(any()) }:

  • every {myMockObject.myMethod(any()) } just Runs

  • every { myMockObject.myMethod(any()) } returns Unit

  • every { myMockObject.myMethod(any()) } answers { Unit }

Spying

In the context of the MockK library, both mocks and spies are used to create test doubles, but they serve slightly different purposes:

  • Mock: A mock is a test double that simulates the behavior of a real object. You can configure it to return specific values when methods are called, and you can verify interactions with it. Mocks are typically used when you want complete control over the behavior of the object being tested.

  • Spy: A spy is a type of test double that wraps around a real object. It allows you to call the actual methods of the object while still being able to verify interactions and override specific method behaviors if needed. Spies are useful when you want to test the behavior of real objects but still need to verify certain interactions or modify specific behaviors.

The spyk() function in MockK is used to create a spy for an object. Unlike mocks, spies allow you to execute the real methods of the object, making them useful for testing while still enabling you to inspect and verify certain interactions.

Here is how you can utilize spying in a test:

// Class representing a weather service
class WeatherService {
    fun fetchWeather(city: String): String {
        // Imagine this method makes an API call to fetch weather data
        return "Sunny in $city"
    }
}

// Test class using MockK
class WeatherServiceTest {

    @Test
    fun `test fetchWeather using spy`() {
        // Given: Create a real instance of WeatherService
        val realWeatherService = WeatherService()

        // Create a spy of the real instance using spyk
        val weatherServiceSpy = spyk(realWeatherService)

        // When: Call the fetchWeather method
        val weather = weatherServiceSpy.fetchWeather("New York")

        // Then: Verify that the fetchWeather method was called with the correct argument
        verify { weatherServiceSpy.fetchWeather("New York") }

        // Assert the result is as expected
        assertEquals("Sunny in New York", weather)
    }
}

In this example, we create a real instance of WeatherService and then create a spy around it using spyk(). The spy allows us to call the actual fetchWeather method while still enabling us to verify that the method was called with the correct parameters. This way, we can test the real behavior of the method while keeping the ability to inspect interactions. This approach is useful when you want to test the actual implementation but still need to ensure certain interactions or override specific behaviors for testing purposes.

MockK Annotation Example

We have specific annotations for working with mock objects in tests:

  • @MockK: This annotation is used to create mock instances of classes or interfaces. It allows you to simulate the behavior of these dependencies without relying on their actual implementations.

  • @InjectMockKs: This annotation automatically injects the mocked dependencies (those annotated with @MockK) into the class under test. It facilitates the creation of the test subject by handling the injection of all required mocks.

  • @Spyk: This annotation is used to create a spy for a class, which is a partial mock that allows you to override specific methods while preserving the original behavior of others. It is particularly useful when you want to test certain interactions with a real instance of the class but still need to control or verify the behavior of specific methods. Using @Spyk simplifies the creation of spies by leveraging MockK's annotation processing capabilities.

For example, using @MockK to create a mock instance of ApiClient allows you to simulate its behavior in a controlled test environment. Then, @InjectMockKs automatically injects this mocked ApiClient into the WeatherService, managing the instantiation of WeatherService with the mocked ApiClient.

// Assuming these classes are defined somewhere in your codebase
data class Weather(val condition: String, val temperature: Int)

// Interface for an API client that WeatherService depends on
interface ApiClient {
    fun fetchWeather(city: String): Weather
}

// WeatherService class that uses ApiClient to fetch weather data
class WeatherService(private val apiClient: ApiClient) {
    fun getWeather(city: String): Weather {
        return apiClient.fetchWeather(city)
    }
}

// Test class using MockK annotations
@ExtendWith(MockKExtension::class)
class WeatherServiceTest {

    @MockK // Creates a mock instance of ApiClient
    private lateinit var mockApiClient: ApiClient

    @InjectMockKs // Automatically injects the mock into WeatherService
    private lateinit var weatherService: WeatherService

    @Test
    fun `test getWeather returns correct weather data`() {
        // Given: Setup the conditions for the test
        val city = "New York"
        val expectedWeather = Weather("Sunny", 25)

        every { mockApiClient.fetchWeather(city) } returns expectedWeather // Mock behavior setup

        // When: Call the method under test
        val actualWeather = weatherService.getWeather(city)

        // Then: Verify the result is as expected
        assertEquals(expectedWeather, actualWeather)

        // Verify: Ensure the mocked method was called with the correct parameters
        verify { mockApiClient.fetchWeather(city) }
    }
}

Additionally, we can use the @Spyk annotation, which allows MockK to automatically process annotations to create a spy for the WeatherService. This approach eliminates the need to manually create the spy using spyk(realWeatherService), as it is handled through annotation processing.. Here is how you can update the previous code:

// Class representing a weather service
class WeatherService {
    fun fetchWeather(city: String): String {
        // Imagine this method makes an API call to fetch weather data
        return "Sunny in $city"
    }
}

// Test class using MockK with Spyk annotation
@ExtendWith(MockKExtension::class)
class WeatherServiceTest {

    @Spyk // Create a spy on an instance of WeatherService
    private lateinit var weatherServiceSpy: WeatherService

    @Test
    fun `test fetchWeather using spy`() {
        // When: Call the fetchWeather method
        val weather = weatherServiceSpy.fetchWeather("New York")

        // Then: Verify that the fetchWeather method was called with the correct argument
        verify { weatherServiceSpy.fetchWeather("New York") }

        // Assert the result is as expected
        assertEquals("Sunny in New York", weather)
    }
}

Object Mocking

Object mocking involves creating mock versions of objects to simulate their behavior during testing. This is particularly useful when dealing with singleton objects or utility classes, where the object's state and behavior need to be controlled to test specific scenarios. By mocking these objects, you can ensure that your tests are predictable and isolated from external dependencies.

In MockK, object mocking is accomplished using the mockkObject function, which allows you to mock and control the behavior of Kotlin objects within your tests. This ensures that the methods on the objects return predefined values, making test outcomes reliable and repeatable.

  • mockkObject: This function sets up a mock on a specified object. Once an object is mocked, you can define specific behaviors for its methods using every.

  • unmockkObject: This function removes the mock setup for the object. It is crucial to unmock objects at the end of your tests to prevent interference with other test cases, keeping your tests isolated.

// Define WeatherService as an object
object WeatherService {
    fun fetchWeather(city: String): String {
        // Imagine this method makes an API call to fetch weather data
        return "Sunny in $city"
    }
}

// Test using MockK
class WeatherServiceTest {

    @Test
    fun `test fetchWeather using mockkObject`() {
        // Given: Mock the WeatherService object
        mockkObject(WeatherService)

        // Define the behavior for the fetchWeather method
        every { WeatherService.fetchWeather("New York") } returns "Sunny in New York"

        // When: Call the fetchWeather method
        val weather = WeatherService.fetchWeather("New York")

        // Then: Verify that the fetchWeather method was called with the correct argument
        verify { WeatherService.fetchWeather("New York") }

        // Assert the result that the method returns the expected string
        assertEquals("Sunny in New York", weather)

        // Finally: Clear the mock after the test to avoid interference with other tests
        unmockkObject(WeatherService)
    }
}

Conclusion

MockK is a sophisticated tool for Kotlin developers, enabling efficient simulation of dependencies within unit tests. By using mocks and spies, developers gain precise control over testing environments, facilitating the creation of isolated, reliable tests that truly measure only the code under scrutiny. This capability to define behavior, verify interactions, and handle exceptions is essential for crafting high-quality, maintainable software. With MockK, you ensure your Kotlin applications are robust, your codebase is clean, and your development process is streamlined.

36 learners liked this piece of theory. 42 didn't like it. What about you?
Report a typo