Humble Object Pattern en Vue.js : séparer la vue de la logique pour mieux tester

Vous avez déjà passé plus de temps à configurer un test de composant Vue qu'à écrire la logique qu'il est censé vérifier ? Vous avez déjà dû moquer un composant du design system juste pour que shallowMount ne plante pas ? Ces douleurs ont une origine commune : la logique métier est prisonnière du framework.

Le Humble Object Pattern propose une solution élégante : accepter qu'on ne peut pas tout tester facilement, et structurer le code pour tester ce qui compte vraiment.

Ce n'est pas la seule approche possible — les composables Vue résolvent aussi une partie du problème — mais c'est un outil particulièrement efficace quand la logique métier est riche et les interactions entre sous-composants complexes.


Le problème : un composant Vue classique

Un composant Vue typique ressemble à ça : un <template> avec le HTML, un <script setup> avec des ref, des computed, des appels API, de la validation, des conditions d'affichage... Le tout dans un seul fichier .vue.

Vu de loin, ça semble organisé. En pratique :

Et quand on essaie quand même de tester, on tombe dans le piège du shallowMount.


Le calvaire du test de composant Vue

Sans dépendance : c'est simple

Quand une classe TypeScript n'a pas de dépendance, on la teste en trois lignes :

const calculator = new PriceCalculator();
const result = calculator.compute(100, 0.2);
expect(result).toBe(120);

Un new, des entrées, une assertion. Terminé.

Avec des dépendances : on isole

Quand il y a des dépendances (un repository, un service HTTP), on les isole derrière une interface et on injecte un Fake ou un Stub en test. C'est le même principe qu'en Java avec l'architecture hexagonale.

Avec Vue : c'est un autre monde

Un composant .vue, c'est un objet qui dépend du moteur de rendu Vue tout entier : le système de templates, la réactivité, les directives, les composants enfants, le cycle de vie...

Pour le tester, il faut :

  1. Monter le composant avec shallowMount ou mount
  2. Mocker les composants enfants (design system, librairies tierces)
  3. Simuler les props, les événements, les stores
  4. Gérer les problèmes de timing avec nextTick

Le setup est complexe à écrire, fragile à maintenir, et les tests sont difficiles à lire. On a tous connu ce moment où un test passe au rouge non pas parce que la logique a changé, mais parce qu'un composant du design system a été mis à jour.


Le Humble Object Pattern

L'idée est simple et vient du monde du software craftsmanship :

  1. Identifier ce qui est difficile à tester dans le composant (tout ce qui dépend de Vue)
  2. Identifier ce qui est facile à tester (la logique métier pure)
  3. Séparer les deux dans des fichiers distincts

On obtient deux objets :

Quand utiliser ce pattern ?

Le Humble Object Pattern n'est pas adapté à tous les composants. Il brille quand la logique métier est significative : règles de validation croisées, orchestration de sous-composants, conditions d'affichage complexes, transformations de données.

Pour un bouton, un champ de formulaire simple ou un composant purement visuel, la séparation crée du boilerplate sans vrai gain. Un composable (useXxx) ou même de la logique inline dans le <script setup> suffisent amplement.

En résumé :


Mise en pratique

Prenons un exemple concret : un éditeur de recettes avec une modale qui gère la sélection des ingrédients, le niveau de difficulté et les notes du chef.

Le composant logique (testable)

Toute la logique vit dans une classe TypeScript pure :

// RecipeEditor.component.ts
export default class RecipeEditorComponent {
  public isOpen: boolean = false;

  constructor(
    private readonly recipeRepository: RecipeRepository,
    private readonly userRole: Role,
    public readonly ingredientSelector: IngredientSelectorComponent,
    public readonly difficultySelector: DifficultySelectorComponent,
    public readonly chefNotes: ChefNotesComponent
  ) {}

  public isReadOnly(): boolean {
    return this.userRole === Role.VIEWER;
  }

