Test unitari fragili dovuti alla necessità di deridere eccessivamente


21

Sono stato alle prese con un problema sempre più fastidioso per quanto riguarda le nostre unit test che stiamo implementando nel mio team. Stiamo tentando di aggiungere unit test nel codice legacy che non era ben progettato e mentre non abbiamo avuto alcuna difficoltà con l'effettiva aggiunta dei test, stiamo iniziando a lottare con il modo in cui i test si stanno rivelando.

Come esempio del problema, supponiamo che tu abbia un metodo che chiama altri 5 metodi come parte della sua esecuzione. Un test per questo metodo potrebbe essere quello di confermare che si verifica un comportamento a seguito della chiamata di uno di questi 5 altri metodi. Quindi, poiché un test unitario dovrebbe fallire per un motivo e un motivo, si desidera eliminare i potenziali problemi causati chiamando questi altri 4 metodi e deridendoli. Grande! Viene eseguito il test unitario, i metodi derisi vengono ignorati (e il loro comportamento può essere confermato come parte di altri test unitari) e la verifica funziona.

Ma c'è un nuovo problema: il test unitario ha una conoscenza intima di come hai confermato che il comportamento e le eventuali modifiche alla firma in uno di quegli altri 4 metodi in futuro, o qualsiasi nuovo metodo che deve essere aggiunto al "metodo genitore", comportare la necessità di modificare il test unitario per evitare possibili guasti.

Naturalmente il problema potrebbe essere mitigato in qualche modo semplicemente avendo più metodi per realizzare meno comportamenti, ma speravo che ci fosse forse una soluzione più elegante disponibile.

Ecco un esempio di unit test che rileva il problema.

Come nota rapida, "MergeTests" è una classe di unit test che eredita dalla classe che stiamo testando e ignora il comportamento secondo necessità. Questo è un "modello" che impieghiamo nei nostri test per permetterci di ignorare le chiamate a classi / dipendenze esterne.

[TestMethod]
public void VerifyMergeStopsSpinner()
{
    var mockViewModel = new Mock<MergeTests> { CallBase = true };
    var mockMergeInfo = new MergeInfo(Mock.Of<IClaim>(), Mock.Of<IClaim>(), It.IsAny<bool>());

    mockViewModel.Setup(m => m.ClaimView).Returns(Mock.Of<IClaimView>);
    mockViewModel.Setup(
        m =>
        m.TryMergeClaims(It.IsAny<Func<bool>>(), It.IsAny<IClaim>(), It.IsAny<IClaim>(), It.IsAny<bool>(),
                         It.IsAny<bool>()));
    mockViewModel.Setup(m => m.GetSourceClaimAndTargetClaimByMergeState(It.IsAny<MergeState>())).Returns(mockMergeInfo);
    mockViewModel.Setup(m => m.SwitchToOverviewTab());
    mockViewModel.Setup(m => m.IncrementSaveRequiredNotification());
    mockViewModel.Setup(m => m.OnValidateAndSaveAll(It.IsAny<object>()));
    mockViewModel.Setup(m => m.ProcessPendingActions(It.IsAny<string>()));

    mockViewModel.Object.OnMerge(It.IsAny<MergeState>());    

    mockViewModel.Verify(mvm => mvm.StopSpinner(), Times.Once());
}

In che modo il resto di voi ha affrontato questo problema o non esiste un modo "semplice" per gestirlo?

Aggiornamento: apprezzo il feedback di tutti. Sfortunatamente, e non sorprende davvero, non sembra esserci un'ottima soluzione, modello o pratica che si possa seguire nei test unitari se il codice da testare è scarso. Ho segnato la risposta che meglio ha catturato questa semplice verità.


Wow, vedo solo una finta configurazione, nessuna istanza SUT o altro, stai testando un'implementazione effettiva qui? Chi dovrebbe chiamare StopSpinner? OnMerge? Dovresti prendere in giro tutte le dipendenze che può chiamare ma non la cosa stessa ..
Joppe

È un po 'difficile da vedere, ma il <MergeTest> Mock è il SUT. Impostiamo il flag CallBase per garantire che il metodo "OnMerge" venga eseguito sull'oggetto reale, ma deridiamo i metodi chiamati da "OnMerge" che potrebbero causare il fallimento del test a causa di problemi di dipendenza, ecc. L'obiettivo del test è l'ultima riga - per verificare che in questo caso abbiamo fermato il filatore.
PremiumTier

MergeTests suona come un'altra classe strumentata, non qualcosa che vive nella produzione, quindi la confusione.
Joppe


1
A parte gli altri tuoi problemi, mi sembra sbagliato che il tuo SUT sia un <MergeTests> falso. Perché dovresti testare un Mock? Perché non stai testando la classe MergeTests stessa?
Eric King,

Risposte:


