📜 ⬆️ ⬇️

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 large number of code examples and illustrations, based on Anton’s speech at the Mobius 2017 conference in St. Petersburg. Anton is a developer of Android applications in Juno, and in his work touches on a variety of related technologies. This report is not about Android and not about Kotlin, it is about testing in general, about ideas that lie above the platform and above the language and that can be adapted to any context.



Why do we need tests?


First you need to decide on why we are writing or want to write tests for your code. There can be several reasons:
')

And, perhaps, the most important reason is that the project can live and develop for a long time (that is, change). Development refers to adding new features, correcting errors, and refactoring.

As our code base grows, the chances of making a mistake increase because the base becomes more complex. And when it goes into production, the cost of the error increases. As a result, there is often a fear of modifications, which is very difficult to fight.

Here are two global tasks that we solve when we write a long-lived project:


What is test code?


What can go wrong when trying to write a test? Often the system just is not ready for this. It can be so connected with the adjacent parts that we cannot set any input parameters to check that everything is working correctly.



To avoid such situations, you need to write the code correctly, that is, to make it testable.

What is test code? To answer this question, you first need to 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 for expected ones. The tested 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 in the examples.

Rule 1. Pass arguments and return values ​​explicitly.


Let's look at testing a function (a certain function in vacuum, which 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 the inputs here. First, the prefix, which 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). To make a pure function from such a function, it is necessary to remove the implicit inputs and outputs. In this case, it is controlled by testing. For example:

 fun ItemDescription(prefix: String, itemIndex: Int): String { return "$prefix: $itemIndex" } 

In other words, it is easy to test a function 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 the module as a function. Suppose a module is a function whose call is extended in time, that is, part of the input parameters are transferred at some time, part in the next line, part after some time-out, then some other part and t .d And the same with exits: part now, part later, etc.

 M(In[1], ... , In[N]) -˃ (Out[1], ... , Out[L]) 

