Hilt kullanılan projede Fragment testi

Pixabay Api sorgusundan dönen resimlerin RecyclerView içerisinde gösterildiği bir projede, bu sorguyu bir testle taklit ederek ekranın nasıl görüneceğini görmek istemiştim. Tabi projede Hilt de kullanıldığı için testte de değişiklikler oluyor. Ben bu testi anlatmak için yeni minimal bir proje oluşturdum. Test için projede yapılması gereken değişiklikleri bu yazıda irdelemek için testi sonra yapıyorum, normalde eş zamanlı yapmak lazım. Böyle bir testi yaparken uygulama mimarisinde repository olması işleri kolaşlaştırıyor.

Proje Github Linki

04.10.2022 Güncelleme: Dependency Injection'da bazı değişiklikler yaptım. Bu yüzden blog yazısını da repo'yu da güncelledim.

Uygulamanın Görünümü

Büyütmek için tıklayın


Proje yapısı

Büyütmek için tıklayın


ImagesRepository.kt

class ImagesRepository(private val requestService: RequestService){

    suspend fun requestImages(search: String, page: Int): Resource<ImagesModel>{
        val response = requestService.searchImageRequest(
            search = search,
            page = page
        )
        if(response.isSuccessful){
            return Resource.success(response.body())
        }

        //ELSE
        return Resource.error("Error.", null)
    }

}


RequestService.kt

interface RequestService {

    @GET(BASE_URL) //https://square.github.io/retrofit/2.x/retrofit/index.html?retrofit2/Retrofit.Builder.html 3rd baseUrl title
    suspend fun searchImageRequest(
        @Query("key") key: String = API_KEY,
        @Query("q") search: String,
        @Query("page") page: Int = 1
    ): Response<ImagesModel>
}


ImagesModel.kt

data class ImagesModel(
    val total: Int?,
    val totalHits: Int?,
    val hits: List<ImageHitsModel>?
)


ImageHitsModel.kt

data class ImageHitsModel(
    val id: Int?,
    val pageUrl: String?,
    val type: String?,
    val tags: String?,
    val previewURL: String?,
    val previewWidth: Int?,
    val previewHeight: Int?,
    val webFormatURL: String?,
    val webFormatWidth: Int?,
    val webFormatHeight: Int?,
    val largeImageURL: String?,
    val fullHDURL: String?,
    val imageURL: String?,
    val imageWidth: Int?,
    val imageHeight: Int?,
    val imageSize: Int?,
    val views: Int?,
    val downloads: Int?,
    val likes: Int?,
    val comments: Int?,
    @Json(name = "user_id") val userId: Int?,
    val user: String?,
    val userImageURL: String?
)


DataModule.kt

@Module
@InstallIn(SingletonComponent::class)
object DataModule {

    @Singleton
    @Provides
    fun providesRequestService(): RequestService{
        val moshi = Moshi
            .Builder()
            .add(KotlinJsonAdapterFactory())
            .build()

        return Retrofit
            .Builder()
            .baseUrl(BASE_URL)
            .addConverterFactory(MoshiConverterFactory.create(moshi))
            .build()
            .create(RequestService::class.java)
    }
}


RepositoryModule.kt

@Module
@InstallIn(SingletonComponent::class)
object RepositoryModule {

    @Singleton
    @Provides
    fun providesImagesRepository(requestService: RequestService): ImagesRepository{
        return ImagesRepository(requestService)
    }
}


MainActivity.kt

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}

 

MainFragment.kt

@AndroidEntryPoint
class MainFragment : Fragment() {
    private var _binding: FragmentMainBinding? = null
    private val binding get() = _binding!!
    private val viewModel: MainFragmentViewModel by viewModels()
    private val recyclerAdapter = MyRecyclerAdapter()
    private var oldHitsList = mutableListOf<ImageHitsModel>()
    private var pageNumber: Int = 1

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = FragmentMainBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        binding.recyclerView.layoutManager = LinearLayoutManager(this.context)
        binding.recyclerView.adapter = recyclerAdapter

