Instrument testing with Jetpack compose and Kotlin room - android-jetpack-compose

I'm testing several behaviours including:
FAB is hidden when table (Kotlin room) is empty
FAB navigates to a page when table (Kotlin room) is not empty
My approach is based on instrumented testing of jetpack but I'm unsure as to how to amend the database to simulate an empty/non-empty table needed to test the above behaviour.
But I'm not entirely sure if this is an ideal approach, and like to get your thoughts
#ExperimentalMaterial3Api
#HiltAndroidTest
#RunWith(AndroidJUnit4::class)
class HomeScreenTest {
#get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
#ExperimentalMaterial3Api
#get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
#Inject
lateinit var repoMoto: MotorcycleRepository
lateinit var repoFix: FixRepository
private lateinit var navController: NavHostController
private lateinit var viewModel: ViewModel
#ExperimentalMaterial3Api
#Before
fun setUp() {
hiltRule.inject()
composeTestRule.setContent {
viewModel = hiltViewModel()
navController = rememberNavController()
MotoAkuTheme {
BottomNavigation(
navController = navController,
vm = viewModel
)
}
}
}
#Test
fun When_noMotoInDb_expect_FABNotShown() {
// TODO Simulate empty Motorcycle table
// Verify FAB is not shown
composeTestRule.onNodeWithTag(BOTTOMNAV_FAB).assertDoesNotExist()
}
#Test
fun When_motoInDBAndfixFABPressed_expect_navigateToAddFixScreen() {
// TODO Simulate a non empty moto list
// Click fix FAB
composeTestRule.onNodeWithTag(BOTTOMNAV_FAB).performClick()
// Determine if route is navigated to Fix screen
val route = navController.currentBackStackEntry?.destination?.route
Assert.assertEquals(Screen.AddFix.name+"/?motoId={motoId}",route)
}
}
Usually, I approach my test as if I'd use the app physically. Hence, if I were to test behaviour that requires an empty/non-empty database, then I'd add then delete accordingly to simulate that.

Related

Use Room + Paging3 + LazyColumn but pagination is not implemented

I have the following code, but I don't think the pagination is implemented.
dao
interface IArticleDao {
#Query(
"""
SELECT * FROM t_article ORDER BY :order DESC
"""
)
fun pagingSource(order: String): PagingSource<Int, Article>
}
Repository
class ArticleRepository #Inject constructor(
private val articleDao: IArticleDao
) {
fun list(order: String) = articleDao.pagingSource(order)
}
ViewModel
#HiltViewModel
class ArticleViewModel #Inject constructor(private val articleRepository: ArticleRepository) : ViewModel() {
fun list(order: String) = Pager(PagingConfig(pageSize = 20)){
articleRepository.list(order)
}.flow.cachedIn(viewModelScope)
}
Screen
val articleViewModel = hiltViewModel<ArticleViewModel>()
val lazyArticleItem = articleViewModel.list("id").collectAsLazyPagingItems()
ArticlePage(lazyArticleItem)
ArticlePage
LazyColumn{
items(...)
when(val state = lazyArticleItem.loadState.append){
is LoadState.Error -> {
println("error")
}
is LoadState.Loading -> {
println("${lazyArticleItem.itemCount}, ${lazyArticleItem.itemSnapshotList}")
}
else -> {}
}
}
lazyArticleItem.itemCount printed the number 668, so I don't think the pagination is working properly, but the data displayed on the UI interface is fine, it's just not paginated by 20 items per page.
In the PagingConfig, you can specify enablePlaceholders, which is true by default (your case). If placeholders are enabled and paging source knows the total number of items, which it knows when it takes data from room database, lazyArticleItem.itemSnapshotList size will be the total size of the source, only the elements that are not yet loaded will be null.
So you can't say that paging is not working based on itemCount. You are also printing itemSnapshotList, are there nulls? You can also try setting enablePlaceholders = false, itemCount then corresponds to the number of loaded items.

How to create a Page/Screen Object Model in Jetpack Compose Testing

