آموزش تست نویسی در اندروید: راهنمای تست واحد با MockK

برنامه نویسی اندروید
آموزش تست نویسی در اندروید: راهنمای تست واحد با MockK

در فرآیند توسعه اپلیکیشن های بزرگ و سازمانی، نوشتن کدهای تمیز تنها نیمی از مسیر موفقیت است؛ تضمین صحت عملکرد کدها در گذر زمان، جلوگیری از خطاهای بازگشتی (Regression Bugs) و امکان ریفکتورینگ با اطمینان خاطر، نیازمند فرهنگ پایدار تست نویسی خودکار (Automated Testing) است.

در اکوسیستم مدرن برنامه نویسی اندروید، تست های واحد محلی (Local Unit Tests) که مستقیماً روی ماشین مجازی جاوا (JVM) اجرا می شوند، سریع ترین و پایدارترین لایه دفاعی به شمار می روند.

با غلبه بلامنازع زبان کاتلین، ابزار های قدیمی نظیر Mockito به دلیل عدم درک عمیق ویژگی های کاتلین (نظیر Coroutines Suspend functions، کدهای inline و کلاس های sealed) با محدودیت های جدی مواجه شدند. در این میان، کتابخانه قدرتمند MockK به عنوان استاندارد اول صنعت برای تست نویسی کاتلین ظهور کرد.

در این مقاله جامع مرجع در بیش از ۳,۶۰۰ کلمه، اصول تست نرم افزار، کار با MockK، تست کورتین ها با Turbine و تکنیک های پیشرفته را گام به گام فرا خواهید گرفت.

هرم تست در اندروید (Testing Pyramid): طبق راهنماهای رسمی گوگل، ساختار استاندارد تست شامل ۷۰٪ تست واحد محلی (Unit Test روی JVM)، ۲۰٪ تست یکپارچگی (Integration) و ۱۰٪ تست سراسری رابط کاربری (UI E۲E) است. تست های واحد کم هزینه ترین و سریع ترین سپر دفاعی در برابر باگ ها هستند.

۱. چرا MockK انتخاب اول توسعه دهندگان کاتلین است؟

سال ها توسعه دهندگان اندروید از Mockito استفاده می کردند. اما با معرفی کاتلین، ناسازگاری های دردناکی بروز کرد:

  • مشکل کلاس های Final: در کاتلین کلیه کلاس ها و متدها به صورت پیش فرض final هستند. ماک کردن کلاس های فاینال در Mockito نیازمند تنظیمات پیچیده و فعال سازی پلاگین های شکننده ByteBuddy بود.
  • عدم پشتیبانی طبیعی از Suspend Functions: کورتین های کاتلین در پشت صحنه یک پارامتر پنهان Continuation دریافت می کنند. استاب کردن این متدها در موکیتو منجر به خطاهای عجیب زمان کامپایل می شد.
  • پشتیبانی از Extension Functions و Object ها: در کاتلین سینگلتون ها با کلیدواژه object ساخته می شوند و توابع اکستنشن کاربرد فراوانی دارند. ماک کردن این ساختارها در موکیتو غیرممکن یا فوق العاده دشوار بود.

کتابخانه MockK از پایه با تفکر زبان کاتلین معماری شد (Kotlin-first mocking library). MockK ساختار زبان را به صورت بومی می فهمد، بدون نیاز به باز کردن کلاس های فاینال کار می کند و دستورات تمیزی نظیر coEvery و coVerify برای کورتین ها به ارمغان می آورد.

۲. انواع بدل های تست (Test Doubles) در مهندسی نرم افزار

برای نوشتن تست های واحد واقعی، باید وابستگی های خارجی (نظیر سرور، دیتابیس یا سنسورها) را با اشیای بدلی جایگزین کنیم. طبق استاندارد جرارد مزاروش، ۵ نوع بدل تست وجود دارد:

  1. Dummy (ساختگی): اشیایی که صرفاً برای پر کردن لیست پارامترهای ورودی متد پاس داده می شوند و هیچ گاه استفاده نمی شوند.
  2. Fake (بدل واقعی سبک): پیاده سازی های واقعی اما ساده شده از اینترفیس، نظیر پیاده سازی درون حافظه ای (In-memory) از دیتابیس با یک HashMap ساده. گوگل استفاده از Fake را برای لایه Repository ترجیح می دهد.
  3. Stub (پاسخ دهنده ثابت): شیئی که پاسخ های از پیش تعیین شده را در جواب فراخوانی های مشخص برمی گرداند (مثلاً متد getUser همیشه کاربر شماره ۱۰ را برمی گرداند).
  4. Spy (جاسوس): یک شیء واقعی که فراخوانی های ورودی و خروجی را ثبت می کند تا بتوان تعداد صدا زدن متدها را راستی آزمایی کرد.
  5. Mock (ماک): شیء ساختگی هوشمندی که مجهز به انتظارات از پیش تعریف شده (Expectations) است و در پایان تست، صحت صدا زده شدن متدها با پارامترهای دقیق را اعتبارسنجی (Verify) می کند.
