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

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

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

ایجاد تولیدکننده قلابی

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

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

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

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

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

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

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

اگر موضوع تحت آزمایش، یک جریان داده را افشا کند، آزمایش باید روی عناصر جریان داده، ادعاهایی (assertions) انجام دهد.

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

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

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

شما می‌توانید اولین انتشار به جریان را با فراخوانی 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)
}

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

// 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() استفاده می‌کند و تا زمانی که کل لیست نتایج آماده بازگشت نباشد، به حالت تعلیق در می‌آید.

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

برای مثال، کلاس 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 دریافت می‌کند. در این مثال، ما آنها را در یک لیست جمع‌آوری می‌کنیم و سپس روی محتویات آن عملیات assertion انجام می‌دهیم:

@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 یک 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())
    }
}

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

آزمایش جریان‌های حالت

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
}

کار با 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.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)
}

منابع اضافی