Skip to main content

Testing

Cloud storage is the hardest part of a mobile app to test honestly - the most-used library here gave up testing iCloud on the simulator entirely. This package ships an in-memory provider with fault injection instead, from a real entry point:

import { createMemoryProvider } from 'react-native-cloud-sync/testing'

Basics

const provider = createMemoryProvider({
initial: { 'user/id': '42' },
accountStatus: 'available',
latencyMs: 0,
})

await provider.getItem('user/id') // '42'
const store = createCloudStore({ providers: ['memory'] })
store.registerProvider(provider)

Injecting failures

import { ErrorCode } from 'react-native-cloud-sync'

const provider = createMemoryProvider({
faults: { setItem: { code: ErrorCode.QUOTA_EXCEEDED } },
})

await expect(store.setItem('k', 'v')).rejects.toMatchObject({
code: ErrorCode.QUOTA_EXCEEDED,
})

At runtime:

provider.setFault('getItem', { code: ErrorCode.NETWORK_UNAVAILABLE })
provider.setFault('getItem', null) // clear

Failures that heal

// Fail twice, then succeed - the shape retry and outbox logic must converge on.
provider.setFault('setItem', { code: ErrorCode.NETWORK_UNAVAILABLE, times: 2 })

await store.setItem('k', 'v') // queued
await store.flushOutbox() // still failing
await store.flushOutbox() // still failing
await store.flushOutbox() // drains

Rate limiting with a server hint

provider.setFault('setItem', {
code: ErrorCode.RATE_LIMITED,
retryAfterMs: 90_000,
})

Asserts your backoff honours the server's hint, not its own schedule.

Simulating other devices

provider.emitRemoteChange({ keys: ['user/id'], reason: 'serverChange' })

The cross-user leak case:

provider.emitAccountChange({ status: 'available', identityChanged: true })
// Assert that every user-scoped cache was dropped.

Asserting call counts

expect(provider.calls.setItem).toBe(1) // no blind second write

Proves a retry did not happen - e.g. a quota failure surfacing instead of queuing forever.

Inspecting state

MemberPurpose
dump()Backing map, bypassing fault injection
seed(data)Replace data without going through setItem
reset()Clear data, faults and listeners
callsPer-operation call counts

Simulating a signed-out fleet

The case that matters most, and the hardest to reach on a device:

const provider = createMemoryProvider({ available: false, initial: { backup: 'real data' } })

// Not null - null would read as "no backup exists", and the documented
// first-launch recipe seeds empty state on null.
await expect(store.getItem('backup')).rejects.toMatchObject({
code: ErrorCode.NOT_SIGNED_IN,
})

setAvailable(false) flips it mid-test - for a mid-session sign-out.

Faults scoped to one key

Real backends fail per record, not per operation - one bad key, the rest of the batch through:

const provider = createMemoryProvider({
faults: {
setItem: { code: ErrorCode.QUOTA_EXCEEDED, only: key => key === 'huge' },
},
})

Also keeps interleaving tests deterministic - in-flight operations no longer share a times budget.

Several providers at once

const apple = createMemoryProvider({ name: 'icloudKV' })
const drive = createMemoryProvider({ name: 'googleDrive' })

const store = createCloudStore({ providers: ['icloudKV', 'googleDrive'], writeMode: 'mirror' })
store.registerProvider(apple)
store.registerProvider(drive)

name makes each double stand in for a real provider - tiering, mirroring and read repair, which key off names, behave as in production.

Asserting that batching happened

The double counts one call per batch, not per key, proving the store batched rather than looped:

const before = provider.calls.getItem
await store.multiGet(['a', 'b', 'c'])
expect(provider.calls.getItem).toBe(before + 1)

Account switches

provider.emitAccountChange({ status: 'available', identityChanged: true })

expect(store.pendingWrites()).toHaveLength(0) // the previous user's writes are gone
expect(provider.cacheClears).toBe(1) // and the provider was told to forget

cacheClears counts how often the store told this provider to drop cached state - proof it reacted, not just forwarded the event.

Mocking the native module

To test a real provider, mock the native module directly - this package ships the mock its own suite uses:

// jest.config.js
module.exports = {
setupFiles: ['react-native-cloud-sync/jest-mock'],
}

A jest.fn() surface plus an event registry, so tests can fire native events without a device:

const harness = (global as any).__RNCloudSync

harness.setPlatform('android') // exercise the REST branches
harness.emit('accountChange', { status: 'available', identityChanged: true })
harness.setAppState('active') // trigger the store's auto-flush
harness.reset()

It deliberately avoids jest.requireActual('react-native') - loading the real index pulls in DevMenu, whose module-level TurboModuleRegistry.getEnforcing call throws under Jest.