انواع بدل های تست و هرم تست اندروید
شکل ۱: طبقه بندی انواع بدل های تست (Test Doubles) و نسبت های بهینه هرم تست نرم افزار در اکوسیستم اندروید

همان‌طور که در دیاگرام فوق مشاهده می‌کنید، بدل های تست به ۵ گروه اصلی تفکیک می‌شوند؛ در حالی که فیک ها پیاده سازی سبک درون رم هستند، ماک ها با نظارت بر رفتار و انتظارات صدازدن متدها، هسته آزمون های واحد سریع لایه پایین هرم تست را تشکیل می‌دهند.

شاخص ارزیابی فریم ورک MockK فریم ورک Mockito-Kotlin
طراحی بومی برای کاتلین ۱۰۰ درصد Kotlin-First پوسته ای (Wrapper) بر روی هسته جاوایی
توابع Suspend و Coroutines پشتیبانی درجه یک با coEvery و coVerify نیازمند پکیج های کمکی و رفتارهای ناپایدار
ماک کردن کلاس های Final پشتیبانی پیش فرض و آنی بدون هیچ کانفیگ نیازمند فعال سازی پلاگین mock-maker-inline
ماک کردن Object و Static پشتیبانی بومی با mockkObject و mockkStatic بسیار محدود و پیچیده
توابع توسعه یافته (Extensions) کاملاً پشتیبانی می شود عدم پشتیبانی پایدار

۳. آموزش راه اندازی MockK در پروژه اندروید (Gradle Dependencies)

برای افزودن MockK و ابزار های مرتبط با تست کورتین ها به پروژه اندروید، وابستگی های زیر را در فایل build.gradle.kts ماژول اپلیکیشن اضافه کنید:

dependencies {
    // هسته JUnit 4 برای اجرای تست ها
    testImplementation("junit:junit:4.13.2")

    // کتابخانه MockK برای تست واحد کاتلین
    testImplementation("io.mockk:mockk:1.13.10")

    // ابزار رسمی گوگل برای تست کورتین های کاتلین
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")

    // کتابخانه Turbine برای تست بی نظیر Flow و StateFlow
    testImplementation("app.cash.turbine:turbine:1.1.0")

    // کتابخانه Google Truth برای Assertion های خوانا و روان
    testImplementation("com.google.truth:truth:1.4.2")

    // ابزار Architecture Components Core Testing برای InstantTaskExecutorRule
    testImplementation("androidx.arch.core:core-testing:2.2.0")
}

۴. ساختار استاندارد تست واحد با الگوی AAA (Arrange-Act-Assert)

یک تست واحد حرفه ای و تمیز همواره از قاعده طلایی سه گانه AAA پیروی می کند:

  • Arrange (چیدمان): آماده سازی اشیاء، ماک کردن متدها و تنظیم شرایط اولیه تست.
  • Act (اقدام): فراخوانی متد یا رفتاری که قصد سنجش صحت آن را داریم.
  • Assert (اعتبارسنجی): بررسی اینکه آیا نتیجه حاصل شده دقیقاً مطابق با انتظار ما هست یا خیر.
نام گذاری استاندارد توابع تست: همواره از الگوی نام گذاری واضح given_when_then یا unitOfWork_stateUnderTest_expectedBehavior استفاده نمایید (به عنوان مثال: getUser_whenNetworkSuccess_shouldEmitUserData). استفاده از بک تیک (Backticks) در کاتلین خوانایی تست ها را شبیه جملات زبان طبیعی انگلیسی می کند: @Test fun `get user returns success when api is ok`().

۵. تست نویسی عملی برای لایه UseCase با MockK

بیایید یک سناریوی واقعی را در نظر بگیریم: یک UseCase برای دریافت پروفایل کاربر که به یک Repository وابسته است.

