跳到主要内容

React Native test 应该在能真正观察到行为的最便宜层,证明一项产品行为。Reducer 可以证明 rollback logic。React Native Testing Library 可以证明一个人按下 control 后看到 error。只有 device 能证明 keyboard 没有盖住 button、Android Back 回到正确画面,或 camera permission sheet 行为正确。

错误不是「unit tests 太少」或「E2E tests 太少」。是要求一个 test environment 去证明它看不见的事。

text
pure function       domain transition
rendered component  semantics + local interaction + async UI
router harness      route contract + deep-link entry
device binary       native integration + operating-system behavior
human               usability + assistive technology + visual quality

这篇 note 对准 Expo SDK 57、React Native 0.86.2、React 19.2,以及 New Architecture——与 深入理解 React Native 同一条 baseline。要覆盖的 UX risks 见 如何改善 Mobile Development 的 User Experience。Red–Green–Refactor 见 Frontend Engineering 中的 Test-Driven Development。这篇是 React Native 的实作 playbook:Jest 与 React Native Testing Library 做快速 behavior tests,MSW 守 HTTP boundary,精简的 Maestro suite 打可安装的 app。


1. 给每一层一个工作

从 claim 出发,再选能拒绝坏实作的最小 environment。

ClaimCheapest useful layerWhat it cannot prove
Rejecting a mutation restores the previous favorite valuePure unitWhat the screen renders
Pressing Favorite exposes busy, success, and error statesRNTL componentNative animation, pixels, or real network
/orders/42 opens the order routeExpo Router harnessUniversal/App Link association
Android Back returns from detail to the order listMaestro on AndroidWhether the task makes sense to a person
Camera denial leaves a manual-upload pathDevice testWhether the explanation earns trust
VoiceOver can complete checkoutHuman accessibility passNothing smaller can replace it

Suite 的中心应该是 behavior tests

  • Pure domain rules 用直接的 unit tests。
  • 多数 feature 信心来自 render 画面,再用 roles、names、text、state 去操作它。
  • 少量 device journeys 证明 native seams。

不要在每一层重复同一个 assertion。如果 unit test 已经彻底证明 currency formatter,Maestro flow 只需要看到 price 存在——不必用 device 重算每一个 rounding case。

Failure: 一座有几千个 mocked hook tests 和三个 device tests 的金字塔,却没有任何 test 曾经 render 使用者看到的 states。


2. 最小的 Expo Test Setup

用 Expo 的 version resolver,让 Jest 与 React Native dependencies 对上已安装的 SDK。

sh
npx expo install jest jest-expo @types/jest @testing-library/react-native --dev
npm install --save-dev msw

jest-expo 提供 React Native transforms,并 mock Expo SDK 的 native 那一半。React Native Testing Library 提供 render、queries、user interactions,以及内建 Jest matchers。不要再加已弃用的 @testing-library/jest-native;现在的 RNTL 在 import package 时就暴露 matchers。

一小段 config 就够开始:

jest.config.js
module.exports = {
  preset: "jest-expo",
  setupFilesAfterEnv: ["<rootDir>/test/setup.ts"],
  clearMocks: true,
}
package.json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:ci": "jest --ci --runInBand"
  }
}

当 app 有明确的 TypeScript types list 时,把 "jest" 加进 compilerOptions.types。把 tests 留在 Expo Router 的 app/ 目录外;app/ 里每个 file 都会被当成 route。

只有在已安装的 dependency 真的 transform 失败时,才加 transformIgnorePatterns。Expo 为 npm、pnpm、Bun 各自文档了 patterns,它们不可互换。一段把整个 node_modules 都 transpile 的复制 regex,可以把十秒的 suite 变成一分钟。

官方 setup 参考是 Expo unit testingRNTL quick start

Failure: 用手工 web/JSDOM config 取代 jest-expo。React Native 不 render DOM,test environment 就不再对上 app 用的 host components。


