From cdb326ef45862ac5b54ae0c0c1afbf65e98bbdbe Mon Sep 17 00:00:00 2001 From: Bober1337IT Date: Sun, 29 Mar 2026 22:05:42 +0200 Subject: [PATCH 1/2] add MainDispatcherRule and NotesViewModelTest --- .../com/bober/notesapp/MainDispatcherRule.kt | 20 +++ .../data/repository/FakeTestNoteRepository.kt | 18 ++- .../presentation/notes/NotesViewModelTest.kt | 118 ++++++++++++++++++ 3 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 app/src/test/java/com/bober/notesapp/MainDispatcherRule.kt create mode 100644 app/src/test/java/com/bober/notesapp/presentation/notes/NotesViewModelTest.kt diff --git a/app/src/test/java/com/bober/notesapp/MainDispatcherRule.kt b/app/src/test/java/com/bober/notesapp/MainDispatcherRule.kt new file mode 100644 index 0000000..24e7493 --- /dev/null +++ b/app/src/test/java/com/bober/notesapp/MainDispatcherRule.kt @@ -0,0 +1,20 @@ +package com.bober.notesapp + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.* +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} \ 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 index 92af543..9b5d0a4 100644 --- a/app/src/test/java/com/bober/notesapp/data/repository/FakeTestNoteRepository.kt +++ b/app/src/test/java/com/bober/notesapp/data/repository/FakeTestNoteRepository.kt @@ -3,26 +3,32 @@ 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.MutableStateFlow import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.update import javax.inject.Inject class FakeTestNoteRepository @Inject constructor(): NoteRepository { - private val notes = mutableListOf() + private val notes = MutableStateFlow>(emptyList()) override fun getNotes(): Flow> { - return flow { emit(notes) } + return (notes) } override suspend fun getNoteById(id: Int): Note? { - return notes.find { it.id == id } + return notes.value.find { it.id == id } } override suspend fun insertNote(note: Note) { - notes.add(note) + notes.update { + it + note + } } override suspend fun deleteNote(note: Note) { - notes.remove(note) + notes.update { currentNotes -> + currentNotes.filter { it.id != note.id } + } } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/bober/notesapp/presentation/notes/NotesViewModelTest.kt b/app/src/test/java/com/bober/notesapp/presentation/notes/NotesViewModelTest.kt new file mode 100644 index 0000000..c46affe --- /dev/null +++ b/app/src/test/java/com/bober/notesapp/presentation/notes/NotesViewModelTest.kt @@ -0,0 +1,118 @@ +package com.bober.notesapp.presentation.notes + +import com.bober.notesapp.MainDispatcherRule +import com.bober.notesapp.data.repository.FakeTestNoteRepository +import com.bober.notesapp.domain.model.Note +import com.bober.notesapp.domain.use_case.AddNote +import com.bober.notesapp.domain.use_case.DeleteNote +import com.bober.notesapp.domain.use_case.GetNotes +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.Rule +import org.junit.Test + +class NotesViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private lateinit var viewModel: NotesViewModel + private lateinit var fakeRepository: FakeTestNoteRepository + + @Before + fun setUp() = runTest { + fakeRepository = FakeTestNoteRepository() + val getNotes = GetNotes(fakeRepository) + val deleteNote = DeleteNote(fakeRepository) + val addNote = AddNote(fakeRepository) + + viewModel = NotesViewModel(getNotes, addNote, deleteNote) + + val notesToInsert = mutableListOf() + ('a'..'z').forEachIndexed { index, ch -> + notesToInsert.add( + Note( + id = index, + title = ch.toString(), + content = ch.toString(), + timestamp = index.toLong(), + color = index + ) + ) + } + notesToInsert.shuffle() + notesToInsert.forEach { fakeRepository.insertNote(it) } + + } + + @Test + fun `Repository change, state should update automatically`() = runTest { + val initialSize = viewModel.state.value.notes.size + assertThat(initialSize).isEqualTo(26) + + val newNote = Note( + title = "Zzz", + content = "Content", + timestamp = 9999L, + color = 1, + id = 100 + ) + fakeRepository.insertNote(newNote) + + val updatedNotes = viewModel.state.value.notes + assertThat(updatedNotes.size).isEqualTo(27) + assertThat(updatedNotes).contains(newNote) + } + + @Test + fun `Order notes by title ascending, state should update correctly`() = runTest { + assertThat(viewModel.state.value.noteOrder).isInstanceOf(NoteOrder.Date::class.java) + assertThat(viewModel.state.value.noteOrder.orderType).isEqualTo(OrderType.Descending) + + val newOrder = NoteOrder.Title(OrderType.Ascending) + viewModel.onEvent(NotesEvent.Order(newOrder)) + + assertThat(viewModel.state.value.noteOrder).isInstanceOf(NoteOrder.Title::class.java) + assertThat(viewModel.state.value.noteOrder.orderType).isEqualTo(OrderType.Ascending) + + val notes = viewModel.state.value.notes + for (i in 0..notes.size - 2) { + assertThat(notes[i].title.lowercase()).isAtMost(notes[i + 1].title.lowercase()) + } + } + + @Test + fun `Delete note and then Restore note, note should be back in repository`() = runTest { + + val noteToDelete = fakeRepository.getNoteById(1)!! + + viewModel.onEvent(NotesEvent.DeleteNote(noteToDelete)) + var notes = fakeRepository.getNotes().first() + assertThat(notes).doesNotContain(noteToDelete) + + viewModel.onEvent(NotesEvent.RestoreNote) + + notes = fakeRepository.getNotes().first() + assertThat(notes).contains(noteToDelete) + } + + @Test + fun `Toggle order section, order section should be visible`() = runTest { + + val initialToggle = viewModel.state.value.isOrderSectionVisible + assertThat(initialToggle).isFalse() + + viewModel.onEvent(NotesEvent.ToggleOrderSection) + + val toggledOnce = viewModel.state.value.isOrderSectionVisible + assertThat(toggledOnce).isTrue() + + viewModel.onEvent(NotesEvent.ToggleOrderSection) + val toggledTwice = viewModel.state.value.isOrderSectionVisible + assertThat(toggledTwice).isFalse() + } +} \ No newline at end of file From dc0b27edcf87556a1e4789330bd2b9475ee4efdd Mon Sep 17 00:00:00 2001 From: Bober1337IT Date: Sun, 29 Mar 2026 22:14:32 +0200 Subject: [PATCH 2/2] add AddEditNoteViewModelTest for testing note creation and editing --- .../add_edit_note/AddEditNoteViewModelTest.kt | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 app/src/test/java/com/bober/notesapp/presentation/add_edit_note/AddEditNoteViewModelTest.kt diff --git a/app/src/test/java/com/bober/notesapp/presentation/add_edit_note/AddEditNoteViewModelTest.kt b/app/src/test/java/com/bober/notesapp/presentation/add_edit_note/AddEditNoteViewModelTest.kt new file mode 100644 index 0000000..0c7a1d4 --- /dev/null +++ b/app/src/test/java/com/bober/notesapp/presentation/add_edit_note/AddEditNoteViewModelTest.kt @@ -0,0 +1,99 @@ +package com.bober.notesapp.presentation.add_edit_note + +import androidx.lifecycle.SavedStateHandle +import com.bober.notesapp.MainDispatcherRule +import com.bober.notesapp.data.repository.FakeTestNoteRepository +import com.bober.notesapp.domain.model.Note +import com.bober.notesapp.domain.use_case.AddNote +import com.bober.notesapp.domain.use_case.GetNote +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AddEditNoteViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private lateinit var viewModel: AddEditNoteViewModel + private lateinit var fakeRepository: FakeTestNoteRepository + private lateinit var getNote: GetNote + private lateinit var addNote: AddNote + + @Before + fun setUp() { + fakeRepository = FakeTestNoteRepository() + getNote = GetNote(fakeRepository) + addNote = AddNote(fakeRepository) + } + + @Test + fun `Load note with id, state should update with note details`() = runTest { + val note = Note( + title = "Title", + content = "Content", + timestamp = 1L, + color = 1, + id = 1 + ) + fakeRepository.insertNote(note) + + val savedStateHandle = SavedStateHandle(mapOf("noteId" to 1)) + viewModel = AddEditNoteViewModel(savedStateHandle, getNote, addNote) + + // Give it a bit of time for the init block to finish its coroutine + mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle() + + assertThat(viewModel.noteTitle.value.text).isEqualTo(note.title) + assertThat(viewModel.noteContent.value.text).isEqualTo(note.content) + assertThat(viewModel.noteTitle.value.isHintVisible).isFalse() + } + + @Test + fun `Enter title, state should update correctly`() { + viewModel = AddEditNoteViewModel(SavedStateHandle(), getNote, addNote) + val title = "New Title" + viewModel.onEvent(AddEditNoteEvent.EnteredTitle(title)) + + assertThat(viewModel.noteTitle.value.text).isEqualTo(title) + } + + @Test + fun `Save note, should be added to repository`() = runTest { + viewModel = AddEditNoteViewModel(SavedStateHandle(), getNote, addNote) + + viewModel.onEvent(AddEditNoteEvent.EnteredTitle("Test Title")) + viewModel.onEvent(AddEditNoteEvent.EnteredContent("Test Content")) + viewModel.onEvent(AddEditNoteEvent.SaveNote) + + mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle() + + val notes = fakeRepository.getNotes().first() + assertThat(notes.any { it.title == "Test Title" && it.content == "Test Content" }).isTrue() + } + + @Test + fun `Save empty note, should emit snackbar event`() = runTest { + viewModel = AddEditNoteViewModel(SavedStateHandle(), getNote, addNote) + + val events = mutableListOf() + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.eventFlow.collect { events.add(it) } + } + + // Title and content are empty by default + viewModel.onEvent(AddEditNoteEvent.SaveNote) + + assertThat(events).isNotEmpty() + assertThat(events.first()).isInstanceOf(AddEditNoteViewModel.UiEvent.ShowSnackbar::class.java) + + job.cancel() + } +}