a284628487 / AndroidPoint

Android Note
1 stars 0 forks source link

Android Unit Test #14

Open a284628487 opened 3 years ago

a284628487 commented 3 years ago

dependencies

    testImplementation "junit:junit:4.13.2"
    testImplementation "androidx.test:core:1.3.0"
    testImplementation "androidx.test:core-ktx:1.3.0"
    testImplementation "androidx.test.ext:junit:1.1.2"
    testImplementation "androidx.test.ext:junit-ktx:1.1.2"
    testImplementation "androidx.arch.core:core-testing:2.1.0"
    testImplementation "org.robolectric:robolectric:4.3.1"
a284628487 commented 3 years ago

ViewMode & LiveData

MainViewModel

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData

class MainViewModel(app: Application) : AndroidViewModel(app) {

    fun doubled(input: Int): Int {
        return input * 2
    }

    fun formatTime(hour: Int, minute: Int): String {
        return getApplication<Application>().getString(R.string.time_format, hour, minute)
    }

    private val _resultLiveData = MutableLiveData<Int>()

    val resultLiveData = _resultLiveData

    fun commitPreResult(input: Int) {
        if (input > 5) {
            _resultLiveData.postValue(100)
        } else {
            _resultLiveData.postValue(input * 20)
        }
    }
}

MainViewModelTest

import android.app.Application
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.After
import org.junit.Before
import org.junit.Test

import org.junit.Assert.*
import org.junit.Rule
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class MainViewModelTest {

    @get:Rule
    var instantExecutorRule = InstantTaskExecutorRule()

    lateinit var viewModel: MainViewModel

    @Before
    fun setUp() {
        val applicationContext = ApplicationProvider.getApplicationContext<Application>()
        viewModel = MainViewModel(applicationContext)
    }

    @After
    fun tearDown() {
    }

    @Test
    fun doubled_input10_return20() {
        val result = viewModel.doubled(10)
        println("doubled_input10_return20: ${result}")
        assertEquals(result, 20)
    }

    @Test
    fun formatTime_inputHour10Min10_return10H10m() {
        val result = viewModel.formatTime(10, 10)
        println("formatTime_inputHour10Min10_return10H10m: ${result}")
        assertEquals(result, "10H:10m")
    }

    @Test
    fun commitPreResult_input2_return40() {
        viewModel.commitPreResult(2)
        val result = viewModel.resultLiveData.getOrAwaitValue()
        println("commitPreResult_input2_return40: ${result}")
        assertEquals(result, 40)
    }
}

LiveDataTestUtil

import androidx.annotation.VisibleForTesting
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException

@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun <T> LiveData<T>.getOrAwaitValue(
    time: Long = 5,
    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)

    try {
        afterObserve.invoke()

        // Don't wait indefinitely if the LiveData is not set.
        if (!latch.await(time, timeUnit)) {
            throw TimeoutException("LiveData value was never set.")
        }

    } finally {
        this.removeObserver(observer)
    }

    @Suppress("UNCHECKED_CAST")
    return data as T
}
a284628487 commented 3 years ago

Access Resources

Add testOptions

android {
    //...
    testOptions.unitTests {
        includeAndroidResources = true
    }
}

add test/resources/robolectric.properties

sdk=28

Modify Locale

        val context: Application = ApplicationProvider.getApplicationContext()
        val configuration = context.resources.configuration
        val locale: Locale = Locale.CHINESE
        configuration.setLocale(locale)
        configuration.setLocales(LocaleList(locale))
        context.resources.updateConfiguration(configuration, context.resources.displayMetrics)
a284628487 commented 3 years ago

Mockito

dependencies

    testImplementation "org.mockito:mockito-core:2.28.2"
    androidTestImplementation "org.mockito:mockito-android:2.28.2"

BusinessImpl

data class UnsplashPhotoUrls(
    @field:SerializedName("small") var small: String
)

interface UnsplashService {

    @GET("search/photos")
    suspend fun searchPhoto(
        @Query("query") query: String
    ): Response<UnsplashPhotoUrls?>
}

class UnsplashRepository @Inject constructor(private val service: UnsplashService) {

    val someHowFlow = MutableStateFlow(0)

    suspend fun searchPhoto(): Response<UnsplashPhotoUrls?> {
        return withContext(Dispatchers.IO) {
            val response = service.searchPhoto("")
            response.body()?.apply {
                small = small.replace("png", "webp")
            }
            someHowFlow.value = response.code()
            response
        }
    }
}

