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)
| # | Item | Effort |
|---|---|---|
| 1 | GitHub Actions CI: npm run check on PRs | 1h |
| 2 | Test data factories for domain entities | 2h |
| 3 | TestWrapper with test QueryClient | 30min |
| 4 | Global error handler (ErrorUtils + unhandled promises) | 1h |
| 5 | Network error classification in TanStack Query config | 1h |
| 6 | ErrorState component with retry | 30min |
| 7 | Move Supabase credentials from eas.json to EAS Secrets | 30min |
| 8 | Add appVersionSource: "remote" + autoIncrement | 15min |
| 9 | Install + configure @sentry/react-native | 2h |
| 10 | Per-tab error boundaries | 1h |
| 11 | QueryErrorResetBoundary for critical screens | 1h |
Phase 2 — After Launch (Should-haves)
| # | Item | Effort |
|---|---|---|
| 12 | Maestro E2E flows (5-10 critical paths) | 4h |
| 13 | GitHub Actions: auto OTA update on merge | 2h |
| 14 | Sentry Performance monitoring (20% sample rate) | 1h |
| 15 | Analytics event schema + tracking hooks | 3h |
| 16 | AppError class hierarchy in domain/errors | 2h |
| 17 | Coverage thresholds (80% domain) | 30min |
Phase 3 — Maturity (Nice-to-haves)
| # | Item | Effort |
|---|---|---|
| 18 | Snapshot tests for stable leaf components | 2h |
| 19 | In-app feedback via Sentry User Feedback | 2h |
| 20 | Feature flag system for gradual rollout | 4h |
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.
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.
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.
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.
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.
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.
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/).
# .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 check2. Error Handling
2.1 Error Boundaries — Strategic Placement
RULE: 3 levels: (1) App root, (2) Per-tab, (3) Per-feature (maps, charts).
<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.
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.
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.
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.
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.
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.
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.
# 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 Sentry4.3 Environment Management
RULE: Never commit secrets to eas.json. Use EAS Secrets.
eas secret:create --name EXPO_PUBLIC_SUPABASE_URL --value "https://..." --scope project
eas secret:create --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value "eyJ..." --scope projectACTION: 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.
# .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 --auto4.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
- eas.json has inline Supabase credentials — migrate to EAS Secrets before public release
- 490 tests but no CI — highest-ROI item: add GitHub Actions
npm run checkon PRs - No crash reporting —
@sentry/react-nativewith Expo plugin is the clear choice - Hex arch is a testing superpower — port/adapter boundary is the perfect mock seam
useQueryErrorhook — consider migrating toQueryCache.onErrorfor global handling