Skip to content

Testing, Error Handling, Monitoring & Build/Deploy Best Practices

Status: Reference

React Native (Expo SDK 55) + EAS + Supabase + Jest 29 + TanStack Query v5 Researched 2026-03-24.


Implementation Priority Matrix

Phase 1 — Before Production (Must-haves)

#ItemEffort
1GitHub Actions CI: npm run check on PRs1h
2Test data factories for domain entities2h
3TestWrapper with test QueryClient30min
4Global error handler (ErrorUtils + unhandled promises)1h
5Network error classification in TanStack Query config1h
6ErrorState component with retry30min
7Move Supabase credentials from eas.json to EAS Secrets30min
8Add appVersionSource: "remote" + autoIncrement15min
9Install + configure @sentry/react-native2h
10Per-tab error boundaries1h
11QueryErrorResetBoundary for critical screens1h

Phase 2 — After Launch (Should-haves)

#ItemEffort
12Maestro E2E flows (5-10 critical paths)4h
13GitHub Actions: auto OTA update on merge2h
14Sentry Performance monitoring (20% sample rate)1h
15Analytics event schema + tracking hooks3h
16AppError class hierarchy in domain/errors2h
17Coverage thresholds (80% domain)30min

Phase 3 — Maturity (Nice-to-haves)

#ItemEffort
18Snapshot tests for stable leaf components2h
19In-app feedback via Sentry User Feedback2h
20Feature flag system for gradual rollout4h

1. Testing Strategy

1.1 Test Pyramid

RULE: 70% unit / 20% integration-component / 10% E2E.

  • Unit: Domain entities, Zod schemas, pure functions, rotation engine — fast (<1ms each)
  • Component: RNTL — render, query by text/role, fireEvent, verify
  • E2E: Maestro — critical happy paths only (login, RSVP, score entry)

1.2 Jest Configuration

RULE: Separate projects for pure logic (node env) and RN components (jest-expo). Node is 3-5x faster.

TwoMore: Already has correct dual-project setup in jest.config.js. Add testTimeout: 10000 and clearMocks: true as defaults.

1.3 Domain & Zod Schema Testing

RULE: Test every schema for: valid parse, invalid fail with correct path, transforms/defaults, edge cases.

typescript
describe('SessionSchema', () => {
  it('parses valid session', () => {
    expect(() => SessionSchema.parse(validSession)).not.toThrow();
  });
  it('rejects maxPlayers < 2', () => {
    expect(() => SessionSchema.parse({ ...validSession, maxPlayers: 1 })).toThrow();
  });
  it('applies default status', () => {
    const result = SessionSchema.parse(sessionWithoutStatus);
    expect(result.status).toBe('scheduled');
  });
});

1.4 Component Testing — RNTL

RULE: Test by user-visible behavior. Query by getByText, getByRole, getByLabelText — never testID unless no semantic alternative. Use userEvent over fireEvent.

typescript
const user = userEvent.setup();
render(<SessionCard session={mockSession} />);
expect(screen.getByText('Saturday Regular')).toBeOnTheScreen();
await user.press(screen.getByRole('button', { name: /RSVP/ }));
expect(onRsvp).toHaveBeenCalledWith(mockSession.id);

1.5 Hook Testing — TanStack Query

RULE: Create reusable createTestQueryClient() with retries disabled, gcTime Infinity.

typescript
export function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: { retry: false, gcTime: Infinity },
      mutations: { retry: false },
    },
  });
}

export function TestWrapper({ children }: { children: React.ReactNode }) {
  const client = createTestQueryClient();
  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

1.6 Integration Testing — Mock Adapters

RULE: Wire real use cases to mock adapters via registry. Never mock domain logic or internal layers.

typescript
const repo = new MockClubRepository();
const useCase = new CreateClubUseCase(repo);
const result = await useCase.execute({ name: 'Test Club', type: 'club' });
expect(result.name).toBe('Test Club');

TwoMore: Mock adapters are used in jest tests (injected via registry override). Runtime always uses Supabase adapters.

1.7 E2E Testing — Maestro

RULE: YAML flows for critical paths only (5-10 max). Add testID to key interactive elements.

yaml
appId: com.ivorybridge.twomore
---
- launchApp
- tapOn:
    id: 'kakao-login-button'
- assertVisible: 'Home'
- tapOn: 'Clubs'
- tapOn:
    id: 'session-card-0'
- tapOn: 'RSVP'
- assertVisible: 'RSVP confirmed'

1.8 Snapshot Testing

RULE: ONLY for stable leaf UI components (badges, icons). Never screens. Keep <50 lines.

1.9 Mock Strategy

RULE: Mock at port boundary. Never mock domain, Zod, or React internals.

1.10 Test Data — Factories

RULE: Factory functions with sensible defaults and overrides. Deterministic IDs.

typescript
let counter = 0;
export function buildSession(overrides: Partial<Session> = {}): Session {
  counter++;
  return {
    id: `test-session-${counter}`,
    clubId: 'test-club-1',
    title: `Test Session ${counter}`,
    status: 'scheduled',
    maxPlayers: 8,
    ...overrides,
  };
}

1.11 CI Testing

RULE: npm run check on every PR. Cache node_modules. Add coverage threshold (80% for domain/).

yaml
# .github/workflows/ci.yml
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }
      - run: npm ci
      - run: npm run check

2. Error Handling

2.1 Error Boundaries — Strategic Placement

RULE: 3 levels: (1) App root, (2) Per-tab, (3) Per-feature (maps, charts).

