For most people, the vast majority of work time is spent meeting requirements, writing business requirements, and then testing and launching. In this case, unit testing becomes a seemingly trivial, but somewhat mysterious existence that you may always want to try but never get a chance to implement. However, as a serious engineer, you should have some understanding of this necessary but not urgent knowledge to have a more complete technology stack.

Unit testing means exactly what it says: test the smallest unit that can run and make it stable. As long as each of the smallest units is correct, the correctness of the upper logic can be guaranteed. So unit tests should be written from the very basics until they cover the core logic. Built on top of the core logic is the UI interface that interacts with the user, which is part of UI testing, but since THE UI changes much more often than the core logic, UI testing is much less important than unit testing.

This article takes v1.1 of my Play Android project as an example to introduce the use of MockWebServer and Mockito commonly used in unit testing and Espresso commonly used in UI testing.

In this project, we used Room as a cache for local data, Retrofit for network requests, and the Repository model to complete the construction of M layer in the three-tier MVX architecture. Layer M basically covers all the core business logic, which means that our unit tests only need to cover Layer M. Room and Retrofit are the foundation of Repository, so the first step is to unit test the DAO and ApiService.

Unit testing of the API

First of all, the API is developed by the backend students, so they should be responsible for the correctness of the API, which sounds like nothing to us, and we really don’t need to write test code for every API. What we really need to do is make sure that the network module can parse the data correctly.

Usually API development follows certain specifications, such as defining returned data as a structure like this:

{
    "data":... ."errorCode": 0.// 0 indicates success, other indicates failure
    "errorMsg": ""
}
Copy the code

All we need to make sure is that the data is properly parsed by convention. One might say, what test is written for something so simple? There is some truth in this sentence, but it is also a bit impetuous. First of all, we certainly didn’t write the test code to prove that 1+1 really equals 2, that wouldn’t make much sense. The purpose of API testing is to make sure that data can be correctly parsed in any situation, to master the testing technique of simulating network requests, and to write the test code in the belief that it is better to be cautious.

To simulate a network request using the MockWebServer library, first add a dependency:

TestImplementation 'com. Squareup. Okhttp3: mockwebserver: 4.8.1'Copy the code

Then build an instance of Retrofit based on the MockWebServer class:

// As normal with Retrofit, just replace the URL with mockWebServer URL
Retrofit.Builder()
    .baseUrl(mockWebServer.url("/"))
    .addConverterFactory(serializationConverterFactory)
    .build()
Copy the code

Because MockWebServer doesn’t actually send the request, it doesn’t know what data to return. To simulate the request, we need to define the return content ourselves, using a class MockResponse. Now let’s simulate a network error request:

class NetworkTest { @Rule @JvmField val instantExecutorRule = InstantTaskExecutorRule() lateinit var service: TestService lateinit var mockWebServer: MockWebServer / / test Before initialization code @ ExperimentalSerializationApi @ Before fun setUp () {MockWebServer = MockWebServer (val) contentType = "application/json".toMediaType() val serializationConverterFactory = Json { ignoreUnknownKeys = true }.asConverterFactory(contentType) service = Retrofit.Builder() .baseUrl(mockWebServer.url("/")) . AddConverterFactory (serializationConverterFactory). The build (). The create (TestService: : class. Java)} / / testing After the completion of the closed @ After fun ClearUp () {mockWebServer.shutdown()} @test fun networkError() = runBlocking {// Construct a 401 return val mockResponse = MockResponse() .addHeader("Content-Type", "application/json; Charset = utF-8 ").setresponsecode (401) Call mockWebServer.enqueue(mockResponse) // Request data val resource = safeCall {val response: ApiResponse<FakeUser> = service.login("fake_username", "fake_password") response.toResource { if (it.data == null) Resource.empty() else Resource.success(it.data) } } // AssertEquals (resource, resource. Error <FakeUser>(401, "Client error "))}}Copy the code

As you can see, the biggest difference from normal development is the need to create your own return values and assert the results, a routine that is often used in subsequent unit tests. With that in mind, let’s complete the test code for normal return data and server return error:

@Test
fun getFakeUserSuccess(a) = runBlocking {
    val mockResponse = MockResponse().apply {
        setBody("" "{" data ": {" name" : "LiHua", "gender" : "male"}, "errorCode" : 0, "errorMsg" : "request is successful}" "" ".trimIndent())
    }
    mockWebServer.enqueue(mockResponse)