  public isValid(): boolean {
    const hasChanges =
      this.ingredientSelector.isModified() ||
      this.difficultySelector.isModified() ||
      this.chefNotes.isModified();

    return (
      this.ingredientSelector.hasIngredients() &&
      this.chefNotes.isValid() &&
      hasChanges
    );
  }

  public async saveRecipe(): Promise<void> {
    await this.recipeRepository.save({
      ingredients: this.ingredientSelector.selectedCodes,
      difficulty: this.difficultySelector.selected,
      notes: this.chefNotes.text,
    });
    this.isOpen = false;
  }
}

C'est du TypeScript pur. Pas de ref, pas de computed, pas de onMounted. Juste une classe avec un constructeur, des méthodes, et des règles métier.

Notez les noms de méthodes : isValid(), hasIngredients(), isModified(). On évite les doubles négations (isNotModified, isNotValid) qui rendraient des expressions comme !this.chefNotes.isNotValid() && !this.ingredientSelector.isNotModified() difficiles à lire. Un code clair se lit comme une phrase affirmative.

Les sous-composants logiques suivent le même principe

Chaque sous-fonctionnalité a sa propre classe :

// IngredientSelector.component.ts
export default class IngredientSelectorComponent {
  public availableIngredients: Ingredient[] = [];
  public selectedCodes: string[] = [];
  private initialCodes: Set<string> = new Set();

  constructor(private readonly catalogPort: CatalogPort) {}

  public async load(recipeId: string, current: Ingredient[]): Promise<void> {
    this.availableIngredients = await this.catalogPort
      .fetchIngredients(recipeId)
      .then((items) => items.sort((a, b) => a.name.localeCompare(b.name)));

    this.selectedCodes = current.map((i) => i.code);
    this.initialCodes = new Set(this.selectedCodes);
  }

  public isModified(): boolean {
    if (this.initialCodes.size !== this.selectedCodes.length) return true;
    return !this.selectedCodes.every((code) => this.initialCodes.has(code));
  }

  public hasIngredients(): boolean {
    return this.selectedCodes.length > 0;
  }

  public updateSelection(codes: string[]): void {
    this.selectedCodes = [...codes];
  }
}
// ChefNotes.component.ts
export default class ChefNotesComponent {
  private initialText: string = '';
  public text: string = '';
  private static readonly MAX_LENGTH = 500;

  public load(notes: string): void {
    this.initialText = notes;
    this.text = notes;
  }

  public update(value: string): void {
    this.text = value;
  }

  public isValid(): boolean {
    return this.text.length <= ChefNotesComponent.MAX_LENGTH;
  }

  public isModified(): boolean {
    return this.initialText !== this.text;
  }
}

La vue Humble (le composant Vue)

Le fichier .vue ne fait qu'une chose : afficher. Il reçoit le composant logique en prop et délègue tout.

<!-- RecipeEditorModal.vue -->
<template>
  <Modal :opened="component.isOpen">
    <IngredientSelectField
      :component="component.ingredientSelector"
      :is-read-only="component.isReadOnly()"
    />

    <DifficultySelectField
      :component="component.difficultySelector"
      :is-read-only="component.isReadOnly()"
    />

    <ChefNotesTextArea
      :component="component.chefNotes"
      :is-read-only="component.isReadOnly()"
    />

    <button
      :disabled="!component.isValid()"
      @click="component.saveRecipe()"
    >
      Enregistrer
    </button>
  </Modal>
</template>

<script setup lang="ts">
import RecipeEditorComponent from './RecipeEditor.component';

const props = defineProps<{
  component: RecipeEditorComponent;
}>();
</script>

Regardez le <script setup> : il n'y a rien dedans à part la déclaration de la prop. Pas de logique, pas de ref, pas de computed. C'est ça, un objet humble.

Le câblage : reactive() fait le lien

Le composant parent crée la classe logique et la rend réactive pour que Vue puisse tracker les changements :

// Dans le composant parent
import { reactive } from 'vue';
import RecipeEditorComponent from './RecipeEditor.component';

