We write really tested code
What is test code? What rules should be followed to write it? How to start writing such code if the code base is not ready for this?
An article with a lot of code examples and illustrations, which is based on the presentation of Anton at the Mobius 2017 conference in St. Petersburg. Anton is an Android app developer at Juno, and in his work touches on many related technologies. This report is not about Android and not about Kotlin, it is about testing in general, about the ideas that lie above the platform and over the language and which can be adapted to any context.
First, it’s worth deciding why we are writing or want to write tests for our code. There may be several reasons:
And perhaps the most important reason is that the project can live and develop for a long time (that is, change). By development is meant the addition of new features, bug fixes, refactoring.
As our code base grows, the chances of making a mistake increase because the base becomes more complex. And when she goes into production, the price of error increases. As a result, fear of modifications often arises, which is very difficult to deal with.
Here are two global tasks that we solve when we write a long-lived project:
What can go wrong when trying to write a test? Often the system is simply not ready for this. It can be so connected with neighboring parts that we cannot set any input parameters to verify that everything is working correctly.
To avoid such situations, you need to write code correctly, that is, make it testable.
What is test code? To answer this question, you need to first understand what a test is. Let's say there is a system that needs to be tested (SUT - System Under Test). Testing is the transfer of some input data and validation of the results against the expected results. The test code means that we have full control over the input and output parameters.
To make the code testable, it is important to adhere to three rules. Let's look at each of them in detail with examples.
Let's look at testing a function (a certain function in a vacuum that takes N arguments and returns a certain number of values):
And there is a function that is not clean:
Consider which inputs are here. Firstly, the prefix that is passed as an argument. Also, the input is the value of the global variable, because it also affects the result of the function. The result of the function is the return value (string), as well as an increase in the global variable. This is the way out.
Schematically, it looks like the figure below.
We have inputs (explicit and implicit) and outputs (explicit and implicit). In order to make a pure function from such a function, it is necessary to remove implicit inputs and outputs. In this case, it is tested in a controlled manner. For example, like this:
In other words, a function is easy to test if all its inputs and outputs are passed explicitly, that is, through a list of arguments and return values.
In order to understand the second rule, I suggest thinking of a module as a function. Suppose a module is a function whose call is extended over time, that is, part of the input parameters are transferred at some point in time, part - in the next line, part - after some timeout, then some other part, etc. .d. And the same with the exits: part now, part - a little later, etc.
What would the inputs and outputs of such a module function look like? Let's try to look at the code first, and then make a more general picture:
The fact of calling the constructor of such a class is the input of our function, and passing a string to the output is obviously also an input. The fact of calling some method of our class will also be the input of the function, because our result will depend on whether the method is called or not.
Getting some value from an explicit dependency is also an input. I call the dependency explicit if it was passed through the module API before use.
Getting some input from an implicit dependency is also an input.
Let's move on to the exits. The return of a certain value from the field is the way out. Modification of this value is the output of the function, since we can then check it from the outside.
Modification of some external state is also an output. It can be explicit, as here:
Or implicit, like here:
Now let's summarize.
The inputs of such a function module can be:
Approximately the same with the outputs:
The outputs of the function module can be:
If we define a module in this way, then we see that the process of testing a module, that is, a test written on this module, is a call to this function and validation of the results. That is, what we write in the given and when blocks (if we use given and when-annotation) is the process of calling functions, and then the process of validating the results.
Thus, the module becomes easy to test if all its inputs and outputs are transmitted either through the module API or through the API of its explicit dependencies.
Even with explicit arguments and explicit dependencies, we still don't get full control, and that's why.
For example, the module has an explicit dependency. The module does nothing but multiply it by three and write to some field.
We write a test for this:
Somehow we are preparing our module, we take the value of the Tripled field from it, write it in the result, expect it to be 15, and check that 15 is equal to the result:
The biggest question is, how do we prepare our explicit dependency in order to say that it returns the top five and we need to get 15 as a result? It depends a lot on what the explicit dependency is.
If the obvious dependence is a singleton, then in tests we cannot say: “Return the five!”, Because the code is already written, and we cannot modify the code in tests.
Accordingly, the test does not work for us - we cannot transfer a normal dependency there.
The same with final classes - we cannot modify their behavior.
The last and good case, when an explicit dependency is an interface that has some kind of implementation:
Then we can already prepare this interface in the test, create a test implementation that will return the top five, and finally pass it to our module class and run the test.
Sometimes functions are private, and here you need to look at what a private implementation is and make sure that there are no implicit dependencies in it, that nothing comes from singletones, from some implicit places. And then, in principle, there should be no problem testing the code through the public API. That is, if the public API fully describes the inputs and outputs (there are no others), then the public API is enough de facto.
It's hard for me to imagine the tested code without some kind of architecture, so as an example I will use MVP. There is a user interface, a View layer, models where the business logic is conditionally collected, a layer of platform adapters (designed to isolate models from the API), as well as platform APIs and third-party APIs that are not associated with the user interface.
We are testing Model and Presenter here because they are completely isolated from the platform.
We have a class and a variety of explicit inputs: input string, lambda, Observable, method call, and also the same thing done through an explicit dependency.
The situation with the exits is very similar. The output may be to return some value from the method, return some value through the lambda, through the Observable, and through an explicit dependency:
Implicit inputs and outputs can be:
Now about each of them in more detail.
We cannot modify the behavior of singletones in tests.
Therefore, they must be taken out as an explicit dependence:
In the example below, we do not call singleton, but create an object of class random. But he pulls some static methods inside himself, which we cannot influence in any way (for example, the current time).
Therefore, such services that we do not control, it makes sense to pass for interfaces that we could control.
We have a certain module that initializes storage. All he does is create a file in some way.
But this API is very insidious: it returns true if successful, and false if there is the same file. And we, for example, need not only create a file, but also understand: it was created or not, and if not, for what reason. Accordingly, we create a typed error and want to return it to the output. Or, if there is no error, then return null.
In addition, this API throws two exceptions. We catch them and, again, return typed errors.
Ok, processed. Now we want to test. The problem is that creating a file is a thing that has side effects (that is, it creates a file in the file system). Therefore, we need to somehow prepare the file system, or make everything that has side effects behind interfaces.
It is not immediately obvious that this is the entrance, but we have already figured out above that this is so.
For example, the module has logic that waits for half a minute. If you plan to write a test for this, you do not want the test to wait half a minute, because all unit tests must pass in general for half a minute. We want to be able to control time, therefore, again, it makes sense to move all work over time to the interface so that it is one point in the system, and you would understand how you work with time and, if necessary, could turn the clock forward or even backward . Then you, say, will be able to test how your module will behave if the clock is moved.
This is the most insidious implicit entry. Let's say a regular Presenter takes some kind of timestamp, formats it according to a strictly specified pattern (no AM or PM, no commas, everything seems to be set) and stores it in the field:
We are writing a test for this. We don’t want to think about what this means in a formatted form, it’s easier for us to run a module on it, see what it displays to us, and write it down here.
We watched that Mobius begins on April 21 at 10 o’clock:
Ok, run this on the local machine, everything works:
We start it on CI, and there for some reason Mobius starts at 7.
CI has a different time zone. It is in the UTC + 0 time zone and, accordingly, the time is formatted differently there, because SimpleDateFormat uses the default time zone. In tests, we did not redefine this, respectively, in CI-servers, which are at zero GMT, we have a different way out. And this insidious all the inputs that are associated with the location, including:
They say that there is no “silver bullet,” but it seems to me that there is one with respect to moking. Because interfaces work everywhere. If you hid your implementation behind an interface, you can be sure that you can replace it in tests, because interfaces are replaced.
Interfaces even sometimes help to do something with singletones. Let's say there is a singleton in the project, which is GodObject. You cannot parse it into several separate modules at once, but you want to slowly introduce some kind of DI, some kind of testability. To do this, you can create an interface that will repeat the public API of this singleton or part of the public API and make the singleton implement this interface. And instead of using singleton itself in the module, you can pass this interface as an explicit dependency. Outside it will, of course, be the transfer of the same GetInstance, but inside you will already work with a clean interface. This may be an intermediate step until everything has passed to the modules and DI.
There are, of course, other alternatives. I said above that you cannot wet out final classes, static methods, singletones. Of course, they can be muted: there is Mockito2 for final classes, there is PowerMоck for final classes, static methods, singletones, but there are a number of problems with them:
Abstracting occurs on the View layer and on the layer of platform adapters.
The View layer is the isolation of the UI framework from the Presenter module. There are two main approaches:
Let's first look at the first option: Activity implements View. Then we have a certain trivial Presenter, which takes a view interface as an input and calls some method on it.
We have a trivial View interface:
And we have an Activity, in which there is an input in the form of the onCreate method, and there is an implementation of the SplashView interface, which already implements directly in a platform way what it needs to do, for example, to display some kind of progress.
Accordingly, we do Presenter as follows: create in OnCreate and pass this as View. So many do, a completely valid option:
There is a second option - View as a separate class. Here Presenter is exactly the same, the interface is exactly the same, but the implementation is a separate class that is not related to Activity.
Accordingly, so that it can work with platform components, a platform view is transmitted to it at the input. And there he is already doing everything he needs.
In this case, the Activity is slightly offloaded. That is, instead of organizing the interface in it, it remains to get this platform view and create SplashPresenter, where a separate class is created as a View.
In fact, from the point of view of testing, these two approaches are the same, because we still work from the interface. We create a mock View, create a Presenter, into which we pass it, and check that a certain method is called.
The only difference is how you look at the Activity and View roles. If it seems to you that the View role is big enough not to mix it with other Activity roles, then putting it in a separate class is a good idea.
Now, regarding the abstraction from the platform on the platform adapter layer. Platform wrappers are the isolation of the Model layer. The problem is that behind this layer on the platform side is the platform API and third-party APIs, and we generally cannot modify them, because they come in different forms. They can come as static methods, as singletones, as final classes, and as non-final classes. In the first three cases, we cannot influence their implementation; we cannot replace their behavior in tests. And only if they are not final classes, can we somehow influence their behavior in tests.
Therefore, instead of using such APIs directly, it might make sense to create a wrapper. This is where the API is used directly:
Instead of doing so, we create a wrapper that in the most trivial case does nothing but forward the methods of our third-party API.
We got a wrapper with the implementation, hid it behind the interface and, accordingly, we already call Wrapper in the module, which comes as an explicit dependency.
In addition to guaranteed testability, this gives the following:
Multiple static calls can be Design Smell, but it depends a lot on what these static calls are. We mean that static calls are pure functions. If they change global variables, then this is Smell. If nothing complicated happens in them and you are ready to cover the functionality of this static method with tests for the entire module where it is called at each place, then this is not Smell, but finding balance. And you can and should depart from the rules.
Android has IDs for strings and other resources. Sometimes in presenters or elsewhere we need to have access to something that depends on the platform. The question is how to abstract it, because the R-class comes from the framework.
Resources - this is already our interface, this is not the Android interface, but we are transferring the same end-ID ID to it. And then notice that, in fact, this is just an end identifier:
And there are already questions of taste, is this ID enough for you to check that everything is behaving correctly? Usually it’s enough for us.
In my opinion, it makes sense to work more deeply with this only if you are doing some kind of cross-platform logic. There, the mechanism of access to resources will be different for iOS and Android, and it is already guaranteed that you need to isolate it.
We have a module, it has an input. He inside himself from this entrance counted some state, wrote in the field.
Everything is fine, we wrote a test for it, and then we got another module, in which very similar logic.
Accordingly, we see that this is a repetition of the same code, we take out this logic by calculating the initial state somewhere in a separate place and cover it with a test.
But how then do we write tests for both modules? In both of them, we need to check the calculateInitialState functionality if it is a conditional part of the implementation. If this is a rather complicated thing, then perhaps it makes sense to make it an explicit dependency and pass it as an interface.
This does not always make sense, since with this approach we can simply check that the calculateInitialState method with such and such a parameter has been called. The same applies to inner classes, extension functions (if we talk about Kotlin), static functions, that is, everything that is a part of an implementation and can twitch from several places.
It is logical to start with models, that is, with no dependencies (these are either models without dependencies, or platform wrappers). We wrote enough of them, and then, using them as dependencies, we build models that accept them as input, and so little by little we build our global dependency graph.
It looks something like the instructions for drawing an owl from a comic guide for beginners.
As a result, you get the following:
If we did all this qualitatively, we can take some input point from the framework (Activity, service, broadcast receiver ...), create some kind of wrapper around it (in the case of Activity, it could be View), take our dependency graph that we did earlier, and create a Presenter. All dependencies are already satisfied, and we can create Presenter by passing them to the input via DI.
When we have done all this, we can go up the testing pyramid to integration tests.
At this stage, we take the layers where we are rigidly insulated from the platform (wrapper and View), and replace them with test implementations. After that, we can integratively test everything that is between them (and this is sometimes useful).
In the end, I want to quote Joshua Bloch’s famous quote: “Learning the art of programming, like most other disciplines, consists of learning the rules in the first step and learning how to break them in the second.”
The rules were stated above. The important part here is to understand how they work. And if you need to break the rules, this must be an informed decision. You should be aware of the consequences of breaking the rules. If you decide to break the rule, you must consciously accept the consequences. If you cannot put up with the consequences, you must not consciously violate them.
If mobile development is your main profile, you will probably be interested in these reports at our November Mobius 2017 Moscow conference :
An article with a lot of code examples and illustrations, which is based on the presentation of Anton at the Mobius 2017 conference in St. Petersburg. Anton is an Android app developer at Juno, and in his work touches on many related technologies. This report is not about Android and not about Kotlin, it is about testing in general, about the ideas that lie above the platform and over the language and which can be adapted to any context.
Why do we need tests?
First, it’s worth deciding why we are writing or want to write tests for our code. There may be several reasons:
- To gain trust in the code;
- For the preparation of documentation;
- To sleep soundly after refactoring;
- To write code faster;
- To show off to colleagues.
And perhaps the most important reason is that the project can live and develop for a long time (that is, change). By development is meant the addition of new features, bug fixes, refactoring.
As our code base grows, the chances of making a mistake increase because the base becomes more complex. And when she goes into production, the price of error increases. As a result, fear of modifications often arises, which is very difficult to deal with.
Here are two global tasks that we solve when we write a long-lived project:
- Managing the complexity of the system, that is, how to make the system as simple as possible for given business requirements;
- System testing (today we are talking about this).
What is test code?
What can go wrong when trying to write a test? Often the system is simply not ready for this. It can be so connected with neighboring parts that we cannot set any input parameters to verify that everything is working correctly.
To avoid such situations, you need to write code correctly, that is, make it testable.
What is test code? To answer this question, you need to first understand what a test is. Let's say there is a system that needs to be tested (SUT - System Under Test). Testing is the transfer of some input data and validation of the results against the expected results. The test code means that we have full control over the input and output parameters.
Three rules for writing test code
To make the code testable, it is important to adhere to three rules. Let's look at each of them in detail with examples.
Rule 1. Pass arguments and return values explicitly.
Let's look at testing a function (a certain function in a vacuum that takes N arguments and returns a certain number of values):
f(Arg[1], ... , Arg[N]) -˃ (R[1], ... , R[L])
And there is a function that is not clean:
fun nextItemDescription(prefix: String): String {
GLOBAL_VARIABLE++
return "$prefix: $GLOBAL_VARIABLE"
}
Consider which inputs are here. Firstly, the prefix that is passed as an argument. Also, the input is the value of the global variable, because it also affects the result of the function. The result of the function is the return value (string), as well as an increase in the global variable. This is the way out.
Schematically, it looks like the figure below.
We have inputs (explicit and implicit) and outputs (explicit and implicit). In order to make a pure function from such a function, it is necessary to remove implicit inputs and outputs. In this case, it is tested in a controlled manner. For example, like this:
fun ItemDescription(prefix: String, itemIndex: Int): String {
return "$prefix: $itemIndex"
}
In other words, a function is easy to test if all its inputs and outputs are passed explicitly, that is, through a list of arguments and return values.
Rule 2. Pass dependencies explicitly
In order to understand the second rule, I suggest thinking of a module as a function. Suppose a module is a function whose call is extended over time, that is, part of the input parameters are transferred at some point in time, part - in the next line, part - after some timeout, then some other part, etc. .d. And the same with the exits: part now, part - a little later, etc.
M(In[1], ... , In[N]) -˃ (Out[1], ... , Out[L])
What would the inputs and outputs of such a module function look like? Let's try to look at the code first, and then make a more general picture:
class Module(
val title: String //input
){}
The fact of calling the constructor of such a class is the input of our function, and passing a string to the output is obviously also an input. The fact of calling some method of our class will also be the input of the function, because our result will depend on whether the method is called or not.
class Module(
val title: String // input
){
fun doSomething() { // input
// …
}
}
Getting some value from an explicit dependency is also an input. I call the dependency explicit if it was passed through the module API before use.
class Module(
val title: String // input
val dependency: Explicit // dependency
){
fun doSomething() { // input
val explicit = dependency.getCurrentState() //input
// …
}
}
Getting some input from an implicit dependency is also an input.
class Module(
val title: String // input
val dependency: Explicit // dependency
){
fun doSomething() { // input
val explicit = dependency.getCurrentState() //input
val implicit = Implicit.getCurrentState() //input
// …
}
}
Let's move on to the exits. The return of a certain value from the field is the way out. Modification of this value is the output of the function, since we can then check it from the outside.
class Module(
){
var state = "Some state"
fun doSomething() {
state = "New state" // output
// …
}
}
Modification of some external state is also an output. It can be explicit, as here:
class Module(
val dependency: Explicit // dependency
){
var state = "Some State"
fun doSomething() {
state = "New State" // output
dependency.setCurrentState("New state") //output
// …
}
}
Or implicit, like here:
class Module(
val dependency: Explicit // dependency
){
var state = "Some state"
fun doSomething() {
state = "New state" // output
dependency.setCurrentState("New state") //output
Implicit.setCurrentState("New state") //input
// …
}
}
Now let's summarize.
In[1], … , In[N]
The inputs of such a function module can be:
- Interactions with the module API and the API of its dependencies;
- The meanings that we conveyed in them;
- The order in which we did these interactions;
- The time between these interactions.
Approximately the same with the outputs:
Out[1], … , Out[N]
The outputs of the function module can be:
- Interactions with the module API and the API of its dependencies;
- The meanings that we conveyed in them;
- The order in which we did these interactions;
- The time between these interactions;
- Modification of a certain state of the module, which can then be observed, is obtained from the outside.
If we define a module in this way, then we see that the process of testing a module, that is, a test written on this module, is a call to this function and validation of the results. That is, what we write in the given and when blocks (if we use given and when-annotation) is the process of calling functions, and then the process of validating the results.
Thus, the module becomes easy to test if all its inputs and outputs are transmitted either through the module API or through the API of its explicit dependencies.
M(In[1], ... , In[N]) -˃ (Out[1], ... , Out[L])
Rule 3. Control dependency interchangeability in tests
Even with explicit arguments and explicit dependencies, we still don't get full control, and that's why.
For example, the module has an explicit dependency. The module does nothing but multiply it by three and write to some field.
class Module(explicit: Explicit) {
val tripled = 3 * explicit.getValue()
}
We write a test for this:
class Module(explicit: Explicit) {
val tripled = 3 * explicit.getValue()
}
@Test
fun testValueGetsTripled() {
}
Somehow we are preparing our module, we take the value of the Tripled field from it, write it in the result, expect it to be 15, and check that 15 is equal to the result:
class Module(explicit: Explicit) {
val tripled = 3 * explicit.getValue()
}
@Test
fun testValueGetsTripled() {
// prepare Explicit dependency
val result = Module( ??? ).tripled
val expected = 15
assertThat(result).isEqualTo(expected)
}
The biggest question is, how do we prepare our explicit dependency in order to say that it returns the top five and we need to get 15 as a result? It depends a lot on what the explicit dependency is.
If the obvious dependence is a singleton, then in tests we cannot say: “Return the five!”, Because the code is already written, and we cannot modify the code in tests.
// 'object' stands for Singleton in Kotlin
object Explicit {
fun getValue(): Int = ...
}
Accordingly, the test does not work for us - we cannot transfer a normal dependency there.
// 'object' stands for Singleton in Kotlin
object Explicit {
fun getValue(): Int = ...
}
@Test
fun testValueGetsTripled() {
val result = Module( ??? ).tripled
val expected = 15
assertThat(result).isEqualTo(expected)
}
The same with final classes - we cannot modify their behavior.
// Classes are final by default in Kotlin
Class Explicit {
fun getValue(): Int = ...
}
@Test
fun testValueGetsTripled() {
val result = Module( ??? ).tripled
val expected = 15
assertThat(result).isEqualTo(expected)
}
The last and good case, when an explicit dependency is an interface that has some kind of implementation:
interface Explicit {
fun getValue(): Int
class Impl: Explicit {
override fun getValue(): Int = ...
}
}
Then we can already prepare this interface in the test, create a test implementation that will return the top five, and finally pass it to our module class and run the test.
@Test
fun testValueGetsTripled() {
val mockedExplicit = object : Explicit {
override fun getValue(): Int = 5
}
val result = Module(mockedExplicit).tripled
val expected = 15
assertThat(result).isEqualTo(expected)
}
Sometimes functions are private, and here you need to look at what a private implementation is and make sure that there are no implicit dependencies in it, that nothing comes from singletones, from some implicit places. And then, in principle, there should be no problem testing the code through the public API. That is, if the public API fully describes the inputs and outputs (there are no others), then the public API is enough de facto.
Three Rules for Writing Testable Code in Practice
It's hard for me to imagine the tested code without some kind of architecture, so as an example I will use MVP. There is a user interface, a View layer, models where the business logic is conditionally collected, a layer of platform adapters (designed to isolate models from the API), as well as platform APIs and third-party APIs that are not associated with the user interface.
We are testing Model and Presenter here because they are completely isolated from the platform.
What are the explicit inputs and outputs
We have a class and a variety of explicit inputs: input string, lambda, Observable, method call, and also the same thing done through an explicit dependency.
class ModuleInputs(
input: String,
inputLambda: () -> String,
inputObservable: Observable,
dependency: Explicit
) {
private val someField = dependency.getInput()
fun passInput(input: String) { }
}
The situation with the exits is very similar. The output may be to return some value from the method, return some value through the lambda, through the Observable, and through an explicit dependency:
class ModuleOutputs(
outputLambda: (String) -> Unit,
dependency: Explicit
) {
val outputObservable = Observable.just("Output")
fun getOutput(): String = "Output"
init{
outputLambda("Output")
dependency.passOutput("Output")
}
}
What implicit inputs and outputs look like and how to convert them to explicit
Implicit inputs and outputs can be:
- Singleton
- Random number generators
- File System and Other Storage
- Time
- Formatting and locales
Now about each of them in more detail.
Singleton
We cannot modify the behavior of singletones in tests.
class Module {
private val state = Implicit.getCurrentState()
}
Therefore, they must be taken out as an explicit dependence:
class Module (dependency: Explicit){
private val state = dependency.getCurrentState()
}
Random number generators
In the example below, we do not call singleton, but create an object of class random. But he pulls some static methods inside himself, which we cannot influence in any way (for example, the current time).
class Module {
private val fileName = "some-file${Random().nextInt()}"
}
Therefore, such services that we do not control, it makes sense to pass for interfaces that we could control.
class Module(rng: Rng) {
private val fileName = "some-file${Random(rng.nextInt()}"
}
File System and Other Storage
We have a certain module that initializes storage. All he does is create a file in some way.
class Module {
fun initStorage(path: String) {
File(path).createNewFile()
}
}
But this API is very insidious: it returns true if successful, and false if there is the same file. And we, for example, need not only create a file, but also understand: it was created or not, and if not, for what reason. Accordingly, we create a typed error and want to return it to the output. Or, if there is no error, then return null.
class Module {
fun initStorage(path: String): FileCreationError? {
return if (File(path).createNewFile()) {
null
} else {
FileCreationError.Exists
}
}
}
In addition, this API throws two exceptions. We catch them and, again, return typed errors.
class Module {
fun initStorage(path: String): FileCreationError? = try {
if (File(path).createNewFile()) {
null
} else {
FileCreationError.Exists
}
} catch (e: SecurityException) {
FileCreationError.Security(e)
} catch (e: Exception) {
FileCreationError.Other(e)
}
}
Ok, processed. Now we want to test. The problem is that creating a file is a thing that has side effects (that is, it creates a file in the file system). Therefore, we need to somehow prepare the file system, or make everything that has side effects behind interfaces.
class Module (private val fileCreator: FileCreator){
fun initStorage(path: String): FileCreationError? = try {
if (fileCreator.createNewFile(path)) {
null
} else {
FileCreationError.Exists
}
} catch (e: SecurityException) {
FileCreationError.Security(e)
} catch (e: Exception) {
FileCreationError.Other(e)
}
}
Time
It is not immediately obvious that this is the entrance, but we have already figured out above that this is so.
class Module {
private val nowTime = System.current.TimeMillis()
private val nowDate = Date()
// and all other time/date APIs
}
For example, the module has logic that waits for half a minute. If you plan to write a test for this, you do not want the test to wait half a minute, because all unit tests must pass in general for half a minute. We want to be able to control time, therefore, again, it makes sense to move all work over time to the interface so that it is one point in the system, and you would understand how you work with time and, if necessary, could turn the clock forward or even backward . Then you, say, will be able to test how your module will behave if the clock is moved.
class Module (time: TimeProvider) {
private val nowTime = time.nowMillis()
private val nowDate = time.nowDate()
// and all other time/date APIs
}
Formatting and locales
This is the most insidious implicit entry. Let's say a regular Presenter takes some kind of timestamp, formats it according to a strictly specified pattern (no AM or PM, no commas, everything seems to be set) and stores it in the field:
class MyTimePresenter(timestamp: Long) {
val formattedTimestamp = SimpleDateFormat("yyyy-MM-dd HH:mm").format(timestamp)
}
We are writing a test for this. We don’t want to think about what this means in a formatted form, it’s easier for us to run a module on it, see what it displays to us, and write it down here.
class MyTimePresenter(timestamp: Long) {
val formattedTimestamp = SimpleDateFormat("yyyy-MM-dd HH:mm").format(timestamp)
}
fun test() {
val mobiusConfStart = 1492758000L
val expected = ""
val actual = MyTimePresenter(timestamp).formattedTimeStamp
assertThat(actual).isEqualTo(expected)
}
We watched that Mobius begins on April 21 at 10 o’clock:
class MyTimePresenter(timestamp: Long) {
val formattedTimestamp = SimpleDateFormat("yyyy-MM-dd HH:mm")
.format(timestamp)
}
fun test() {
val mobiusConfStart = 1492758000L
val expected = "2017-04-21 10:00"
val actual = MyTimePresenter(timestamp).formattedTimeStamp
assertThat(actual).isEqualTo(expected)
}
Ok, run this on the local machine, everything works:
>> `actual on dev machine` = "2017-04-21 10:00" // UTC +3
We start it on CI, and there for some reason Mobius starts at 7.
>> `actual on CI` = "2017-04-21 07:00" // UTC
CI has a different time zone. It is in the UTC + 0 time zone and, accordingly, the time is formatted differently there, because SimpleDateFormat uses the default time zone. In tests, we did not redefine this, respectively, in CI-servers, which are at zero GMT, we have a different way out. And this insidious all the inputs that are associated with the location, including:
- Currency
- Number format
- Timezone
- Locales
How to wet dependencies in tests
They say that there is no “silver bullet,” but it seems to me that there is one with respect to moking. Because interfaces work everywhere. If you hid your implementation behind an interface, you can be sure that you can replace it in tests, because interfaces are replaced.
interface MyService {
fun doSomething()
class Impl(): MyService {
override fun doSomething() { /* ... */ }
}
}
class TestService: MyService {
override fun doSomething() { /* ... */ }
}
val mockService = mock()
Interfaces even sometimes help to do something with singletones. Let's say there is a singleton in the project, which is GodObject. You cannot parse it into several separate modules at once, but you want to slowly introduce some kind of DI, some kind of testability. To do this, you can create an interface that will repeat the public API of this singleton or part of the public API and make the singleton implement this interface. And instead of using singleton itself in the module, you can pass this interface as an explicit dependency. Outside it will, of course, be the transfer of the same GetInstance, but inside you will already work with a clean interface. This may be an intermediate step until everything has passed to the modules and DI.
interface StateProvider {
fun getCurrentState(): String
}
object Implicit: StateProvider {
override fun getCurrentState(): String = "State"
}
class SomeModule(stateProvider: StateProvider) {
init {
val state = StateProvider.getCurrentState()
}
}
There are, of course, other alternatives. I said above that you cannot wet out final classes, static methods, singletones. Of course, they can be muted: there is Mockito2 for final classes, there is PowerMоck for final classes, static methods, singletones, but there are a number of problems with them:
- They most often signal design problems (this applies mainly to PowerMock)
- They may stop working at some point, for example, on the 1501 test, so it’s better to immediately think about the architecture suitable for testing and not use such frameworks.
Abstraction from the platform and why it is needed
Abstracting occurs on the View layer and on the layer of platform adapters.
View layer abstraction
The View layer is the isolation of the UI framework from the Presenter module. There are two main approaches:
- When Activity implements the View interface itself;
- When View is a separate class.
Let's first look at the first option: Activity implements View. Then we have a certain trivial Presenter, which takes a view interface as an input and calls some method on it.
class SplashPresenter(view: SplashView) {
init {
view.showLoading()
}
}
We have a trivial View interface:
interface SplashView {
fun showLoading()
}
And we have an Activity, in which there is an input in the form of the onCreate method, and there is an implementation of the SplashView interface, which already implements directly in a platform way what it needs to do, for example, to display some kind of progress.
interface SplashView {
fun showLoading()
}
class SplashActivity: Activity, SplashView {
override fun onCreate() {
}
override fun showLoading() {
findViewById(R.id.progress).show()
}
}
Accordingly, we do Presenter as follows: create in OnCreate and pass this as View. So many do, a completely valid option:
interface SplashView {
fun showLoading()
}
class SplashActivity: Activity, SplashView {
override fun onCreate() {
SplashPresenter(view = this)
}
override fun showLoading() {
findViewById(R.id.progress).show()
}
}
There is a second option - View as a separate class. Here Presenter is exactly the same, the interface is exactly the same, but the implementation is a separate class that is not related to Activity.
class SplashPresenter(view: SplashView) {
init {
view.showLoading()
}
interface SplashView {
fun showLoading()
class Impl : SplashView {
override fun showLoading() {
}
}
}
}
Accordingly, so that it can work with platform components, a platform view is transmitted to it at the input. And there he is already doing everything he needs.
interface SplashView {
fun showLoading()
class Impl(private val viewRoot: View) : SplashView {
override fun showLoading() {
viewRoot.findViewById(R.id.progress).show()
}
}
}
In this case, the Activity is slightly offloaded. That is, instead of organizing the interface in it, it remains to get this platform view and create SplashPresenter, where a separate class is created as a View.
class SplashActivity: Activity, SplashView {
override fun onCreate() {
// Platform View class
val rootView: View = ...
SplashPresenter(
view = SplashView.Impl(rootView)
)
}
}
In fact, from the point of view of testing, these two approaches are the same, because we still work from the interface. We create a mock View, create a Presenter, into which we pass it, and check that a certain method is called.
@Test
fun testLoadingIsShown() {
val mockedView = mock()
SplashPresenter(mockedView)
verify (mockedView).showLoading()
}
The only difference is how you look at the Activity and View roles. If it seems to you that the View role is big enough not to mix it with other Activity roles, then putting it in a separate class is a good idea.
Platform Wrappers layer abstraction
Now, regarding the abstraction from the platform on the platform adapter layer. Platform wrappers are the isolation of the Model layer. The problem is that behind this layer on the platform side is the platform API and third-party APIs, and we generally cannot modify them, because they come in different forms. They can come as static methods, as singletones, as final classes, and as non-final classes. In the first three cases, we cannot influence their implementation; we cannot replace their behavior in tests. And only if they are not final classes, can we somehow influence their behavior in tests.
Therefore, instead of using such APIs directly, it might make sense to create a wrapper. This is where the API is used directly:
class Module {
init {
ThirdParty.doSomething()
}
}
Instead of doing so, we create a wrapper that in the most trivial case does nothing but forward the methods of our third-party API.
interface Wrapper {
fun doSomething()
class Impl: Wrapper {
override fun doSomething() {
ThirdParty.doSomething()
}
}
}
We got a wrapper with the implementation, hid it behind the interface and, accordingly, we already call Wrapper in the module, which comes as an explicit dependency.
class Module(wrapper: Wrapper) {
init {
wrapper.doSomething()
}
}
In addition to guaranteed testability, this gives the following:
- The ability to use a convenient design instead of binding to the design of platform APIs;
- Reducing the complexity of the input parameter (Single Responsibility instead of God Object);
- Easier change of API if necessary (without rewriting the entire code base).
Multiple static calls can be Design Smell, but it depends a lot on what these static calls are. We mean that static calls are pure functions. If they change global variables, then this is Smell. If nothing complicated happens in them and you are ready to cover the functionality of this static method with tests for the entire module where it is called at each place, then this is not Smell, but finding balance. And you can and should depart from the rules.
Access to resources
Android has IDs for strings and other resources. Sometimes in presenters or elsewhere we need to have access to something that depends on the platform. The question is how to abstract it, because the R-class comes from the framework.
class SplashPresenter(view: SplashView, resources: Resources) {
init {
view.setTitle(resources.getString(R.string.welcome))
view.showLoading()
}
}
Resources - this is already our interface, this is not the Android interface, but we are transferring the same end-ID ID to it. And then notice that, in fact, this is just an end identifier:
class SplashPresenter(view: SplashView, resources: Resources) {
init {
view.setTitle(resources.getString(R.string.welcome))
view.showLoading()
}
}
interface Resources {
fun getString(id: Int): String
}
And there are already questions of taste, is this ID enough for you to check that everything is behaving correctly? Usually it’s enough for us.
public final class R {
public static final class string {
public static final int welcome=0x7f050000;
}
}
In my opinion, it makes sense to work more deeply with this only if you are doing some kind of cross-platform logic. There, the mechanism of access to resources will be different for iOS and Android, and it is already guaranteed that you need to isolate it.
What is an implementation detail
We have a module, it has an input. He inside himself from this entrance counted some state, wrote in the field.
class SomeModule(input: String) {
val state = calculateInitialState(input)
// Pure
private fun calculateInitialState(input: String): String =
"Some complex computation for $input"
}
Everything is fine, we wrote a test for it, and then we got another module, in which very similar logic.
class SomeModule(input: String) {
val state = calculateInitialState(input)
// Pure
private fun calculateInitialState(input: String): String =
"Some complex computation for $input"
}
class AnotherModule(input: String) {
val state = calculateInitialState(input)
// Pure
private fun calculateInitialState(input: String): String =
"Some complex computation for $input"
}
Accordingly, we see that this is a repetition of the same code, we take out this logic by calculating the initial state somewhere in a separate place and cover it with a test.
class SomeModule(input: String) {
val state = calculateInitialState(input)
// Pure
private fun calculateInitialState(input: String): String =
"Some complex computation for $input"
}
object StateCalculator {
fun calculateInitialState(input: String): String =
"Some complex computation for $input"
}
But how then do we write tests for both modules? In both of them, we need to check the calculateInitialState functionality if it is a conditional part of the implementation. If this is a rather complicated thing, then perhaps it makes sense to make it an explicit dependency and pass it as an interface.
class SomeModule(input: String, stateCalculator: StateCalculator){
val state = stateCalculator.calculateInitialState(input)
}
interface StateCalculator {
fun calculateInitialState(input: String): String
class Impl: StateCalculator {
override fun calculateInitialState(input: String): String =
"Some complex computation for $input"
}
}
This does not always make sense, since with this approach we can simply check that the calculateInitialState method with such and such a parameter has been called. The same applies to inner classes, extension functions (if we talk about Kotlin), static functions, that is, everything that is a part of an implementation and can twitch from several places.
How to start if our code base is not ready yet
It is logical to start with models, that is, with no dependencies (these are either models without dependencies, or platform wrappers). We wrote enough of them, and then, using them as dependencies, we build models that accept them as input, and so little by little we build our global dependency graph.
It looks something like the instructions for drawing an owl from a comic guide for beginners.
As a result, you get the following:
- everything that was implicit (singleton) will become explicit (will begin to be transmitted through DI);
- You will gain control and understanding of the initialization process;
- models will become easy to test.
If we did all this qualitatively, we can take some input point from the framework (Activity, service, broadcast receiver ...), create some kind of wrapper around it (in the case of Activity, it could be View), take our dependency graph that we did earlier, and create a Presenter. All dependencies are already satisfied, and we can create Presenter by passing them to the input via DI.
When we have done all this, we can go up the testing pyramid to integration tests.
At this stage, we take the layers where we are rigidly insulated from the platform (wrapper and View), and replace them with test implementations. After that, we can integratively test everything that is between them (and this is sometimes useful).
Instead of a conclusion
In the end, I want to quote Joshua Bloch’s famous quote: “Learning the art of programming, like most other disciplines, consists of learning the rules in the first step and learning how to break them in the second.”
The rules were stated above. The important part here is to understand how they work. And if you need to break the rules, this must be an informed decision. You should be aware of the consequences of breaking the rules. If you decide to break the rule, you must consciously accept the consequences. If you cannot put up with the consequences, you must not consciously violate them.
If mobile development is your main profile, you will probably be interested in these reports at our November Mobius 2017 Moscow conference :
- The Void of Heritage: A Strategy for Fundamental Improvements in the Project (Vladimir Ivanov, EPAM Systems)
- Service layer architecture using compound operations (Gleb Novik, Tinkoff)
- Crash Android NDK reports (Ivan Ponomarev, Akvelon)