Come evitare i test unitari fragili?


24

Abbiamo scritto quasi 3000 test: i dati sono stati codificati, il riutilizzo del codice è molto limitato. Questa metodologia ha iniziato a morderci il culo. Man mano che il sistema cambia, ci ritroviamo a dedicare più tempo a riparare i test rotti. Abbiamo test unitari, di integrazione e funzionali.

Quello che sto cercando è un modo definitivo per scrivere test gestibili e gestibili.

Frameworks


Questo è molto più adatto a Programmers.StackExchange, IMO ...
IAbstract

Risposte:


21

Non pensarli come "test unitari rotti", perché non lo sono.

Sono specifiche che il tuo programma non supporta più.

Non pensarlo come "correzione dei test", ma come "definizione di nuovi requisiti".

I test dovrebbero prima specificare l'applicazione, non viceversa.

Non puoi dire di avere un'implementazione funzionante finché non sai che funziona. Non puoi dire che funzioni fino a quando non lo provi.

Alcune altre note che potrebbero guidarti:

  1. I test e le classi in prova dovrebbero essere brevi e semplici . Ogni test dovrebbe solo verificare una funzionalità coerente. Cioè, non gli importa di cose che altri test già controllano.
  2. I test e i tuoi oggetti dovrebbero essere liberamente accoppiati, in modo tale che se cambi un oggetto, stai solo cambiando il suo grafico di dipendenza verso il basso e altri oggetti che usano quell'oggetto non ne sono influenzati.
  3. Potresti creare e testare le cose sbagliate . I tuoi oggetti sono progettati per una facile interfaccia o semplice implementazione? Se è l'ultimo caso, ti ritroverai a cambiare molto codice che utilizza l'interfaccia della vecchia implementazione.
  4. Nel migliore dei casi, attenersi rigorosamente al principio della responsabilità singola. Nel caso peggiore, aderire al principio di segregazione dell'interfaccia. Vedi Principi SOLIDI .

5
+1 perDon't think of it as "fixing the tests", but as "defining new requirements".
StuperUser

2
+1 I test dovrebbero prima specificare l'applicazione, non viceversa
treecoder il

11

Quello che descrivi potrebbe in realtà non essere una cosa negativa, ma un indicatore di problemi più profondi che i tuoi test scoprono

Man mano che il sistema cambia, ci ritroviamo a dedicare più tempo a riparare i test rotti. Abbiamo test unitari, di integrazione e funzionali.

Se potessi cambiare il tuo codice e i tuoi test non si interrompessero, ciò sarebbe sospetto per me. La differenza tra una modifica legittima e un bug è solo il fatto che è richiesta, ciò che viene richiesto è (presunto TDD) definito dai test.

i dati sono stati codificati.

I dati hard coded nei test sono una buona cosa. I test funzionano come falsificazioni, non come prove. Se il calcolo è eccessivo, i test potrebbero essere tautologie. Per esempio:

assert sum([1,2,3]) == 6
assert sum([1,2,3]) == 1 + 2 + 3
assert sum([1,2,3]) == reduce(operator.add, [1,2,3])

Maggiore è l'astrazione, più ci si avvicina all'algoritmo e, quindi, più vicino a confrontare l'implementazione acuta con se stesso.

pochissimo riutilizzo del codice

Il miglior riutilizzo del codice nei test è imho 'Checks', come in jUnits assertThat, perché rendono semplici i test. Oltre a ciò, se i test possono essere rifattorizzati per condividere il codice, è probabile che lo sia anche il vero codice testato , riducendo così i test a quelli che testano la base refactored.


Mi piacerebbe sapere dove il downvoter non è d'accordo.
keppla,

keppla - Non sono il downvoter, ma generalmente, a seconda di dove mi trovo nel modello, preferisco testare l'interazione degli oggetti rispetto ai dati di test a livello di unità. I test dei dati funzionano meglio a livello di integrazione.
Ritch Melton,

@keppla Ho una classe che inoltra un ordine a un altro canale se i suoi articoli totali contengono determinati articoli con restrizioni. Creo un ordine falso popolandolo con 4 articoli, due dei quali sono quelli limitati. Per quanto riguarda gli articoli con restrizioni, questo test è unico. Ma i passaggi per la creazione di un ordine falso e l'aggiunta di due articoli regolari è la stessa configurazione utilizzata da un altro test che verifica il flusso di lavoro degli articoli non limitato. In questo caso insieme agli articoli se l'ordine deve avere la configurazione dei dati del cliente e la configurazione degli indirizzi, ecc. Non è un buon caso di riutilizzo degli helper di configurazione. Perché affermare solo il riutilizzo?
Asif Shiraz,

6

Ho avuto anche questo problema. Il mio approccio migliorato è stato il seguente:

  1. Non scrivere test unitari a meno che non siano l'unico buon modo per testare qualcosa.

    Sono pienamente pronto ad ammettere che i test unitari hanno il costo più basso di diagnosi e tempi di riparazione. Questo li rende uno strumento prezioso. Il problema è, con l'evidente chilometraggio-che può variare, che i test unitari sono spesso troppo piccoli per meritare il costo di mantenimento della massa del codice. Ho scritto un esempio in fondo, dai un'occhiata.

  2. Utilizzare le asserzioni ovunque siano equivalenti al test unitario per quel componente. Le asserzioni hanno la proprietà piacevole di essere sempre verificate durante qualsiasi build di debug. Quindi, invece di testare i vincoli di classe "Employee" in un'unità di test separata, si sta effettivamente testando la classe Employee attraverso tutti i casi di test nel sistema. Le asserzioni hanno anche la bella proprietà di non aumentare la massa del codice tanto quanto i test unitari (che alla fine richiedono impalcature / derisione / qualunque cosa).

    Prima che qualcuno mi uccida: le build di produzione non dovrebbero andare in crash sulle asserzioni. Invece, dovrebbero accedere al livello "Errore".

    Come avvertimento per qualcuno che non ci ha ancora pensato, non affermare nulla sull'input dell'utente o della rete. È un enorme errore ™.

    Nelle mie ultime basi di codice, ho rimosso con giudizio le unit test ovunque vedessi un'ovvia opportunità di affermazioni. Ciò ha notevolmente ridotto il costo della manutenzione complessiva e mi ha reso una persona molto più felice.

  3. Preferisci i test di sistema / integrazione, implementandoli per tutti i tuoi flussi primari e le esperienze utente. Probabilmente non è necessario che i casi angolari siano qui. Un test di sistema verifica il comportamento dell'utente finale eseguendo tutti i componenti. Per questo motivo, un test di sistema è necessariamente più lento, quindi scrivi quelli che contano (niente di più, niente di meno) e affronterai i problemi più importanti. I test di sistema hanno costi di manutenzione molto bassi.

    È fondamentale ricordare che, poiché si utilizzano asserzioni, ogni test di sistema eseguirà contemporaneamente duecento "test unitari". Sei anche abbastanza sicuro che i più importanti vengano eseguiti più volte.

  4. Scrivi API forti che possono essere testate funzionalmente. I test funzionali sono imbarazzanti e (ammettiamolo) un po 'insignificanti se l'API rende troppo difficile verificare i componenti funzionanti da soli. Una buona progettazione delle API a) semplifica le fasi dei test eb) genera affermazioni chiare e preziose.

    Il test funzionale è la cosa più difficile da ottenere, soprattutto quando si hanno componenti che comunicano uno-a-molti o (peggio, oh dio) molti-a-molti attraverso le barriere di processo. Più ingressi e uscite sono collegati a un singolo componente, più difficile sarà il test funzionale, perché devi isolarne uno per testarne realmente la funzionalità.


Sulla questione di "non scrivere unit test", presenterò un esempio:

TEST(exception_thrown_on_null)
{
    InternalDataStructureType sink;
    ASSERT_THROWS(sink.consumeFrom(NULL), std::logic_error);
    try {
        sink.consumeFrom(NULL);
    } catch (const std::logic_error& e) {
        ASSERT(e.what() == "You must not pass NULL as a parameter!");
    }
}

L'autore di questo test ha aggiunto sette righe che non contribuiscono affatto alla verifica del prodotto finale. L'utente non dovrebbe mai vedere ciò accadere, sia perché a) nessuno dovrebbe mai passare NULL lì (quindi scrivi un'asserzione, quindi) oppure b) il caso NULL dovrebbe causare un comportamento diverso. Se il caso è (b), scrivere un test che verifichi effettivamente quel comportamento.

La mia filosofia è diventata che non dovremmo testare artefatti di implementazione. Dovremmo testare solo tutto ciò che può essere considerato un output effettivo. Altrimenti, non c'è modo di evitare di scrivere il doppio della massa di codice di base tra i test unitari (che impongono un'implementazione particolare) e l'implementazione stessa.

È importante notare, qui, che ci sono buoni candidati per i test unitari. In effetti, ci sono anche diverse situazioni in cui un unit test è l'unico mezzo adeguato per verificare qualcosa e in cui è di grande valore scrivere e mantenere tali test. Nella parte superiore della mia testa, questo elenco include algoritmi non banali, contenitori di dati esposti in un'API e codice altamente ottimizzato che appare "complicato" (noto anche come "il ragazzo successivo probabilmente lo rovinerà").

Il mio consiglio specifico per te, quindi: Inizia a cancellare i test unitari con cautela mentre si rompono, ponendoti la domanda "è un risultato o sto sprecando il codice?" Probabilmente riuscirai a ridurre il numero di cose che stanno sprecando il tuo tempo.


3
Preferisci i test di sistema / integrazione - Questo è terribilmente negativo. Il tuo sistema arriva al punto in cui sta usando questi test (rallentamenti!) Per testare le cose che potrebbero essere catturate rapidamente a livello di unità e impiegano ore perché funzionino perché hai così tanti test simili e lenti.
Ritch Melton,