@HiltViewModel
class GalleryViewModel @Inject constructor(
    private val repository: UnsplashRepository
) : ViewModel() {

    val searchPhotoStatus = repository.someHowFlow.asLiveData()
}

UnitTest

@RunWith(AndroidJUnit4::class)
class ApiMockTest {

    @get:Rule
    var instantTaskExecutorRule = InstantTaskExecutorRule()

    @Mock
    private lateinit var apiService: UnsplashService

    lateinit var repo: UnsplashRepository

    lateinit var viewModel: GalleryViewModel

    @Before
    fun setUp() {
        MockitoAnnotations.initMocks(this)

        repo = UnsplashRepository(apiService)
        viewModel = GalleryViewModel(repo)
    }

    @Test
    fun testApi() {
        runBlocking {
            val mockResponse = UnsplashPhotoUrls("https://www.xxx/aaa.png")
            Mockito.`when`(apiService.searchPhoto(""))
                .thenReturn(
                    Response.success(mockResponse)
                )
            // invoke api direct
            val response = apiService.searchPhoto("")
            println(response.isSuccessful)
            println(response.body())
            assert(response.isSuccessful)
            assert(response.body()?.small?.endsWith("png") == true)

            // invoke api from repo and do some convert or something else
            val response2 = repo.searchPhoto()
            println(response2.isSuccessful)
            println(response2.body())
            assert(response2.body()?.small?.endsWith("webp") == true)
            assert(repo.someHowFlow.first() == 200)

            // test ViewModel business
            val statusValue = getValue(viewModel.searchPhotoStatus)
            println(statusValue)
            assert(statusValue == 200)
        }
    }
}

fun <T> getValue(liveData: LiveData<T>): T {
    val data = arrayOfNulls<Any>(1)
    val latch = CountDownLatch(1)
    liveData.observeForever { o ->
        data[0] = o
        latch.countDown()
    }
    latch.await(2, TimeUnit.SECONDS)

    @Suppress("UNCHECKED_CAST")
    return data[0] as T
}
a284628487 commented 3 years ago

Room

Database & Dao


@Entity(tableName = RoomConstants.DB_TABLE_WORD)
data class Word(@PrimaryKey(autoGenerate = true) var id: Int = 0,
                @ColumnInfo(name = "word") val word: String)

@Dao
interface WordDao {

    @Query("SELECT * FROM ${RoomConstants.DB_TABLE_WORD} ORDER BY word ASC")
    fun getAllWords(): Flow<List<Word>>

    @Update(onConflict = OnConflictStrategy.IGNORE)
    suspend fun update(word: Word): Int

    @Query("SELECT * FROM ${RoomConstants.DB_TABLE_WORD} WHERE word LIKE :word")
    suspend fun queryByWord(word: String): List<Word>
}

@Database(entities = [Word::class], version = 1, exportSchema = false)
abstract class WordRoomDatabase : RoomDatabase() {

    abstract fun wordDao(): WordDao

    companion object {
        @Volatile
        private var INSTANCE: WordRoomDatabase? = null

        fun getDatabase(context: Context, scope: CoroutineScope): WordRoomDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance =
                    Room.databaseBuilder(context, WordRoomDatabase::class.java, "word_db.db")
                        .build()
                INSTANCE = instance
                INSTANCE!!
            }
        }
    }
}

UnitTest


@RunWith(AndroidJUnit4::class)
class ExampleUnitTest {

    private lateinit var database: WordRoomDatabase

    private lateinit var wordDao: WordDao

    @Before
    fun createDb() {
        runBlocking {
            launch(Dispatchers.IO) {
                val context = ApplicationProvider.getApplicationContext<Application>()
                database =
                    Room.inMemoryDatabaseBuilder(context, WordRoomDatabase::class.java).build()
                wordDao = database.wordDao()
                wordDao.insert(Word(word = "Hello"))
                wordDao.insert(Word(word = "World"))
            }
        }
    }

    @Test
    fun testDataBaseWordDao() {
        runBlocking {
            launch(Dispatchers.IO) {
                wordDao.getAllWords().first {
                    println(it.size)
                    true
                }
                wordDao.queryByWord("Hello").firstOrNull()?.let {
                    println(it.word)
                    val newWord = Word(it.id, "${it.word}World")
                    wordDao.update(newWord)
                }
                wordDao.queryByWord("HelloWorld").firstOrNull()?.let {
                    println(it.word)
                }
            }
        }
    }

