Stratégie Test IDs — Applications Mobile (React Native / Expo)
Convention et guide d'implémentation des identifiants de test pour les tests e2e sur les applications mobile (
mobile-client,mobile-pro).
1. Objectif
Fournir des points d'ancrage stables aux outils de test e2e (Appium, Detox, Maestro) via la prop testID de React Native. Ces identifiants sont indépendants du style, du texte affiché et de la hiérarchie des vues — ils survivent aux refactors UI.
2. Outil e2e recommandé
| Outil | Usage | Sélecteur |
|---|---|---|
| Appium | Tests cross-platform (iOS + Android) | resource-id (Android), accessibilityIdentifier (iOS) |
| Maestro | Tests rapides, scripting YAML | testID directement |
| Detox | Tests grey-box React Native | by.id('testID') |
React Native mappe automatiquement testID vers les attributs natifs de chaque plateforme.
3. Convention de nommage
Format
<screen>.<section>.<element>- screen : nom de l'écran en
kebab-case(ex:send-amount,change-pin,login) - section : zone logique de l'écran (ex:
form,header,keypad,list) - element : élément ciblé (ex:
phone-input,submit-btn,error-text)
Règles
- Tout en minuscules
- Séparateur de niveau : point (
.) - Séparateur de mots : tiret (
-) - Pas de préfixe d'app (
cmms:,pro:) — le contexte de test détermine l'app - Maximum 3 niveaux de profondeur
- Les éléments répétés (listes) utilisent un suffixe dynamique :
list.item-${index}oulist.item-${id}
Exemples
login.form.phone-input
login.form.pin-input
login.form.submit-btn
login.form.error-text
send-amount.keypad.key-5
send-amount.keypad.key-delete
send-amount.display.amount-text
send-amount.quick-amounts.btn-5000
send-amount.action.continue-btn
change-pin.form.current-pin
change-pin.form.new-pin
change-pin.form.confirm-pin
change-pin.form.submit-btn
change-pin.form.error-text
settings.list.item-change-pin
settings.list.item-sessions
settings.list.item-logout
transactions.list.item-${transactionId}
transactions.filter.date-picker
transactions.filter.type-select4. Support testID dans @nex/ui-mobile
Les composants du design system acceptent déjà la prop testID et la propagent sur leurs sous-éléments internes. Pas de wrapper <View testID> nécessaire — passer la prop directement suffit.
Composants composites (propagation par suffixe)
| Composant | testID de base | Suffixes propagés |
|---|---|---|
| NxInput | TextInput | .container (wrapper), .label, .error, .hint |
| NxButton | TouchableOpacity | .label (texte), .loader (spinner) |
| NxPhoneInput | View racine | .country-selector, .number-input, .modal, .country-<code> (ex: .country-cm) |
| NxPageHeader | SafeAreaView | .back-btn, .title |
Composants leaf (forwarding simple)
Les composants suivants acceptent testID et le placent sur leur élément racine : NxText, NxIcon, NxIconButton, NxBadge, NxAvatar, NxImage, NxSvgIcon, NxCard, NxRow, NxColumn, NxScreen.
Exemple d'usage
// Input avec label et erreur
<NxInput
testID="login.form.email-input"
label="Email"
error={emailError}
/>
// => testID="login.form.email-input" sur le TextInput
// testID="login.form.email-input.container" sur le wrapper
// testID="login.form.email-input.label" sur le label
// testID="login.form.email-input.error" sur le message d'erreur
// Sélection d'un pays précis
<NxPhoneInput testID="login.phone.phone-input" ... />
// => testID="login.phone.phone-input.country-cm" sur l'item Cameroun dans la modal
// Bouton avec loader
<NxButton testID="login.form.submit-btn" loading>Valider</NxButton>
// => testID="login.form.submit-btn" sur le TouchableOpacity
// testID="login.form.submit-btn.loader" sur l'ActivityIndicatorRègle
Lors de l'ajout de nouveaux composants dans @nex/ui-mobile, suivre le même contrat :
- Prop
testID?: stringdans l'interface - Base sur l'élément le plus sémantiquement pertinent (TextInput pour un input, TouchableOpacity pour un bouton)
- Sous-éléments importants reçoivent des suffixes descriptifs (
.label,.error,.back-btn, etc.)
5. Implémentation sur les écrans
Exemple : AmountScreen (Send Flow)
// apps/mobile-client/screens/SendFlow/Step3_Amount/AmountScreen.tsx
const AmountScreen: React.FC = () => {
const { transferData, userBalance, setAmount } = useSendFlowStore();
const [amountString, setAmountString] = useState("");
const [error, setError] = useState<string | null>(null);
return (
<SafeAreaView testID="send-amount.screen">
<PageHeader testID="send-amount.header" title="Montant" />
<View testID="send-amount.display">
<ThemedText testID="send-amount.display.amount-text">
{currentAmount > 0 ? formatAmount(currentAmount) : "0"}
</ThemedText>
<ThemedText testID="send-amount.display.currency-text">XAF</ThemedText>
{error && (
<ThemedText testID="send-amount.display.error-text">{error}</ThemedText>
)}
</View>
{/* Montants rapides */}
<View testID="send-amount.quick-amounts">
{quickAmounts.map((item) => (
<TouchableOpacity
key={item.value}
testID={`send-amount.quick-amounts.btn-${item.value}`}
onPress={() => handleQuickAmountSelect(item.value)}
>
<ThemedText>{item.label}</ThemedText>
</TouchableOpacity>
))}
</View>
{/* Clavier numérique */}
<View testID="send-amount.keypad">
{keys.map((key) => (
<TouchableOpacity
key={key}
testID={`send-amount.keypad.key-${key}`}
onPress={() => handleKeyPress(key)}
>
<ThemedText>{key}</ThemedText>
</TouchableOpacity>
))}
</View>
<NxButton
testID="send-amount.action.continue-btn"
variant="primary"
fullWidth
onPress={handleContinue}
>
Continuer
</NxButton>
</SafeAreaView>
);
};Exemple : ChangePinCodeScreen
// apps/mobile-pro/screens/Settings/ChangePinCode/ChangePinCodeScreen.tsx
const ChangePinCodeScreen = () => {
return (
<SafeAreaView testID="change-pin.screen">
<NxPageHeader testID="change-pin.header" title="Changer le code PIN" />
<ScrollView testID="change-pin.form">
<SimpleInput
testID="change-pin.form.current-pin"
label="Code PIN actuel"
type="password"
pinLength={6}
value={currentPin}
onChangeText={setCurrentPin}
/>
<SimpleInput
testID="change-pin.form.new-pin"
label="Nouveau code PIN"
type="password"
pinLength={6}
value={newPin}
onChangeText={setNewPin}
/>
<SimpleInput
testID="change-pin.form.confirm-pin"
label="Confirmer le nouveau code PIN"
type="password"
pinLength={6}
value={confirmPin}
onChangeText={setConfirmPin}
/>
{error && (
<NxText testID="change-pin.form.error-text" variant="caption" color="error">
{error}
</NxText>
)}
<NxButton
testID="change-pin.form.submit-btn"
variant="primary"
fullWidth
loading={isLoading}
disabled={!isFormValid || isLoading}
onPress={handleChangePin}
>
Valider le nouveau PIN
</NxButton>
</ScrollView>
</SafeAreaView>
);
};6. Éléments à cibler en priorité
Ne pas mettre un testID sur tout. Cibler les éléments avec lesquels les tests interagissent :
| Catégorie | Exemples | Priorité |
|---|---|---|
| Champs de saisie | TextInput, PIN input, phone input | Haute |
| Boutons d'action | Submit, continuer, annuler, confirmer | Haute |
| Messages d'erreur | Validation inline, erreurs API | Haute |
| Écrans (container root) | SafeAreaView principal | Haute |
| Éléments de navigation | Tabs, liens, retour | Moyenne |
| Éléments de liste | Lignes de transaction, items de settings | Moyenne |
| Textes d'affichage | Solde, montant, statut | Moyenne |
| Modales | Confirmation, sélection pays | Moyenne |
| Éléments décoratifs | Icônes, séparateurs, badges | Basse (éviter) |
7. Helper utilitaire (optionnel)
Un helper pour assurer la cohérence et simplifier la création de test IDs :
// packages/shared-utils/src/test-ids.ts
/**
* Construit un testID selon la convention screen.section.element
* Usage : buildTestId('login', 'form', 'phone-input') => 'login.form.phone-input'
*/
export function buildTestId(...parts: (string | undefined)[]): string | undefined {
const filtered = parts.filter(Boolean);
if (filtered.length === 0) return undefined;
return filtered.join(".");
}
/**
* Crée un scope de testID pour un écran donné
* Usage :
* const tid = createTestIdScope('login');
* tid('form', 'phone-input') => 'login.form.phone-input'
* tid('form', 'submit-btn') => 'login.form.submit-btn'
*/
export function createTestIdScope(screen: string) {
return (...parts: string[]): string => {
return [screen, ...parts].join(".");
};
}Utilisation dans un écran
const LoginScreen = () => {
const tid = createTestIdScope("login");
return (
<SafeAreaView testID={tid("screen")}>
<NxPhoneInput testID={tid("form", "phone-input")} />
<NxInput testID={tid("form", "pin-input")} />
<NxButton testID={tid("form", "submit-btn")}>Connexion</NxButton>
</SafeAreaView>
);
};8. Stripping en production
React Native ignore testID en production par défaut sur Android. Sur iOS, accessibilityIdentifier reste présent mais n'est pas visible par l'utilisateur.
Si nécessaire, un plugin Babel peut strip les props testID en build de production :
// babel.config.js (optionnel, uniquement si nécessaire)
module.exports = function (api) {
const plugins = [];
if (process.env.NODE_ENV === "production") {
plugins.push(["react-native-production-testid-strip"]);
}
return {
presets: ["babel-preset-expo"],
plugins,
};
};Note : Ce stripping n'est pas recommandé dans un premier temps. Les
testIDont un impact négligeable sur les performances et peuvent être utiles pour le debug en production (crash reporting, analytics).
9. Parcours critiques à couvrir en priorité
- Authentification —
login.*(phone, PIN, biométrie, erreurs) - Transfert P2P —
send-*.*(sélection contact, montant, récap, confirmation) - Cash-in / Cash-out —
cash-in.*,cash-out.* - Consultation solde —
home.wallet.* - Historique transactions —
transactions.list.*,transactions.detail.* - Paramètres sécurité —
change-pin.*,sessions.* - KYC —
kyc.*(upload documents, vérification)