Azure Functions : tester unitairement la cohérence des durées de vie de vos services avant que cela ne casse au runtime !
Les Azure Functions supportent maintenant l'injection de dépendances, comme c'est le cas en AspNet Core depuis un moment déjà. Dans Asp.Net Core comme sur les Azure Functions, une fonctionnalité de validation de scope vous empêche de faire n'importe quoi entre différentes dépendances (davantage sur le sujet bientôt). Dernièrement, j'ai eu la mauvaise surprise de découvrir que cela levait une exception une fois déployé et appelé. Dans cet article, nous verrons comment tester cela de façon automatique dans un test unitaire pour pouvoir mettre en place une correction préemptive.
(Franchement j'ai eu du mal à trouver un titre concis pour cet article :D !)
Faire n'importe quoi ?
Lorsque vous enregistrez un service, vous pouvez choisir plusieurs durées de vie :
- singleton : une seule instance en vie toute la durée de vie de l'Api,
- scoped : une instance créée par requête à votre Api / exécution de votre fonction,
- transient : chaque demande du service crée une nouvelle instance.
Ainsi vous pouvez très bien vous retrouver à avoir une dépendance d'un singleton enregistrée sur une durée de vie plus courte en scoped. Une fois que la requête est terminée, cette dépendance sera supprimée par le système mais encore référencée par le Singleton : une bonne façon d'avoir des bugs incompréhensibles.
public class ScopedService { } public class SingletonService { private readonly ScopedService _scopedService; public SingletonService(ScopedService scopedService) { _scopedService = scopedService; } } serviceCollection.AddScoped<ScopedService>(); serviceCollection.AddSingleton<SingletonService>(); // mauvaise chose ! serviceCollection.BuildServiceProvider() .GetServices(typeof(SingletonService));
Depuis les dernières version d'AspNet Core, vous avez un garde fou d'activé automatiquement sur votre environnement de développement. Cela s'appelle de la validation de périmètre (scope validation dans la documentation).
C'est bien, mais encore faut-il lancer l'api ou l'Azure Function en local pour le constater. Dans mon environnement de travail habituel je ne lance que très peu les Azure Functions en local car je teste tout de manière automatisée dans des tests unitaires. Aussi, je n'ai constaté ce crash que lors d'un test sur mon environnement de dev (mais une fois l'Azure Function publiée et déployée suite à un processus de Pull Request rigoureux = trop tard donc !)
Voici le genre de message obtenu :
DryIoc.ContainerException: Dependency ScopedService {ServiceKey=DefaultKey(4), ReturnDefault} as parameter "scopedService" reuse CurrentScopeReuse {Lifespan=100} lifespan shorter than its parent's: singleton SingletonService {ServiceKey=DefaultKey(4), ReturnDefault} as parameter "SingletonService" #3355
Le système d'injection de dépendance des Azure Functions
Une abstraction de l'injection de dépendances à base de Service Provider est utilisée sur AspNet Core et les Azure Functions. Il est donc possible de brancher n'importe quel "container" comme base de votre injection de dépendances avec AspNet Core. Pour les Azure Functions, ce n'est pas vraiment le cas et vous pouvez constater assez facilement sur Github (le code de l'hôte des Azure Functions y est ) que DryIOC est utilisé comme système sous-jacent pour l'injection de dépendances.
Nous allons donc utiliser celui-ci dans nos tests unitaires pour reproduire la validation de la cohérence des durées de vie de nos services. Il faudra cependant faire attention car DryIoc est bien utilisé mais c'est une version customisée. Globalement, pour reproduire le même comportement, il faut enregistrer nos services Scoped en Scoped et pas en ScopedOrSingleton (une durée de vie spécifique à DryIoc).
Le test unitaire
Notre test unitaire va alors consister en cette logique :
- Créer une instance de ServiceCollection,
- Enregistrer nos services avec le même code que sur votre Azure Function,
- Créer un container DryIoc, le remplir avec les services dans notre ServiceCollection,
- Appeler la méthode Validate du container pour savoir si l'on a des erreurs.
var serviceCollection = new ServiceCollection(); // Enregistrer les services serviceCollection.AddTransient<SingletonServiceConsumer, SingletonServiceConsumer>(); serviceCollection.AddSingleton<SingletonService>(); serviceCollection.AddScoped(_ => new ScopedService()); // créer un container var container = new DryIoc.Container(Rules.Default); // enregistrer les dépendances de ServiceCollection foreach (var descriptor in serviceCollection) { if (descriptor.ImplementationType != null) { var reuse = descriptor.Lifetime == ServiceLifetime.Singleton ? Reuse.Singleton : descriptor.Lifetime == ServiceLifetime.Scoped ? Reuse.Scoped : Reuse.Transient; container.Register( descriptor.ServiceType, descriptor.ImplementationType, reuse); } else if (descriptor.ImplementationFactory != null) { var reuse = descriptor.Lifetime == ServiceLifetime.Singleton ? Reuse.Singleton : descriptor.Lifetime == ServiceLifetime.Scoped ? Reuse.Scoped : Reuse.Transient; container.RegisterDelegate( true, descriptor.ServiceType, descriptor.ImplementationFactory, reuse); } else { container.RegisterInstance( true, descriptor.ServiceType, descriptor.ImplementationInstance); } } // Tester avec Validate var errors = container.Validate(); errors.Should().BeEmpty();
Et voilà !
Commentaires