    @After
    fun closeDb() {
        database.close()
    }
}

Result Output

2
Hello
HelloWorld
a284628487 commented 3 years ago

Espresso Activity

dependencies

    testImplementation 'androidx.test.espresso:espresso-core:3.4.0'
    testImplementation 'androidx.test.espresso:espresso-intents:3.4.0'
    testImplementation 'androidx.test.ext:truth:1.3.0'

UnitTest1

MainActivity中有一个输入框和两个按钮,textToBeChanged 将输入框中的内容复制到textToBeChangedactivityChangeTextBtn会启动一个新的Activity。

@RunWith(AndroidJUnit4.class)
public final class ChangeTextBehaviorLocalTest {

    /**
     * Use {@link ActivityScenarioRule} to create and launch the activity under test.
     */
    @Rule
    public ActivityScenarioRule<MainActivity> activityScenarioRule =
            new ActivityScenarioRule<MainActivity>(MainActivity.class);

    @Before
    public void intentsInit() {
        // initialize Espresso Intents capturing
        Intents.init();
    }

    @After
    public void intentsTeardown() {
        // release Espresso Intents capturing
        Intents.release();
    }

    @Test
    public void changeText_sameActivity() {
        // Type text and then press the button.
        onView(withId(R.id.editTextUserInput)).perform(typeText("HelloWorld"), closeSoftKeyboard());
        onView(withId(R.id.changeTextBt)).perform(click());

        // Check that the text was changed.
        onView(withId(R.id.textToBeChanged)).check(matches(withText("HelloWorld")));
    }

    @Test
    public void changeText_newActivity() {
        // Type text and then press the button.
        onView(withId(R.id.editTextUserInput)).perform(typeText("HelloWorld"), closeSoftKeyboard());
        onView(withId(R.id.activityChangeTextBtn)).perform(click());

        // An intent is fired to launch a different Activity. Robolectric doesn't currently
        // support launching a new Activity, so use Espresso Intents to verify intent was sent
        assertThat(Iterables.getOnlyElement(Intents.getIntents())).hasComponentClass(ShowTextActivity.class);
    }
}

UnitTest2

ShowTextActivity接收从Intent中传过来的值,它有一个文本框show_text_view,将Intent中取到的值显示出来。

@RunWith(AndroidJUnit4.class)
public final class ChangeTextBehaviorLocalTest2 {
    @Rule
    public ActivityScenarioRule<ShowTextActivity> activityScenarioRule =
            new ActivityScenarioRule(new Intent(ApplicationProvider.getApplicationContext(), ShowTextActivity.class)
                    .putExtra(ShowTextActivity.KEY_EXTRA_MESSAGE, "HelloWorld"));
    // Launch ShowTextActivity and pass extra to intent.

    @Before
    public void intentsInit() {
        Intents.init();
    }

    @After
    public void intentsTeardown() {
        Intents.release();
    }

    @Test
    public void showParameterFromIntentExtra() {
        onView(withId(R.id.show_text_view)).check(matches(withText("HelloWorld")));

        activityScenarioRule.getScenario().onActivity(activity -> {
            // ShowTextActivity.onPause change text from `HelloWorld` to `HelloChina`
            activity.onPause();
            onView(withId(R.id.show_text_view)).check(matches(withText("HelloChina")));
        });
        // recreate activity
        activityScenarioRule.getScenario().recreate();
        // after recreate, the text is still HelloWorld
        onView(withId(R.id.show_text_view)).check(matches(withText("HelloWorld")));
    }
}
a284628487 commented 3 years ago

Espresso Fragment

dependencies

    testImplementation 'androidx.test.espresso:espresso-core:3.4.0-beta01'
    testImplementation 'androidx.fragment:fragment-testing:1.1.0-rc01'
    testImplementation 'com.google.truth:truth:1.3.0'
    testImplementation 'org.robolectric:annotations:4.5.1'

UniTest1

@RunWith(AndroidJUnit4::class)
@LooperMode(LooperMode.Mode.PAUSED)
class SampleDialogFragmentTest {

