Testing on EAS Build: Preview Builds, Environment Configs, and Native Plugin Validation
EAS Build creates native iOS and Android builds in the cloud. For testing, EAS provides three distinct opportunities: preview builds for QA teams, build-time validation of native plugins, and production-equivalent builds for E2E testing.
This guide covers testing strategies across the EAS Build pipeline: structuring build profiles, validating environment configs, testing native plugins, and running automated tests against preview builds.
EAS Build Profiles
eas.json defines your build profiles. Each profile controls the environment your tests run in:
{
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"env": {
"APP_ENV": "development",
"API_URL": "https://api-dev.yourapp.com"
}
},
"preview": {
"distribution": "internal",
"env": {
"APP_ENV": "preview",
"API_URL": "https://api-staging.yourapp.com"
},
"channel": "preview"
},
"production": {
"distribution": "store",
"env": {
"APP_ENV": "production",
"API_URL": "https://api.yourapp.com"
},
"channel": "production"
}
}
}Never use a single build profile for both development and production. Environment config differences (API URLs, feature flags, error reporting) must match what production will see.
Testing Environment Configuration
Before your build ships, validate that the right environment variables are in the right places. Create a build-time validation script:
// scripts/validate-env.ts
import Constants from 'expo-constants'
const requiredEnvVars = ['APP_ENV', 'API_URL'] as const
export function validateBuildEnvironment() {
const errors: string[] = []
const expoConfig = Constants.expoConfig?.extra
for (const key of requiredEnvVars) {
if (!expoConfig?.[key]) {
errors.push(`Missing required env var: ${key}`)
}
}
if (errors.length > 0) {
throw new Error(`Build environment validation failed:\n${errors.join('\n')}`)
}
return {
appEnv: expoConfig.APP_ENV as string,
apiUrl: expoConfig.API_URL as string,
}
}Test this validation:
// scripts/validate-env.test.ts
import { describe, it, expect, jest, beforeEach } from '@jest/globals'
jest.mock('expo-constants', () => ({
default: {
expoConfig: {
extra: {
APP_ENV: 'test',
API_URL: 'https://api.test.com',
},
},
},
}))
import { validateBuildEnvironment } from './validate-env'
describe('validateBuildEnvironment', () => {
it('returns config when all env vars are present', () => {
const config = validateBuildEnvironment()
expect(config.appEnv).toBe('test')
expect(config.apiUrl).toBe('https://api.test.com')
})
it('throws when APP_ENV is missing', () => {
jest.resetModules()
jest.mock('expo-constants', () => ({
default: {
expoConfig: { extra: { API_URL: 'https://api.test.com' } },
},
}))
const { validateBuildEnvironment: validate } = require('./validate-env')
expect(() => validate()).toThrow('Missing required env var: APP_ENV')
})
})Validating Native Plugin Configuration
Expo plugins modify native code at build time (npx expo prebuild). Test that your plugin configuration is correct before starting a cloud build:
// app.config.ts
import { ExpoConfig } from 'expo/config'
export default (): ExpoConfig => ({
name: 'MyApp',
slug: 'my-app',
plugins: [
'expo-notifications',
[
'expo-camera',
{
cameraPermission: 'Allow $(PRODUCT_NAME) to access your camera',
microphonePermission: 'Allow $(PRODUCT_NAME) to access your microphone',
},
],
[
'@sentry/react-native/expo',
{
organization: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
},
],
],
})Test the config output:
// app.config.test.ts
import { describe, it, expect } from '@jest/globals'
describe('Expo app config', () => {
let config: ReturnType<typeof import('./app.config').default>
beforeEach(async () => {
process.env.SENTRY_ORG = 'test-org'
process.env.SENTRY_PROJECT = 'test-project'
jest.resetModules()
config = (await import('./app.config')).default()
})
it('includes required plugins', () => {
const pluginNames = config.plugins?.map((p) => (Array.isArray(p) ? p[0] : p))
expect(pluginNames).toContain('expo-notifications')
expect(pluginNames).toContain('expo-camera')
expect(pluginNames).toContain('@sentry/react-native/expo')
})
it('camera plugin has required permissions', () => {
const cameraPlugin = config.plugins?.find(
(p) => Array.isArray(p) && p[0] === 'expo-camera'
) as [string, Record<string, string>]
expect(cameraPlugin[1].cameraPermission).toContain('camera')
expect(cameraPlugin[1].microphonePermission).toContain('microphone')
})
it('Sentry plugin uses env vars', () => {
const sentryPlugin = config.plugins?.find(
(p) => Array.isArray(p) && p[0] === '@sentry/react-native/expo'
) as [string, Record<string, string>]
expect(sentryPlugin[1].organization).toBe('test-org')
expect(sentryPlugin[1].project).toBe('test-project')
})
})Prebuild Validation in CI
Run expo prebuild in CI to validate that your native configuration generates correctly before triggering an EAS Build:
# .github/workflows/validate-prebuild.yaml
name: Validate Expo Prebuild
on:
pull_request:
paths:
- 'app.config.ts'
- 'eas.json'
- 'package.json'
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm install
- name: Run config tests
run: npm test -- --testPathPattern=app.config
- name: Validate prebuild (dry run)
run: npx expo prebuild --clean --platform ios --no-install
env:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}If expo prebuild fails, the EAS Build would fail too — but catching it in CI is faster and cheaper.
Running E2E Tests Against Preview Builds
Trigger a preview build, then run Maestro tests against it:
# .github/workflows/e2e-preview.yaml
name: E2E Tests (Preview Build)
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
outputs:
build-id: ${{ steps.build.outputs.build-id }}
steps:
- uses: actions/checkout@v4
- uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: Build preview
id: build
run: |
BUILD_ID=$(eas build --platform ios --profile preview --non-interactive --json | jq -r '.id')
echo "build-id=$BUILD_ID" >> $GITHUB_OUTPUT
e2e:
needs: build
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Install Maestro
run: curl -Ls "https://get.maestro.mobile.dev" | bash
- name: Wait for EAS build and download
run: |
eas build:view ${{ needs.build.outputs.build-id }} --wait
eas build:download ${{ needs.build.outputs.build-id }} --output ./build.ipa
- name: Boot simulator and install app
run: |
xcrun simctl boot "iPhone 15 Pro"
xcrun simctl install booted ./build.ipa
- name: Run Maestro tests
run: maestro test .maestro/ --device "iPhone 15 Pro"Testing EAS Update Channels
EAS Update lets you push JavaScript-only updates over the air. Test that OTA updates work for your channel:
# Publish an update to the preview channel
eas update --branch preview --message <span class="hljs-string">"Test update"
<span class="hljs-comment"># Check that the update is available
eas update:list --branch previewTest OTA update handling in your app:
// hooks/useOTAUpdate.ts
import * as Updates from 'expo-updates'
import { useState, useEffect } from 'react'
export function useOTAUpdate() {
const [updateAvailable, setUpdateAvailable] = useState(false)
useEffect(() => {
async function checkForUpdate() {
try {
const update = await Updates.checkForUpdateAsync()
if (update.isAvailable) {
setUpdateAvailable(true)
}
} catch {
// Silently fail in development
}
}
if (!__DEV__) {
checkForUpdate()
}
}, [])
async function applyUpdate() {
await Updates.fetchUpdateAsync()
await Updates.reloadAsync()
}
return { updateAvailable, applyUpdate }
}// hooks/useOTAUpdate.test.ts
import { renderHook, waitFor } from '@testing-library/react-native'
import { describe, it, expect, jest, beforeEach } from '@jest/globals'
jest.mock('expo-updates', () => ({
checkForUpdateAsync: jest.fn(),
fetchUpdateAsync: jest.fn(),
reloadAsync: jest.fn(),
}))
import * as Updates from 'expo-updates'
import { useOTAUpdate } from './useOTAUpdate'
// Disable DEV mode for these tests
const originalDev = global.__DEV__
beforeAll(() => { global.__DEV__ = false })
afterAll(() => { global.__DEV__ = originalDev })
describe('useOTAUpdate', () => {
beforeEach(() => jest.clearAllMocks())
it('sets updateAvailable when update exists', async () => {
jest.mocked(Updates.checkForUpdateAsync).mockResolvedValue({ isAvailable: true } as any)
const { result } = renderHook(() => useOTAUpdate())
await waitFor(() => {
expect(result.current.updateAvailable).toBe(true)
})
})
it('keeps updateAvailable false when no update', async () => {
jest.mocked(Updates.checkForUpdateAsync).mockResolvedValue({ isAvailable: false } as any)
const { result } = renderHook(() => useOTAUpdate())
await waitFor(() => {
expect(Updates.checkForUpdateAsync).toHaveBeenCalled()
})
expect(result.current.updateAvailable).toBe(false)
})
it('fetches and reloads when applyUpdate is called', async () => {
jest.mocked(Updates.checkForUpdateAsync).mockResolvedValue({ isAvailable: false } as any)
const { result } = renderHook(() => useOTAUpdate())
await result.current.applyUpdate()
expect(Updates.fetchUpdateAsync).toHaveBeenCalled()
expect(Updates.reloadAsync).toHaveBeenCalled()
})
})EAS Build Secrets Testing
Environment secrets in EAS should be validated locally before the build:
# List secrets for the project
eas secret:list
<span class="hljs-comment"># Add a secret
eas secret:create --scope project --name SENTRY_AUTH_TOKEN --value <span class="hljs-string">"your-token"
<span class="hljs-comment"># Validate that required secrets exist before triggering a build
eas build --profile production --non-interactive 2>&1 <span class="hljs-pipe">| grep -i <span class="hljs-string">"error\|warning"What Automated Tests Miss
Even with preview builds and E2E tests:
- App Store review — EAS builds pass your CI, but App Store review may reject for policy reasons
- Real device hardware variations — notch, Dynamic Island, button positions affect UI
- Carrier and network edge cases — VPNs, restricted networks, airplane mode handling
- Background/foreground transitions — app lifecycle during a phone call or notification
HelpMeTest runs continuous tests against your EAS preview channel. When an OTA update breaks navigation or a native plugin misconfiguration causes a crash, automated monitoring catches it.
Summary
EAS Build testing strategy:
- Build profiles → separate
development,preview, andproductionprofiles with explicit env vars - Environment config → unit test the validation script and
app.config.tsoutput - Native plugins → run
expo prebuild --cleanin CI before triggering EAS Build - Preview builds → trigger EAS Build in CI, download the artifact, run Maestro E2E tests
- OTA updates → mock
expo-updatesand test update check + apply flow in unit tests - Secrets → use
eas secret:listin CI to verify secrets exist before a build starts
Catching configuration problems in CI is 10x faster than waiting for a cloud build to fail — validate everything you can before pushing to EAS.