React Native apps don't usually fail loudly. They rot quietly. A refactor leaves a screen that return null and ships a blank page. A "temporary" stub passes lint, type-check, and unit tests because nothing renders it. A business rule everyone agreed on — "the wallet changes when you spend" — is never actually asserted, so it silently breaks three releases later.

Tests don't catch these because tests check logic, and these are integration truths. So I made them impossible to skip, with two build gates that fail npm run check and CI.

Gate 1 — a "screens gate": a screen must actually be a screen

A stub screen (returns null, or renders nothing) is invisible to logic tests. This gate walks src/ui/screens and asserts every file is a real, default-exported component that renders markup:

// src/__tests__/screens.gate.test.tsx import fs from 'fs'; import path from 'path'; const SCREENS_DIR = path.join(__dirname, '..', 'ui', 'screens'); const MIN_LINES = 20; const files = fs.existsSync(SCREENS_DIR) ? fs.readdirSync(SCREENS_DIR).filter((f) => f.endsWith('Screen.tsx')) : []; describe('every screen is a real implementation (not a stub)', () => { it('has screen files', () => expect(files.length).toBeGreaterThan(0)); files.forEach((file) => { it(`${file} is implemented`, () => { const src = fs.readFileSync(path.join(SCREENS_DIR, file), 'utf8'); expect(src.split('\n').length).toBeGreaterThan(MIN_LINES); expect(src).not.toMatch(/return\s+null\s*;/); expect(src).toMatch(/export\s+default/); expect(src).toMatch(/return\s*\(|<[A-Za-z][A-Za-z0-9.]*[\s/>]/); const mod = require(path.join(SCREENS_DIR, file)); expect(typeof mod.default).toBe('function'); }); }); }); 

It's ~25 lines, and it has caught a black-screen launch more than once.

Gate 2 — an acceptance-contract gate: no unproven invariants

Instead of hoping people remember to test the important things, you declare them as a machine-checkable contract (contract/acceptance_spec.json):

{ "invariants": [ { "id": "INV-PERSIST-1", "assertion": "settings round-trip through storage unchanged" }, { "id": "INV-STATE-1", "assertion": "changing a setting leaves unrelated fields untouched" } ], "journeys": [ { "id": "J-SETTINGS-1", "name": "settings persist, reload, and reset" } ] } 

Then a gate test fails the build unless every id is referenced by at least one test — and it rejects fake coverage: tests that only assert on literals they defined themselves don't count, and contract-tagged test files must import real code.

// src/__tests__/contract.gate.test.tsx (excerpt) it('every invariant/journey id is proven by a test', () => { const c = JSON.parse(fs.readFileSync(CONTRACT, 'utf8')); const ids = [...c.invariants, ...c.journeys].map((x) => x.id); const missing = ids.filter((id) => !corpusText.includes(id)); expect(missing).toEqual([]); }); 

The effect: "we should test that" becomes a red build. You can't ship an unproven invariant.

The point

Both gates are cheap and boring — which is exactly why they work. They turn quality from a habit into a property of the build. npm run check (strict TS + ESLint + Jest) stays green because it has to.

The free baseline — typed navigation, deep links, theme tokens, MMKV persistence, and native modules mocked for tests — is on GitHub: https://github.com/nwebersoftware/expo-starter

The production gates above, plus CI and local iOS/Android release scripts, are packaged up in a Pro kit — link in the first comment.