L'articolo "Sliding Responsibility of the Repository Pattern" ha sollevato diverse domande a cui è molto difficile rispondere. Abbiamo bisogno di un repository se è impossibile ignorare completamente i dettagli tecnici? Quanto deve essere complesso il repository affinché la sua aggiunta possa essere considerata utile? La risposta a queste domande varia a seconda dell'enfasi posta nello sviluppo dei sistemi. Probabilmente la domanda più difficile è la seguente:hai bisogno anche di un repository? Il problema dell'"astrazione scorrevole" e la crescente complessità della codifica con un aumento del livello di astrazione non consentono di trovare una soluzione che soddisfi entrambi i lati della recinzione. Ad esempio, nel reporting, la progettazione dell'intenzione porta alla creazione di un gran numero di metodi per ciascun filtro e ordinamento e una soluzione generica crea un elevato sovraccarico di codifica.
Per avere un quadro completo, ho esaminato il problema delle astrazioni in termini di applicazione in un codice legacy. Un repository, in questo caso, ci interessa solo come strumento per ottenere codice di qualità e senza bug. Naturalmente, questo schema non è l'unica cosa necessaria per l'applicazione delle pratiche TDD. Avendo mangiato un moggio di sale durante lo sviluppo di diversi grandi progetti e osservando cosa funziona e cosa no, ho sviluppato alcune regole per me stesso che mi aiutano a seguire le pratiche TDD. Sono aperto a una critica costruttiva e ad altri metodi di implementazione del TDD.
Premessa
Alcuni potrebbero notare che non è possibile applicare TDD in un vecchio progetto. Si ritiene che diversi tipi di test di integrazione (test dell'interfaccia utente, end-to-end) siano più adatti a loro perché è troppo difficile capire il vecchio codice. Inoltre, puoi sentire che scrivere test prima della codifica effettiva porta solo a una perdita di tempo, perché potremmo non sapere come funzionerà il codice. Ho dovuto lavorare su diversi progetti, dove mi sono limitato ai soli test di integrazione, ritenendo che gli unit test non siano indicativi. Allo stesso tempo, sono stati scritti molti test, hanno eseguito molti servizi, ecc. Di conseguenza, solo una persona poteva capirli, che, in effetti, li ha scritti.
Durante la mia pratica, sono riuscito a lavorare su diversi progetti molto grandi, dove c'era molto codice legacy. Alcuni di loro prevedevano test e altri no (c'era solo l'intenzione di implementarli). Ho partecipato a due grandi progetti, nei quali ho cercato in qualche modo di applicare l'approccio TDD. Nella fase iniziale, TDD è stato percepito come uno sviluppo Test First. Alla fine, le differenze tra questa comprensione semplificata e la percezione attuale, chiamata in breve BDD, divennero più chiare. Qualunque sia la lingua utilizzata, i punti principali, li chiamo regole, rimangono simili. Qualcuno può trovare parallelismi tra le regole e altri principi per scrivere un buon codice.
Regola 1:Usare Bottom-Up (Inside-Out)
Questa regola si riferisce piuttosto al metodo di analisi e progettazione del software quando si incorporano nuove parti di codice in un progetto di lavoro.
Quando si progetta un nuovo progetto, è assolutamente naturale immaginare un intero sistema. In questa fase, controlli sia l'insieme dei componenti che la futura flessibilità dell'architettura. Pertanto, puoi scrivere moduli che possono essere facilmente e intuitivamente integrati tra loro. Tale approccio dall'alto verso il basso consente di eseguire una buona progettazione anticipata dell'architettura futura, descrivere le linee guida necessarie e avere un quadro completo di ciò che, alla fine, si desidera. Dopo un po', il progetto si trasforma in quello che viene chiamato il codice legacy. E poi inizia il divertimento.
Nella fase in cui è necessario incorporare una nuova funzionalità in un progetto esistente con un mucchio di moduli e dipendenze tra di loro, può essere molto difficile metterli tutti in testa per realizzare il design corretto. L'altro lato di questo problema è la quantità di lavoro richiesta per svolgere questo compito. Pertanto, l'approccio bottom-up sarà più efficace in questo caso. In altre parole, prima crei un modulo completo che risolva il compito necessario, quindi lo incorpori nel sistema esistente, apportando solo le modifiche necessarie. In questo caso, puoi garantire la qualità di questo modulo, in quanto è un'unità completa del funzionale.
Va notato che non è tutto così semplice con gli approcci. Ad esempio, durante la progettazione di una nuova funzionalità in un vecchio sistema, utilizzerai, che ti piaccia o no, entrambi gli approcci. Durante l'analisi iniziale, è ancora necessario valutare il sistema, quindi abbassarlo al livello del modulo, implementarlo e quindi tornare al livello dell'intero sistema. A mio avviso, la cosa principale qui non è dimenticare che il nuovo modulo dovrebbe essere una funzionalità completa ed essere indipendente, come strumento separato. Più ti atterrai rigorosamente a questo approccio, meno modifiche verranno apportate al vecchio codice.
Regola 2:prova solo il codice modificato
Quando si lavora con un vecchio progetto, non è assolutamente necessario scrivere test per tutti i possibili scenari del metodo/classe. Inoltre, potresti non essere affatto a conoscenza di alcuni scenari, poiché potrebbero essercene molti. Il progetto è già in produzione, il cliente è soddisfatto, quindi puoi rilassarti. In generale, solo le tue modifiche causano problemi in questo sistema. Pertanto, solo loro dovrebbero essere testati.
Esempio
C'è un modulo del negozio online, che crea un carrello di articoli selezionati e lo memorizza in un database. Non ci interessa l'implementazione specifica. Fatto come fatto:questo è il codice legacy. Ora dobbiamo introdurre qui un nuovo comportamento:inviare una notifica all'ufficio contabilità nel caso in cui il costo del carrello superi $ 1000. Ecco il codice che vediamo. Come introdurre il cambiamento?
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); } }
Secondo la prima regola, le modifiche devono essere minime e atomiche. Non ci interessa il caricamento dei dati, non ci interessa il calcolo delle tasse e il salvataggio nel database. Ma siamo interessati al carrello calcolato. Se esistesse un modulo che fa ciò che è richiesto, eseguirebbe l'attività necessaria. Ecco perché lo facciamo.
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); } }
Tale notificatore funziona da solo, può essere testato e le modifiche apportate al vecchio codice sono minime. Questo è esattamente ciò che dice la seconda regola.
Regola 3:testiamo solo i requisiti
Per liberarti dal numero di scenari che richiedono test con unit test, pensa a ciò di cui hai effettivamente bisogno da un modulo. Scrivi prima l'insieme minimo di condizioni che puoi immaginare come requisiti per il modulo. Il set minimo è il set, che quando integrato con uno nuovo, il comportamento del modulo non cambia molto e, quando viene rimosso, il modulo non funziona. L'approccio BDD aiuta molto in questo caso.
Inoltre, immagina come le altre classi che sono clienti del tuo modulo interagiranno con esso. Hai bisogno di scrivere 10 righe di codice per configurare il tuo modulo? Più semplice è la comunicazione tra le parti del sistema, meglio è. Pertanto, è meglio selezionare i moduli responsabili di qualcosa di specifico dal vecchio codice. SOLID verrà in aiuto in questo caso.
Esempio
Ora vediamo come tutto quanto descritto sopra ci aiuterà con il codice. Per prima cosa, seleziona tutti i moduli che sono associati solo indirettamente alla creazione del carrello. Ecco come viene distribuita la responsabilità dei moduli.
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); } }
In questo modo si possono distinguere. Naturalmente, tali modifiche non possono essere apportate contemporaneamente in un sistema di grandi dimensioni, ma possono essere apportate gradualmente. Ad esempio, quando le modifiche riguardano un modulo fiscale, è possibile semplificare il modo in cui altre parti del sistema dipendono da esso. Questo può aiutare a sbarazzarsi di dipendenze elevate e utilizzarlo in futuro come strumento autonomo.
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); } }
Per quanto riguarda i test, questi scenari saranno sufficienti. Finora, la loro implementazione non ci interessa.
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() { } }
Regola 4:aggiungi solo codice testato
Come ho scritto in precedenza, dovresti ridurre al minimo le modifiche al vecchio codice. Per fare ciò, è possibile dividere il codice vecchio e quello nuovo/modificato. Il nuovo codice può essere inserito in metodi che possono essere verificati mediante unit test. Questo approccio aiuterà a ridurre i rischi associati. Ci sono due tecniche che sono state descritte nel libro "Lavorare efficacemente con il codice legacy" (link al libro qui sotto).
Metodo/classe Sprout:questa tecnica consente di incorporare un nuovo codice molto sicuro in uno vecchio. Il modo in cui ho aggiunto il notificante è un esempio di questo approccio.
Metodo di avvolgimento:un po' più complicato, ma l'essenza è la stessa. Non sempre funziona, ma solo nei casi in cui un nuovo codice viene chiamato prima/dopo quello vecchio. Durante l'assegnazione delle responsabilità, due chiamate del metodo ApplyTaxes sono state sostituite da una chiamata. Per questo, è stato necessario modificare il secondo metodo in modo che la logica non si rompesse di molto e potesse essere verificata. Ecco com'era la classe prima dei cambiamenti.
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; } }
Ed ecco come si prende cura. La logica di lavorare con gli elementi del carrello è leggermente cambiata, ma in generale tutto è rimasto lo stesso. In questo caso, il vecchio metodo chiama prima un nuovo ApplyToItems, quindi la sua versione precedente. Questa è l'essenza di questa tecnica.
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; } }
Regola 5:"Interrompi" le dipendenze nascoste
Questa è la regola sul male più grande in un vecchio codice:l'uso del nuovo operatore all'interno del metodo di un oggetto per creare altri oggetti, repository o altri oggetti complessi. Perché è così male? La spiegazione più semplice è che ciò rende le parti del sistema altamente connesse e contribuisce a ridurne la coerenza. Ancora più breve:porta alla violazione del principio “basso accoppiamento, alta coesione”. Se guardi dall'altra parte, allora questo codice è troppo difficile da estrarre in uno strumento separato e indipendente. Sbarazzarsi di tali dipendenze nascoste in una volta è molto laborioso. Ma questo può essere fatto gradualmente.
Innanzitutto, devi trasferire l'inizializzazione di tutte le dipendenze al costruttore. In particolare, questo vale per il nuovo operatori e la creazione di classi. Se hai ServiceLocator per ottenere istanze di classi, dovresti anche rimuoverlo nel costruttore, dove puoi estrarre tutte le interfacce necessarie da esso.
In secondo luogo, le variabili che memorizzano l'istanza di un oggetto/repository esterno devono avere un tipo astratto e meglio un'interfaccia. L'interfaccia è migliore perché fornisce più funzionalità a uno sviluppatore. Di conseguenza, ciò consentirà di creare uno strumento atomico da un modulo.
In terzo luogo, non lasciare fogli di metodo di grandi dimensioni. Questo mostra chiaramente che il metodo fa più di quanto specificato nel suo nome. È anche indicativo di una possibile violazione di SOLID, la Legge di Demetra.
Esempio
Ora vediamo come è stato modificato il codice che crea il carrello. Solo il blocco di codice che crea il carrello è rimasto invariato. Il resto è stato collocato in classi esterne e può essere sostituito da qualsiasi implementazione. Ora la classe EuropeShop assume la forma di uno strumento atomico che necessita di alcune cose che sono esplicitamente rappresentate nel costruttore. Il codice diventa più facile da percepire.
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
Regola 6:meno grandi test sono, meglio è
I grandi test sono diversi test di integrazione che tentano di testare gli script utente. Indubbiamente sono importanti, ma controllare la logica di alcuni IF nella profondità del codice è molto costoso. La scrittura di questo test richiede la stessa quantità di tempo, se non di più, come la scrittura della funzionalità stessa. Supportarli è come un altro codice legacy, che è difficile da modificare. Ma questi sono solo test!
È necessario capire quali test sono necessari e aderire chiaramente a questa comprensione. Se hai bisogno di un controllo di integrazione, scrivi un set minimo di test, inclusi scenari di interazione positivi e negativi. Se devi testare l'algoritmo, scrivi un set minimo di unit test.
Regola 7:non testare metodi privati
Un metodo privato può essere troppo complesso o contenere codice che non viene chiamato dai metodi pubblici. Sono sicuro che qualsiasi altro motivo a cui puoi pensare si rivelerà una caratteristica di un codice o di un design "cattivo". Molto probabilmente, una parte del codice del metodo privato dovrebbe essere trasformata in un metodo/classe separato. Verificare se il primo principio di SOLID è violato. Questo è il primo motivo per cui non vale la pena farlo. La seconda è che in questo modo si controlla non il comportamento dell'intero modulo, ma come il modulo lo implementa. L'implementazione interna può cambiare indipendentemente dal comportamento del modulo. Pertanto, in questo caso, ottieni test fragili e ci vuole più tempo del necessario per supportarli.
Per evitare la necessità di testare metodi privati, presenta le tue classi come un insieme di strumenti atomici e non sai come vengono implementati. Ti aspetti un comportamento che stai testando. Questo atteggiamento vale anche per le classi nel contesto dell'assemblea. Le lezioni a disposizione dei clienti (da altre assemblee) saranno pubbliche e quelle che svolgono lavori interni – private. Anche se c'è una differenza dai metodi. Le classi interne possono essere complesse, quindi possono essere trasformate in interne e anche testate.
Esempio
Ad esempio, per testare una condizione nel metodo privato della classe EuropeTaxes, non scriverò un test per questo metodo. Mi aspetto che le tasse vengano applicate in un certo modo, quindi il test rifletterà proprio questo comportamento. Nel test, ho contato manualmente quale dovrebbe essere il risultato, l'ho preso come standard e mi aspetto lo stesso risultato dalla 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); } }
Regola 8:non testare l'algoritmo dei metodi
Alcune persone controllano il numero di chiamate di determinati metodi, verificano la chiamata stessa, ecc., in altre parole, controllano il lavoro interno dei metodi. È altrettanto brutto come il test di quelli privati. La differenza è solo nel livello di applicazione di tale controllo. Questo approccio fornisce ancora una volta molti test fragili, quindi alcune persone non prendono il TDD correttamente.
Leggi di più...
Regola 9:non modificare il codice legacy senza test
Questa è la regola più importante perché riflette il desiderio di una squadra di seguire questa strada. Senza il desiderio di andare in questa direzione, tutto ciò che è stato detto sopra non ha un significato particolare. Perché se uno sviluppatore non vuole utilizzare TDD (non ne comprende il significato, non ne vede i vantaggi, ecc.), il suo vero vantaggio sarà offuscato dalla discussione costante su quanto sia difficile e inefficiente.
Se intendi utilizzare TDD, discuti con il tuo team, aggiungilo a Definition of Done e applicalo. All'inizio sarà dura, come con tutto ciò che è nuovo. Come ogni arte, il TDD richiede una pratica costante e il piacere arriva mentre impari. A poco a poco, ci saranno più unit test scritti, inizierai a sentire la "salute" del tuo sistema e inizierai ad apprezzare la semplicità della scrittura del codice, descrivendo i requisiti nella prima fase. Esistono studi TDD condotti su progetti di grandi dimensioni in Microsoft e IBM, che mostrano una riduzione dei bug nei sistemi di produzione dal 40% all'80% (vedi i link sotto).
Ulteriori letture
- Libro "Lavorare efficacemente con il codice legacy" di Michael Feathers
- TDD quando sei al collo in Legacy Code
- Rompere le dipendenze nascoste
- Il ciclo di vita del codice legacy
- Dovresti testare i metodi privati su una classe?
- Interni di unit test
- 5 idee sbagliate comuni su TDD e test unitari
- Legge di Demetra