For basic testing, if I create a test class like below, it works fine.
class MyComposeTest {
#get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()
#Test
fun myTest() {
composeTestRule.onNodeWithText("Login").performClick()
composeTestRule.onNodeWithText("Home").assertIsDisplayed()
}
}
But what if i want to abstract some of these into separate classes for an end-to-end test?
e.g. I want to create a login page class with all locators for Login and similarly for Home page and simplify my test as
#Test
fun myTest() {
val login = LoginPage()
val home = HomePage()
login.loginBtn.performClick()
home.homeTxt.assertIsDisplayed()
}
I am not sure how my page classes (with locators) should look like to make this possible.
You should pass the composeTestRule in the page's constructor. The code would look like this:
class BaseTestSuite {
#get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()
}
class LoginPage(composeTestRule: ComposeContentTestRule) {
val loginBtn = onNodeWithText("Login")
fun tapLoginButton() {
loginBtn.performClick()
}
}
class MyTestSuite() : BaseTestSuite {
val loginPage = LoginPage(composeTestRule)
#Test
fun myTest() {
loginPage.tapLoginButton()
// rest of the code
}
}

Jetpack Compose - UI Test Coil Fetched Image

I have a custom Image composable that uses Coil's rememberAsyncImagePainter.
However, I have another component that uses resources and has logic that is handled separately.
I successfully render the custom resource placeholder, however I'm not sure how I can write a test to check that the actual url image is loaded & visible.
Both the url image and the resource image have different testTags, however in the test, the node with the url image's tag never exists.
Does Coil have any solution to mock the ImageRequest.Builder so that I can guarantee that the URL image successfully loads?
I would prefer to not add any test-related code to the component itself, but if that's the only way, then I would prefer the component to be testable.
According to the official docs https://coil-kt.github.io/coil/image_loaders/#testing, you can create a FakeImageLoader class like this:
class FakeImageLoader(private val context: Context) : ImageLoader {
override val defaults = DefaultRequestOptions()
override val components = ComponentRegistry()
override val memoryCache: MemoryCache? get() = null
override val diskCache: DiskCache? get() = null
override fun enqueue(request: ImageRequest): Disposable {
// Always call onStart before onSuccess.
request.target?.onStart(request.placeholder)
val result = ColorDrawable(Color.BLACK)
request.target?.onSuccess(result)
return object : Disposable {
override val job = CompletableDeferred(newResult(request, result))
override val isDisposed get() = true
override fun dispose() {}
}
}
override suspend fun execute(request: ImageRequest): ImageResult {
return newResult(request, ColorDrawable(Color.BLACK))
}
private fun newResult(request: ImageRequest, drawable: Drawable): SuccessResult {
return SuccessResult(
drawable = drawable,
request = request,
dataSource = DataSource.MEMORY_CACHE
)
}
override fun newBuilder() = throw UnsupportedOperationException()
override fun shutdown() {}
}
And you UI test, you can write
#Test
fun testCustomImageComposable() {
Coil.setImageLoader(FakeCoilImageLoader())
setContent {
CutomImageComposable(...)
}
// ... assert image is displayed, etc.
}
This should guarantee the image to be shown every-time the test is executed.
Alternatively, you can mock ImageLoader and mimic the behavior above.
However, I would not recommend mocking ImageLoader, as we do not know whether rememberAsyncImagePainter uses all of the methods, but if you must mock it, it would be worth a try.

XCTest testing asyncronous Combine #Publishers [duplicate]

