Skip to content

Commit 7e26838

Browse files
committed
Add tests for SyncWorker
1 parent 1f1ef87 commit 7e26838

File tree

5 files changed

+327
-6
lines changed

5 files changed

+327
-6
lines changed

app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,4 +130,5 @@ dependencies {
130130
androidTestImplementation(libs.bundles.kointest)
131131
androidTestImplementation(libs.okhttp.mockserver)
132132
androidTestImplementation(libs.coil.test)
133+
androidTestImplementation(libs.workmanager.test)
133134
}
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
package com.readrops.app.sync
2+
3+
import android.app.Notification
4+
import android.content.Context
5+
import android.util.Log
6+
import androidx.core.app.NotificationManagerCompat
7+
import androidx.test.core.app.ApplicationProvider
8+
import androidx.work.Configuration
9+
import androidx.work.ListenableWorker
10+
import androidx.work.OneTimeWorkRequestBuilder
11+
import androidx.work.WorkInfo
12+
import androidx.work.WorkManager
13+
import androidx.work.testing.SynchronousExecutor
14+
import androidx.work.testing.TestListenableWorkerBuilder
15+
import androidx.work.testing.WorkManagerTestInitHelper
16+
import androidx.work.workDataOf
17+
import com.readrops.api.utils.ApiUtils
18+
import com.readrops.app.testutil.ReadropsTestRule
19+
import com.readrops.app.testutil.TestUtils
20+
import com.readrops.app.util.extensions.getSerializable
21+
import com.readrops.db.Database
22+
import com.readrops.db.entities.Feed
23+
import com.readrops.db.entities.account.Account
24+
import com.readrops.db.entities.account.AccountType
25+
import junit.framework.TestCase.assertNotNull
26+
import kotlinx.coroutines.delay
27+
import kotlinx.coroutines.runBlocking
28+
import kotlinx.coroutines.test.runTest
29+
import okhttp3.mockwebserver.Dispatcher
30+
import okhttp3.mockwebserver.MockResponse
31+
import okhttp3.mockwebserver.MockWebServer
32+
import okhttp3.mockwebserver.RecordedRequest
33+
import okio.Buffer
34+
import org.junit.After
35+
import org.junit.Before
36+
import org.junit.Rule
37+
import org.junit.Test
38+
import org.koin.test.KoinTest
39+
import org.koin.test.inject
40+
import java.net.HttpURLConnection
41+
import java.util.concurrent.TimeUnit
42+
import kotlin.test.assertEquals
43+
import kotlin.test.assertFalse
44+
import kotlin.test.assertTrue
45+
46+
/**
47+
*
48+
* This test suite runs over [SyncWorker] setup and implementation:
49+
* - WorkManagerTestInitHelper is used to test worker setup
50+
* - TestListenableWorkerBuilder is used to test worker implementation
51+
*
52+
* Notifications are also tested:
53+
* - Show notification
54+
* - Trigger read and star actions
55+
*
56+
* Remaining to test:
57+
* - [SyncWorker.startNow] which is currently untestable
58+
* - Simultaneous execution between a manual and auto worker
59+
* - Notification click (show the right screen)
60+
*/
61+
class SyncWorkerTest : KoinTest {
62+
63+
private val database: Database by inject()
64+
private val notificationManager: NotificationManagerCompat by inject()
65+
private val mockServer = MockWebServer()
66+
private val context = ApplicationProvider.getApplicationContext<Context>()
67+
68+
@get:Rule
69+
val rule = ReadropsTestRule()
70+
71+
private val localAccount = Account(
72+
name = "Local account",
73+
type = AccountType.LOCAL,
74+
isNotificationsEnabled = true
75+
)
76+
77+
private val feverAccount = Account(
78+
name = "Fever account",
79+
type = AccountType.FEVER,
80+
)
81+
82+
private val localFeed = Feed(
83+
name = "Hacker news",
84+
url = mockServer.url("/local").toString(),
85+
isNotificationEnabled = true
86+
)
87+
88+
@Before
89+
fun before() = runTest {
90+
//mockServer.start()
91+
92+
val config = Configuration.Builder()
93+
.setMinimumLoggingLevel(Log.DEBUG)
94+
.setExecutor(SynchronousExecutor())
95+
.build()
96+
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
97+
98+
mockServer.dispatcher = object : Dispatcher() {
99+
100+
override fun dispatch(request: RecordedRequest): MockResponse {
101+
return MockResponse()
102+
.setResponseCode(HttpURLConnection.HTTP_OK)
103+
.setHeader(ApiUtils.CONTENT_TYPE_HEADER, "application/rss+xml")
104+
.setBody(Buffer().readFrom(TestUtils.loadResource("rss_feed_1_item.xml")))
105+
}
106+
107+
}
108+
109+
localAccount.id = database.accountDao().insert(localAccount).toInt()
110+
feverAccount.id = database.accountDao().insert(feverAccount).toInt()
111+
112+
localFeed.apply {
113+
accountId = localAccount.id
114+
id = database.feedDao().insert(localFeed).toInt()
115+
}
116+
}
117+
118+
@After
119+
fun after() {
120+
mockServer.shutdown()
121+
database.clearAllTables()
122+
notificationManager.cancelAll()
123+
}
124+
125+
@Test
126+
fun manualWorkerTest() = runTest {
127+
val worker = TestListenableWorkerBuilder.from<SyncWorker>(context, SyncWorker::class.java)
128+
.setTags(listOf(SyncWorker.WORK_MANUAL))
129+
.setInputData(
130+
workDataOf(
131+
SyncWorker.ACCOUNT_ID_KEY to localAccount.id,
132+
SyncWorker.FEED_ID_KEY to localFeed.id
133+
)
134+
)
135+
.build()
136+
137+
val result = worker.doWork()
138+
139+
assertTrue { result is ListenableWorker.Result.Success }
140+
assertTrue { result.outputData.getBoolean(SyncWorker.END_SYNC_KEY, false) }
141+
142+
assertEquals(0, notificationManager.activeNotifications.size)
143+
}
144+
145+
@Test
146+
fun autoWorkerWithNotificationsTest() = runBlocking {
147+
val worker = TestListenableWorkerBuilder.from<SyncWorker>(context, SyncWorker::class.java)
148+
.setTags(listOf(SyncWorker.WORK_AUTO))
149+
.setInputData(
150+
workDataOf(
151+
SyncWorker.ACCOUNT_ID_KEY to localAccount.id,
152+
SyncWorker.FEED_ID_KEY to localFeed.id
153+
)
154+
)
155+
.build()
156+
157+
val result = worker.doWork()
158+
159+
assertTrue { result is ListenableWorker.Result.Success }
160+
assertTrue { result.outputData.getBoolean(SyncWorker.END_SYNC_KEY, false) }
161+
162+
with(notificationManager.activeNotifications.first()) {
163+
assertEquals(SyncWorker.SYNC_RESULT_NOTIFICATION_ID, id)
164+
assertEquals(
165+
"Hacker news",
166+
this.notification.extras.getString(Notification.EXTRA_TITLE)
167+
)
168+
169+
notification.actions.forEach { it.actionIntent.send() }
170+
171+
// wait for global scope to execute in SyncBroadcastReceiver
172+
delay(1000L)
173+
174+
val items = database.itemDao().selectItems(localFeed.id)
175+
176+
assertTrue { items.first().isRead }
177+
assertTrue { items.first().isStarred }
178+
}
179+
}
180+
181+
@Test
182+
fun workerConflictTest() = runTest {
183+
val workManager = WorkManager.getInstance(context)
184+
val driver = WorkManagerTestInitHelper.getTestDriver(context)!!
185+
186+
val request1 = OneTimeWorkRequestBuilder<SyncWorker>()
187+
.addTag(SyncWorker.TAG)
188+
.addTag(SyncWorker.WORK_MANUAL)
189+
.setInputData(
190+
workDataOf(
191+
SyncWorker.ACCOUNT_ID_KEY to localAccount.id,
192+
SyncWorker.FEED_ID_KEY to localFeed.id
193+
)
194+
)
195+
.build()
196+
197+
val request2 = OneTimeWorkRequestBuilder<SyncWorker>()
198+
.addTag(SyncWorker.TAG)
199+
.addTag(SyncWorker.WORK_MANUAL)
200+
.setInputData(
201+
workDataOf(
202+
SyncWorker.ACCOUNT_ID_KEY to localAccount.id,
203+
SyncWorker.FEED_ID_KEY to localFeed.id
204+
)
205+
)
206+
.build()
207+
208+
workManager.enqueue(request1)
209+
workManager.enqueue(request2)
210+
211+
driver.setAllConstraintsMet(request1.id)
212+
driver.setAllConstraintsMet(request2.id)
213+
214+
val workInfos = listOf(
215+
workManager.getWorkInfoById(request1.id).get(),
216+
workManager.getWorkInfoById(request2.id).get()
217+
)
218+
219+
assertTrue { workInfos.any { it?.state == WorkInfo.State.FAILED } }
220+
val failedWorkInfo = workInfos.find { it?.state == WorkInfo.State.FAILED }!!
221+
assertEquals(true, failedWorkInfo.outputData.getBoolean(SyncWorker.SYNC_FAILURE_KEY, false))
222+
assertNotNull { failedWorkInfo.outputData.getSerializable(SyncWorker.SYNC_FAILURE_EXCEPTION_KEY) }
223+
}
224+
225+
@Test
226+
fun periodicLaunchTest() {
227+
val workManager = WorkManager.getInstance(context)
228+
229+
SyncWorker.startPeriodically(context, "1")
230+
var workInfo = workManager.getWorkInfosByTag(SyncWorker.WORK_AUTO).get()
231+
.first()
232+
assertTrue { workInfo.state == WorkInfo.State.ENQUEUED }
233+
assertEquals(TimeUnit.HOURS.toMillis(1L), workInfo.periodicityInfo?.repeatIntervalMillis)
234+
235+
SyncWorker.startPeriodically(context, "manual")
236+
workInfo = workManager.getWorkInfoById(workInfo.id).get()!!
237+
assertTrue { workInfo.state == WorkInfo.State.CANCELLED }
238+
}
239+
240+
@Test
241+
fun exceptionTest() = runTest {
242+
val manualWorker =
243+
TestListenableWorkerBuilder.from<SyncWorker>(context, SyncWorker::class.java)
244+
.setTags(listOf(SyncWorker.WORK_MANUAL))
245+
.setInputData(
246+
workDataOf(
247+
SyncWorker.ACCOUNT_ID_KEY to feverAccount.id,
248+
)
249+
)
250+
.build()
251+
252+
val result = manualWorker.doWork()
253+
254+
assertTrue { result is ListenableWorker.Result.Failure }
255+
assertTrue { result.outputData.getBoolean(SyncWorker.SYNC_FAILURE_KEY, false) }
256+
assertNotNull { result.outputData.getSerializable(SyncWorker.SYNC_FAILURE_EXCEPTION_KEY) }
257+
258+
val autoWorker =
259+
TestListenableWorkerBuilder.from<SyncWorker>(context, SyncWorker::class.java)
260+
.setTags(listOf(SyncWorker.WORK_AUTO))
261+
.setInputData(
262+
workDataOf(
263+
SyncWorker.ACCOUNT_ID_KEY to feverAccount.id,
264+
)
265+
)
266+
.build()
267+
268+
val autoResult = autoWorker.doWork()
269+
270+
assertTrue { autoResult is ListenableWorker.Result.Failure }
271+
assertFalse { autoResult.outputData.getBoolean(SyncWorker.SYNC_FAILURE_KEY, false) }
272+
}
273+
274+
@Test
275+
fun localAccountErrorTest() = runTest {
276+
mockServer.dispatcher = object : Dispatcher() {
277+
278+
override fun dispatch(request: RecordedRequest): MockResponse {
279+
return MockResponse()
280+
.setResponseCode(HttpURLConnection.HTTP_NOT_FOUND)
281+
}
282+
}
283+
284+
val worker = TestListenableWorkerBuilder.from<SyncWorker>(context, SyncWorker::class.java)
285+
.setTags(listOf(SyncWorker.WORK_MANUAL))
286+
.setInputData(
287+
workDataOf(
288+
SyncWorker.ACCOUNT_ID_KEY to localAccount.id,
289+
SyncWorker.FEED_ID_KEY to localFeed.id
290+
)
291+
)
292+
.build()
293+
294+
val result = worker.doWork()
295+
296+
assertTrue { result is ListenableWorker.Result.Success }
297+
assertNotNull { result.outputData.getSerializable(SyncWorker.LOCAL_SYNC_ERRORS_KEY) }
298+
}
299+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<rss xmlns:atom="http://www.w3.org/1999/xhtml" xmlns:media="http://search.yahoo.com/mrss/"
3+
version="2.0">
4+
<channel>
5+
<title>Hacker News</title>
6+
<atom:link href="https://news.ycombinator.com/feed/" rel="self" />
7+
<link>https://news.ycombinator.com/</link>
8+
<description>Links for the intellectually curious, ranked by readers.</description>
9+
<item>
10+
<title>Africa declared free of wild polio</title>
11+
<link>https://www.bbc.com/news/world-africa-53887947</link>
12+
<pubDate>Tue, 25 Aug 2020 17:15:49 +0000</pubDate>
13+
<comments>https://news.ycombinator.com/item?id=24273602</comments>
14+
<author>Author 1</author>
15+
<description>
16+
<![CDATA[<a href="https://news.ycombinator.com/item?id=24273602">Comments</a>]]></description>
17+
<media:description>media description</media:description>
18+
</item>
19+
</channel>
20+
</rss>

app/src/main/java/com/readrops/app/sync/SyncWorker.kt

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import com.readrops.app.R
2626
import com.readrops.app.ReadropsApp
2727
import com.readrops.app.repositories.SyncResult
2828
import com.readrops.app.util.extensions.putSerializable
29-
import com.readrops.db.Database
3029
import com.readrops.db.entities.account.Account
3130
import kotlinx.coroutines.flow.first
3231
import org.koin.core.component.KoinComponent
@@ -41,7 +40,6 @@ class SyncWorker(
4140
) : CoroutineWorker(appContext, params), KoinComponent {
4241

4342
private val notificationManager by inject<NotificationManagerCompat>()
44-
private val database by inject<Database>()
4543

4644
override suspend fun doWork(): Result {
4745
val isManual = tags.contains(WORK_MANUAL)
@@ -209,10 +207,10 @@ class SyncWorker(
209207
}
210208

211209
companion object {
212-
private val TAG: String = SyncWorker::class.java.simpleName
210+
val TAG: String = SyncWorker::class.java.simpleName
213211

214-
private val WORK_AUTO = "$TAG-auto"
215-
private val WORK_MANUAL = "$TAG-manual"
212+
val WORK_AUTO = "$TAG-auto"
213+
val WORK_MANUAL = "$TAG-manual"
216214

217215
const val SYNC_NOTIFICATION_ID = 2
218216
const val SYNC_RESULT_NOTIFICATION_ID = 3

gradle/libs.versions.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ paging = "3.3.6"
1414
okhttp = "4.12.0"
1515
retrofit = "3.0.0"
1616
about_libraries = "12.1.2"
17+
work-manager = "2.10.1"
1718

1819
[plugins]
1920
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
@@ -73,6 +74,9 @@ koin-test-junit4 = { module = "io.insert-koin:koin-test-junit4", version.ref = "
7374
paging-runtime = { module = "androidx.paging:paging-runtime-ktx", version.ref = "paging" }
7475
paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }
7576

77+
workmanager = { module = "androidx.work:work-runtime-ktx", version.ref = "work-manager" }
78+
workmanager-test = { module = "androidx.work:work-testing", version.ref = "work-manager" }
79+
7680
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
7781
okhttp-mockserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
7882

@@ -98,7 +102,6 @@ corektx = "androidx.core:core-ktx:1.16.0"
98102
appcompat = "androidx.appcompat:appcompat:1.7.0"
99103
material = "com.google.android.material:material:1.12.0"
100104
palette = "androidx.palette:palette-ktx:1.0.0"
101-
workmanager = "androidx.work:work-runtime-ktx:2.10.1"
102105
encrypted-preferences = "androidx.security:security-crypto:1.1.0-alpha07"
103106
datastore = "androidx.datastore:datastore-preferences:1.1.6"
104107
browser = "androidx.browser:browser:1.8.0"

0 commit comments

Comments
 (0)