diff --git a/README.md b/README.md index 6ee567c7..3f856b52 100644 --- a/README.md +++ b/README.md @@ -16,4 +16,10 @@ BottomSheet를 사용한다. 카카오 API 사용을 위한 앱 키를 외부에 노출하지 않는다. -가능한 MVVM 아키텍처 패턴을 적용하도록 한다. \ No newline at end of file + + +## 기능 요구 사항 + +테스트 코드를 작성한다. + +가능한 MVVM 아키텍처 패턴을 적용하도록 한다. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fbb691ae..c5fd4bdb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,9 +42,21 @@ android { buildFeatures { viewBinding = true } + testOptions { + execution = "ANDROIDX_TEST_ORCHESTRATOR" + } } dependencies { + testImplementation("io.mockk:mockk-android:1.13.11") + testImplementation("io.mockk:mockk-agent:1.13.11") + androidTestImplementation("androidx.test:core:1.5.0") + androidTestImplementation("androidx.test:core-ktx:1.5.0") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.ext:junit-ktx:1.1.5") + androidTestImplementation("androidx.test.ext:truth:1.5.0") + androidTestImplementation("androidx.test:runner:1.5.2") + androidTestUtil("androidx.test:orchestrator:1.4.2") implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1") implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.1") implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.4.0") @@ -59,9 +71,11 @@ dependencies { implementation("com.kakao.maps.open:android:2.9.5") implementation("com.kakao.sdk:v2-all:2.20.3") implementation("androidx.activity:activity:1.8.0") + debugImplementation("androidx.fragment:fragment-testing:1.8.1") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + testImplementation ("org.robolectric:robolectric:4.9") } fun getApiKey(key: String): String { val properties = Properties() diff --git a/app/src/androidTest/java/MapFragmentUiTest.kt b/app/src/androidTest/java/MapFragmentUiTest.kt new file mode 100644 index 00000000..0b279322 --- /dev/null +++ b/app/src/androidTest/java/MapFragmentUiTest.kt @@ -0,0 +1,43 @@ +import androidx.fragment.app.testing.FragmentScenario +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.* +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.* +import androidx.test.ext.junit.runners.AndroidJUnit4 +import campus.tech.kakao.View.MapFragment +import campus.tech.kakao.map.R +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MapFragmentUITest { + private lateinit var fragmentScenario: FragmentScenario + + @Before + fun setUp() { + fragmentScenario = FragmentScenario.launchInContainer(MapFragment::class.java) + } + @After + fun tearDown() { + fragmentScenario.close() + } + @Test + fun testMapViewIsDisplayed() { + onView(withId(R.id.KakaoMapView)).check(matches(isDisplayed())) + } + @Test + fun testSearchViewIsDisplayed() { + onView(withId(R.id.searchView1)).check(matches(isDisplayed())) + } + @Test + fun testSearchViewFunctionality() { + onView(withId(R.id.searchView1)).perform(click()) + onView(withId(R.id.searchView1)).perform(typeText("검색어"), closeSoftKeyboard()) + onView(withId(R.id.searchView1)).check(matches(withText("검색어"))) + } + + + +} \ No newline at end of file diff --git a/app/src/androidTest/java/SearchFragmentUiTest.kt b/app/src/androidTest/java/SearchFragmentUiTest.kt new file mode 100644 index 00000000..ffc7dccd --- /dev/null +++ b/app/src/androidTest/java/SearchFragmentUiTest.kt @@ -0,0 +1,50 @@ +import androidx.fragment.app.testing.FragmentScenario +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.typeText +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.runners.AndroidJUnit4 +import campus.tech.kakao.View.SearchFragment +import campus.tech.kakao.map.R +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +@RunWith(AndroidJUnit4::class) + +class SearchFragmentUiTest { + private lateinit var fragmentScenario: FragmentScenario + @Before + fun setUp() { + fragmentScenario = FragmentScenario.launchInContainer(SearchFragment::class.java) + } + @After + fun tearDown() { + fragmentScenario.close() + } + @Test + fun testSearchViewIsDisplayed() { + onView(withId(R.id.searchView2)).check(matches(isDisplayed())) + } + @Test + fun testRecyclerViewIsDisplayed() { + + onView(withId(R.id.recyclerView)).check(matches(isDisplayed())) + } + @Test + fun testHistoryRecyclerViewIsDisplayed() { + onView(withId(R.id.historyRecyclerView)).check(matches(isDisplayed())) + } + @Test + fun testSearchViewFunctionality() { + onView(withId(R.id.searchView2)).perform(click()) + onView(withId(R.id.searchView2)).perform(typeText("검색어")) + + + onView(withId(R.id.recyclerView)).check(matches(isDisplayed())) + } + + +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/MapFragment.kt b/app/src/main/java/campus/tech/kakao/MapFragment.kt index 219f71f1..56163a92 100644 --- a/app/src/main/java/campus/tech/kakao/MapFragment.kt +++ b/app/src/main/java/campus/tech/kakao/MapFragment.kt @@ -24,8 +24,8 @@ import com.kakao.vectormap.label.LabelStyles class MapFragment : Fragment() { private lateinit var mapView: MapView private lateinit var searchView: SearchView - private var x: Double = 127.108621 - private var y: Double = 37.402005 + var x: Double = 127.108621 + var y: Double = 37.402005 private var kakaoMap: KakaoMap? = null override fun onCreate(savedInstanceState: Bundle?) { @@ -51,7 +51,7 @@ class MapFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) mapView = view.findViewById(R.id.KakaoMapView) - searchView = view.findViewById(R.id.searchView) + searchView = view.findViewById(R.id.searchView1) requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { @@ -110,7 +110,7 @@ class MapFragment : Fragment() { mapView.pause() } - private fun saveLastLocation(lat: Double, lng: Double) { + fun saveLastLocation(lat: Double, lng: Double) { val sharedPreferences = requireContext().getSharedPreferences("MapPrefs", Context.MODE_PRIVATE) with(sharedPreferences.edit()) { putFloat("lastX", lng.toFloat()) @@ -119,7 +119,7 @@ class MapFragment : Fragment() { } } - private fun getLastLocation(context: Context): Pair { + fun getLastLocation(context: Context): Pair { val sharedPreferences = context.getSharedPreferences("MapPrefs", Context.MODE_PRIVATE) val lat = sharedPreferences.getFloat("lastY", 37.402005f).toDouble() val lng = sharedPreferences.getFloat("lastX", 127.108621f).toDouble() diff --git a/app/src/main/java/campus/tech/kakao/Model/RetrofitClient.kt b/app/src/main/java/campus/tech/kakao/Model/RetrofitClient.kt index 116e6d80..5de6c2da 100644 --- a/app/src/main/java/campus/tech/kakao/Model/RetrofitClient.kt +++ b/app/src/main/java/campus/tech/kakao/Model/RetrofitClient.kt @@ -4,7 +4,7 @@ import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory object RetrofitClient { -private const val BASE_URL = "https://dapi.kakao.com/" +const val BASE_URL = "https://dapi.kakao.com/" val instance: KakaoApiService by lazy { val retrofit = Retrofit.Builder() diff --git a/app/src/main/java/campus/tech/kakao/PlaceRepository.kt b/app/src/main/java/campus/tech/kakao/PlaceRepository.kt new file mode 100644 index 00000000..42c1ee69 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/PlaceRepository.kt @@ -0,0 +1,27 @@ +package campus.tech.kakao + +import campus.tech.kakao.Model.KakaoApiService +import campus.tech.kakao.Model.Place +import campus.tech.kakao.Model.ResultSearchKeyword +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response + +class PlaceRepository(private val apiService: KakaoApiService) { + + fun searchPlaces(apiKey: String, query: String, callback: (List?, Throwable?) -> Unit) { + apiService.searchPlaces(apiKey, query).enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful && response.body() != null) { + callback(response.body()?.documents ?: emptyList(), null) + } else { + callback(null, Throwable("No results")) + } + } + + override fun onFailure(call: Call, t: Throwable) { + callback(null, t) + } + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/PlaceViewModel.kt b/app/src/main/java/campus/tech/kakao/PlaceViewModel.kt new file mode 100644 index 00000000..bd045082 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/PlaceViewModel.kt @@ -0,0 +1,24 @@ +package campus.tech.kakao + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import campus.tech.kakao.Model.Place + +class PlaceViewModel(private val repository: PlaceRepository) : ViewModel() { + + private val _places = MutableLiveData?>() + val places: MutableLiveData?> get() = _places + + private val _error = MutableLiveData() + val error: MutableLiveData get() = _error + + fun searchPlaces(apiKey: String, query: String) { + repository.searchPlaces(apiKey, query) { result, error -> + if (result != null) { + _places.value = result + } else { + _error.value = error + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/PlaceViewModelFactory.kt b/app/src/main/java/campus/tech/kakao/PlaceViewModelFactory.kt new file mode 100644 index 00000000..e92321f3 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/PlaceViewModelFactory.kt @@ -0,0 +1,14 @@ +package campus.tech.kakao + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider + +class PlaceViewModelFactory(private val repository: PlaceRepository) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(PlaceViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return PlaceViewModel(repository) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/SearchFragment.kt b/app/src/main/java/campus/tech/kakao/SearchFragment.kt index 80752750..80a43f5e 100644 --- a/app/src/main/java/campus/tech/kakao/SearchFragment.kt +++ b/app/src/main/java/campus/tech/kakao/SearchFragment.kt @@ -7,24 +7,26 @@ import android.view.ViewGroup import android.widget.TextView import androidx.appcompat.widget.SearchView import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import campus.tech.kakao.map.R -import campus.tech.kakao.Model.ResultSearchKeyword import campus.tech.kakao.Model.RetrofitClient import campus.tech.kakao.Model.SQLiteDb -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response +import campus.tech.kakao.PlaceRepository +import campus.tech.kakao.PlaceViewModel +import campus.tech.kakao.PlaceViewModelFactory class SearchFragment : Fragment() { - private lateinit var searchView: SearchView - private lateinit var recyclerView: RecyclerView - private lateinit var noResultTextView: TextView + lateinit var searchView: SearchView + lateinit var recyclerView: RecyclerView + lateinit var noResultTextView: TextView private lateinit var databaseHelper: SQLiteDb - private lateinit var historyRecyclerView: RecyclerView + lateinit var historyRecyclerView: RecyclerView private lateinit var historyAdapter: HistoryAdapter private lateinit var adapter: PlacesAdapter + private lateinit var placeViewModel: PlaceViewModel override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -38,6 +40,7 @@ class SearchFragment : Fragment() { initializeViews(view) setupRecyclerViews() setupSearchView() + setupViewModel() updateHistoryData() } @@ -47,7 +50,7 @@ class SearchFragment : Fragment() { } private fun initializeViews(view: View) { - searchView = view.findViewById(R.id.searchView) + searchView = view.findViewById(R.id.searchView2) recyclerView = view.findViewById(R.id.recyclerView) noResultTextView = view.findViewById(R.id.noResultTextView) historyRecyclerView = view.findViewById(R.id.historyRecyclerView) @@ -76,7 +79,8 @@ class SearchFragment : Fragment() { searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { if (!query.isNullOrEmpty()) { - searchPlaces(query) + val apiKey = "KakaoAK ${campus.tech.kakao.map.BuildConfig.KAKAO_REST_API_KEY}" + placeViewModel.searchPlaces(apiKey, query) } return true } @@ -86,35 +90,30 @@ class SearchFragment : Fragment() { showNoResultMessage() adapter.updateData(emptyList()) } else { - searchPlaces(newText) + val apiKey = "KakaoAK ${campus.tech.kakao.map.BuildConfig.KAKAO_REST_API_KEY}" + placeViewModel.searchPlaces(apiKey, newText) } return true } }) } - private fun searchPlaces(query: String) { - val apiKey = "KakaoAK ${campus.tech.kakao.map.BuildConfig.KAKAO_REST_API_KEY}" - - RetrofitClient.instance.searchPlaces(apiKey, query).enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful && response.body() != null) { - val places = response.body()?.documents ?: emptyList() - if (places.isEmpty()) { - showNoResultMessage() - } else { - hideNoResultMessage() - adapter.updateData(places) - } - } else { - showNoResultMessage() - } - } + private fun setupViewModel() { + val repository = PlaceRepository(RetrofitClient.instance) + placeViewModel = ViewModelProvider(this, PlaceViewModelFactory(repository)).get(PlaceViewModel::class.java) - override fun onFailure(call: Call, t: Throwable) { + placeViewModel.places.observe(viewLifecycleOwner, Observer { places -> + if (places?.isEmpty() == true) { showNoResultMessage() + } else { + hideNoResultMessage() + places?.let { adapter.updateData(it) } } }) + + placeViewModel.error.observe(viewLifecycleOwner, Observer { error -> + showNoResultMessage() + }) } private fun showNoResultMessage() { diff --git a/app/src/main/res/layout/fragment_map.xml b/app/src/main/res/layout/fragment_map.xml index 9297d525..af5a599c 100644 --- a/app/src/main/res/layout/fragment_map.xml +++ b/app/src/main/res/layout/fragment_map.xml @@ -3,18 +3,21 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="match_parent"> + android:layout_height="match_parent" + tools:context="campus.tech.kakao.View.MapFragment"> + + + tools:context="campus.tech.kakao.View.SearchFragment"> diff --git a/app/src/test/java/MapFragmentUnitTest.kt b/app/src/test/java/MapFragmentUnitTest.kt new file mode 100644 index 00000000..e941a4e9 --- /dev/null +++ b/app/src/test/java/MapFragmentUnitTest.kt @@ -0,0 +1,70 @@ +import android.content.Context +import android.content.SharedPreferences +import androidx.fragment.app.testing.FragmentScenario +import androidx.test.core.app.ApplicationProvider +import campus.tech.kakao.View.MapFragment +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + + +@Config(manifest = Config.NONE) +@RunWith(RobolectricTestRunner::class) +class MapFragmentUnitTest { + private lateinit var fragmentScenario: FragmentScenario + + @Before + fun setUp() { + fragmentScenario = FragmentScenario.launchInContainer(MapFragment::class.java) + } + + @After + fun tearDown() { + fragmentScenario.close() + } + + @Test + fun testGetLastLocation() { + val context = ApplicationProvider.getApplicationContext() + val sharedPreferences: SharedPreferences = context.getSharedPreferences("MapPrefs", Context.MODE_PRIVATE) + sharedPreferences.edit().putFloat("lastY", 37.402005f).putFloat("lastX", 127.108621f).apply() + + fragmentScenario.onFragment { fragment -> + val lastLocation = fragment.getLastLocation(context) + + assertEquals(37.402005, lastLocation.first) + assertEquals(127.108621, lastLocation.second) + } + } + + @Test + fun testSaveLastLocation() { + val context = ApplicationProvider.getApplicationContext() + val sharedPreferences: SharedPreferences = context.getSharedPreferences("MapPrefs", Context.MODE_PRIVATE) + + fragmentScenario.onFragment { fragment -> + fragment.saveLastLocation(37.402005, 127.108621) + + val lastY = sharedPreferences.getFloat("Y", 0f) + val lastX = sharedPreferences.getFloat("X", 0f) + + assertEquals(37.402005f, lastY) + assertEquals(127.108621f, lastX) + } + } + + @Test + fun testSetCoordinates() { + fragmentScenario.onFragment { fragment -> + fragment.setCoordinates(127.0, 37.0, "PlaceName", "RoadAddressName") + assertEquals(127.0, fragment.x) + assertEquals(37.0, fragment.y) + } + } + + + } diff --git a/app/src/test/java/RetrofitUnitTest.kt b/app/src/test/java/RetrofitUnitTest.kt new file mode 100644 index 00000000..ed70d1da --- /dev/null +++ b/app/src/test/java/RetrofitUnitTest.kt @@ -0,0 +1,29 @@ +package campus.tech.kakao.Model + +import okhttp3.HttpUrl +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +@Config(manifest = Config.NONE) +@RunWith(RobolectricTestRunner::class) +class RetrofitClientTest { + + @Test + fun testRetrofitInstanceCreation() { + val apiService: KakaoApiService = RetrofitClient.instance + + assertNotNull("KakaoApiService instance not gonna be null", apiService) + val retrofit = Retrofit.Builder() + .baseUrl(RetrofitClient.BASE_URL) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + val baseUrl: HttpUrl = retrofit.baseUrl() + assertEquals("baseUrls gonna be same with expected url", RetrofitClient.BASE_URL, baseUrl.toString()) + } +} \ No newline at end of file diff --git a/app/src/test/java/SearchFragmentUnitTest.kt b/app/src/test/java/SearchFragmentUnitTest.kt new file mode 100644 index 00000000..b54d7f74 --- /dev/null +++ b/app/src/test/java/SearchFragmentUnitTest.kt @@ -0,0 +1,104 @@ +import android.content.Context +import android.view.View +import androidx.fragment.app.testing.FragmentScenario +import androidx.test.core.app.ApplicationProvider +import campus.tech.kakao.Model.SQLiteDb +import campus.tech.kakao.View.SearchFragment +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNotNull +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@Config(manifest = Config.NONE) +@RunWith(RobolectricTestRunner::class) +class SearchFragmentUnitTest { + + + + private lateinit var fragmentScenario: FragmentScenario + + @Before + fun setUp() { + fragmentScenario = FragmentScenario.launchInContainer(SearchFragment::class.java) + } + + @After + fun tearDown() { + fragmentScenario.close() + } + + @Test + fun testInitializeViews() { + fragmentScenario.onFragment { fragment -> + fragment.initializeViews(fragment.requireView()) + + assertNotNull(fragment.searchView) + assertNotNull(fragment.recyclerView) + assertNotNull(fragment.noResultTextView) + assertNotNull(fragment.historyRecyclerView) + } + } + + @Test + fun testSetupRecyclerViews() { + fragmentScenario.onFragment { fragment -> + fragment.setupRecyclerViews() + + assertNotNull(fragment.recyclerView.adapter) + assertNotNull(fragment.historyRecyclerView.adapter) + } + } + + @Test + fun testUpdateHistoryData() { + fragmentScenario.onFragment { fragment -> + val context = ApplicationProvider.getApplicationContext() + val databaseHelper = SQLiteDb(context) + databaseHelper.insertIntoSelectedData("testPlace") + + fragment.updateHistoryData() + + val historyData = databaseHelper.getAllSelectedData() + assertEquals(1, historyData.size) + assertEquals("testPlace", historyData[0]) + } + } + + @Test + fun testSearchPlaces() { + fragmentScenario.onFragment { fragment -> + fragment.searchPlaces("testQuery") + + fragment.showNoResultMessage() + assertEquals(View.VISIBLE, fragment.noResultTextView.visibility) + assertEquals(View.GONE, fragment.recyclerView.visibility) + } + } + + @Test + fun testShowNoResultMessage() { + fragmentScenario.onFragment { fragment -> + fragment.showNoResultMessage() + + assertEquals(View.VISIBLE, fragment.noResultTextView.visibility) + assertEquals(View.GONE, fragment.recyclerView.visibility) + assertEquals(View.GONE, fragment.historyRecyclerView.visibility) + } + } + + @Test + fun testHideNoResultMessage() { + fragmentScenario.onFragment { fragment -> + fragment.hideNoResultMessage() + + assertEquals(View.GONE, fragment.noResultTextView.visibility) + assertEquals(View.VISIBLE, fragment.recyclerView.visibility) + assertEquals(View.VISIBLE, fragment.historyRecyclerView.visibility) + } + } + + } diff --git a/gradle.properties b/gradle.properties index 3caa6cc3..d3a4885b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,4 +21,4 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -android.defaults.buildfeatures.buildconfig=true \ No newline at end of file +android.defaults.buildfeatures.buildconfig=true