diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 68868c0..4e0eec4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -113,11 +113,13 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation(libs.androidx.junit.ktx) testImplementation(libs.junit) testImplementation(libs.junit.jupiter) testImplementation(libs.junit.jupiter) testImplementation(libs.junit.jupiter) testImplementation(libs.junit.jupiter) + testImplementation(libs.jupiter.junit.jupiter) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) @@ -125,6 +127,16 @@ dependencies { debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) + // + testImplementation(libs.jupiter.junit.jupiter.api) + testRuntimeOnly(libs.jupiter.junit.jupiter.engine) + + // AndroidX Test Core + testImplementation(libs.androidx.core) + + // Kotlin testing + testImplementation(libs.jetbrains.kotlin.test) + // MockK testImplementation(libs.mockk.mockk) @@ -152,6 +164,7 @@ dependencies { ksp(libs.androidx.room.compiler) implementation(libs.androidx.room.ktx) testImplementation(libs.androidx.room.testing) + testImplementation(libs.robolectric.robolectric) // Retrofit implementation(libs.retrofit2.retrofit) diff --git a/app/src/main/java/com/example/stockmarketcheck/mainFeature/presentation/company_info/CompanyInfoScreen.kt b/app/src/main/java/com/example/stockmarketcheck/mainFeature/presentation/company_info/CompanyInfoScreen.kt index d43e0a0..bb40522 100644 --- a/app/src/main/java/com/example/stockmarketcheck/mainFeature/presentation/company_info/CompanyInfoScreen.kt +++ b/app/src/main/java/com/example/stockmarketcheck/mainFeature/presentation/company_info/CompanyInfoScreen.kt @@ -55,7 +55,7 @@ fun CompanyInfoScreen( modifier = Modifier .fillMaxWidth() - .padding(start = 5.dp), + .padding(start = 10.dp), ) Spacer(modifier = Modifier.height(8.dp)) Text( @@ -65,7 +65,7 @@ fun CompanyInfoScreen( modifier = Modifier .fillMaxWidth() - .padding(start = 5.dp), + .padding(start = 10.dp), ) Spacer(modifier = Modifier.height(8.dp)) HorizontalDivider( @@ -80,7 +80,7 @@ fun CompanyInfoScreen( modifier = Modifier .fillMaxWidth() - .padding(start = 5.dp), + .padding(start = 10.dp), overflow = TextOverflow.Ellipsis, ) Spacer(modifier = Modifier.height(8.dp)) @@ -90,7 +90,7 @@ fun CompanyInfoScreen( modifier = Modifier .fillMaxWidth() - .padding(start = 5.dp), + .padding(start = 10.dp), overflow = TextOverflow.Ellipsis, ) Spacer(modifier = Modifier.height(8.dp)) @@ -106,7 +106,7 @@ fun CompanyInfoScreen( modifier = Modifier .fillMaxWidth() - .padding(start = 5.dp), + .padding(start = 10.dp), ) if (state.stockIntradayInfos.isNotEmpty()) { Spacer(modifier = Modifier.height(16.dp)) diff --git a/app/src/main/java/com/example/stockmarketcheck/mainFeature/presentation/company_info/CompanyInfoViewModel.kt b/app/src/main/java/com/example/stockmarketcheck/mainFeature/presentation/company_info/CompanyInfoViewModel.kt index 44deac1..020d7ed 100644 --- a/app/src/main/java/com/example/stockmarketcheck/mainFeature/presentation/company_info/CompanyInfoViewModel.kt +++ b/app/src/main/java/com/example/stockmarketcheck/mainFeature/presentation/company_info/CompanyInfoViewModel.kt @@ -29,44 +29,33 @@ class CompanyInfoViewModel state = state.copy(isLoading = true) val companyInfoResult = async { repository.getCompanyInfo(symbol) } val intradayInfoResult = async { repository.getIntradayInfo(symbol) } - when (val result = companyInfoResult.await()) { - is Resource.Success -> { - state = - state.copy( - company = result.data, - isLoading = false, - error = null, - ) - } - is Resource.Error -> { - state = + + val companyInfo = companyInfoResult.await() + val intradayInfo = intradayInfoResult.await() + + state = + when { + companyInfo is Resource.Error -> state.copy( isLoading = false, - error = result.message, + error = companyInfo.message, company = null, ) - } - else -> Unit - } - when (val result = intradayInfoResult.await()) { - is Resource.Success -> { - state = + intradayInfo is Resource.Error -> state.copy( - stockIntradayInfos = result.data ?: emptyList(), isLoading = false, - error = null, + error = intradayInfo.message, + company = (companyInfo as? Resource.Success)?.data, ) - } - is Resource.Error -> { - state = + companyInfo is Resource.Success && intradayInfo is Resource.Success -> state.copy( isLoading = false, - error = result.message, - company = null, + company = companyInfo.data, + stockIntradayInfos = intradayInfo.data ?: emptyList(), + error = null, ) + else -> state.copy(isLoading = false, error = "An unexpected error occurred") } - else -> Unit - } } } } \ No newline at end of file diff --git a/app/src/main/java/com/example/stockmarketcheck/mainFeature/presentation/company_info/StockChart.kt b/app/src/main/java/com/example/stockmarketcheck/mainFeature/presentation/company_info/StockChart.kt index a666a93..e830f9d 100644 --- a/app/src/main/java/com/example/stockmarketcheck/mainFeature/presentation/company_info/StockChart.kt +++ b/app/src/main/java/com/example/stockmarketcheck/mainFeature/presentation/company_info/StockChart.kt @@ -59,12 +59,12 @@ fun IntradayInfoChart( val xAxisData = AxisData.Builder() .axisStepSize(50.dp) - .steps(infos.size - 1) + .steps(infos.size) .labelData { i -> if (i != 0) { - infos.getOrNull(i)?.date?.format(DateTimeFormatter.ofPattern("HH:mm")) ?: "" + infos.getOrNull(i)?.date?.minusHours(0)?.format(DateTimeFormatter.ofPattern("HH:mm")) ?: "" } else { - infos.getOrNull(i)?.date?.format(DateTimeFormatter.ofPattern("HH")) ?: "" + infos.getOrNull(i)?.date?.minusHours(0)?.format(DateTimeFormatter.ofPattern("HH")) ?: "" } } .labelAndAxisLinePadding(15.dp) diff --git a/app/src/test/java/com/example/stockmarketcheck/mainFeature/data/repository/StockRepositoryImplTest.kt b/app/src/test/java/com/example/stockmarketcheck/mainFeature/data/repository/StockRepositoryImplTest.kt new file mode 100644 index 0000000..3cc17e8 --- /dev/null +++ b/app/src/test/java/com/example/stockmarketcheck/mainFeature/data/repository/StockRepositoryImplTest.kt @@ -0,0 +1,328 @@ +package com.example.stockmarketcheck.mainFeature.data.repository + +import com.example.stockmarketcheck.core.util.Resource +import com.example.stockmarketcheck.mainFeature.data.csv.CSVParser +import com.example.stockmarketcheck.mainFeature.data.local.CompanyListingEntity +import com.example.stockmarketcheck.mainFeature.data.local.StockDao +import com.example.stockmarketcheck.mainFeature.data.local.StockDatabase +import com.example.stockmarketcheck.mainFeature.data.mapper.toCompanyListing +import com.example.stockmarketcheck.mainFeature.data.mapper.toCompanyListingEntity +import com.example.stockmarketcheck.mainFeature.data.remote.StockClient +import com.example.stockmarketcheck.mainFeature.data.remote.dto.CompanyInfoDto +import com.example.stockmarketcheck.mainFeature.domain.model.CompanyInfo +import com.example.stockmarketcheck.mainFeature.domain.model.CompanyListing +import com.example.stockmarketcheck.mainFeature.domain.model.IntradayInfo +import io.mockk.* +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import java.io.IOException +import java.time.LocalDateTime + +class StockRepositoryImplTest { + // Mock dependencies + private lateinit var mockApi: StockClient + private lateinit var mockDb: StockDatabase + private lateinit var mockDao: StockDao + private lateinit var mockCompanyListingsParser: CSVParser + private lateinit var mockIntradayInfoParser: CSVParser + + // System under test + private lateinit var repository: StockRepositoryImpl + + @Before + fun setup() { + mockApi = mockk() + mockDb = mockk() + mockDao = mockk() + mockCompanyListingsParser = mockk() + mockIntradayInfoParser = mockk() + + every { mockDb.dao } returns mockDao + + repository = + StockRepositoryImpl( + api = mockApi, + db = mockDb, + companyListingsParser = mockCompanyListingsParser, + intradayInfoParser = mockIntradayInfoParser, + ) + } + + @Test + fun `getCompanyListings returns success when api call is successful (fetchFromRemote = true)`() = + runBlocking { + // Arrange + val query = "" + val mockResponseBody = "mock csv data".toResponseBody() + val mockCompanyListings = listOf(CompanyListing("AAPL", "Apple Inc.", "US")) + val mockCompanyListingEntities = mockCompanyListings.map { it.toCompanyListingEntity() } + + coEvery { mockApi.getListings() } returns mockResponseBody + coEvery { mockCompanyListingsParser.parse(any()) } returns mockCompanyListings + // el andThen significa q luego del emptyList (q es la 1ra llamada) todas las llamadas son mockCompanyListingEntities + coEvery { mockDao.searchCompanyListings(query) } returns emptyList() andThen mockCompanyListingEntities + // is used for void functions or suspend functions that don't return anything. It tells MockK that the function should be mocked to do nothing + // (just run without any side effects or returns) they just perform an action. + coEvery { mockDao.clearCompanyListings() } just Runs + coEvery { mockDao.insertCompanyListings(any()) } just Runs + + // Act + val results = repository.getCompanyListings(fetchFromRemote = true, query = query).toList() + + // Assert + assertEquals(4, results.size) + assertTrue(results[0] is Resource.Loading) + assertTrue(results[1] is Resource.Success) // Initial Success (el search vacio porq query es "") + assertTrue(results[2] is Resource.Success) // Final Success (list from remote API) + assertTrue(results[3] is Resource.Loading) + + val initialSuccess = results[1] as Resource.Success + val finalSuccess = results[2] as Resource.Success + assertEquals(emptyList(), initialSuccess.data) + assertEquals(mockCompanyListings, finalSuccess.data) + + assertTrue((results[0] as Resource.Loading).isLoading) + assertFalse((results[3] as Resource.Loading).isLoading) + + // Verify interactions + // used to check that a certain function was called during the test. It's part of behavior verification in testing + coVerify { mockApi.getListings() } + coVerify { mockCompanyListingsParser.parse(any()) } + coVerify { mockDao.clearCompanyListings() } + coVerify { mockDao.insertCompanyListings(any()) } + } + + @Test + fun `getCompanyListings returns error when api call fails (fetchFromRemote = true)`() = + runBlocking { + // Arrange + val query = "" + coEvery { mockApi.getListings() } throws IOException("Network error") + coEvery { mockDao.searchCompanyListings(query) } returns emptyList() + + // Act + val results = repository.getCompanyListings(fetchFromRemote = true, query = query).toList() + + // Assert + assertEquals(3, results.size) + assertTrue(results[0] is Resource.Loading) + assertTrue(results[1] is Resource.Success) + assertTrue(results[2] is Resource.Error) + + val successResult = results[1] as Resource.Success + assertEquals(emptyList(), successResult.data) + + val errorResult = results[2] as Resource.Error + assertEquals("Couldn't load data", errorResult.message) + + // Verify interactions + coVerify { mockApi.getListings() } + coVerify(exactly = 0) { mockDao.clearCompanyListings() } + coVerify(exactly = 0) { mockDao.insertCompanyListings(any()) } + } + + @Test + fun `getCompanyListings returns cached data when fetchFromRemote is false`() = + runBlocking { + // Arrange + val query = "" + val cachedListings = listOf(CompanyListingEntity("AAPL", "Apple Inc.", "US")) + coEvery { mockDao.searchCompanyListings(query) } returns cachedListings + + // Act + val results = repository.getCompanyListings(fetchFromRemote = false, query = query).toList() + + // Assert + assertEquals(3, results.size) + assertTrue(results[0] is Resource.Loading) + assertTrue(results[1] is Resource.Success) + assertTrue(results[2] is Resource.Loading) + + val successResult = results[1] as Resource.Success + assertEquals(cachedListings.map { it.toCompanyListing() }, successResult.data) + + // Verify interactions + coVerify(exactly = 0) { mockApi.getListings() } + coVerify(exactly = 0) { mockDao.clearCompanyListings() } + coVerify(exactly = 0) { mockDao.insertCompanyListings(any()) } + } + + @Test + fun `getCompanyListings fetches remote data when local db is empty`() = + runBlocking { + // Arrange + val query = "" + val mockResponseBody = "mock csv data".toResponseBody() + val mockCompanyListings = listOf(CompanyListing("AAPL", "Apple Inc.", "US")) + val mockCompanyListingEntities = mockCompanyListings.map { it.toCompanyListingEntity() } + + coEvery { mockDao.searchCompanyListings(query) } returns emptyList() andThen mockCompanyListingEntities + coEvery { mockApi.getListings() } returns mockResponseBody + coEvery { mockCompanyListingsParser.parse(any()) } returns mockCompanyListings + coEvery { mockDao.clearCompanyListings() } just Runs + coEvery { mockDao.insertCompanyListings(any()) } just Runs + + // Act + val results = repository.getCompanyListings(fetchFromRemote = false, query = query).toList() + + // Assert + assertEquals(4, results.size) + assertTrue(results[0] is Resource.Loading) + assertTrue(results[1] is Resource.Success) + assertTrue(results[2] is Resource.Success) + assertTrue(results[3] is Resource.Loading) + + val initialSuccess = results[1] as Resource.Success + val finalSuccess = results[2] as Resource.Success + assertEquals(emptyList(), initialSuccess.data) + assertEquals(mockCompanyListings, finalSuccess.data) + + assertTrue((results[0] as Resource.Loading).isLoading) + assertFalse((results[3] as Resource.Loading).isLoading) + + // Verify interactions + coVerify { mockApi.getListings() } + coVerify { mockCompanyListingsParser.parse(any()) } + coVerify { mockDao.clearCompanyListings() } + coVerify { mockDao.insertCompanyListings(any()) } + } + + @Test + fun `getCompanyListings filters results when query is not empty`() = + runBlocking { + // Arrange + val query = "App" + val allListings = + listOf( + CompanyListingEntity("AAPL", "Apple Inc.", "US"), + CompanyListingEntity("MSFT", "Microsoft Corporation", "US"), + CompanyListingEntity("GOOGL", "Alphabet Inc.", "US"), + ) + val filteredListings = + listOf( + CompanyListingEntity("AAPL", "Apple Inc.", "US"), + ) + + // Mock the DAO to return all listings for empty query and filtered for non-empty + coEvery { mockDao.searchCompanyListings("") } returns allListings + coEvery { mockDao.searchCompanyListings(query) } returns filteredListings + + // Act + val results = repository.getCompanyListings(fetchFromRemote = false, query = query).toList() + + // Assert + assertEquals(3, results.size) + assertTrue(results[0] is Resource.Loading) + assertTrue(results[1] is Resource.Success) + assertTrue(results[2] is Resource.Loading) + + val successResult = results[1] as Resource.Success + assertEquals(filteredListings.map { it.toCompanyListing() }, successResult.data) + + // Verify interactions + coVerify { mockDao.searchCompanyListings(query) } + coVerify(exactly = 0) { mockApi.getListings() } + + // Verify that with empty query, we get all listings + val resultsEmptyQuery = repository.getCompanyListings(fetchFromRemote = false, query = "").toList() + val successResultEmptyQuery = resultsEmptyQuery[1] as Resource.Success + assertEquals(allListings.map { it.toCompanyListing() }, successResultEmptyQuery.data) + } + + @Test + fun `getIntradayInfo returns success when api call is successful`() = + runBlocking { + // Arrange + val symbol = "AAPL" + val mockResponseBody = "mock csv data".toResponseBody() + val mockIntradayInfoList = listOf(IntradayInfo(LocalDateTime.now(), 150.0)) + + coEvery { mockApi.getIntradayInfo(symbol) } returns mockResponseBody + coEvery { mockIntradayInfoParser.parse(any()) } returns mockIntradayInfoList + + // Act + val result = repository.getIntradayInfo(symbol) + + // Assert + assertTrue(result is Resource.Success) + assertEquals(mockIntradayInfoList, (result as Resource.Success).data) + + // Verify interactions + coVerify { mockApi.getIntradayInfo(symbol) } + coVerify { mockIntradayInfoParser.parse(any()) } + } + + @Test + fun `getIntradayInfo returns error when api call fails`() = + runBlocking { + // Arrange + val symbol = "AAPL" + coEvery { mockApi.getIntradayInfo(symbol) } throws IOException("Network error") + + // Act + val result = repository.getIntradayInfo(symbol) + + // Assert + assertTrue(result is Resource.Error) + assertEquals("Couldn't load intraday info", (result as Resource.Error).message) + + // Verify interactions + coVerify { mockApi.getIntradayInfo(symbol) } + } + + @Test + fun `getCompanyInfo returns success when api call is successful`() = + runBlocking { + // Arrange + val symbol = "AAPL" + val mockCompanyInfoDto = + CompanyInfoDto( + symbol = "AAPL", + description = "Apple Inc.", + name = "Apple", + country = "USA", + industry = "Technology", + ) + coEvery { mockApi.getCompanyInfo(symbol) } returns mockCompanyInfoDto + + // Act + val result = repository.getCompanyInfo(symbol) + + // Assert + assertTrue(result is Resource.Success) + val expectedCompanyInfo = + CompanyInfo( + symbol = "AAPL", + description = "Apple Inc.", + name = "Apple", + country = "USA", + industry = "Technology", + ) + assertEquals(expectedCompanyInfo, (result as Resource.Success).data) + + // Verify interactions + coVerify { mockApi.getCompanyInfo(symbol) } + } + + @Test + fun `getCompanyInfo returns error when api call fails`() = + runBlocking { + // Arrange + val symbol = "AAPL" + coEvery { mockApi.getCompanyInfo(symbol) } throws IOException("Network error") + + // Act + val result = repository.getCompanyInfo(symbol) + + // Assert + assertTrue(result is Resource.Error) + assertEquals("Couldn't load company info", (result as Resource.Error).message) + + // Verify interactions + coVerify { mockApi.getCompanyInfo(symbol) } + } +} \ No newline at end of file diff --git a/app/src/test/java/com/example/stockmarketcheck/mainFeature/local/StockDaoTest.kt b/app/src/test/java/com/example/stockmarketcheck/mainFeature/local/StockDaoTest.kt new file mode 100644 index 0000000..a502ba0 --- /dev/null +++ b/app/src/test/java/com/example/stockmarketcheck/mainFeature/local/StockDaoTest.kt @@ -0,0 +1,128 @@ +package com.example.stockmarketcheck.mainFeature.local + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.example.stockmarketcheck.mainFeature.data.local.CompanyListingEntity +import com.example.stockmarketcheck.mainFeature.data.local.StockDao +import com.example.stockmarketcheck.mainFeature.data.local.StockDatabase +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@ExperimentalCoroutinesApi +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class StockDaoTest { + private lateinit var database: StockDatabase + private lateinit var dao: StockDao + + @Before + fun setup() { + // Create an in-memory database for testing + database = + Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + StockDatabase::class.java, + ).allowMainThreadQueries().build() + dao = database.dao + } + + @After + fun teardown() { + database.close() + } + + @Test + fun insertAndRetrieveCompanyListings() = + runBlocking { + // Prepare test data + val companies = + listOf( + CompanyListingEntity("Apple Inc.", "AAPL", "NASDAQ"), + CompanyListingEntity("Google", "GOOGL", "NASDAQ"), + ) + + // Insert data + dao.insertCompanyListings(companies) + + // Retrieve data + val retrievedCompanies = dao.searchCompanyListings("") + + // Assert + assertEquals(2, retrievedCompanies.size) + assertEquals("Apple Inc.", retrievedCompanies[0].name) + assertEquals("AAPL", retrievedCompanies[0].symbol) + assertEquals("NASDAQ", retrievedCompanies[0].exchange) + assertNotNull(retrievedCompanies[0].id) + assertEquals("Google", retrievedCompanies[1].name) + assertEquals("GOOGL", retrievedCompanies[1].symbol) + assertEquals("NASDAQ", retrievedCompanies[1].exchange) + assertNotNull(retrievedCompanies[1].id) + } + + @Test + fun searchCompanyListings() = + runBlocking { + // Prepare and insert test data + val companies = + listOf( + CompanyListingEntity("Apple Inc.", "AAPL", "NASDAQ"), + CompanyListingEntity("Google", "GOOGL", "NASDAQ"), + CompanyListingEntity("Microsoft", "MSFT", "NASDAQ"), + ) + dao.insertCompanyListings(companies) + + // Apple search + val appleSearch = dao.searchCompanyListings("apple") + assertEquals(1, appleSearch.size) + assertEquals("Apple Inc.", appleSearch[0].name) + + // Google search + val googleSearch = dao.searchCompanyListings("GOOGL") + assertEquals(1, googleSearch.size) + assertEquals("Google", googleSearch[0].name) + + // Microsoft search + val softSearch = dao.searchCompanyListings("soft") + assertEquals(1, softSearch.size) + assertEquals("Microsoft", softSearch[0].name) + + // No match search + val noMatch = dao.searchCompanyListings("xyz") + assertEquals(0, noMatch.size) + + // Case insensitive search + val caseInsensitiveSearch = dao.searchCompanyListings("gOoGl") + assertEquals(1, caseInsensitiveSearch.size) + assertEquals("Google", caseInsensitiveSearch[0].name) + } + + @Test + fun clearCompanyListings() = + runBlocking { + // Prepare and insert test data + val companies = + listOf( + CompanyListingEntity("Apple Inc.", "AAPL", "NASDAQ"), + CompanyListingEntity("Google", "GOOGL", "NASDAQ"), + ) + dao.insertCompanyListings(companies) + + // Verify data was inserted + var retrievedCompanies = dao.searchCompanyListings("") + assertEquals(2, retrievedCompanies.size) + + // Clear the database + dao.clearCompanyListings() + + // Verify that the database is empty + retrievedCompanies = dao.searchCompanyListings("") + assertEquals(0, retrievedCompanies.size) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/example/stockmarketcheck/mainFeature/presentation/company_info/CompanyInfoViewModelTest.kt b/app/src/test/java/com/example/stockmarketcheck/mainFeature/presentation/company_info/CompanyInfoViewModelTest.kt new file mode 100644 index 0000000..284b4c9 --- /dev/null +++ b/app/src/test/java/com/example/stockmarketcheck/mainFeature/presentation/company_info/CompanyInfoViewModelTest.kt @@ -0,0 +1,103 @@ +package com.example.stockmarketcheck.mainFeature.presentation.company_info + +import androidx.lifecycle.SavedStateHandle +import com.example.stockmarketcheck.core.util.Resource +import com.example.stockmarketcheck.mainFeature.domain.model.CompanyInfo +import com.example.stockmarketcheck.mainFeature.domain.model.IntradayInfo +import com.example.stockmarketcheck.mainFeature.domain.repository.StockRepository +import io.mockk.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import java.time.LocalDateTime + +@ExperimentalCoroutinesApi +class CompanyInfoViewModelTest { + private lateinit var viewModel: CompanyInfoViewModel + private lateinit var repository: StockRepository + private lateinit var savedStateHandle: SavedStateHandle + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + repository = mockk() + savedStateHandle = + SavedStateHandle().apply { + set("symbol", "AAPL") + } + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `init fetches company info and intraday info successfully`() = + runTest { + // Arrange + val companyInfo = CompanyInfo("AAPL", "Apple Inc.", "USA", "1234", "Tech") + val intradayInfo = listOf(IntradayInfo(LocalDateTime.of(2023, 7, 25, 12, 0), 150.0)) + + coEvery { repository.getCompanyInfo("AAPL") } returns Resource.Success(companyInfo) + coEvery { repository.getIntradayInfo("AAPL") } returns Resource.Success(intradayInfo) + + // Act + viewModel = CompanyInfoViewModel(savedStateHandle, repository) + testDispatcher.scheduler.advanceUntilIdle() + + // Assert + assertEquals(companyInfo, viewModel.state.company) + assertEquals(intradayInfo, viewModel.state.stockIntradayInfos) + assertFalse(viewModel.state.isLoading) + assertNull(viewModel.state.error) + + coVerify { repository.getCompanyInfo("AAPL") } + coVerify { repository.getIntradayInfo("AAPL") } + } + + @Test + fun `init handles company info error`() = + runTest { + // Arrange + coEvery { repository.getCompanyInfo("AAPL") } returns Resource.Error("Couldn't load company info", null) + coEvery { repository.getIntradayInfo("AAPL") } returns Resource.Success(emptyList()) + + // Act + viewModel = CompanyInfoViewModel(savedStateHandle, repository) + testDispatcher.scheduler.advanceUntilIdle() + + // Assert + assertNull(viewModel.state.company) + assertTrue(viewModel.state.stockIntradayInfos.isEmpty()) + assertFalse(viewModel.state.isLoading) + assertEquals("Couldn't load company info", viewModel.state.error) + + coVerify { repository.getCompanyInfo("AAPL") } + coVerify { repository.getIntradayInfo("AAPL") } + } + + @Test + fun `init handles intraday info error`() = + runTest { + // Arrange + val companyInfo = CompanyInfo("AAPL", "Apple Inc.", "USA", "1234", "Tech") + coEvery { repository.getCompanyInfo("AAPL") } returns Resource.Success(companyInfo) + coEvery { repository.getIntradayInfo("AAPL") } returns Resource.Error("Error fetching intraday info", null) + + // Act + viewModel = CompanyInfoViewModel(savedStateHandle, repository) + testDispatcher.scheduler.advanceUntilIdle() + + // Assert + assertEquals(companyInfo, viewModel.state.company) + assertTrue(viewModel.state.stockIntradayInfos.isEmpty()) + assertFalse(viewModel.state.isLoading) + assertEquals("Error fetching intraday info", viewModel.state.error) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/example/stockmarketcheck/mainFeature/presentation/company_listings/CompanyListingsViewModelTest.kt b/app/src/test/java/com/example/stockmarketcheck/mainFeature/presentation/company_listings/CompanyListingsViewModelTest.kt new file mode 100644 index 0000000..ba748b9 --- /dev/null +++ b/app/src/test/java/com/example/stockmarketcheck/mainFeature/presentation/company_listings/CompanyListingsViewModelTest.kt @@ -0,0 +1,114 @@ +package com.example.stockmarketcheck.mainFeature.presentation.company_listings + +import com.example.stockmarketcheck.core.util.Resource +import com.example.stockmarketcheck.mainFeature.domain.model.CompanyListing +import com.example.stockmarketcheck.mainFeature.domain.repository.StockRepository +import io.mockk.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +@ExperimentalCoroutinesApi +class CompanyListingsViewModelTest { + private lateinit var viewModel: CompanyListingsViewModel + private lateinit var repository: StockRepository + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + repository = mockk() + + // Mock all possible combinations + coEvery { repository.getCompanyListings(eq(false), any()) } returns flowOf(Resource.Success(emptyList())) + coEvery { repository.getCompanyListings(eq(true), any()) } returns flowOf(Resource.Success(emptyList())) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `init fetches company listings`() = + runTest { + // Arrange + val companyListings = listOf(CompanyListing("AAPL", "Apple Inc.", "US")) + coEvery { repository.getCompanyListings(false, "") } returns flowOf(Resource.Success(companyListings)) + + // Act + viewModel = CompanyListingsViewModel(repository) + testDispatcher.scheduler.advanceUntilIdle() + + // Assert + assertEquals(companyListings, viewModel.state.companies) + assertFalse(viewModel.state.isLoading) + + coVerify { repository.getCompanyListings(false, "") } + } + + @Test + fun `onEvent Refresh fetches company listings from remote`() = + runTest { + // Arrange + val companyListings = listOf(CompanyListing("AAPL", "Apple Inc.", "US")) + coEvery { repository.getCompanyListings(true, "") } returns flowOf(Resource.Success(companyListings)) + viewModel = CompanyListingsViewModel(repository) + + // Act + viewModel.onEvent(CompanyListingsEvent.Refresh) + testDispatcher.scheduler.advanceUntilIdle() + + // Assert + assertEquals(companyListings, viewModel.state.companies) + assertFalse(viewModel.state.isLoading) + + coVerify { repository.getCompanyListings(true, "") } + } + + @Test + fun `onEvent OnSearchQueryChange updates search query and fetches listings`() = + runTest { + // Arrange + val companyListings = listOf(CompanyListing("AAPL", "Apple Inc.", "US")) + coEvery { repository.getCompanyListings(false, "apple") } returns flowOf(Resource.Success(companyListings)) + viewModel = CompanyListingsViewModel(repository) + + // Act + viewModel.onEvent(CompanyListingsEvent.OnSearchQueryChange("Apple")) + testDispatcher.scheduler.advanceTimeBy(510) // Wait for debounce + + // Assert + assertEquals("Apple", viewModel.state.searchQuery) + assertEquals(companyListings, viewModel.state.companies) + + coVerify { repository.getCompanyListings(false, "apple") } + } + + @Test + fun `getCompanyListings handles loading state`() = + runTest { + // Arrange + coEvery { repository.getCompanyListings(false, "") } returns + flowOf( + Resource.Loading(true), + Resource.Loading(false), + Resource.Success(emptyList()), + ) + + // Act + viewModel = CompanyListingsViewModel(repository) + testDispatcher.scheduler.advanceUntilIdle() + + // Assert + assertFalse(viewModel.state.isLoading) + assertTrue(viewModel.state.companies.isEmpty()) + + coVerify { repository.getCompanyListings(false, "") } + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 81b1197..dc79957 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,11 +2,15 @@ accompanistSystemuicontroller = "0.28.0" agp = "8.5.1" converterGson = "2.11.0" +coreVersion = "1.6.1" +junitJupiterApiVersion = "5.8.2" +junitJupiterEngineVersion = "5.8.2" kotlin = "1.9.0" coreKtx = "1.13.1" junit = "4.13.2" junitVersion = "1.2.1" espressoCore = "3.6.1" +kotlinTestVersion = "1.8.20" kotlinxCoroutinesAndroid = "1.8.0" kotlinxCoroutinesCore = "1.8.0" kotlinxCoroutinesTestVersion = "1.8.0" @@ -20,6 +24,7 @@ mockkVersion = "1.13.12" okHttpVersion = "4.12.0" opencsv = "5.7.1" retrofitVersion = "2.10.0" +robolectricVersion = "4.13" roomRuntime = "2.6.1" serialization = "1.6.1" ksp = "1.9.0-1.0.13" @@ -27,10 +32,13 @@ hilt = "2.51" hiltNavigationCompose = "1.2.0" turbine = "1.1.0" ycharts = "2.1.0" -junitJupiter = "5.8.1" +junitJupiter = "5.8.2" +junitKtx = "1.2.1" +junitJupiterVersion = "5.8.1" [libraries] accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanistSystemuicontroller" } +androidx-core = { module = "androidx.test:core", version.ref = "coreVersion" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } androidx-material = { module = "androidx.compose.material:material", version.ref = "material" } @@ -40,6 +48,7 @@ androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomRunt androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" } androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "roomRuntime" } converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } +jetbrains-kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlinTestVersion" } jetbrains-kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTestVersion" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } @@ -54,6 +63,8 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +jupiter-junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junitJupiterApiVersion" } +jupiter-junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junitJupiterEngineVersion" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } mockk-mockk = { module = "io.mockk:mockk", version.ref = "mockkVersion" } @@ -66,9 +77,12 @@ hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } retrofit2-retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofitVersion" } +robolectric-robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectricVersion" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } ycharts = { module = "co.yml:ycharts", version.ref = "ycharts" } junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junitJupiter" } +androidx-junit-ktx = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "junitKtx" } +jupiter-junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junitJupiterVersion" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }