Skip to content

Commit 260746f

Browse files
authored
๐Ÿš€ 3๋‹จ๊ณ„ - GitHub(UI ์ƒํƒœ) (#56)
* refactor: ColorScheme ํŒŒ์ผ ์ถ”๊ฐ€ ๋ฐ topAppBarContainer ํ™•์žฅ ์†์„ฑ ์ถ”๊ฐ€ * refactor: CenterAlignedTopAppBar containerColor White์—์„œ topAppBarContainer ์†์„ฑ์œผ๋กœ ๋Œ€์ฒด ๋ฐ Color White ์ œ๊ฑฐ * refactor: HorizontalDivider thickness 1.dp ์†์„ฑ ์ œ๊ฑฐ * test(refactor): GithubScreenTest ํ™”๋ฉด ๊ฒ€์ฆ ๋กœ์ง ์ฝ”๋“œ ๊ฐ„๊ฒฐํ™” * feat: UiState sealed class ์ถ”๊ฐ€ * feat: RepositoryUiState ์ถ”๊ฐ€ * feat: GithubViewModel์—์„œ repositoryUiState ์ถ”๊ฐ€ ๋ฐ ์ƒํƒœ ๊ด€๋ฆฌ ๊ฐœ์„  * feat: CenteredContent ์ปดํฌ๋„ŒํŠธ ์ถ”๊ฐ€ ๋ฐ GithubScreen์—์„œ ์ƒํƒœ์— ๋”ฐ๋ฅธ UI ์ฒ˜๋ฆฌ ๊ฐœ์„  * feat: GithubScreen์— ์Šค๋‚ต๋ฐ” ์˜ค๋ฅ˜ ์ฒ˜๋ฆฌ ๋ฐ ์žฌ์‹œ๋„ ๋กœ์ง ์ถ”๊ฐ€ * feat: ํ•˜๋“œ์ฝ”ํŒ… text string resource๋กœ ๋Œ€์ฒด * feat: SnackbarHost, CircularProgressIndicator sementic ํƒœ๊ทธ ์ถ”๊ฐ€ * refactor: ๋นˆ ํ™”๋ฉด ๋ฌธ๊ตฌ ์ˆ˜์ • * test: GithubScreen(Stateless) ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ž‘์„ฑ * feat: FakeGithubRepository ์ถ”๊ฐ€ * test: GithubScreen(Stateful) ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ž‘์„ฑ * docs: 2๋‹จ๊ณ„ ๊ฐœ์„  ์‚ฌํ•ญ ์ •์˜ * docs: 3๋‹จ๊ณ„ ๊ธฐ๋Šฅ ์š”๊ตฌ ์‚ฌํ•ญ ์ •์˜ * test(fix): repository.clearData() ์ œ๊ฑฐ * refactor: FakeGithubRepository clearData ํ•จ์ˆ˜ ์ œ๊ฑฐ * test: StatelessGithubScreenTest ์Šค๋‚ต๋ฐ” ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ถ”๊ฐ€ * refactor: LaunchedEffect viewModel key ์ง€์ • * refactor: _errorFlow emit > tryEmit ์œผ๋กœ ์ˆ˜์ •
1 parent a526fa5 commit 260746f

File tree

15 files changed

+559
-91
lines changed

15 files changed

+559
-91
lines changed

โ€ŽREADME.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,19 @@
2020
- ํžŒํŠธ ์ฝ”๋“œ๋ฅผ ์ฐธ๊ณ ํ•˜์—ฌ ViewModel Factory๋ฅผ ๊ตฌํ˜„ํ•œ๋‹ค. (Hilt์™€ ๊ฐ™์€ ๋ณ„๋„์˜ DI ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ํ™œ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค)
2121
- ์ €์žฅ์†Œ ๋ชฉ๋ก์„ ๋…ธ์ถœํ•˜๋Š” UI์™€ ๊ด€๋ จ๋œ ๊ธฐ๋Šฅ์€ ui ํŒจํ‚ค์ง€์— ๊ตฌํ˜„๋˜์–ด์•ผ ํ•œ๋‹ค.
2222
- UI ๋ ˆ์ด์–ด๋Š” ๋ฐ์ดํ„ฐ ๋ ˆ์ด์–ด๋ฅผ ์˜์กดํ•˜์ง€๋งŒ, ๋ฐ์ดํ„ฐ ๋ ˆ์ด์–ด๋Š” UI ๋ ˆ์ด์–ด๋ฅผ ์˜์กดํ•ด์„  ์•ˆ ๋œ๋‹ค.
23+
24+
#### ๐Ÿš€ 2๋‹จ๊ณ„ - GitHub(UI ๋ ˆ์ด์–ด) ๊ฐœ์„  ์‚ฌํ•ญ
25+
- GithubScreen(stateless) private ์ ‘๊ทผ ์ œ์–ด์ž ์ œ๊ฑฐ (ํ…Œ์ŠคํŠธ ์šฉ์ด์„ฑ)
26+
- Text style ์ฝ”๋“œ ๊ฐ„์†Œํ™” (style ์ง€์ •ํ•˜๋ฉด ์ผ๊ด„ ์ ์šฉ)
27+
- Color ์ง€์ •์„ MaterialTheme.colors ์‚ฌ์šฉํ•˜๋„๋ก ๋ณ€๊ฒฝ (ํ™•์žฅ ํ”„๋กœํผํ‹ฐ ํ™œ์šฉ)
28+
- LazyColumn key ์ถ”๊ฐ€ (์„ฑ๋Šฅ ์ตœ์ ํ™”)
29+
- viewModelScope Dispatchers.IO ์ œ๊ฑฐ(Retrofit ๋‚ด๋ถ€์ ์œผ๋กœ ์ ์ ˆํžˆ ์„ธํŒ…ํ•ด์คŒ)
30+
- GithubScreen ๋‚ด๋ถ€ ์ปดํฌ๋„ŒํŠธ ๋ถ„๋ฆฌ
31+
- GithubViewModel getRepositories > fetchRepositories๋กœ ํ•จ์ˆ˜๋ช… ๋ณ€๊ฒฝ(์˜๋„ ๋ช…ํ™•ํ™”)
32+
- HorizontalDivider thickness 1dp ์ œ๊ฑฐ (๋‚ด๋ถ€ ๊ธฐ๋ณธ๊ฐ’์ด 1dp)
33+
34+
### ๐Ÿš€ 3๋‹จ๊ณ„ - GitHub(UI ์ƒํƒœ) ์š”๊ตฌ ์‚ฌํ•ญ
35+
- ๋ชฉ๋ก์ด ๋กœ๋”ฉ๋˜๊ธฐ ์ „์—๋Š” ๋กœ๋”ฉ UI๋ฅผ ๋…ธ์ถœํ•œ๋‹ค.
36+
- ๋ชฉ๋ก์ด ๋นˆ ๊ฒฝ์šฐ์—๋Š” ๋นˆ ํ™”๋ฉด UI๋ฅผ ๋…ธ์ถœํ•œ๋‹ค.
37+
- ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•œ ๊ฒฝ์šฐ ์žฌ์‹œ๋„ ๊ฐ€๋Šฅํ•œ ์Šค๋‚ต๋ฐ”๋ฅผ ๋…ธ์ถœํ•œ๋‹ค.
38+

โ€Žapp/src/androidTest/java/GithubScreenTest.kt

Lines changed: 0 additions & 48 deletions
This file was deleted.
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import androidx.compose.ui.test.assertIsDisplayed
2+
import androidx.compose.ui.test.junit4.createComposeRule
3+
import androidx.compose.ui.test.onAllNodesWithText
4+
import androidx.compose.ui.test.onFirst
5+
import androidx.compose.ui.test.onNodeWithContentDescription
6+
import androidx.compose.ui.test.onNodeWithText
7+
import androidx.compose.ui.test.performClick
8+
import kotlinx.coroutines.runBlocking
9+
import nextstep.github.data.model.RepositoryModel
10+
import nextstep.github.data.repository.FakeGithubRepository
11+
import nextstep.github.ui.screen.github.GithubScreen
12+
import nextstep.github.ui.screen.github.GithubViewModel
13+
import org.junit.Before
14+
import org.junit.Rule
15+
import org.junit.Test
16+
17+
class StatefulGithubScreenTest {
18+
19+
@get:Rule
20+
val composeTestRule = createComposeRule()
21+
22+
private lateinit var fakeViewModel: GithubViewModel
23+
private lateinit var repository: FakeGithubRepository
24+
private val data = listOf(
25+
RepositoryModel(
26+
id = 1,
27+
fullName = "next-step/nextstep-docs",
28+
description = "nextstep ๋งค๋‰ด์–ผ ๋ฐ ๋ฌธ์„œ๋ฅผ ๊ด€๋ฆฌํ•˜๋Š” ์ €์žฅ์†Œ",
29+
),
30+
RepositoryModel(
31+
id = 2,
32+
fullName = "next-step/holy-moly",
33+
description = "nextstep ํ™€๋ฆฌ๋ชฐ๋ฆฌํ•œ ์ €์žฅ์†Œ",
34+
),
35+
RepositoryModel(
36+
id = 3,
37+
fullName = "next-step/haly-galy",
38+
description = "nextstep ํ• ๋ฆฌ๊ฐˆ๋ฆฌํ•œ ์ €์žฅ์†Œ",
39+
),
40+
)
41+
42+
@Before
43+
fun setUp() {
44+
// FakeGithubRepository ์ƒ์„ฑ
45+
repository = FakeGithubRepository()
46+
47+
// GithubViewModel FakeGithubRepository ์ฃผ์ž… ๋ฐ ์ƒ์„ฑ
48+
fakeViewModel = GithubViewModel(repository)
49+
}
50+
51+
@Test
52+
fun Github_๋ฐ์ดํ„ฐ๋ฅผ_์ •์ƒ์ ์œผ๋กœ_๋ถˆ๋Ÿฌ์™”์„๋–„_๋ฐ์ดํ„ฐ๊ฐ€_ํ™”๋ฉด์—_ํ‘œ์‹œ๋œ๋‹ค() {
53+
// ๋ฐ์ดํ„ฐ ์„ธํŒ…
54+
repository.setFakeData(data)
55+
56+
composeTestRule.setContent {
57+
GithubScreen(viewModel = fakeViewModel)
58+
}
59+
60+
// ๋ฐ์ดํ„ฐ ๋กœ๋“œ
61+
runBlocking {
62+
fakeViewModel.fetchRepositories("next-step")
63+
}
64+
65+
// ๋ฐ์ดํ„ฐ ์ถœ๋ ฅ ๊ฒ€์ฆ
66+
data.forEach {
67+
composeTestRule.onNodeWithText(it.fullName)
68+
.assertExists()
69+
}
70+
}
71+
72+
@Test
73+
fun Github_๋ฐ์ดํ„ฐ๊ฐ€_๋น„์–ด์žˆ์„๋•Œ_ํ™”๋ฉด์—_๋ฉ”์‹œ์ง€๊ฐ€_์ถœ๋ ฅ๋œ๋‹ค() {
74+
composeTestRule.setContent {
75+
GithubScreen(viewModel = fakeViewModel)
76+
}
77+
78+
// ๋ฐ์ดํ„ฐ ๋กœ๋“œ
79+
runBlocking {
80+
fakeViewModel.fetchRepositories("next-step")
81+
}
82+
83+
// ๋นˆ ๋ฐ์ดํ„ฐ ๋ฉ”์‹œ์ง€ ์ถœ๋ ฅ ๊ฒ€์ฆ
84+
composeTestRule.onAllNodesWithText("๋ชฉ๋ก์ด ๋น„์—ˆ์Šต๋‹ˆ๋‹ค.")
85+
.onFirst()
86+
.assertExists()
87+
}
88+
89+
@Test
90+
fun Github_๋ฐ์ดํ„ฐ๋ฅผ_๋กœ๋“œ์ค‘_์—๋Ÿฌ_๋ฐœ์ƒ์‹œ_์—๋Ÿฌ_์Šค๋‚ต๋ฐ”๋ฅผ_ํ˜ธ์ถœํ•œ๋‹ค() {
91+
// ๋ฐ์ดํ„ฐ ๋กœ๋“œ ์‹คํŒจ ์„ธํŒ…
92+
repository.setFailureMode(true)
93+
94+
composeTestRule.setContent {
95+
GithubScreen(viewModel = fakeViewModel)
96+
}
97+
98+
// ๋ฐ์ดํ„ฐ ๋กœ๋“œ
99+
runBlocking {
100+
fakeViewModel.fetchRepositories("next-step")
101+
}
102+
103+
// ์—๋Ÿฌ ๋ฐœ์ƒ ์Šค๋‚ต๋ฐ” ํ˜ธ์ถœ ๊ฒ€์ฆ
104+
composeTestRule.onNodeWithContentDescription("Snackbar")
105+
.assertIsDisplayed()
106+
107+
composeTestRule.onNodeWithText("์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.")
108+
.assertIsDisplayed()
109+
110+
composeTestRule.onNodeWithText("์žฌ์‹œ๋„")
111+
.assertIsDisplayed()
112+
}
113+
114+
@Test
115+
fun Github_์—๋Ÿฌ_์Šค๋‚ต๋ฐ”_์žฌ์‹œ๋„_ํด๋ฆญ์‹œ_๋ฐ์ดํ„ฐ๋ฅผ_๋กœ๋“œ๋ฅผ_๋‹ค์‹œ_์‹œ๋„ํ•œ๋‹ค() {
116+
// ๋ฐ์ดํ„ฐ ๋กœ๋“œ ์‹คํŒจ ์„ธํŒ…
117+
repository.setFailureMode(true)
118+
119+
composeTestRule.setContent {
120+
GithubScreen(viewModel = fakeViewModel)
121+
}
122+
123+
runBlocking {
124+
fakeViewModel.fetchRepositories("next-step")
125+
}
126+
127+
// ๋ฐ์ดํ„ฐ ๋กœ๋“œ ์„ฑ๊ณต ์„ธํŒ…
128+
repository.setFailureMode(false)
129+
repository.setFakeData(data)
130+
131+
// ์—๋Ÿฌ ๋ฐœ์ƒ ํ›„ ์žฌ์‹œ๋„ ํด๋ฆญ
132+
composeTestRule.onNodeWithText("์žฌ์‹œ๋„")
133+
.performClick()
134+
135+
// ๋ฐ์ดํ„ฐ ๋กœ๋“œ ์„ฑ๊ณต ๊ฒ€์ฆ
136+
data.forEach {
137+
composeTestRule.onNodeWithText(it.fullName)
138+
.assertExists()
139+
}
140+
}
141+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import androidx.compose.material3.SnackbarHostState
2+
import androidx.compose.material3.SnackbarResult
3+
import androidx.compose.ui.test.assertIsDisplayed
4+
import androidx.compose.ui.test.assertIsNotDisplayed
5+
import androidx.compose.ui.test.junit4.createComposeRule
6+
import androidx.compose.ui.test.onAllNodesWithText
7+
import androidx.compose.ui.test.onFirst
8+
import androidx.compose.ui.test.onNodeWithContentDescription
9+
import androidx.compose.ui.test.onNodeWithText
10+
import kotlinx.coroutines.ExperimentalCoroutinesApi
11+
import kotlinx.coroutines.launch
12+
import kotlinx.coroutines.test.advanceUntilIdle
13+
import kotlinx.coroutines.test.runTest
14+
import nextstep.github.ui.screen.github.GithubScreen
15+
import nextstep.github.ui.screen.github.RepositoryUiState
16+
import nextstep.github.ui.uistate.UiState
17+
import org.junit.Rule
18+
import org.junit.Test
19+
20+
class StatelessGithubScreenTest {
21+
22+
@get:Rule
23+
val composeTestRule = createComposeRule()
24+
25+
val uiState = UiState.Success(
26+
data = listOf(
27+
RepositoryUiState(
28+
id = 1,
29+
fullName = "next-step/nextstep-docs",
30+
description = "nextstep ๋งค๋‰ด์–ผ ๋ฐ ๋ฌธ์„œ๋ฅผ ๊ด€๋ฆฌํ•˜๋Š” ์ €์žฅ์†Œ",
31+
),
32+
RepositoryUiState(
33+
id = 2,
34+
fullName = "next-step/holy-moly",
35+
description = "nextstep ํ™€๋ฆฌ๋ชฐ๋ฆฌํ•œ ์ €์žฅ์†Œ",
36+
),
37+
RepositoryUiState(
38+
id = 3,
39+
fullName = "next-step/haly-galy",
40+
description = "nextstep ํ• ๋ฆฌ๊ฐˆ๋ฆฌํ•œ ์ €์žฅ์†Œ",
41+
),
42+
)
43+
)
44+
45+
@Test
46+
fun Github_๋ ˆํฌ์ง€ํ† ๋ฆฌ_๋ฐ์ดํ„ฐ๊ฐ€_์ •์ƒ์ ์œผ๋กœ_ํ™”๋ฉด์—_์ถœ๋ ฅ๋œ๋‹ค() {
47+
composeTestRule.setContent {
48+
GithubScreen(
49+
repositoryUiState = uiState,
50+
snackbarHostState = SnackbarHostState()
51+
)
52+
}
53+
54+
uiState.data.forEach {
55+
composeTestRule.onNodeWithText(it.fullName)
56+
.assertExists()
57+
}
58+
}
59+
60+
@Test
61+
fun Github_๋ ˆํฌ์ง€ํ† ๋ฆฌ_๋ฐ์ดํ„ฐ๊ฐ€_๋นˆ๊ฐ’์ผ์‹œ_ํ™”๋ฉด์—_๋ฉ”์‹œ์ง€๊ฐ€_์ถœ๋ ฅ๋œ๋‹ค() {
62+
composeTestRule.setContent {
63+
GithubScreen(
64+
repositoryUiState = UiState.Empty,
65+
snackbarHostState = SnackbarHostState()
66+
)
67+
}
68+
69+
composeTestRule.onAllNodesWithText("๋ชฉ๋ก์ด ๋น„์—ˆ์Šต๋‹ˆ๋‹ค.")
70+
.onFirst()
71+
.assertExists()
72+
}
73+
74+
@Test
75+
fun Github_๋ ˆํฌ์ง€ํ† ๋ฆฌ_๋ฐ์ดํ„ฐ๊ฐ€_๋กœ๋”ฉ์ผ์‹œ_๋กœ๋”ฉ๋ฐ”๊ฐ€_ํ™”๋ฉด์—_์ถœ๋ ฅ๋œ๋‹ค() {
76+
composeTestRule.setContent {
77+
GithubScreen(
78+
repositoryUiState = UiState.Loading,
79+
snackbarHostState = SnackbarHostState()
80+
)
81+
}
82+
83+
composeTestRule.onNodeWithContentDescription("LoadingProgressBar")
84+
.assertIsDisplayed()
85+
}
86+
87+
@OptIn(ExperimentalCoroutinesApi::class)
88+
@Test
89+
fun ์˜ค๋ฅ˜_๋ฐœ์ƒ์‹œ_ํ™”๋ฉด์—_์Šค๋‚ต๋ฐ”๊ฐ€_ํ˜ธ์ถœ๋œ๋‹ค() = runTest {
90+
91+
val snackbarHostState = SnackbarHostState()
92+
93+
composeTestRule.setContent {
94+
GithubScreen(
95+
repositoryUiState = UiState.Empty,
96+
snackbarHostState = snackbarHostState
97+
)
98+
}
99+
100+
launch {
101+
snackbarHostState.showSnackbar(
102+
message = "์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.",
103+
actionLabel = "์žฌ์‹œ๋„",
104+
)
105+
}
106+
107+
advanceUntilIdle()
108+
109+
composeTestRule.onNodeWithContentDescription("Snackbar")
110+
.assertIsDisplayed()
111+
112+
composeTestRule.onNodeWithText("์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.")
113+
.assertIsDisplayed()
114+
115+
snackbarHostState.currentSnackbarData?.dismiss()
116+
}
117+
118+
@OptIn(ExperimentalCoroutinesApi::class)
119+
@Test
120+
fun ์Šค๋‚ต๋ฐ”_์žฌ์‹œ๋„_๋ฒ„ํŠผ์„_ํด๋ฆญํ•˜๋ฉด_์Šค๋‚ต๋ฐ”๊ฐ€_์‚ฌ๋ผ์ง„๋‹ค() = runTest {
121+
val snackbarHostState = SnackbarHostState()
122+
123+
composeTestRule.setContent {
124+
GithubScreen(
125+
repositoryUiState = UiState.Empty,
126+
snackbarHostState = snackbarHostState
127+
)
128+
}
129+
130+
launch {
131+
when (snackbarHostState.showSnackbar(
132+
message = "์˜ˆ๊ธฐ์น˜ ๋ชปํ•œ ์˜ค๋ฅ˜",
133+
actionLabel = "์žฌ์‹œ๋„"
134+
)) {
135+
SnackbarResult.ActionPerformed -> {
136+
snackbarHostState.currentSnackbarData?.dismiss()
137+
}
138+
139+
else -> {}
140+
}
141+
}
142+
143+
advanceUntilIdle()
144+
145+
snackbarHostState.currentSnackbarData?.performAction()
146+
147+
advanceUntilIdle()
148+
149+
composeTestRule.onNodeWithContentDescription("Snackbar")
150+
.assertIsNotDisplayed()
151+
}
152+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package nextstep.github.data.repository
2+
3+
import nextstep.github.data.model.RepositoryModel
4+
5+
class FakeGithubRepository : GithubRepository {
6+
7+
private var shouldFail = false
8+
private var fakeData: List<RepositoryModel> = emptyList()
9+
10+
fun setFakeData(data: List<RepositoryModel>) {
11+
shouldFail = false
12+
fakeData = data
13+
}
14+
15+
fun setFailureMode(isFail: Boolean) {
16+
shouldFail = isFail
17+
}
18+
19+
override suspend fun getRepositories(organization: String): Result<List<RepositoryModel>> {
20+
return if (shouldFail) {
21+
Result.failure(Exception("Fake network error"))
22+
} else {
23+
Result.success(fakeData)
24+
}
25+
}
26+
}

0 commit comments

Comments
ย (0)