        setObserver()
        setClickListeners()
    }

    override fun onDestroy() {
        super.onDestroy()
        _binding = null
    }

    private fun setObserver(){
        viewModel.data.observe(viewLifecycleOwner){ resource->
            when(resource.status){
                Status.STANDBY -> {}
                Status.LOADING -> {}
                Status.SUCCESS -> {resource.data?.hits?.let {
                    binding.progressBarLoadingMore.visibility = View.GONE
                    binding.progressBarLoadingSearchResults.visibility = View.GONE
                    if(it.isEmpty()){
                        Toast.makeText(requireContext(), "The end of the search results.", Toast.LENGTH_LONG).show()
                    }else{
                        binding.recyclerView.visibility = View.VISIBLE
                        recyclerAdapter.submitList(oldHitsList + it)
                        oldHitsList += it
                        binding.loadMoreButton.visibility = View.VISIBLE
                    }
                }}
                Status.ERROR -> {
                    Toast.makeText(requireContext(), "Error", Toast.LENGTH_LONG).show()
                    binding.progressBarLoadingMore.visibility = View.GONE
                    binding.progressBarLoadingSearchResults.visibility = View.GONE
                    binding.loadMoreButton.visibility = View.VISIBLE
                }
            }
        }
    }

    private fun setClickListeners(){
        binding.loadMoreButton.setOnClickListener {
            it.visibility = View.GONE
            binding.progressBarLoadingMore.visibility = View.VISIBLE
            pageNumber += 1
            viewModel.requestImages(binding.editTextSearch.text.toString(), pageNumber)
        }

        binding.searchButton.setOnClickListener {
            with(binding){
                progressBarLoadingSearchResults.visibility = View.VISIBLE
                recyclerView.visibility = View.GONE
                loadMoreButton.visibility = View.GONE
            }
            oldHitsList = mutableListOf()
            pageNumber = 1
            viewModel.requestImages(binding.editTextSearch.text.toString(), pageNumber)
        }
    }
}

 

MainFragmentViewModel.kt

@HiltViewModel
class MainFragmentViewModel @Inject constructor(private val repository: ImagesRepository): ViewModel() {
    private val _data = MutableLiveData<Resource<ImagesModel>>(Resource.standby(null))
    val data get() = _data

    fun requestImages(search: String, page: Int){
        _data.value = Resource.loading(null)

        viewModelScope.launch(Dispatchers.IO){
            _data.postValue(repository.requestImages(search, page))
        }
    }
}

 

MyRecyclerAdapter.kt

class MyRecyclerAdapter: ListAdapter<ImageHitsModel, MyRecyclerAdapter.ViewHolder>(ImageHitsModelListDiffCallback){
    class ViewHolder(val binding: SingleImageItemBinding): RecyclerView.ViewHolder(binding.root){

    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val binding = SingleImageItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return ViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {

        with(holder.binding){
            Glide
                .with(imageView.context)
                .load(getItem(position).largeImageURL)
                .listener(object: RequestListener<Drawable> { //https://stackoverflow.com/a/54130621
                    override fun onLoadFailed(
                        e: GlideException?,
                        model: Any?,
                        target: Target<Drawable>?,
                        isFirstResource: Boolean
                    ): Boolean {
                        progressBar.visibility = View.GONE
                        return false
                    }

                    override fun onResourceReady(
                        resource: Drawable?,
                        model: Any?,
                        target: Target<Drawable>?,
                        dataSource: DataSource?,
                        isFirstResource: Boolean
                    ): Boolean {
                        progressBar.visibility = View.GONE
                        return false
                    }

                })
                .into(holder.binding.imageView)
        }

    }

    object ImageHitsModelListDiffCallback: DiffUtil.ItemCallback<ImageHitsModel>(){
        override fun areItemsTheSame(oldItem: ImageHitsModel, newItem: ImageHitsModel): Boolean {
            return oldItem.id == newItem.id
        }

        override fun areContentsTheSame(oldItem: ImageHitsModel, newItem: ImageHitsModel): Boolean {
            return oldItem.largeImageURL == newItem.largeImageURL
        }
    }

}

 

ApiKey.kt

const val API_KEY: String = "myApiKey"

 

Resource.kt

data class Resource<out T>(val status: Status, val data: T?, val message: String?) {

