Stratégie Test IDs — Applications Web (Nuxt / Nuxt UI)
Convention et guide d'implémentation des identifiants de test pour les tests e2e sur les dashboards web (
cmms,backoffice).
1. Objectif
Fournir des sélecteurs stables aux outils de test e2e (Selenium, Playwright, Cypress) via l'attribut data-testid. Ces identifiants sont indépendants du CSS, du texte affiché et de la structure DOM — ils survivent aux changements de design et aux refactors de composants.
2. Outils e2e compatibles
| Outil | Sélecteur | Exemple |
|---|---|---|
| Selenium | CSS / XPath | driver.find_element(By.CSS_SELECTOR, '[data-testid="login.form.submit-btn"]') |
| Playwright | getByTestId | page.getByTestId('login.form.submit-btn') |
| Cypress | CSS selector | cy.get('[data-testid="login.form.submit-btn"]') |
Tous ces outils supportent l'attribut HTML standard data-testid.
3. Convention de nommage
Format
<page>.<section>.<element>- page : nom de la page/route en
kebab-case(ex:cash-in,agents,login,dashboard) - section : zone logique de la page (ex:
form,table,modal,filters,header) - element : élément ciblé (ex:
phone-input,submit-btn,row-${id},search-input)
Règles
- Tout en minuscules
- Séparateur de niveau : point (
.) - Séparateur de mots : tiret (
-) - Pas de préfixe d'app — le contexte de test (URL, config) détermine l'app
- Maximum 3 niveaux de profondeur
- Les éléments répétés (lignes de table) utilisent un suffixe dynamique :
table.row-${id}outable.row-${index}
Exemples
login.form.email-input
login.form.password-input
login.form.submit-btn
login.form.error-alert
cash-in.form.amount-input
cash-in.form.phone-input
cash-in.form.description-input
cash-in.form.submit-btn
cash-in.form.cancel-btn
cash-in.modal.confirm-btn
cash-in.modal.cancel-btn
cash-in.modal.pin-input
cash-in.success.amount-text
cash-in.success.reference-text
cash-in.success.close-btn
agents.table.search-input
agents.table.row-${agentId}
agents.table.row-${agentId}.status-badge
agents.table.row-${agentId}.actions-btn
agents.table.pagination-next
agents.table.pagination-prev
dashboard.wallet.balance-text
dashboard.wallet.refresh-btn
dashboard.stats.total-transactions
dashboard.stats.total-volume
transactions.filters.date-from
transactions.filters.date-to
transactions.filters.type-select
transactions.filters.apply-btn
transactions.table.row-${txId}
transactions.table.export-btn4. Implémentation avec Nuxt UI
Nuxt UI (v4) utilise des composants comme UForm, UInput, UButton, UTable, etc. L'attribut data-testid se passe directement comme attribut HTML natif.
Principe
En Vue 3, tout attribut non-prop est automatiquement propagé au composant racine via attribute fallthrough. Il suffit d'ajouter :data-testid="..." sur le composant — pas besoin d'ajouter une prop dédiée.
Composable standard : useTestId
Disponible à apps/cmms/app/composables/useTestId.ts, auto-importé par Nuxt :
export function useTestId(page: string) {
return (...parts: string[]): string => [page, ...parts].join('.')
}Utilisation dans une page / un composant d'étape :
<script setup lang="ts">
const tid = useTestId('login') // ou 'customer-onboarding.step-1'
</script>
<template>
<div :data-testid="tid('page')">
<UInput :data-testid="tid('form', 'email-input')" />
<UButton :data-testid="tid('form', 'submit-btn')">Valider</UButton>
</div>
</template>Exemple : Formulaire CashIn
<!-- apps/cmms/layers/2-operations/app/components/cash-in/CashInForm.vue -->
<script setup lang="ts">
interface Props {
type: 'MASTER_AGENT' | 'AGENT'
schema: any
loading?: boolean
}
defineProps<Props>()
const tid = useTestId('cash-in')
const state = reactive({
amount: undefined,
phone: '',
countryCode: '242',
description: ''
})
</script>
<template>
<UForm
:schema="schema"
:state="state"
class="space-y-4"
:data-testid="tid('form', 'container')"
@submit="onSubmit"
>
<UFormField label="Montant (XAF)" name="amount" required>
<UInput
v-model.number="state.amount"
type="number"
icon="i-lucide-coins"
:data-testid="tid('form', 'amount-input')"
/>
</UFormField>
<UFormField :label="labels.phone" name="phone" required>
<CommonCemacPhoneInput
:country-code="state.countryCode"
:number="state.phone"
:data-testid="tid('form', 'phone-input')"
@update:country-code="state.countryCode = $event"
@update:number="state.phone = $event"
/>
</UFormField>
<UFormField label="Description (optionnel)" name="description">
<UTextarea
v-model="state.description"
:rows="2"
:data-testid="tid('form', 'description-input')"
/>
</UFormField>
<div class="flex justify-end gap-3 pt-4">
<UButton
color="gray"
variant="ghost"
:disabled="loading"
:data-testid="tid('form', 'cancel-btn')"
@click="onCancel"
>
Annuler
</UButton>
<UButton
type="submit"
color="primary"
:loading="loading"
:data-testid="tid('form', 'submit-btn')"
>
{{ labels.submitButton }}
</UButton>
</div>
</UForm>
</template>Exemple : Modal de confirmation avec PIN
<!-- apps/cmms/layers/2-operations/app/components/cash-in/AgentCashInModal.vue -->
<template>
<UModal
v-model:open="isOpen"
:data-testid="`${testIdPrefix}.modal.container`"
>
<!-- Étape 1 : Formulaire -->
<template v-if="!showPinStep && !showSuccess">
<CashInForm
:test-id-prefix="testIdPrefix"
:type="type"
:schema="schema"
:loading="loading"
@submit="handlePreSubmit"
@cancel="close"
/>
</template>
<!-- Étape 2 : Saisie du PIN -->
<template v-if="showPinStep && !showSuccess">
<div :data-testid="`${testIdPrefix}.pin-step`">
<h3>Confirmer la transaction</h3>
<p :data-testid="`${testIdPrefix}.pin-step.amount-text`">
Montant : {{ formatAmount(pendingAmount) }} XAF
</p>
<UInput
v-model="pinCode"
type="password"
maxlength="6"
placeholder="Code PIN"
:data-testid="`${testIdPrefix}.pin-step.pin-input`"
/>
<div class="flex gap-3 mt-4">
<UButton
variant="ghost"
:data-testid="`${testIdPrefix}.pin-step.back-btn`"
@click="showPinStep = false"
>
Retour
</UButton>
<UButton
color="primary"
:loading="loading"
:disabled="pinCode.length !== 6"
:data-testid="`${testIdPrefix}.pin-step.confirm-btn`"
@click="handleConfirm"
>
Confirmer
</UButton>
</div>
</div>
</template>
<!-- Étape 3 : Succès -->
<template v-if="showSuccess">
<div :data-testid="`${testIdPrefix}.success`">
<p :data-testid="`${testIdPrefix}.success.reference-text`">
Réf : {{ transactionResult?.reference }}
</p>
<p :data-testid="`${testIdPrefix}.success.amount-text`">
{{ formatAmount(transactionResult?.amount) }} XAF
</p>
<UButton
color="primary"
:data-testid="`${testIdPrefix}.success.close-btn`"
@click="close"
>
Fermer
</UButton>
</div>
</template>
<!-- Erreur -->
<UAlert
v-if="errorState"
color="error"
:title="errorState.title"
:description="errorState.message"
:data-testid="`${testIdPrefix}.error-alert`"
/>
</UModal>
</template>Exemple : Table avec données dynamiques
<!-- apps/cmms/layers/X/app/pages/agents/index.vue -->
<template>
<div data-testid="agents.page">
<!-- Filtres -->
<div data-testid="agents.filters">
<UInput
v-model="search"
placeholder="Rechercher un agent..."
icon="i-lucide-search"
data-testid="agents.filters.search-input"
/>
<USelect
v-model="statusFilter"
:items="statusOptions"
data-testid="agents.filters.status-select"
/>
<UButton
data-testid="agents.filters.apply-btn"
@click="applyFilters"
>
Filtrer
</UButton>
</div>
<!-- Table -->
<UTable
:rows="agents"
:columns="columns"
data-testid="agents.table"
>
<template #body="{ rows }">
<tr
v-for="row in rows"
:key="row.id"
:data-testid="`agents.table.row-${row.id}`"
>
<td :data-testid="`agents.table.row-${row.id}.name`">
{{ row.name }}
</td>
<td>
<UBadge
:color="row.status === 'active' ? 'success' : 'warning'"
:data-testid="`agents.table.row-${row.id}.status-badge`"
>
{{ row.status }}
</UBadge>
</td>
<td>
<UButton
variant="ghost"
icon="i-lucide-more-horizontal"
:data-testid="`agents.table.row-${row.id}.actions-btn`"
/>
</td>
</tr>
</template>
</UTable>
<!-- Pagination -->
<UPagination
v-model:page="currentPage"
:total="totalAgents"
:page-count="pageSize"
data-testid="agents.table.pagination"
/>
</div>
</template>5. Composants partagés personnalisés
Grâce à l'attribute fallthrough de Vue 3, le data-testid passé sur un composant custom atterrit automatiquement sur son élément racine. Pour les composants composites où les tests doivent cibler des sous-éléments précis (input interne, modal, items d'une liste), accepter une prop dataTestid et propager avec des suffixes :
Exemple : CommonCemacPhoneInput
<!-- apps/cmms/app/components/common/CemacPhoneInput.vue -->
<script setup lang="ts">
interface Props {
countryCode: string
number: string
dataTestid?: string
}
const props = defineProps<Props>()
const tid = (part: string) =>
props.dataTestid ? `${props.dataTestid}.${part}` : undefined
</script>
<template>
<div :data-testid="dataTestid">
<USelect
v-model="countryCode"
:items="cemacCountries"
:data-testid="tid('country-select')"
/>
<UInput
:model-value="number"
type="tel"
maxlength="12"
:data-testid="tid('number-input')"
@update:model-value="$emit('update:number', $event)"
/>
</div>
</template>Pour la plupart des composants custom simples (un seul élément racine), pas besoin de prop dédiée : le
:data-testidpassé depuis le parent est fallthrough-é automatiquement.
6. Éléments à cibler en priorité
| Catégorie | Exemples | Priorité |
|---|---|---|
| Champs de formulaire | UInput, USelect, UTextarea | Haute |
| Boutons d'action | Submit, confirmer, annuler | Haute |
| Messages d'erreur/succès | UAlert, messages inline | Haute |
| Modales | Confirmation, PIN, détails | Haute |
| Lignes de table | Chaque ligne avec son ID | Moyenne |
| Filtres et recherche | Champs de filtre, bouton appliquer | Moyenne |
| Pagination | Navigation entre pages | Moyenne |
| Navigation | Sidebar, tabs, breadcrumbs | Moyenne |
| Badges et statuts | Indicateurs visuels testables | Basse |
| Éléments décoratifs | Icônes, dividers | Basse (éviter) |
7. Composable useTestId
Le composable est déjà implémenté et auto-importé — voir section 4. Rappel de sa signature :
// apps/cmms/app/composables/useTestId.ts
export function useTestId(page: string) {
return (...parts: string[]): string => [page, ...parts].join('.')
}Pour les étapes d'un wizard, le scope inclut déjà le numéro d'étape :
<script setup lang="ts">
const tid = useTestId('customer-onboarding.step-1')
</script>
<template>
<UForm :data-testid="tid('form', 'container')">
<UInput :data-testid="tid('form', 'input-first-name')" />
<UButton :data-testid="tid('form', 'submit-btn')">Continuer</UButton>
</UForm>
</template>8. Stripping en production
Les attributs data-testid n'ont aucun impact fonctionnel ni de performance significatif. Cependant, si souhaité, ils peuvent être retirés du build de production via un plugin Vite :
// nuxt.config.ts (optionnel)
export default defineNuxtConfig({
vite: {
vue: {
template: {
compilerOptions: {
// Retire data-testid en production
nodeTransforms: process.env.NODE_ENV === 'production'
? [
(node) => {
if (node.type === 1 /* ELEMENT */) {
node.props = node.props.filter(
(prop) =>
!(prop.type === 6 && prop.name === 'data-testid') &&
!(prop.type === 7 && prop.arg?.content === 'data-testid')
)
}
},
]
: [],
},
},
},
},
})Note : Comme pour le mobile, le stripping n'est pas recommandé initialement. Ces attributs sont invisibles pour l'utilisateur et utiles pour le debug.
9. Parcours critiques à couvrir en priorité
CMMS (Agent / Master Agent)
- Authentification —
login.* - Cash-In —
cash-in.*(formulaire, PIN, confirmation, succès) - Cash-Out —
cash-out.* - Dashboard —
dashboard.*(solde, statistiques) - Historique transactions —
transactions.*(filtres, table, détails) - Gestion agents —
agents.*(liste, création, activation) - Paiements de masse —
bulk-payments.*
Backoffice (Admin)
- Authentification —
login.* - Gestion utilisateurs —
users.*(recherche, détail, blocage) - KYC —
kyc.*(file d'attente, validation, rejet) - Configuration —
config.*(limites, commissions, canaux) - Transactions —
transactions.*(monitoring, détail, actions) - Rapports —
reports.*(export, filtres)
10. Bonnes pratiques Selenium
Quelques patterns utiles pour les tests Selenium avec data-testid :
# Python — Selenium WebDriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
def by_testid(testid: str):
"""Sélecteur CSS par data-testid."""
return (By.CSS_SELECTOR, f'[data-testid="{testid}"]')
# Attendre qu'un élément soit visible
wait = WebDriverWait(driver, 10)
amount_input = wait.until(EC.visibility_of_element_located(by_testid("cash-in.form.amount-input")))
amount_input.send_keys("5000")
# Cliquer sur le bouton submit
submit_btn = driver.find_element(*by_testid("cash-in.form.submit-btn"))
submit_btn.click()
# Vérifier le message de succès
success_ref = wait.until(EC.visibility_of_element_located(by_testid("cash-in.success.reference-text")))
assert success_ref.text.startswith("Réf :")
# Vérifier une ligne de table spécifique
agent_row = driver.find_element(*by_testid("agents.table.row-agent123"))
status = agent_row.find_element(*by_testid("agents.table.row-agent123.status-badge"))
assert status.text == "active"// Java — Selenium WebDriver
// Helper
public static By byTestId(String testId) {
return By.cssSelector(String.format("[data-testid='%s']", testId));
}
// Usage
WebElement amountInput = wait.until(
ExpectedConditions.visibilityOfElementLocated(byTestId("cash-in.form.amount-input"))
);
amountInput.sendKeys("5000");
driver.findElement(byTestId("cash-in.form.submit-btn")).click();