Testing

createTestApp()

createTestApp is the single entry point for integration tests. One call wires all 16 in-memory adapters, a MockApiClient, a real observable store, and a frozen config.

import { createTestApp } from '@objectifthunes/limestone-sdk/testing';

const app = createTestApp({ theme: myTheme });

Pass initialState to pre-seed auth or any other store slice:

const app = createTestApp({
  theme: myTheme,
  initialState: {
    auth: {
      status: 'authenticated',
      user: { id: '1', name: 'Alice' },
      tokens: { accessToken: 'tok', refreshToken: 'ref' },
    },
  },
});

TestApp fields

FieldTypeDescription
storeStore<AppState>Observable store, pre-seeded with initialState
configReadonly<LimestoneConfig>Frozen config passed to the provider
apiMockApiClientMock HTTP client with response registration and request recording
biometricsIn-memory BiometricProvider with state
cameraIn-memory CameraProvider with state
storageIn-memory SecureStorageProvider with state
hapticsIn-memory HapticsProvider with state
notificationsIn-memory NotificationProvider with state
purchasesIn-memory PurchaseProvider with state
permissionsIn-memory PermissionsProvider with state
shareIn-memory ShareProvider with state
clipboardIn-memory ClipboardProvider with state
keychainIn-memory KeychainProvider with state
locationIn-memory LocationProvider with state
mediaLibraryIn-memory MediaLibraryProvider with state
mapIn-memory MapProvider with state
webBrowserIn-memory WebBrowserProvider with state
contactsIn-memory ContactsProvider with state
reviewIn-memory ReviewProvider with state

MockApiClient

MockApiClient implements ApiClient and adds two extras: mockResponse and requests.

mockResponse

Register a canned response for a method + path combination.

app.api.mockResponse('GET', '/users/me', {
  status: 200,
  body: { id: '1', name: 'Alice' },
});

app.api.mockResponse('POST', '/sessions', {
  status: 401,
  body: { error: 'Invalid credentials' },
});

requests

An array of every request the client received, in order.

expect(app.api.requests).toHaveLength(1);
expect(app.api.requests[0]).toEqual({
  method: 'GET',
  path: '/users/me',
  body: undefined,
  headers: undefined,
});

Each entry is a RecordedRequest: { method, path, body?, headers? }.

Adapter state manipulation

Every in-memory adapter exposes a state object. Write to it directly to simulate hardware states and inject fixture data.

// Simulate biometrics unavailable
app.biometrics.state.available = false;

// Simulate a failed auth attempt
app.biometrics.state.nextResult = { success: false, error: 'locked' };

// Pre-seed secure storage
app.storage.state.store = { 'auth-token': 'abc123' };

// Simulate no network permissions
app.permissions.state.statuses = { location: 'denied', camera: 'granted' };

// Simulate a specific clipboard value
app.clipboard.state.value = 'copied text';

// Make review unavailable
app.review.state.available = false;

resetAll()

Call app.resetAll() between tests to return every adapter state, the store, and the request log to their initial values.

afterEach(() => {
  app.resetAll();
});

Complete test example

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import { LimestoneProvider } from '@objectifthunes/limestone-sdk';
import { createTestApp } from '@objectifthunes/limestone-sdk/testing';
import { LoginForm } from '../src/screens/LoginForm';
import { myTheme } from '../src/theme';

const app = createTestApp({ theme: myTheme });

function renderWithApp(ui: React.ReactElement) {
  return render(
    <LimestoneProvider config={app.config}>
      {ui}
    </LimestoneProvider>,
  );
}

afterEach(() => {
  app.resetAll();
});

describe('LoginForm', () => {
  it('submits valid credentials and calls the sessions endpoint', async () => {
    app.api.mockResponse('POST', '/sessions', {
      status: 200,
      body: { accessToken: 'tok', refreshToken: 'ref', user: { id: '1' } },
    });

    const { getByLabelText, getByText } = renderWithApp(<LoginForm />);

    fireEvent.changeText(getByLabelText('Email'), 'alice@example.com');
    fireEvent.changeText(getByLabelText('Password'), 'secret1234');
    fireEvent.press(getByText('Sign in'));

    await waitFor(() => {
      expect(app.api.requests).toHaveLength(1);
      expect(app.api.requests[0].method).toBe('POST');
      expect(app.api.requests[0].path).toBe('/sessions');
      expect(app.api.requests[0].body).toEqual({
        email: 'alice@example.com',
        password: 'secret1234',
      });
    });
  });

  it('shows a field error when the server returns 401', async () => {
    app.api.mockResponse('POST', '/sessions', {
      status: 401,
      body: { errors: { email: 'Invalid email or password' } },
    });

    const { getByLabelText, getByText, findByText } = renderWithApp(<LoginForm />);

    fireEvent.changeText(getByLabelText('Email'), 'wrong@example.com');
    fireEvent.changeText(getByLabelText('Password'), 'wrongpass');
    fireEvent.press(getByText('Sign in'));

    expect(await findByText('Invalid email or password')).toBeTruthy();
  });

  it('blocks submission when biometrics lock the session', async () => {
    app.biometrics.state.available = true;
    app.biometrics.state.nextResult = { success: false, error: 'locked' };

    const bio = app.biometrics;
    const result = await bio.authenticate({ promptMessage: 'Confirm' });

    expect(result.success).toBe(false);
    expect(result.error).toBe('locked');
  });
});

Using individual in-memory doubles

Import the doubles directly when you do not need the full createTestApp setup.

import {
  createInMemoryBiometrics,
  createInMemorySecureStorage,
  createInMemoryNotifications,
} from '@objectifthunes/limestone-sdk/testing';

const biometrics = createInMemoryBiometrics();
biometrics.state.available = true;
biometrics.state.nextResult = { success: true };

const result = await biometrics.authenticate();
expect(result.success).toBe(true);

const storage = createInMemorySecureStorage();
await storage.set('key', 'value');
expect(storage.state.store).toEqual({ key: 'value' });

All 16 in-memory doubles follow the same pattern: create, mutate state, call port methods, assert.