| name | write-unit-tests |
| description | Use when writing or structuring unit tests in stripe-android — covers runScenario pattern, fakes, Turbine Flow testing, and Truth assertions |
Setting Up Tests
This skill describes how to structure tests in the Stripe Android SDK using fakes, scenarios, and proper verification patterns.
Core Principles
- Use fakes over mocks - Leverage fake implementations for dependencies (see
create-fake skill)
- Create test scenarios - Use Scenario classes with
runScenario functions to organize test setup
- Verify all events consumed - Call
ensureAllEventsConsumed() on fakes after test block
- Use Truth assertions - Always use
assertThat(actual).isEqualTo(expected) from Google Truth
- One case per test - Each
@Test should cover a single scenario or configuration
- Use Turbine for Flow testing - Test Flow emissions with Turbine's
.test { } syntax
Basic Test Structure
Every test should follow this pattern:
@Test
fun `test description`() = runScenario(
config = testConfig
) {
fakeService.result = expectedResult
val result = systemUnderTest.doSomething()
assertThat(result.status).isEqualTo(expectedStatus)
assertThat(result.id).isEqualTo(expectedId)
assertThat(fakeService.calls.awaitItem()).isEqualTo(expectedCall)
}
Scenario Pattern with runScenario
Basic Structure
Create a runScenario function and a Scenario class at the bottom of your test file:
class MyFeatureTest {
@Test
fun `test case`() = runScenario {
assertThat(systemUnderTest.getValue()).isEqualTo(expectedValue)
}
private fun runScenario(
config: Config = defaultConfig,
block: suspend Scenario.() -> Unit,
) = runTest {
val fakeRepository = FakeRepository()
val fakeAnalytics = FakeAnalytics()
val systemUnderTest = MyFeature(
repository = fakeRepository,
analytics = fakeAnalytics,
config = config,
)
Scenario(
systemUnderTest = systemUnderTest,
fakeRepository = fakeRepository,
fakeAnalytics = fakeAnalytics,
).apply { block() }
fakeRepository.ensureAllEventsConsumed()
fakeAnalytics.ensureAllEventsConsumed()
}
private data class Scenario(
val systemUnderTest: MyFeature,
val fakeRepository: FakeRepository,
val fakeAnalytics: FakeAnalytics,
)
}
Key Features:
runScenario replaces runTest as the test entry point
- Default parameters for all configuration make tests concise
- Trailing lambda provides DSL-like syntax with scenario fields
ensureAllEventsConsumed() called automatically after test block
- Scenario class holds system under test and all fakes
Using runScenario in Tests
@Test
fun `fetching data returns success when repository succeeds`() = runScenario {
fakeRepository.dataResult = Result.success(testData)
val result = systemUnderTest.fetchData()
assertThat(result.isSuccess).isTrue()
assertThat(fakeRepository.fetchCalls.awaitItem()).isEqualTo(FetchCall(userId = "123"))
}
Assertion Style
When the code under test returns an object, prefer asserting on the specific fields you care about instead of asserting that the whole object is equal to an expected object:
assertThat(result.status).isEqualTo(Status.Complete)
assertThat(result.paymentMethodId).isEqualTo("pm_123")
Use whole-object equality only when exact object equality is the behavior under test.
Field-level assertions usually produce better failure messages because the failing property is obvious.
One Case Per Test
Keep each unit test focused on a single case. If you need to verify several related configurations, write one test per configuration instead of combining them into one test.
@Test
fun `analytics event for saved card`() = runScenario(
paymentSelection = PaymentSelection.Saved(paymentMethod),
) {
assertThat(fakeAnalytics.calls.awaitItem().event).isEqualTo("saved_card")
}
@Test
fun `analytics event for new card`() = runScenario(
paymentSelection = PaymentSelection.New.Card(cardParams),
) {
assertThat(fakeAnalytics.calls.awaitItem().event).isEqualTo("new_card")
}
This keeps failures isolated. When multiple scenarios are combined into one test, execution stops at the first failing assertion and hides the rest of the failures from that run.
Turbine Flow Testing
Use Turbine's .test { } to assert Flow emissions:
@Test
fun `state updates when data changes`() = runScenario {
systemUnderTest.state.test {
assertThat(awaitItem()).isEqualTo(State.Loading)
fakeRepository.emit(newData)
assertThat(awaitItem()).isEqualTo(State.Loaded(newData))
ensureAllEventsConsumed()
}
}
Common Turbine operations:
awaitItem() — wait for next emission
expectNoEvents() — assert no emissions occurred
ensureAllEventsConsumed() — verify no unconsumed events remain
skipItems(n) — skip past emissions you don't care about
Quick Reference
| What | Pattern |
|---|
| Test entry point | fun \test name`() = runScenario { }` |
| Assertions | assertThat(actual).isEqualTo(expected) |
| Flow testing | flow.test { assertThat(awaitItem()).isEqualTo(x) } |
| Fake call tracking | assertThat(fake.calls.awaitItem()).isEqualTo(call) |
| Fake validation | ensureAllEventsConsumed() — automatic in runScenario |
| Test granularity | One scenario or configuration per @Test |
| Compose UI tests | Invoke compose-tests skill |
| Creating fakes | Invoke create-fake skill |
| NetworkRule integration tests | Invoke network-tests skill |
Concurrency Testing with Real I/O
When testing coroutines that involve real network I/O (NetworkRule/OkHttp), use runTest + testScheduler.advanceUntilIdle() + async/await().
Asserting StateFlow emission sequences (use Turbine)
When you need to verify a StateFlow's transitions during an async operation, use Turbine's .test { } to assert the full sequence:
@Test
fun `loading state transitions during mutation`() = runTest {
val holdResponse = CountDownLatch(1)
networkRule.enqueue(host("api.stripe.com"), method("POST"), path("/v1/...")) { response ->
holdResponse.await(10, TimeUnit.SECONDS)
response.setBody("{}")
}
systemUnderTest.isLoading.test {
assertThat(awaitItem()).isFalse()
val job = async { systemUnderTest.mutate() }
testScheduler.advanceUntilIdle()
assertThat(awaitItem()).isTrue()
holdResponse.countDown()
job.await()
assertThat(awaitItem()).isFalse()
}
}
Asserting operations are queued (use CountDownLatch)
When you need to verify that concurrent operations are serialized by a mutex, use CountDownLatch to observe request ordering:
@Test
fun `concurrent operations are serialized by mutex`() = runTest {
val holdFirstResponse = CountDownLatch(1)
val secondRequestArrived = CountDownLatch(1)
networkRule.enqueue(host("api.stripe.com"), method("POST"), path("/v1/...")) { response ->
holdFirstResponse.await(10, TimeUnit.SECONDS)
response.setBody("{}")
}
networkRule.enqueue(host("api.stripe.com"), method("POST"), path("/v1/...")) { response ->
secondRequestArrived.countDown()
response.setBody("{}")
}
val jobB = async { systemUnderTest.operationB() }
testScheduler.advanceUntilIdle()
val jobA = async { systemUnderTest.operationA() }
testScheduler.advanceUntilIdle()
assertThat(secondRequestArrived.count).isEqualTo(1)
holdFirstResponse.countDown()
jobB.await()
jobA.await()
assertThat(secondRequestArrived.count).isEqualTo(0)
}
Rules
async + await() over launch + join() — await() propagates exceptions
testScheduler.advanceUntilIdle() over Thread.sleep — deterministic, advances to suspension point
- After releasing a latch, use
await() (not advanceUntilIdle() alone) — OkHttp delivers continuations asynchronously
- Always pass a timeout to
CountDownLatch.await() inside mock handlers — prevents hangs
- Assert both negative (didn't happen during hold) AND positive (did happen after release)
- Turbine
.test { } for StateFlow emission sequences; CountDownLatch for request ordering
Common Mistakes
- Using mocks instead of fakes — always create
FakeClassName implementations
- Forgetting
ensureAllEventsConsumed() — runScenario handles this, but if using runTest directly, call it manually
- Testing implementation details — test behavior (inputs → outputs), not internal method calls
- Missing edge cases — null values, empty lists, blank strings, error paths
- Using
Thread.sleep in tests — use testScheduler.advanceUntilIdle() instead
- Testing stdlib behavior — don't test that
HashMap.clear() works
- Vacuous assertions — assert the pre-condition exists before testing its removal
- Asserting on full objects by default — prefer field-level assertions unless exact object equality is the contract being tested
- Combining multiple scenarios in one test — split related configurations into separate
@Tests so failures stay isolated