Ok, so you know how
and always say if something gets confusing or you get mired in fixing issues you should just throw it out and start over? It applies to blog posts too. I was working on a blog post on my Dam Monitor project - specifically how to make it notify the user. This took me down a rabbit hole of issues with testing and mocking things in the android framework. So I threw it out, and here we start again. I know it will be a smaller scope than I originally thought. I am down to how do I test android notifications?Story Mode:
So instead of on my DaMon project I will use my Kotlin Multiplatform Testing project. I will start with a small test in the android unit tests - a lot of studying and checking showed me that you cannot reasonably mock the notification manager so we will see what we can test. Also, much pushing by my friendly AIs got me to try Robolectric for writing android tests as unit tests under the JVM, definitely better.
Lets start with an inbox:
# Inbox
- [ ] test that android notification builder is a class
- [ ] test that android notification builder has a method called build
- [ ] test that android notification builder creates an AndroidNotification object
- [ ] test that android notification builder creates an AndroidNotification object with the correct properties
Ok, good little runway to get going. First test in androidUnitTest
:
@Test
fun `AndroidNotificationBuilder is a class`() {
AndroidNotificationBuilder().shouldNotBeNull()
}
ok, pretty simplistic test, but it can be fun to go super small steps to see if you can. I don’t advocate always taking this level but as they say, you should be able to take tiny steps. Then when you don’t understand why something is going wrong, you can slow down, take tiny steps and it becomes obvious.
class AndroidNotificationBuilder {}
makes that pass. Next step
@Test
fun `AndroidNotificationBuilder is a class`() {
val notification:Notification =
AndroidNotificationBuilder().build()
}
which can be made to pass with:
class AndroidNotificationBuilder {
fun build(): Notification {
return Notification()
}
}
woohoo! all tests pass. Don’t forget to git commit at the tiny steps! Next, lets ask for some things to be added to the Notification.
@Test
fun `AndroidNotificationBuilder is a class`() {
val titleText = "::SOME TITLE::"
AndroidNotificationBuilder()
.withTitle(titleText)
.build()
.extras
.getString(Notification.EXTRA_TITLE)
.shouldBe(titleText)
}
This leads to a small cascade of issues. To make a Notification
you need to use NotificationManager.Builder(context, channelId)
so you need a context. So my test ends up looking like this:
package notifications
import android.app.Notification
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import io.kotest.matchers.shouldBe
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.test.Test
@RunWith(RobolectricTestRunner::class)
class AndroidNotificationBuilderTest {
@Test
fun `AndroidNotificationBuilder is a class`() {
val titleText = "::SOME TITLE::"
val context: Context = ApplicationProvider.getApplicationContext()
AndroidNotificationBuilder(context)
.withTitle(titleText)
.build()
.extras
.getString(Notification.EXTRA_TITLE)
.shouldBe(titleText)
}
}
which requires a dependency on “androidx.test:core-ktx:1.5.0”
at the time of writing. so my build.gradle.kts
section looks like:
kotlin{sourcesets{...
val androidUnitTest by getting {
dependencies {
implementation(kotlin("test"))
implementation(libs.robolectric)
implementation(libs.androidx.test.core.ktx)
}
}
and libs.versions.toml
:
coreKtx = "1.5.0"
androidx-test-core-ktx = { group = "androidx.test", name = "core-ktx", version.ref = "coreKtx" }
ok, so lets write the code to make that test pass:
class AndroidNotificationBuilder(
private val context: Context,
private var title: String = ""
) {
fun build(): Notification {
return NotificationCompat.Builder(context, "channelId")
.setContentTitle(title)
.build()
}
fun withTitle(title: String): AndroidNotificationBuilder {
return AndroidNotificationBuilder(context, title)
}
}
looks good, don’t see a big need for a refactor here. Next lets fix the hard-coded channelId:
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P])
class AndroidNotificationBuilderTest {
@Test
fun `AndroidNotificationBuilder makes a builder with the correct channel id`() {
val channelId = "::THE CHANNEL ID::"
val context: Context = ApplicationProvider.getApplicationContext()
val notification = AndroidNotificationBuilder(context, channelId)
.build()
if(Build.VERSION.SDK_INT > Build.VERSION_CODES.O) {
notification.channelId
.shouldBe(channelId)
} else {
notification.extras
.getString(Notification.EXTRA_CHANNEL_ID)
.shouldBe(channelId)
}
}
@Test
fun `AndroidNotificationBuilder makes a notification with properties`() {
val titleText = "::SOME TITLE::"
val channelId = "::THE CHANNEL ID::"
val context: Context = ApplicationProvider.getApplicationContext()
val notification = AndroidNotificationBuilder(context, channelId)
.withTitle(titleText)
.build()
notification
.extras
.getString(Notification.EXTRA_TITLE)
.shouldBe(titleText)
}
}
Note we had to set a config with an SDK build code for robolectric
. Pi
is the minimum for being able to read the channel ID of the notification
here is my code:
class AndroidNotificationBuilder(
private val context: Context,
private val channelId: String,
private var title: String = ""
) {
fun withTitle(title: String): AndroidNotificationBuilder {
return AndroidNotificationBuilder(context, channelId, title)
}
fun build(): Notification {
return builder().build()
}
private fun builder() : NotificationCompat.Builder {
return NotificationCompat.Builder(context, channelId)
.setContentTitle(title)
}
}
so after some more work on the builder, we find we are going to need a channel so we add a buildChannel
and put it into the notification builder. We maybe should extract it, but it isn’t too hard to understand right now so it is in the future improvements category. Also we add text, and a small icon to the notifications. Our test has become:
package notifications
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.graphics.drawable.Icon
import android.os.Build
import androidx.test.core.app.ApplicationProvider
import io.kotest.matchers.equality.shouldBeEqualUsingFields
import io.kotest.matchers.ints.shouldBeExactly
import io.kotest.matchers.shouldBe
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.Test
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P])
class AndroidNotificationBuilderTest {
@Test
fun `AndroidNotificationBuilder notification has channel id`() {
val channelId = "::THE CHANNEL ID::"
val channelDescription = "::THE CHANNEL DESCRIPTION::"
val context = ApplicationProvider
.getApplicationContext<Context>()
AndroidNotificationBuilder(
context,
channelId,
channelDescription
)
.build()
.channelId
.shouldBe(channelId)
}
@Test
fun `AndroidNotificationBuilder_notificationChannel has priority`() {
val channelId = "::THE CHANNEL ID::"
val channelDescription = "::THE CHANNEL DESCRIPTION::"
val priority = NotificationManager.IMPORTANCE_HIGH
val context = ApplicationProvider
.getApplicationContext<Context>()
val channel = AndroidNotificationBuilder(
context,
channelId,
channelDescription
)
.withPriority(priority)
.buildChannel()
verifyChannelId(channel, channelId)
verifyChannelPriority(channel, priority)
verifyChannelDescription(channel, channelDescription)
}
private fun verifyChannelDescription(
channel: NotificationChannel,
channelDescription: String
) {
channel.description
.shouldBe(channelDescription)
}
private fun verifyChannelPriority(
channel: NotificationChannel,
priority: Int
) {
channel
.importance
.shouldBeExactly(priority)
}
private fun verifyChannelId(
channel: NotificationChannel,
channelId: String
) {
channel
.id
.shouldBe(channelId)
}
@Test
fun `AndroidNotificationBuilder notification has all properties`() {
val titleText = "::SOME TITLE::"
val channelId = "::THE CHANNEL ID::"
val channelDescription = "::THE CHANNEL DESCRIPTION::"
val textContent = "::SOME TEXT::"
val smallIcon: Int = android.R.drawable.ic_dialog_info
val priority = NotificationManager.IMPORTANCE_HIGH
val context = ApplicationProvider
.getApplicationContext<Context>()
val notification = AndroidNotificationBuilder(
context,
channelId,
channelDescription
)
.withTitle(titleText)
.withText(textContent)
.withPriority(priority)
.withSmallIcon(smallIcon)
.build()
verifyTitle(notification, titleText)
verifyText(notification, textContent)
verifySmallIconSetToSomething(notification, smallIcon, context)
}
private fun verifySmallIconSetToSomething(
notification: Notification,
smallIcon: Int,
context: Context
) {
val icon = Icon.createWithResource(context, smallIcon)
notification
.smallIcon
.shouldBeEqualUsingFields(icon)
}
private fun verifyText(
notification: Notification,
textContent: String
) {
notification
.extras
.getString(Notification.EXTRA_TEXT)
.shouldBe(textContent)
}
private fun verifyTitle(
notification: Notification,
titleText: String
) {
notification
.extras
.getString(Notification.EXTRA_TITLE)
.shouldBe(titleText)
}
}
and the builder that makes it work is:
ackage notifications
import android.app.Notification
import android.app.NotificationChannel
import android.content.Context
import androidx.core.app.NotificationCompat
class AndroidNotificationBuilder(
private val context: Context,
private val channelId: String,
private val channelDescription: String,
private var title: String = "",
private var text: String = "",
private var priority: Int = NotificationCompat.PRIORITY_DEFAULT,
private var smallIcon: Int? = null
) {
fun withTitle(title: String): AndroidNotificationBuilder {
return AndroidNotificationBuilder(
this.context,
this.channelId,
this.channelDescription,
title,
this.text,
this.priority,
this.smallIcon
)
}
fun withText(text: String): AndroidNotificationBuilder {
return AndroidNotificationBuilder(
this.context,
this.channelId,
this.channelDescription,
this.title,
text,
this.priority,
this.smallIcon
)
}
fun withPriority(priority: Int): AndroidNotificationBuilder {
return AndroidNotificationBuilder(
this.context,
this.channelId,
this.channelDescription,
this.title,
this.text,
priority,
this.smallIcon
)
}
fun withSmallIcon(smallIcon: Int?): AndroidNotificationBuilder {
return AndroidNotificationBuilder(
this.context,
this.channelId,
this.channelDescription,
this.title,
this.text,
this.priority,
smallIcon
)
}
fun build(): Notification {
return builder().build()
}
private fun builder(): NotificationCompat.Builder {
var builder = NotificationCompat.Builder(context, channelId)
.setContentTitle(title)
.setContentText(text)
builder = addIcon(builder)
return builder
}
private fun addIcon(builder: NotificationCompat.Builder)
: NotificationCompat.Builder
{
var builder = builder
smallIcon?.let {
builder = builder.setSmallIcon(it)
}
return builder
}
fun buildChannel(): NotificationChannel {
return NotificationChannel(channelId, channelId, priority)
.apply { description = channelDescription }
}
}
so now we can create a notification. Not much use if we can’t display it. How do you display a notification in android? You register a channel (once only) and then send notifications
val notificationManager = context
.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
with(NotificationManagerCompat.from(context)) {
notify(notificationId, notification)
}
we already create a channel, so our notification service should create a notification on a certain channel. Can we test this? Thankfully, Robolectric
has a ShadowNotificationManager
that will allow us to do this. I start with a test that shows we have no notifications before we do anything:
@Test
fun `no notification nothing shows up`() {
val notificationManager =
ApplicationProvider.getApplicationContext<Context>()
.getSystemService(Context.NOTIFICATION_SERVICE)
as NotificationManager
val shadowNotificationManager =
Shadows.shadowOf(notificationManager)
shadowNotificationManager.allNotifications
.shouldBeEmpty()
}
this passes. I like the test though, shows me something about how it works. Next test, lets make sure we can put a notification to the channel:
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P])
class NotificationGoesToCorrectChannelTest {
@Test
fun `no notification nothing shows up`() {
val notificationManager =
ApplicationProvider.getApplicationContext<Context>()
.getSystemService(Context.NOTIFICATION_SERVICE)
as NotificationManager
val shadowNotificationManager =
Shadows.shadowOf(notificationManager)
shadowNotificationManager.allNotifications
.shouldBeEmpty()
}
@Test
fun `AndroidNotificationSystem puts notification on channel`() {
val context =
ApplicationProvider.getApplicationContext<Context>()
val channelId = "::THE CHANNEL ID::"
val channelDescription = "::THE CHANNEL DESCRIPTION::"
val text = "::THE TEXT::"
val notificationBuilder = AndroidNotificationBuilder(
context,
channelId,
channelDescription
)
.withText(text)
AndroidNotificationSystem(context)
.showNotification(notificationBuilder)
val notificationManager =
ApplicationProvider.getApplicationContext<Context>()
.getSystemService(Context.NOTIFICATION_SERVICE)
as NotificationManager
val shadowNotificationManager =
Shadows.shadowOf(notificationManager)
val notificationFound = shadowNotificationManager.allNotifications
.shouldHaveSize(1)
.first()
notificationFound
.channelId
.shouldBe(channelId)
notificationFound
.extras.getString(Notification.EXTRA_TEXT)
.shouldBe(text)
}
}
relatively simple test but it is a bit messy, may need to refactor it some. Still, it shows me some stuff immediately: I have put the channel in the wrong place. The channel id needs to be used in the builder, but the builder should not make the channel. The life of the channel is the life of the app, the life of the builder is the time it takes to make the notification. How do we make these changes in little steps? First I’m going to change the first test to use AndroidNotificationChannelBuilder
. Well first I stash my new system tests, then I move the test in question to AndroidNotificationChannelBuilderTest
(I know, very creative name):
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P])
class AndroidNotificationChannelBuilderTest {
@Test
fun `channel builder notificationChannel has priority`() {
val channelId = "::THE CHANNEL ID::"
val channelDescription = "::THE CHANNEL DESCRIPTION::"
val priority = NotificationManager.IMPORTANCE_HIGH
val context = ApplicationProvider
.getApplicationContext<Context>()
val channel = AndroidNotificationChannelBuilder(
context,
channelId,
channelDescription
)
.withPriority(priority)
.buildChannel()
verifyChannelId(channel, channelId)
verifyChannelPriority(channel, priority)
verifyChannelDescription(channel, channelDescription)
}
private fun verifyChannelDescription(
channel: NotificationChannel,
channelDescription: String
) {
channel.description
.shouldBe(channelDescription)
}
private fun verifyChannelPriority(
channel: NotificationChannel,
priority: Int
) {
channel
.importance
.shouldBeExactly(priority)
}
private fun verifyChannelId(
channel: NotificationChannel,
channelId: String
) {
channel
.id
.shouldBe(channelId)
}
}
twenty seconds of copying and all the tests are passing. But it needs some refactoring - clearly it is a builder with most of the parameters not buildable. So we make those changes and we remember we should have used named arguments to avoid mistakes, but our tests catch our mistakes. Still, I need to remember to do this in the future, especially in builders whose argument lists may be changing. Anyway, we now have a separate builder for the channel and the notification. So we can go back to our test for it showing up on the correct channel. But I realize I’m needing a smaller step - lets just get the ability to add channels (and register them)
@Test
fun `add channel registers them`() {
val channelId = "::THE CHANNEL ID::"
val context = ApplicationProvider
.getApplicationContext<Context>()
val channel = createNotificationChannel(context, channelId)
AndroidNotificationSystem(
context
).addChannel(channel)
shadowNotificationManager(context)
.notificationChannels
.shouldContain( channel )
}
private fun shadowNotificationManager(context: Context): ShadowNotificationManager {
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE)
as NotificationManager
val shadowNotificationManager = Shadows.shadowOf(notificationManager)
return shadowNotificationManager
}
private fun createNotificationChannel(
context: Context,
channelId: String
): NotificationChannel {
val channelDescription = "::THE CHANNEL DESCRIPTION::"
val channel = AndroidNotificationChannelBuilder(
context
)
.withChannelId(channelId)
.withChannelDescription(channelDescription)
.build()
return channel
}
this requires we register the channel when we add it. actually, lets just call the routine register. Here is the AndroidNotificationSystem
so far:
class AndroidNotificationSystem(val context: Context) {
fun register(channel: NotificationChannel) {
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE)
as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
now we can add the requirement to post to a channel. Lets add it to our test:
@Test
fun `add channel registers them`() {
val channelId = "::THE CHANNEL ID::"
val context = ApplicationProvider
.getApplicationContext<Context>()
val channel = createNotificationChannel(context, channelId)
AndroidNotificationSystem(
context
).register(channel)
shadowNotificationManager(context)
.notificationChannels
.shouldContain( channel )
}
@Test
fun `notification goes to correct channel`() {
val channelId = "::THE CHANNEL ID::"
val context = ApplicationProvider
.getApplicationContext<Context>()
val notificationSystem =
createAndroidNotificationSystemWithChannel(
context,
channelId
)
val notification = makeNotification(context, channelId)
notificationSystem.send(notification)
verifyNotificationHasChannel(context, channelId, notification)
}
private fun verifyNotificationHasChannel(
context: Context,
channelId: String,
notification: Notification
) {
val notificationReceived = shadowNotificationManager(context)
.allNotifications
.shouldHaveSize(1)
.first()
notificationReceived
.channelId
.shouldBe(channelId)
notificationReceived
.shouldBeEqual(notification)
}
private fun createAndroidNotificationSystemWithChannel(
context: Context,
channelId: String
): AndroidNotificationSystem {
val notificationSystem = AndroidNotificationSystem(context)
val channel = createNotificationChannel(context, channelId)
notificationSystem.register(channel)
return notificationSystem
}
private fun makeNotification(
context: Context,
channelId: String
) = AndroidNotificationBuilder(context)
.withChannelId(channelId)
.build()
private fun shadowNotificationManager(context: Context):
ShadowNotificationManager {
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE)
as NotificationManager
val shadowNotificationManager =
Shadows.shadowOf(notificationManager)
return shadowNotificationManager
}
private fun createNotificationChannel(
context: Context,
channelId: String
): NotificationChannel {
val channelDescription = "::THE CHANNEL DESCRIPTION::"
val channel = AndroidNotificationChannelBuilder(
context
)
.withChannelId(channelId)
.withChannelDescription(channelDescription)
.build()
return channel
}
}
which gets the implementation:
class AndroidNotificationSystem(val context: Context) {
fun register(channel: NotificationChannel) {
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE)
as NotificationManager
notificationManager.createNotificationChannel(channel)
}
fun send(notification: Notification) {
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE)
as NotificationManager
notificationManager
.notify(notification.hashCode(), notification)
}
}
Not bad! tests passing. Now, I move the 3 main classes to androidMain
and discover something interesting: I used NotificationManager
when I should have used NotificationManagerCompat
. Also I should check for permissions:
class AndroidNotificationSystem(val context: Context) {
fun register(channel: NotificationChannel) {
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE)
as NotificationManager
notificationManager.createNotificationChannel(channel)
}
fun send(notification: Notification) {
val notificationManager =
NotificationManagerCompat.from(context)
if (ActivityCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
return
}
notificationManager
.notify(notification.hashCode(), notification)
}
}
and it told me to add the uses-permission
line to AndroidManifest.xml
:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
...
I try refactoring the conditional to its own routine but then the IDE doesn’t recognize that the check is being done and harasses me with red underlining. Grrr…
Next up we will need to do the same for desktop, and then make a generic system for interfacing with it and get the setup correct in all implementations. Sounds like another episode to me!
Recipe:
We need to add robolectric
and androidx.test:core-ktx
as dependencies in build.gradle.kts
kotlin { sourceSets {...
val androidUnitTest by getting {
dependencies {
implementation(kotlin("test"))
implementation(libs.robolectric)
implementation(libs.androidx.test.core.ktx)
}
}
and lib.versions.toml:
versions:
...
robolectric = "4.12.1"
coreKtx = "1.5.0"
...
libraries:
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
androidx-test-core-ktx = { group = "androidx.test", name = "core-ktx", version.ref = "coreKtx" }
...
Then we have a set of classes in androidMain.src.kotlin:
AndroidNotificationBuilder
:
class AndroidNotificationBuilder(
private val context: Context,
private val channelId: String = "",
private var title: String = "",
private var text: String = "",
private var smallIcon: Int? = null
) {
fun build(): Notification {
return builder().build()
}
fun withTitle(title: String): AndroidNotificationBuilder {
return AndroidNotificationBuilder(
context = this.context,
channelId = this.channelId,
title = title,
text = this.text,
smallIcon = this.smallIcon
)
}
fun withText(text: String): AndroidNotificationBuilder {
return AndroidNotificationBuilder(
context = this.context,
channelId = this.channelId,
title = this.title,
text = text,
smallIcon = this.smallIcon
)
}
fun withChannelId(channelId: String): AndroidNotificationBuilder {
return AndroidNotificationBuilder(
context = this.context,
channelId = channelId,
title = this.title,
text = this.text,
smallIcon = this.smallIcon
)
}
fun withSmallIcon(smallIcon: Int?): AndroidNotificationBuilder {
return AndroidNotificationBuilder(
context = this.context,
channelId = this.channelId,
title = this.title,
text = this.text,
smallIcon = smallIcon
)
}
private fun builder(): NotificationCompat.Builder {
val builder = NotificationCompat.Builder(context, channelId)
.setContentTitle(title)
.setContentText(text)
addIconTo(builder)
return builder
}
private fun addIconTo(builder: NotificationCompat.Builder) {
smallIcon?.let {
builder.setSmallIcon(it)
}
}
}
AndroidNotificationChannelBuilder
:
class AndroidNotificationChannelBuilder(
val context: Context,
val channelId: String = "",
val channelDescription: String = "",
val priority: Int = NotificationManager.IMPORTANCE_DEFAULT
) {
fun withPriority(priority: Int): AndroidNotificationChannelBuilder {
return AndroidNotificationChannelBuilder(
context = this.context,
channelId = this.channelId,
channelDescription = this.channelDescription,
priority = priority
)
}
fun build(): NotificationChannel {
return NotificationChannel(channelId, channelId, priority)
.apply { description = channelDescription }
}
fun withChannelDescription(channelDescription: String): AndroidNotificationChannelBuilder {
return AndroidNotificationChannelBuilder(
context = this.context,
channelId = this.channelId,
channelDescription = channelDescription,
priority = this.priority
)
}
fun withChannelId(channelId: String): AndroidNotificationChannelBuilder {
return AndroidNotificationChannelBuilder(
context = this.context,
channelId = channelId,
channelDescription = this.channelDescription,
priority = this.priority
)
}
}
and AndroidNotificationSystem
:
class AndroidNotificationSystem(val context: Context) {
fun register(channel: NotificationChannel) {
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE)
as NotificationManager
notificationManager.createNotificationChannel(channel)
}
fun send(notification: Notification) {
val notificationManager =
NotificationManagerCompat.from(context)
if (ActivityCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
return
}
notificationManager
.notify(notification.hashCode(), notification)
}
}
And I believe we need to add the POST_NOTIFICATIONS
permission to the AndroidManifest.xml:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
...