3. 一个 Feature,四个可观察 States

用一个 order detail 当 throughline:

  1. Order 载入时 Favorite 是关的。
  2. 按下 Favorite 立刻更新 control。
  3. 成功的 request 让它维持开,并显示 Saved。
  4. 失败的 request 把它滚回去,并露出 Retry。

Component 应该用 semantics 表达那些 states,而不只靠颜色。

tsx
<>
  <Pressable
    accessibilityRole="switch"
    accessibilityLabel="Favorite order"
    accessibilityState={{
      checked: favorite,
      disabled: saving,
      busy: saving,
    }}
    disabled={saving}
    onPress={toggleFavorite}
    testID="favorite-toggle"
  >
    <Text>{favorite ? "Favorited" : "Favorite"}</Text>
  </Pressable>

  {message ? <Text accessibilityRole="alert">{message}</Text> : null}
</>

accessibilityRole、label、state 构成 component 的公开 interaction contract。RNTL 可以 query 它。VoiceOver 与 TalkBack 可以 announce 它。Maestro 可以在 localized build 用稳定的 testID。一个 accessible component 服务三者,而不是创造绕过 user interface 的 test-only API。

这个 testID 是刻意的,因为这个 control 坐在关键 device journey 上。它不是把 testID 放到每个 wrapper View 的理由。

Failure: 在 hook 里测 favorite === true,可见 control 仍然写 Favorite,并暴露 checked: false


4. 把确定的 Transitions 放进 Pure Functions

Optimistic UI 有一段独立于 React Native 的 state transition。那一部分不用 render 就能测。

ts
type FavoriteState = {
  favorite: boolean
  previous: boolean | null
  status: "idle" | "saving"
}

export function beginFavoriteChange(state: FavoriteState): FavoriteState {
  return {
    favorite: !state.favorite,
    previous: state.favorite,
    status: "saving",
  }
}

export function rejectFavoriteChange(state: FavoriteState): FavoriteState {
  return {
    favorite: state.previous ?? state.favorite,
    previous: null,
    status: "idle",
  }
}
ts
describe("favorite transition", () => {
  test("rolls back to the value before the optimistic change", () => {
    const initial: FavoriteState = {
      favorite: false,
      previous: null,
      status: "idle",
    }

    const saving = beginFavoriteChange(initial)
    const rejected = rejectFavoriteChange(saving)

    expect(saving).toEqual({
      favorite: true,
      previous: false,
      status: "saving",
    })
    expect(rejected).toEqual(initial)
  })
})

这个 test 快,因为它不拥有 renderer、provider、network、clock。如果 transition 更丰富,为 starting on、starting off、invalid events 加 table-driven cases。

不要只为了增加 unit-test 数量而抽出 function。抽出时机是 function 命名一段 domain transition、拿掉不可能的 states,或被共享。一行 setFavorite(!favorite) wrapper 不是有用的 module。

Failure: mock useState、直接呼叫 component function,然后 assert setter 收到 true。那测的是 React wiring,不是产品行为。


5. 透过与 Production 相同的 Providers Render

Feature tests 应该用一个小的 render harness,每个 test 建立新鲜的 providers。共享的 global query client 会在 tests 之间泄漏 cache 与 mutation state。

tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { render } from "@testing-library/react-native"

export async function renderOrder(orderId = "42") {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
      mutations: { retry: false },
    },
  })

  return render(
    <QueryClientProvider client={queryClient}>
      <OrderScreen orderId={orderId} />
    </QueryClientProvider>
  )
}

Retries 在 production 有用,在确定的 error test 里有害:assertion 会等过它没有要求验证的 policies。在 harness 关掉它们,如果 retry behavior 本身是产品承诺,再写另一个 test。

Provider wrappers 应该保住 production behavior,只替换外面的 boundaries:

  • 保留真实的 component tree、state machine、query client、formatter。
  • 在 seams 替换 network responses、time、secure storage、OS capabilities。
  • 每个 test 建立新鲜的 mutable stores 与 caches。

