آزمایش کردن جریان‌های Kotlin در Android

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

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

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

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

موضوع تحت آزمایش و لایه داده
شکل ۱. موضوع تحت آزمایش و لایه داده.

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

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

برای انتشار مجموعه‌ای از مقادیر ازپیش‌تعریف‌شده در یک جریان، از سازنده 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
    // ...
}

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

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

اگر موضوع تحت آزمایش جریانی را آشکار می‌کند، آزمایش باید ادعاهایی درباره عناصر جاری‌سازی داده‌ها مطرح کند.

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

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

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

با فراخوانی 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 myRepositoryTestList() = runTest {
    val repository = MyFakeRepository()
    // 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 دریافت می‌کند. در این مثال، آن‌ها را در فهرستی جمع‌آوری می‌کنیم و سپس ادعاهایی درباره محتوای آن انجام می‌دهیم:

@OptIn(ExperimentalCoroutinesApi::class)
@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 میانای برنامه‌سازی کاربردی مناسبی برای ایجاد روتین همکار جمع‌آوری ارائه می‌دهد، همچنین ویژگی‌های مناسب دیگری برای آزمایش «جریان‌ها» ارائه می‌دهد:

@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 در اختیار رابط کاربری قرار می‌دهد:

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 با این ساختگی، می‌توانید مقادیر را از ساختگی منتشر کنید تا به‌روزرسانی‌ها را در StateFlow مربوط به ViewModel راه‌اندازی کنید، و سپس در 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
}

کار کردن با StateFlows ایجادشده توسط 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.WhileSubscribed اغلب در مدل‌های نمایشی استفاده می‌شوند.

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

@OptIn(ExperimentalCoroutinesApi::class)
@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)
}

منابع بیشتر