Unit test for repository added

This commit is contained in:
2020-04-20 17:28:18 +01:00
parent 5380cd0c74
commit b9a66c3a79
20 changed files with 143 additions and 55 deletions

View File

@@ -42,8 +42,9 @@ dependencies {
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
//mockito
//mockito and livedata testing
testImplementation 'org.mockito:mockito-inline:2.13.0'
implementation 'android.arch.core:core-testing'
//Retrofit and GSON
implementation 'com.squareup.retrofit2:retrofit:2.6.0'

View File

@@ -13,7 +13,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".ui.MainActivity">
<activity android:name=".ui.main.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@@ -1,8 +1,7 @@
package com.example.h_mal.candyspace.data.api
import okhttp3.HttpUrl
import com.example.h_mal.candyspace.data.api.model.ApiResponse
import okhttp3.OkHttpClient
import okhttp3.Request
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
@@ -15,17 +14,21 @@ interface ApiClass {
@GET("users?")
suspend fun getUsersFromApi(@Query("inname") inname: String): Response<ApiResponse>
//invoke method creating an invocation of the api call
companion object{
operator fun invoke(
//injected @params
networkConnectionInterceptor: NetworkConnectionInterceptor,
queryParamsInterceptor: QueryParamsInterceptor
) : ApiClass{
//okHttpClient with interceptors
val okkHttpclient = OkHttpClient.Builder()
.addNetworkInterceptor(networkConnectionInterceptor)
.addInterceptor(queryParamsInterceptor)
.build()
//retrofit to be used in @Repository
return Retrofit.Builder()
.client(okkHttpclient)
.baseUrl("https://api.stackexchange.com/2.2/")

View File

@@ -1,4 +1,6 @@
package com.example.h_mal.candyspace.data.api
package com.example.h_mal.candyspace.data.api.model
import com.example.h_mal.candyspace.data.api.model.User
data class ApiResponse(
val items : List<User>?,

View File

@@ -1,4 +1,4 @@
package com.example.h_mal.candyspace.data.api
package com.example.h_mal.candyspace.data.api.model
import com.google.gson.annotations.Expose
import com.google.gson.annotations.SerializedName

View File

@@ -1,4 +1,4 @@
package com.example.h_mal.candyspace.data.api
package com.example.h_mal.candyspace.data.api.model
import com.google.gson.annotations.Expose
import com.google.gson.annotations.SerializedName

View File

@@ -1,15 +1,15 @@
package com.example.h_mal.candyspace.data.repositories
import androidx.lifecycle.LiveData
import com.example.h_mal.candyspace.data.api.ApiClass
import com.example.h_mal.candyspace.data.api.ApiResponse
import com.example.h_mal.candyspace.data.api.model.ApiResponse
import com.example.h_mal.candyspace.data.api.ResponseUnwrap
import com.example.h_mal.candyspace.data.api.User
class Repository(
private val api: ApiClass
): ResponseUnwrap() {
//get api response from retrofit class
//then unwrap data object from retrofit response class
suspend fun getUsers(username: String): ApiResponse {
return responseUnwrap { api.getUsersFromApi(username) }
}

View File

@@ -1,24 +1,24 @@
package com.example.h_mal.candyspace.ui.main
package com.example.h_mal.candyspace.ui.home
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.h_mal.candyspace.R
import com.example.h_mal.candyspace.data.api.User
import com.example.h_mal.candyspace.data.api.model.User
import com.example.h_mal.candyspace.databinding.MainFragmentBinding
import com.example.h_mal.candyspace.ui.MainActivity.Companion.viewModel
import com.example.h_mal.candyspace.utils.displayToast
import com.example.h_mal.candyspace.ui.main.MainActivity.Companion.viewModel
import com.example.h_mal.candyspace.ui.user.ListItemViewModel
import com.example.h_mal.candyspace.ui.user.UserProfileFragment
import com.xwray.groupie.GroupAdapter
import com.xwray.groupie.ViewHolder
/*
/**
* UI for the first screen holding the list, search box
*/
class MainFragment : Fragment(){
@@ -41,26 +41,37 @@ class MainFragment : Fragment(){
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
//observer the live data of what is retrieved from the API call
viewModel.usersLiveData.observe(viewLifecycleOwner, Observer {
//create adapter for viewbinding into the recycler view
val mAdapter = GroupAdapter<ViewHolder>().apply {
addAll(it.toUserViewModels())
}
//setup the recyclerview
binding.recyclerView.apply {
layoutManager = LinearLayoutManager(context)
setHasFixedSize(true)
adapter = mAdapter
}
/*
* Item click listener for recyclerView
*
*/
mAdapter.setOnItemClickListener { item, _ ->
//get the position of the item clicked
val i = mAdapter.getAdapterPosition(item)
//set user in @MainViewModel
viewModel.setCurrentUser(it[i])
/**
* display [UserProfileFragment]
*/
activity!!.supportFragmentManager.beginTransaction()
.replace(R.id.container, UserProfileFragment())
.replace(R.id.container,
UserProfileFragment()
)
.addToBackStack("user")
.commit()
}

View File

@@ -1,5 +1,8 @@
package com.example.h_mal.candyspace.ui.main
/**
* completion listener for [MainViewModel] when handling async calls
*/
interface CompletionListener {
fun onStarted()
fun onSuccess()

View File

@@ -1,24 +1,22 @@
package com.example.h_mal.candyspace.ui
package com.example.h_mal.candyspace.ui.main
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.view.MenuItem
import android.widget.Toast
import androidx.lifecycle.ViewModelProvider
import com.example.h_mal.candyspace.R
import com.example.h_mal.candyspace.ui.main.CompletionListener
import com.example.h_mal.candyspace.ui.main.MainFragment
import com.example.h_mal.candyspace.ui.main.MainViewModel
import com.example.h_mal.candyspace.ui.main.MainViewModelFactory
import com.example.h_mal.candyspace.ui.home.MainFragment
import com.example.h_mal.candyspace.utils.displayToast
import com.example.h_mal.candyspace.utils.hide
import com.example.h_mal.candyspace.utils.show
import kotlinx.android.synthetic.main.main_activity.*
import org.kodein.di.Kodein
import org.kodein.di.KodeinAware
import org.kodein.di.android.kodein
import org.kodein.di.generic.instance
/**
* [MainActivity] hosting the fragments and controlling a lot of the UI
*/
class MainActivity : AppCompatActivity(), KodeinAware, CompletionListener {
//retrieve the viewmodel factory from the kodein dependency injection
@@ -35,6 +33,7 @@ class MainActivity : AppCompatActivity(), KodeinAware, CompletionListener {
super.onCreate(savedInstanceState)
setContentView(R.layout.main_activity)
//setup home button for back navigation
supportActionBar?.setDisplayHomeAsUpEnabled(true);
supportActionBar?.setDisplayShowHomeEnabled(true);
@@ -43,11 +42,15 @@ class MainActivity : AppCompatActivity(), KodeinAware, CompletionListener {
viewModel.completionListener = this
if (savedInstanceState == null) {
//display first fragment
supportFragmentManager.beginTransaction()
.replace(R.id.container, MainFragment())
.replace(R.id.container,
MainFragment()
)
.commit()
}
//fragment change listener to display title based on current fragment
supportFragmentManager.addOnBackStackChangedListener {
val name = when (supportFragmentManager.fragments[0]::class.java.simpleName) {
"UserProfileFragment" -> "User"
@@ -57,6 +60,11 @@ class MainActivity : AppCompatActivity(), KodeinAware, CompletionListener {
}
}
/*
* Back button over override
* - close app if home fragment
* - return to previous fragment if User fragment
*/
override fun onBackPressed() {
if(supportFragmentManager.backStackEntryCount > 0){
supportFragmentManager.popBackStack()
@@ -66,6 +74,7 @@ class MainActivity : AppCompatActivity(), KodeinAware, CompletionListener {
}
//When home button in toolbar is pressed
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> onBackPressed()
@@ -73,15 +82,21 @@ class MainActivity : AppCompatActivity(), KodeinAware, CompletionListener {
return super.onOptionsItemSelected(item)
}
/*
* completion listener methods
*/
override fun onStarted() {
//show loading
progress_circular.show()
}
override fun onSuccess() {
//loading completed - hide loading
progress_circular.hide()
}
override fun onFailure(message: String) {
//loading failed - hide loading and display toast of error
progress_circular.hide()
displayToast(message)
}

View File

@@ -3,15 +3,10 @@ package com.example.h_mal.candyspace.ui.main
import android.content.Context
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.ImageView
import androidx.core.content.ContextCompat.getSystemService
import androidx.databinding.BindingAdapter
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.example.h_mal.candyspace.R
import com.example.h_mal.candyspace.data.api.User
import com.example.h_mal.candyspace.data.api.model.User
import com.example.h_mal.candyspace.data.repositories.Repository
import com.squareup.picasso.Picasso
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -21,29 +16,39 @@ class MainViewModel(
private val repository: Repository
) : ViewModel() {
// string binded to the edittext input in @R.layout.main_fragment
var searchString: String? = null
var completionListener: CompletionListener? = null
//data objects - live data and var
val usersLiveData = MutableLiveData<List<User>>()
var currentUserLiveData: User? = null
fun submit(view: View){
/**
* view binding of the submit button in @R.layout.main_fragment
*/
fun submit(view: View?){
//close keyboard when clicked
view.let { v ->
val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.hideSoftInputFromWindow(v.windowToken, 0)
val imm = view?.context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.hideSoftInputFromWindow(v?.windowToken, 0)
}
completionListener?.onStarted()
//return if search string is empty
if (searchString.isNullOrEmpty()){
completionListener?.onFailure("Search box is empty")
return
}
//open a coroutine on the Main thread and update views upon load
CoroutineScope(Dispatchers.Main).launch {
try {
//retrieve response from API call
val apiResponse = repository.getUsers(searchString!!)
//unwrap list of user out of ApiResponse
apiResponse.items?.let {
if (it.isNotEmpty()){
//update live data

View File

@@ -4,6 +4,10 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.example.h_mal.candyspace.data.repositories.Repository
/**
* Viewmodel factory for [MainViewModel]
* @repository injected into MainViewModel
*/
@Suppress("UNCHECKED_CAST")
class MainViewModelFactory(
private val repository: Repository

View File

@@ -1,7 +1,7 @@
package com.example.h_mal.candyspace.ui.main
package com.example.h_mal.candyspace.ui.user
import com.example.h_mal.candyspace.R
import com.example.h_mal.candyspace.data.api.User
import com.example.h_mal.candyspace.data.api.model.User
import com.example.h_mal.candyspace.databinding.ListItemLayoutBinding
import com.xwray.groupie.databinding.BindableItem

View File

@@ -1,15 +1,13 @@
package com.example.h_mal.candyspace.ui.main
package com.example.h_mal.candyspace.ui.user
import android.os.Bundle
import android.view.*
import androidx.fragment.app.Fragment
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.Observer
import com.example.h_mal.candyspace.R
import com.example.h_mal.candyspace.databinding.FragmentUserProfileBinding
import com.example.h_mal.candyspace.ui.MainActivity
import com.example.h_mal.candyspace.ui.MainActivity.Companion.viewModel
import com.example.h_mal.candyspace.ui.main.MainActivity.Companion.viewModel
import com.squareup.picasso.Picasso
/**
@@ -20,7 +18,8 @@ import com.squareup.picasso.Picasso
class UserProfileFragment : Fragment() {
companion object {
fun newInstance() = UserProfileFragment()
fun newInstance() =
UserProfileFragment()
}
lateinit var binding: FragmentUserProfileBinding
@@ -29,13 +28,13 @@ class UserProfileFragment : Fragment() {
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
activity?.actionBar?.setHomeButtonEnabled(true)
// Inflate the layout for this fragment
// Inflate the layout for this fragment into data binding
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_user_profile, container, false)
return binding.root
}
//Update the data for viewbinding onResume as data would have changed when selecting a new user
override fun onResume() {
super.onResume()
viewModel.currentUserLiveData?.let {

View File

@@ -2,14 +2,14 @@
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".ui.main.UserProfileFragment">
tools:context=".ui.user.UserProfileFragment">
<data>
<import type="com.example.h_mal.candyspace.R"/>
<import type="com.example.h_mal.candyspace.utils.ConverterUtil"/>
<variable
name="user"
type="com.example.h_mal.candyspace.data.api.User" />
type="com.example.h_mal.candyspace.data.api.model.User" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout

View File

@@ -6,7 +6,7 @@
<data>
<variable
name="user"
type="com.example.h_mal.candyspace.data.api.User" />
type="com.example.h_mal.candyspace.data.api.model.User" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout

View File

@@ -4,7 +4,7 @@
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.MainActivity" >
tools:context=".ui.main.MainActivity" >
<ProgressBar
android:id="@+id/progress_circular"

View File

@@ -13,7 +13,7 @@
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.main.MainFragment">
tools:context=".ui.home.MainFragment">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@@ -1,8 +1,7 @@
package com.example.h_mal.candyspace.data.repositories
import com.example.h_mal.candyspace.data.api.ApiClass
import com.example.h_mal.candyspace.data.api.ApiResponse
import com.example.h_mal.candyspace.data.api.User
import com.example.h_mal.candyspace.data.api.model.ApiResponse
import kotlinx.coroutines.runBlocking
import okhttp3.MediaType
import okhttp3.ResponseBody

View File

@@ -1,12 +1,58 @@
package com.example.h_mal.candyspace.ui.main
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.example.h_mal.candyspace.data.api.ApiClass
import com.example.h_mal.candyspace.data.api.model.ApiResponse
import com.example.h_mal.candyspace.data.api.model.User
import com.example.h_mal.candyspace.data.repositories.Repository
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.Mockito.mock
import org.mockito.MockitoAnnotations
import retrofit2.Response
import java.io.IOException
class MainViewModelTest {
@get:Rule
var rule: TestRule = InstantTaskExecutorRule()
lateinit var viewModel: MainViewModel
@Mock
lateinit var repository: Repository
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
viewModel = MainViewModel(repository)
}
@Test
fun getApiFromRepository_SuccessfulReturn() = runBlocking{
val user = mock(User::class.java)
val mockApiResponse = ApiResponse(listOf(user),null,null,null)
Mockito.`when`(repository.getUsers("12345")).thenReturn(mockApiResponse)
viewModel.submit(null)
assertEquals(mockApiResponse.items, viewModel.usersLiveData.value)
}
@Test
fun getApiFromRepository_unsuccessfulReturn() = runBlocking{
Mockito.`when`(repository.getUsers("12345")).thenAnswer{ throw IOException() }
viewModel.submit(null)
assertEquals(null, viewModel.usersLiveData.value)
}
}