    val response: ApiResponse<FakeUser> = service.login("fake_username"."fake_password")
    assertTrue(response.isSuccess())
    assertEquals(response.data, FakeUser("LiHua"."male"))}Copy the code

We wrote json directly in the test code above, which made the readability very poor. We can deal with it in the form of files. Create the resources directory under the test directory, and then create a response directory to store JSON.

valinputStream = javaClass.classLoader!! .getResourceAsStream("response/get_user.json")
val bufferedSource = inputStream.source().buffer()
val mockResponse = MockResponse()
mockWebServer.enqueue(mockResponse.setBody(bufferedSource.readString(Charsets.UTF_8)))

mockWebServer.enqueue(mockResponse)
// ...
Copy the code

Unit testing of DAO

The first step in testing is to add dependencies:

AndroidTestImplementation 'androidx. Test: core: 1.2.0' androidTestImplementation 'androidx. Arch. The core: the core - testing: 2.1.0' AndroidTestImplementation 'androidx. Room: room - testing: 2.2.5'Copy the code

Room test is divided into two parts: upgrade test and DAO test. At present, the project has not been upgraded, so we can only write DAO test for the time being, and it will be followed up in the project when there is further upgrade.

Now look at the implementation of the UserDao:

@Dao
interface UserDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(user: User)

    @Query("SELECT * FROM user LIMIT 1")
    suspend fun getUser(a): User?

    @Query("SELECT * FROM user LIMIT 1")
    fun getUserLiveData(a): LiveData<User>

    @Query("DELETE FROM user")
    suspend fun clearAllUsers(a)
}
Copy the code

Although getUser and getUserLiveData execute the same SQL statement, they are applicable to different scenarios. On the one hand, we hope to update the page automatically when data changes with the help of LiveData features, and on the other hand, we also hope to obtain user information at any time, such as determining whether the user logs in.

Back to the subject, what tests should we write? The most rigorous approach is to test each method in a comprehensive way, testing how each method performs under various scenarios, such as correct, abnormal, and so on. But it takes a lot of effort, and there are many scenarios where testing is meaningless. For example, since we’re pretty clear that 1+1=2, we don’t need to write tests for the obvious. There is also no need to write similar test code for each additional Dao as the “same” behavior, such as Insert+Select+Delete, is equivalent to the UserDao as well as to any other Dao. So in my opinion, just write tests on code you suspect. Doubt is subjective. Some people are cautious, others are careless, so I suggest you be confident but don’t be conceited, because you will end up paying the bill when things go wrong. . Of course, if you have enough time, the full range of testing is best.

The first test

Let’s start to write the first test, such as test OnConflictStrategy. The effect of the REPLACE, need to insert a data, first take out look, and then insert another data (the primary key values are the same), then, if every time to take out the results and expected, after the function running normally. Before writing tests, it is necessary to connect to the database, so Room’s inMemoryDatabaseBuilder comes in handy.

open class DbTest {
    protected val db: WanAndroidDb
        get() = _db

    @get:Rule
    var instantTaskExecutorRule = InstantTaskExecutorRule()

    private lateinit var _db: WanAndroidDb

    @Before
    fun initDb(a) {
        val context = ApplicationProvider.getApplicationContext<Context>()
        _db = Room.inMemoryDatabaseBuilder(context, WanAndroidDb::class.java).build()
    }

