Database
 sql >> Base de données >  >> RDS >> Database

Règles d'implémentation de TDD dans l'ancien projet

L'article « Sliding Responsibility of the Repository Pattern » a soulevé plusieurs questions auxquelles il est très difficile de répondre. Avons-nous besoin d'un référentiel si le mépris total des détails techniques est impossible ? Quelle doit être la complexité du référentiel pour que son ajout puisse être considéré comme utile ? La réponse à ces questions varie selon l'importance accordée au développement des systèmes. La question la plus difficile est probablement la suivante :avez-vous même besoin d'un référentiel ? Le problème de «l'abstraction fluide» et la complexité croissante du codage avec une augmentation du niveau d'abstraction ne permettent pas de trouver une solution qui satisferait les deux côtés de la clôture. Par exemple, dans le reporting, la conception d'intention conduit à la création d'un grand nombre de méthodes pour chaque filtre et tri, et une solution générique crée une surcharge de codage importante.

Pour avoir une image complète, j'ai regardé le problème des abstractions en termes de leur application dans un code hérité. Un dépôt, dans ce cas, ne nous intéresse qu'en tant qu'outil permettant d'obtenir du code de qualité et sans bogue. Bien sûr, ce schéma n'est pas la seule chose nécessaire à l'application des pratiques TDD. Après avoir mangé un boisseau de sel lors du développement de plusieurs grands projets et observé ce qui fonctionne et ce qui ne fonctionne pas, j'ai développé quelques règles pour moi-même qui m'aident à suivre les pratiques TDD. Je suis ouvert à une critique constructive et à d'autres méthodes de mise en œuvre de TDD.

Avant-propos

Certains peuvent remarquer qu'il n'est pas possible d'appliquer TDD dans un ancien projet. Il existe une opinion selon laquelle différents types de tests d'intégration (tests d'interface utilisateur, de bout en bout) leur conviennent mieux car il est trop difficile de comprendre l'ancien code. De plus, vous pouvez entendre que l'écriture de tests avant le codage proprement dit n'entraîne qu'une perte de temps, car nous ne savons peut-être pas comment le code fonctionnera. J'ai eu à travailler sur plusieurs projets, où je me suis limité uniquement aux tests d'intégration, estimant que les tests unitaires ne sont pas indicatifs. En même temps, beaucoup de tests ont été écrits, ils ont exécuté beaucoup de services, etc. En conséquence, une seule personne pouvait les comprendre, qui, en fait, les a écrits.

Au cours de ma pratique, j'ai réussi à travailler sur plusieurs très gros projets, où il y avait beaucoup de code hérité. Certains d'entre eux comportaient des tests, d'autres non (il n'y avait qu'une intention de les mettre en œuvre). J'ai participé à deux grands projets, dans lesquels j'ai en quelque sorte essayé d'appliquer l'approche TDD. Au stade initial, TDD était perçu comme un développement Test First. Finalement, les différences entre cette compréhension simplifiée et la perception actuelle, brièvement appelée BDD, sont devenues plus claires. Quelle que soit la langue utilisée, les points principaux, je les appelle règles, restent similaires. Quelqu'un peut trouver des parallèles entre les règles et d'autres principes d'écriture de bon code.

Règle 1 :Utilisation de la méthode ascendante (de l'intérieur vers l'extérieur)

Cette règle fait plutôt référence à la méthode d'analyse et de conception de logiciel lors de l'intégration de nouveaux morceaux de code dans un projet de travail.

Lorsque vous concevez un nouveau projet, il est tout à fait naturel d'imaginer un système complet. A ce stade, vous contrôlez à la fois l'ensemble des composants et la flexibilité future de l'architecture. Par conséquent, vous pouvez écrire des modules qui peuvent être facilement et intuitivement intégrés les uns aux autres. Une telle approche descendante vous permet d'effectuer une bonne conception initiale de la future architecture, de décrire les lignes directrices nécessaires et d'avoir une image complète de ce que vous voulez finalement. Après un certain temps, le projet se transforme en ce qu'on appelle le code hérité. Et puis le plaisir commence.

Au stade où il est nécessaire d'intégrer une nouvelle fonctionnalité dans un projet existant avec un tas de modules et de dépendances entre eux, il peut être très difficile de les mettre tous dans votre tête pour faire la bonne conception. L'autre côté de ce problème est la quantité de travail nécessaire pour accomplir cette tâche. Par conséquent, l'approche ascendante sera plus efficace dans ce cas. En d'autres termes, vous créez d'abord un module complet qui résout la tâche nécessaire, puis vous l'intégrez au système existant, en n'apportant que les modifications nécessaires. Dans ce cas, vous pouvez garantir la qualité de ce module, car il s'agit d'une unité complète du fonctionnel.

Il convient de noter que tout n'est pas si simple avec les approches. Par exemple, lors de la conception d'une nouvelle fonctionnalité dans un ancien système, vous utiliserez, que cela vous plaise ou non, les deux approches. Lors de l'analyse initiale, vous devez encore évaluer le système, puis le descendre au niveau du module, l'implémenter, puis revenir au niveau de l'ensemble du système. À mon avis, l'essentiel ici est de ne pas oublier que le nouveau module doit être une fonctionnalité complète et être indépendant, en tant qu'outil distinct. Plus vous respecterez strictement cette approche, moins de modifications seront apportées à l'ancien code.

Règle 2 :Tester uniquement le code modifié

Lorsque vous travaillez avec un ancien projet, il n'est absolument pas nécessaire d'écrire des tests pour tous les scénarios possibles de la méthode/classe. De plus, vous n'êtes peut-être pas du tout au courant de certains scénarios, car il peut y en avoir beaucoup. Le projet est déjà en production, le client est satisfait, vous pouvez donc vous détendre. En général, seules vos modifications causent des problèmes dans ce système. Par conséquent, seuls eux doivent être testés.

Exemple

Il existe un module de boutique en ligne, qui crée un panier d'articles sélectionnés et le stocke dans une base de données. Nous ne nous soucions pas de la mise en œuvre spécifique. Fait comme fait - c'est le code hérité. Nous devons maintenant introduire un nouveau comportement ici :envoyer une notification au service de comptabilité au cas où le coût du panier dépasserait 1 000 $. Voici le code que nous voyons. Comment introduire le changement ?

public class EuropeShop : Shop
{
    public override void CreateSale()
    {
        var items = LoadSelectedItemsFromDb();
        var taxes = new EuropeTaxes();
        var saleItems = items.Select(item => taxes.ApplyTaxes(item)).ToList();
        var cart = new Cart();
        cart.Add(saleItems);
        taxes.ApplyTaxes(cart);
        SaveToDb(cart);
    }
}

Selon la première règle, les changements doivent être minimes et atomiques. Nous ne sommes pas intéressés par le chargement des données, nous ne nous soucions pas du calcul des taxes et de l'enregistrement dans la base de données. Mais nous sommes intéressés par le panier calculé. S'il y avait un module qui fait ce qui est requis, alors il effectuerait la tâche nécessaire. C'est pourquoi nous faisons cela.

public class EuropeShop : Shop
{
    public override void CreateSale()
    {
        var items = LoadSelectedItemsFromDb();
        var taxes = new EuropeTaxes();
        var saleItems = items.Select(item => taxes.ApplyTaxes(item)).ToList();
        var cart = new Cart();
        cart.Add(saleItems);
        taxes.ApplyTaxes(cart);

        // NEW FEATURE
        new EuropeShopNotifier().Send(cart);

        SaveToDb(cart);
    }
}

Un tel notificateur fonctionne de manière autonome, peut être testé et les modifications apportées à l'ancien code sont minimes. C'est exactement ce que dit la deuxième règle.

Règle 3 :Nous ne testons que les exigences

Pour vous soulager du nombre de scénarios qui nécessitent des tests avec des tests unitaires, pensez à ce dont vous avez réellement besoin d'un module. Écrivez d'abord pour l'ensemble minimum de conditions que vous pouvez imaginer comme exigences pour le module. L'ensemble minimum est l'ensemble qui, lorsqu'il est complété par un nouveau, le comportement du module ne change pas beaucoup, et lorsqu'il est supprimé, le module ne fonctionne pas. L'approche BDD aide beaucoup dans ce cas.

Imaginez également comment les autres classes clientes de votre module interagiront avec lui. Vous avez besoin d'écrire 10 lignes de code pour configurer votre module ? Plus la communication entre les parties du système est simple, mieux c'est. Par conséquent, il est préférable de sélectionner les modules responsables de quelque chose de spécifique à partir de l'ancien code. SOLID viendra en aide dans ce cas.

Exemple

Voyons maintenant comment tout ce qui est décrit ci-dessus nous aidera avec le code. Sélectionnez d'abord tous les modules qui ne sont qu'indirectement associés à la création du panier. C'est ainsi que la responsabilité des modules est répartie.

public class EuropeShop : Shop
{
    public override void CreateSale()
    {
        // 1) load from DB
        var items = LoadSelectedItemsFromDb();

        // 2) Tax-object creates SaleItem and
        // 4) goes through items and apply taxes
        var taxes = new EuropeTaxes();
        var saleItems = items.Select(item => taxes.ApplyTaxes(item)).ToList();

        // 3) creates a cart and 4) applies taxes
        var cart = new Cart();
        cart.Add(saleItems);
        taxes.ApplyTaxes(cart);

        new EuropeShopNotifier().Send(cart);

        // 4) store to DB
        SaveToDb(cart);
    }
}

De cette façon, ils peuvent être distingués. Bien entendu, de tels changements ne peuvent pas être apportés d'un seul coup dans un grand système, mais ils peuvent être apportés progressivement. Par exemple, lorsque les modifications concernent un module fiscal, vous pouvez simplifier la manière dont les autres parties du système en dépendent. Cela peut aider à se débarrasser des dépendances élevées et à l'utiliser à l'avenir comme un outil autonome.

public class EuropeShop : Shop
{
    public override void CreateSale()
    {
        // 1) extracted to a repository
        var itemsRepository = new ItemsRepository();
        var items = itemsRepository.LoadSelectedItems();
			
        // 2) extracted to a mapper
        var saleItems = items.ConvertToSaleItems();
			
        // 3) still creates a cart
        var cart = new Cart();
        cart.Add(saleItems);
			
        // 4) all routines to apply taxes are extracted to the Tax-object
        new EuropeTaxes().ApplyTaxes(cart);
			
        new EuropeShopNotifier().Send(cart);
			
        // 5) extracted to a repository
        itemsRepository.Save(cart);
    }
}

Quant aux tests, ces scénarios seront suffisants. Jusqu'à présent, leur mise en œuvre ne nous intéresse pas.

public class EuropeTaxesTests
{
    public void Should_not_fail_for_null() { }

    public void Should_apply_taxes_to_items() { }

    public void Should_apply_taxes_to_whole_cart() { }

    public void Should_apply_taxes_to_whole_cart_and_change_items() { }
}

public class EuropeShopNotifierTests
{
    public void Should_not_send_when_less_or_equals_to_1000() { }

    public void Should_send_when_greater_than_1000() { }

    public void Should_raise_exception_when_cannot_send() { }
}

Règle 4 :Ajoutez uniquement le code testé

Comme je l'ai écrit plus tôt, vous devez minimiser les modifications apportées à l'ancien code. Pour ce faire, l'ancien et le nouveau code/modifié peuvent être séparés. Le nouveau code peut être placé dans des méthodes qui peuvent être vérifiées à l'aide de tests unitaires. Cette approche contribuera à réduire les risques associés. Deux techniques ont été décrites dans le livre "Travailler efficacement avec le code hérité" (lien vers le livre ci-dessous).

Méthode/classe Sprout - cette technique vous permet d'intégrer un nouveau code très sécurisé dans un ancien. La façon dont j'ai ajouté le notificateur est un exemple de cette approche.

Méthode Wrap - un peu plus compliquée, mais l'essence est la même. Cela ne fonctionne pas toujours, mais seulement dans les cas où un nouveau code est appelé avant/après un ancien. Lors de l'attribution des responsabilités, deux appels de la méthode ApplyTaxes ont été remplacés par un seul appel. Pour cela, il a fallu changer la deuxième méthode afin que la logique ne se brise pas trop et qu'elle puisse être vérifiée. Voilà à quoi ressemblait la classe avant les changements.

public class EuropeTaxes : Taxes
{
    internal override SaleItem ApplyTaxes(Item item)
    {
        var saleItem = new SaleItem(item)
        {
            SalePrice = item.Price*1.2m
        };
        return saleItem;
    }

    internal override void ApplyTaxes(Cart cart)
    {
        if (cart.TotalSalePrice <= 300m) return;
        var exclusion = 30m/cart.SaleItems.Count;
        foreach (var item in cart.SaleItems)
            if (item.SalePrice - exclusion > 100m)
                item.SalePrice -= exclusion;
    }
}

Et voici à quoi ça ressemble après. La logique de travail avec les éléments du chariot a un peu changé, mais en général, tout est resté le même. Dans ce cas, l'ancienne méthode appelle d'abord un nouveau ApplyToItems, puis sa version précédente. C'est l'essence même de cette technique.

public class EuropeTaxes : Taxes
{
    internal override void ApplyTaxes(Cart cart)
    {
        ApplyToItems(cart);
        ApplyToCart(cart);
    }

    private void ApplyToItems(Cart cart)
    {
        foreach (var item in cart.SaleItems)
            item.SalePrice = item.Price*1.2m;
    }

    private void ApplyToCart(Cart cart)
    {
        if (cart.TotalSalePrice <= 300m) return;
        var exclusion = 30m / cart.SaleItems.Count;
        foreach (var item in cart.SaleItems)
            if (item.SalePrice - exclusion > 100m)
                item.SalePrice -= exclusion;
    }
}

Règle 5 :"Casser" les dépendances cachées

C'est la règle du plus grand mal dans un vieux code :l'utilisation du nouveau opérateur à l'intérieur de la méthode d'un objet pour créer d'autres objets, référentiels ou autres objets complexes. Pourquoi est-ce mauvais ? L'explication la plus simple est que cela rend les parties du système fortement connectées et contribue à réduire leur cohérence. Encore plus court :conduit à la violation du principe « faible couplage, forte cohésion ». Si vous regardez de l'autre côté, ce code est trop difficile à extraire dans un outil séparé et indépendant. Se débarrasser immédiatement de ces dépendances cachées est très laborieux. Mais cela peut se faire progressivement.

Tout d'abord, vous devez transférer l'initialisation de toutes les dépendances au constructeur. Cela s'applique en particulier au nouveau opérateurs et la création de classes. Si vous avez ServiceLocator pour obtenir des instances de classes, vous devez également le supprimer du constructeur, où vous pouvez en extraire toutes les interfaces nécessaires.

Deuxièmement, les variables qui stockent l'instance d'un objet/référentiel externe doivent avoir un type abstrait, et mieux une interface. L'interface est meilleure car elle offre plus de fonctionnalités à un développeur. En conséquence, cela permettra de créer un outil atomique à partir d'un module.

Troisièmement, ne laissez pas de grandes feuilles de méthode. Cela montre clairement que la méthode fait plus que ce qui est spécifié dans son nom. C'est également révélateur d'une éventuelle violation de SOLID, la loi de Déméter.

Exemple

Voyons maintenant comment le code qui crée le panier a été modifié. Seul le bloc de code qui crée le panier est resté inchangé. Le reste a été placé dans des classes externes et peut être remplacé par n'importe quelle implémentation. Maintenant, la classe EuropeShop prend la forme d'un outil atomique qui a besoin de certaines choses qui sont explicitement représentées dans le constructeur. Le code devient plus facile à percevoir.

public class EuropeShop : Shop
{
    private readonly IItemsRepository _itemsRepository;
    private readonly Taxes.Taxes _europeTaxes;
    private readonly INotifier _europeShopNotifier;

    public EuropeShop()
    {
        _itemsRepository = new ItemsRepository();
        _europeTaxes = new EuropeTaxes();
        _europeShopNotifier = new EuropeShopNotifier();
    }

    public override void CreateSale()
    {
        var items = _itemsRepository.LoadSelectedItems();
        var saleItems = items.ConvertToSaleItems();

        var cart = new Cart();
        cart.Add(saleItems);

        _europeTaxes.ApplyTaxes(cart);
        _europeShopNotifier.Send(cart);
        _itemsRepository.Save(cart);
    }
}SCRIPT

Règle 6 :moins il y a de gros tests, mieux c'est

Les grands tests sont différents tests d'intégration qui tentent de tester les scripts utilisateur. Sans aucun doute, ils sont importants, mais vérifier la logique de certains IF dans la profondeur du code coûte très cher. L'écriture de ce test prend le même temps, sinon plus, que l'écriture de la fonctionnalité elle-même. Les prendre en charge est comme un autre code hérité, difficile à modifier. Mais ce ne sont que des tests !

Il est nécessaire de comprendre quels tests sont nécessaires et d'adhérer clairement à cette compréhension. Si vous avez besoin d'une vérification d'intégration, écrivez un ensemble minimum de tests, y compris des scénarios d'interaction positifs et négatifs. Si vous avez besoin de tester l'algorithme, écrivez un ensemble minimal de tests unitaires.

Règle 7 :Ne testez pas les méthodes privées

Une méthode privée peut être trop complexe ou contenir du code qui n'est pas appelé à partir de méthodes publiques. Je suis sûr que toute autre raison à laquelle vous pouvez penser se révélera être une caractéristique d'un "mauvais" code ou design. Très probablement, une partie du code de la méthode privée devrait devenir une méthode/classe distincte. Vérifiez si le premier principe de SOLID est violé. C'est la première raison pour laquelle cela ne vaut pas la peine de le faire. La seconde est que de cette façon vous ne vérifiez pas le comportement de l'ensemble du module, mais comment le module l'implémente. L'implémentation interne peut changer quel que soit le comportement du module. Par conséquent, dans ce cas, vous obtenez des tests fragiles, et cela prend plus de temps que nécessaire pour les supporter.

Pour éviter d'avoir à tester des méthodes privées, présentez vos classes comme un ensemble d'outils atomiques et vous ne savez pas comment elles sont implémentées. Vous vous attendez à un certain comportement que vous testez. Cette attitude s'applique également aux cours dans le cadre de l'assemblée. Les classes accessibles aux clients (d'autres assemblées) seront publiques, et celles qui effectuent un travail interne – privées. Bien qu'il y ait une différence de méthodes. Les classes internes peuvent être complexes, elles peuvent donc être transformées en classes internes et également testées.

Exemple

Par exemple, pour tester une condition dans la méthode privée de la classe EuropeTaxes, je n'écrirai pas de test pour cette méthode. Je m'attends à ce que les taxes soient appliquées d'une certaine manière, de sorte que le test reflétera ce comportement même. Lors du test, j'ai compté manuellement ce qui devrait être le résultat, je l'ai pris comme standard et j'attends le même résultat de la classe.

public class EuropeTaxes : Taxes
{
    // code skipped

    private void ApplyToCart(Cart cart)
    {
        if (cart.TotalSalePrice <= 300m) return; // <<< I WANT TO TEST THIS CONDIFTION
        var exclusion = 30m / cart.SaleItems.Count;
        foreach (var item in cart.SaleItems)
            if (item.SalePrice - exclusion > 100m)
                item.SalePrice -= exclusion;
    }
}

// test suite
public class EuropeTaxesTests
{
    // code skipped

    [Fact]
    public void Should_apply_taxes_to_cart_greater_300()
    {
        #region arrange
        // list of items which will create a cart greater 300
        var saleItems = new List<Item>(new[]{new Item {Price = 83.34m},
            new Item {Price = 83.34m},new Item {Price = 83.34m}})
            .ConvertToSaleItems();
        var cart = new Cart();
        cart.Add(saleItems);

        const decimal expected = 83.34m*3*1.2m;
        #endregion

        // act
        new EuropeTaxes().ApplyTaxes(cart);

        // assert
        Assert.Equal(expected, cart.TotalSalePrice);
    }
}

Règle 8 :Ne testez pas l'algorithme des méthodes

Certaines personnes vérifient le nombre d'appels de certaines méthodes, vérifient l'appel lui-même, etc., en d'autres termes, vérifient le travail interne des méthodes. C'est aussi mauvais que de tester les privés. La différence réside uniquement dans la couche d'application d'un tel contrôle. Cette approche donne encore une fois beaucoup de tests fragiles, donc certaines personnes ne prennent pas TDD correctement.

En savoir plus…

Règle 9 :Ne modifiez pas l'ancien code sans tests

C'est la règle la plus importante car elle reflète une volonté d'équipe de suivre cette voie. Sans la volonté d'aller dans ce sens, tout ce qui a été dit plus haut n'a pas de sens particulier. Parce que si un développeur ne veut pas utiliser TDD (ne comprend pas sa signification, n'en voit pas les avantages, etc.), alors son véritable avantage sera brouillé par une discussion constante sur sa difficulté et son inefficacité.

Si vous envisagez d'utiliser TDD, discutez-en avec votre équipe, ajoutez-le à la définition de terminé et appliquez-le. Au début, ce sera difficile, comme pour tout ce qui est nouveau. Comme tout art, le TDD nécessite une pratique constante et le plaisir vient au fur et à mesure que vous apprenez. Progressivement, il y aura plus de tests unitaires écrits, vous commencerez à sentir la "santé" de votre système et commencerez à apprécier la simplicité d'écriture du code, décrivant les exigences dans la première étape. Il existe des études TDD menées sur de vrais gros projets chez Microsoft et IBM, montrant une réduction des bogues dans les systèmes de production de 40 % à 80 % (voir les liens ci-dessous).

Autres lectures

  1. Livre "Travailler efficacement avec le code hérité" par Michael Feathers
  2. TDD jusqu'au cou dans Legacy Code
  3. Briser les dépendances cachées
  4. Le cycle de vie du code hérité
  5. Devez-vous tester unitairement les méthodes privées sur une classe ?
  6. Internes de tests unitaires
  7. Cinq idées fausses courantes sur le TDD et les tests unitaires
  8. Loi de Déméter