I am trying to use coroutines to handle asynchronous code for my login service. Unfortunately, the implementation of the login service must accept callbacks when it completes. I do not want this login() function to complete until one of these callbacks occurs.
Here is what I have:
fun login(): Outcome = runBlocking {
suspendCoroutine<Outcome> { continuation ->
loginService.login(
onLoginSuccess = {
// do some stuff
continuation.resume(Outcome.SUCCESS)
},
onLoginFailure = {
// handle failure case
continuation.resume(Outcome.FAILURE)
}
)
}
}
My issue is my tests never complete. I think what is happening is that the continuation block itself isn't running. I tried wrapping the call to uut.login() in a runBlocking as well, but it didn't help. Here is my test code (using Spek):
describe("when login") {
val successCaptor: ArgumentCaptor<() -> Unit> = TestHelpers.argumentCaptorForClass()
val failureCaptor: ArgumentCaptor<() -> Unit> = TestHelpers.argumentCaptorForClass()
var result: Outcome? = null
beforeEachTest {
doNothing().whenever(mockLoginService)?.login(capture(successCaptor), capture(failureCaptor))
result = uut?.execute()
}
it("logs in with the login service") {
verify(mockLoginService)?.login(any(), any())
}
describe("and the login succeeds") {
beforeEachTest {
successCaptor.value.invoke()
}
// other tests...
it("returns an outcome of SUCCESS") {
expect(result).to.equal(Outcome.SUCCESS)
}
}
describe("and the login fails") {
beforeEachTest {
failureCaptor.value.invoke()
}
// other tests...
it("returns an outcome of FAILURE") {
expect(result).to.equal(Outcome.FAILURE)
}
}
}
Basically, I'd like to assert that the login() method returned either a SUCCESS or FAILURE outcome based on what occurred.
Any ideas?
Of course, I figured this out right after posting. If interested, here is what I did in the test:
describe("when login") {
val successCaptor: ArgumentCaptor<() -> Unit> = TestHelpers.argumentCaptorForClass()
val failureCaptor: ArgumentCaptor<() -> Unit> = TestHelpers.argumentCaptorForClass()
var result: Outcome? = null
describe("and the login succeeds") {
beforeEachTest {
whenever(mockLoginService?.login(capture(successCaptor), capture(failureCaptor))).thenAnswer {
successCaptor.value.invoke()
}
result = uut?.execute()
}
it("logs in with the login service") {
verify(mockLoginService)?.login(any(), any())
}
it("returns an outcome of SUCCESS") {
expect(result).to.equal(Outcome.SUCCESS)
}
}
describe("and the login fails") {
beforeEachTest {
whenever(mockLoginService?.login(capture(successCaptor), capture(failureCaptor))).thenAnswer {
failureCaptor.value.invoke()
}
result = uut?.execute()
}
it("logs in with the login service") {
verify(mockLoginService)?.login(any(), any())
}
// other tests
it("returns an outcome of FAILURE") {
expect(result).to.equal(Outcome.FAILURE)
}
}
}
Related
I have sophisticated scenario where a set of mutually dependent coroutine flows depends on each other and chained:
viewModelScope.launch {
repository.cacheAccount(person)
.flatMapConcat { it->
Log.d(App.TAG, "[2] create account call (server)")
repository.createAccount(person)
}
.flatMapConcat { it ->
if (it is Response.Data) {
repository.cacheAccount(it.data)
.collect { it ->
// no op, just execute the command
Log.d(App.TAG, "account has been cached")
}
}
flow {
emit(it)
}
}
.catch { e ->
Log.d(App.TAG, "[3] get an exception in catch block")
Log.e(App.TAG, "Got an exception during network call", e)
state.update { state ->
val errors = state.errors + getErrorMessage(PersonRepository.Response.Error.Exception(e))
state.copy(errors = errors, isLoading = false)
}
}
.collect { it ->
Log.d(App.TAG, "[4] collect the result")
updateStateProfile(it)
}
}
cache an account on the local disk
create an account on the backend
in positive scenario, cache the newly create account in the local disk
Now I have to add more calls to a new API endpoint and the scenario become even more sophisticated. This endpoint is a ethereum chain.
4a. In the positive scenario, put in the local disk (cache) initiated transaction cacheRepository.createChainTx()
4b. In the negative scenario, just emit further the response from the backend
4a.->5. Register user on the 2nd endpoint repository.registerUser()
The response from 2nd endpoint put in the cache by updating existing row. Even negative case except of exception should be cached to update status of tx.
viewModelScope.launch {
lateinit var newTx: ITransaction
cacheRepository.createChainTxAsFlow(RegisterUserTransaction(userWalletAddress = userWalletAddress))
.map { it ->
newTx= it
repository.registerUserOnSwapMarket(userWalletAddress)
}
.onEach { it -> preProcessResponse(it, newTx) }
.flowOn(backgroundDispatcher)
.collect { it -> processResponse(it) }
}
This a scenario which should be integrated into the 1st Flow chain.
The issue is I do not see how to do it clear in Flow chain. I can rewrite code without chaining, but it also bring variety if else statements.
How would you do this scenario in human readable way?
I'll ended up with this code for transition period:
viewModelScope.launch(backgroundDispatcher) {
try {
var cachedPersonProfile = repository.cacheAccount(person)
var createAccountResponse = repository.createAccount(person)
when(createAccountResponse) {
is Response.Data -> {
repository.cacheAccount(createAccountResponse.data)
val cachedTx = cacheRepository.createChainTx(RegisterUserTransaction(userWalletAddress = person.userWalletAddress))
val chainTx = walletRepository.registerUserOnSwapMarket(userWalletAddress = person.userWalletAddress)
when(chainTx) {
is ru.home.swap.core.network.Response.Data -> {
if (chainTx.data.isStatusOK()) {
cachedTx.status = TxStatus.TX_MINED
} else {
cachedTx.status = TxStatus.TX_REVERTED
}
}
is ru.home.swap.core.network.Response.Error.Message -> {
cachedTx.status = TxStatus.TX_EXCEPTION
}
is ru.home.swap.core.network.Response.Error.Exception -> {
cachedTx.status = TxStatus.TX_EXCEPTION
}
}
cacheRepository.createChainTx(cachedTx)
withContext(Dispatchers.Main) {
state.update { state ->
if (cachedTx.status == TxStatus.TX_MINED) {
state.copy(
isLoading = false,
profile = createAccountResponse.data,
status = StateFlagV2.PROFILE
)
} else {
val txError = "Failed register the profile on chain with status ${TxStatus.TX_MINED}"
state.copy(
isLoading = false,
errors = state.errors + txError
)
}
}
}
}
else -> { updateStateProfile(createAccountResponse) }
}
} catch (ex: Exception) {
withContext(Dispatchers.Main) {
state.update { state ->
val errors = state.errors + getErrorMessage(PersonRepository.Response.Error.Exception(ex))
state.copy(errors = errors, isLoading = false)
}
}
}
}
If you have a better alternative, please share it in the post as an answer.
Situation
I submit data setTripDeliver, the collect works fine (trigger LOADING and then SUCCESS). I pressed a button go to next fragment B (using replace). After that, I press back button (using popbackstack). the collect SUCCESS triggered.
Codes Related
These codes at the FragmentA.kt inside onViewCreated.
private fun startLifeCycle() {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
collectTripDeliver()
}
launch {
collectTripReattempt()
}
}
}
}
These codes when to submit data at a button setOnClickListener.
viewLifecycleOwner.lifecycleScope.launchWhenStarted {
viewModel.setTripDeliver(
verificationCode,
remark
)
}
Method to collect flow collectTripReattempt()
private suspend fun collectTripReattempt() {
viewModel.tripReattempt.collect {
when (it) {
is Resource.Initialize -> {
}
is Resource.Loading -> {
Log.i("???","collectTripReattempt loading")
handleSaveEarly()
}
is Resource.Success -> {
val error = it.data?.error
if (error == null) {
Tools.showToast(requireContext(), "Success Reattempt")
Log.i("???","collectTripReattempt Success")
} else {
Tools.showToast(requireContext(), "$error")
}
handleSaveEnding()
}
is Resource.Error -> {
handleSaveEnding()
}
}
}
}
Below codes are from ViewModel.
private val _tripDeliver =
MutableStateFlow<Resource<TripDeliverResponse>>(Resource.Initialize())
val tripDeliver: StateFlow<Resource<TripDeliverResponse>> = _tripDeliver
This method to call repository.
suspend fun setTripDeliver(
verificationCode: String?,
remark: String?
) {
_tripDeliver.value = Resource.Loading()
try {
val result = withContext(ioDispatcher) {
val tripDeliverParameter = DeliverParameter(
verificationCode,
remark
)
val response = appRepository.setTripDeliver(tripDeliverParameter)
Resource.getResponse { response }
}
_tripDeliver.value = result
} catch (e: Exception) {
when (e) {
is IOException -> _tripDeliver.value =
Resource.Error(messageInt = R.string.no_internet_connection)
else -> _tripDeliver.value =
Resource.Error("Trip Deliver Error: " + e.message)
}
}
}
Logcat
2021-07-09 19:56:10.946 7446-7446/com.package.app I/???: collectTripReattempt loading
2021-07-09 19:56:11.172 7446-7446/com.package.app I/???: collectTripReattempt Success
2021-07-09 19:56:17.703 7446-7446/com.package.app I/???: collectTripReattempt Success
As you can see, the last Success is called again AFTER I pressed back button (popbackstack)
Question
How to make it trigger once only? Is it the way I implement it is wrong? Thank you in advance.
This is not problem of your implementation this is happening because of stateIn() which use used in your viewModel to convert regular flow into stateFlow
If according to your code snippet the success is triggered once again, then why not loading has triggered?
as per article, it is showing the latest cached value when you left the screen and came back you got the latest cached value on view.
Resource:
https://medium.com/androiddevelopers/migrating-from-livedata-to-kotlins-flow-379292f419fb
The latest value will still be cached so that when the user comes back to it, the view will have some data immediately.
I have found the solution, thanks to #Nurseyit Tursunkulov for giving me a clue. I have to use SharedFlow.
At the ViewModel, I replace the initialize with these:
private val _tripDeliver = MutableSharedFlow<Resource<TripDeliverResponse>>(replay = 0)
val tripDeliver: SharedFlow<Resource<TripDeliverResponse>> = _tripDeliver
At the replay I have to use 0, so this SharedFlow will trigger once. Next, change _tripDeliver.value to _tripDeliver.emit() like the codes below:
fun setTripDeliver(
verificationCode: String?,
remark: String?
) = viewModelScope.launch {
_tripDeliver.emit(Resource.Loading())
if (verificationCode == null && remark == null) {
_tripDeliver.emit(Resource.Error("Remark cannot be empty if verification is empty"))
return#launch
}
try {
val result = withContext(ioDispatcher) {
val tripDeliverParameter = DeliverParameter(
verificationCode,
remark,
)
val response = appRepository.setTripDeliver(tripDeliverParameter)
Resource.getResponse { response }
}
_tripDeliver.emit(result)
} catch (e: Exception) {
when (e) {
is IOException -> _tripDeliver.emit(Resource.Error(messageInt = R.string.no_internet_connection))
else -> _tripDeliver.emit(Resource.Error("Trip Deliver Error: " + e.message))
}
}
}
I hope this answer will help the others also.
I think this is because of coldFlow, you need a HotFlow. Another option is to try to hide and show fragment, instead of replacing. And yet another solution is to keep this code in viewModel.
In my opinion, I think your way of using coroutines in lifeScope is incorrect. After the lifeScope status of FragmentA is at Started again, the coroutine will be restarted:
launch {
collectTripDeliver()
}
launch {
collectTripReattempt()
}
So I think: You need to modify this way:
private fun startLifeCycle() {
viewLifecycleOwner.lifecycleScope.launch {
launch {
collectTripDeliver()
}
launch {
collectTripReattempt()
}
}
}
I have a method that looks like that:
private lateinit var cards: List<Card>
fun start() = viewModelScope.launch {
if (!::cards.isInitialized) {
getCards().collect { result ->
result
.doIfSuccess {
cards = it.data
Log.d(TAG, "Received cards")
}
.doIfError {
_errorState.setIfNotEqual(it.exception)
Log.e(TAG, "Cards were not received because of ${it.exception}")
return#collect // <--- that's the place
}
}
}
Log.d(TAG, "Message that needs to be shown only if cards were received")
if (сards.isEmpty()) {
Log.e(TAG, "Сards list is empty")
_errorState.setIfNotEqual(NoCardsException)
return#launch
}
val сard = сards[0]
}
I need to completely return from the method, not only from the .collect block, I've tried to use return#launch or some other custom labels, but it doesn't work even though Kotlin compiler suggests me to set it like that:
I think you can use transformWhile to create a new Flow that does an operation on each item you receive until you return false. Then collect that Flow. I didn't test this because I'm not really sure of how you've structured .doIfSuccess and .doIfError.
fun start() = viewModelScope.launch {
if (!::cards.isInitialized) {
getCards().transformWhile { result ->
result
.doIfSuccess {
cards = it.data
Log.d(TAG, "Received cards")
}
.doIfError {
_errorState.setIfNotEqual(it.exception)
Log.e(TAG, "Cards were not received because of ${it.exception}")
return#transformWhile false
}
return#transformWhile true
}.collect()
}
//...
}
EDIT:
If you only want the first value from the Flow, you could do this:
fun start() = viewModelScope.launch {
if (!::cards.isInitialized) {
getCards().first()
.doIfSuccess {
cards = it.data
Log.d(TAG, "Received cards")
}
.doIfError {
_errorState.setIfNotEqual(it.exception)
Log.e(TAG, "Cards were not received because of ${it.exception}")
return#launch
}
}
//...
}
My test case to test the viewmodel looks like this :
#Before
fun setUp() {
loginActivityViewModel = LoginActivityViewModel(loginRepository)
.apply { users.observeForever(userObserver) }
}
#Test
fun `check user response when get successful response from server`() {
testCoroutineRule.runBlockingTest {
//Given
whenever(loginRepository.getLoginResponse(loginRequest)).then(Answer { loginResponse })
//When
loginActivityViewModel.loginResponse(loginRequest)
//Then
verify(userObserver).onChanged(Resource.loading(data = null))
verify(userObserver).onChanged(Resource.success(data = loginResponse))
}
}
#Test
fun `check user response when get unsuccessful response from server`() {
testCoroutineRule.runBlockingTest {
//Given
whenever(loginRepository.getLoginResponse(loginRequest)).thenThrow(Error("Some error"))
//When
loginActivityViewModel.loginResponse(loginRequest)
//Then
verify(userObserver).onChanged(Resource.loading(data = null))
verify(userObserver).onChanged(Resource.error(message = "Some error"))
}
}
Inside this first test case run successfully but when it run 2nd one giving this error:
Wanted but not invoked: userObserver.onChanged(
Resource(status=ERROR, data=null, message=Some error) );
-> at com.android.loginapp.viewmodel.LoginActivityViewModelTest$check user response when get unsuccessful response from
server$1.invokeSuspend(LoginActivityViewModelTest.kt:83)
However, there was exactly 1 interaction with this mock:
userObserver.onChanged(
Resource(status=LOADING, data=null, message=null) );
-> at androidx.lifecycle.LiveData.considerNotify(LiveData.java:131)
My viewModel network calling method look like this:
fun loginResponse(loginRequest: LoginRequest) {
viewModelScope.launch {
users.postValue(Resource.loading(null))
try {
val usersFromApi = loginRepository.getLoginResponse(loginRequest)
users.postValue(Resource.success(usersFromApi))
} catch (e: Exception) {
users.postValue(Resource.error(e.message.toString()))
}
}
}
Not sure why it's giving this error.
I need use .thenThrow(RuntimeException("test error")) then only it will pass.
I'm using Rx2Apollo to make a graphql call:
private fun registerCardToken(token: String): io.reactivex.Observable<RegisterCardTokenMutation.RegisterCreditCard> {
val apolloCall = apolloClient().mutate(RegisterCardTokenMutation.builder().token(token).build())
return Rx2Apollo.from(apolloCall).map {
(it.data() as RegisterCardTokenMutation.Data).registerCreditCard()
}.doOnError({ error ->
//Log.e("registerCardToke", error.message)
})
}
This works well, but I want to handle specific error and retry this onces. I have tried to work around this using retryWhen and retry , but not able to write any executable code yet.
The retry persons a token refresh before performing the actual retry. Here's the token refresh sample:
private fun refreshBearerToken(callback: OnCompleteListener<GetTokenResult>) {
FirebaseAuth.getInstance().currentUser?.getIdToken(true)?.addOnCompleteListener(callback)
}
First, you have to turn refreshBearerToken into an Observable
val refreshTokenSource = Observable.create({ emitter ->
FirebaseAuth.
getInstance().
currentUser?.
getIdToken(true)?.
addOnCompleteListener({ task ->
if (task.isSuccessful()) {
emitter.onNext(task.getResult())
emitter.onComplete()
} else {
emitter.onError(task.getException())
}
})
})
Second, use some external reference holding the current token and conditionally use it before calling registerCardToken:
val currentToken = AtomicReference<String>()
val registerCardTokenObservable = Observable.defer({
val token = currentToken.get()
if (token == null) {
return refreshTokenSource
.doOnNext({ currentToken.set(it) })
.flatMap({ registerCardToken(it) })
}
return registerCardToken(token)
})
.retry({ error ->
if ((error is IOException) || (error.getMessage().contains("network")) {
currentToken.set(null)
return true
}
return false
})