    @Test
    fun launchDialogFragmentAndVerifyUI() {
        // Use launchFragment to launch the dialog fragment in a dialog.
        val scenario = launchFragment<SampleDialogFragment>()

        scenario.onFragment { fragment ->
            assertThat(fragment.dialog).isNotNull()
            assertThat(fragment.requireDialog().isShowing).isTrue()
        }

        // Now use espresso to look for the fragment's text view and verify it is displayed.
        Espresso.onView(ViewMatchers.withId(R.id.textView)).inRoot(isDialog())
            .check(ViewAssertions.matches(ViewMatchers.withText("I am a fragment")));
    }

    @Test
    fun launchDialogFragmentEmbeddedToHostActivityAndVerifyUI() {
        // Use launchFragmentInContainer to inflate a dialog fragment's view into Activity's content view.
        val scenario = launchFragmentInContainer<SampleDialogFragment>()

        scenario.onFragment { fragment ->
            // Dialog is not created because we use launchFragmentInContainer and the view is inflated
            // into the Activity's content view.
            assertThat(fragment.dialog).isNull()
        }

        // Now use espresso to look for the fragment's text view and verify it is displayed.
        Espresso.onView(ViewMatchers.withId(R.id.textView))
            .check(ViewAssertions.matches(ViewMatchers.withText("I am a fragment")));
    }

    @Test
    fun launchDialogFragment_whenClickHideButton_thenFragmentHide() {
        val tag = "FragmentScenario_Fragment_Tag"
        // Use launchFragment to launch the dialog fragment in a dialog.
        val scenario = launchFragment<SampleDialogFragment>()

        scenario.onFragment {
            val activity = it.activity

            assertThat(checkNotNull(activity?.supportFragmentManager?.findFragmentByTag(tag)))
            // Click Button hideMe to dismiss dialogFragment
            Espresso.onView(ViewMatchers.withId(R.id.hideMe)).inRoot(isDialog())
                .perform(ViewActions.click())
            // Fragment has been detached
            assertThat(null == activity?.supportFragmentManager?.findFragmentByTag(tag))
        }
    }
}
a284628487 commented 3 years ago

Expresso ListView

Business

In an ListActivity,it contain's a list, each item has one TextView, one ToggleButton and can be clicked。Click each listitem will update the selection value display to a TextView in the Activity。

UnitTest

/**
 * Tests to verify that the behavior of {@link LongListActivity} is correct.
 * Note that in order to scroll the list you shouldn't use {@link ViewActions#scrollTo()} as
 * {@link Espresso#onData(org.hamcrest.Matcher)} handles scrolling.</p>
 */
@RunWith(AndroidJUnit4.class)
@LargeTest
public class LongListActivityTest {

    private static final String TEXT_ITEM_30 = "item: 30";
    private static final String TEXT_ITEM_30_SELECTED = "30";
    private static final String TEXT_ITEM_60 = "item: 60";
    private static final String LAST_ITEM_ID = "item: 99";

    @Rule
    public ActivityScenarioRule<LongListActivity> rule = new ActivityScenarioRule<>(
            LongListActivity.class);

    // Test that the list is long enough for this sample, the last item shouldn't appear.
    @Test
    public void lastItem_NotDisplayed() {
        // Last item should not exist if the list wasn't scrolled down.
        onView(withText(LAST_ITEM_ID)).check(doesNotExist());
    }

    // Check that the item is created. onData() takes care of scrolling.
    @Test
    public void list_Scrolls() {
        onRow(LAST_ITEM_ID).check(matches(isCompletelyDisplayed()));
    }

    // Clicks on a row and checks that the activity detected the click.
    @Test
    public void row_Click() {
        // Click on one of the rows.
        onRow(TEXT_ITEM_30).onChildView(withId(R.id.rowContentTextView)).perform(click());
        // Check that the activity detected the click on the first column.
        onView(ViewMatchers.withId(R.id.selection_row_value))
                .check(matches(withText(TEXT_ITEM_30_SELECTED)));
    }

    // Checks that a toggle button is checked after clicking on it.
    @Test
    public void toggle_Click() {
        // Click on a toggle button.
        onRow(TEXT_ITEM_30).onChildView(withId(R.id.rowToggleButton)).perform(click());
        // Check that the toggle button is checked.
        onRow(TEXT_ITEM_30).onChildView(withId(R.id.rowToggleButton)).check(matches(isChecked()));
    }