const component = reactive(
  new RecipeEditorComponent(
    recipeRepository,
    currentUserRole,
    new IngredientSelectorComponent(catalogPort),
    new DifficultySelectorComponent(),
    new ChefNotesComponent()
  )
);

reactive() fonctionne comme ref() mais pour les objets. Quand une propriété du composant logique change (par exemple isOpen passe à true), Vue met à jour le template automatiquement.

Attention aux pièges de reactive() avec les classes

L'utilisation de reactive() sur une instance de classe fonctionne bien dans la plupart des cas, mais il y a quelques subtilités à connaître :

En pratique, tant que vos classes utilisent des propriétés publiques simples et des méthodes appelées directement depuis le template, tout se passe bien.


Les tests : simples, rapides, lisibles

Voilà à quoi ressemble un test maintenant :

describe('RecipeEditorComponent', () => {
  let component: RecipeEditorComponent;
  let ingredientSelector: IngredientSelectorComponent;

  beforeEach(() => {
    ingredientSelector = new IngredientSelectorComponent(
      new FakeCatalogPort()
    );

    component = new RecipeEditorComponent(
      new InMemoryRecipeRepository(),
      Role.EDITOR,
      ingredientSelector,
      new DifficultySelectorComponent(),
      new ChefNotesComponent()
    );
  });

  it('should be valid when something changed and has ingredients', () => {
    ingredientSelector.selectedCodes = ['tomato'];
    expect(component.isValid()).toBe(true);
  });

  it('should be invalid when no ingredient is selected', () => {
    ingredientSelector.selectedCodes = [];
    expect(component.isValid()).toBe(false);
  });

  it('should be read-only for viewers', () => {
    const viewer = new RecipeEditorComponent(
      new InMemoryRecipeRepository(),
      Role.VIEWER,
      ingredientSelector,
      new DifficultySelectorComponent(),
      new ChefNotesComponent()
    );
    expect(viewer.isReadOnly()).toBe(true);
  });
});

Pas de shallowMount. Pas de wrapper.find('.button').trigger('click'). Pas de await nextTick(). Juste des new, des appels de méthodes, et des assertions. C'est du test unitaire classique, exactement comme en Java.

Les sous-composants se testent de la même façon :

describe('IngredientSelectorComponent', () => {
  let component: IngredientSelectorComponent;

  beforeEach(() => {
    component = new IngredientSelectorComponent(new FakeCatalogPort());
  });

  it('should detect when selection has been modified', async () => {
    await component.load('recipe-1', [{ code: 'tomato', name: 'Tomate' }]);
    component.updateSelection(['tomato', 'basil']);
    expect(component.isModified()).toBe(true);
  });

  it('should detect when nothing changed', async () => {
    await component.load('recipe-1', [{ code: 'tomato', name: 'Tomate' }]);
    expect(component.isModified()).toBe(false);
  });
});

describe('ChefNotesComponent', () => {
  it('should be invalid when text exceeds max length', () => {
    const notes = new ChefNotesComponent();
    notes.load('');
    notes.update('a'.repeat(501));
    expect(notes.isValid()).toBe(false);
  });

  it('should be valid when text is within limit', () => {
    const notes = new ChefNotesComponent();
    notes.load('');
    notes.update('Ajouter le basilic en fin de cuisson.');
    expect(notes.isValid()).toBe(true);
  });
});

Et les composables dans tout ça ?

Les composables Vue (useRecipeEditor, useIngredientSelector...) sont l'approche idiomatique en Vue pour extraire de la logique. Ils résolvent une partie du problème : le code est factorisé, réutilisable, et peut être testé avec des wrappers comme renderHook de @testing-library/vue.

Cependant, un composable reste couplé au système de réactivité de Vue : il utilise ref, computed, watch, onMounted... Pour le tester, il faut un environnement Vue, même minimal. Ce n'est pas un problème en soi, mais la friction est plus élevée qu'un simple new.

Le Humble Object Pattern va un cran plus loin en supprimant toute dépendance au framework dans la couche logique. C'est particulièrement intéressant quand :