18
  1. Correggi il codice per una migliore progettazione. Se i tuoi test hanno questi problemi, il tuo codice avrà problemi peggiori quando provi a cambiare le cose.

  2. Se non puoi, allora forse devi essere meno ideale. Test rispetto alle condizioni pre e post del metodo. A chi importa se stai usando gli altri 5 metodi? Presumibilmente hanno i propri test unitari che chiariscono (ehm) cosa ha causato il fallimento quando i test falliscono.

"I test unitari dovrebbero avere solo una ragione per fallire" è una buona linea guida, ma nella mia esperienza, poco pratica. I test difficili da scrivere non vengono scritti. Test fragili non vengono creduti.


Sono completamente d'accordo con la correzione del design del codice, ma nel mondo meno ideale di sviluppo per una grande azienda con tempistiche ristrette, può essere difficile sostenere la necessità di "ripagare" il debito tecnico sostenuto da squadre passate o decisioni sbagliate una volta. Per il tuo secondo punto, gran parte della derisione non è solo perché vogliamo che il test fallisca solo per un motivo - è perché non è possibile eseguire il codice in esecuzione senza prima gestire un gran numero di dipendenze create all'interno di quel codice . Ci scusiamo per spostare i pali dell'obiettivo su quello.
PremiumTier

Se un design migliore non è realistico, concordo con "A chi importa se stai usando gli altri 5 metodi?" Verificare che il metodo esegua la funzione richiesta, non come lo sta facendo.
Kwebble

@Kwebble - Capito, tuttavia l'obiettivo della domanda era determinare se esistesse un modo semplice per verificare il comportamento di un metodo quando bisogna anche prendere in giro altri comportamenti chiamati all'interno del metodo per eseguire il test. Voglio rimuovere il "come", ma non so come :)
PremiumTier

Non esiste un proiettile d'argento magico. Non esiste un "modo semplice" per testare un codice scadente. O il codice sotto test deve essere sottoposto a refactoring, oppure anche il codice di test stesso sarà scadente. O il test sarà scarso perché sarà troppo specifico per i dettagli interni, come hai incontrato, o come suggerito da btilly , puoi eseguire i test su un ambiente di lavoro, ma i test saranno molto più lenti e complessi. Ad ogni modo, i test saranno più difficili da scrivere, più difficili da mantenere e inclini a falsi negativi.
Steven Doggart,

8

Suddividere metodi di grandi dimensioni in metodi di piccole dimensioni più mirati è sicuramente una buona pratica. Lo vedi come dolore nel verificare il comportamento del test unitario, ma stai sperimentando il dolore anche in altri modi.

Detto questo, è un'eresia ma personalmente sono un fan della creazione di ambienti di test temporanei realistici. Cioè, piuttosto che deridere tutto ciò che è nascosto all'interno di quegli altri metodi, assicurati che ci sia un ambiente temporaneo facile da configurare (completo di database e schemi privati ​​- SQLite può aiutarti qui) che ti consente di eseguire tutte quelle cose. La responsabilità di sapere come costruire / abbattere quell'ambiente di test vive con il codice che lo richiede, in modo che quando cambia, non è necessario cambiare tutto il codice di unit test che dipendeva dalla sua esistenza.

Ma noto che questa è un'eresia da parte mia. Le persone che sono fortemente coinvolte nei test unitari sostengono test unitari "puri" e chiamano quelli che ho descritto "test di integrazione". Personalmente non mi preoccupo di questa distinzione.


3

Vorrei prendere in considerazione un allentamento sulle finte e solo formulare test che potrebbero includere i metodi che chiama.

Non testare il come , testare il cosa . È il risultato che conta, includere i metodi secondari se necessario.

Da un altro punto di vista potresti formulare un test, farlo passare con un metodo grande, refactoring e finire con un albero di metodi dopo il refactoring. Non è necessario testarli tutti separatamente. È il risultato finale che conta.

Se i metodi secondari rendono difficile testare alcuni aspetti, considera di suddividerli in classi separate in modo da poterli deridere in modo più pulito senza che la tua classe sotto test sia pesantemente strumentata / aggraffata. È difficile capire se in realtà stai testando un'implementazione concreta nel tuo esempio di test.


Il problema è che dobbiamo deridere il "come" per testare il "cosa". È una limitazione imposta dal design del codice. Certamente non desidero "deridere" il come sia ciò che rende fragile il test.
PremiumTier

Guardando i nomi dei metodi penso che la tua classe testata si stia semplicemente assumendo troppe responsabilità. Leggi secondo il principio della responsabilità singola. Il prestito da MVC può aiutare un po ', la tua classe sembra gestire problemi di interfaccia utente, infrastruttura e business.
Joppe

Sì :( Sarebbe quel codice legacy mal progettato a cui mi riferivo. Stiamo lavorando alla riprogettazione e al refactor ma abbiamo pensato che sarebbe stato meglio mettere prima alla prova la fonte.
PremiumTier
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.