Skip to content

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

OutilSélecteurExemple
SeleniumCSS / XPathdriver.find_element(By.CSS_SELECTOR, '[data-testid="login.form.submit-btn"]')
PlaywrightgetByTestIdpage.getByTestId('login.form.submit-btn')
CypressCSS selectorcy.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

  1. Tout en minuscules
  2. Séparateur de niveau : point (.)
  3. Séparateur de mots : tiret (-)
  4. Pas de préfixe d'app — le contexte de test (URL, config) détermine l'app
  5. Maximum 3 niveaux de profondeur
  6. Les éléments répétés (lignes de table) utilisent un suffixe dynamique : table.row-${id} ou table.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-btn

4. 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 :

ts
export function useTestId(page: string) {
  return (...parts: string[]): string => [page, ...parts].join('.')
}

Utilisation dans une page / un composant d'étape :

vue
<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

vue
<!-- 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

vue
<!-- 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

vue
<!-- 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

vue
<!-- 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-testid passé depuis le parent est fallthrough-é automatiquement.


6. Éléments à cibler en priorité

CatégorieExemplesPriorité
Champs de formulaireUInput, USelect, UTextareaHaute
Boutons d'actionSubmit, confirmer, annulerHaute
Messages d'erreur/succèsUAlert, messages inlineHaute
ModalesConfirmation, PIN, détailsHaute
Lignes de tableChaque ligne avec son IDMoyenne
Filtres et rechercheChamps de filtre, bouton appliquerMoyenne
PaginationNavigation entre pagesMoyenne
NavigationSidebar, tabs, breadcrumbsMoyenne
Badges et statutsIndicateurs visuels testablesBasse
Éléments décoratifsIcônes, dividersBasse (éviter)

7. Composable useTestId

Le composable est déjà implémenté et auto-importé — voir section 4. Rappel de sa signature :

ts
// 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 :

vue
<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 :

ts
// 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)

  1. Authentificationlogin.*
  2. Cash-Incash-in.* (formulaire, PIN, confirmation, succès)
  3. Cash-Outcash-out.*
  4. Dashboarddashboard.* (solde, statistiques)
  5. Historique transactionstransactions.* (filtres, table, détails)
  6. Gestion agentsagents.* (liste, création, activation)
  7. Paiements de massebulk-payments.*

Backoffice (Admin)

  1. Authentificationlogin.*
  2. Gestion utilisateursusers.* (recherche, détail, blocage)
  3. KYCkyc.* (file d'attente, validation, rejet)
  4. Configurationconfig.* (limites, commissions, canaux)
  5. Transactionstransactions.* (monitoring, détail, actions)
  6. Rapportsreports.* (export, filtres)

10. Bonnes pratiques Selenium

Quelques patterns utiles pour les tests Selenium avec data-testid :

python
# 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
// 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();

NxPay — Plateforme fintech CEMAC