    companion object {

        fun <T> success(data: T?): Resource<T> {
            return Resource(Status.SUCCESS, data, null)
        }

        fun <T> error(msg: String, data: T?): Resource<T> {
            return Resource(Status.ERROR, data, msg)
        }

        fun <T> loading(data: T?): Resource<T> {
            return Resource(Status.LOADING, data, null)
        }
        //Addition to original after the line
        fun <T> standby(data: T?): Resource<T> {
            return Resource(Status.STANDBY, data, null)
        }

    }

}

enum class Status {
    SUCCESS,
    ERROR,
    LOADING
    //Addition to original after the line
    ,STANDBY
}
//Origin of Resource Class: https://blog.mindorks.com/mvvm-architecture-android-tutorial-for-beginners-step-by-step-guide

 

Util.kt

const val BASE_URL: String = "https://pixabay.com/api/"

 

FragmentTestingWithHiltApplication.kt

@HiltAndroidApp
class FragmentTestingWithHiltApplication: Application()


Testin Yazılması

Öncelikle Gradle test implementation'ları projeden bakabilirsiniz. Ardından Hilt'in instrumented testlerde çalışabilmesi için yeni bir test runner yazıp, bunun Gradle'da nasıl ayarlandığına bu linkten bakabilirsiniz.

Şimdi MainFragmentTest.kt sınıfımı androidTest altında oluşturuyorum:

@HiltAndroidTest
class MainFragmentTest {

    @get: Rule
    var hiltRule = HiltAndroidRule(this)

}

Fragment'ı testte başlatabilmek için dökümantasyonda açıklandığı üzere launchFragmentInContainer fonksiyonu Hilt ile kullanılamadığından launchFragmentInHiltContainer fonksiyonunu kullanacağız. Ben de projede androidTest altında HiltExt.kt adında dosya oluşturup linkteki fonksiyonu bu dosyaya olduğu gibi ekliyorum. Sadece tema parametresini değiştiriyorum. Fakat fonksiyonun üzerindeki yorum satırlarında yazdığı üzere debug klasörüne HiltTestActivity eklememiz gerektiği ve debug AndroidManifest.xml dosyasında bunu dahil etmemiz gerektiği belirtilmiş. Bunun için önce dosya hiyerarşisini Project yaparak src klasörü altına bir debug\java klasörü oluşturup bu klasör altında kendi uygulama paketimi oluşturuyorum:

Büyütmek için tıklayın

 

Ardından AndroidManifest.xml dosyasını ekleyip tekrar Android dosya hiyerarşisine geçerek HiltTestActivity.kt dosyasını ekliyorum:

Büyütmek için tıklayın


HiltTestActivity.kt (debug)

@AndroidEntryPoint
class HiltTestActivity : AppCompatActivity()


AndroidManifest.kt (debug)

<?xml version="1.0" encoding="utf-8"?>

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.paketismi.fragmenttestingwithhilt">

    <application>
        <activity
            android:name=".HiltTestActivity"
            android:exported="false"  />
    </application>

</manifest>


Artık androidTest bölümünde sınıflarımızı yazmaya başlıyoruz. Önce ImagesRepository sınıfını override eden FakeImagesRepository sınıfını yazacağız. Bunun için ImagesRepository sınıfına ve fonksiyonuna open anahtar kelimesini eklememiz gerekiyor: 

ImagesRepository.kt

open class ImagesRepository @Inject constructor(private val requestService: RequestService){

    open suspend fun requestImages(search: String, page: Int): Resource<ImagesModel>{
        val response = requestService.searchImageRequest(
            search = search,
            page = page
        )
        if(response.isSuccessful){
            return Resource.success(response.body())
        }

        //ELSE
        return Resource.error("Error.", null)
    }

}


Sıra FakeImagesRepository'yi yazmada:

FakeImagesRepository.kt

class FakeImagesRepository(requestService: RequestService): ImagesRepository(requestService) {
    private var lastId = 0