    @After
    fun closeDb(a) {
        _db.close()
    }
}
Copy the code

Use inMemoryDatabaseBuilder to get the Db object used by the test, where @before and @After represent the code that will be executed Before and After the test, respectively. As well as @get:Rule = InstantTaskExecutorRule(), it can convert asynchronous tasks to synchronous execution, as described in Developer.Android:

A JUnit Test Rule that swaps the background executor used by the Architecture Components with a different one which executes each task synchronously.

You can use this rule for your host side tests that use Architecture Components.

Now that we have Db, we can finally Test our code using the @test annotation, and we can quickly write code like this:

class UserDaoTest : DbTest() {
    @Test
    fun insertAndReplace(a) = runBlocking {
        val userDao = db.userDao()
        // create the first instance where uid is the primary key
        val user = createUser(uid = 100, nickname = "nick1")
        userDao.insert(user)
        // Get the data from the database and make sure it is the same as the inserted data
        valgetFromDb = userDao.getUser() assertThat(getFromDb, notNullValue()) assertThat(getFromDb!! .id, `is` (100))
        assertThat(getFromDb.nickname, `is` ("nick1"))

        // Can be replaced only when the primary key value is the same
        val replaceUser = createUser(uid = 100, nickname = "nick2")
        userDao.insert(replaceUser)
        // Get from the database and verify that the data has been updated
        valgetReplaceUserFromDb = userDao.getUser() assertThat(getReplaceUserFromDb, notNullValue()) assertThat(getReplaceUserFromDb!! .id, `is` (100))
        assertThat(getReplaceUserFromDb.nickname, `is` ("nick2"))}}Copy the code

Click the Run button to the left of the method to see the test results:

Test LiveData

The UserDao will return a LiveData, and we want to make sure that the LiveData will be updated automatically when the database content changes, but LiveData requires an Observer to be activated, which can be fixed by observeForever.

// Automatically unsubscribe
fun <T> LiveData<T>.getOrAwaitValue(
    time: Long = 2,
    timeUnit: TimeUnit = TimeUnit.SECONDS,
    afterObserve: () -> Unit= {}): T {
    var data: T? = null
    val latch = CountDownLatch(1)
    val observer = object : Observer<T> {
        override fun onChanged(o: T?). {
            data = o
            latch.countDown()
            this@getOrAwaitValue.removeObserver(this)}}this.observeForever(observer)

    afterObserve.invoke()

    // Don't wait indefinitely if the LiveData is not set.
    if(! latch.await(time, timeUnit)) {this.removeObserver(observer)
        throw TimeoutException("LiveData value was never set.")}@Suppress("UNCHECKED_CAST")
    return data as T
}

@Test
fun notifyLiveData(a) = runBlocking {
    val userDao = db.userDao()

    val user = createUser(uid = 100, nickname = "nick1")
    userDao.insert(user)

    val liveData = userDao.getUserLiveData()

    val user1 = liveData.getOrAwaitValue()
    assertEquals(user1, user)

    val replaceUser = createUser(uid = 100, nickname = "nick2")
    userDao.insert(replaceUser)

    val user2 = liveData.getOrAwaitValue()
    assertEquals(user2, replaceUser)
}
Copy the code

RoomTrackingLiveData: RoomTrackingLiveData: RoomTrackingLiveData: RoomTrackingLiveData: RoomTrackingLiveData: RoomTrackingLiveData: RoomTrackingLiveData

@Override
protected void onActive(a) {
    super.onActive();
    mContainer.onActive(this);
    getQueryExecutor().execute(mRefreshRunnable);
}
Copy the code

The Repository test

Testing the Dao ensures that the program is correct on the base unit, but it’s not enough. To have enough confidence in the code, at least complete the M-layer tests, which will follow with the Repository tests. If you’re not familiar with Repository, check out the description in Android App Architecture. Let’s take a simple Repository and look at what to look for when writing tests for it.

