
Tester du code avec appels statiques non mockables
Écrire du code de qualité est essentiel pour garantir la stabilité, la testabilité et la maintenabilité d’une application. Mais dans un projet legacy, il n’est pas rare de tomber sur du code sans tests unitaires, souvent couplé à des appels statiques impossibles à mocker. Ce type de dépendance rend l’écriture de nouveaux tests automatisés difficile et décourageante. Pourtant, il existe des solutions simples pour contourner ces blocages sans réécrire toute la base de code. Dans cet article, nous allons examiner un exemple concret et voir comment refactorer le code legacy pour le rendre testable, même en présence d’appels statiques non mockables.
Pourquoi les appels statiques sont problématiques pour les tests unitaires
Les méthodes et classes statiques sont souvent utilisées pour des raisons de commodité puisqu’elles permettent d’éviter la création d’instances, et donc de bénéficier de variables globales et de méthodes utilitaires semblables à des fonctions. Cependant, elles posent plusieurs problèmes majeurs pour les tests unitaires :
- Couplage fort : Les appels statiques créent une dépendance directe entre le code testé et la classe statique. Dans la majorité des cas, cela empêche l’utilisation de mocks ou de stubs pour simuler le comportement de la classe statique, rendant le mocking statique difficile.
- Difficulté de testabilité : Les méthodes statiques peuvent rarement être mockées ou espionnées, il est alors impossible d’isoler le code source à tester. Nous sommes donc contraints de coder des tests plus complexes pour prendre en compte le comportement de la classe statique. Cela conduit souvent à des tests automatisés difficiles à maintenir, longs à écrire et souvent incomplets.
- Couverture de code limitée : Les appels statiques rendent la configuration des tests plus complexe, voire non configurable. Par conséquent, il est difficile d’atteindre une couverture de code satisfaisante, ce qui peut nuire à la confiance dans le code testé et à sa maintenabilité.
Il est donc préférable d’éviter les appels statiques dans nos projets pour garantir la testabilité, la maintenabilité et la qualité du code.
La réalité derrière les appels statiques non mockables
Dans les projets legacy, il est courant de rencontrer des appels statiques à des classes utilitaires. Ces classes, souvent conçues comme des services globaux, étaient une pratique répandue avant l’adoption de l’injection de dépendances. Elles sont généralement utilisées pour des opérations transverses comme la journalisation, les accès à la base de données ou les appels réseau.
Le problème ? Ces appels statiques sont difficiles, voire impossibles à mocker, ce qui rend l’écriture de tests unitaires très compliquée. Résultat, les tests sont souvent absents, ou bien remplacés par des tests manuels réalisés sur un environnement d’intégration. Cette approche augmente la dette technique et rend le code plus fragile face aux évolutions.
Une fois le projet en production, la situation se complique encore. Les équipes sont souvent réduites, ou le projet est transféré à une autre équipe qui ne connaît pas le code. Les ressources sont limitées, les délais serrés, et les développeurs doivent ajouter des fonctionnalités ou corriger des bugs sans pouvoir s’appuyer sur des tests fiables. Ils finissent par tester directement en environnement d’intégration, ce qui est risqué !
Au fil du temps, cette situation entraîne :
- Une augmentation de la dette technique,
- Des délais de livraison plus longs,
- Et un risque accru de régressions.
Exemple de code legacy avec appels statiques
Nous allons voir concrètement pourquoi les appels statiques posent problème en test unitaire grâce à un exemple concret qui vous permettra de mieux comprendre les enjeux.
Code source inspiré du code legacy
Un code source difficile à tester
Nous allons partir d’un code source typique d’un projet legacy, où les appels statiques sont utilisés pour accéder à des méthodes de classes utilitaires. Voici un extrait de code qui illustre ce cas :
// src/main/java/com/lavoiedudev/legacy/service/OfferService.java
package com.lavoiedudev.legacy.service;
import com.lavoiedudev.legacy.dao.OfferDao;
import com.lavoiedudev.legacy.dto.Contract;
import com.lavoiedudev.legacy.dto.Offer;
import com.lavoiedudev.legacy.dto.Product;
import com.lavoiedudev.legacy.repository.OfferRepository;
import com.lavoiedudev.legacy.repository.ProductRepository;
import com.lavoiedudev.legacy.utils.DataSourceUtil;
import com.lavoiedudev.legacy.utils.PriceUtil;
import org.hibernate.Session;
import java.util.stream.Collectors;
public class OfferService {
public static Offer getContractOffer(Contract contract) {
Offer offer;
try (Session session = DataSourceUtil.openSession()) { // <1>
OfferDao entity = OfferRepository.findOfferByContractId(
session,
contract.getId()
); // <1>
offer = new Offer();
offer.setupFromEntity(entity);
offer.setProducts(
ProductRepository.getAssociatedProducts(
session,
contract.getId(),
entity
) // <1>
.stream()
.map(Product::fromEntity)
.collect(Collectors.toList())
);
PriceUtil.computePriceFor(offer); // <2>
}
return offer;
}
}
Appels statiques à des méthodes de classes utilitaires faisant des opérations de base de données.
Appel statique à une méthode utilitaire pour calculer le prix de l’offre.
Ce code source utilise des appels statiques pour accéder à des méthodes de classes utilitaires. Nous retrouvons :
- Des appels statiques à des méthodes de classes utilitaires pour ouvrir une session Hibernate et récupérer des données depuis la base de données. Ces appels sont problématiques pour les tests unitaires car ils accèdent à une ressource externe (la base de données).
- Un appel statique à une méthode utilitaire pour calculer le prix de l’offre. Cette méthode statique ne pose aucun problème pour les tests unitaires car il s’agit d’une simple fonction de calcul indépendante de l’état du système.
Pourquoi les appels statiques sont problématiques ?
Les anciennes pratiques de développement privilégiaient souvent les appels statiques pour des raisons de commodité. Elle permettaient de pallier à l’absence d’injection de dépendances. Cependant, en reproduisant cette stratégie, la méthode statique getContractOffer
est impossible à tester de manière fiable. En effet, les appels statiques interagissant avec la base de données sont sujets aux problèmes suivants :
- Échec de connexion à la base de données,
- Entité non trouvée dans la base de données,
- Entité dont les données peuvent varier, etc.
Ces situations entraînent donc des comportements variables durant les tests. Les assertions des tests unitaires remontent des échecs aléatoires. Pour éviter cela, durant un test unitaire, la bonne pratique consiste à simuler ces comportements pour garantir la fiabilité des tests. Malheureusement, les appels statiques rendent souvent cette simulation impossible.
Test unitaire toujours en échec
Une image vaut mille mots, je vous propose donc d’illustrer le problème avec un test unitaire.
Nous considérons que ce test unitaire est réalisé automatiquement sur un environnement isolé, donc sans accès à une base de données. Dans ce contexte, l’appel à la méthode statique DataSourceUtil.openSession()
remonte systématiquement un échec de connexion au serveur de base de données. J’implémente donc cette méthode pour qu’elle lève systématiquement une JDBCConnectionException
afin de simuler cet échec de connexion.
Puis, j’écris un test unitaire basique pour tester la méthode getContractOffer
de notre nouveau service OfferService
:
// src/test/java/com/lavoiedudev/legacy/service/OfferServiceTest.java
package com.lavoiedudev.legacy.service;
import com.lavoiedudev.legacy.dto.Contract;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class OfferServiceTest {
@Test
void getContractOffer() {
Contract contract = new Contract();
contract.setId(1);
assertNotNull(
OfferService.getContractOffer(contract)
);
}
}
Dans une situation nominale, ce test unitaire devrait réussir. Cependant, en raison de l’appel statique à DataSourceUtil.openSession()
, il échoue systématiquement avec une exception JDBCConnectionException
:
$ mvn test
...
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.lavoiedudev.legacy.service.OfferServiceTest
[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.115 s <<< FAILURE! - in com.lavoiedudev.legacy.service.OfferServiceTest
[ERROR] com.lavoiedudev.legacy.service.OfferServiceTest.getContractOffer Time elapsed: 0.073 s <<< ERROR!
org.hibernate.exception.JDBCConnectionException: Fake connection exception # <1>
...
[INFO]
[INFO] Results:
[INFO]
[ERROR] Errors:
[ERROR] OfferServiceTest.getContractOffer:16 » JDBCConnection Fake connection exception
[INFO]
[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0
...
Exception levée par l’appel statique à DataSourceUtil.openSession()
.
Si la génération de vos livrable est conditionnée à la réussite des tests unitaires, ce test unitaire en échec vous empêchera de livrer votre code. D’où l’importance de rendre ce code testable !
La solution simple pour contourner les appels statiques : refactor minimal
Dans un monde idéal, il faudrait réécrire l’ensemble du code pour supprimer les appels statiques et utiliser l’injection de dépendances. Cependant, dans un projet legacy, c’est souvent irréalisable (code volumineux, manque de temps, fort risque de régressions, etc.). Heureusement, il existe une solution simple pour contourner les appels statiques sans réécrire tout le code : le refactor minimal.
Qu’est-ce que le refactor minimal ?
Le refactor minimal consiste à modifier le code existant de manière à réduire sa dette technique en ne réécrivant que les parties du code source impactées par une correction ou une évolution. L’objectif est de rendre le code plus testable et maintenable sans nécessiter une réécriture complète.
Pendant ce refactor, nous allons appliquer les principes SOLID uniquement sur le nouveau code. Pour cela, nous allons créer des wrappers et des interfaces qui vont encapsuler les appels statiques problématiques. Ainsi, le code sera moins couplé, plus facile à tester et à maintenir. L’avantage : on ne touche pas au code legacy existant et on code proprement les parties à faire évoluer.
Refactor en trois étapes
Étape 1 : Définir des interfaces pour isoler les appels statiques
La première étape consiste à définir des interfaces qui permettront d’isoler les appels statiques aux classes utilitaires. Ces interfaces serviront de contrat pour le service OfferService
, en le recentrant sur la logique métier.
Nous créons donc trois interfaces :
ISessionProvider
pour fournir une session Hibernate,IOfferRepository
pour accéder aux offres,IProductRepository
pour accéder aux produits.
ISessionProvider
// src/main/java/com/lavoiedudev/legacy/bridge/api/utils/ISessionProvider.java
package com.lavoiedudev.legacy.bridge.api.utils;
import org.hibernate.Session;
public interface ISessionProvider {
/** Retourne une session ouverte */
Session openSession();
}
IOfferRepository
// src/main/java/com/lavoiedudev/legacy/bridge/api/repository/IOfferRepository.java
package com.lavoiedudev.legacy.bridge.api.repository;
import com.lavoiedudev.legacy.dao.OfferDao;
import org.hibernate.Session;
public interface IOfferRepository {
/** Trouve l'offre associée au contrat */
OfferDao findOfferByContractId(Session session, long id);
}
IProductRepository
// src/main/java/com/lavoiedudev/legacy/bridge/api/repository/IProductRepository.java
package com.lavoiedudev.legacy.bridge.api.repository;
import com.lavoiedudev.legacy.dao.OfferDao;
import com.lavoiedudev.legacy.dao.ProductDao;
import org.hibernate.Session;
import java.util.List;
public interface IProductRepository {
/** Retourne les produits de l'offre et du contrat */
List<ProductDao> getAssociatedProducts(
Session session, long contractId, OfferDao offer
);
}
Étape 2 : Implémenter les interfaces pour encapsuler les appels statiques
La deuxième étape consiste à implémenter les interfaces créées précédemment pour encapsuler les appels statiques. Ces implémentations sont des classes wrappers qui vont utiliser les classes utilitaires existantes pour fournir le comportement attendu.
Nous allons créer trois classes wrappers :
SessionProvider
qui implémenteISessionProvider
pour encapsuler l’appel statique àDataSourceUtil.openSession()
,OfferRepositoryImpl
qui implémenteIOfferRepository
pour encapsuler l’appel statique àOfferRepository.findOfferByContractId()
,ProductRepositoryImpl
qui implémenteIProductRepository
pour encapsuler l’appel statique àProductRepository.getAssociatedProducts()
.
SessionProvider
// src/main/java/com/lavoiedudev/legacy/bridge/utils/SessionProvider.java
package com.lavoiedudev.legacy.bridge.utils;
import com.lavoiedudev.legacy.bridge.api.utils.ISessionProvider;
import com.lavoiedudev.legacy.utils.DataSourceUtil;
import org.hibernate.Session;
public class SessionProvider implements ISessionProvider {
@Override
public Session openSession() {
return DataSourceUtil.openSession();
}
}
OfferRepositoryImpl
// src/main/java/com/lavoiedudev/legacy/bridge/repository/OfferRepositoryImpl.java
package com.lavoiedudev.legacy.bridge.repository;
import com.lavoiedudev.legacy.bridge.api.repository.IOfferRepository;
import com.lavoiedudev.legacy.dao.OfferDao;
import com.lavoiedudev.legacy.repository.OfferRepository;
import org.hibernate.Session;
public class OfferRepositoryImpl implements IOfferRepository {
@Override
public OfferDao findOfferByContractId(Session session, long id) {
return OfferRepository.findOfferByContractId(session, id);
}
}
ProductRepositoryImpl
// src/main/java/com/lavoiedudev/legacy/bridge/repository/ProductRepositoryImpl.java
package com.lavoiedudev.legacy.bridge.repository;
import com.lavoiedudev.legacy.bridge.api.repository.IProductRepository;
import com.lavoiedudev.legacy.dao.OfferDao;
import com.lavoiedudev.legacy.dao.ProductDao;
import com.lavoiedudev.legacy.repository.ProductRepository;
import org.hibernate.Session;
import java.util.List;
public class ProductRepositoryImpl implements IProductRepository {
@Override
public List<ProductDao> getAssociatedProducts(
Session session, long contractId, OfferDao offer
) {
return ProductRepository.getAssociatedProducts(
session, contractId, offer
);
}
}
Étape 3 : Corriger le service pour le rendre testable
Maintenant que nous avons nos interfaces et leurs implémentations, nous pouvons corriger le service OfferService
pour le rendre testable. Nous allons injecter les dépendances via le constructeur du service et supprimer les appels statiques.
/src/main/java/com/lavoiedudev/legacy/service/OfferService.java
package com.lavoiedudev.legacy.service;
+import com.lavoiedudev.legacy.bridge.api.repository.IOfferRepository;
+import com.lavoiedudev.legacy.bridge.api.repository.IProductRepository;
+import com.lavoiedudev.legacy.bridge.api.utils.ISessionProvider;
import com.lavoiedudev.legacy.dao.OfferDao;
import com.lavoiedudev.legacy.dto.Contract;
import com.lavoiedudev.legacy.dto.Offer;
import com.lavoiedudev.legacy.dto.Product;
-import com.lavoiedudev.legacy.repository.OfferRepository;
-import com.lavoiedudev.legacy.repository.ProductRepository;
-import com.lavoiedudev.legacy.utils.DataSourceUtil;
import com.lavoiedudev.legacy.utils.PriceUtil;
import org.hibernate.Session;
// ...
public class OfferService {
- public static Offer getContractOffer(Contract contract) {
+ private final ISessionProvider sessionProvider;
+
+ private final IOfferRepository offerRepository;
+
+ private final IProductRepository productRepository;
+
+ public OfferService(
+ ISessionProvider sessionProvider,
+ IOfferRepository offerRepository,
+ IProductRepository productRepository
+ ) {
+ this.sessionProvider = sessionProvider;
+ this.offerRepository = offerRepository;
+ this.productRepository = productRepository;
+ }
+
+ public Offer getContractOffer(Contract contract) {
Offer offer;
- try (Session session = DataSourceUtil.openSession()) {
- OfferDao entity = OfferRepository.findOfferByContractId(
+ try (Session session = sessionProvider.openSession()) {
+ OfferDao entity = offerRepository.findOfferByContractId(
session,
contract.getId()
);
// ...
offer.setupFromEntity(entity);
offer.setProducts(
- ProductRepository.getAssociatedProducts(
+ productRepository.getAssociatedProducts(
session,
contract.getId(),
entity
// ...
Dans ce code refactoré, nous avons injecté les dépendances via le constructeur du service et remplacé les appels statiques par des appels aux interfaces. Cet ensemble de modifications applique trois principes SOLID non respectés dans le code legacy qui facilitent la testabilité du service :
- Single Responsibility Principle : Le service
OfferService
n’est plus responsable de la gestion des sessions ou de l’accès aux données. Il se concentre uniquement sur la logique métier. - Dependency Inversion Principle : Le service dépend désormais d’abstractions (interfaces) plutôt que de classes concrètes. Cela permet de remplacer facilement les implémentations par des mocks lors des tests unitaires.
- Open/Closed Principle : Le service est ouvert à l’extension (en ajoutant de nouvelles implémentations des interfaces) mais fermé à la modification (le code existant n’est pas modifié).
Test unitaire refactoré
Maintenant que le code legacy a été amélioré, nous pouvons écrire un test unitaire plus robuste. Vous constaterez que la configuration du test sera très simple et surtout que le code sera testable de manière fiable !
// src/test/java/com/lavoiedudev/legacy/service/OfferServiceTest.java
package com.lavoiedudev.legacy.service;
import com.lavoiedudev.legacy.bridge.api.repository.IOfferRepository;
import com.lavoiedudev.legacy.bridge.api.repository.IProductRepository;
import com.lavoiedudev.legacy.bridge.api.utils.ISessionProvider;
import com.lavoiedudev.legacy.dao.OfferDao;
import com.lavoiedudev.legacy.dto.Contract;
import org.hibernate.Session;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.util.ArrayList;
import static org.junit.jupiter.api.Assertions.assertNotNull;
class OfferServiceTest {
@Test
void getContractOffer() {
// Création des mock
ISessionProvider sessionProvider = Mockito.mock(ISessionProvider.class);
IOfferRepository offerRepository = Mockito.mock(IOfferRepository.class);
IProductRepository productRepository = Mockito.mock(IProductRepository.class);
// Configuration pour le test
Mockito.when(sessionProvider.openSession())
.thenReturn(Mockito.mock(Session.class));
Mockito.when(offerRepository.findOfferByContractId(
Mockito.any(),
Mockito.anyLong()
))
.thenReturn(Mockito.mock(OfferDao.class));
Mockito.when(productRepository.getAssociatedProducts(
Mockito.any(),
Mockito.anyLong(),
Mockito.any()
))
.thenReturn(new ArrayList<>());
// Test
OfferService service = new OfferService(
sessionProvider,
offerRepository,
productRepository
);
Contract contract = new Contract();
contract.setId(1);
assertNotNull(
service.getContractOffer(contract)
);
}
}
Ce test unitaire se décompose en trois parties :
- Création des mocks à partir des interfaces définies précédemment,
- Configuration des mocks pour simuler le comportement attendu,
- Exécution du test en appelant la méthode
getContractOffer
du service.
En configurant les dépendances du service de cette manière, nous garantissons un contexte de test stable et maîtrisé qui assure la fiabilité du test. D’ailleurs, en relançant le test unitaire, vous constaterez qu’il réussit sans problème :
$ mvn test
...
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.lavoiedudev.legacy.service.OfferServiceTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.449 s - in com.lavoiedudev.legacy.service.OfferServiceTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
...
Bonnes pratiques pour simplifier les tests unitaires
Pour garantir la testabilité et la robustesse du code, il est essentiel d’adopter quelques bonnes pratiques fondamentales :
- Utilisation systématique de l’injection de dépendances : L’injection de dépendances permet de découpler les composants et de remplacer facilement les dépendances réelles par des mocks ou des stubs lors des tests. Cela facilite la configuration du contexte de test et évite les effets de bord liés à l’environnement ou aux ressources externes.
- Préférence pour les interfaces et abstractions : En utilisant des interfaces plutôt que des classes concrètes, on rend le code plus flexible. Les interfaces servent de contrat, ce qui facilite leur remplacement par des implémentations différentes durant les tests.
- Limiter les dépendances externes : Plus le code dépend de ressources externes (base de données, fichiers, services distants), plus il devient difficile à tester de façon fiable. Il est donc recommandé d’isoler ces dépendances derrière des abstractions et de les injecter, afin de pouvoir les simuler ou les contrôler lors des tests unitaires.
- Conception du code avec les règles SOLID : Les principes SOLID favorisent un code modulaire, extensible et testable. En les appliquant, on réduit le couplage, on améliore la maintenabilité et on facilite la mise en place de tests unitaires.
En appliquant ces bonnes pratiques, vous vous assurez que le code reste évolutif et vérifiable par des tests automatisés fiables.
Conclusion
La testabilité du code est un enjeu majeur pour garantir la qualité, la fiabilité et la maintenabilité des applications. Même face à un projet legacy, il est tout à fait possible d’améliorer la situation sans devoir tout réécrire. Vous l’avez vu dans notre exemple : avec des ajustements ciblés comme l’introduction d’interfaces et l’injection de dépendances, nous avons rendu le code testable et évolutif.
N’hésitez pas à appliquer ces bonnes pratiques progressivement, en commençant par les parties du code que vous devez faire évoluer ou corriger. Chaque amélioration, même minime, contribue à réduire la dette technique et à renforcer la robustesse de votre projet.
Si vous avez d’autres astuces ou d’autres solutions pour rendre le code legacy testable, n’hésitez pas à les partager en commentaire !