Testing on EAS Build: Preview Builds, Environment Configs, and Native Plugin Validation

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 preview

Test 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, and production profiles with explicit env vars
  • Environment config → unit test the validation script and app.config.ts output
  • Native plugins → run expo prebuild --clean in CI before triggering EAS Build
  • Preview builds → trigger EAS Build in CI, download the artifact, run Maestro E2E tests
  • OTA updates → mock expo-updates and test update check + apply flow in unit tests
  • Secrets → use eas secret:list in 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.

Read more