This question already has answers here:
How To UnitTest Combine Cancellables?
(2 answers)
Closed 1 year ago.
I'm working on an iOS app (utilizing Swift, XCTest, and Combine) trying to test a function within my view model, which is calling and setting a sink on a publisher. I'd like to test the view model, not the publisher itself. I really don't want to use DispatchQueue.asyncAfter( because theoretically I don't know how long the publisher will take to respond. For instance, how would I test XCTAssertFalse(viewModel.isLoading)
class ViewModel: ObservableObject {
#Published var isLoading: Bool = false
#Published var didError: Bool = false
var dataService: DataServiceProtocol
init(dataService: DataServiceProtocol) {
self.dataService = dataService
}
func getSomeData() { // <=== This is what I'm hoping to test
isLoading = true
dataService.getSomeData() //<=== This is the Publisher
.sink { (completion) in
switch completion {
case .failure(_):
DispatchQueue.main.async {
self.didError = true
}
case .finished:
print("finished")
}
DispatchQueue.main.async {
self.isLoading = false
}
} receiveValue: { (data) in
print("Ok here is the data", data)
}
}
}
I'd like to write a test that might look like:
func testGetSomeDataDidLoad() {
// this will test whether or not getSomeData
// loaded properly
let mockDataService: DataServiceProtocol = MockDataService
let viewModel = ViewModel(dataService: mockDataService)
viewModel.getSomeData()
// ===== THIS IS THE PROBLEM...how do we know to wait for getSomeData? ======
// It isn't a publisher...so we can't listen to it per se... is there a better way to solve this?
XCTAssertFalse(viewModel.isLoading)
XCTAssertFalse(viewModel.didError)
}
Really hoping to refactor our current tests so we don't utilize a DispatchQueue.asyncAfter(
Yeah, everybody's saying, MVVM increases testability. Which is hugely true, and thus a recommended pattern. But, how you test View Models is shown only very rarely in tutorials. So, how can we test this thing?
The basic idea testing a view model is using a mock which can do the following:
The mock must record changes in its output (which is the published properties)
Record a change of the output
Apply an assertion function to the output
Possibly record more changes
In order to work better with the following tests, refactor your ViewModel slightly, so it gets a single value representing your view state, using a struct:
final class MyViewModel {
struct ViewState {
var isLoading: Bool = false
var didError: Bool = false
}
#Published private(set) var viewState: ViewState = .init()
...
}
Then, define a Mock for your view. You might try something like this, which is a pretty naive implementation:
The mock view also gets a list of assertion functions which test your view state in order.
class MockView {
var viewModel: MyViewModel
var cancellable = Set<AnyCancellable>()
typealias AssertFunc = (MyViewModel.ViewState) -> Void
let asserts: ArraySlice<AssertFunc>
private var next: AssertFunc? = nil
init(viewModel: MyViewModel, asserts: [AssertFunc]) {
self.viewModel = viewModel
self.asserts = ArraySlice(asserts)
self.next = asserts.first
viewModel.$viewState
.sink { newViewState in
self.next?(newViewState)
self.next = self.asserts.dropFirst().first
}
}
}
You may setup the mock like this:
let mockView = MockView(
viewModel: viewModel,
asserts: [
{ state in
XCTAssertEqual(state.isLoading, false)
XCTAssertEqual(state.didError, false)
},
{ state in
XCTAssertEqual(state.isLoading, true)
...
},
...
])
You can also use XCT expectation in the assert functions.
Then, in your test you create the view model, your mock data service and the configured mockView.
let mockDataService: DataServiceProtocol = MockDataService
let viewModel = ViewModel(dataService: mockDataService)
let mockView = MockView(
viewModel: viewModel,
asserts: [
{ state in
XCTAssertEqual(state.isLoading, false)
XCTAssertEqual(state.didError, false)
},
...
{ state in
XCTAssertEqual(state.isLoading, false)
XCTAssertEqual(state.didError, false)
expectFinished.fulfill()
},
...
])
viewModel.getSomeData()
// wait for the expectations
Caution: I didn't compile or run the code.
You may also take a look at Entwine

Jetpack Compose collectAsState() does not work with Flow combine()

I want to load data from Firestore, and combine it with other data using Flow combine()
ViewModel:
private val userCurrentProject = MutableStateFlow("")
val projects = repository
.listenToProject() //listening via Firestore snapshot listener, no problem here
.combine(userCurrentProject) { projects, currentProjectName ->
// combine works and called normally
projects.map { project ->
project.apply {
isUserCurrentProject = name == currentProjectName
}
}
}
fun setCurrentProject(projectName: String) = viewModelScope.launch {
userCurrentProject.emit(projectName)
}
Composables:
fun ProjectListScreen(navController: NavHostController, viewModel: ProjectsViewModel) {
val projects by viewModel.projects.collectAsState(initial = emptyList())
// This is where the problem started
// Lazy column not updated when projects flow is emitting new value
// Even Timber log does not called
Timber.d("Projects : $projects")
LazyColumn {
items(projects) { project ->
ProjectItem(project = project) {
currentlySelectedProject = project
scope.launch { bottomSheetState.show() }
}
}
}
The flow is working normally, but the state never got updated, I don't know why. Maybe this is a problem with collectAsState()?
But the state is updated when I navigate to next screen (add new project screen), then press back (popBackStack)
NB: using asLiveData() with observeAsState() does not work either.
I've finally found the answer
The culprit is that a State of custom object/class behaves differently than a state of primitives (String, Int, etc.)
For a State of object, you need to use copy()
So I just changed this part of ViewModel
val projects = repository
.listenProject()
.combine(userCurrentProject) { projects, currentProjectName ->
projects.map { project ->
// use copy instead of apply
val isCurrentProject = project.name == currentProjectName
project.copy(isUserCurrentProject = isCurrentProject)
}
}

Resources