interface ClassifyRepository {
    fun getClassifies(forceUpdate: Boolean): LiveData<Resource<List<ClassifyModel>>>
}

class DefaultClassifyRepository(
    private val classifyDao: ClassifyDao,
    private val classifyService: ClassifyService
) : BaseRepository(), ClassifyRepository {

    override fun getClassifies(forceUpdate: Boolean): LiveData<Resource<List<ClassifyModel>>> {
        return liveData {
            if (forceUpdate) {
                emit(fetchFromNetwork())
            } else {
                val dbResult = classifyDao.getClassifies()
                if (dbResult.isNotEmpty()) {
                    emit(Resource.success(dbResult))
                } else {
                    emit(fetchFromNetwork())
                }
            }
        }
    }

    private suspend fun fetchFromNetwork(a): Resource<List<ClassifyModel>> {
        return safeCall {
            val classifies = classifyService.getClassifies()
            classifies.data? .let { classifyDao.insertClassifies(it) } classifies.toResource {if (it.data.isNullOrEmpty()) {
                    Resource.empty()
                } else {
                    Resource.success(it.data)}}}}}Copy the code

It is very simple: the data is obtained from the local, but cannot be obtained from the network. After obtained from the network, the data is saved to the local, so that the cache data is preferentially obtained. What are the scenarios to consider if you’re writing test code for it? If forceUpdate=true, ensure that data is fetched directly from the network. Otherwise, ensure that network requests are not triggered if there is data cached, network requests are triggered if there is no data cached, and Error is returned if the network request also fails. Maybe you will say, this code will look according to the idea that the execution, but only when you want to check if it is correct to run, waiting for the network request return data, compiled from running again the process of waiting for the network takes a lot of time and energy, what’s more, you may see no expectations when network error pages. Unit testing has none of these problems, which is one of the advantages of unit testing.

Unlike Dao, we used Mockito for logical tests. It allows us to simulate an object instead of using a real instance, which not only validates the logic, but also saves the execution time of real object methods. Because logical tests are platform independent, they can be written in the test directory. Again, the first step is to add dependencies:

testImplementation 'junit: junit: 4.13'
testImplementation 'org. Mockito: mockito - core: 2.25.0'
testImplementation 'androidx. Arch. The core: the core - testing: 2.1.0'
Copy the code

Thanks to Mockito’s excellent design, the code we wrote for the tests was as fluent as natural language, so the following code can be easily understood and I don’t need to repeat it here:

class ClassifyRepositoryTest {
    @get:Rule
    var instantTaskExecutorRule = InstantTaskExecutorRule()

    // Simulate an object
    private val classifyDao = mock(ClassifyDao::class.java)
    private val classifyService = mock(ClassifyService::class.java)

    private val classifyRepository = DefaultClassifyRepository(classifyDao, classifyService)

    @Test
    fun getClassifiesForceUpdate(a) = runBlocking {
        val models = listOf(
            ClassifyModel(classifyId = 0, name = "", courseId = 0, parentChapterId = 0))val response = ApiResponse(data = models)
        // Before calling a mock object method, define how to execute the method
        `when`(classifyService.getClassifies()).thenReturn(response)

        val result = classifyRepository.getClassifies(true)

        val observer = mock<Observer<Resource<List<ClassifyModel>>>>()
        result.observeForever(observer)

        // Verify that the method is called
        verify(classifyService).getClassifies()
        verify(classifyDao).insertClassifies(anyList())
        verifyNoMoreInteractions(classifyService)
        verifyNoMoreInteractions(classifyDao)
        verify(observer).onChanged(Resource.success(models))
    }
}
Copy the code

With when and Verify, you can test any logic, and with proper design, you can test any scenario your program might encounter without relying on external resources.

