Open a284628487 opened 3 years ago
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)
}
}
}
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)
}
}
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
}
android {
//...
testOptions.unitTests {
includeAndroidResources = true
}
}
test/resources/robolectric.properties
sdk=28
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)
testImplementation "org.mockito:mockito-core:2.28.2"
androidTestImplementation "org.mockito:mockito-android:2.28.2"
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()
}
@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
}
@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!!
}
}
}
}
@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()
}
}
2
Hello
HelloWorld
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'
MainActivity中有一个输入框和两个按钮,textToBeChanged
将输入框中的内容复制到textToBeChanged
,activityChangeTextBtn
会启动一个新的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);
}
}
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")));
}
}
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'
@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))
}
}
}
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。
/**
* 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));
}
}
'io.mockk:mockk:1.9.3'
Mockito: 无法mock final类和方法,不适合Kotlin;可以使用MockK,专门为Kotlin打造的mock类;
通过 mockk(),mockkObject(),spyk(),mockStatic() 方法返回的对象,就是可mock状态,只有处于这个状态的对象才能通过every对对象的api进行mock。
模拟Kotlin object class
或者 静态类
的方法,通过every代码块mock其静态方法。
object XxHelper {
fun getName(): String = "1"
}
mockObject(XxHelper)
every {
XxHelper.getName()
} returns "mockName"
assertEquals("mockName", XxHelper.getName())
创建一个混合的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挂起函数。
这个方法返回需要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
dependencies