How to think about fakes and mocks to make your testing easier
Avoid being blocked by unmatched input to the mock with fake and leverage power of mock when needed
While building your app, you implement the database, API client and other third-party solutions. If you follow the proper structure, you end up with abstractions for every data provider you use. A side-effect of abstractions is easier testability of all the components. Everything is done as should be and writing tests is one of the tasks of every proper developer.
Mocking framework is a toolbox part of every developer and should be used. I used to apply it almost everywhere and it was not the best solution. Here is why the overusing mocking framework can be a bad idea:
- you must learn the syntax, conventions and limitations of the framework
- stubbing can get challenging with not trivial implementations
- multiple mocks in one test can get overwhelming, complex and unreadable in test cases
- mocking a lot of implementations can give you a false sense that everything is fine (but it is not)
- over-reliance on third-party framework
If you overuse the mocking framework, you can end up debugging the stubs and not making any progress on your real issue. I did it multiple times, when instead of solving the issue I ended up trying to tinker with the mocked stubs or hitting some new limitation of the mocking library. It took quite some time to find the right solution or it got embarrassing.
In realm of unit tests, we try to test the smallest units of logic and everything else should not interfere with the target of the test. Here, the mocks can do more harm than good. We do not need them in the most cases. A simple fake or dummy would resolve the whole situation.
Fake is simplified implementation of the original implementation. Dummy is just placeholder with no real implementation.
The solution is quite simple. Instead of using the mock, create some simple fake to use in the test. It has multiple advantages:
- you have ownership of the code
- you have full control over how the implementation behaves
- limitation and complexity is imposed only by your solution
- some fakes and dummies are reusable multiple times over test cases with no stubbing
- errors can be quite easily detectable and resolvable
- can make code much more readable
You might argue that writing all of the fakes will take a lot of time. Yes, that is true. In the beginning, it will take a lot of time, but testing and coding should share the same amount of time spent. A couple of fakes later, you will find yourself repurposing them in multiple test cases and time spent on them will come back eventually.
Mockups should be used only for 2 purposes:
- direct dependency on the third-party
- code, which is about to change sooner or later
Practical example
I will use Kotlin code for demonstration, but it is applicable in any language.
Imagine, we want to store our settings options in the mobile app, from which we are get a simple boolean (It can be SharedPreferences
in Android, UserDefaults
in iOS or FlutterSecureStorage
in Flutter).
For a simple demonstration, here is a sample code for abstraction and implementation.
/**
* our abstraction to handle storage for premium user option
*/
abstract class SettingsStorageTemplate {
abstract fun isPremium(): Boolean
abstract fun setPremium(isPremium: Boolean)
}
/**
* real implementation with Android preferences via [SharedPreferences]
*/
class SettingsStorage(private val preferences: SharedPreferences) : SettingsStorageTemplate() {
override fun isPremium(): Boolean = preferences.getBoolean("isPremium", false)
override fun setPremium(isPremium: Boolean) =
preferences.edit().putBoolean("isPremium", isPremium).apply()
}
The implementation does not do anything extra. It stores and reads our premium option.
In testing, we could create a mock and stub it as follows with mockk library:
@Mock
lateinit var settingsStorage: SettingsStorageTemplate
settingsStorage.stub {
on { this.isPremium() } doAnswer { true }
}
Writing a stub for every test case can be a bit tedious if the value changes frequently. We can do it a bit better with fake implementation. The fake implementation stores the options into the temporary memory and it is not written into any permanent database. Initialization of the settings resets the settings to default.
/**
* fake implementation, which stores the value in memory
* within unit test it will provide simplified, but full functionality
*/
class SettingsStorageFake : SettingsStorageTemplate() {
private var isPremium: Boolean = false
override fun isPremium(): Boolean = isPremium
override fun setPremium(isPremium: Boolean) {
this.isPremium = isPremium
}
/**
* additional static constructors for quick startup
*/
companion object {
fun premiumUser(): SettingsStorageTemplate = SettingsStorageFake().apply {
setPremium(true)
}
fun regularUser(): SettingsStorageTemplate = SettingsStorageFake().apply {
setPremium(false)
}
}
}
After setting up our fake, the test case arrangement would look like this:
val settings = SettingsStorageFake().apply {
setPremium(true)
}
… and that’s it! It is much more readable and clear what the test case is intended to set up. If you need quick initialization of settings, you can use methods from companion objects like:
SettingsStorageFake.premiumUser()
SettingsStorageFake.regularUser()
To read more about dependency injection and abstraction of components go here.
Thanks for reading!