MVI in Kotlin Multiplatform — part 3 (3 of 3)

Arkadii Ivanov
Bumble Tech

--

This is the concluding article in a series of three articles on MVI architectural pattern in Kotlin Multiplatform. In the previous two articles (part 1 and part 2) we reminded ourselves what MVI is, created the Kittens module for loading images of kittens, and integrated it into iOS and Android applications.

In this part, we are going to cover the Kittens module with unit and integration tests. We will learn about the current limitations of testing in Kotlin Multiplatform, how to overcome them and even make them work to our advantage.

The updated sample project is available in our GitHub:

Prologue

There is no doubt that testing is important. Of course, testing adds friction to the development of the functionality itself. But, on the other hand, writing tests:

  • allows you to check edge cases that can be difficult to catch manually;
  • prevents regression when adding new functionality, fixing bugs or refactoring;
  • forces code decomposition and structuring.

The last point, at first glance, may seem like a downside because decomposition takes time. However, it actually makes code more readable and so is beneficial in the long run.

Indeed, the ratio of time spent reading versus writing is well over 10 to 1. We are constantly reading old code as part of the effort to write new code. …[Therefore,] making it easy to read makes it easier to write.

“Indeed, the ratio of time spent reading versus writing is well over 10 to 1. We are constantly reading old code as part of the effort to write new code. …[Therefore,] making it easy to read makes it easier to write.”

― Robert C. Martin, Clean Code: A Handbook of Agile Software Craftsmanship

Kotlin Multiplatform empowers testing even more. It adds one very important point: each test is automatically executed against every supported target (platform). Therefore, if you support e.g. just Android and iOS, then you can actually multiply the number of tests you have by 2. And if at some point you support one more target, you are already covered because now you have x3 tests. Testing against all supported targets is especially important because there could be differences in code behaviour e.g. Kotlin/Native has a tricky memory model. JavaScript may sometimes also produce unexpected outcomes.

Before going any further it’s worth mentioning some limitations of testing in Kotlin Multiplatform. The biggest one is the lack of any real mocking framework for Kotlin/Native and Kotlin/JS. Whilst this can be perceived as a disadvantage, I actually consider it as a huge benefit. I struggled when I first started writing tests in Kotlin Multiplatform because I had to abstract every single dependency with an interface and write test implementations (fakes). This was time-consuming. But at some point, I realised that spending time on abstractions is an investment that results in a cleaner code. I noticed that any subsequent modifications of such code required less time. Why is that? Because the interaction of a class with its dependencies is not nailed (by mocks). In most cases, just updating test implementations is sufficient. There is no need to dive into each test method in order to update mocks. The result of this is that I no longer use mocks, even in normal Android development. For more on this point, I recommend the following article: Mocking is not practical — Use fakes (by Pravin Sonawane).

The plan

Let’s remember what we have in the Kittens module and what we should test:

  • There is KittenStore — the core component of the module. It’s implementation KittenStoreImpl contains most of the module’s business logic. This is the first thing we are going to test.
  • KittenComponent — the facade of the module and the integration point of all the internal components. We will cover this component with integration tests.
  • KittenView — a public interface representing the UI, a dependency of the KittenComponent
  • KittenDataSource — an internal interface for low-level network access which has platform-specific implementations for iOS and Android

To better understand this, here is the UML diagram of the Kitten module:

So, the plan is:

  1. Test the KittenStore
    - Write test implementation of KittenStore.Parser
    - Write test implementation of KittenStore.Network
    - Write unit tests for KittenStoreImpl
  2. Test the KittenComponent
    - Write test implementation of KittenDataSource
    - Write test implementation of KittenView
    - Write integration tests for KittenComponent
  3. Run tests
  4. Provide some conclusions

Test the KittenStore

So, the KittenStore interface has its implementing class, KittenStoreImpl, and this is what we are going to test. It has two dependencies (inner interfaces) defined right in the class itself. Let’s start with writing test implementations for them.

