Skip to content

Commit 99c2159

Browse files
authored
Merge pull request #1 from RevenueCat/test/subscriptions
Add unit tests for the subscriptions
2 parents 24373b1 + 9cbba38 commit 99c2159

9 files changed

Lines changed: 454 additions & 1 deletion

File tree

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ jobs:
8787
uses: gradle/actions/setup-gradle@v4
8888

8989
- name: Run unit tests
90-
run: ./gradlew allTests --stacktrace
90+
run: ./gradlew testDebugUnitTest --stacktrace
9191

9292
- name: Upload test reports
9393
if: always()

build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ class KmpLibraryConventionPlugin : Plugin<Project> {
5454
commonMain.dependencies {
5555
implementation(libs.findLibrary("kotlinx-coroutines-core").get())
5656
}
57+
58+
commonTest.dependencies {
59+
implementation(libs.findLibrary("kotlin-test").get())
60+
implementation(libs.findLibrary("kotlinx-coroutines-test").get())
61+
implementation(libs.findLibrary("turbine").get())
62+
}
5763
}
5864
}
5965

feature/home/build.gradle.kts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,11 @@ plugins {
55
android {
66
namespace = "com.revenuecat.catpaywalls.feature.home"
77
}
8+
9+
kotlin {
10+
sourceSets {
11+
commonTest.dependencies {
12+
implementation(projects.core.data)
13+
}
14+
}
15+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/*
2+
* Copyright (c) 2025 RevenueCat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.revenuecat.catpaywalls.feature.home
17+
18+
import app.cash.turbine.test
19+
import kotlinx.coroutines.Dispatchers
20+
import kotlinx.coroutines.ExperimentalCoroutinesApi
21+
import kotlinx.coroutines.test.StandardTestDispatcher
22+
import kotlinx.coroutines.test.resetMain
23+
import kotlinx.coroutines.test.runTest
24+
import kotlinx.coroutines.test.setMain
25+
import kotlin.test.AfterTest
26+
import kotlin.test.BeforeTest
27+
import kotlin.test.Test
28+
import kotlin.test.assertEquals
29+
import kotlin.test.assertIs
30+
31+
@OptIn(ExperimentalCoroutinesApi::class)
32+
class CatArticlesViewModelTest {
33+
private val testDispatcher = StandardTestDispatcher()
34+
private lateinit var fakeRepository: FakeArticlesRepository
35+
36+
@BeforeTest
37+
fun setup() {
38+
Dispatchers.setMain(testDispatcher)
39+
fakeRepository = FakeArticlesRepository()
40+
}
41+
42+
@AfterTest
43+
fun tearDown() {
44+
Dispatchers.resetMain()
45+
}
46+
47+
@Test
48+
fun initialStateIsLoading() = runTest {
49+
// Given
50+
val articles = FakeArticlesRepository.createSampleArticles()
51+
fakeRepository.setArticlesResult(Result.success(articles))
52+
53+
// When
54+
val viewModel = CatArticlesViewModel(fakeRepository)
55+
56+
// Then
57+
viewModel.uiState.test {
58+
assertIs<HomeUiState.Loading>(awaitItem())
59+
cancelAndIgnoreRemainingEvents()
60+
}
61+
}
62+
63+
@Test
64+
fun whenArticlesFetchSucceeds_stateIsSuccessWithArticles() = runTest {
65+
// Given
66+
val articles = FakeArticlesRepository.createSampleArticles(count = 5)
67+
fakeRepository.setArticlesResult(Result.success(articles))
68+
69+
// When
70+
val viewModel = CatArticlesViewModel(fakeRepository)
71+
72+
// Then
73+
viewModel.uiState.test {
74+
// Initial loading state
75+
assertIs<HomeUiState.Loading>(awaitItem())
76+
77+
// Advance dispatcher to process the flow
78+
testDispatcher.scheduler.advanceUntilIdle()
79+
80+
// Success state with articles
81+
val successState = awaitItem()
82+
assertIs<HomeUiState.Success>(successState)
83+
assertEquals(5, successState.articles.size)
84+
assertEquals("Test Article 1", successState.articles.first().title)
85+
86+
cancelAndIgnoreRemainingEvents()
87+
}
88+
}
89+
90+
@Test
91+
fun whenArticlesFetchFails_stateIsErrorWithMessage() = runTest {
92+
// Given
93+
val errorMessage = "Network error"
94+
fakeRepository.setArticlesResult(Result.failure(Exception(errorMessage)))
95+
96+
// When
97+
val viewModel = CatArticlesViewModel(fakeRepository)
98+
99+
// Then
100+
viewModel.uiState.test {
101+
// Initial loading state
102+
assertIs<HomeUiState.Loading>(awaitItem())
103+
104+
// Advance dispatcher to process the flow
105+
testDispatcher.scheduler.advanceUntilIdle()
106+
107+
// Error state
108+
val errorState = awaitItem()
109+
assertIs<HomeUiState.Error>(errorState)
110+
assertEquals(errorMessage, errorState.message)
111+
112+
cancelAndIgnoreRemainingEvents()
113+
}
114+
}
115+
116+
@Test
117+
fun whenArticlesListIsEmpty_stateIsSuccessWithEmptyList() = runTest {
118+
// Given
119+
fakeRepository.setArticlesResult(Result.success(emptyList()))
120+
121+
// When
122+
val viewModel = CatArticlesViewModel(fakeRepository)
123+
124+
// Then
125+
viewModel.uiState.test {
126+
// Initial loading state
127+
assertIs<HomeUiState.Loading>(awaitItem())
128+
129+
// Advance dispatcher
130+
testDispatcher.scheduler.advanceUntilIdle()
131+
132+
// Success state with empty list
133+
val successState = awaitItem()
134+
assertIs<HomeUiState.Success>(successState)
135+
assertEquals(0, successState.articles.size)
136+
137+
cancelAndIgnoreRemainingEvents()
138+
}
139+
}
140+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright (c) 2025 RevenueCat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.revenuecat.catpaywalls.feature.home
17+
18+
import com.revenuecat.catpaywalls.core.data.ArticlesRepository
19+
import com.revenuecat.catpaywalls.core.model.Article
20+
import kotlinx.coroutines.flow.Flow
21+
import kotlinx.coroutines.flow.flow
22+
23+
/**
24+
* Fake implementation of [ArticlesRepository] for testing.
25+
*/
26+
class FakeArticlesRepository : ArticlesRepository {
27+
private var articlesResult: Result<List<Article>> = Result.success(emptyList())
28+
private var articleByIdResults: MutableMap<Long, Result<Article>> = mutableMapOf()
29+
30+
fun setArticlesResult(result: Result<List<Article>>) {
31+
articlesResult = result
32+
}
33+
34+
fun setArticleByIdResult(id: Long, result: Result<Article>) {
35+
articleByIdResults[id] = result
36+
}
37+
38+
override fun fetchArticles(): Flow<Result<List<Article>>> = flow {
39+
emit(articlesResult)
40+
}
41+
42+
override fun getArticleById(id: Long): Flow<Result<Article>> = flow {
43+
val result = articleByIdResults[id]
44+
?: Result.failure(NoSuchElementException("Article with id $id not found"))
45+
emit(result)
46+
}
47+
48+
companion object {
49+
fun createSampleArticles(count: Int = 3): List<Article> = (1..count).map { index ->
50+
Article(
51+
title = "Test Article $index",
52+
content = "This is the content for test article $index",
53+
description = "Description for article $index",
54+
author = "Test Author $index",
55+
date = "2025-01-0$index",
56+
cover = "https://example.com/cover$index.jpg",
57+
)
58+
}
59+
}
60+
}

feature/subscriptions/build.gradle.kts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,11 @@ plugins {
55
android {
66
namespace = "com.revenuecat.catpaywalls.feature.subscriptions"
77
}
8+
9+
kotlin {
10+
sourceSets {
11+
commonTest.dependencies {
12+
implementation(projects.core.data)
13+
}
14+
}
15+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Copyright (c) 2025 RevenueCat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.revenuecat.catpaywalls.feature.subscriptions
17+
18+
import com.revenuecat.catpaywalls.core.data.PaywallsRepository
19+
import com.revenuecat.purchases.kmp.models.CustomerInfo
20+
import com.revenuecat.purchases.kmp.models.Offering
21+
import com.revenuecat.purchases.kmp.models.StoreTransaction
22+
import kotlinx.coroutines.flow.Flow
23+
import kotlinx.coroutines.flow.flow
24+
25+
/**
26+
* Fake implementation of [PaywallsRepository] for testing.
27+
*/
28+
class FakePaywallsRepository : PaywallsRepository {
29+
private var offeringResult: Result<Offering>? = null
30+
private var customerInfoResult: Result<CustomerInfo>? = null
31+
private var purchaseResults: MutableMap<String, Result<StoreTransaction>> = mutableMapOf()
32+
33+
fun setOfferingResult(result: Result<Offering>?) {
34+
offeringResult = result
35+
}
36+
37+
fun setCustomerInfoResult(result: Result<CustomerInfo>?) {
38+
customerInfoResult = result
39+
}
40+
41+
fun setPurchaseResult(packageId: String, result: Result<StoreTransaction>) {
42+
purchaseResults[packageId] = result
43+
}
44+
45+
fun simulateOfferingError(message: String = "Failed to fetch offering") {
46+
offeringResult = Result.failure(Exception(message))
47+
}
48+
49+
fun simulateCustomerInfoError(message: String = "Failed to fetch customer info") {
50+
customerInfoResult = Result.failure(Exception(message))
51+
}
52+
53+
override fun fetchOffering(): Flow<Result<Offering>> = flow {
54+
val result = offeringResult
55+
?: Result.failure(IllegalStateException("No offering configured"))
56+
emit(result)
57+
}
58+
59+
override fun fetchCustomerInfo(): Flow<Result<CustomerInfo>> = flow {
60+
val result = customerInfoResult
61+
?: Result.failure(IllegalStateException("No customer info configured"))
62+
emit(result)
63+
}
64+
65+
override fun awaitPurchase(packageId: String): Flow<Result<StoreTransaction>> = flow {
66+
val result = purchaseResults[packageId]
67+
?: Result.failure(IllegalStateException("Package not found: $packageId"))
68+
emit(result)
69+
}
70+
}

0 commit comments

Comments
 (0)