En pratique, les deux approches ne s'excluent pas. On peut utiliser des composables pour la logique simple (un useDebounce, un useLocalStorage) et le Humble Object Pattern pour les composants riches avec des règles métier significatives.


L'architecture qui en découle

En adoptant ce pattern, on se retrouve avec une structure claire :

recipeEditor/
  RecipeEditor.component.ts          ← Logique (testable)
  RecipeEditorModal.vue              ← Vue (humble)

  ingredients/
    IngredientSelector.component.ts  ← Logique
    IngredientSelectField.vue        ← Vue

  difficulty/
    DifficultySelector.component.ts  ← Logique
    DifficultySelectField.vue        ← Vue

  notes/
    ChefNotes.component.ts           ← Logique
    ChefNotesTextArea.vue            ← Vue

Chaque fonctionnalité suit le même pattern : un fichier .component.ts pour la logique, un fichier .vue pour l'affichage. C'est un principe similaire à l'architecture hexagonale en backend : on isole le domaine métier des détails d'infrastructure.


Les avantages

Tests plus simples et plus rapides

Plus de shallowMount, plus de mocks de composants Vue, plus de problèmes de timing. Un new et des assertions, c'est tout. Les tests s'exécutent en millisecondes.

Composants Vue allégés et lisibles

Le fichier .vue ne contient que du template. On le lit en quelques secondes, on comprend immédiatement ce qu'il affiche. Plus besoin de scroller à travers 200 lignes de logique mélangée au HTML.

Réutilisation de la logique

Un composant logique peut être passé à différentes vues. On peut afficher les mêmes données dans une modale, une page dédiée ou un panneau latéral sans dupliquer la logique.

Découplage du framework

C'est l'avantage le plus stratégique. La logique métier ne dépend pas de Vue. Si Vue introduit une breaking change dans une migration (syntaxe des directives, API de réactivité, cycle de vie...), seule la vue humble est impactée. Le composant TypeScript, lui, n'est pas touché. Les migrations Vue passent de chantier redouté à formalité.

Conception forcée

Le pattern pousse naturellement à réfléchir à la structure : constructeur avec injection de dépendances, interfaces, responsabilités claires. On retrouve les réflexes de conception qu'on a en backend.


Et le HTML, on ne le teste plus ?

C'est une question légitime. En extrayant la logique, on ne teste effectivement plus le rendu. Mais est-ce vraiment le rôle d'un test unitaire de vérifier que le HTML s'affiche correctement ?

Le Humble Object Pattern s'accompagne idéalement de tests d'acceptation (avec Cypress par exemple) qui valident les parcours utilisateurs de bout en bout. Ces tests vérifient que le bouton est bien désactivé quand il faut, que la modale s'ouvre, que le formulaire se soumet correctement.

La stratégie de test devient :

On teste ce qui compte, à chaque niveau, avec l'outil adapté.


Conclusion

Le Humble Object Pattern, c'est accepter qu'on ne peut pas tout tester... mais qu'on peut tout structurer pour tester ce qui compte vraiment.

En séparant la logique métier du framework UI :

  1. Les tests redeviennent simples — du TypeScript pur, testable comme n'importe quelle classe
  2. Les composants Vue deviennent lisibles — du template pur, facile à relire et à maintenir
  3. L'architecture se découple du framework — les migrations deviennent indolores
  4. La barrière d'adoption est faible — c'est la même logique que l'architecture hexagonale en Java, appliquée au frontend

Ce n'est pas une solution universelle. Pour les composants simples, un composable ou du code inline dans le <script setup> reste l'approche la plus pragmatique. Mais dès que la logique métier prend de l'ampleur, extraire des classes TypeScript pures change la donne : les tests deviennent un plaisir, et le code survit aux évolutions du framework.

C'est simplement appliquer au frontend les principes de séparation des responsabilités qu'on applique déjà en backend depuis des années. La logique dans des classes, l'affichage dans des templates. Chacun son rôle.