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.
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
Yorum Gönder