With the Repository layer tests done, and the M-layer covered, you can now say with confidence that the core business is almost Bug free (why almost? There are always some things that will surprise you. Now let’s focus on the last layer, the UI layer. Although UI testing is not as important as the M layer because of its frequent changes, it is worth writing about if you have enough time, and it will save you a lot of time later.

UI test

1. Understand Espresso and IdlingResource

When coding, we always write a part of the code and run it to see the effect. In fact, this is the UI test itself, but it is human-driven, and very dependent on the external environment, if the server is down after running, your mood must be a little fluctuate, right? Sometimes you just want to see what happens when something goes wrong, so you have to change the code and run it again… After writing, happily push the code to the remote end, only to find that the changed code has not changed back, this time your mood should be much more obvious, right? If you don’t want to be so volatile, you really need to know about UI testing.

We want to write code that doesn’t need to be deleted, that automatically executes when you click start, that you can test it whether the network is good or not, and that you can test it all at the same time. Luckily Espresso can do all of that.

By our design, the ViewModel does very little as a “puppet”, so its unit tests are of minimal significance. Of course, if necessary, the tests should be done in the same way as Repository tests, which will not be described here. Here’s a quick look at the basic structure of Espresso:

// Add dependencies
androidTestImplementation 'androidx. Test. Espresso: espresso - core: 3.3.0'

class IdlingResourceActivityTest {
    @Test
    fun testEspresso(a) {
        val scenario = ActivityScenario.launch(IdlingResourceActivity::class.java)

        onView(withId(R.id.et_name)).perform(typeText("wanandroid"), closeSoftKeyboard())
        onView(withId(R.id.et_pwd)).perform(typeText("123456"), closeSoftKeyboard())
        onView(withId(R.id.btn_login)).perform(click())
    }
}
Copy the code

If you run the Test, you’ll see the phone automatically opens the page, automatically fills in the content and hits the login button. But nothing is being done, and we need to be able to verify the results after doing something. Now let’s add a confirmation statement:

onView(withId(R.id.tv_login_success)).check(matches(ViewMatchers.withText(R.string.text_login_success)))

Copy the code

Our intention was that tv_login_success would be assigned text after the button was clicked, but this test failed. The reason for this is that we simulate a time-consuming operation after the button is clicked, which takes a little time to return the result, but the checked statement is executed immediately. This kind of time-consuming manipulation is common in UI testing, and fortunately Espresso has the ability to handle it, but it does require a little preparation.

Add a dependency first, noting that it is a production dependency rather than a test dependency:

Implementation 'androidx. Test. Espresso: espresso - idling - resource: 3.3.0'Copy the code

Espresso provides an IdlingResource interface and has a CountingIdlingResource implementation by default. Espresso uses IdlingResource#isIdleNow to know if it is idle to continue testing. So before a time-consuming operation starts, we need to tell Espresso that it’s “busy”, and after a time-consuming operation we need to tell Espresso that it’s “busy”. This action needs to be done in a production project, so it’s a bit of an “extra” preparation for Espresso. Now let’s make a small change to IdlingResourceActivity:

// Use a single CountingIdlingResource object
object EspressoIdlingResource {
    private const val RESOURCE = "GLOBAL"
    var countingIdlingResource = CountingIdlingResource(RESOURCE, true)
        private set

    fun increment(a) {
        countingIdlingResource.increment()
    }

    fun decrement(a) {
        if(! countingIdlingResource.isIdleNow) { countingIdlingResource.decrement() } } }class IdlingResourceActivity : BaseActivity() {
    override fun onCreate(savedInstanceState: Bundle?). {
        / /...
        btn_login.setOnClickListener {
            lifecycleScope.launch {
                fetchFakeData {
                    tv_login_success.text = it
                }
            }
        }
    }

    private suspend fun fetchFakeData(callback: (text: String) - >Unit) {
        withContext(Dispatchers.IO) {
            // The time-consuming task starts
            EspressoIdlingResource.increment()
            delay(3000)
            withContext(Dispatchers.Main) {
                // Time-consuming tasks are completed
                EspressoIdlingResource.decrement()
                callback.invoke(getString(R.string.text_login_success))
            }
        }
    }

    @VisibleForTesting
    fun getIdlingResource(a): IdlingResource {
        return EspressoIdlingResource.countingIdlingResource
    }
}
Copy the code

Next, let’s improve the test code by adding IdlingResource:

class IdlingResourceActivityTest {
    private lateinit var idlingResource: IdlingResource

    @Test
    fun testEspresso(a) {
        val scenario = ActivityScenario.launch(IdlingResourceActivity::class.java)
        scenario.onActivity {
            idlingResource = it.getIdlingResource()
            IdlingRegistry.getInstance().register(idlingResource)
        }

        onView(withId(R.id.et_name)).perform(typeText("wanandroid"), closeSoftKeyboard())
        onView(withId(R.id.et_pwd)).perform(typeText("123456"), closeSoftKeyboard())
        onView(withId(R.id.btn_login)).perform(click())

        onView(withId(R.id.tv_login_success)).check(matches(ViewMatchers.withText(R.string.text_login_success)))
    }

    @After
    fun release(a) {
        IdlingRegistry.getInstance().unregister(idlingResource)
    }
}
Copy the code

Espresso was able to be sensitive to time-consuming tasks, but the problem was that (1) we had to modify all time-consuming tasks for notification, and (2) such tests depended on the real world, had accidents due to network and other reasons, and couldn’t test all boundaries. So this approach is only appropriate for situations where you explicitly want to execute in real code, not for large-scale use.

2. Better ways to test

To override all the boundaries of a problem, instead of relying on real network requests, use Mockito (yes, mocktio when testing Repository). With Mockito, we can fake success and fake failure without much time. To use Mockito, time-consuming operations cannot be written directly into the Activity, so we need to make a small change. Here we use ViewModel:

class IdlingResourceActivity : BaseActivity() {
    private val viewModelFactory: ViewModelProvider.Factory =
        IdlingResourceInjection.provideViewModelFactory()
    private val viewModel: IdlingResourceViewModel by viewModels { viewModelFactory }

    override fun onCreate(savedInstanceState: Bundle?). {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.idling_resource_activity)
        btn_login.setOnClickListener {
            viewModel.fetchFakeData()
        }

        viewModel.fakeData.observe(this, {
            tv_login_success.text = getString(it)
        })
    }
}
Copy the code

