testid-autoinject
v0.3.8
Published
Zero-touch build-time injection of stable testID / data-testid for React Native (Expo) and Next.js. Frontend writes nothing; QA automation (Appium, Playwright) gets stable selectors.
Maintainers
Readme
testid-autoinject
Zero-touch, build-time injection of stable testID / data-testid for
React Native (Expo), Next.js, and Vue 3 (Vite / Nuxt).
Frontend writes nothing. QA automation (Appium, Playwright, Detox, Maestro) gets deterministic selectors that survive refactors.
Demo
Next.js — stable data-testid injected at build time, no source changes:
https://github.com/user-attachments/assets/82bf7b57-7986-465e-9b3a-4dd5f5d48c12
Why
Manually adding test ids is a recurring tax: QA asks, frontend adds, repeat.
Existing auto-injectors solve the labour but generate positional ids
(file__Button_3) that shift the moment anyone reorders JSX — breaking every
hardcoded selector in the automation suite.
testid-autoinject derives ids from stable, human-meaningful signals
(label / accessibility text / element name) instead of source position:
{screen}-{label|element}-{type}
login-submit-button
checkout-email-inputAdd an unrelated element above it — the id does not change.
How it works
- Babel plugin (core) — at build time, injects a stable id onto every
interactive element that lacks one. Runs only in
test/development. - ESLint plugin (safety net) — flags elements the Babel pass cannot reach (third-party components, wrappers that swallow props) and keeps the attribute name consistent. Autofixable.
- CLI scanner — report coverage on any directory without installing anything into the target project.
- ID map export (optional) — emit a
testid-map.json(id → file / element / label). Not a frontend deliverable and not required to obtain ids — it's mainly for detecting id drift in CI. See Discovering ids.
Install
npm add -D testid-autoinjectSetup
React Native / Expo — babel.config.js
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [
['testid-autoinject/babel', { platform: 'native' }],
],
};
};Restart Metro with a clean cache after changing Babel config: expo start -c.
EAS builds set
NODE_ENV=production. Every EAS build —development,preview, andproductionprofiles — runs Babel withNODE_ENV=production, which is not in the defaultenvs(["test","development"]). So with the default config, ids are injected only on your local dev server and silently not in any EAS build. If your QA automation runs against an EAS build, add the environment explicitly:['testid-autoinject/babel', { platform: 'native', envs: ['test', 'development', 'production'] }]To keep ids out of your store release, gate on a signal you control instead of
NODE_ENV— e.g. a custom env var set only for QA/preview builds:// eas.json build profile: "env": { "TESTID": "1" } const plugins = process.env.TESTID ? [['testid-autoinject/babel', { platform: 'native', envs: ['production'] }]] : [];
Next.js — SWC plugin (next.config.ts)
Next.js 13+ compiles with SWC. Adding a .babelrc opts SWC out, which breaks
App Router Server Actions and next/font on Next 15 — so use the SWC plugin
engine instead. It runs inside SWC (nothing is disabled) and works on both
webpack and Turbopack.
// next.config.ts
import { swc } from 'testid-autoinject/swc';
const nextConfig = {
experimental: {
swcPlugins: [swc({ platform: 'web' })],
},
};
export default nextConfig;
swc()picks the wasm that matches your Next.js version automatically:plugin-16.wasm(swc_core 54) for Next 16+, orplugin-15.wasm(swc_core 35) for Next 15.5.x — the wasm's swc_core must match the host's or Next throwsfailed to invoke plugin. Both ship prebuilt in the package, so consumers need no Rust toolchain. The Babel engine above still covers React Native / Expo (Metro uses Babel). All engines share the same id-derivation logic, so ids are identical across them.
Turbopack on Next 15: during
next build --turbopack, Next 15 reportsdevelopment(notproduction) as the env to SWC plugins, so anenvs: ['production']gate silently skips injection. Include'development'inenvsif you want injection in Turbopack production builds on Next 15. Next 16 reports the env correctly.
Vue 3 — Vite (vite.config.ts)
Vue SFC templates are compiled by the Vue template compiler, not Babel/SWC, so
the Vue engine plugs into the compiler's nodeTransforms hook. Sources stay
untouched; the attribute exists only in compiled output:
// vite.config.ts
import vue from '@vitejs/plugin-vue';
import { vueTestId } from 'testid-autoinject/vue';
export default {
plugins: [
vue({
template: {
compilerOptions: { nodeTransforms: [vueTestId()] },
},
}),
],
};Options mirror the Babel plugin (envs, targets, injectAll, stripDirs,
emitMap, cjkFallback); the attribute defaults to data-testid. Id
derivation is shared with the other engines, so
<button @click="handleSubmit">送出</button> in src/pages/login/index.vue
gets login-submit-button — same signals, same priority: label attribute /
static text, then intent mined from @click / @change / @submit, then the
CJK label verbatim.
Notes:
- Manual values win. A static
data-testidor a bound:data-testid(including one arriving throughv-bind="obj") overrides the injected value. - Components get it too. Injecting on a component relies on Vue's attribute
fallthrough to land the id on its root element; components with
inheritAttrs: falseare covered by the ESLint safety net instead. - SSR: apply the transform to both server and client template compilers (in
Nuxt, set it in both
vite.vue.template.compilerOptionssides), otherwise hydration will warn about the mismatching attribute. v-forrows share one id — same limitation as the other engines; see Lists.
Nuxt 3 — nuxt.config.ts
import { vueTestId } from 'testid-autoinject/vue';
export default defineNuxtConfig({
vue: {
compilerOptions: { nodeTransforms: [vueTestId()] },
},
});Babel options
| Option | Default | Description |
|---|---|---|
| platform | "web" | "native" → testID; "web" → data-testid. |
| attribute | from platform | Override the attribute name entirely. |
| envs | ["test","development"] | Only inject when NODE_ENV is one of these. |
| targets | interactive set | Element names to inject on. |
| injectAll | false | Inject on every element, ignoring targets. |
| stripDirs | ["src","app","screens","components","pages"] | Leading path segments dropped from the screen slug. |
| emitMap | false | Write an id → source map. |
| mapFile | <cwd>/testid-map.json | Where to write the map. |
| cjkFallback | true | Keep a non-ASCII label (CJK, …) verbatim as a readable fallback. Set false for ASCII-only ids. |
Manual ids always win — an element that already has the attribute is left untouched, so you can override any generated id by hand.
Non-Latin UIs (CJK)
Visible text on a Chinese/Japanese/Korean UI carries no ASCII signal, so the id name is chosen from the first meaningful source, in order:
- English label attribute / text —
aria-label,name, static text. - English handler intent — mined from
onClick/onChange/onSubmit: the call argument (onClick={() => setDateType("today")}→today), the handler name (onClick={handleSubmit}→submit), or the receiver (onClick={datePicker.open}→date-picker). This is where most CJK buttons get a clean English id, because handlers are already named in English. - The label text itself, preserved verbatim (
login-登入-button) — readable and stable.data-testid, Appium ids, and Playwright locators accept Unicode.
An id never degrades to a positional counter while any of these signals exist.
ESLint (flat config)
import { recommended } from 'testid-autoinject';
export default [
recommended('native'), // or 'web'
];Vue templates
recommendedVue() lints <template> blocks in .vue files (requires
vue-eslint-parser):
import { recommendedVue } from 'testid-autoinject';
export default [
recommendedVue(),
];The vue-require-testid rule flags interactive elements missing the attribute
— typically components with inheritAttrs: false that the compile-time
injector's attribute fallthrough cannot reach. Its autofix inserts the same
derived {screen}-{label|element}-{type} id the injector would produce, so
fixed sources and injected builds agree on selectors.
Spread-bearing elements and allowSpread
require-testid defaults to allowSpread: true, so it skips any element
carrying a {...props} spread — on the assumption the spread may already supply
the attribute. The injector makes no such assumption: it injects into those
elements anyway. So a clean lint run does not guarantee the output is free of
generated ids, and a shared spread-bearing component renders the same id at
every call site — a strict-mode violation for Playwright that the lint output
never points at.
If you want a green lint to mean "no generated ids slipped through", override the
rule with allowSpread: false after the preset:
import { recommended } from 'testid-autoinject';
export default [
recommended('web'),
{
rules: {
'testid/require-testid': ['warn', { attribute: 'data-testid', allowSpread: false }],
},
},
];This surfaces every spread-bearing target that lacks an explicit id, including
shared wrappers and cases where a {...register(...)}-style spread would
otherwise hide an injected id overriding one the component computes internally.
CLI
npx testid-scan ./src --platform web
npx testid-scan ./app --platform native --attribute testID
npx testid-scan ./src --json > testid-gaps.json| Flag | Default | Description |
|---|---|---|
| --platform native\|web | web | Picks the default attribute (testID / data-testid). |
| --attribute <name> | from platform | Check a different attribute. |
| --severity warn\|error | preset level | Force one level for every rule. |
| --include-tests | off | Also scan *.test.*, *.spec.*, __tests__/, __mocks__/. |
| --json | off | Print the report as JSON for CI. |
Exit code is 0 when clean or warnings only, 1 when any gap is at error
level, and 2 for bad flags. Files the parser cannot read are listed as
skipped rather than silently ignored.
Discovering ids
Injected ids live in the running app — there is nothing the frontend has to hand over. QA (or a test-authoring agent) can read them with whatever tool fits their workflow; these are equivalent options, none is required:
- Appium Inspector — inspect a native element and read its accessibility id.
- Browser DevTools — inspect an element and read its
data-testid. - Playwright codegen — record interactions and let it emit locators.
- MCP live-grab — an agent reads the live DOM / native tree and lists ids.
testid-map.json— an optional offline list, handy for CI drift checks.
Because ids are stable and follow {screen}-{label|element}-{type}, they can
often be predicted from the screen without inspecting anything.
Ids only exist in a
test/developmentbuild — point your tooling at that, not a production build.
Locating elements in tests
The injected ids are ordinary selectors. Point your automation at the same
attribute the plugin wrote (data-testid on web, testID on native).
Playwright (web — data-testid)
getByTestId resolves data-testid by default, so it matches the plugin's web
output with no extra config:
import { test, expect } from '@playwright/test';
test('submits the checkout form', async ({ page }) => {
await page.goto('/checkout');
// id shape: {screen}-{label|element}-{type}
await page.getByTestId('checkout-email-input').fill('[email protected]');
await page.getByTestId('checkout-go-button').click();
await expect(page.getByTestId('checkout-success-text')).toBeVisible();
});Using a different attribute (e.g. you set
attribute: 'data-qa')? Tell Playwright once inplaywright.config.ts:use: { testIdAttribute: 'data-qa' }.
Appium + WebdriverIO (native — testID)
React Native maps testID to the iOS accessibility identifier and the
Android resource-id. The portable selector is the accessibility-id form
(~id); it works as-is on iOS and on Android once the id reaches the native
tree:
import { remote } from 'webdriverio';
const driver = await remote({
capabilities: {
platformName: 'iOS', // or 'Android'
'appium:automationName': 'XCUITest', // 'UiAutomator2' on Android
'appium:app': '/path/to/app',
},
});
// `~` is the accessibility-id selector — matches the injected testID.
await driver.$('~login-email-input').setValue('[email protected]');
await driver.$('~login-submit-button').click();
await driver.$('~login-success-text').waitForDisplayed();
await driver.deleteSession();Android fallback. If
~iddoes not match, the id likely arrived as aresource-idinstead of a content-desc. Match it explicitly:await driver.$('android=new UiSelector().resourceId("login-submit-button")').click();This is the same forwarding issue described below — a wrapper component that drops
testIDbefore the native element will be invisible to either selector.
The one thing that still needs a human
Custom wrapper components must forward the injected attribute to the
underlying native element, or Appium won't see it in the native tree. This is a
one-time fix per shared component — the require-testid rule flags where it's
needed.
Lists: rows share one id
Injection is a build-time transform, so a .map() — one JSX node in source —
gets one literal id, and every rendered row shares it:
// source
items.map((it) => <Button onPress={() => go(it)}>{it.name}</Button>)
// after the plugin — the SAME id on every row
items.map((it) => <Button testID="orders-list-go-button" onPress={() => go(it)}>{it.name}</Button>)This is not fixable at build time: per-row uniqueness lives in runtime data
(it.id), which the AST cannot see. Two ways to handle it:
Scope the shared id at test time — anchor to the row, then find the control inside it (no app change):
// Playwright — scope by row content const row = page.getByRole('listitem').filter({ hasText: 'Order #42' }); await row.getByTestId('orders-list-go-button').click(); // …or by position await page.getByTestId('orders-list-go-button').nth(2).click(); // Appium/WebdriverIO: (await driver.$$('~orders-list-go-button'))[2].click()Set a per-row id by hand when you need a stable unique selector. Manual ids always win, so the plugin leaves it alone:
items.map((it) => <Button testID={`orders-list-go-${it.id}`} onPress={() => go(it)}>{it.name}</Button>)
License
MIT