Test implementation of KittenStore.Network

This component is responsible for network requests. Here is what the interface looks like:

Before actually writing the test implementation of the Network interface we first need to answer one important question. What data is returned by the server? The answer is: the server returns a random bunch of image URLs, and a different set of URLs is returned every time. In real life, JSON format is used but since we have the Parser abstraction we don’t care much about the format in unit tests. The real implementation may switch threads under the hood, so subscribers may become frozen in Kotlin/Native. It would be good to simulate this behaviour as well, just to make sure the code handles this correctly.

So, our test implementation of the Network needs to have the following features:

  • it should return a non-empty bunch of different strings on every request
  • responses should be encoded as String
  • encoding format should be common between the Network and the Parser
  • it should be possible to simulate network errors (the Maybe should just complete without response)
  • it should be possible to simulate invalid response format (so the Parser could fail)
  • it should be possible to simulate response delays, so we can verify loading stages
  • the test implementation itself should be freezable in Kotlin/Native, just in case.

Test implementation may look like this:

TestKittenStoreNetwork has storage of strings (just like the real server) and is able to generate them. On every request, the current list of strings is encoded into a single string. If the “images” property is null, then Maybe will just complete, which should be treated as an error.

We also used the TestScheduler. This has one important feature — it freezes all callbacks coming through it. So, the observeOn operator being used together with TestScheduler will be freezing the downstream as well as all data passing through it. Just like in real life. But at the same time, no multithreading will be involved, so it’s easy to test and there are no flaky tests.

The TestScheduler also has a special “manual processing” mode. This mode allows us to test network delays.

Test implementation of KittenStore.Parser

This component is responsible for parsing network responses. Here is the interface:

So, whatever is loaded from the network should be transformed to a list of URLs. Our Network just concatenates strings separated by the semicolon symbol ‘;’, so the same format should be used here.

Here is the test implementation:

Like with the Network it uses the TestScheduler to freeze subscribers and verify their compatibility with the Kotlin/Native memory model. Parsing errors are simulated if the input string is empty.

Unit tests for KittenStoreImpl

Now we have test implementations for all dependencies, it’s time for unit tests. The complete unit tests can be found here, I will just show the initialisation part and a few tests.

The first step is to instantiate our test implementations:

Then, the KittenStoreImpl uses the mainScheduler so the next step is to override it:

And now some tests. The KittenStoreImpl should load images once created. Which means a network request should be performed, its response should be parsed and the state should be updated with the result. Here is the test:

What we did:

  • we generated images in the Network
  • we created a new instance of the KittenStoreImpl
  • we verified that the state contains the proper list of strings.

Another case we need to cover is when KittenStore.Intent.Reload is received. In this instance the list should be reloaded from the network:

Here are the steps:

  • generate initial images
  • create the KittenStoreImpl
  • generate new images
  • send Intent.Reload
  • verify that the state contains new images.

And lastly, let’s test the following case, there should be isLoading flag set in the State while images are loading:

We enabled manual processing for the Network TestScheduler, so it won’t automatically process. This allows us to verify the State while the response is pending.

Test the KittenComponent

As mentioned earlier, the KittenComponent is the integration point of the whole module. We can cover it with integration tests. Let’s look at its API:

There are two dependencies: KittenDataSource and KittenView. We need test implementations for all of them before we can start testing.

This diagram shows demonstrating the data flow within the module:

Test implementation of KittenDataSource

This component is responsible for low-level network requests. It has separate implementations for each target, and we need one more implementation for tests. Here is what the interface of the KittenDataSource looks like:

TheCatAPI supports pagination so it’s worth noting that I added the related arguments. Apart from this, it is very similar to the KittenStore Network we implemented earlier. The only difference is that we have to use JSON format, since we are testing real code in integration. So, let’s just reuse ideas here:

As before, we are generating different lists of strings that are encoded into a JSON array on every request. If there are no images generated or if request arguments are invalid, the Maybe will just complete without a response.