// کلاس بیزینس که قصد تست آن را داریم
class GetUserProfileUseCase(private val repository: UserRepository) {
    suspend operator fun invoke(userId: String): Result<UserProfile> {
        if (userId.isBlank()) {
            return Result.failure(IllegalArgumentException("شناسه کاربر نباید خالی باشد"))
        }
        return repository.fetchUserProfile(userId)
    }
}

کد تست واحد زیر صحت کارکرد این UseCase را با سناریوهای موفقیت و ورودی نامعتبر اعتبارسنجی می کند:

در پیاده سازی کلاس تست با MockK، ابتدا وابستگی های لایه ریپازیتوری را با متد mockk() شبیه سازی کرده و سناریوی اجرای موفق را با الگوی AAA تست می‌کنیم:

class GetUserProfileUseCaseTest {

    private val repository: UserRepository = mockk()
    private lateinit var useCase: GetUserProfileUseCase

    @Before
    fun setUp() {
        useCase = GetUserProfileUseCase(repository)
    }

    @Test
    fun `invoke with valid userId returns success result`() = runTest {
        // Arrange
        val expectedProfile = UserProfile(id = "user_100", name = "وحید حسینی", score = 95)
        coEvery { repository.fetchUserProfile("user_100") } returns Result.success(expectedProfile)

        // Act
        val result = useCase("user_100")

        // Assert & Verify
        assertTrue(result.isSuccess)
        assertEquals(expectedProfile, result.getOrNull())
        coVerify(exactly = 1) { repository.fetchUserProfile("user_100") }
    }
}

سپس در تستی مجزا، مسیرهای نامعتبر و خطاها را راستی‌آزمایی می‌کنیم تا مطمئن شویم در صورت ارسال شناسه نامعتبر، ریپازیتوری اصلاً فراخوانی نخواهد شد:

@Test
fun `invoke with blank userId returns failure without calling repository`() = runTest {
    // Act
    val result = useCase("   ")

    // Assert
    assertTrue(result.isFailure)

    // Verify: اطمینان از عدم فراخوانی متد وب سرویس با ورودی نامعتبر
    coVerify(exactly = 0) { repository.fetchUserProfile(any()) }
}

۶. چالش تست کورتین ها در ViewModel و مدیریت Dispatchers

در اپلیکیشن های مدرن اندروید، ویومدل ها از viewModelScope برای پرتاب کورتین ها استفاده می کنند. به صورت پیش فرض viewModelScope از Dispatchers.Main استفاده می کند که متکی بر Looper اندروید است. در تست های محلی JVM، لوپر اندروید وجود ندارد و اجرای مستقیم کد خطای زیر را پرتاب می کند:
IllegalStateException: Module with the Main dispatcher had failed to initialize.

برای حل این چالش، باید از یک TestDispatcher و نوشتن یک JUnit TestRule تمیز به نام MainDispatcherRule بهره گرفت:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description

@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

۷. تست StateFlow با کتابخانه شگفت انگیز Turbine

تست کردن جریان های واکنشی همزمان (Reactive Streams) نظیر StateFlow و SharedFlow همواره پر از چالش های Timing و تاخیر بود. کتابخانه Turbine این فرآیند را به یک تجربه لذت بخش و خطی بدل می سازد.

تست کورتین ها و استیت فلو با کتابخانه Turbine در ویومدل
شکل ۳: ره گیری و تست جریان های واکنشی StateFlow در ویومدل با متد اکستنشن test کتابخانه Turbine و کنترل زمان با MainDispatcherRule

دیاگرام فوق پایپ لاین آزمون واکنشی با توربین را نشان می‌دهد؛ توربین استریم های بی‌پایان StateFlow را در کانالی تستی به دام انداخته و به توسعه دهنده امکان می‌دهد با متد awaitItem() انتشار هر وضعیت را به ترتیب زمانی بدون خطای زمان بندی بسنجد.

کد زیر تست جامع یک ViewModel را نشان می دهد که وضعیت های لودینگ، موفقیت و خطا را منتشر می کند:

برای تست استیت های ویومدل، ابتدا قانون دیسپچر را با MainDispatcherRule تعریف کرده و یوزکیس وابستگی را با mockk() مقداردهی می‌کنیم:

class HomeViewModelTest {

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    private val fetchFeedUseCase: FetchFeedUseCase = mockk()
    private lateinit var viewModel: HomeViewModel

    @Before
    fun setUp() {
        viewModel = HomeViewModel(fetchFeedUseCase)
    }
}

سپس با متد اکستنشن test از کتابخانه Turbine، چرخه تغییر وضعیت از حالت لودینگ تا بارگذاری موفق را به صورت قطعی تست می‌نماییم:

@Test
fun `loadFeed emits Loading state then Success state with items`() = runTest {
    val mockItems = listOf("مقاله اول", "مقاله دوم")
    coEvery { fetchFeedUseCase() } returns Result.success(mockItems)

    viewModel.uiState.test {
        assertEquals(HomeUiState.Idle, awaitItem())

        viewModel.loadFeed()

        assertEquals(HomeUiState.Loading, awaitItem())

        val successState = awaitItem() as HomeUiState.Success
        assertEquals(mockItems, successState.data)

        ensureAllEventsConsumed()
    }
}

۸. تکنیک های پیشرفته MockK: آبجکت ها، متدهای استاتیک و Slot

ابزار MockK قابلیت هایی را فراهم می کند که در فریم ورک های دیگر غیرممکن تلقی می شود:

۱. استفاده از Capturing Slot برای شکار آرگومان ها

زمانی که می خواهید پارامتر ارسالی به یک متد را دقیقاً بررسی کنید، از slot استفاده کنید:

val slot = slot<NetworkRequest>()
coEvery { networkClient.execute(capture(slot)) } returns NetworkResponse(200)

// پس از اجرای کد، محتوای درون slot را ارزیابی می کنیم
assertEquals("https://api.example.com/v1/auth", slot.captured.url)
assertEquals("Bearer token_abc", slot.captured.headers["Authorization"])

۲. ماک کردن Singleton Object ها

برای ماک کردن آبجکت های کاتلین از دستور mockkObject استفاده کرده و در متد tearDown حتماً unmockkObject را فراخوانی کنید:

mockkObject(AnalyticsTracker)
every { AnalyticsTracker.trackEvent(any()) } returns Unit

// فراخوانی کد تست...

verify { AnalyticsTracker.trackEvent("screen_view_home") }
unmockkObject(AnalyticsTracker)

۹. اندازه گیری پوشش تست (Code Coverage) با پلاگین Kover

برای اطمینان از اینکه بخش های حیاتی برنامه دارای تست کافی هستند، شرکت جتبرینز ابزار رسمی Kover را برای اکوسیستم کاتلین و اندروید ارائه داده است.

با اعمال پلاگین Kover در گریدل، دستور زیر گزارش های تعاملی HTML تولید می کند:

./gradlew koverHtmlReport

شما می توانید در پایپ لاین CI/CD حداقل پوشش الزامی تعیین کنید؛ برای نمونه، قانونی تنظیم نمایید که اگر پوشش خطوط کدهای لایه Domain و ViewModel از ۸۰ درصد کمتر بود، بیلد پروژه شکست بخورد و مرج مسدود گردد.

۱۰. الگو های ضدالگو (Anti-patterns) در تست های اندروید و راه های فرار

تست های غیراصولی نه تنها کمکی به توسعه نمی کنند، بلکه سرعت تیم را کند کرده و تست های شکننده (Flaky Tests) تولید می کنند. از خطاهای زیر پرهیز نمایید:

  • تست کردن جزئیات پیاده سازی (Implementation Details): به جای تست رفتارهای عمومی خروجی، تست کردن متغیرهای خصوصی درون کلاس وابستگی شدید به کد ایجاد می کند و مانع ریفکتورینگ می شود.
  • استفاده بی رویه از Thread.sleep(): هرگز در تست ها تاخیر زمانی ثابت نگذارید؛ از مکانیزم های کنترل زمان کورتین نظیر advanceTimeBy() استفاده کنید.
  • تست های بیش از حد ماک شده (Over-mocking): اگر برای اجرای یک تست ساده مجبورید ۲۰ خط متد ماک تعریف کنید، معماری کلاس شما دچار بوی بد کد (Code Smell) است و باید به کلاس های کوچک تر تفکیک شود.

۱۱. معماری تست پذیر: تزریق وابستگی ها (DI) و اصل معکوس سازی وابستگی

هیچ کدی بدون طراحی تست پذیر، به سادگی قابل تست نیست. بزرگ ترین مانع تست نویسی در اندروید، ساختن دستی اشیاء وابسته درون بدنه کلاس با کلیدواژه new یا فراخوانی مستقیم سینگلتون ها است.

با رعایت اصل معکوس سازی وابستگی (DIP - Dependency Inversion Principle) از اصول SOLID:

  1. کلاس های سطح بالا (مانند UseCase یا ViewModel) هرگز نباید به کلاس های سطح پایین پیاده سازی شده وابسته باشند؛ هر دو باید به یک اینترفیس انتزاعی متکی باشند.
  2. کلیه وابستگی ها باید از طریق سازنده (Constructor Injection) به کلاس تزریق شوند.
  3. در زمان اجرای برنامه، فریم ورک هایی مانند Hilt پیاده سازی های واقعی مبتنی بر Retrofit و Room را تزریق می کنند، و در زمان تست های واحد، ما اشیای MockK یا Fake را از طریق سازنده پاس می دهیم.
چرخه اجرای تست واحد با الگوی AAA در MockK
شکل ۲: خط لوله سه مرحله ای اجرای تست واحد با الگوی AAA در MockK: چیدمان پیش فرض ها (Arrange)، فراخوانی اکشن (Act) و اعتبارسنجی پیامدها (Assert/Verify)

مطابق این خط لوله پردازشی، جداسازی شفاف فاز آماده سازی رفتار با coEvery از فاز اجرا و در نهایت سنجش نتایج و اعتبارسنجی فراخوانی متدها با coVerify، خوانایی و پایداری تست ها را به حداکثر می‌رساند.

// طراحی تست پذیر با تزریق اینترفیس در سازنده
class PaymentProcessor(
    private val paymentGateway: PaymentGateway,
    private val currencyConverter: CurrencyConverter,
    private val analytics: AnalyticsTracker
) {
    suspend fun processOrder(orderId: String, amountUsd: Double): Boolean {
        val amountRial = currencyConverter.usdToRial(amountUsd)
        val success = paymentGateway.charge(orderId, amountRial)
        if (success) {
            analytics.logPaymentSuccess(orderId, amountRial)
        }
        return success
    }
}

۱۲. تست های شباهت رفتاری با Robolectric: تست های سریع بدون دستگاه فیزیکی

برخی از کامپوننت های اندروید ارتباط عمیقی با SDK فریم ورک دارند (نظیر Context، SharedPreferences یا دسترسی به منابع رشته ای R.string). اجرای این تست ها به صورت Instrumented Test روی امولاتور یا گوشی واقعی بسیار زمان بر است.

فریم ورک Robolectric با شبیه سازی متدهای باینری فریم ورک اندروید (Shadow Objects) روی JVM کامپیوتر توسعه دهنده، به شما اجازه می دهد تست های نیازمند Context را با سرعت شگفت انگیز در چند ثانیه اجرا کنید:

@RunWith(RobolectricTestRunner::class)
class AndroidPreferenceStorageTest {

    @Test
    fun `saveToken writes to SharedPreferences and reads back correctly`() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        val storage = PreferenceStorage(context)

        storage.saveAuthToken("token_xyz_123")

        assertEquals("token_xyz_123", storage.getAuthToken())
    }
}

۱۳. تست لایه پایگاه داده محلی (Room) با In-Memory Database

تست کردن کوئری های دیتابیس محلی (DAO) نیازمند ذخیره سازی در یک پایگاه داده ایزوله است تا دیتای واقعی کاربر روی دستگاه آلوده نشود. کتابخانه Room یک سازنده اختصاصی به نام Room.inMemoryDatabaseBuilder ارائه می دهد:

در پیاده سازی تست دیتابیس روم، پیکربندی پایگاه داده در حافظه موقت (inMemoryDatabaseBuilder) و بستن آن در متد @After ایزولاسیون کامل تست ها را تضمین می‌کند:

@RunWith(AndroidJUnit4::class)
class UserDaoTest {

    private lateinit var database: AppDatabase
    private lateinit var userDao: UserDao

    @Before
    fun createDb() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        database = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
            .allowMainThreadQueries()
            .build()
        userDao = database.userDao()
    }

    @After
    fun closeDb() {
        database.close()
    }
}

سپس عملیات درج کاربر و بازیابی مستقیم آن از طریق آدرس ایمیل را در متد تستی مجزا اعتبارسنجی می‌کنیم:

@Test
fun insertUserAndReadByEmail() = runTest {
    val user = UserEntity(id = 1, name = "وحید", email = "test@example.com")
    userDao.insertUser(user)

    val retrieved = userDao.getUserByEmail("test@example.com")
    assertEquals(user, retrieved)
}

استفاده از دیتابیس درون حافظه ای سرعت اجرای تست ها را بیش از ۱۰ برابر نسبت به دیتابیس فیزیکی روی دیسک افزایش می دهد.

۱۴. تست رتروفیت و پاسخ های وب سرویس با MockWebServer

ماک کردن مستقیم اینترفیس های Retrofit گاهی ممکن است خطاهای ناشی از تبدیل JSON با Gson یا Kotlinx.Serialization را پنهان کند. شرکت Square ابزار فوق العاده ای به نام MockWebServer ارائه کرده که یک سرور HTTP واقعی و سبک را به صورت محلی روی پورت لوکال هوست راه اندازی می کند:

برای تست سرویس هایی رتروفیت بدون نیاز به سرور واقعی، MockWebServer را در متدهای @Before و @After مدیریت می‌کنیم:

class ApiServiceTest {

    private lateinit var mockWebServer: MockWebServer
    private lateinit var apiService: UserApiService

    @Before
    fun startServer() {
        mockWebServer = MockWebServer()
        mockWebServer.start()

        apiService = Retrofit.Builder()
            .baseUrl(mockWebServer.url("/"))
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(UserApiService::class.java)
    }

    @After
    fun shutdownServer() {
        mockWebServer.shutdown()
    }
}

سپس پاسخ سرور را با انکیو کردن پاسخ ساختگی (MockResponse) شبیه سازی کرده و پارامترهای ارسالی درخواست را راستی‌آزمایی می‌نماییم:

@Test
fun `getUser returns valid parsed user dto`() = runTest {
    val jsonBody = "{\"id\": 42, \"username\": \"vahidhoseini\", \"is_active\": true}"
    mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(jsonBody))

    val response = apiService.getUserDetails(42)

    assertEquals("vahidhoseini", response.username)
    assertEquals(true, response.isActive)

    val recordedRequest = mockWebServer.takeRequest()
    assertEquals("/users/42", recordedRequest.path)
}

۱۵. تست رابط کاربری نوین در جت پک کامپوز با ComposeTestRule

با منسوخ شدن سیستم View های قدیمی مبتنی بر XML، تست رابط کاربری به سوی تست های اعلانی با createComposeRule حرکت کرده است.

تست های کامپوز به جای تکیه بر آیدی های گرافیکی شکننده، از شناسه های معنایی (Semantics Tree) استفاده می کنند که برای ابزار های دسترس پذیری (Accessibility) نیز استاندارد هستند:

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import org.junit.Rule
import org.junit.Test

class LoginScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun `clicking login button shows loading indicator`() {
        // بارگذاری مستقیم کامپوزبل در محیط تستی
        composeTestRule.setContent {
            LoginScreenContent(isLoading = false, onLoginClick = {})
        }

        // بررسی نمایش دکمه ورود
        composeTestRule.onNodeWithText("ورود به حساب کاربری")
            .assertIsDisplayed()
            .performClick()
    }
}

۱۶. اجرای خودکار تست ها در پایپ لاین CI/CD گیت هاب اکشنز

تست هایی که به صورت خودکار در هر Pull Request اجرا نشوند، به مرور زمان نادیده گرفته شده و شکسته می شوند. با کانفیگ زیر در فایل .github/workflows/android_test.yml اجرای تست ها را بیمه کنید:

پایپ لاین خودکارسازی تست های اندروید در CI/CD
شکل ۴: فرآیند خودکارسازی اجرای تست های اندروید در پایپ لاین گیت هاب اکشنز، گزارش گیری Kover و حفاظت از شاخه اصلی

همان‌طور که در معماری DevOps بالا به تصویر کشیده شده است، با هر Push یا Pull Request، گیت‌هاب اکشنز تست های واحد را به طور موازی اجرا کرده، گزارش پوشش کد را از Kover استخراج نموده و در صورت برآورده نشدن حداقل آستانه کیفیت، از مرج شدن کد ممانعت به عمل می‌آورد.

name: اعتبارسنجی خودکار تست های اندروید
on: [push, pull_request]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - name: دریافت سورس کد پروژه
        uses: actions/checkout@v4

      - name: تنظیم نسخه جاوا ۱۷
        uses: actions/setup-java@v4
        with:
          distribution: 'zulu'
          java-version: '17'

      - name: اعطای دسترسی اجرایی به گریدل
        run: chmod +x gradlew

      - name: اجرای کلیه تست های واحد پروژه
        run: ./gradlew testDebugUnitTest --continue

      - name: آرشیو گزارش های تست HTML
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-reports
          path: '**/build/reports/tests/'

۱۷. تست لایه بارگذاری صفحه بندی شده (Paging ۳) در کاتلین

تست کردن کامپوننت PagingData همواره یکی از پیچیده ترین بخش های تست اندروید به دلیل وجود متغیرهای پس زمینه بود. با معرفی TestPagingDataDiffer و کتابخانه کمکی Paging Testing، می توان بدون نیاز به اجرای UI لیست مقادیر صفحه بندی را استخراج کرد:

در تست لایه صفحه بندی (Paging ۳)، از شیء AsyncPagingDataDiffer برای همگام سازی و اعتبارسنجی آیتم های دریافتی استریم استفاده می‌کنیم:

@Test
fun `loadArticles returns paged list correctly`() = runTest {
    val fakeRepo = FakeArticleRepository(items = (1..50).map { "مقاله $it" })
    val viewModel = ArticlesViewModel(fakeRepo)

    val differ = AsyncPagingDataDiffer(
        diffCallback = object : DiffUtil.ItemCallback<String>() {
            override fun areItemsTheSame(old: String, new: String) = old == new
            override fun areContentsTheSame(old: String, new: String) = old == new
        },
        updateCallback = NoopListCallback(),
        mainDispatcher = Dispatchers.Main
    )

    val job = launch {
        viewModel.articlesPagingFlow.collectLatest { differ.submitData(it) }
    }
    advanceUntilIdle()

    assertEquals(20, differ.snapshot().size)
    assertEquals("مقاله 1", differ.snapshot()[0])
    job.cancel()
}

۱۸. تست مسیربابی امن (Type-Safe Navigation) در کامپوز

با انتشار کتابخانه Navigation Compose ۲.۸ و معرفی مسیربابی تایپ سیف بر پایه کلاس های @Serializable، تست کردن ناوبری میان صفحات کامپوز بسیار پایدارتر شده است:

import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.testing.TestNavHostController
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
import org.junit.Test

class AppNavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun `clicking profile item navigates to ProfileScreen destination`() {
        lateinit var navController: TestNavHostController

        composeTestRule.setContent {
            navController = TestNavHostController(ApplicationProvider.getApplicationContext())
            navController.navigatorProvider.addNavigator(ComposeNavigator())
            AppNavGraph(navController = navController)
        }

        // بررسی مسیر اولیه صفحه خانه
        assertEquals("home_route", navController.currentDestination?.route)

        // اقدام: کلیک روی دکمه رفتن به پروفایل
        composeTestRule.onNodeWithText("مشاهده پروفایل").performClick()

        // اعتبارسنجی تغییر مسیر به مقصد پروفایل
        assertEquals("profile_route", navController.currentDestination?.route)
    }
}

۱۹. جدول مقایسه جامع استراتژی های تست دستی و خودکار در سازمان ها

معیار ارزیابی تست دستی (Manual QA) تست خودکار واحد با MockK
سرعت اجرا بسیار کند (ساعت ها یا روزها) فوق العاده آنی (چند میلی ثانیه)
هزینه در طول زمان خطی و افزایشی با بزرگ شدن پروژه بسیار پایین و یک بار سرمایه گذاری
پوشش خطاهای بازگشتی بسیار ضعیف و متکی به خطای انسانی ۱۰۰ درصد قطعی در کلیه پول ریکوئست ها
تسهیل ریفکتورینگ ایجاد ترس و اضطراب از شکستن کدها اطمینان خاطر مطلق در بازنویسی کدها

۲۰. مدیریت نشت حافظه (Memory Leaks) در بیلد های تست طولانی مدت

در پروژه های انترپرایز با بیش از ۵,۰۰۰ تست واحد، اجرای متوالی تست ها ممکن است به دلیل کش شدن ماک ها در حافظه JVM منجر به خطای OutOfMemoryError: Java heap space شود:

  • فراخوانی unmockkAll و clearAllMocks: همواره در کلاس پایه تست ها (BaseTest) متد @After را پیاده سازی کرده و کش MockK را پاک سازی کنید.
  • تنظیم متغیر org.gradle.jvmargs: در فایل gradle.properties با تخصیص حافظه مناسب (مثلاً -Xmx4096m -XX:MaxMetaspaceSize=1024m) فضای کافی به پروسه تست دیمن گریدل اختصاص دهید.
  • اجرای موازی تست ها با maxParallelForks: در کانفیگ تست گریدل، با تنظیم maxParallelForks = Runtime.runtime.availableProcessors() / 2 تست ها را روی چند هسته به صورت موازی اجرا کنید تا سرعت اجرای کلی تست ها تا ۶۰ درصد بهبود یابد.

۲۱. چک لیست ارزیابی و بازبینی کدهای تست در تیم های نرم افزاری

پیش از تایید و مرج هر Pull Request حاوی کدهای تست، رعایت ۵ معیار حیاتی زیر را در Code Review بررسی کنید:

  1. آیا تست کاملاً قطعی (Deterministic) است و رفتار غیرقابل پیش بینی تصادفی نشان نمی دهد؟
  2. آیا نام گذاری تست علت شکست و رفتار مورد انتظار را بدون نیاز به خواندن بدنه کد توضیح می دهد؟
  3. آیا از اصل Single Assertion (اعتبارسنجی یک مفهوم واحد در هر تست) تبعیت شده است؟
  4. آیا وابستگی های زمانی با کورتین های تستی به درستی کنترل شده اند و هیچ delay واقعی وجود ندارد؟
  5. آیا در صورت بروز شکست، پیام خطای تست کاملاً واضح و به دور از ابهام است؟
قانون پاک سازی تست های تکرار شونده (Isolation): هر تست واحد باید کاملاً ایزوله و بی حالت (Stateless) باشد. هیچ تستی نباید به نتیجه یا دیتای به جا مانده از تست قبلی وابسته باشد. همواره در متد @Before اشیاء جدید بسازید و در متد @After وضعیت منابع اشتراکی را ریست نمایید.

۲۲. پرسش های متداول در خصوص تست نویسی اندروید

۱. چرا نباید لایه UI را در تست های واحد محلی تست کرد؟

تست های واحد محلی روی JVM سیستم عامل بدون رندرینگ فیزیکی صفحه اجرا می شوند. تست اجزای گرافیکی Compose و Activity نیازمند محیط شبیه ساز یا شبیه ساز Robolectric است.

۲. منظور از خاصیت Relaxed Mock در MockK چیست؟

اگر هنگام ساخت ماک از mockk(relaxed = true) استفاده کنید، برای متدهایی که صریحاً رفتاری تعریف نکرده اید مقادیر پیش فرض (نظیر ۰، رشته خالی یا شیء خالی) بازمی گرداند و استثنای MockKException پرتاب نمی شود.

۳. کتابخانه Turbine چه کمکی به تست Flow می کند؟

توربین امکان خواندن مقادیر جریان های داده را به صورت مرحله به مرحله با متد awaitItem() فراهم می کند و نیاز به جمع آوری دستی و استفاده از لیست های تستی موقت را برطرف می سازد.

۴. چگونه می توان پوشش کد (Code Coverage) را در پروژه اندازه گیری کرد؟

با استفاده از پلاگین رسمی Kover شرکت JetBrains برای گریدل، می توان گزارش های درصد پوشش خطوط کد (Line Coverage) و شاخه های شرطی (Branch Coverage) را استخراج نمود.

۵. آیا می توان متدهای Private را در MockK ماک کرد؟

بله، MockK از طریق قابلیت های رفلکشن پیشرفته امکان ماک و اعتبارسنجی متدهای خصوصی را با تابع coEvery { mock["privateMethodName"](...) } دارد، هرچند از نظر اصول مهندسی تست متدهای پابلیک ارجحیت دارد.

۶. چگونه می توان خطای Out of Memory را در تست های بزرگ جلوگیری کرد؟

با پاک سازی ماک های سنگین با متد clearAllMocks() و لغو ماک اشیای سینگلتون با unmockkAll() در متد tearDown کلاس های تست.

۷. تفاوت اصلی بین spyk و mockk در چیست و چه زمانی باید از Spy استفاده کرد؟

در mockk تمام متدهای کلاس شبیه سازی شده و رفتار پیش فرض ندارند مگر اینکه با every مقداردهی شوند. اما در spyk پیاده سازی واقعی متدها اجرا می شود مگر اینکه صریحاً رفتار متد خاصی را بازنویسی کنید.

۸. نحوه تست کدهای شامل CoroutineExceptionHandler در MockK چگونه است؟

برای تست خطاهای مدیریت نشده در کورتین ها، می توان یک هندلر ماک شده را به TestScope متصل کرد و با فراخوانی متد verify از دریافت خطای مورد نظر توسط هندلر اطمینان حاصل کرد.

امتیاز و نظرات

۰.۰ (۰ نظر)
برای ثبت دیدگاه باید وارد حساب کاربری شوید. ورود

هنوز نظری ثبت نشده است. اولین نفر باشید.