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.
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 :
ref et des computed partout, avec des interdépendances implicitesEt quand on essaie quand même de tester, on tombe dans le piège du shallowMount.
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é.
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.
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 :
shallowMount ou mountnextTickLe 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.
L'idée est simple et vient du monde du software craftsmanship :
On obtient deux objets :
.vue) — il contient uniquement le template HTML et le minimum de glue Vue. Il est "humble" parce qu'il est tellement simple qu'il n'a presque pas besoin d'être testé unitairement..component.ts) — il contient toute la logique métier en TypeScript pur. Il est testable avec un simple new, sans aucune dépendance au framework.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é :
<script setup> classique ou composablePrenons 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.
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.
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;
}
}
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.
reactive() fait le lienLe 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.
reactive() avec les classesL'utilisation de reactive() sur une instance de classe fonctionne bien dans la plupart des cas, mais il y a quelques subtilités à connaître :
this — reactive() crée un Proxy autour de l'objet. Si une méthode est passée comme callback (par exemple setTimeout(component.doSomething, 100)), le this peut être perdu. Préférez setTimeout(() => component.doSomething(), 100) pour conserver le contexte.#field) ne sont pas réactives — le Proxy de Vue ne peut pas intercepter l'accès aux champs privés natifs. Utilisez la convention TypeScript private (qui est effacée à la compilation) plutôt que la syntaxe #.reactive() les intercepte correctement, ils peuvent donc être utilisés sans problème.Map ou un Set muté directement, Vue ne détectera pas toujours le changement. Préférez réassigner la collection entière ou utilisez des tableaux simples.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.
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);
});
});
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.
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.
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.
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.
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.
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é.
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.
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é.
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 :
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.