stripe/write-unit-tests
Use when writing or structuring unit tests in stripe-android — covers runScenario pattern, fakes, Turbine Flow testing, and Truth assertions
npx skills add https://github.com/stripe/stripe-android --skill write-unit-tests
This skill describes how to structure tests in the Stripe Android SDK using fakes, scenarios, and proper verification patterns.
create-fake skill)runScenario functions to organize test setupensureAllEventsConsumed() on fakes after test blockassertThat(actual).isEqualTo(expected) from Google Truth@Test should cover a single scenario or configuration.test { } syntaxEvery test should follow this pattern:
@Test
fun `test description`() = runScenario(
// Test-specific parameters
config = testConfig
) {
// 1. Configure: Set up fake behaviors (optional)
fakeService.result = expectedResult
// 2. Execute: Call the code under test
val result = systemUnderTest.doSomething()
// 3. Verify: Assert results and check fake calls
assertThat(result.status).isEqualTo(expectedStatus)
assertThat(result.id).isEqualTo(expectedId)
assertThat(fakeService.calls.awaitItem()).isEqualTo(expectedCall)
}
// 4. Validation: ensureAllEventsConsumed called automatically by runScenario
Create a runScenario function and a Scenario class at the bottom of your test file:
class MyFeatureTest {
@Test
fun `test case`() = runScenario {
// Test code using scenario fields
assertThat(systemUnderTest.getValue()).isEqualTo(expectedValue)
}
private fun runScenario(
config: Config = defaultConfig,
block: suspend Scenario.() -> Unit,
) = runTest {
// Setup fakes
val fakeRepository = FakeRepository()
val fakeAnalytics = FakeAnalytics()
// Create system under test
val systemUnderTest = MyFeature(
repository = fakeRepository,
analytics = fakeAnalytics,
config = config,
)
// Run test block with scenario context
Scenario(
systemUnderTest = systemUnderTest,
fakeRepository = fakeRepository,
fakeAnalytics = fakeAnalytics,
).apply { block() }
// Validate all fakes
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 pointensureAllEventsConsumed() called automatically after test block@Test
fun `fetching data returns success when repository succeeds`() = runScenario {
// Configure fake behavior
fakeRepository.dataResult = Result.success(testData)
// Execute
val result = systemUnderTest.fetchData()
// Verify
assertThat(result.isSuccess).isTrue()
assertThat(fakeRepository.fetchCalls.awaitItem()).isEqualTo(FetchCall(userId = "123"))
}
// ensureAllEventsConsumed called automatically
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.
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.
Use Turbine's .test { } to assert Flow emissions:
@Test
fun `state updates when data changes`() = runScenario {
systemUnderTest.state.test {
// Initial state
assertThat(awaitItem()).isEqualTo(State.Loading)
// Trigger change
fakeRepository.emit(newData)
assertThat(awaitItem()).isEqualTo(State.Loaded(newData))
// No more events expected
ensureAllEventsConsumed()
}
}
Common Turbine operations:
awaitItem() — wait for next emissionexpectNoEvents() — assert no emissions occurredensureAllEventsConsumed() — verify no unconsumed events remainskipItems(n) — skip past emissions you don't care about| 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 |
When testing coroutines that involve real network I/O (NetworkRule/OkHttp), use runTest + testScheduler.advanceUntilIdle() + async/await().
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()
}
}
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) // A hasn't fired
holdFirstResponse.countDown()
jobB.await()
jobA.await()
assertThat(secondRequestArrived.count).isEqualTo(0) // A did fire
}
async + await() over launch + join() — await() propagates exceptionstestScheduler.advanceUntilIdle() over Thread.sleep — deterministic, advances to suspension pointawait() (not advanceUntilIdle() alone) — OkHttp delivers continuations asynchronouslyCountDownLatch.await() inside mock handlers — prevents hangs.test { } for StateFlow emission sequences; CountDownLatch for request orderingFakeClassName implementationsensureAllEventsConsumed() — runScenario handles this, but if using runTest directly, call it manuallyThread.sleep in tests — use testScheduler.advanceUntilIdle() insteadHashMap.clear() works@Tests so failures stay isolatedTake stripe/write-unit-tests from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.