Многие Android-разработчики неосознанно попадают в одну и ту же архитектурную ловушку — они помещают слишком много логики в классы фреймворков, такие как FirebaseMessagingService, BroadcastReceiver, Activity или Service. Поначалу это кажется быстрым и легким. Но вскоре код становится хрупким, его становится трудно тестировать и почти невозможно поддерживать.
В этой статье мы покажем, как избежать этой ловушки, применяя одно простое правило:
Делайте компоненты Android «тупыми».
Мы будем использовать Firebase Cloud Messaging (FCM) в качестве примера, но этот принцип применим ко всей архитектуре вашего приложения.
Пример FCM Push Notifications
Настройка Firebase Cloud Messaging (FCM) на Android очень проста. Вы настраиваете AndroidManifest.xml, запрашиваете разрешения и переопределяете два ключевых метода:
onNewToken(token: String)— вызывается при генерации нового токена FCMonMessageReceived(message: RemoteMessage)— вызывается при получении push-сообщения
Тогда у вас получится что-то вроде:
override fun onMessageReceived(remoteMessage: RemoteMessage) {
val title = remoteMessage.data["title"]
val body = remoteMessage.data["body"]
val deeplink = remoteMessage.data["deeplink"]
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(deeplink))
val notification = NotificationCompat.Builder(this, "channel_id")
.setContentTitle(title)
.setContentText(body)
.setContentIntent(PendingIntent.getActivity(this, 0, intent, 0))
.build()
NotificationManagerCompat.from(this).notify(1, notification)
}
Все работает… пока не перестает. Почему?
- Вы не можете провести модульное тестирование
- Вы не можете повторно использовать логику
- Вам нужны настоящие пуши для тестирования
Давайте наведем порядок.
Лучший способ: делегировать все
1. Сохраняйте FirebaseMessagingService тонким
Ваш сервис не должен содержать логику. Он должен передавать работу инжектируемым, тестируемым компонентам.
@AndroidEntryPoint
class MyFirebaseMessagingService : FirebaseMessagingService() {
@Inject lateinit var pushHandler: dagger.Lazy<PushHandler>
@Inject lateinit var pushMessageMapper: dagger.Lazy<PushMessageMapper>
override fun onNewToken(token: String) {
//Communicate with your server to update the token
}
override fun onMessageReceived(message: RemoteMessage) {
val mapped = pushMessageMapper.get().map(message)
pushHandler.get().handle(mapped)
}
}
Это дает вам возможность использовать инъекции зависимостей (например, Hilt или Koin) и изолировать логику для повторного использования и тестирования.
2. Сопоставляйте с чистой доменной моделью
Не передавайте по кругу RemoteMessage от Firebase. Вместо этого создайте модель, не зависящую от платформы, которая отражает потребности вашего приложения.
data class PushMessage(
val title: String?,
val body: String?,
val deeplink: String?,
val type: String?
)
Сопоставьте данные FCM с этим классом:
class PushMessageMapper @Inject constructor() {
fun map(message: RemoteMessage): PushMessage {
val data = message.data
return PushMessage(
title = data["title"],
body = data["body"],
deeplink = data["deeplink"],
type = data["type"]
)
}
}
Абстрагируя RemoteMessage, вы отделяете свою основную логику от Firebase.
3. Внедряйте и тестируйте бизнес-логику
Теперь создайте PushHandler, который принимает вашу доменную модель и решает, что делать.
interface PushHandler {
fun handle(message: PushMessage)
}
class DefaultPushHandler @Inject constructor(
private val notificationFactory: NotificationFactory,
private val deeplinkParser: DeeplinkParser,
@ApplicationContext private val context: Context
) : PushHandler {
override fun handle(message: PushMessage) {
val notification = notificationFactory.createNotification(context, message)
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED) {
NotificationManagerCompat.from(context).notify(1001, notification)
}
}
}
Теперь ваша логика проверяема, расширяема и проста в обслуживании.
Тот же шаблон для BroadcastReceivers
Эта ошибка не ограничивается push-уведомлениями. BroadcastReceiver — еще один классический нарушитель.
Плохой паттерн — логика внутри приемника:
class BootCompletedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
val workManager = WorkManager.getInstance(context)
workManager.enqueue(...) // logic here
}
}
Лучший паттерн — делегирование логики:
class BootCompletedReceiver : BroadcastReceiver() {
private val rescheduler: TaskRescheduler by inject()
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action == Intent.ACTION_BOOT_COMPLETED) {
rescheduler.reschedule()
}
}
}
interface TaskRescheduler {
fun reschedule()
}
class DefaultTaskRescheduler @Inject constructor(
private val workManager: WorkManager
) : TaskRescheduler {
override fun reschedule() {
workManager.enqueue(...) // clean and testable
}
}
Это позволяет тестировать измененную логику с помощью модульных тестов — нет необходимости симулировать трансляции.
Юнит-тестирование с помощью Robolectric
Вот здесь все становится еще веселей. Поскольку ваша основная логика больше не привязана к компонентам Android, тестирование становится простым:
@RunWith(RobolectricTestRunner::class)
class DefaultPushHandlerTest {
private lateinit var context: Context
private val posted = mutableListOf<Notification>()
@Before
fun setup() {
context = ApplicationProvider.getApplicationContext()
}
@Test
fun `push handler posts notification`() {
val handler = DefaultPushHandler(
FakeNotificationFactory(context, posted),
FakeDeeplinkParser(),
context
)
handler.handle(PushMessage("Hello", "World", null, null))
assertEquals("Hello", posted.first().extras.getString(Notification.EXTRA_TITLE))
}
}
Вам не нужно устройство или эмулятор — только Kotlin и Robolectric.
Сквозная верификация с помощью UIAutomator
С помощью UIAutomator вы также можете протестировать реальное поведение системы:
@RunWith(AndroidJUnit4::class)
class NotificationUITest {
@Test
fun notificationIsDisplayed() {
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
val context = ApplicationProvider.getApplicationContext<Context>()
val handler = DefaultPushHandler(
FakeNotificationFactoryThatShowsRealNotif(context),
FakeDeeplinkParser(),
context
)
handler.handle(PushMessage("Test Title", "Test Body", null, null))
device.openNotification()
device.wait(Until.hasObject(By.textContains("Test Title")), 5000)
val notif = device.findObject(UiSelector().textContains("Test Title"))
assertTrue("Notification not found", notif.exists())
}
}
class FakeNotificationFactoryThatShowsRealNotif(
private val context: Context
) : NotificationFactory {
init {
createNotificationChannel()
}
override fun createNotification(context: Context, message: PushMessage): Notification {
return NotificationCompat.Builder(context, TEST_CHANNEL_ID)
.setContentTitle(message.title ?: "Test Title")
.setContentText(message.body ?: "Test Body")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build().also {
NotificationManagerCompat.from(context).notify(TEST_NOTIFICATION_ID, it)
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = "Test Channel"
val importance = NotificationManager.IMPORTANCE_HIGH
val channel = NotificationChannel(TEST_CHANNEL_ID, name, importance)
NotificationManagerCompat.from(context).createNotificationChannel(channel)
}
}
companion object {
private const val TEST_CHANNEL_ID = "test_channel"
private const val TEST_NOTIFICATION_ID = 42
}
}
Идеально подходит для проверки поведения на реальных устройствах в CI или предрелизных тестах.
Заключительный совет
Держите свои компоненты Android тупыми, а логику чистой.
- FirebaseMessagingService не должен содержать код уведомлений
- BroadcastReceiver не должен заниматься заданиями
- Активити не должна содержать бизнес-логику
Используйте @Inject, @Bind и interface, чтобы сделать каждую часть вашей системы тестируемой и модульной.
TL;DR
- Компоненты Android должны делегировать, а не делать
- Переносите логику в инжектируемые классы
- Создавайте доменные модели, чтобы отвязаться от SDK
- Юнит-тестируйте с помощью фейков, проверяйте UI с помощью UIAutomator
Чистая архитектура начинается с одного правила: делайте компоненты тупыми.

