# Unit Test - 使用hilt測試10 - Testing swipe detele Functionality --- ## 開始吧 ### 情境 * 透過Espresso模擬輸入資料,寫入Db,模擬swipe delete,驗證資料是否正確刪除 ### 建立ShoppingAdapter ```kotlin= class ShoppingItemAdapter @Inject constructor( var glide: RequestManager ) : RecyclerView.Adapter<ShoppingItemAdapter.ShoppingItemViewHolder>() { class ShoppingItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) //效能取捨使用 diffUtil,址更新有變動的項目 private val diffCallBack = object : DiffUtil.ItemCallback<ShoppingItem>() { override fun areItemsTheSame(oldItem: ShoppingItem, newItem: ShoppingItem): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame(oldItem: ShoppingItem, newItem: ShoppingItem): Boolean { return oldItem.hashCode() == newItem.hashCode() } } private val differ = AsyncListDiffer(this, diffCallBack) var shoppingItems: List<ShoppingItem> get() = differ.currentList set(value) = differ.submitList(value) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ShoppingItemViewHolder { return ShoppingItemViewHolder( LayoutInflater.from(parent.context).inflate( R.layout.item_shopping, parent, false ) ) } override fun onBindViewHolder(holder: ShoppingItemViewHolder, position: Int) { val shoppingItem = shoppingItems[position] holder.itemView.apply { glide.load(shoppingItem.imageUrl).into(ivShoppingImage) tvName.text = shoppingItem.name val amointText = "${shoppingItem.amount}" tvShoppingItemAmount.text = amointText val priceText = "${shoppingItem.price}" tvShoppingItemPrice.text = priceText } } override fun getItemCount(): Int { return shoppingItems.size } } ``` ### 撰寫ShoppingFragment功能 * 使用ItemTouchHelper實現滑動刪除 * 設定recycler view ```kotlin= class ShoppingFragment @Inject constructor( val shoppingItemAdapter: ShoppingItemAdapter ): Fragment(R.layout.fragment_shopping) { lateinit var viewModel: ShoppingViewModel override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel = ViewModelProvider(requireActivity()).get(ShoppingViewModel::class.java) subscribeToObserver() setupRecyclerView() fabAddShoppingItem.setOnClickListener { findNavController().navigate( ShoppingFragmentDirections.actionShoppingFragmentToAddShoppingItemFragment()) } } //recycler view左右滑動刪除 private val itemTouchCallback = object : ItemTouchHelper.SimpleCallback( 0, LEFT or RIGHT ){ override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ): Boolean { return true } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { val pos = viewHolder.layoutPosition val item = shoppingItemAdapter.shoppingItems[pos] viewModel.deleteShoppingItem(item) //因為這邊已經先刪除了 //所以這邊如果取消就要把剛剛刪除的資料補回去 Snackbar.make(requireView(), "Successfully deleted item", Snackbar.LENGTH_LONG).apply { setAction("Undo"){ viewModel.insertShoppingItemIntoDb(item) } show() } } } private fun subscribeToObserver(){ viewModel.shoppingItems.observe(viewLifecycleOwner, Observer { shoppingItemAdapter.shoppingItems = it }) viewModel.totalPrice.observe(viewLifecycleOwner, Observer{ val price = it ?: 0f val priceText = "Total Price $price€" tvShoppingItemPrice.text = priceText }) } private fun setupRecyclerView(){ rvShoppingItems.apply { adapter = shoppingItemAdapter layoutManager = LinearLayoutManager(requireContext()) ItemTouchHelper(itemTouchCallback).attachToRecyclerView(this) } } } ``` ### 修正ShoppingFragment * 因為在測試時,我們會Lanuch fragment,但viewModel在這邊Create時就註冊了observer,所以要將viewModel當參數傳遞,讓Test也可以參照到這裡的viewModel ```kotlin= class ShoppingFragment @Inject constructor( val shoppingItemAdapter: ShoppingItemAdapter, var viewModel: ShoppingViewModel? = null ): Fragment(R.layout.fragment_shopping) { // 這行刪除 //lateinit var viewModel: ShoppingViewModel override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel = viewModel ?: ViewModelProvider(requireActivity()).get(ShoppingViewModel::class.java) } ``` ### 修正TestShoppingFragmentFactory ```kotlin= class TestShoppingFragmentFactory @Inject constructor( private val imageAdapter: ImageAdapter, private val glide: RequestManager, private val shoppingItemAdapter: ShoppingItemAdapter ) : FragmentFactory() { override fun instantiate(classLoader: ClassLoader, className: String): Fragment { return when (className) { ImagePickFragment::class.java.name -> ImagePickFragment(imageAdapter) AddShoppingItemFragment::class.java.name -> AddShoppingItemFragment(glide) //這裡多傳入一個用我們fakeRepo產生的viewModel ShoppingFragment::class.java.name -> ShoppingFragment(shoppingItemAdapter, ShoppingViewModel(FakeRepository()) ) else -> super.instantiate(classLoader, className) } } } ``` ![](https://i.imgur.com/BU6jCpp.png) ### 再把剛剛在test新增的內容貼到main project 的FragmentFactory * 將ShoppingViewModel實例化的參數取消 ```kotlin= class ShoppingFragmentFactory @Inject constructor( private val imageAdapter: ImageAdapter, private val glide: RequestManager, private val shoppingItemAdapter: ShoppingItemAdapter ) : FragmentFactory() { override fun instantiate(classLoader: ClassLoader, className: String): Fragment { return when (className) { ImagePickFragment::class.java.name -> ImagePickFragment(imageAdapter) AddShoppingItemFragment::class.java.name -> AddShoppingItemFragment(glide) ShoppingFragment::class.java.name -> ShoppingFragment(shoppingItemAdapter) else -> super.instantiate(classLoader, className) } } } ``` ### 寫測試吧 ==幾個重點== 1. 第18行fargment Factory實例化時會帶有viewModel & Adapter參數 2. 第24行,我們launch ShoppingFragment 並attach到我們定義的empty activity,這裡分兩個部分: 1. 測試方的Fragment Factory 會Inject ShoppingItemAdapter,並用fale Repository實例化一個viewModel 2. 在真正app用的Fragment Factory,也會Inject ShoppingItemAdapter,建構子也會帶viewModel,但是viewModel是在ovViewCreated透過viewModelProvider獲取 3. 將testViewModel參照到real ViewModel 4. 透過real viewModel寫入一筆資料 5. 透過Espresso模擬swipe刪除 6. 再驗證testViewModel的item是否有刪除 ```kotlin= @MediumTest @HiltAndroidTest @ExperimentalCoroutinesApi class ShoppingFragmentTest { @get:Rule var hiltRule = HiltAndroidRule(this) @get:Rule var instantTaskExecutorRule = InstantTaskExecutorRule() @Before fun setup() { hiltRule.inject() } @Inject lateinit var testFragmentFactory: TestShoppingFragmentFactory @Test fun swipeShoppingItem_deleteItemInDb() { val shoppingItem = ShoppingItem("Test", 1, 1.2f, "testUrl", 1) var testViewModel: ShoppingViewModel? = null launchFragmentInHiltContainer<ShoppingFragment>( fragmentFactory = testFragmentFactory ) { testViewModel = viewModel viewModel?.insertShoppingItemIntoDb(shoppingItem) } onView(withId(R.id.rvShoppingItems)).perform( RecyclerViewActions.actionOnItemAtPosition<ShoppingItemAdapter.ShoppingItemViewHolder>( 0, swipeLeft() ) ) assertThat(testViewModel?.shoppingItems?.getOrAwaitValue()).isEmpty() } @Test fun clickAddShoppingItemButton_navigateToAddShoppingItemFragment() { val navController = mock(NavController::class.java) launchFragmentInHiltContainer<ShoppingFragment> { Navigation.setViewNavController(requireView(), navController) } onView(withId(R.id.fabAddShoppingItem)).perform(click()) verify(navController).navigate( ShoppingFragmentDirections.actionShoppingFragmentToAddShoppingItemFragment() ) } } ``` ![](https://i.imgur.com/qTORyso.png) 參考資料 [ Philipp Lackner's channel](https://www.youtube.com/watch?v=V5S2-Mg8B2U) ###### tags: `test` `Unit Test` `hilt` `kotlin`