Come scrivere test unitari prima del refactoring?


55

Ho letto alcune risposte a domande su una linea simile, ad esempio "Come si fa a far funzionare i test unitari durante il refactoring?". Nel mio caso lo scenario è leggermente diverso in quanto mi è stato dato un progetto da rivedere e allineare con alcuni standard che abbiamo, al momento non ci sono test per il progetto!

Ho identificato una serie di cose che penso avrebbero potuto essere fatte meglio come NON mescolare il codice di tipo DAO in un livello di servizio.

Prima di eseguire il refactoring è sembrata una buona idea scrivere test per il codice esistente. Il problema mi sembra che quando faccio refactoring quei test si interromperanno mentre sto cambiando dove viene fatta una certa logica e i test verranno scritti tenendo presente la struttura precedente (dipendenze derise ecc.)

Nel mio caso, quale sarebbe il modo migliore per procedere? Sono tentato di scrivere i test attorno al codice refactored ma sono consapevole che esiste il rischio che io possa riformattare le cose in modo errato che potrebbe cambiare il comportamento desiderato.

Che si tratti di un refattore o di una riprogettazione, sono contento che la mia comprensione di questi termini sia corretta, al momento sto lavorando alla seguente definizione di refactoring "Con il refactoring, per definizione, non cambi ciò che fa il tuo software, cambiate come lo fa ". Quindi non sto cambiando ciò che fa il software, cambierei come / dove lo fa.

Allo stesso modo posso vedere l'argomento secondo cui se sto cambiando la firma di metodi che potrebbero essere considerati una riprogettazione.

Ecco un breve esempio

MyDocumentService.java (attuale)

public class MyDocumentService {
   ...
   public List<Document> findAllDocuments() {
      DataResultSet rs = documentDAO.findAllDocuments();
      List<Document> documents = new ArrayList<>();
      for(DataObject do: rs.getRows()) {
         //get row data create new document add it to 
         //documents list
      }

      return documents;
   }
}

MyDocumentService.java (refactored / riprogettato qualunque cosa)

public class MyDocumentService {
   ...
   public List<Document> findAllDocuments() {
      //Code dealing with DataResultSet moved back up to DAO
      //DAO now returns a List<Document> instead of a DataResultSet
      return documentDAO.findAllDocuments();
   }
}

14
È davvero refactoring che hai intenzione di fare o riprogettare ? Perché la risposta può essere diversa nei due casi.
citato

4
Sto lavorando alla definizione "Con il refactoring, per definizione, non cambi ciò che fa il tuo software, cambi il modo in cui lo fa." Quindi credo che in questo caso si tratti di refactoring, sentiti libero di correggere la mia comprensione del termine
PDStat,

21
Non scrivere test di integrazione. Il "refactoring" che stai pianificando è al di sopra del livello di unit testing. Solo unit test le nuove classi (o quelle vecchie che sai che le stai mantenendo).
Smetti di fare del male a Monica

2
Per quanto riguarda la definizione di refactoring, il tuo software definisce chiaramente cosa fa? In altre parole, è già "scomposto" in moduli con API indipendenti? In caso contrario, non è possibile effettuare il refactoring, tranne forse al livello più alto (rivolto all'utente). A livello di modulo, inevitabilmente lo ridisegnerai. Nel qual caso, non perdere tempo a scrivere test unitari prima di avere unità.
Kevin Krumwiede,

4
Molto probabilmente dovrai fare un po 'di refactoring senza la rete di sicurezza dei test solo per riuscire a metterlo in un cablaggio di prova. Il miglior consiglio che posso darti è che se il tuo IDE o strumento di refactoring non lo fanno per te, non farlo manualmente. Continua ad applicare le rifatturazioni automatiche fino a quando non riesci a ottenere il CUT in un'imbracatura. Avrai sicuramente voglia di prendere una copia di "Lavorare efficacemente con il codice legacy" di Michael Feather.
RubberDuck,

Risposte:


56

Stai cercando test per verificare le regressioni . vale a dire rompere alcuni comportamenti esistenti. Vorrei iniziare identificando a quale livello quel comportamento rimarrà lo stesso e che l'interfaccia che guida quel comportamento rimarrà lo stesso, e inizierei a testare a quel punto.

Ora hai alcuni test che affermano che qualunque cosa tu faccia al di sotto di questo livello, il tuo comportamento rimane lo stesso.

Hai ragione a chiederti come i test e il codice possano rimanere sincronizzati. Se la tua interfaccia con un componente rimane la stessa, puoi scrivere un test attorno a questo e affermare le stesse condizioni per entrambe le implementazioni (mentre crei la nuova implementazione). In caso contrario, è necessario accettare che un test per un componente ridondante sia un test ridondante.


1
Vale a dire, probabilmente stai facendo test di integrazione o di sistema piuttosto che test di unità. Probabilmente userete ancora uno strumento di "unit test" per questo, ma colpirete più di un'unità di codice con ogni test.
Móż,

Sì. È proprio così. Il test di regressione potrebbe fare qualcosa di molto alto livello ad esempio le richieste di riposo per un server e, eventualmente, un test di database successiva (cioè sicuramente non un'unità di prova!)
Brian Agnew

40

La pratica raccomandata è iniziare con la scrittura di "test pin-down" che testano il comportamento corrente del codice, possibilmente includendo bug, ma senza richiedere di scendere nella follia di discernere se un determinato comportamento che viola i documenti dei requisiti è un bug, soluzione alternativa per qualcosa di cui non si è a conoscenza o che rappresenta una modifica non documentata dei requisiti.

È più logico che questi test pin-down siano ad alto livello, ovvero l'integrazione piuttosto che i test unitari, in modo che continuino a funzionare quando si avvia il refactoring.

Ma alcuni refactoring potrebbero essere necessari per rendere testabile il codice - basta fare attenzione a refactoring "sicuri". Ad esempio, in quasi tutti i casi i metodi privati ​​possono essere resi pubblici senza interrompere nulla.


+1 per i test di integrazione. A seconda dell'app, potresti essere in grado di iniziare a livello di invio effettivo delle richieste all'app Web. Ciò che l'app restituisce non dovrebbe cambiare solo a causa del refactoring, anche se se restituisce HTML, questo è sicuramente meno testabile.
jpmc26,

Mi piace la frase 'pin-down' test.
Brian Agnew,

12

Suggerisco - se non lo hai già fatto - di leggere sia Lavorare in modo efficace con il codice legacy che refactoring - Migliorare la progettazione del codice esistente .

[..] Il problema mi sembra che quando faccio refactoring quei test si interromperanno mentre sto cambiando dove viene fatta una certa logica e i test verranno scritti tenendo a mente la struttura precedente (dipendenze derise ecc.) [ ..]

Non necessariamente vedo questo come un problema: scrivere i test, cambiare la struttura del codice, e quindi regolare la struttura di prova anche . Questo ti darà un feedback diretto se la tua nuova struttura è effettivamente migliore di quella vecchia, perché se lo è, i test adattati saranno più facili da scrivere (e quindi cambiare i test dovrebbe essere relativamente semplice, riducendo il rischio di avere una nuova introduzione bug supera i test).

Inoltre, come altri hanno già scritto: non scrivere test troppo dettagliati (almeno non all'inizio). Cerca di rimanere ad un alto livello di astrazione (quindi i tuoi test saranno probabilmente meglio definiti come test di regressione o addirittura di integrazione).


1
Questo. I test sembreranno terribili , ma copriranno il comportamento esistente. Quindi, man mano che il codice viene refactored, lo stesso vale per i test, in fase di blocco. Ripeti finché non hai qualcosa di cui essere orgoglioso. ++
RubberDuck,

1
Seguo entrambi questi consigli sui libri: li ho sempre a portata di mano quando devo occuparmi del codice testless.
Toby Speight,

5

Non scrivere test unitari severi in cui deridi tutte le dipendenze. Alcune persone ti diranno che questi non sono test unitari reali. Ignorali. Questi test sono utili, ed è quello che conta.

Diamo un'occhiata al tuo esempio:

public class MyDocumentService {
   ...
   public List<Document> findAllDocuments() {
      DataResultSet rs = documentDAO.findAllDocuments();
      List<Document> documents = new ArrayList<>();
      for(DataObject do: rs.getRows()) {
         //get row data create new document add it to 
         //documents list
      }

      return documents;
   }
}

Il tuo test probabilmente assomiglia a questo:

DocumentDao documentDao = Mock.create(DocumentDao.class);
Mock.when(documentDao.findAllDocuments())
    .thenReturn(DataResultSet.create(...))
assertEquals(..., new MyDocumentService(documentDao).findAllDocuments());

Invece di deridere DocumentDao, deridere le sue dipendenze:

DocumentDao documentDao = new DocumentDao(db);
Mock.when(db...)
    .thenReturn(...)
assertEquals(..., new MyDocumentService(documentDao).findAllDocuments());

Ora puoi spostare la logica da MyDocumentServicedentro DocumentDaosenza interrompere i test. I test mostreranno che la funzionalità è la stessa (per quanto tu l'abbia testata).


Se stai testando DocumentService e non deridi il DAO, non è affatto un test unitario. È qualcosa tra il test unitario e quello di integrazione. Non è vero?
Laiv

7
@Laiv, in realtà esiste una notevole varietà nel modo in cui le persone usano il termine unit test. Alcuni lo usano per indicare solo test rigorosamente isolati. Altri includono qualsiasi test che viene eseguito rapidamente. Alcuni includono tutto ciò che viene eseguito in un framework di test. Ma alla fine, non importa come si desidera definire il termine unit test. La domanda è: quali test sono utili, quindi non dovremmo distrarci da come esattamente definiamo unit test.
Winston Ewert,

Ottimo punto che dimostra che l'utilità è la cosa più importante. Esagerati test unitari per gli algoritmi più banali solo per far sì che i test unitari facciano più danni che benefici, se non solo una grande perdita di tempo e risorse preziose. Questo può essere applicato a quasi tutto ed è qualcosa che vorrei sapere prima nella mia carriera.
Lee,

3

Come dici tu, se cambi il comportamento, allora è una trasformazione e non un refattore. A quale livello si cambia il comportamento è ciò che fa la differenza.

Se non ci sono test formali al più alto livello, prova a trovare una serie di requisiti che i clienti (chiamando il codice o gli umani) che devono rimanere gli stessi dopo la riprogettazione affinché il tuo codice venga considerato funzionante. Questo è l'elenco dei casi di test che devi implementare.

Per rispondere alla tua domanda sul cambiamento delle implementazioni che richiedono il cambiamento dei casi di test, ti suggerisco di dare un'occhiata al TDD di Detroit (classica) vs London (mockist). Martin Fowler ne parla nel suo fantastico articolo I mock non sono stub ma molte persone hanno opinioni. Se inizi dal livello più alto, dove i tuoi esterni non possono cambiare e scendi, i requisiti dovrebbero rimanere abbastanza stabili fino a raggiungere un livello che deve davvero cambiare.

Senza alcun test questo sarà difficile e potresti voler considerare di eseguire i client attraverso percorsi a doppio codice (e registrando le differenze) fino a quando non sarai sicuro che il tuo nuovo codice fa esattamente quello che deve fare.


3

Ecco il mio approccio. Ha un costo in termini di tempo perché è un test di rifattore in 4 fasi.

Ciò che ho intenzione di esporre potrebbe essere migliore in componenti con una maggiore complessità rispetto a quello esposto nell'esempio della domanda.

Ad ogni modo la strategia è valida per qualsiasi componente candidato che deve essere normalizzato da un'interfaccia (DAO, Servizi, Controller, ...).

1. L'interfaccia

Consente di raccogliere tutti i metodi pubblici da MyDocumentService e di metterli tutti insieme in un'interfaccia. Per esempio. Se esiste già, usa quello invece di impostarne uno nuovo .

public interface DocumentService {

   List<Document> getAllDocuments();

   //more methods here...
}

Quindi forziamo MyDocumentService a implementare questa nuova interfaccia.

Fin qui tutto bene. Non sono state apportate modifiche sostanziali, abbiamo rispettato il contratto attuale e i comportamenti rimangono intatti.

public class MyDocumentService implements DocumentService {

 @Override
 public List<Document> getAllDocuments(){
         //legacy code here as it is.
        // with no changes ...
  }
}

2. Unit test del codice legacy

Qui abbiamo il duro lavoro. Per impostare una suite di test. Dovremmo stabilire il maggior numero possibile di casi: casi di successo e anche casi di errore. Questi ultimi sono per il bene della qualità del risultato.

Ora, invece di testare MyDocumentService , utilizzeremo l'interfaccia come contratto da testare.

Non ho intenzione di entrare nei dettagli, quindi perdonami se il mio codice sembra troppo semplice o troppo agnostico

public class DocumentServiceTestSuite {

   @Mock
   MyDependencyA mockDepA;

   @Mock
   MyDependencyB mockDepB;

    //... More mocks

   DocumentService service;

  @Before
   public void initService(){
       service = MyDocumentService(mockDepA, mockDepB);
      //this is purposed way to inject 
      //dependencies. Replace it with one you like more.  
   }

   @Test
   public void getAllDocumentsOK(){
         // here I mock depA and depB
         // wanted behaivors...

         List<Document> result = service.getAllDocuments();

          Assert.assertX(result);
          Assert.assertY(result);
           //... As many you think appropiate
    } 
 }

Questa fase richiede più tempo di qualsiasi altra in questo approccio. Ed è il più importante perché stabilirà il punto di riferimento per confronti futuri.

Nota: a causa della mancanza di modifiche sostanziali e il comportamento rimane intatto. Suggerisco di fare un tag qui in SCM. Tag o ramo non importa. Basta fare una versione.

Lo vogliamo per rollback, confronti di versioni e può essere per esecuzioni parallele del vecchio codice e di quello nuovo.

3. Refactoring

Refactor verrà implementato in un nuovo componente. Non faremo alcuna modifica al codice esistente. Il primo passaggio è semplice come copiare e incollare MyDocumentService e rinominarlo in CustomDocumentService (ad esempio).

La nuova classe continua a implementare DocumentService . Quindi vai e riformatta getAllDocuments () . (Iniziamo con uno. Rifattori di pin)

Potrebbe richiedere alcune modifiche all'interfaccia / ai metodi di DAO. In tal caso, non modificare il codice esistente. Implementa il tuo metodo nell'interfaccia DAO. Annota il vecchio codice come obsoleto e in seguito saprai cosa deve essere rimosso.

È importante non interrompere / modificare l'implementazione esistente. Vogliamo eseguire entrambi i servizi in parallelo e quindi confrontare i risultati.

public class CustomDocumentService implements DocumentService {

 @Override
 public List<Document> getAllDocuments(){
         //new code here ...
         //due to im refactoring service 
         //I do the less changes possible on its dependencies (DAO).
         //these changes will come later 
         //and they will have their own tests
  }
 }

4. Aggiornamento di DocumentServiceTestSuite

Ok, ora la parte più semplice. Per aggiungere i test del nuovo componente.

public class DocumentServiceTestSuite {

   @Mock
   MyDependencyA mockDepA;

   @Mock
   MyDependencyB mockDepB;

   DocumentService service;
   DocumentService customService;

  @Before
   public void initService(){
       service = MyDocumentService(mockDepA, mockDepB);
        customService = CustomDocumentService(mockDepA, mockDepB);
       // this is purposed way to inject 
       //dependencies. Replace it with the one you like more
   }

   @Test
   public void getAllDocumentsOK(){
         // here I mock depA and depB
         // wanted behaivors...

         List<Document> oldResult = service.getAllDocuments();

          Assert.assertX(oldResult);
          Assert.assertY(oldResult);
           //... As many you think appropiate

          List<Document> newResult = customService.getAllDocuments();

          Assert.assertX(newResult);
          Assert.assertY(newResult);
           //... The very same made to oldResult

          //this is optional
Assert.assertEquals(oldResult,newResult);
    } 
 }

Ora abbiamo oldResult e newResult entrambi validati in modo indipendente, ma possiamo anche confrontarli. Quest'ultima convalida è facoltativa e dipende dal risultato. Potrebbe non essere comparabile.

Potrebbe non essere troppo complicato per confrontare due raccolte in questo modo, ma sarebbe valida per qualsiasi altro tipo di oggetto (pojos, entità del modello di dati, DTO, wrapper, tipi nativi ...)

Appunti

Non oserei dire come fare unit test o come usare libs finti. Non oso nemmeno dire come devi fare il refactor. Quello che volevo fare è suggerire una strategia globale. Come portarlo avanti dipende da te. Sai esattamente com'è il codice, la sua complessità e se tale strategia merita di essere provata. Fatti come il tempo e le risorse contano qui. Importa anche cosa ti aspetti da questi test in futuro.

Ho iniziato i miei esempi da un servizio e seguirò con DAO e così via. Andare in profondità nei livelli di dipendenza. Più o meno potrebbe essere descritto come una strategia ascendente. Tuttavia per piccoli cambiamenti / refactor ( come quello esposto nell'esempio del tour ), un bottom-up renderebbe il compito più semplice. Perché l'ambito delle modifiche è piccolo.

Infine, spetta a te rimuovere il codice deprecato e reindirizzare le vecchie dipendenze a quello nuovo.

Rimuovi anche i test obsoleti e il lavoro viene eseguito. Se hai aggiornato la versione della vecchia soluzione con i relativi test, puoi controllarti e confrontarti in qualsiasi momento.

In conseguenza di così tanti lavori, hai testato, validato e aggiornato il codice legacy. E nuovo codice, testato, validato e pronto per essere aggiornato.


3

tl; dr Non scrivere test unitari. Scrivi i test a un livello più appropriato.


Data la tua definizione operativa di refactoring:

non cambi ciò che fa il tuo software, cambi il modo in cui lo fa

c'è uno spettro molto ampio. Da un lato c'è una modifica autonoma a un metodo particolare, forse usando un algoritmo più efficiente. Dall'altro lato è il porting in un'altra lingua.

Qualunque livello di refactoring / riprogettazione venga eseguito, è importante disporre di test che funzionino a quel livello o superiore.

I test automatici sono spesso classificati per livello come:

  • Test unitari - Componenti individuali (classi, metodi)

  • Test di integrazione - Interazioni tra componenti

  • Test di sistema - L'applicazione completa

Scrivi il livello di prova che può resistere al refactoring essenzialmente intatto.

Pensare:

Quale comportamento essenziale e pubblicamente visibile avrà l'applicazione sia prima che dopo il refactoring? Come posso testare che la cosa funzioni ancora allo stesso modo?


2

Non perdere tempo a scrivere test che si agganciano in punti in cui puoi prevedere che l'interfaccia cambierà in modo non banale. Questo è spesso un segno che stai provando a testare le classi che sono di natura "collaborativa" - il cui valore non è in quello che fanno loro stessi, ma in come interagiscono con un numero di classi strettamente correlate per produrre comportamenti preziosi . È quel comportamento che vuoi testare, il che significa che vuoi testare a un livello superiore. I test al di sotto di questo livello richiedono spesso molti brutti scherzi e i test che ne risultano possono essere più un ostacolo allo sviluppo che un aiuto per difendere il comportamento.

Non ti annoiare troppo se stai facendo un refactor, una riprogettazione o altro. È possibile apportare modifiche che al livello inferiore costituiscono una riprogettazione di un numero di componenti, ma a un livello di integrazione più elevato equivalgono semplicemente a un refactor. Il punto è chiarire quale comportamento ha valore per te e difenderlo mentre procedi.

Potrebbe essere utile considerare mentre scrivi i tuoi test: potrei facilmente descrivere a un QA, un proprietario di un prodotto o un utente quali sono questi test? Se sembra che descrivere il test sia troppo esoterico e tecnico, forse stai testando al livello sbagliato. Esegui il test nei punti / livelli che "hanno senso" e non confondere il tuo codice con test a tutti i livelli.


Sempre interessato ai motivi dei voti negativi!
topo Ripristina Monica

1

Il tuo primo compito è provare a trovare la "firma del metodo ideale" per i tuoi test. Sforzati di renderlo una funzione pura . Questo dovrebbe essere indipendente dal codice che è effettivamente sotto test; è un piccolo strato adattatore. Scrivi il tuo codice su questo strato adattatore. Ora, quando si esegue il refactoring del codice, è sufficiente modificare il livello dell'adattatore. Qui c'è un semplice esempio:

[TestMethod]
public void simple_addition()
{
    Assert.AreEqual(7, Eval("3 + 4"));
}

[TestMethod]
public void order_of_operations()
{
    Assert.AreEqual(52, Eval("2 + 5 * 10"));
}

[TestMethod]
public void absolute_value()
{
    Assert.AreEqual(9, Eval("abs(-9)"));
    Assert.AreEqual(5, Eval("abs(5)"));
    Assert.AreEqual(0, Eval("abs(0)"));
}

static object Eval(string expression)
{
    // This is the code under test.
    // I can refactor this as much as I want without changing the tests.
    var settings = new EvaluatorSettings();
    Evaluator.Settings = settings;
    Evaluator.Evaluate(expression);
    return Evaluator.LastResult;
}

I test sono buoni, ma il codice sotto test ha un'API errata. Posso riformattare senza modificare i test semplicemente aggiornando il mio livello adattatore:

static object Eval(string expression)
{
    // After refactoring...
    var settings = new EvaluatorSettings();
    var evaluator = new Evaluator(settings);
    return evaluator.Evaluate(expression);
}

Questo esempio sembra una cosa abbastanza ovvia da fare secondo il principio Don't Repeat Yourself, ma potrebbe non essere così evidente in altri casi. Il vantaggio va oltre DRY: il vero vantaggio è il disaccoppiamento dei test dal codice in prova.

Naturalmente, questa tecnica potrebbe non essere consigliabile in tutte le situazioni. Ad esempio, non vi sarebbe motivo di scrivere adattatori per POCO / POJO perché non dispongono di un'API che potrebbe cambiare indipendentemente dal codice di test. Inoltre, se stai scrivendo un numero limitato di test, uno strato adattatore relativamente grande sarebbe probabilmente uno sforzo sprecato.

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.