Finally, we mock a ViewModel object to achieve our purpose:

class IdlingResourceActivityTest {
    private val viewModel = mock(IdlingResourceViewModel::class.java)

    @Test
    fun fakeTest(a) {
        val fakeData = MutableLiveData<Int>()
        doReturn(fakeData).`when`(viewModel).fakeData
        `when`(viewModel.fetchFakeData()).then {
            fakeData.postValue(R.string.text_login_success)
        }
        IdlingResourceInjection.viewModel = viewModel

        val scenario = ActivityScenario.launch(IdlingResourceActivity::class.java)
        onView(withId(R.id.et_name)).perform(typeText("wanandroid"), closeSoftKeyboard())
        onView(withId(R.id.et_pwd)).perform(typeText("123456"), closeSoftKeyboard())
        onView(withId(R.id.btn_login)).perform(click())

        onView(withId(R.id.tv_login_success)).check(matches(ViewMatchers.withText(R.string.text_login_success)))
    }
Copy the code

conclusion

After all of this, we have an overview of Android unit testing. First to be sure the unit test is very useful, can greatly enhance our confidence in the code, but the writing test is a manual process into automated process, is not only tedious and boring, also test the degree of patience and careful, so in the case of insufficient manpower or time should be prior written tests of core business, Ensure inputs and outputs have the highest “value for money”.


I am aeroplane sauce, if you like my article, you can follow me ~

The path of programming is long and difficult. However, the road ahead is long, I see no end, I will search high and low.