    override suspend fun requestImages(search: String, page: Int): Resource<ImagesModel> {

        val hits = mutableListOf<ImageHitsModel>()
        for(i in lastId..lastId+2){
            hits.add(
                ImageHitsModel(
                    id = i,
                    pageUrl = null,
                    type = null,
                    tags = null,
                    previewURL = null,
                    previewWidth = null,
                    previewHeight = null,
                    webFormatURL = null,
                    webFormatWidth = null,
                    webFormatHeight = null,
                    largeImageURL = i.toString(), //DiffUtil checks the contents.
                    fullHDURL = null,
                    imageURL = null,
                    imageWidth = null,
                    imageHeight = null,
                    imageSize = null,
                    views = null,
                    downloads = null,
                    likes = null,
                    comments = null,
                    userId = null,
                    user = null,
                    userImageURL = null,
                ))
        }
        lastId += 3
        val imagesModel = ImagesModel(null, null, hits)

        return Resource.success(imagesModel)
    }
}

Burada id atamamız recycler adapter için gerekli.


Recycler adapter'ın da Fake versiyonunu yazıp onBindViewHolder fonksiyonunu teste uygun bir şekilde düzenlememiz gerekiyor. Çünkü Glide, recycler adapter sınıfında url'den görsel yüklüyor. Bunun için yine MyRecyclerAdapter sınıfına open anahtar kelimesini ekliyoruz ve ardından FakeReyclerAdapter sınıfını yazıyoruz. Fakat bundan önce projeye test için kullanılacak bir görsel eklememiz gerekiyor.

Resource Manager -> Import Drawables yolunu izleyerek bir görsel ekleyebilirsiniz.

FakeRecyclerView.kt

class FakeRecyclerAdapter: MyRecyclerAdapter() {

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        with(holder.binding){
            progressBar.visibility = View.GONE
            Glide
                .with(imageView.context)
                .load(R.drawable.test_image)
                .into(holder.binding.imageView)
        }
    }
}


Artık son aşamaya geldik. Testte, orijinalleri yerine bu yazdığımız fake versiyonların enjekte edilmesini sağlayacak Hilt modüllerini yazmamız gerekiyor.

FakeImagesRepositoryModule.kt

@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [RepositoryModule::class]
)
object FakeImagesRepositoryModule {

    @Singleton
    @Provides
    fun providesFakeImagesRepository(requestService: RequestService): ImagesRepository{
        return FakeImagesRepository(requestService)
    }
}


Şuan MyRecyclerAdapter sınıfı projede Hilt ile enjekte edilmiyor. Önce onu düzenlememiz gerekiyor. Bu yüzden önce asıl projede MyRecylerAdapter sınınıfının Hilt modülünü yazacağız ve MainFragment'ta enjeksiyonu için gerekli düzenlemeleri yapacağız. Ardından androidTest kısmında yine bir Hilt modülü daha yazıp orijinali ile fake versiyonunu değiştireceğiz.

RecyclerAdapterModule.kt

@Module
@InstallIn(SingletonComponent::class)
object RecyclerAdapterModule {
    
    @Singleton
    @Provides
    fun providesMyRecyclerAdapter(): MyRecyclerAdapter{
        return MyRecyclerAdapter()
    }
}


MainFragment sınıfında Initializing alanını güncelliyoruz:

@AndroidEntryPoint
class MainFragment : Fragment() {
    private var _binding: FragmentMainBinding? = null
    private val binding get() = _binding!!
    private val viewModel: MainFragmentViewModel by viewModels()
    @Inject lateinit var recyclerAdapter: MyRecyclerAdapter //private val recyclerAdapter = MyRecyclerAdapter()
    ...

 

Ardından test için FakeRecyclerAdapterModule objemizi yazıyoruz:

FakeRecyclerAdapterModule.kt

@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [RecyclerAdapterModule::class]
)
object FakeRecyclerAdapterModule {
    
    @Singleton
    @Provides
    fun providesFakeRecyclerAdapter(): MyRecyclerAdapter{
        return FakeRecyclerAdapter()
    }
}


Her şeyi tamamladık. Artık MainFragmentTest sınıfımızda fonksiyonumuzu yazıp testi başlatabiliriz:

MainFragmentTest.kt

@HiltAndroidTest
class MainFragmentTest {

    @get: Rule
    var hiltRule = HiltAndroidRule(this)

    @Test
    fun testMainFragment(){
        launchFragmentInHiltContainer<MainFragment>()
        Thread.sleep(20000L)
    }

}

Search butonuna tıklandığında görsellerin yüklendiğini görebilirsiniz veya tıklamayı Espresso ile de gerçekleştirebiliriz:

MainFragmentTest.kt

@HiltAndroidTest
class MainFragmentTest {

    @get: Rule
    var hiltRule = HiltAndroidRule(this)

    @Test
    fun testMainFragment(){
        launchFragmentInHiltContainer<MainFragment>()
        onView(withId(R.id.searchButton)).perform(click())
        Thread.sleep(20000L)
    }

}
Büyütmek için tıklayın


Yorumlar