Quali sono i principi di progettazione che promuovono il codice verificabile? (progettazione del codice testabile rispetto alla guida alla progettazione attraverso i test)


54

La maggior parte dei progetti su cui lavoro considera lo sviluppo e le unit testing in isolamento, il che rende la scrittura di unit test in un secondo momento un incubo. Il mio obiettivo è tenere a mente i test durante le fasi di progettazione di alto livello e basso livello stesso.

Voglio sapere se ci sono dei principi di progettazione ben definiti che promuovono il codice verificabile. Uno di questi principi che ho capito di recente è l'inversione delle dipendenze attraverso l'iniezione delle dipendenze e l'inversione del controllo.

Ho letto che esiste qualcosa noto come SOLID. Voglio capire se il rispetto dei principi SOLID risulta indirettamente in codice facilmente testabile? In caso contrario, esistono principi di progettazione ben definiti che promuovono il codice verificabile?

Sono consapevole che esiste qualcosa noto come Test Driven Development. Tuttavia, sono più interessato a progettare il codice tenendo conto dei test durante la fase di progettazione stessa piuttosto che guidare la progettazione attraverso i test. Spero che abbia senso.

Un'altra domanda relativa a questo argomento è se va bene ricodificare un prodotto / progetto esistente e apportare modifiche al codice e al design allo scopo di poter scrivere un test unit unit per ciascun modulo?



Grazie. Ho appena iniziato a leggere l'articolo e ha già senso.

1
Questa è una delle domande del mio colloquio ("Come si progetta il codice per essere facilmente testato sull'unità?"). Mi mostra da solo se capiscono il test unitario, il derisione / stub, OOD e potenzialmente TDD. Purtroppo, le risposte di solito sono qualcosa come "Crea un database di test".
Chris Pitman,

Risposte:


57

Sì, SOLID è un ottimo modo per progettare codice che può essere facilmente testato. Come primer breve:

S - Principio di responsabilità singola: un oggetto dovrebbe fare esattamente una cosa e dovrebbe essere l'unico oggetto nella base di codice che fa quell'unica cosa. Ad esempio, prendi una classe di dominio, ad esempio una fattura. La classe Fattura dovrebbe rappresentare la struttura dei dati e le regole commerciali di una fattura come utilizzata nel sistema. Dovrebbe essere l'unica classe che rappresenta una fattura nella base di codice. Questo può essere ulteriormente suddiviso per dire che un metodo dovrebbe avere uno scopo e dovrebbe essere l'unico metodo nella base di codice che soddisfa questa esigenza.

Seguendo questo principio, aumenti la testabilità del tuo progetto diminuendo il numero di test che devi scrivere che testano la stessa funzionalità su oggetti diversi e di solito finisci con pezzi di funzionalità più piccoli che sono più facili da testare isolatamente.

O - Principio aperto / chiuso: una classe dovrebbe essere aperta all'estensione, ma chiusa al cambiamento . Una volta che un oggetto esiste e funziona correttamente, idealmente non dovrebbe essere necessario tornare a quell'oggetto per apportare modifiche che aggiungono nuove funzionalità. Invece, l'oggetto dovrebbe essere esteso, derivandolo o inserendo in esso implementazioni di dipendenza nuove o diverse, per fornire quella nuova funzionalità. Questo evita la regressione; è possibile introdurre la nuova funzionalità quando e dove è necessario, senza modificare il comportamento dell'oggetto in quanto è già utilizzato altrove.

Aderendo a questo principio, generalmente aumenti la capacità del codice di tollerare "beffe" e eviti anche di dover riscrivere i test per anticipare nuovi comportamenti; tutti i test esistenti per un oggetto dovrebbero comunque funzionare sull'implementazione non estesa, mentre dovrebbero funzionare anche i nuovi test per nuove funzionalità che utilizzano l'implementazione estesa.

L - Principio di sostituzione di Liskov: una classe A, dipendente dalla classe B, dovrebbe essere in grado di usare qualsiasi X: B senza conoscere la differenza. Ciò significa sostanzialmente che tutto ciò che usi come dipendenza dovrebbe avere un comportamento simile a quello visto dalla classe dipendente. Ad esempio, supponiamo di avere un'interfaccia IWriter che espone Write (string), che è implementato da ConsoleWriter. Ora devi invece scrivere su un file, quindi crei FileWriter. Nel fare ciò, è necessario assicurarsi che FileWriter possa essere utilizzato allo stesso modo di ConsoleWriter (il che significa che l'unico modo in cui il dipendente può interagire con esso è chiamare Scrivi (stringa)) e quindi ulteriori informazioni che FileWriter potrebbe aver bisogno di fare il lavoro (come il percorso e il file in cui scrivere) deve essere fornito da un'altra parte rispetto al dipendente.

Questo è enorme per la scrittura di codice testabile, perché un progetto conforme all'LSP può avere un oggetto "deriso" sostituito alla cosa reale in qualsiasi momento senza cambiare il comportamento previsto, consentendo di testare piccoli pezzi di codice in isolamento con la sicurezza che il sistema funzionerà quindi con gli oggetti reali collegati.

I - Principio di segregazione dell'interfaccia: un'interfaccia dovrebbe avere il minor numero di metodi possibile per fornire la funzionalità del ruolo definito dall'interfaccia . In poche parole, interfacce più piccole sono meglio di meno interfacce più grandi. Questo perché un'interfaccia di grandi dimensioni ha più motivi per cambiare e provoca altre modifiche altrove nella base di codice che potrebbero non essere necessarie.

L'adesione all'ISP migliora la testabilità riducendo la complessità dei sistemi in prova e delle dipendenze di tali SUT. Se l'oggetto che si sta testando dipende da un'interfaccia IDoThreeThings che espone DoOne (), DoTwo () e DoThree (), è necessario deridere un oggetto che implementa tutti e tre i metodi anche se l'oggetto utilizza solo il metodo DoTwo. Ma, se l'oggetto dipende solo da IDoTwo (che espone solo DoTwo), puoi deridere più facilmente un oggetto che ha quel metodo.

D - Principio di inversione di dipendenza: le concrezioni e le astrazioni non dovrebbero mai dipendere da altre concrezioni, ma dalle astrazioni . Questo principio applica direttamente il principio dell'accoppiamento libero. Un oggetto non dovrebbe mai sapere cosa sia un oggetto; dovrebbe invece preoccuparsi di ciò che fa un oggetto. Pertanto, l'uso di interfacce e / o classi di base astratte è sempre da preferire all'uso di implementazioni concrete quando si definiscono proprietà e parametri di un oggetto o metodo. Ciò consente di scambiare un'implementazione con un'altra senza dover cambiare l'utilizzo (se si segue anche LSP, che va di pari passo con DIP).

Ancora una volta, questo è enorme per la testabilità, in quanto ti consente, ancora una volta, di iniettare un'implementazione fittizia di una dipendenza anziché un'implementazione di "produzione" nel tuo oggetto in fase di test, pur testando l'oggetto nella forma esatta che avrà mentre in produzione. Questa è la chiave per test unitari "in isolamento".


16

Ho letto che esiste qualcosa noto come SOLID. Voglio capire se il rispetto dei principi SOLID risulta indirettamente in codice facilmente testabile?

Se applicato correttamente, sì. C'è un post sul blog di Jeff che spiega i principi SOLID in un modo molto breve (vale la pena ascoltare anche il podcast citato), suggerisco di dare un'occhiata lì se descrizioni più lunghe ti stanno buttando fuori.

Dalla mia esperienza, 2 principi di SOLID svolgono un ruolo importante nella progettazione di codice testabile:

  • Principio di segregazione delle interfacce : dovresti preferire molte interfacce specifiche per il cliente piuttosto che poche per scopi generici. Questo va di pari passo con il principio di responsabilità singola e ti aiuta a progettare classi orientate alle caratteristiche / attività, che in cambio sono molto più facili da testare (rispetto a quelle più generali, o spesso "gestori" e "contesti" abusati ) - meno dipendenze , meno complessità, prove più precise, ovvie. In breve, piccoli componenti portano a semplici test.
  • Principio di inversione di dipendenza - progettazione per contratto, non per implementazione. Questo ti gioverà di più quando testerai oggetti complessi e ti renderai conto che non hai bisogno di un intero grafico di dipendenze solo per configurarlo , ma puoi semplicemente deridere l'interfaccia e farla finita.

Credo che questi due ti aiuteranno di più nella progettazione per la testabilità. Anche quelli rimanenti hanno un impatto, ma direi che non è così grande.

(...) se va bene ricodificare un prodotto / progetto esistente e apportare modifiche al codice e al design allo scopo di poter scrivere un test unit unit per ciascun modulo?

Senza test unitari esistenti, viene semplicemente messo - chiedendo problemi. Il test unitario è la garanzia che il codice funzioni . L'introduzione della modifica della rottura viene individuata immediatamente se si dispone di una copertura dei test adeguata.

Ora, se vuoi cambiare il codice esistente per aggiungere unit test , questo introduce un gap in cui non hai ancora dei test, ma hai già cambiato codice . Naturalmente, potresti non avere idea di cosa abbiano rotto le tue modifiche. Questa è la situazione che vuoi evitare.

Vale comunque la pena di scrivere unit test, anche a fronte di codici difficili da testare. Se il codice funziona , ma non è testato dall'unità, la soluzione appropriata sarebbe scrivere test per esso e quindi introdurre le modifiche. Tuttavia, tieni presente che la modifica del codice testato per renderlo più facilmente verificabile è qualcosa su cui la tua direzione potrebbe non voler spendere soldi (probabilmente sentirai che porta poco o nessun valore commerciale).


tra cui alta coesione e basso accoppiamento
jk.

8

LA TUA PRIMA DOMANDA:

SOLID è davvero la strada da percorrere. Trovo che i due aspetti più importanti dell'acronimo SOLID, quando si tratta di testabilità, sono S (Single Responsibility) e D (Dependency Injection).

Responsabilità unica : le tue lezioni dovrebbero davvero fare solo una cosa e solo una cosa. una classe che crea un file, analizza alcuni input e li scrive nel file sta già facendo tre cose. Se la tua classe fa solo una cosa, sai esattamente cosa aspettarti da essa, e progettare i casi di test per questo dovrebbe essere abbastanza facile.

Dependency Injection (DI): ti dà il controllo dell'ambiente di test. Invece di creare oggetti forreign all'interno del codice, lo si inietta attraverso il costruttore della classe o la chiamata del metodo. Quando non lo sei, sostituisci semplicemente le classi reali con stub o mock, che controlli interamente.

LA TUA SECONDA DOMANDA: Idealmente, scrivi dei test che documentano il funzionamento del tuo codice prima di riformattarlo. In questo modo, è possibile documentare che il refactoring riproduce gli stessi risultati del codice originale. Tuttavia, il problema è che il codice di funzionamento è difficile da testare. Questa è una situazione classica! Il mio consiglio è: riflettere attentamente sul refactoring prima del test unitario. Se puoi; scrivere test per il codice di lavoro, quindi refactoring il codice e quindi refactoring dei test. So che costerà ore, ma sarai più sicuro che il codice refactored faccia lo stesso del vecchio. Detto questo, mi sono arreso molte volte. Le classi possono essere così brutte e disordinate che una riscrittura è l'unico modo per renderle testabili.


4

Oltre alle altre risposte, che si concentrano sul raggiungimento di un accoppiamento libero, vorrei dire una parola sul test della logica complicata.

Una volta ho dovuto testare un'unità di una classe la cui logica era complessa, con molti condizionali e dove era difficile capire il ruolo dei campi.

Ho sostituito questo codice con molte piccole classi che rappresentano una macchina a stati . La logica divenne molto più semplice da seguire, poiché i diversi stati della precedente classe divennero espliciti. Ogni classe statale era indipendente dalle altre e quindi era facilmente testabile.

Il fatto che gli stati fossero espliciti ha reso più semplice enumerare tutti i possibili percorsi del codice (le transizioni di stato) e quindi scrivere un test unitario per ciascuno di essi.

Naturalmente, non tutte le logiche complesse possono essere modellate come una macchina a stati.


3

SOLID è un ottimo inizio, secondo la mia esperienza, quattro degli aspetti di SOLID funzionano davvero bene con i test unitari.

  • Principio unico di responsabilità - ogni classe fa solo una cosa e una cosa. Calcolo di un valore, apertura di un file, analisi di una stringa, qualunque cosa. La quantità di input e output, così come i punti di decisione, dovrebbe quindi essere molto minima. Il che rende facile scrivere test.
  • Principio di sostituzione di Liskov - dovresti essere in grado di sostituire stub e beffe senza alterare le proprietà desiderabili (i risultati previsti) del tuo codice.
  • Principio di segregazione delle interfacce : la separazione dei punti di contatto dalle interfacce semplifica l'utilizzo di un framework di derisione come Moq per creare stub e mock. Invece di dover fare affidamento sulle classi concrete, fai semplicemente affidamento su qualcosa che implementa l'interfaccia.
  • Principio di iniezione di dipendenza - Questo è ciò che consente di iniettare quegli stub e mock nel codice tramite un costruttore, una proprietà o un parametro nel metodo che si desidera testare.

Vorrei anche esaminare diversi modelli, in particolare il modello di fabbrica. Diciamo che hai una classe concreta che implementa un'interfaccia. Dovresti creare una factory per creare un'istanza della classe concrete, ma restituisci invece l'interfaccia.

public interface ISomeInterface
{
    int GetValue();
}  

public class SomeClass : ISomeInterface
{
    public int GetValue()
    {
         return 1;
    }
}

public interface ISomeOtherInterface
{
    bool IsSuccess();
}

public class SomeOtherClass : ISomeOtherInterface
{
     private ISomeInterface m_SomeInterface;

     public SomeOtherClass(ISomeInterface someInterface)
     {
          m_SomeInterface = someInterface;
     }

     public bool IsSuccess()
     {
          return m_SomeInterface.GetValue() == 1;
     }
}

public class SomeFactory
{
     public virtual ISomeInterface GetSomeInterface()
     {
          return new SomeClass();
     }

     public virtual ISomeOtherInterface GetSomeOtherInterface()
     {
          ISomeInterface someInterface = GetSomeInterface();

          return new SomeOtherClass(someInterface);
     }
}

Nei tuoi test puoi Moq o qualche altro framework beffardo per sovrascrivere quel metodo virtuale e restituire un'interfaccia del tuo progetto. Ma per quanto riguarda il codice di attuazione, la fabbrica non è cambiata. Puoi anche nascondere molti dettagli dell'implementazione in questo modo, il tuo codice di implementazione non si preoccupa del modo in cui l'interfaccia è costruita, tutto ciò che importa è recuperare un'interfaccia.

Se vuoi approfondire un po 'questo, consiglio vivamente di leggere The Art of Unit Testing . Fornisce alcuni ottimi esempi su come utilizzare questi principi ed è una lettura abbastanza veloce.


1
Si chiama principio di "inversione" delle dipendenze, non principio di "iniezione".
Mathias Lykkegaard Lorenzen,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.