1
@RitchMelton Completamente separato dalla discussione, sembra che tu abbia bisogno di un nuovo server CI. L'IC non dovrebbe comportarsi in questo modo.
Andres Jaan Tack,

1
Un programma in crash (che è ciò che fanno le asserzioni) non dovrebbe uccidere il tuo test runner (CI). Ecco perché hai un runner di test; quindi qualcosa può rilevare e segnalare tali guasti.
Andres Jaan Tack,

1
Le asserzioni in stile "Assert" di solo debug con cui ho familiarità (non asserzioni di test) fanno apparire una finestra di dialogo che blocca l'elemento della configurazione perché è in attesa di interazione con gli sviluppatori.
Ritch Melton,

1
Ah, beh, ciò spiegherebbe molto sul nostro disaccordo. :) Mi riferisco alle affermazioni in stile C. Ho notato solo ora che questa è una domanda .NET. cplusplus.com/reference/clibrary/cassert/assert
Andres Jaan Tack,

5

Mi sembra che il tuo test unitario funzioni come un incantesimo. È una buona cosa che sia così fragile da cambiare, dato che è una specie di punto. Piccole modifiche nei test di rottura del codice in modo da poter eliminare la possibilità di errore durante il programma.

Tuttavia, tieni presente che devi solo testare le condizioni che potrebbero far fallire il tuo metodo o dare risultati inaspettati. Ciò manterrebbe i test della tua unità più inclini a "rompersi" se c'è un vero problema piuttosto che cose banali.

Anche se mi sembra che stai ridisegnando pesantemente il programma. In tali casi, fai tutto il necessario e rimuovi i vecchi test e sostituiscili successivamente con quelli nuovi. La riparazione di unit test è utile solo se non stai risolvendo a causa di cambiamenti radicali nel tuo programma. Altrimenti potresti scoprire che stai dedicando troppo tempo alla riscrittura dei test per essere applicabile nella tua nuova sezione del codice del programma.


3

Sono sicuro che altri avranno molti più input, ma nella mia esperienza, queste sono alcune cose importanti che ti aiuteranno:

  1. Utilizzare una factory di oggetti test per creare strutture di dati di input, quindi non è necessario duplicare quella logica. Forse cerca in una libreria di supporto come AutoFixture per ridurre il codice necessario per la configurazione del test.
  2. Per ogni classe di test, centralizzare la creazione del SUT, quindi sarà facile cambiare quando le cose vengono refactored.
  3. Ricorda che quel codice di test è importante tanto quanto il codice di produzione. Dovrebbe anche essere refactored, se trovi che ti stai ripetendo, se il codice sembra irraggiungibile, ecc. Ecc.

Più riutilizzi il codice tra i test, più diventano fragili, perché ora cambiare un test può interromperne un altro. Questo potrebbe essere un costo ragionevole, in cambio della manutenibilità - non sto entrando in questo argomento qui - ma sostenere che i punti 1 e 2 rendono i test meno fragili (che era la domanda) è semplicemente sbagliato.
pdr

@driis - Esatto, il codice di test ha modi di dire diversi rispetto all'esecuzione del codice. Nascondere le cose rifattorizzando il codice "comune" e usando cose come i contenitori IoC maschera solo i problemi di progettazione esposti dai test.
Ritch Melton,

Mentre il punto che @pdr fa è probabilmente valido per i test unitari, direi che per i test di integrazione / sistema, potrebbe essere utile pensare in termini di "preparare l'applicazione per l'attività X". Ciò potrebbe comportare la navigazione nella posizione corretta, l'impostazione di determinate impostazioni di runtime, l'apertura di un file di dati e così via. Se più test di integrazione iniziano nello stesso posto, il refactoring di quel codice per riutilizzarlo su più test potrebbe non essere un problema se si comprendono i rischi e i limiti di tale approccio.
un CVn

2

Gestisci i test come fai con il codice sorgente.

Controllo della versione, rilasci di checkpoint, rilevamento dei problemi, "proprietà delle caratteristiche", pianificazione e stima degli sforzi, ecc. Ecc. Ci sono già stato fatto - penso che questo sia il modo più efficiente per affrontare i problemi che descrivi.


1

Dovresti assolutamente dare un'occhiata ai modelli di test XUnit di Gerard Meszaros . Ha una grande sezione con molte ricette per riutilizzare il codice di test ed evitare duplicazioni.

Se i tuoi test sono fragili, potrebbe anche non essere abbastanza ricorso per testare i doppi. In particolare, se si ricreano interi grafici di oggetti all'inizio di ogni test unitario, le sezioni Disposizione nei test potrebbero diventare sovradimensionate e spesso ci si trova in situazioni in cui è necessario riscrivere le sezioni Disposizione in un numero considerevole di test solo perché una delle tue classi più comunemente usate è cambiata. I falsi e gli stub possono aiutarti qui riducendo il numero di oggetti che devi reidratare per avere un contesto di test rilevante.

Rimuovere i dettagli non importanti dalle impostazioni del test tramite simulazioni e stub e applicare schemi di test per riutilizzare il codice dovrebbe ridurne significativamente la fragilità.

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.