The time has come to share with you this small article on testing Android app logic. It didn’t occur to me that it is a significant component of Android app development for quite a time but it’s never too late to learn, neither for you, nor for me :)
Overview
First, let’s learn what tests are and why do we need to write them. From Wikipedia:
Unit testing is a software testing method used for testing individual units of source code in order to determine whether they are fit for use.
The idea is to write code for each non-trivial function or method. It allows you to relatively quickly check if the latest change in code causes regression, i. e. new errors appear in the part of the program that was already tested, and makes it easy to identify and eliminate such errors.
Simply put, tests are methods that check the efficiency of your code. But how exactly does it all work? Let’s start from the beginning. Test is considered to be completed if no mistakes were encountered. And for various types of checks we use supplementary methods like assertXXX
“family” (see examples below).
There’re 2 types of unit tests in Android:
- Local unit tests which are tests that are performed using JVM only. They are meant for testing business logic that is not interacting with the operating system.
- Instrumented unit tests are tests used for testing logic interconnected with Android API. They are performed on physical device/emulator and thus take more time than local tests
So we have 2 types of tests and choose the relevant one based on the goals of the logic we test. Of course, it’s better to write local tests, if it’s possible.
Also, when creating tests, pay close attention to package organization. The following structure is practical:
app/src/main/java
— app source code.app/src/test/java
— local tests.app/src/androidTest/java
— instrumented tests.
Setup
If for some reasons you aren’t using Android Studio that generates them when you are creating a project, try to memorize the structure above. Furthermore, IDE configures Gradle file of app module:
android { ... defaultConfig{ ... testInstrumentationRunner"android.support.test.runner.AndroidJUnitRunner"} ... } dependencies { testImplementation "junit:junit:4.13.2" androidTestImplementation "androidx.test.ext:junit:1.1.5"}
A popular framework for making test writing process easier is called Mockito. Its features include imitation of objects and overseeing them, as well as supportive tools for conducting checkups. To start using it add a dependency:
testImplementation "org.mockito:mockito-core:5.8.0"
Local Tests In Android
Let’s now take a look at 2 very simple tests that check 2 objects for inconsistencies (2 numbers in our case):
@Test fun addition_isCorrect(){ assertEquals(4, 2+2)} @Test fun addition_isNotCorrect(){ assertEquals("Numbers isn't equals!", 5, 2+2)}
To do it create a class in the local test package and put our methods into it. For JUnit to know that these methods are tests they are marked with @Test
. assertEquals()
method shows you AssertionError
when the first (expected) and the second (result) do not correspond to each other.
Let’s run them (by right-clicking the class and choosing Run ‘ExampleUnitTest’ in the menu that appears) to see the output:
You can see that the first method was executed and the second gets an error since 5 != (2 + 2). You can also set up an expected exception for test using an expected
parameter:
@Test(expected =NullPointerException::class) fun nullStringTest(){ val str:String?=null assertTrue(str!!.isEmpty())}
In this case the test will be executed since we expected this exception. You can specify timeout
parameter for long operations and define the value in milliseconds. The test is considered failed if the method is not executed within the defined period.
@Test(timeout =10) fun timeoutTest(){ ... }
There’s also a quite interesting Matchers mechanism. For example, assertThat(T actual, Matcher<? super T> matcher)
accepts them as input. Methods of org.hamcrest
return them. CoreMatchers
class and belong to overlap logic operations. Let’s take a look at several of them:
@Test fun assertTest(){ val simpleList =1..10 val x =3 assertThat(x, `is`(3)) assertThat(x, `is`(not(4))) assertThat(simpleList, hasItem(3))}
As their names suggest, is()
can be described as “is equal”, is(not())
as “isn’t equal” and hasItem()
as a check for elements availability in the list. And it can be read as a coherent sentence. Here you can find a full list of matchers.
We have seen simple examples so far that can be used for writing simple tests. But I would also like you to take a look at Mockito library that can be used for making our tests superb!
As was mentioned earlier, Mockito is used for creating so called mock objects. Their goal is to replace complex objects that can’t be tested or aren’t suitable for tests. There’re two ways to declare them:
val mockContext:Context= mock(Context::class.java)
or
@Mock var mockedList: MutableList<String>= mutableListOf()
Note that in order to use @Mock
annotation you should mark your class with annotation:
@RunWith(MockitoJUnitRunner::class)
or call out in @Before
method next code:
@Before fun init(){ MockitoAnnotations.openMocks(this)}
After receiving mocked object you can do magic! For example, in order to override app name from the string resource you can do the following in @Before
method:
`when`(mockContext.getString(R.string.app_name)) .thenReturn("Fake name")
Now when getString()
method is called “Fake string” will be returned :
@Test fun checkAppName(){ assertEquals("Fake name", mockContext.getString(R.string.app_name)) assertNotEquals("My Application", mockContext.getString(R.string.app_name))}
There’s also a verify()
method that will fail the test if the specified method was not called out before that. Let’s take a look at the code:
@Test fun verifyListFail(){ mockedList.add("Useless string") mockedList.clear() verify(mockedList).add("one") verify(mockedList).clear()}
Here’s how it works: we pass our mock list to verify(mockedList)
and then specify methods we are interested in. In our case — adding string and clearing the list. An essential tool, I believe :)
But the full potential of this method opens up when you’re using spy objects. The main difference is that, unlike mocks, they aren’t created directly. You might ask: “So what’s the point then?” The point is that by creating a “spy” you can oversee it like a fake object:
@Test fun verifyList(){ mockedList.add("one") mockedList.clear() verify(mockedList).add("one") verify(mockedList).clear()}
Instrumented Tests In Android
Using this type of tests we get access to real context, as well as all the Android API capabilities. Apart from it, there’s a “God mode” we can use for managing the activity life cycle. First of all let’s add the required dependencies:
androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1" androidTestImplementation "androidx.test:runner:1.5.2" androidTestImplementation "androidx.test:rules:1.5.0"
For test to be recognized as instrumented, you should mark the class with the respective annotation:
@RunWith(AndroidJUnit4::class)class ExampleInstrumentedTest { ... }
Tests themselves are marked in same way as local tests — with @Test
annotation. Let’s check whether the package of our context corresponds to our app using the test method that Android Studio generates by default:
@Test fun useAppContext(){// Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("com.stfalcon.myapplication", appContext.packageName)}
Using the available tools you can listen to activities’ status:
@Test fun lictenLifeCycle(){ ActivityLifecycleMonitorRegistry.getInstance().addLifecycleCallback{ activity, stage -> Log.d("TAG", "$activity $stage")}}
You also can check if the view matches the matcher you are interested in or perform actions such as a click, text input etc.
@Test fun uiRelatedTest(){ onView(withId(R.id.text)).check(ViewAssertions.matches(ViewMatchers.isDisplayed())) onView(withId(R.id.text)).perform(ViewActions.typeText("Hello")) onView(withId(R.id.text)).perform(ViewActions.doubleClick())}
Conclusion
These are probably the most important things I found when familiarizing myself with the topic. Don’t throw stones at me :) But I think that it would be enough for starters. If you want to learn more about junit capabilities, I recommend you to read official documentation and study info on capabilities of the instrumented test.