🔝 الدليل الرئيسي: Kotlin وJetpack Compose في 2026
مشروع Compose يكبر بدون اختبارات تلقائية يبدأ في التراجع بسرعة. تعديل في composable يكسر شاشة أخرى بصمت، الفريق يفقد الثقة، وكل release يصبح جلسة اختبارات يدوية مرهقة. طبقة اختبار Jetpack Compose الرسمية تُسمى androidx.compose.ui.test وتكشف API معبّر لقيادة شجرة composables في اختبار instrumenté أو Robolectric. مع اختبار وحدوي JVM لـ ViewModel واختبار DAO لـ Room، تُشكّل هرمًا كاملًا.
المتطلبات
- مشروع Compose بشاشة وظيفية واحدة على الأقل (راجع أول شاشات Compose)
- ViewModel وStateFlow في مكانه
- محاكي Android 16 (API 36) مُعَدّ، أو Robolectric مُثبَّت
- الوقت المُقدَّر: 90 دقيقة
الخطوة 1 — فهم طوابق الاختبار الثلاثة
تطبيق مُختبَر جيدًا له ثلاثة مستويات. اختبارات وحدوية JVM (سريعة، بدون Android) تُغطّي ViewModels وRepositories وأصناف الأعمال — حوالي 70% من التغطية. اختبارات تكامل (Robolectric أو محاكي) تُغطّي Room وDataStore والتسلسل — حوالي 20%. اختبارات UI Compose تُغطّي مسارات المستخدم الحرجة — حوالي 10%. هذا الهرم يُحقّق توازنًا بين زمن التنفيذ والثقة المُكتسبة.
الخطوة 2 — إضافة تبعيات الاختبار
[versions]
junit = "4.13.2"
androidxJunit = "1.3.0"
espressoCore = "3.7.0"
composeBom = "2026.05.00"
[libraries]
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
dependencies {
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
الخطوة 3 — كتابة أول اختبار Compose
@RunWith(AndroidJUnit4::class)
class WelcomeCardTest {
@get:Rule val composeTestRule = createComposeRule()
@Test
fun afficherTitreEtDescription() {
composeTestRule.setContent {
HelloKotlinTheme {
WelcomeCard(
title = "Mon titre",
description = "Ma description"
)
}
}
composeTestRule.onNodeWithText("Mon titre").assertIsDisplayed()
composeTestRule.onNodeWithText("Ma description").assertIsDisplayed()
}
}
createComposeRule() يُنشئ بيئة Compose معزولة دون Activity كاملة. setContent { ... } مكافئ Activity حقيقية. onNodeWithText("...") يختار العقدة. assertIsDisplayed() يتحقق من العرض. إذا لم يجد المُحدّد شيئًا، يفشل الاختبار برسالة واضحة.
الخطوة 4 — اختبار تفاعلات المستخدم
@Test
fun clickerArticleAppelleCallback() {
var idClique = -1
composeTestRule.setContent {
HelloKotlinTheme {
ListeArticles(
articles = listOf(
Article(id = 42, titre = "Titre 42", contenu = "...")
),
onArticleClick = { idClique = it.id }
)
}
}
composeTestRule.onNodeWithText("Titre 42").performClick()
assertEquals(42, idClique)
}
تقنية spy بمتغيّر محلي تعمل للاختبارات البسيطة. للحالات المعقدة، نستخدم ViewModel مزيف أو callback object يُسجّل الاستدعاءات. ميزة lambda المباشرة: تبقى قابلة للقراءة ولا تعتمد على mock library.
الخطوة 5 — اختبار شاشة مع ViewModel
@Test
fun afficheSpinnerPendantChargement() {
val fakeRepo = FakeArticlesRepository(delaiInfini = true)
val viewModel = ListeArticlesViewModel(fakeRepo)
composeTestRule.setContent {
HelloKotlinTheme {
EcranListeArticles(viewModel = viewModel)
}
}
composeTestRule.onNodeWithTag("loading").assertIsDisplayed()
}
@Test
fun afficheErreurAvecBoutonReessayer() = runTest {
val fakeRepo = FakeArticlesRepository(erreur = "Connexion impossible")
val viewModel = ListeArticlesViewModel(fakeRepo)
composeTestRule.setContent {
HelloKotlinTheme {
EcranListeArticles(viewModel = viewModel)
}
}
composeTestRule.onNodeWithText("Connexion impossible", substring = true).assertIsDisplayed()
composeTestRule.onNodeWithText("Réessayer").performClick()
}
نستخدم onNodeWithTag للاختيار عبر Modifier.testTag("loading") — عملي لـ composables بدون نص ثابت مثل CircularProgressIndicator. الـ testTag يجب أن تبقى نادرة، محصورة على العقد التي نحتاج لاستهدافها في اختبار.
الخطوة 6 — اختبار مسار متعدد الشاشات مع التنقّل
@RunWith(AndroidJUnit4::class)
class ParcoursNavigationTest {
@get:Rule val composeTestRule = createAndroidComposeRule<MainActivity>()
@Test
fun ouvrirArticleAffichePageDetail() {
composeTestRule.onNodeWithText("Premier article").performClick()
composeTestRule.onNodeWithText("Contenu du premier article").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription("Retour").assertIsDisplayed()
}
}
لهذه الاختبارات التكاملية، نُعدّ MainActivity للاختبار تحقن Repository مزيف عبر Hilt (@HiltAndroidTest + HiltAndroidRule). يتجنّب الاعتماد على خادم حقيقي ويضمن بيانات حتمية.
الخطوة 7 — التنفيذ والتكامل في CI
الاختبارات instrumentés تُطلق عبر ./gradlew connectedDebugAndroidTest أو من Android Studio. على Mac M2، المحاكي يبدأ في 20-30 ثانية، تنفيذ اختبار يستغرق 500 ms إلى ثانيتين. لـ 50 اختبارًا، احسب 3-5 دقائق إجمالًا.
name: Tests UI Compose
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
with: { distribution: temurin, java-version: 17 }
- uses: gradle/actions/setup-gradle@v4
- run: ./gradlew test connectedDebugAndroidTest --no-daemon
على runner Linux مجاني، المحاكي غير متوفر أصلًا، لكن reactivecircus/android-emulator-runner يُعدّه. لـ 90% من المشاريع، Robolectric في CI يُغطّي الأساسي.
الخطوة 8 — التقاط لقطات مرجعية (golden tests)
@Test
fun rendu_carte_article_correspond_a_la_reference() {
composeTestRule.setContent {
HelloKotlinTheme {
WelcomeCard(title = "Titre", description = "Description")
}
}
composeTestRule.onRoot().captureToImage()
.assertAgainstGolden("welcome_card_default")
}
عدة مكتبات تُنفّذ هذا النمط: Paparazzi (Cash App، الأشهر، JVM بحت، لا محاكي)، Roborazzi (مبني على Robolectric)، وShot. Paparazzi عمومًا الخيار الأفضل لـ Compose في 2026: تجميع فوري، مقارنة pixel-perfect، تكامل CI مباشر.
أخطاء شائعة
| العَرَض | السبب | الحل |
|---|---|---|
| الاختبار يفشل بـ « ComposeNode not found » | composable لم يُرَّكب بعد | أضف composeTestRule.awaitIdle() أو انتظر عبر waitUntil |
| اختبار متذبذب مع الرسوم المتحركة | Compose ينتظر نهاية الرسوم | عطّل الرسوم في الاختبار |
| مُحدّد غير فريد | عدة عقد بنفس النص | استخدم useUnmergedTree = true أو testTag |
| الاختبار بطيء البدء | إعادة تجميع Gradle | أطلق عدة اختبارات في نفس الصنف |
| Hilt لا يحقن في اختبار | غياب @HiltAndroidTest وHiltAndroidRule |
أضف التعليقات + module اختبار |
توسيع التغطية دون إثقال
الخطأ الكلاسيكي: استهداف 100% تغطية وقضاء وقت أكبر في كتابة اختبارات من كتابة الكود. الأفضل تغطية 60% من المسارات الحرجة من 95% من كل شيء. عمليًا، لتطبيق نموذجي: اختبار وحدوي لكل ViewModel (نجاح، خطأ، حالة فارغة)، اختبار DAO لكل استعلام معقد، اختبار UI لكل شاشة كبرى، اختبار مسار لتدفقات حرجة (login، شراء، تقديم نموذج). 20-40 اختبار لتطبيق 8 شاشات.
لقياس التغطية، JaCoCo يتكامل عبر plugin Gradle kover (JetBrains). يُولّد تقرير HTML يُشير إلى الأسطر غير المُختبَرة. هدف معقول: 70% على طبقات الأعمال، 50% على طبقة UI Compose.
الأسئلة الشائعة
Espresso أم Compose Test؟
Compose Test لاختبار Compose، Espresso مفيد فقط إذا بقيت Views XML.
Robolectric أم محاكي؟
Robolectric لـ CI سريع وللتطوير. محاكي للتحقق في ظروف حقيقية قبل release.
كيف نختبر الرسوم المتحركة؟
عطّل عبر SnapshotStateObserver أو استخدم composeTestRule.mainClock.autoAdvance = false لقيادة الزمن يدويًا.
هل نختبر كل composable؟
لا. اختبر composables stateful والشاشات الكاملة. الصغار stateless لا يُقدّمون قيمة اختبار.
كيف نختبر AlertDialog؟
الـ dialogue يُرَّكب في نافذة منفصلة. استخدم onNodeWithText على الزر الذي يُطلقه، ثم onNodeWithText على زر dialogue.
اختبار instrumenté أم وحدوي؟
وحدوي JVM متى أمكن: أسرع 10×. instrumenté فقط عند الحاجة لبيئة Android كاملة.
Paparazzi أم Roborazzi؟
Paparazzi لـ Compose منصة أحادية. Roborazzi إذا كان المشروع يستخدم Robolectric.
كيف نتجنّب الاختبارات المتذبذبة؟
عطّل الرسوم، استخدم waitForIdle() قبل assertions، وفضّل waitUntil على Thread.sleep.