The kotlinx.serialization library is used for JSON encoding. The real KittenStoreParser also uses it for decoding.

Test implementation for KittenView

This is the last dependency we need to fake before we can start testing. Here is its interface:

It accepts Models and produces Events, so the test implementation is very simple:

We simply need to remember the last rendered Model as this will allow us to verify that the displayed Model is correct. We can also dispatch Events on behalf of the KittenView via its dispatch(Event) method.

Integration tests for KittenComponent

The complete test class can be found here, but again I will include just a few tests here.

As before, let’s start with instantiating dependencies and the KittenComponent:

There are two schedulers currently used internally: mainScheduler and computationScheduler. We need to override them:

Now we can write some tests. Let’s test the happy case first, once created images are loaded and displayed:

This test is pretty similar to what we wrote in unit tests for the KittenStore. Except now the whole module is involved. Here are the steps:

  • generate image URLs in the TestKittenDataSource
  • create and start the KittenComponent
  • very that image URLs reached the TestKittenView.

Another interesting case is that images need to be reloaded when the KittenView produces the RefreshTriggered event:

Here are the steps described:

  • generate initial image URLs
  • create and start the KittenComponent
  • generate new image URLs
  • dispatch Event.RefreshTriggered on behalf of the KittenView
  • verify that new image URLs reached the TestKittenView.

Run tests

To run all tests, we need to execute the following Gradle task:

./gradlew :shared:kittens:build

It will compile the module and run all tests against all supported targets, both in android and iosX64.

Here is the Jacoco test coverage report:

Conclusion

In this article we covered the Kittens module with unit and integration tests. The proposed module design allowed us to cover the following parts of the module:

  • the KittenStoreImpl — contains most of the business logic
  • the KittenStoreNetwork — responsible for high-level network requests
  • the KittenStoreParser — responsible for parsing network responses
  • all mappings and wirings.

The last point is very important and is possible thanks to MVI. The only responsibility of the UI is to render data and to dispatch events. All subscriptions and wirings are done inside the module. So, we can cover everything but rendering with common tests.

Such tests have the following advantages:

  • do not access platform-specific SDKs
  • executed very fast
  • not flaky
  • executed against all supported targets.

We were also able to verify the code for compatibility with the tricky Kotlin/Native memory model. This is very important as well because there is no compile-time safety. The code would just crash at runtime with exceptions that are difficult to debug.

I hope this will help you with your project. Thanks for reading my articles and don’t forget to follow me on Twitter!

If contributing to projects such as this is of interest to you, we are always looking for people to join the team. So, feel free to get in touch and find out more about joining the Bumble family!

MVI in Kotlin Multiplatform — Part 1
MVI in Kotlin Multiplatform — Part 2

Bonus exercise

If you want to experience the power of fakes or want to play with MVI, here are some practice tasks:

Refactor the KittenDataSource

There are two implementations of the KittenDataSource interface: one for Android and one for iOS. I mentioned that they are responsible for low-level network access. But actually they have one more role: they generate the endpoint URL based on input arguments “limit” and “page”. At the same time, we have the KittenStoreNetwork class that does nothing but delegate the call to the KittenDataSource.

Objective: to move the endpoint URL generation logic from KittenDataSourceImpl (Android and iOS) to KittenStoreNetwork. You need to change the KittenDataSource interface in the following way:

Once you’ve done so you will need to update the tests. The only class you will need to touch is the TestKittenDataSource.

Add pagination

TheCatAPI supports pagination so we can add this functionality for better user experience. You can start with adding a new Event.EndReached for the KittenView, after that the code will stop compiling. Then, step by step you will need to add the corresponding Intent.LoadMore and to handle it in the KittenStoreImpl. You will also need to update the KittenStoreImpl.Network interface in the following way:

Finally, you will need to update some test implementations, fix one or two existing tests and then write some new ones to cover the pagination.

--

--