How could the ins and outs of such a function module look like? Let's try first to look at the code, and then we will do a more general picture:

 class Module( val title: String //input ){} 

The very fact of calling such a class constructor 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 a dependency explicit if it was passed through a 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 go to the exits. Returning a value from a field is a way out. Modifying this value is the output of the function, since we can then test it from the outside.

 class Module( ){ var state = "Some state" fun doSomething() { state = "New state" // output // … } } 

Modification of some external state is also the way out. It can be explicit, like this:

 class Module( val dependency: Explicit // dependency ){ var state = "Some State" fun doSomething() { state = "New State" // output dependency.setCurrentState("New state") //output // … } } 

Or implicit, like this:

 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:


Approximately the same with exits:

 Out[1], … , Out[N] 

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 for this module, is a call to this function and validation of results. That is what we write in the given and when blocks (if we use the given and the annotation), this is the process of calling functions, and then the process of validating the results.



Thus, a module becomes simple for testing if all its inputs and outputs are passed 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 substitutability in tests


Even with explicit arguments and explicit dependencies, we still do not get complete control, and here's why.

For example, in the module there is 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 it:

 class Module(explicit: Explicit) { val tripled = 3 * explicit.getValue() } @Test fun testValueGetsTripled() { } 

Somehow we prepare our module, take the value of the Tripled field from it, write it to the result, expect it to be 15, and check that 15 equals 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 obvious dependency in order to say that it returns the top five and we need to get 15 as a result? It strongly depends on what the apparent dependence is.

If an obvious dependency is a singleton, then in tests we cannot say to him: “Return the top five!”, Because the code is already written, and we cannot modify the code in the tests.

 // 'object' stands for Singleton in Kotlin object Explicit { fun getValue(): Int = ... } 

Accordingly, the test does not work for us - we cannot transfer there a normal dependency.

 // '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 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 singletons, or from some implicit places. And then, in principle, there should be no problems in order to test the code through a 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 test code in practice


It's hard for me to imagine the code under test without some kind of architecture, so I’ll use MVP as an example. There is a user interface, a View layer, models where business logic is conditionally assembled, a layer of platform adapters (designed to isolate models from the API), as well as platform APIs and third-party APIs that are not related to the user interface.



We test here Model and Presenter, because they are completely isolated from the platform.

What are the obvious inputs and outputs


We have a class and a variety of explicit inputs: a line at the input, lambda, Observable, method call, as well as all the same, made through an explicit dependency.

 class ModuleInputs( input: String, inputLambda: () -> String, inputObservable: Observable<String>, dependency: Explicit ) { private val someField = dependency.getInput() fun passInput(input: String) { } } 

The situation with exits is very similar. The output can be the return of a value from a method, the return of a value through a 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:

  1. Singletons
  2. Random number generators
  3. File system and other storage
  4. Time
  5. Formatting and locale

Now about each of them in more detail.

Singletons


We can not modify the behavior of singletons in tests.

 class Module { private val state = Implicit.getCurrentState() } 

Therefore, they need to endure as an obvious 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 the class random. But here he is already jerking inside some static methods that 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 make the 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 which 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 cunning: it returns true when successful, and false if it contains the same file. And we, for example, need not just to create a file, but also to understand: whether 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, 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 fetch 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. And 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 either somehow prepare the file system, or make everything that has side effects behind the 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, in the module there is a logic that waits for half a minute. If you plan to write a test for it, you do not want the test to wait half a minute, because all unit tests should take a total of half a minute. We want to be able to control time, so, again, all the work over time makes sense for 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 time forward or even backward. . Then you can, say, test how your module behaves if you change the clock.

 class Module (time: TimeProvider) { private val nowTime = time.nowMillis() private val nowDate = time.nowDate() // and all other time/date APIs } 

Formatting and locale


This is the most insidious implicit input. Say, a regular Presenter takes some time stamp, formats it according to a strictly defined template (no AM or PM, no commas, everything seems to be specified) and stores in the field:

 class MyTimePresenter(timestamp: Long) { val formattedTimestamp = SimpleDateFormat("yyyy-MM-dd HH:mm").format(timestamp) } 

We write a test for it. We don’t want to think about what it is like in a formatted form, it’s easier for us to launch a module on it, see what it outputs to us, and write it 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 saw that Mobius begins on April 21 at 10 am:

 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, we start it on the local machine, everything works:

 >> `actual on dev machine` = "2017-04-21 10:00" // UTC +3 

We run it on CI, and for some reason there Mobius starts at 7.

 >> `actual on CI` = "2017-04-21 07:00" // UTC 

CI is a different time zone. It is in the UTC + 0 time zone and, accordingly, time is formatted differently there, because SimpleDateFormat uses the default time zone. In the tests, we did not redefine it, respectively, in CI servers that are at zero GMT, we have another way out. And this insidious all the entrances that are associated with the location, including:


How to mock dependencies in tests


They say that there is no “silver bullet”, but it seems to me that it is relative to mocking. Because interfaces work everywhere. If you hid your implementation behind the interface, you can be sure that you can replace it in the tests, because the interfaces are replaced.

 interface MyService { fun doSomething() class Impl(): MyService { override fun doSomething() { /* ... */ } } } class TestService: MyService { override fun doSomething() { /* ... */ } } val mockService = mock<MYService>() 

Interfaces even sometimes help to do something with singletons. Let's say the project has a singleton, which is a GodObject. You can not disassemble 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 a part of the public API and make it so that the singleton implements this interface. And instead of using a singleton in the module, you can pass this interface as an explicit dependency. Outside, of course, it will be the transfer of the same GetInstance, but inside you will already be working with a clean interface. This can be an intermediate step, until everything is switched to 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 mock final classes, static methods, singletons. Of course, you can mock them: there is Mockito2 for final-classes, there is a PowerMock for final-classes, static-methods, singltons, but there are a number of problems with them:


Abstraction from the platform and why it is needed


Abstraction occurs on the View layer and on the layer of platform adapters.


Abstraction on the View layer


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 the View. Then we have a certain trivial Presenter, which accepts the view interface as 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 the platform way what it needs to do, for example, display some 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: we create in OnCreate and, as View, we pass this. So many do, quite a 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, in order to enable it to 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 a little off. 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 to which we pass it, and check that a method is called.

 @Test fun testLoadingIsShown() { val mockedView = mock<SplashView>() SplashPresenter(mockedView) verify (mockedView).showLoading() } 

The only difference is how you look at the roles of the Activity and View. If it seems to you that the role of the View is large enough not to mix it with other roles of the Activity, then putting it into a separate class is a good idea.

Platform Wrappers layer abstraction


Now what concerns abstraction from the platform on the layer of platform adapters. Platform wrappers are an isolation of the Model layer. The problem is that behind this layer on the platform side there is a third-party platform API and API, and we cannot generally modify them, because they come in different forms. They can come as static methods, as singltons, 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 may 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, which in the most trivial case does nothing but pass the methods of our third-party API.

 interface Wrapper { fun doSomething() class Impl: Wrapper { override fun doSomething() { ThirdParty.doSomething()     } } } 

We received a wrapper with the implementation, hid it behind the interface and, accordingly, in the module we already call the Wrapper, which comes as an obvious dependency.

 class Module(wrapper: Wrapper) { init { wrapper.doSomething() } } 

In addition to guaranteed testability, this gives the following:


Multiple static calls can be Design Smell, but it strongly depends on what is in these static calls. We mean that static calls are pure functions. If they change global variables, then this is Smell. If nothing hypercomplex occurs in them and you are ready to cover the functionality of this static method in each place of its use with tests for the entire module where it is called, then this is not Smell, but finding a balance. And the rules can and should be retreated.

Access to resources


Android has IDs for strings and other resources. Sometimes in presenters or in other places 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() } } 

The resources are already our interface, this is not the Android interface, but we are transferring to it all the same end ID ID. And note that, in fact, this is just an end id:

 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 it enough for you that this ID has come to verify that everything behaves correctly. Usually it is enough.

 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 already guaranteed to isolate it.

What is the implementation detail


We have a module, it has an input. He internally from this input counted some state, recorded 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 good, we wrote a test for it, and then we got another module, in which there is a 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 make this logic for 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 function if it is a conditional part of the implementation. If this is a rather complicated thing, then it may make sense to put it out as an explicit dependency and pass it on 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 verify that the calculateInitialState method was called with such a parameter. The same applies to the internal classes, extension-functions (if we talk about Kotlin), static-functions, that is, everything that is an implementation part and can be jerked from several places.

How to start if our codebase 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 take them as inputs, and so we gradually build our global dependency graph.



It looks something like the instructions for drawing an owl from a comic beginner's guide.



As a result, you will receive the following:


If we have done all this qualitatively, we can take some kind of entry point from the framework (Activity, service, broadcast receiver ...), create some kind of wrapper around it (in the case of Activity it can be View), take our dependency graph which we did earlier and create a presenter. All dependencies are already satisfied, and we can create a Presenter, passing them to the input via DI.



When we have done all this, we can go up the testing pyramid for integration tests.


At this stage, we take the layers, where we are strictly insulated from the platform (wrapper and View), and replace them with test implementations. After that, we can integrate testing everything that is between them (and this is sometimes useful).



Instead of conclusion


In the end, I want to quote Joshua Bloch’s famous quotation: “Learning the art of programming, like most other disciplines, consists of learning the rules at the first stage and learning how to break them - at the second.”

Above it was stated exactly the rules. The important part here is to understand how they work. And if you need to break the rules, it must be a conscious decision. You should know what the consequences may be due to a violation of the rules. If you decide to break the rule, you must consciously accept the consequences. If you can not put up with the consequences, you must not consciously violate them.




If mobile development is your main profile, you will certainly be interested in these reports at our November Mobius 2017 Moscow conference :

Source: https://habr.com/ru/post/341944/


All Articles