如果每个 test 都手动 nest 五个 providers,把它们放进 renderApp。如果 renderApp 有二十个 options 能创造不可能的 production states,它已经变成第二个 application。

Failure: mock useOrder() 回传每个 state。Test 证明四个 hardcoded objects 会 render,但不证明 screen 从真实 requests 与 interactions 到达那些 states。


6. 用 MSW 控制 HTTP Boundary

Mock Service Worker 拦截 request,而不是替换 API hook。Screen 仍然 serialize 真实 request、parse 真实 response、更新真实 cache,再 render 结果。

对隔离的 Jest/RNTL tests,用 MSW 的 Node integration:

test/server.ts
import { http, HttpResponse } from "msw"
import { setupServer } from "msw/node"

export const handlers = [
  http.get("https://api.example.com/orders/:orderId", ({ params }) => {
    return HttpResponse.json({
      id: params.orderId,
      title: "Order 42",
      favorite: false,
    })
  }),

  http.put(
    "https://api.example.com/orders/:orderId/favorite",
    async ({ request }) => {
      const body = await request.json()
      return HttpResponse.json(body)
    }
  ),
]

export const server = setupServer(...handlers)
test/setup.ts
import { server } from "./server"

beforeAll(() => server.listen({ onUnhandledRequest: "error" }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

onUnhandledRequest: "error" 把意外的真实 request 变成 test failure。resetHandlers() 拿掉 per-test overrides,让 offline test 不能毒害它之后的 test。

MSW 有两个 environments:

  • Jest/RNTL: msw/node,因为 test 在 Jest process 执行。
  • 正在跑的 React Native app: msw/native,配 React Native polyfills,当 development build 刻意打 mock handlers。

不要把 msw/node import 进 app bundle;Node 的 http module 在 device 上不存在。这个区分见 MSW's React Native integration

Failure: 在 file 顶部 jest.mock("../use-order")。每个 test 现在都绕过 request shape、response parsing、cache updates,以及最可能漂掉的那条 boundary。


7. 测这个人做什么、看到什么

RNTL 的 userEvent 发出逼真的 host interaction sequence。当它支持该 action 时,优先于 fireEvent

tsx
import { screen, userEvent, waitFor } from "@testing-library/react-native"
import { delay, http, HttpResponse } from "msw"
import { server } from "../test/server"

test("favorites an order and confirms the save", async () => {
  server.use(
    http.put("https://api.example.com/orders/:orderId/favorite", async () => {
      await delay(250)
      return HttpResponse.json({ favorite: true })
    })
  )

  await renderOrder()
  const user = userEvent.setup()
  const favorite = await screen.findByRole("switch", {
    name: "Favorite order",
  })

  expect(favorite).not.toBeChecked()

  await user.press(favorite)

  expect(favorite).toBeChecked()
  expect(favorite).toBeDisabled()
  expect(await screen.findByText("Saved")).toBeOnTheScreen()

  await waitFor(() => expect(favorite).toBeEnabled())
})

Delayed handler 在不伸进 component 的情况下,创造可观察的 saving state。Test 断言 interaction contract:

  • Control 能用 role 与 accessible name 找到。
  • Optimistic state 出现。
  • Saving 期间禁止重复提交。
  • Server confirmation 变得可见。
  • Control 再次可用。

user.press() 是 asynchronous。要 await。现在的 RNTL user events 会重现 pressInpressOut,以及 native 最短 press duration;直接 fireEvent.press() 只呼叫 handler。

Failure: fireEvent(toggle, "onPress"),然后检查 toggle.props.style.opacity。Test 绕过 host interaction,把自己锁在视觉 implementation detail。


8. 让 Failure 与 Offline 成为一等 Tests

Error path 不是换了 status code 的 success test。它有自己的产品承诺:滚回、保住 context、解释失败了什么、提供 recovery。

tsx
test("rolls back and exposes retry when saving fails", async () => {
  server.use(
    http.put("https://api.example.com/orders/:orderId/favorite", () =>
      HttpResponse.error()
    )
  )

  await renderOrder()
  const user = userEvent.setup()
  const favorite = await screen.findByRole("switch", {
    name: "Favorite order",
  })

  await user.press(favorite)

  expect(await screen.findByRole("alert")).toHaveTextContent(
    "Could not update favorite. Try again."
  )
  expect(favorite).not.toBeChecked()
  expect(screen.getByRole("button", { name: "Try again" })).toBeEnabled()
})

不同 failure classes 用不同 MSW responses:

ts
server.use(
  http.get("https://api.example.com/orders/:orderId", () => {
    return new HttpResponse(null, { status: 401 })
  })
)

server.use(
  http.get("https://api.example.com/orders/:orderId", async () => {
    await delay(5_000)
    return HttpResponse.json(order)
  })
)

server.use(
  http.get("https://api.example.com/orders/:orderId", () => {
    return HttpResponse.error()
  })
)

那些代表 unauthorized、slow、network failure。它们不该全部 render「Something went wrong。」

现在就存在的东西用 getBy*,断言不存在用 queryBy*,会异步出现的用 findBy*。当 assertion 不能自然写成一个 findBy query 时才用 waitFor,例如 control 变成 enabled。

永远不要加 await new Promise(resolve => setTimeout(resolve, 1000)) 来「让 React settle」。等可见结果。Fake timers 属于实际 timer policy 的 tests,或作为 userEvent.setup 的 clock;它们不是未知异步工作的解药。

Failure: 把 suite-wide timeout 调大,直到一个没有 await 的 mutation 不再常常失败。


9. 把 Routes 当 Routes 测

Expo Router 的 expo-router/testing-library 建立 in-memory file system,并加上 route matchers。它可以证明一次 press 改变 route,以及 deep link 解析到预期画面。

tsx
import { renderRouter, screen } from "expo-router/testing-library"
import { Link } from "expo-router"
import { Text } from "react-native"
import { userEvent } from "@testing-library/react-native"

test("opens an order from the list", async () => {
  await renderRouter(
    {
      index: () => <Link href="/orders/42">Open Order 42</Link>,
      "orders/[orderId]": () => <Text>Order detail</Text>,
    },
    { initialUrl: "/" }
  )

  const user = userEvent.setup()
  await user.press(screen.getByRole("link", { name: "Open Order 42" }))

  expect(screen).toHavePathname("/orders/42")
  expect(screen.getByText("Order detail")).toBeOnTheScreen()
})

Deep-link entry 是同一条 route,只是起始 URL 不同:

tsx
test("resolves a direct order URL", async () => {
  await renderRouter(
    {
      index: () => <Text>Orders</Text>,
      "orders/[orderId]": () => <Text>Order detail</Text>,
    },
    { initialUrl: "/orders/42" }
  )

  expect(screen).toHavePathname("/orders/42")
  expect(screen.getByText("Order detail")).toBeOnTheScreen()
})

这证明 JavaScript route contract。它不证明 https://example.com/orders/42 与 iOS app 关联、Android 验证了 domain,或其他 app 不能占领 custom scheme。那些需要已安装的 binary 与 operating-system configuration。

小的 navigation contract 用 inline routes。当 layouts、auth guards、nested groups 是行为的一部分时,用 fixture directory。把 test files 留在 app/ 外。见 Expo Router testing

Failure: mock router.push 并 assert 它被叫了一个 string。Destination 可能不存在、可能在错误 layout 下解析,或立刻 redirect。


10. 在 Capability Seam Mock Native Modules

Jest 打不开 Keychain、显示不了 iOS permission sheet、也无法把 app 移到 background。Mock capability 来测周围的 JavaScript policy,然后为 native contract 保留 device coverage。

tsx
import * as SecureStore from "expo-secure-store"

jest.mock("expo-secure-store", () => ({
  getItemAsync: jest.fn(),
  setItemAsync: jest.fn(),
  deleteItemAsync: jest.fn(),
}))

test("restores a refresh token into the session flow", async () => {
  jest.mocked(SecureStore.getItemAsync).mockResolvedValue("refresh-token")

  await renderSessionGate()

  expect(await screen.findByText("Orders")).toBeOnTheScreen()
  expect(SecureStore.getItemAsync).toHaveBeenCalledWith("auth.refresh")
})

那个 test 证明 app 问了预期的 key,并处理回传的 token。它不证明 Keychain accessibility class、Android Keystore behavior、backup policy、biometric prompts,或 process death 之后的 persistence。

Permissions 用同一条 boundary:

ts
type CameraCapability = {
  getStatus(): Promise<"undetermined" | "granted" | "denied" | "blocked">
  request(): Promise<"granted" | "denied">
  openSettings(): Promise<void>
}

Inject 或 mock CameraCapability 来证明:

  • Undetermined 只在这个人点 Scan receipt 之后才问。
  • Granted 打开 scanner。
  • Denied 留下 manual upload。
  • Blocked 提供 Settings action,而不是永远 request。

然后安装 app,证明真实的 system sheet、从 Settings 回来的 path,以及平台特定的 status mapping。

Expo Modules 可以从 module 的 mocks/ directory 出货 mocks,jest-expo 会为 requireNativeModule resolve 它们。App-owned adapters 仍然有用,因为它们命名产品 capability,而不是在整个 feature 暴露 vendor API。

Failure: 永远回 Granted 的 native mock。Permission code「被覆盖了」,但每个 denial 与 recovery branch 都是死的。


11. 透过 Public Interface 选 Elements

Selector 品质决定 refactor 是因为产品理由还是 tree-shape 理由而弄坏 tests。

用这个顺序:

  1. Role + accessible name: getByRole("button", { name: "Try again" })
  2. Role + state: getByRole("switch", { checked: true })
  3. Visible text 或 display value: 这个人读或打的内容。
  4. Placeholder 或 accessibility hint: 只有当那就是实际 interface。
  5. testID: 稳定的 device-automation seam,或没有有用 public selector 的 element。
  6. Props/tree traversal: 最后手段。

RNTL role queries 需要 accessibility element。TextTextInputSwitch 是 accessible hosts。Pressable 提供 accessible host。普通 View 需要 accessible

tsx
expect(
  screen.getByRole("switch", {
    name: "Favorite order",
    checked: true,
  })
).toBeChecked()

失败的 role query 可以揭露真正的 accessibility defect。失败的 getByTestId("button-7") 通常只揭露 implementation identifier 变了。

Maestro 有不同压力:localized visible text 会随语言变,所以稳定的 testID 值适合放在关键 journey boundaries。用 favorite-togglecheckout-submit 这种名字,不是 blue-button-right 这种 styling 或位置。

除非坐标本身就是被测的行为,永远不要用 screen coordinates 选。Device size、font scale、keyboard、localization 会把它移走。

Failure: 给每个 nested ViewtestID,并对 React Native 可能在 mount 前 flatten 的 component tree 建 tests。


12. Snapshot Contracts,不是 Screens

宽的 React Native snapshot 会 serialize host nodes、styles、wrapper views、provider output、implementation details。Screen 变大它就变大。Reviewers 不再读它。更新它变成用按键批准。

优先明确行为:

tsx
expect(screen.getByRole("heading", { name: "Order 42" })).toBeVisible()
expect(screen.getByRole("switch", { checked: false })).toBeEnabled()
expect(screen.queryByRole("alert")).not.toBeOnTheScreen()

当 serialization 本身就是 contract 时,窄的 snapshot 可以占一个位置:

ts
expect(orderRoute("42")).toMatchInlineSnapshot(`
  {
    "params": {
      "orderId": "42",
    },
    "pathname": "/orders/[orderId]",
  }
`)

即使那样,toEqual 可能更清楚。规则不是「snapshots 被禁止」。规则是人必须能解释 snapshot 保护了什么语义变化。

Screenshots 与 visual regression 是另一回事。Device screenshot 可以抓住 clipping、overlap、platform rendering changes,serialized React tree 做不到。视觉主张用视觉工具。

Failure: 一份 2,000 行的 screen snapshot,唯一有意义的变化是 Retry 消失了。


13. 让 Maestro 保持 Thin 与 Native

Maestro 透过 native accessibility layer 驱动已安装的 app。它证明 JavaScript、Fabric、native views、navigation、operating system 一起工作。它不需要 app 里的 npm dependency。

只在 journey 需要的地方暴露稳定 ids:

tsx
<Pressable
  accessibilityRole="button"
  accessibilityLabel={`Open ${order.title}`}
  onPress={() => router.push(`/orders/${order.id}`)}
  testID={`order-${order.id}`}
>
  <OrderSummary order={order} />
</Pressable>

完整的关键 journey 保持短:

.maestro/order-favorite.yaml
appId: ${APP_ID}
---
- launchApp:
    clearState: true
- assertVisible: "Orders"
- tapOn:
    id: "order-42"
- assertVisible: "Order 42"
- tapOn:
    id: "favorite-toggle"
- assertVisible: "Saved"

对平台特定 app ids 跑同一条 flow:

sh
maestro test -e APP_ID=com.example.orders .maestro/order-favorite.yaml

Assertions 是 outcomes,不是 sleeps。Maestro 等 UI 变稳定;固定 delays 通常让 flow 更慢,而且仍然 flaky。

用聚焦的 flow 证明已安装的 deep link:

.maestro/order-deep-link.yaml
appId: ${APP_ID}
---
- launchApp:
    clearState: true
- openLink: "https://example.com/orders/42"
- assertVisible:
    id: "order-detail-screen"
- assertVisible: "Order 42"

App-only 的 order-detail-screen id 避免 URL 打开也写「Order 42」的 browser page 时 flow 通过。那跨越 renderRouter 看不见的 OS link association。

把 Android Back 放在聚焦的 Android-only flow:

.maestro/order-android-back.yaml
appId: ${APP_ID}
---
- launchApp
- tapOn:
    id: "order-42"
- assertVisible:
    id: "order-detail-screen"
- back
- assertVisible: "Orders"

平台特定 flows 只在它们描述的 host 上跑。Maestro 的 back command 行使 Android 真实 back path;iOS navigation 应该用可见的 back control,或另做 gesture-focused test。

优先用 native modules 对上 production 的 development 或 internal distribution build。Expo Go 对开发有用,但跑在 Expo 的 container 里,所以不能用 app 自己的 id launch,也不能证明最终 native surface。

Maestro 是这里的 default,因为它是 black-box、cross-platform、便宜采用。Detox 是有更深 synchronization 与 programmatic control 的 instrumented 替代。当 app 的复杂度需要那种 control 时才选它——不是因为维护两套 E2E stacks 看起来全面。

Failure: 把每个 unit 与 component case 重建成 Maestro flow。Suite 变慢、data-heavy、无法诊断。


14. Device Tests 拥有 Native Seams

RNTL render 一棵 React Native tree。它不 boot UIKit 或 Android Views。为失败点在 JavaScript 外的行为保留 device evidence:

  • Keyboard appearance、dismissal、input accessories、inset adjustment。
  • iOS edge swipe、Android hardware/predictive Back、互相竞争的 gestures。
  • Camera、photos、notifications、biometrics、permission recovery。
  • Universal Links、App Links、custom schemes、push-open routing。
  • Secure storage persistence、logout cleanup、process death。
  • Background/resume、interrupted uploads、reconnect。
  • Reduced motion、dynamic type、TalkBack、VoiceOver focus。
  • Native crashes、ANRs、startup、image decode、UI-thread performance。

其中一些可以 device-automated。有些仍需要手动测试。「在 simulator 上跑」不等于「在实体装置上与 screen reader 一起工作」。

完整 condition matrix 见 如何改善 Mobile Development 的 User Experience。可安装 builds、signing、release gates 见 React 与 React Native 的 Frontend CI/CD。这篇 note 的 device suite 专注 test design。

Failure: assert KeyboardAvoidingView 存在,就称 keyboard bug 被测过。


15. 把 Flakes 当成 Test System 的 Defects

Flaky test 有不受控的 input:time、state、network、animation、data、order、device,或与产品赛跑的 assertion。

这样 triage:

  1. 单独重现失败,以及在前一个 test 之后重现。
  2. 找出不受控的 boundary。
  3. 用可观察 outcome 取代固定等待。
  4. Reset server handlers、caches、storage、app state。
  5. Seed 确定的 account 或 API fixture。
  6. 失败时捕捉 device logs 与 screenshots。
  7. 只有带 owner、reason、expiry 才 quarantine。

Retries 可以暴露机率。它们不能让坏讯号变得可信。第三次 attempt 才通过的 release gate,教团队忽略红灯。

对 device journeys:

  • 给每条 flow 独立 setup;不要依赖执行顺序。
  • 对 localized 或 dynamic controls 用稳定 ids。
  • Keyboard 占住画面时,明确 hide 或 handle。
  • 透过 API 或 fixture boundary 控制 test data,不是透过十个 setup screens。
  • 每次重要 mutation 之后 assert 产品结果。

Failure: 全域加 retry: 3,然后把最后一次绿灯当成证据。


16. Coverage 是地图,不是目标

Line coverage 说 code 执行了。它不说 assertion 能侦测到 bug。

有用的 review 问:

  • 哪些 money、identity、authorization、privacy、data-loss paths 变了?
  • 这个 feature 能进入哪些可见 states?
  • 哪些 transitions 能失败或被打断?
  • 哪些 claims 属于 JavaScript,哪些属于 native host?
  • 每个 test 会不会因为 title 里那个 bug 而失败?

用 coverage reports 找没被造访的 branches。不要写空 tests 把百分比变绿。一个 rollback transition 的直接 test,比 render 十个 screens 只 assert 它们不 throw 更有价值。

Test names 应该陈述产品承诺:

text
rolls back favorite when the save fails
opens a blocked permission path in Settings
preserves the draft after an unauthorized refresh
returns to the order list with Android Back

不是:

text
calls handler
updates state
renders correctly
works

更广的 regression strategy——blast radius、layer selection、什么赚到 E2E coverage——见 Frontend Regression Tests

Failure: 提高 coverage threshold,而没测到的 branch 正是唯一能弄丢使用者 draft 的那条。


Takeaway

React Native testing strategy 是一组 boundaries。

  1. 规则能否在没有 React 的情况下跑? 直接测 pure transition。
  2. Claim 能否透过 rendered interface 看见? 用 RNTL、semantic queries、userEvent、可观察的 async states。
  3. Feature 有没有跨 HTTP? 用 MSW 拦截 request,而不是 mock hook。
  4. Navigation 是不是行为本身? Render Expo Router file system,并 assert route。
  5. JavaScript 是否依赖 OS capability? Mock capability 测 policy;用 device 测 capability。
  6. Claim 是否需要 UIKit、Android Views,或 OS? 放进小型 Maestro journey 或手动 device pass。
  7. Test 在等时间还是等行为? 等行为。
  8. Assertion 能否撑过保住产品的 refactor? 如果不能,它耦合了 implementation。

目标不是最大的 suite。是让坏掉的产品承诺很难出货的最小 suite。


Recap Q&A