tsx
<ErrorBoundary fallback={<FullScreenError />}>
  <Tabs>
    <ErrorBoundary fallback={<TabError />}>
      <HomeTab />
    </ErrorBoundary>
    <ErrorBoundary fallback={<TabError />}>
      <ClubsTab />
    </ErrorBoundary>
  </Tabs>
</ErrorBoundary>

TwoMore: Root boundary exists. Add per-tab and per-feature.

2.2 Global Error Handler

RULE: ErrorUtils.setGlobalHandler for sync JS errors. Log to Sentry.

typescript
export function setupGlobalErrorHandler() {
  const defaultHandler = ErrorUtils.getGlobalHandler();
  ErrorUtils.setGlobalHandler((error, isFatal) => {
    Sentry.captureException(error, { extra: { isFatal } });
    if (isFatal) {
      Alert.alert('App Error', 'Please restart the app.', [
        { text: 'Restart', onPress: () => Updates.reloadAsync() },
      ]);
    }
    defaultHandler(error, isFatal);
  });
}

2.3 Network Error Classification

RULE: Transient (5xx, timeout) → auto-retry. Client error (4xx) → user message, no retry. Offline → queue, show banner.

typescript
retry: (failureCount, error) => {
  if (isClientError(error)) return false;
  return failureCount < 3;
},

2.4 TanStack Query Error Handling

RULE: QueryCache.onError for global toasts. QueryErrorResetBoundary for critical screens.

typescript
const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error, query) => {
      if (query.state.data !== undefined) {
        showToast(`Update failed: ${getErrorMessage(error)}`);
      }
    },
  }),
  mutationCache: new MutationCache({
    onError: (error) => {
      Sentry.captureException(error);
    },
  }),
});

2.5 Error Classification

RULE: Typed AppError hierarchy with kind: retriable / fatal / user_fixable / silent.

typescript
export type ErrorKind = 'retriable' | 'fatal' | 'user_fixable' | 'silent';

export class AppError extends Error {
  constructor(
    message: string,
    public readonly kind: ErrorKind,
    public readonly userMessage?: string,
    public readonly cause?: unknown
  ) {
    super(message);
  }
}

2.6 Crash Reporting — Sentry

RULE: @sentry/react-native with Expo plugin. Source maps via EAS build hooks. SENTRY_AUTH_TOKEN as EAS Secret.

typescript
Sentry.init({
  dsn: 'https://xxx@sentry.io/xxx',
  environment: __DEV__ ? 'development' : 'production',
  tracesSampleRate: __DEV__ ? 1.0 : 0.2,
  enableAutoSessionTracking: true,
});

2.7 Graceful Degradation

RULE: Every feature has 4 states: loading (skeleton), error (retry button), empty (empty state), offline (cached + banner). Never blank screen.


3. Monitoring & Observability

3.1 Analytics

RULE: Track screen views automatically. Track key business events manually with typed schema.

typescript
type AnalyticsEvent =
  | { name: 'session_rsvp'; clubId: string; sessionId: string }
  | { name: 'club_created'; type: 'club' | 'pickup' }
  | { name: 'match_scored'; sessionId: string; roundNumber: number };

3.2 Performance Monitoring

RULE: Sentry Performance at 20% sample rate. Custom spans for rotation engine and Elo.

3.3 Error Tracking

RULE: Tag errors with club_id, user_id. Alert on error spikes (>10 in 5 min).


4. Build & Deploy

4.1 EAS Build Profiles

RULE: 4 profiles: development, e2e-test, preview, production. Enable build caching. Use autoIncrement for production.

4.2 EAS Update (OTA)

RULE: Map channels 1:1 with profiles. Test on preview before production. Use fingerprint checks.

bash
# 1. Push to preview first
eas update --branch preview --message "fix"
# 2. Test on preview build
# 3. Push to production
eas update --branch production --message "fix"
# 4. Monitor Sentry

4.3 Environment Management

RULE: Never commit secrets to eas.json. Use EAS Secrets.

bash
eas secret:create --name EXPO_PUBLIC_SUPABASE_URL --value "https://..." --scope project
eas secret:create --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value "eyJ..." --scope project

ACTION: Migrate inline credentials from eas.json to EAS Secrets.

4.4 CI/CD — GitHub Actions + EAS

RULE: On PR → lint + typecheck + test. On merge to master → auto OTA to preview. On release tag → production build.

yaml
# .github/workflows/preview-update.yml
on:
  push:
    branches: [master]
jobs:
  update:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }
      - uses: expo/expo-github-action@v8
        with: { eas-version: latest, token: ${{ secrets.EXPO_TOKEN }} }
      - run: npm ci
      - run: eas update --branch preview --auto

4.5 Release Strategy

RULE: 3-stage: (1) Preview → team tests, (2) TestFlight/Internal track → beta, (3) Staged rollout 10% → 50% → 100%.

4.6 App Size

RULE: npx expo-atlas for bundle analysis. React.lazy() for heavy screens. Target <25MB APK preview, <15MB AAB production.

4.7 Version Management

RULE: semver for version. autoIncrement: true for buildNumber. appVersionSource: "remote" in eas.json.


Key Findings for TwoMore

  1. eas.json has inline Supabase credentials — migrate to EAS Secrets before public release
  2. 490 tests but no CI — highest-ROI item: add GitHub Actions npm run check on PRs
  3. No crash reporting@sentry/react-native with Expo plugin is the clear choice
  4. Hex arch is a testing superpower — port/adapter boundary is the perfect mock seam
  5. useQueryError hook — consider migrating to QueryCache.onError for global handling

Sources

Markdown remains the source of truth. Run yarn docs:check before handoff.