diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml
new file mode 100644
index 0000000..5c07f19
--- /dev/null
+++ b/.github/workflows/testing.yml
@@ -0,0 +1,24 @@
+name: Android Testing CI
+on: [push]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - name: set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: gradle
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Unit Test
+ run: ./gradlew testDebugUnitTest
+
+ - name: Android Test Report
+ uses: asadmansr/android-test-report-action@v1.2.0
+ if: ${{ always() }}
\ No newline at end of file
diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml
new file mode 100644
index 0000000..761af4e
--- /dev/null
+++ b/.idea/androidTestResultsUserPreferences.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
index bac89b4..202dd8b 100644
--- a/.idea/deploymentTargetSelector.xml
+++ b/.idea/deploymentTargetSelector.xml
@@ -13,6 +13,12 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 92c4e9e..f467023 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -48,6 +48,13 @@ android {
compose = true
buildConfig = true
}
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ excludes += "/META-INF/LICENSE.md"
+ excludes += "/META-INF/LICENSE-notice.md"
+ }
+ }
}
dependencies {
@@ -85,6 +92,8 @@ dependencies {
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
+ androidTestImplementation(libs.mockk)
+ androidTestImplementation(libs.kotlinx.coroutines.test)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}
\ No newline at end of file
diff --git a/app/src/androidTest/java/dev/javfuentes/dailyjoke/ExampleInstrumentedTest.kt b/app/src/androidTest/java/dev/javfuentes/dailyjoke/ExampleInstrumentedTest.kt
index 8867369..176640b 100644
--- a/app/src/androidTest/java/dev/javfuentes/dailyjoke/ExampleInstrumentedTest.kt
+++ b/app/src/androidTest/java/dev/javfuentes/dailyjoke/ExampleInstrumentedTest.kt
@@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("dev.javfuentes.dailyjoke", appContext.packageName)
+ assertEquals("dev.javfuentes.dailyjoke.debug", appContext.packageName)
}
}
\ No newline at end of file
diff --git a/app/src/androidTest/java/dev/javfuentes/dailyjoke/ui/JokeScreenTest.kt b/app/src/androidTest/java/dev/javfuentes/dailyjoke/ui/JokeScreenTest.kt
deleted file mode 100644
index fd45aa2..0000000
--- a/app/src/androidTest/java/dev/javfuentes/dailyjoke/ui/JokeScreenTest.kt
+++ /dev/null
@@ -1,148 +0,0 @@
-package dev.javfuentes.dailyjoke.ui
-
-import androidx.compose.ui.test.*
-import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import dev.javfuentes.dailyjoke.data.Joke
-import dev.javfuentes.dailyjoke.data.JokeType
-import dev.javfuentes.dailyjoke.data.model.*
-import dev.javfuentes.dailyjoke.data.repository.JokeRepository
-import dev.javfuentes.dailyjoke.ui.screens.JokeScreen
-import dev.javfuentes.dailyjoke.ui.theme.DailyJokeTheme
-import dev.javfuentes.dailyjoke.viewmodel.JokeViewModel
-import io.mockk.*
-import kotlinx.coroutines.flow.flowOf
-import org.junit.*
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class JokeScreenTest {
-
- @get:Rule
- val composeTestRule = createComposeRule()
-
- private val mockRepository = mockk()
- private lateinit var viewModel: JokeViewModel
-
- @Before
- fun setup() {
- viewModel = JokeViewModel(mockRepository)
- }
-
- @After
- fun tearDown() {
- clearAllMocks()
- }
-
- @Test
- fun jokeScreen_displaysLoadingIndicator_whenLoading() {
- // Given
- coEvery { mockRepository.getRandomJoke() } returns flowOf(ApiResult.Loading())
-
- // When
- composeTestRule.setContent {
- DailyJokeTheme {
- JokeScreen(viewModel = viewModel)
- }
- }
-
- // Then
- composeTestRule.onNodeWithText("Loading joke...").assertIsDisplayed()
- }
-
- @Test
- fun jokeScreen_displaysJoke_whenSuccess() {
- // Given
- val mockJoke = Joke(
- id = 1,
- category = "Programming",
- setup = "Why do programmers prefer dark mode?",
- punchline = "Because light attracts bugs!",
- type = JokeType.TWOPART,
- safe = true,
- lang = "en"
- )
- coEvery { mockRepository.getRandomJoke() } returns flowOf(ApiResult.Success(mockJoke))
-
- // When
- composeTestRule.setContent {
- DailyJokeTheme {
- JokeScreen(viewModel = viewModel)
- }
- }
-
- // Then
- composeTestRule.onNodeWithText("Why do programmers prefer dark mode?").assertIsDisplayed()
- composeTestRule.onNodeWithText("Because light attracts bugs!").assertIsDisplayed()
- composeTestRule.onNodeWithText("Programming").assertIsDisplayed()
- composeTestRule.onNodeWithText("Joke #1").assertIsDisplayed()
- }
-
- @Test
- fun jokeScreen_displaysErrorMessage_whenError() {
- // Given
- coEvery { mockRepository.getRandomJoke() } returns flowOf(
- ApiResult.Error(NetworkException("Network error"))
- )
-
- // When
- composeTestRule.setContent {
- DailyJokeTheme {
- JokeScreen(viewModel = viewModel)
- }
- }
-
- // Then
- composeTestRule.onNodeWithText("Network error").assertIsDisplayed()
- composeTestRule.onNodeWithText("Try Again").assertIsDisplayed()
- }
-
- @Test
- fun jokeScreen_retriesOnButtonClick() {
- // Given
- coEvery { mockRepository.getRandomJoke() } returns flowOf(
- ApiResult.Error(NetworkException("Network error"))
- )
-
- composeTestRule.setContent {
- DailyJokeTheme {
- JokeScreen(viewModel = viewModel)
- }
- }
-
- // When
- composeTestRule.onNodeWithText("Try Again").performClick()
-
- // Then
- coVerify { mockRepository.getRandomJoke() }
- }
-
- @Test
- fun jokeScreen_refreshesOnNewJokeButtonClick() {
- // Given
- val mockJoke = createMockJoke()
- coEvery { mockRepository.getRandomJoke() } returns flowOf(ApiResult.Success(mockJoke))
-
- composeTestRule.setContent {
- DailyJokeTheme {
- JokeScreen(viewModel = viewModel)
- }
- }
-
- // When
- composeTestRule.onNodeWithText("New Joke").performClick()
-
- // Then
- coVerify(atLeast = 2) { mockRepository.getRandomJoke() }
- }
-
- private fun createMockJoke() = Joke(
- id = 1,
- category = "Programming",
- setup = "Test setup",
- punchline = "Test punchline",
- type = JokeType.TWOPART,
- safe = true,
- lang = "en"
- )
-}
\ No newline at end of file
diff --git a/app/src/test/java/dev/javfuentes/dailyjoke/viewmodel/JokeViewModelTest.kt b/app/src/test/java/dev/javfuentes/dailyjoke/viewmodel/JokeViewModelTest.kt
deleted file mode 100644
index a80aabd..0000000
--- a/app/src/test/java/dev/javfuentes/dailyjoke/viewmodel/JokeViewModelTest.kt
+++ /dev/null
@@ -1,208 +0,0 @@
-package dev.javfuentes.dailyjoke.viewmodel
-
-import app.cash.turbine.test
-import com.google.common.truth.Truth.assertThat
-import dev.javfuentes.dailyjoke.data.Joke
-import dev.javfuentes.dailyjoke.data.JokeType
-import dev.javfuentes.dailyjoke.data.model.*
-import dev.javfuentes.dailyjoke.data.repository.JokeRepository
-import io.mockk.*
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.flowOf
-import kotlinx.coroutines.test.*
-import org.junit.*
-
-@ExperimentalCoroutinesApi
-class JokeViewModelTest {
-
- private val mockRepository = mockk()
- private lateinit var viewModel: JokeViewModel
- private val testDispatcher = StandardTestDispatcher()
-
- @Before
- fun setup() {
- Dispatchers.setMain(testDispatcher)
- viewModel = JokeViewModel(mockRepository)
- }
-
- @After
- fun tearDown() {
- Dispatchers.resetMain()
- clearAllMocks()
- }
-
- @Test
- fun `initial state should be loading`() = runTest {
- // Given
- val mockJoke = createMockJoke()
- coEvery { mockRepository.getRandomJoke() } returns flowOf(
- ApiResult.Loading(),
- ApiResult.Success(mockJoke)
- )
-
- // When
- viewModel.uiState.test {
- // Then
- val initialState = awaitItem()
- assertThat(initialState.isLoading).isTrue()
- assertThat(initialState.joke).isNull()
- assertThat(initialState.errorMessage).isNull()
- }
- }
-
- @Test
- fun `handleEvent LoadJoke should update state with success`() = runTest {
- // Given
- val mockJoke = createMockJoke()
- coEvery { mockRepository.getRandomJoke() } returns flowOf(
- ApiResult.Loading(),
- ApiResult.Success(mockJoke)
- )
-
- // When
- viewModel.handleEvent(JokeUiEvent.LoadJoke)
-
- // Then
- viewModel.uiState.test {
- val loadingState = awaitItem()
- assertThat(loadingState.isLoading).isTrue()
-
- val successState = awaitItem()
- assertThat(successState.isLoading).isFalse()
- assertThat(successState.joke).isEqualTo(mockJoke)
- assertThat(successState.errorMessage).isNull()
- }
- }
-
- @Test
- fun `handleEvent LoadJoke should update state with error`() = runTest {
- // Given
- val errorMessage = "Network error"
- coEvery { mockRepository.getRandomJoke() } returns flowOf(
- ApiResult.Loading(),
- ApiResult.Error(NetworkException(errorMessage))
- )
-
- // When
- viewModel.handleEvent(JokeUiEvent.LoadJoke)
-
- // Then
- viewModel.uiState.test {
- val loadingState = awaitItem()
- assertThat(loadingState.isLoading).isTrue()
-
- val errorState = awaitItem()
- assertThat(errorState.isLoading).isFalse()
- assertThat(errorState.joke).isNull()
- assertThat(errorState.errorMessage).isEqualTo(errorMessage)
- }
- }
-
- @Test
- fun `handleEvent RefreshJoke should set isRefreshing to true`() = runTest {
- // Given
- val mockJoke = createMockJoke()
- coEvery { mockRepository.getRandomJoke() } returns flowOf(
- ApiResult.Loading(),
- ApiResult.Success(mockJoke)
- )
-
- // When
- viewModel.handleEvent(JokeUiEvent.RefreshJoke)
-
- // Then
- viewModel.uiState.test {
- val refreshingState = awaitItem()
- assertThat(refreshingState.isRefreshing).isTrue()
- }
- }
-
- @Test
- fun `handleEvent ClearError should clear error message`() = runTest {
- // Given
- coEvery { mockRepository.getRandomJoke() } returns flowOf(
- ApiResult.Error(NetworkException("Error"))
- )
- viewModel.handleEvent(JokeUiEvent.LoadJoke)
-
- // When
- viewModel.handleEvent(JokeUiEvent.ClearError)
-
- // Then
- viewModel.uiState.test {
- val clearedState = awaitItem()
- assertThat(clearedState.errorMessage).isNull()
- }
- }
-
- @Test
- fun `handleEvent SaveFavoriteJoke should add current joke to favorites list`() = runTest {
- // Given
- val mockJoke = createMockJoke()
- coEvery { mockRepository.getRandomJoke() } returns flowOf(
- ApiResult.Success(mockJoke)
- )
-
- // Load a joke first
- viewModel.handleEvent(JokeUiEvent.LoadJoke)
-
- // When
- viewModel.handleEvent(JokeUiEvent.SaveFavoriteJoke)
-
- // Then
- viewModel.uiState.test {
- val stateWithFavorite = awaitItem()
- assertThat(stateWithFavorite.favoriteJokes).hasSize(1)
- assertThat(stateWithFavorite.favoriteJokes).contains(mockJoke)
- }
- }
-
- @Test
- fun `handleEvent SaveFavoriteJoke should not add duplicate jokes to favorites`() = runTest {
- // Given
- val mockJoke = createMockJoke()
- coEvery { mockRepository.getRandomJoke() } returns flowOf(
- ApiResult.Success(mockJoke)
- )
-
- // Load a joke first
- viewModel.handleEvent(JokeUiEvent.LoadJoke)
-
- // When - save the same joke twice
- viewModel.handleEvent(JokeUiEvent.SaveFavoriteJoke)
- viewModel.handleEvent(JokeUiEvent.SaveFavoriteJoke)
-
- // Then
- viewModel.uiState.test {
- val stateWithFavorites = awaitItem()
- assertThat(stateWithFavorites.favoriteJokes).hasSize(1)
- assertThat(stateWithFavorites.favoriteJokes).contains(mockJoke)
- }
- }
-
- @Test
- fun `handleEvent SaveFavoriteJoke should do nothing when no current joke exists`() = runTest {
- // Given - no joke loaded (initial state)
-
- // When
- viewModel.handleEvent(JokeUiEvent.SaveFavoriteJoke)
-
- // Then
- viewModel.uiState.test {
- val state = awaitItem()
- assertThat(state.favoriteJokes).isEmpty()
- assertThat(state.joke).isNull()
- }
- }
-
- private fun createMockJoke() = Joke(
- id = 1,
- category = "Programming",
- setup = "Why do programmers prefer dark mode?",
- punchline = "Because light attracts bugs!",
- type = JokeType.TWOPART,
- safe = true,
- lang = "en"
- )
-}
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index f00f9bf..d1f6059 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -16,6 +16,7 @@ coroutines = "1.10.2"
mockk = "1.14.4"
truth = "1.4.4"
turbine = "1.2.1"
+robolectric = "4.15.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -44,6 +45,7 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
+robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }