تست جریان های Kotlin در اندروید

نحوه آزمایش واحدها یا ماژول هایی که با جریان ارتباط برقرار می کنند به این بستگی دارد که آیا موضوع مورد آزمایش از جریان به عنوان ورودی یا خروجی استفاده می کند.

  • اگر موضوع مورد آزمایش یک جریان را مشاهده کند، می‌توانید جریان‌هایی را در وابستگی‌های جعلی ایجاد کنید که می‌توانید از طریق آزمایش‌ها آن را کنترل کنید.
  • اگر واحد یا ماژول جریانی را در معرض دید قرار دهد، می‌توانید یک یا چند مورد منتشر شده از یک جریان را در آزمایش بخوانید و تأیید کنید.

ایجاد یک تولید کننده جعلی

هنگامی که موضوع مورد آزمایش مصرف کننده یک جریان است، یکی از راه های معمول برای آزمایش آن، جایگزینی تولید کننده با یک پیاده سازی جعلی است. به عنوان مثال، با توجه به کلاسی که مخزنی را مشاهده می کند که داده ها را از دو منبع داده در تولید می گیرد:

موضوع مورد آزمایش و لایه داده
شکل 1. موضوع مورد آزمایش و لایه داده.

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

وابستگی ها با یک پیاده سازی جعلی جایگزین می شوند
شکل 2. وابستگی ها با یک پیاده سازی جعلی جایگزین می شوند.

برای انتشار یک سری از مقادیر از پیش تعریف شده در یک جریان، از سازنده flow استفاده کنید:

class MyFakeRepository : MyRepository {
    fun observeCount() = flow {
        emit(ITEM_1)
    }
}

در آزمایش، این مخزن جعلی تزریق می شود و جایگزین پیاده سازی واقعی می شود:

@Test
fun myTest() {
    // Given a class with fake dependencies:
    val sut = MyUnitUnderTest(MyFakeRepository())
    // Trigger and verify
    ...
}

اکنون که بر خروجی‌های موضوع مورد آزمایش کنترل دارید، می‌توانید با بررسی خروجی‌های آن بررسی کنید که درست کار می‌کند.

اثبات انتشار جریان در یک آزمایش

اگر آزمودنی مورد آزمایش جریانی را در معرض دید قرار دهد، آزمون باید در مورد عناصر جریان داده اظهارنظر کند.

بیایید فرض کنیم که مخزن مثال قبلی یک جریان را نشان می دهد:

مخزن با وابستگی های جعلی که یک جریان را آشکار می کند
شکل 3. یک مخزن (موضوع مورد آزمایش) با وابستگی های جعلی که یک جریان را آشکار می کند.

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

شما می توانید با فراخوانی first() اولین انتشار به جریان را مصرف کنید. این تابع منتظر می ماند تا اولین مورد دریافت شود و سپس سیگنال لغو را به سازنده ارسال می کند.

@Test
fun myRepositoryTest() = runTest {
    // Given a repository that combines values from two data sources:
    val repository = MyRepository(fakeSource1, fakeSource2)

    // When the repository emits a value
    val firstItem = repository.counter.first() // Returns the first item in the flow

    // Then check it's the expected item
    assertEquals(ITEM_1, firstItem)
}

اگر تست نیاز به بررسی چندین مقدار داشته باشد، فراخوانی toList() باعث می شود که جریان منتظر بماند تا منبع تمام مقادیر خود را منتشر کند و سپس آن مقادیر را به عنوان یک لیست برمی گرداند. این فقط برای جریان های داده محدود کار می کند.

@Test
fun myRepositoryTest() = runTest {
    // Given a repository with a fake data source that emits ALL_MESSAGES
    val messages = repository.observeChatMessages().toList()

    // When all messages are emitted then they should be ALL_MESSAGES
    assertEquals(ALL_MESSAGES, messages)
}

برای جریان‌های داده‌ای که به مجموعه پیچیده‌تری از آیتم‌ها نیاز دارند یا تعداد محدودی از آیتم‌ها را بر نمی‌گردانند، می‌توانید از Flow API برای انتخاب و تبدیل آیتم‌ها استفاده کنید. در اینجا چند نمونه آورده شده است:

// Take the second item
outputFlow.drop(1).first()

// Take the first 5 items
outputFlow.take(5).toList()

// Takes the first item verifying that the flow is closed after that
outputFlow.single()

// Finite data streams
// Verify that the flow emits exactly N elements (optional predicate)
outputFlow.count()
outputFlow.count(predicate)

جمع آوری مستمر در طول آزمایش

جمع‌آوری یک جریان با استفاده از toList() همانطور که در مثال قبلی مشاهده شد collect() به صورت داخلی استفاده می‌کند و تا زمانی که کل لیست نتایج آماده بازگشت شود به حالت تعلیق در می‌آید.

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

به عنوان مثال، کلاس Repository زیر را برای آزمایش و پیاده سازی منبع داده جعلی همراه با آن که دارای یک روش emit برای تولید مقادیر به صورت پویا در طول آزمایش است، در نظر بگیرید:

class Repository(private val dataSource: DataSource) {
    fun scores(): Flow<Int> {
        return dataSource.counts().map { it * 10 }
    }
}

class FakeDataSource : DataSource {
    private val flow = MutableSharedFlow<Int>()
    suspend fun emit(value: Int) = flow.emit(value)
    override fun counts(): Flow<Int> = flow
}

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

@Test
fun continuouslyCollect() = runTest {
    val dataSource = FakeDataSource()
    val repository = Repository(dataSource)

    val values = mutableListOf<Int>()
    backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
        repository.scores().toList(values)
    }

    dataSource.emit(1)
    assertEquals(10, values[0]) // Assert on the list contents

    dataSource.emit(2)
    dataSource.emit(3)
    assertEquals(30, values[2])

    assertEquals(3, values.size) // Assert the number of items collected
}

از آنجایی که جریانی که در اینجا توسط Repository در معرض دید قرار می‌گیرد، هرگز کامل نمی‌شود، فراخوانی toList که آن را جمع‌آوری می‌کند، هرگز برنمی‌گردد. شروع کار جمع آوری در TestScope.backgroundScope تضمین می کند که کوروتین قبل از پایان آزمون لغو می شود. در غیر این صورت، runTest منتظر تکمیل آن می ماند و باعث می شود که تست پاسخ ندهد و در نهایت با شکست مواجه شود.

توجه کنید که چگونه UnconfinedTestDispatcher برای برنامه جمع آوری در اینجا استفاده می شود. این تضمین می‌کند که برنامه جمع‌آوری مشتاقانه راه‌اندازی می‌شود و آماده دریافت مقادیر پس از بازگشت launch است.

با استفاده از توربین

کتابخانه Turbine شخص ثالث یک API مناسب برای ایجاد یک برنامه جمع‌آوری و همچنین سایر ویژگی‌های راحتی برای آزمایش جریان‌ها ارائه می‌دهد:

@Test
fun usingTurbine() = runTest {
    val dataSource = FakeDataSource()
    val repository = Repository(dataSource)

    repository.scores().test {
        // Make calls that will trigger value changes only within test{}
        dataSource.emit(1)
        assertEquals(10, awaitItem())

        dataSource.emit(2)
        awaitItem() // Ignore items if needed, can also use skip(n)

        dataSource.emit(3)
        assertEquals(30, awaitItem())
    }
}

برای جزئیات بیشتر به اسناد کتابخانه مراجعه کنید.

تست StateFlows

StateFlow یک دارنده داده قابل مشاهده است که می تواند برای مشاهده مقادیری که در طول زمان به عنوان یک جریان دارد جمع آوری شود. توجه داشته باشید که این جریان از مقادیر با هم ترکیب شده است، به این معنی که اگر مقادیر در یک StateFlow به سرعت تنظیم شوند، جمع‌آورنده‌های آن StateFlow تضمینی برای دریافت تمام مقادیر میانی، فقط آخرین مقادیر، ندارند.

در آزمایش‌ها، اگر ترکیب را در ذهن داشته باشید، می‌توانید مقادیر StateFlow را جمع‌آوری کنید، همانطور که می‌توانید هر جریان دیگری را جمع‌آوری کنید، از جمله با Turbine. تلاش برای جمع آوری و اثبات همه مقادیر میانی در برخی از سناریوهای آزمایشی مطلوب است.

با این حال، ما به طور کلی توصیه می‌کنیم که StateFlow به‌عنوان یک دارنده داده در نظر بگیرید و به جای آن، ویژگی value آن را تأیید کنید. به این ترتیب، آزمایش‌ها وضعیت فعلی شی را در یک نقطه زمانی معین تأیید می‌کنند و به این بستگی ندارند که آیا ادغام اتفاق می‌افتد یا خیر.

به عنوان مثال، این ViewModel را انتخاب کنید که مقادیر را از یک Repository جمع آوری می کند و آنها را در یک StateFlow در معرض UI قرار می دهد:

class MyViewModel(private val myRepository: MyRepository) : ViewModel() {
    private val _score = MutableStateFlow(0)
    val score: StateFlow<Int> = _score.asStateFlow()

    fun initialize() {
        viewModelScope.launch {
            myRepository.scores().collect { score ->
                _score.value = score
            }
        }
    }
}

یک پیاده سازی جعلی برای این Repository ممکن است به شکل زیر باشد:

class FakeRepository : MyRepository {
    private val flow = MutableSharedFlow<Int>()
    suspend fun emit(value: Int) = flow.emit(value)
    override fun scores(): Flow<Int> = flow
}

هنگام آزمایش ViewModel با این جعلی، می‌توانید مقادیری را از جعلی منتشر کنید تا به‌روزرسانی‌ها را در ViewModel's StateFlow راه‌اندازی کنید و سپس value به‌روزرسانی شده را مشخص کنید:

@Test
fun testHotFakeRepository() = runTest {
    val fakeRepository = FakeRepository()
    val viewModel = MyViewModel(fakeRepository)

    assertEquals(0, viewModel.score.value) // Assert on the initial value

    // Start collecting values from the Repository
    viewModel.initialize()

    // Then we can send in values one by one, which the ViewModel will collect
    fakeRepository.emit(1)
    assertEquals(1, viewModel.score.value)

    fakeRepository.emit(2)
    fakeRepository.emit(3)
    assertEquals(3, viewModel.score.value) // Assert on the latest value
}

کار با StateFlow های ایجاد شده توسط stateIn

در بخش قبل، ViewModel از یک MutableStateFlow برای ذخیره آخرین مقدار منتشر شده توسط یک جریان از Repository استفاده می کند. این یک الگوی رایج است که معمولاً با استفاده از عملگر stateIn که یک جریان سرد را به StateFlow داغ تبدیل می‌کند، به روشی ساده‌تر پیاده‌سازی می‌شود:

class MyViewModelWithStateIn(myRepository: MyRepository) : ViewModel() {
    val score: StateFlow<Int> = myRepository.scores()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), 0)
}

اپراتور stateIn یک پارامتر SharingStarted دارد که تعیین می کند چه زمانی فعال می شود و شروع به مصرف جریان اصلی می کند. گزینه هایی مانند SharingStarted.Lazily و SharingStarted.WhileSubsribed اغلب در ViewModels استفاده می شوند.

حتی اگر در آزمایش خود بر value StateFlow تاکید می کنید، باید یک جمع کننده ایجاد کنید. این می تواند یک جمع کننده خالی باشد:

@Test
fun testLazilySharingViewModel() = runTest {
    val fakeRepository = HotFakeRepository()
    val viewModel = MyViewModelWithStateIn(fakeRepository)

    // Create an empty collector for the StateFlow
    backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
        viewModel.score.collect()
    }

    assertEquals(0, viewModel.score.value) // Can assert initial value

    // Trigger-assert like before
    fakeRepository.emit(1)
    assertEquals(1, viewModel.score.value)

    fakeRepository.emit(2)
    fakeRepository.emit(3)
    assertEquals(3, viewModel.score.value)
}

منابع اضافی