diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..1de72da --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,44 @@ +name: Android CI + +# Define when the pipeline should run +on: + push: + branches: [ "master", "main" ] + pull_request: + branches: [ "master", "main" ] + +jobs: + build_and_test: + runs-on: ubuntu-latest + + steps: + # Download the code from the repository + - name: Checkout code + uses: actions/checkout@v4 + + # Set up Java 17 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + # Make the Gradle wrapper executable + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + # Run Local Unit Tests + - name: Run local Unit Tests + run: ./gradlew testDebugUnitTest + + # Build the APK to ensure the project compiles correctly + - name: Build Debug APK + run: ./gradlew assembleDebug + + # Upload the APK as an artifact so you can download it from GitHub + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: app-debug + path: app/build/outputs/apk/debug/app-debug.apk \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6b1d910..5d33560 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,11 +30,11 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = "11" + jvmTarget = "17" } buildFeatures { compose = true @@ -50,13 +50,10 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) - testImplementation(libs.junit) - androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.tooling) - debugImplementation(libs.androidx.compose.ui.test.manifest) // Hilt implementation(libs.hilt.android) ksp(libs.hilt.compiler) @@ -73,12 +70,26 @@ dependencies { // Navigation implementation(libs.navigation.compose) - // Tests + // Local Unit Tests + testImplementation(libs.androidx.test.core) testImplementation(libs.junit) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.compose.ui.test.junit4) - debugImplementation(libs.androidx.compose.ui.tooling) + testImplementation(libs.androidx.arch.core.testing) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.google.truth) + testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.mockk) debugImplementation(libs.androidx.compose.ui.test.manifest) + + // Instrumentation tests + androidTestImplementation(libs.hilt.android.testing) + kspAndroidTest(libs.hilt.compiler) + androidTestImplementation(libs.junit) + androidTestImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.arch.core.testing) + androidTestImplementation(libs.google.truth) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.test.core.ktx) + androidTestImplementation(libs.okhttp.mockwebserver) + androidTestImplementation(libs.mockk.android) + androidTestImplementation(libs.androidx.test.runner) } \ No newline at end of file diff --git a/app/src/main/java/com/bober/notesapp/data/repository/FakeNoteRepository.kt b/app/src/main/java/com/bober/notesapp/data/repository/FakeNoteRepository.kt index 4acf62d..667c8c5 100644 --- a/app/src/main/java/com/bober/notesapp/data/repository/FakeNoteRepository.kt +++ b/app/src/main/java/com/bober/notesapp/data/repository/FakeNoteRepository.kt @@ -1,52 +1,43 @@ package com.bober.notesapp.data.repository -import android.app.Application -import com.bober.notesapp.R import com.bober.notesapp.domain.model.Note import com.bober.notesapp.domain.repository.NoteRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import javax.inject.Inject -class FakeNoteRepository @Inject constructor( - private val app: Application -): NoteRepository { +class FakeNoteRepository @Inject constructor(): NoteRepository { + + private val fakeContent = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec vestibulum vel arcu vel ullamcorper. Integer venenatis diam ac facilisis accumsan. Phasellus lorem felis, sollicitudin non justo ut, posuere dignissim nunc. Nunc dignissim volutpat mauris quis rhoncus. Curabitur tempor ipsum non lacus porttitor mollis. Etiam dictum eros eros, at auctor augue consequat a. Fusce a arcu ac est maximus hendrerit at lacinia tellus.In feugiat dui non laoreet vestibulum. Nunc vulputate id mauris a iaculis. Curabitur vitae tempus justo. Morbi a est et sapien maximus porta eu ac ante. Cras gravida feugiat elementum. Vestibulum dictum volutpat est sit amet commodo. Suspendisse sed lectus magna. Pellentesque eget augue sed ipsum aliquet tincidunt. Interdum et malesuada fames ac ante ipsum primis in faucibus. Nulla sed auctor arcu. Proin ligula nisl, auctor ac ornare eu, mattis quis lorem. Phasellus et gravida metus, vitae tincidunt nibh. Nam tempus, libero porttitor mattis suscipit, neque neque viverra nunc, non ultrices nibh odio non turpis. Vestibulum dui sem, congue vel tellus ac, convallis iaculis mauris. Integer vulputate, augue bibendum congue ullamcorper, lacus enim luctus magna, sed iaculis magna enim eget augue." // This replaces a real database during development or testing - private val _notesFlow = MutableStateFlow>( + private val notesFlow = MutableStateFlow( listOf( - Note(id = 1, title = "First Note", content = app.getString(R.string.fake_content), timestamp = 1L, color = 0xFFFFAB91.toInt()), - Note(id = 2, title = "Second Note", content = app.getString(R.string.fake_content), timestamp = 2L, color = 0xFFE7ED9B.toInt()), - Note(id = 3, title = "Third Note", content = app.getString(R.string.fake_content), timestamp = 3L, color = 0xFFD7AEFB.toInt()) + Note(id = 1, title = "First Note", content = fakeContent, timestamp = 1L, color = 0xFFFFAB91.toInt()), + Note(id = 2, title = "Second Note", content = fakeContent, timestamp = 2L, color = 0xFFE7ED9B.toInt()), + Note(id = 3, title = "Third Note", content = fakeContent, timestamp = 3L, color = 0xFFD7AEFB.toInt()) ) ) + override fun getNotes(): Flow> { - return _notesFlow.asStateFlow() + return notesFlow } override suspend fun getNoteById(id: Int): Note? { - return _notesFlow.value.find { it.id == id } + return notesFlow.value.find { it.id == id } } override suspend fun insertNote(note: Note) { - _notesFlow.update { currentNotes -> - val existingNote = currentNotes.find { it.id == note.id } - if (existingNote != null) { - currentNotes.map { if (it.id == note.id) note else it } - } else { - val newId = (currentNotes.maxOfOrNull { it.id ?: 0 } ?: 0) + 1 - currentNotes + note.copy(id = newId) - } + notesFlow.update { currentNotes -> + currentNotes + note } } override suspend fun deleteNote(note: Note) { - println("Repository: Deleting note ${note.id}") - _notesFlow.update { currentNotes -> + notesFlow.update { currentNotes -> currentNotes.filter { it.id != note.id } } - println("Repository: Remaining notes: ${_notesFlow.value.size}") } } \ No newline at end of file diff --git a/app/src/main/java/com/bober/notesapp/di/AppModule.kt b/app/src/main/java/com/bober/notesapp/di/AppModule.kt index 3b09b26..130c236 100644 --- a/app/src/main/java/com/bober/notesapp/di/AppModule.kt +++ b/app/src/main/java/com/bober/notesapp/di/AppModule.kt @@ -30,11 +30,6 @@ object AppModule { @Singleton fun provideNoteRepository(db : NoteDatabase): NoteRepository{ return NoteRepositoryImpl(db.noteDao()) + // return FakeNoteRepository() } - -// @Provides -// @Singleton -// fun provideNoteRepository(app: Application): NoteRepository{ -// return FakeNoteRepository(app) -// } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3d8ff1a..1943441 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,4 @@ NotesApp - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec vestibulum vel arcu vel ullamcorper. Integer venenatis diam ac facilisis accumsan. Phasellus lorem felis, sollicitudin non justo ut, posuere dignissim nunc. Nunc dignissim volutpat mauris quis rhoncus. Curabitur tempor ipsum non lacus porttitor mollis. Etiam dictum eros eros, at auctor augue consequat a. Fusce a arcu ac est maximus hendrerit at lacinia tellus. - -In feugiat dui non laoreet vestibulum. Nunc vulputate id mauris a iaculis. Curabitur vitae tempus justo. Morbi a est et sapien maximus porta eu ac ante. Cras gravida feugiat elementum. Vestibulum dictum volutpat est sit amet commodo. Suspendisse sed lectus magna. Pellentesque eget augue sed ipsum aliquet tincidunt. Interdum et malesuada fames ac ante ipsum primis in faucibus. Nulla sed auctor arcu. Proin ligula nisl, auctor ac ornare eu, mattis quis lorem. Phasellus et gravida metus, vitae tincidunt nibh. Nam tempus, libero porttitor mattis suscipit, neque neque viverra nunc, non ultrices nibh odio non turpis. Vestibulum dui sem, congue vel tellus ac, convallis iaculis mauris. Integer vulputate, augue bibendum congue ullamcorper, lacus enim luctus magna, sed iaculis magna enim eget augue. + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec vestibulum vel arcu vel ullamcorper. Integer venenatis diam ac facilisis accumsan. Phasellus lorem felis, sollicitudin non justo ut, posuere dignissim nunc. Nunc dignissim volutpat mauris quis rhoncus. Curabitur tempor ipsum non lacus porttitor mollis. Etiam dictum eros eros, at auctor augue consequat a. Fusce a arcu ac est maximus hendrerit at lacinia tellus.In feugiat dui non laoreet vestibulum. Nunc vulputate id mauris a iaculis. Curabitur vitae tempus justo. Morbi a est et sapien maximus porta eu ac ante. Cras gravida feugiat elementum. Vestibulum dictum volutpat est sit amet commodo. Suspendisse sed lectus magna. Pellentesque eget augue sed ipsum aliquet tincidunt. Interdum et malesuada fames ac ante ipsum primis in faucibus. Nulla sed auctor arcu. Proin ligula nisl, auctor ac ornare eu, mattis quis lorem. Phasellus et gravida metus, vitae tincidunt nibh. Nam tempus, libero porttitor mattis suscipit, neque neque viverra nunc, non ultrices nibh odio non turpis. Vestibulum dui sem, congue vel tellus ac, convallis iaculis mauris. Integer vulputate, augue bibendum congue ullamcorper, lacus enim luctus magna, sed iaculis magna enim eget augue. \ No newline at end of file diff --git a/app/src/test/java/com/bober/notesapp/ExampleUnitTest.kt b/app/src/test/java/com/bober/notesapp/ExampleUnitTest.kt deleted file mode 100644 index de906ef..0000000 --- a/app/src/test/java/com/bober/notesapp/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.bober.notesapp - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/app/src/test/java/com/bober/notesapp/data/repository/FakeTestNoteRepository.kt b/app/src/test/java/com/bober/notesapp/data/repository/FakeTestNoteRepository.kt new file mode 100644 index 0000000..92af543 --- /dev/null +++ b/app/src/test/java/com/bober/notesapp/data/repository/FakeTestNoteRepository.kt @@ -0,0 +1,28 @@ +package com.bober.notesapp.data.repository + +import com.bober.notesapp.domain.model.Note +import com.bober.notesapp.domain.repository.NoteRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +class FakeTestNoteRepository @Inject constructor(): NoteRepository { + + private val notes = mutableListOf() + + override fun getNotes(): Flow> { + return flow { emit(notes) } + } + + override suspend fun getNoteById(id: Int): Note? { + return notes.find { it.id == id } + } + + override suspend fun insertNote(note: Note) { + notes.add(note) + } + + override suspend fun deleteNote(note: Note) { + notes.remove(note) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/bober/notesapp/domain/use_case/AddNoteTest.kt b/app/src/test/java/com/bober/notesapp/domain/use_case/AddNoteTest.kt new file mode 100644 index 0000000..b23fbb1 --- /dev/null +++ b/app/src/test/java/com/bober/notesapp/domain/use_case/AddNoteTest.kt @@ -0,0 +1,73 @@ +package com.bober.notesapp.domain.use_case + +import com.bober.notesapp.data.repository.FakeTestNoteRepository +import com.bober.notesapp.domain.model.InvalidNoteException +import com.bober.notesapp.domain.model.Note +import kotlinx.coroutines.test.runTest +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test + +class AddNoteTest { + private lateinit var addNote: AddNote + private lateinit var fakeRepository: FakeTestNoteRepository + + @Before + fun setUp() { + fakeRepository = FakeTestNoteRepository() + addNote = AddNote(fakeRepository) + } + + @Test + fun `Insert valid note, successfully adds to repository`() = runTest { + val note = Note( + title = "Not blank", + content = "Not blank", + timestamp = 1L, + color = 1, + id = 99 + ) + addNote(note) + + val result = fakeRepository.getNoteById(99) + assertThat(result).isEqualTo(note) + } + + @Test + fun `Insert note with blank title, throws exception`() = runTest { + val note = Note( + title = "", + content = "Not blank", + timestamp = 1L, + color = 1, + id = 99 + ) + + val exception = try { + addNote(note) + null + } catch (e: InvalidNoteException){ + e + } + assertThat(exception?.message).isEqualTo("The title of the note can't be empty.") + } + + @Test + fun `Insert note with blank content, throws exception`() = runTest { + val note = Note( + title = "Not blank", + content = "", + timestamp = 1L, + color = 1, + id = 99 + ) + + val exception = try { + addNote(note) + null + } catch (e: InvalidNoteException){ + e + } + assertThat(exception?.message).isEqualTo("The content of the note can't be empty.") + } +} \ No newline at end of file diff --git a/app/src/test/java/com/bober/notesapp/domain/use_case/GetNotesTest.kt b/app/src/test/java/com/bober/notesapp/domain/use_case/GetNotesTest.kt new file mode 100644 index 0000000..6b18948 --- /dev/null +++ b/app/src/test/java/com/bober/notesapp/domain/use_case/GetNotesTest.kt @@ -0,0 +1,93 @@ +package com.bober.notesapp.domain.use_case + +import com.bober.notesapp.data.repository.FakeTestNoteRepository +import com.bober.notesapp.domain.model.Note +import com.bober.notesapp.domain.util.NoteOrder +import com.bober.notesapp.domain.util.OrderType +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class GetNotesTest { + + private lateinit var getNotes: GetNotes + private lateinit var fakeRepository: FakeTestNoteRepository + + @Before + fun setUp(){ + fakeRepository = FakeTestNoteRepository() + getNotes = GetNotes(fakeRepository) + + val notesToInsert = mutableListOf() + ('a'..'z').forEachIndexed { index, ch -> + notesToInsert.add( + Note( + title = ch.toString(), + content = ch.toString(), + timestamp = index.toLong(), + color = index + ) + ) + } + notesToInsert.shuffle() + runTest { + notesToInsert.forEach { fakeRepository.insertNote(it) } + } + } + + @Test + fun `Order notes by title ascending, correct order`() = runTest { + val notes = getNotes(NoteOrder.Title(OrderType.Ascending)).first() + + for (i in 0..notes.size-2){ + assertThat(notes[i].title).isLessThan(notes[i+1].title) + } + } + + @Test + fun `Order notes by title descending, correct order`() = runTest { + val notes = getNotes(NoteOrder.Title(OrderType.Descending)).first() + + for (i in 0..notes.size - 2) { + assertThat(notes[i].title).isGreaterThan(notes[i + 1].title) + } + } + + @Test + fun `Order notes by date ascending, correct order`() = runTest { + val notes = getNotes(NoteOrder.Date(OrderType.Ascending)).first() + + for (i in 0..notes.size - 2) { + assertThat(notes[i].timestamp).isLessThan(notes[i + 1].timestamp) + } + } + + @Test + fun `Order notes by date descending, correct order`() = runTest { + val notes = getNotes(NoteOrder.Date(OrderType.Descending)).first() + + for (i in 0..notes.size - 2) { + assertThat(notes[i].timestamp).isGreaterThan(notes[i + 1].timestamp) + } + } + + @Test + fun `Order notes by color ascending, correct order`() = runTest { + val notes = getNotes(NoteOrder.Color(OrderType.Ascending)).first() + + for (i in 0..notes.size - 2) { + assertThat(notes[i].color).isLessThan(notes[i + 1].color) + } + } + + @Test + fun `Order notes by color descending, correct order`() = runTest { + val notes = getNotes(NoteOrder.Color(OrderType.Descending)).first() + + for (i in 0..notes.size - 2) { + assertThat(notes[i].color).isGreaterThan(notes[i + 1].color) + } + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a8c6be0..df609c0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,11 @@ ksp = "2.0.21-1.0.25" room = "2.6.1" coroutines = "1.9.0" navigation = "2.8.5" +androidxTestCore = "1.6.1" +archCoreTesting = "2.2.0" +truth = "1.4.4" +mockwebserver = "4.12.0" +mockk = "1.13.13" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -45,6 +50,20 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx- # Navigation navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } +# Unit Testing +androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } +androidx-arch-core-testing = { group = "androidx.arch.core", name = "core-testing", version.ref = "archCoreTesting" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } +google-truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } +okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "mockwebserver" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } + +# Instrumentation Testing +hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } +androidx-test-runner = { group = "androidx.test", name = "runner", version = "1.6.2" } +mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } +androidx-test-core-ktx = { group = "androidx.test", name = "core-ktx", version.ref = "androidxTestCore" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }