Come scrivere test unitari prima del refactoring?

55

Ho letto alcune risposte alle domande lungo una linea simile, come "Come fai a mantenere i tuoi test unitari funzionanti durante il refactoring?". Nel mio caso lo scenario è leggermente diverso in questo Mi è stato dato un progetto da rivedere e allineare con alcuni standard che abbiamo, attualmente non ci sono test per il progetto!

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

Prima di refactoring sembrava una buona idea scrivere test per il codice esistente. Il problema che mi sembra è che quando faccio il refattore, quei test si romperanno mentre sto cambiando dove viene eseguita una certa logica e i test verranno scritti con la struttura precedente in mente (dipendenze fittizie 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 c'è il rischio che io possa refactoring in modo errato che potrebbe cambia il comportamento desiderato.

Che si tratti di un refactoring o di una riprogettazione sono felice per la mia comprensione di quei termini da correggere, attualmente sto lavorando alla seguente definizione per il refactoring "Con il refactoring, per definizione, non cambierai ciò che il software funziona, tu cambi il modo in cui lo fa ". Quindi non sto cambiando ciò che il software farebbe cambiando come / dove lo fa.

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

Ecco un breve esempio

MyDocumentService.java (corrente)

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 / redesigned whatever)

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();
   }
}
    
posta PDStat 16.05.2016 - 15:27
fonte

9 risposte

56

Stai cercando test che controllino regressioni . cioè rompendo alcuni comportamenti esistenti. Inizierei identificando a quale livello quel comportamento rimarrà lo stesso, e che l'interfaccia che guida quel comportamento rimarrà la stessa, e inizierò a mettere i test a quel punto.

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

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

    
risposta data 16.05.2016 - 15:53
fonte
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 requisiti richiesti è un bug, soluzione alternativa per qualcosa di cui non sei a conoscenza o che rappresenta una modifica non documentata dei requisiti.

È più sensato che questi test pin-down siano ad un livello elevato, cioè integrazione piuttosto che test unitari, in modo che continuino a funzionare quando si avvia il refactoring.

Ma alcuni rifattorici potrebbero essere necessari per rendere verificabile il codice: basta fare attenzione a rispettare i refactoring "sicuri". Ad esempio, in quasi tutti i casi i metodi che sono privati possono essere resi pubblici senza rompere nulla.

    
risposta data 16.05.2016 - 16:07
fonte
13

Suggerisco - se non l'hai già fatto - di leggere entrambi Lavorare efficacemente con il codice legacy così come < a href="http://martinfowler.com/books/refactoring.html"> Rifattorizzazione - Migliorare la progettazione del codice esistente .

[..] The problem it appears to me is that when I do refactor then those tests will break as I'm changing where certain logic is done and the tests will be written with the previous structure in mind (mocked dependencies etc.) [..]

Non lo vedo necessariamente come un problema: scrivi i test, modifica la struttura del tuo codice, quindi modifica la struttura di test anche . Questo ti darà un feedback diretto se la tua nuova struttura è effettivamente migliore di quella vecchia, perché se lo è, i test corretti saranno più facili da scrivere (e quindi cambiare i test dovrebbe essere relativamente semplice, abbassando il rischio che un bug appena introdotto superi i test).

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

    
risposta data 16.05.2016 - 17:43
fonte
5

Non scrivere rigorosi test unitari in cui prendi in giro tutte le dipendenze. Alcuni ti diranno che non si tratta di veri e propri test unitari. Ignorali. Questi test sono utili e questo è ciò 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 prendere in giro DocumentDao, prendi in giro le sue dipendenze:

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

Ora puoi spostare la logica da MyDocumentService a DocumentDao senza interrompere i test. I test mostreranno che la funzionalità è la stessa (per quanto l'hai testata).

    
risposta data 16.05.2016 - 17:26
fonte
3

Come dici tu, se cambi il comportamento, allora è una trasformazione e non un refactoring. A che livello cambi 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 (codice chiamante o umani) devono mantenere gli stessi dopo che la riprogettazione del tuo codice è stata considerata funzionante. Questa è la lista 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 a Detroit (classica) contro Londra (mockist) TDD. Martin Fowler ne parla nel suo fantastico articolo I mock non sono stub ma molte persone hanno opinioni. Se inizi al livello più alto, dove i tuoi esterni non possono cambiare e scendi, i requisiti dovrebbero rimanere abbastanza stabili finché non arrivi ad un livello che deve davvero cambiare.

Senza test questo sarà difficile e potresti prendere in considerazione l'esecuzione di client attraverso percorsi di doppio codice (e registrando le differenze) finché non sarai sicuro che il tuo nuovo codice faccia esattamente ciò che deve fare.

    
risposta data 16.05.2016 - 16:05
fonte
3

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

Quello che ho intenzione di esporre può essere meglio disponibile in componenti con una complessità maggiore di quella esposta nell'esempio della domanda.

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

1. L'interfaccia

Consente di raccogliere tutti i metodi pubblici da MyDocumentService e di raggrupparli 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 imponiamo MyDocumentService per implementare questa nuova interfaccia.

Fin qui tutto bene. Non sono stati apportati cambiamenti importanti, abbiamo rispettato l'attuale contratto e il comportamento rimane intatto.

public class MyDocumentService implements DocumentService {

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

2. Test di unità del codice precedente

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

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

Non entrerò 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 ogni altra in questo approccio. Ed è il più importante perché imposterà il punto di riferimento per future comparazioni.

Nota: non sono state apportate modifiche importanti e il comportamento rimane inalterato. Suggerisco di fare un tag qui nel SCM. Tag o ramo non importa. Basta fare una versione.

Lo vogliamo per i rollback, le comparazioni delle versioni e potrebbe essere per le esecuzioni parallele del vecchio codice e del nuovo.

3. refactoring

Il Refactor sarà implementato in un nuovo componente. Non apporteremo alcuna modifica al codice esistente. Il primo passo è facile come copiare e incollare MyDocumentService e rinominarlo in CustomDocumentService (ad esempio).

La nuova classe continua a implementare DocumentService . Quindi vai a rifattorizzare getAllDocuments () . (Iniziamo con uno. Pin-refactors)

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

È importante non rompere / cambiare 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ù facile. 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 indipendentemente, ma possiamo anche confrontarci. Quest'ultima validazione è facoltativa e dipende dal risultato. Può essere che non è comparabile.

Non si può fare troppo 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 ...)

Note

Non oserei dire come eseguire i test unitari o come utilizzare le librerie simulate. Non oso né di dire come devi fare il refattore. Quello che volevo fare è suggerire una strategia globale. Come portarlo avanti dipende da te. Sai esattamente come è 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 con un servizio e vorrei seguire con DAO e così via. Andando in profondità nei livelli di dipendenza. Più o meno potrebbe essere descritto come una strategia up-bottom . Tuttavia, per modifiche / refatture minori ( come quello esposto nell'esempio del tour ), un bottom up farebbe più facilmente il compito. Perché l'ambito delle modifiche è poco.

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

Rimuovi anche i test deprecati e il lavoro è finito. Se hai eseguito il versioning della vecchia soluzione con i suoi test, puoi controllarli e confrontarti in qualsiasi momento.

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

    
risposta data 16.05.2016 - 22:22
fonte
3

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

Considerata la tua definizione operativa di refactoring:

you don't change what your software does, you change how it does it

c'è molto ampio spettro. Da una parte c'è una modifica autonoma a un metodo particolare, magari usando un algoritmo più efficiente. Dall'altra parte porta in un'altra lingua.

Qualunque sia il livello di refactoring / riprogettazione che viene eseguito, è importante avere test che funzionino a quel livello o più.

I test automatici sono spesso classificati per livello come:

  • Test delle unità - Singoli componenti (classi, metodi)

  • Test di integrazione - Interazioni tra i componenti

  • Test di sistema - L'applicazione completa

Scrivi il livello di test che può sopportare essenzialmente il refactoring.

Pensate:

What essential, publicly-visible behavior will the application have both before and after the refactoring? How can I test that thing still works the same?

    
risposta data 18.05.2016 - 00:05
fonte
2

Non perdere tempo a scrivere test che si aggrappino a punti in cui puoi anticipare che l'interfaccia cambierà in modo non banale. Questo è spesso un segno che stai provando a testare le classi che sono "collaborative" in natura - il cui valore non è in quello che fanno loro stesse, ma nel modo in cui interagiscono con un numero di classi strettamente correlate per produrre un comportamento prezioso . È il comportamento quel che vuoi testare, il che significa che vuoi testare ad un livello più alto. I test al di sotto di questo livello spesso richiedono un sacco di brutti scherni, ei test risultanti possono essere più un fattore di sviluppo per lo sviluppo che un aiuto per il comportamento di difesa.

Non esagerare se stai realizzando un refactoring, una riprogettazione o altro. È possibile apportare modifiche che al livello più basso costituiscono una riprogettazione di un numero di componenti, ma a un livello di integrazione più elevato equivale semplicemente a un refactoring. Il punto è essere chiari su quale comportamento è di tuo valore e difendere questo comportamento mentre si va.

Potrebbe essere utile considerare mentre scrivi i tuoi test - potrei descrivere facilmente a un QA, a un product owner o a un utente, cosa sta effettivamente testando questo test? Se sembra che descrivere il test sia troppo esoterico e tecnico, forse stai provando al livello sbagliato. Prova nei punti / livelli che "hanno senso" e non intaccare il tuo codice con test ad ogni livello.

    
risposta data 16.05.2016 - 22:50
fonte
1

Il tuo primo compito è cercare di trovare la "firma del metodo ideale" per i tuoi test. Cerca 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 adattatore. Ora, quando si rifatta il codice, è sufficiente cambiare il livello dell'adattatore. Ecco 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 refactoring senza cambiare i test semplicemente aggiornando il mio strato 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 Non ripetere te stesso, ma potrebbe non essere così ovvio in altri casi. Il vantaggio va oltre il DRY: il vero vantaggio è il disaccoppiamento dei test dal codice sotto test.

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

    
risposta data 16.05.2016 - 19:36
fonte

Leggi altre domande sui tag