    // Make sure that clicking on the toggle button doesn't trigger a click on the row.
    @Test
    public void toggle_ClickDoesntPropagate() {
        // Click on one of the rows.
        onRow(TEXT_ITEM_30).onChildView(withId(R.id.rowContentTextView)).perform(click());
        // Click on the toggle button, in a different row.
        onRow(TEXT_ITEM_60).onChildView(withId(R.id.rowToggleButton)).perform(click());
        // Check that the activity didn't detect the click on the first column.
        onView(ViewMatchers.withId(R.id.selection_row_value))
                .check(matches(withText(TEXT_ITEM_30_SELECTED)));
    }

    /**
     * Uses {@link Espresso#onData(org.hamcrest.Matcher)} to get a reference to a specific row.
     *
     * @param str the content of the field
     * @return a {@link DataInteraction} referencing the row
     */
    private static DataInteraction onRow(String str) {
        // List<Map<ROW_TEXT, StringValue>>
        return onData(hasEntry(equalTo(LongListActivity.ROW_TEXT), is(str)));
    }

    /**
     * ListView's adapter source is List<String>
     */
    private static DataInteraction onRowForList(String str) {
        // List<String>
        return onData(equalToIgnoringCase(str));
    }
}
a284628487 commented 3 years ago

mockk

'io.mockk:mockk:1.9.3'

Mockito: 无法mock final类和方法,不适合Kotlin;可以使用MockK,专门为Kotlin打造的mock类;

通过 mockk(),mockkObject(),spyk(),mockStatic() 方法返回的对象,就是可mock状态,只有处于这个状态的对象才能通过every对对象的api进行mock。

mockkObject or mockkStatic

模拟Kotlin object class 或者 静态类 的方法,通过every代码块mock其静态方法。


object XxHelper {
    fun getName(): String = "1"
}

mockObject(XxHelper)

every {
    XxHelper.getName()
} returns "mockName"

assertEquals("mockName", XxHelper.getName())

spyk

创建一个混合的mock对象,可以选择性的mock该对象的一部分方法,而没有mock的方法,则继续执行真实的方法逻辑。


val context = spyk<Context>()
every {
    context.getString(R.string.app_name)
} returns "mockName"

assertEquals("mockName", context.getString(R.string.app_name))

val counter = object : Counter() {
    overrid fun get() = 2
}
val mockCounter = spyk(counter)
every {
    mockCounter.get()
} returns 1

assertEquals(1, mockCounter.get())

coroutine: MockK还可以mock协程的suspend函数,使用coEvery{ }可以模拟suspend挂起函数。

mockk

这个方法返回需要mock的实例对象,该实例对象的所有函数都为待mock状态,这些待mock状态的函数都不能直接调用,需要结合使用 every 将其 mock 后才能调用

大型测试 Appium、UIAutomator 用户通过界面操作真实设备的流程,涉及跨应用和系统UI交互的流程 依赖真实设备 中型测试 JUnit、Espresso、Robolectric 需要多个类、方法完成的功能(依赖Android框架,数据库等) 依赖模拟器或Shadow 小型测试 JUnit、MockK、Robolectric 单个方法(不依赖Android框架、数据库等,或者依赖方可以方便mock) 不依赖或者依赖Shadow

书写规范:GWT Given :单元测试预置条件 When:执行逻辑 Then:验证执行结果

测试替身

为了达到测试目的并且减少被测试对象的依赖,使用“替身”代替一个真实的对象,保证 测试的速度和稳定性 隔离被测代码 加速执行测试 使执行变的确定 模拟特殊情况 访问隐藏信息

测试替身分类

Dummy Object-虚假对象 Stub-测试桩 Fake-伪造对象 Spy-测试间谍 Mock-模拟对象

Dummy Object-虚假对象 通常用于填充参数,并且不会被真正调用到

Stub-测试桩 用最简单的可能实现代替真实实现,通常使用在验证目标回传值,以及验证目标对象状态的改变

Fake-伪造对象 对接口方法有具体实现的,但是实现中做了些捷径,使它们不能应用到生产环境

Spy-测试间谍 监视真实的对象,调用真实对象的方法,可以用来监控行为是否被执行、执行顺序等

Mock-模拟对象 由Mock库动态创建的,能提供类似Dummy、Stub、Spy的功能 开发人员看不到Mock对象的代码,但可以设置Mock对象成员的行为及返回值

Android Mock 框架

Mockito MockK Robolectric Powermock