Codice di unit test con dipendenza del file system


138

Sto scrivendo un componente che, dato un file ZIP, deve:

  1. Decomprimi il file.
  2. Trova una DLL specifica tra i file decompressi.
  3. Carica quella dll attraverso la riflessione e invoca un metodo su di essa.

Vorrei testare questo componente.

Sono tentato di scrivere codice che si occupa direttamente del file system:

void DoIt()
{
   Zip.Unzip(theZipFile, "C:\\foo\\Unzipped");
   System.IO.File myDll = File.Open("C:\\foo\\Unzipped\\SuperSecret.bar");
   myDll.InvokeSomeSpecialMethod();
}

Ma la gente spesso dice "Non scrivere unit test che si basano su file system, database, rete, ecc."

Se dovessi scrivere questo in un modo test unitario, suppongo che sarebbe simile a questo:

void DoIt(IZipper zipper, IFileSystem fileSystem, IDllRunner runner)
{
   string path = zipper.Unzip(theZipFile);
   IFakeFile file = fileSystem.Open(path);
   runner.Run(file);
}

Sìì! Ora è testabile; Sono in grado di alimentare doppi di prova (simulazioni) con il metodo DoIt. Ma a quale costo? Ora ho dovuto definire 3 nuove interfacce solo per renderlo testabile. E cosa sto testando esattamente? Sto testando che la mia funzione DoIt interagisce correttamente con le sue dipendenze. Non verifica che il file zip sia stato decompresso correttamente, ecc.

Non mi sembra più di testare la funzionalità. Mi sento come se stessi solo testando le interazioni di classe.

La mia domanda è questa : qual è il modo corretto di testare l'unità che dipende dal file system?

modifica Sto usando .NET, ma il concetto potrebbe applicare anche Java o codice nativo.


8
La gente dice di non scrivere nel file system in un test unitario perché se sei tentato di scrivere nel file system non capisci cosa costituisce un test unitario. Un test unitario di solito interagisce con un singolo oggetto reale (l'unità testata) e tutte le altre dipendenze vengono derise e passate. La classe test consiste quindi di metodi di test che convalidano i percorsi logici attraverso i metodi dell'oggetto e SOLO i percorsi logici in l'unità sotto test.
Christopher Perry,

1
nella tua situazione, l'unica parte che necessita di test unitari sarebbe myDll.InvokeSomeSpecialMethod();dove verifichi che funzioni correttamente sia in situazioni di successo che di fallimento, quindi non farei test unitari DoItma DllRunner.Rundetto uso improprio di un test UNIT per ricontrollare che l'intero processo funzioni sarebbe un uso improprio accettabile e poiché si tratterebbe di un test di integrazione mascherato da un test unitario, non è necessario applicare le normali regole di test unitario
MikeT

Risposte:


47

Non c'è davvero niente di sbagliato in questo, è solo una questione se lo chiami un test unitario o un test di integrazione. Devi solo assicurarti che se interagisci con il file system, non ci sono effetti collaterali indesiderati. In particolare, assicurati di ripulire dopo te stesso - elimina tutti i file temporanei che hai creato - e di non sovrascrivere accidentalmente un file esistente che ha lo stesso nome di un file temporaneo che stavi utilizzando. Usa sempre percorsi relativi e non percorsi assoluti.

Sarebbe anche una buona idea chdir()entrare in una directory temporanea prima di eseguire il test e chdir()viceversa.


27
+1, tuttavia, tieni presente che chdir()è a livello di processo, quindi potresti interrompere la possibilità di eseguire i test in parallelo, se il tuo framework di test o una sua versione futura lo supportano.

69

Sìì! Ora è testabile; Sono in grado di alimentare doppi di prova (simulazioni) con il metodo DoIt. Ma a quale costo? Ora ho dovuto definire 3 nuove interfacce solo per renderlo testabile. E cosa sto testando esattamente? Sto testando che la mia funzione DoIt interagisce correttamente con le sue dipendenze. Non verifica che il file zip sia stato decompresso correttamente, ecc.

Hai colpito l'unghia proprio sulla sua testa. Quello che vuoi testare è la logica del tuo metodo, non necessariamente se un vero file può essere indirizzato. Non è necessario verificare (in questo test unitario) se un file è stato decompresso correttamente, il metodo lo dà per scontato. Le interfacce sono preziose da sole perché forniscono astrazioni su cui è possibile programmare, piuttosto che fare affidamento implicitamente o esplicitamente su un'implementazione concreta.


12
La DoItfunzione testabile come indicato non ha nemmeno bisogno di essere testata . Come ha giustamente sottolineato l'interrogante, non è rimasto nulla di significativo da testare. Ora è l'implementazione di IZipper, IFileSysteme IDllRunnerche ha bisogno di essere testato, ma sono proprio le cose che sono state derise per il test!
Ian Goldby,

56

La tua domanda espone una delle parti più difficili dei test per gli sviluppatori:

"Che diavolo collaudo?"

Il tuo esempio non è molto interessante perché incolla solo alcune chiamate API, quindi se dovessi scrivere un test unit per esso finiresti per affermare che i metodi sono stati chiamati. Test come questo associano strettamente i dettagli dell'implementazione al test. Questo è negativo perché ora devi cambiare il test ogni volta che modifichi i dettagli di implementazione del tuo metodo perché la modifica dei dettagli di implementazione interrompe i tuoi test!

Avere dei brutti test è in realtà peggio che non averne affatto.

Nel tuo esempio:

void DoIt(IZipper zipper, IFileSystem fileSystem, IDllRunner runner)
{
   string path = zipper.Unzip(theZipFile);
   IFakeFile file = fileSystem.Open(path);
   runner.Run(file);
}

Mentre puoi passare in giro, non c'è logica nel metodo da testare. Se dovessi tentare un test unitario per questo potrebbe assomigliare a questo:

// Assuming that zipper, fileSystem, and runner are mocks
void testDoIt()
{
  // mock behavior of the mock objects
  when(zipper.Unzip(any(File.class)).thenReturn("some path");
  when(fileSystem.Open("some path")).thenReturn(mock(IFakeFile.class));

  // run the test
  someObject.DoIt(zipper, fileSystem, runner);

  // verify things were called
  verify(zipper).Unzip(any(File.class));
  verify(fileSystem).Open("some path"));
  verify(runner).Run(file);
}

Congratulazioni, hai praticamente incollato i dettagli di implementazione del tuo DoIt()metodo in un test. Buon mantenimento.

Quando si scrivono i test, si desidera testare il WHAT e non il HOW . Vedi Test Black Box per ulteriori informazioni.

Il COSA è il nome del metodo (o almeno dovrebbe essere). Gli HOW sono tutti i piccoli dettagli di implementazione che vivono nel tuo metodo. Buoni test ti consentono di scambiare l' HOW senza interrompere il WHAT .

Pensaci in questo modo, chiediti:

"Se modifico i dettagli di implementazione di questo metodo (senza alterare il contratto pubblico), interromperò i miei test?"

Se la risposta è sì, stai testando il HOW e non il WHAT .

Per rispondere alla tua domanda specifica sul test del codice con dipendenze del file system, diciamo che hai avuto qualcosa di un po 'più interessante in corso su un file e che volevi salvare il contenuto codificato Base64 di byte[]un file in un file. Puoi usare gli stream per questo per verificare che il tuo codice faccia la cosa giusta senza dover controllare come lo fa. Un esempio potrebbe essere qualcosa del genere (in Java):

interface StreamFactory {
    OutputStream outStream();
    InputStream inStream();
}

class Base64FileWriter {
    public void write(byte[] contents, StreamFactory streamFactory) {
        OutputStream outputStream = streamFactory.outStream();
        outputStream.write(Base64.encodeBase64(contents));
    }
}

@Test
public void save_shouldBase64EncodeContents() {
    OutputStream outputStream = new ByteArrayOutputStream();
    StreamFactory streamFactory = mock(StreamFactory.class);
    when(streamFactory.outStream()).thenReturn(outputStream);

    // Run the method under test
    Base64FileWriter fileWriter = new Base64FileWriter();
    fileWriter.write("Man".getBytes(), streamFactory);

    // Assert we saved the base64 encoded contents
    assertThat(outputStream.toString()).isEqualTo("TWFu");
}

Il test usa un ByteArrayOutputStreamma nell'applicazione (usando l'iniezione delle dipendenze) il vero StreamFactory (forse chiamato FileStreamFactory) tornerebbe FileOutputStreamda outputStream()e scriverà in un File.

La cosa interessante del writemetodo qui è che stava scrivendo i contenuti con codifica Base64, quindi è quello che abbiamo testato. Per il tuo DoIt()metodo, questo sarebbe testato in modo più appropriato con un test di integrazione .


1
Non sono sicuro di essere d'accordo con il tuo messaggio qui. Stai dicendo che non è necessario testare questo tipo di metodo? Quindi in pratica stai dicendo che TDD è male? Come se stessi facendo TDD, non puoi scrivere questo metodo senza prima scrivere un test. Oppure devi fare affidamento su un sospetto che il tuo metodo non richiederà un test? Il motivo per cui TUTTI i framework di test unitari includono una funzione di "verifica" è che è OK usarlo. "Questo è negativo perché ora devi cambiare il test ogni volta che modifichi i dettagli di implementazione del tuo metodo" ... benvenuto nel mondo dei test unitari.
Ronnie,

2
Dovresti testare il CONTRATTO di un metodo, non la sua implementazione. Se devi modificare il test ogni volta che viene modificata l'implementazione di quel contratto, rimani in un momento orribile mantenendo sia la base di codice dell'app che la base di codice di test.
Christopher Perry,

@Ronnie applicare alla cieca test unitari non è utile. Esistono progetti di varia natura e i test unitari non sono efficaci in tutti. Ad esempio, sto lavorando a un progetto in cui il 95% del codice riguarda gli effetti collaterali (si noti che questa natura pesante degli effetti collaterali è obbligatoria , è una complessità essenziale, non casuale , poiché raccoglie dati da un'ampia varietà di fonti stateful e la presenta con pochissima manipolazione, quindi non c'è quasi alcuna logica pura). Il test unitario non è efficace qui, lo è il test di integrazione.
Vicky Chijwani,

Gli effetti collaterali dovrebbero essere spinti ai bordi del sistema, non dovrebbero essere intrecciati attraverso i livelli. Ai bordi si verificano gli effetti collaterali, che sono comportamenti. Ovunque dovresti cercare di avere funzioni pure senza effetti collaterali, che sono facilmente testati e facili da ragionare, riutilizzare e comporre.
Christopher Perry,

24

Sono reticente a inquinare il mio codice con tipi e concetti che esistono solo per facilitare i test unitari. Certo, se rende il design più pulito e migliore, allora ottimo, ma penso che spesso non sia così.

La mia opinione è che i test unitari farebbero tutto il possibile, il che potrebbe non essere una copertura del 100%. In effetti, potrebbe essere solo del 10%. Il punto è che i test unitari dovrebbero essere veloci e non avere dipendenze esterne. Potrebbero testare casi come "questo metodo genera un ArgumentNullException quando si passa a null per questo parametro".

Vorrei quindi aggiungere test di integrazione (anche automatizzati e probabilmente utilizzando lo stesso framework di test unitari) che possono avere dipendenze esterne e testare scenari end-to-end come questi.

Quando misuro la copertura del codice, misuro sia i test unitari che quelli di integrazione.


5
Sì, ti sento. C'è questo bizzarro mondo che raggiungi dove hai disaccoppiato così tanto, che tutto ciò che ti rimane sono invocazioni di metodi su oggetti astratti. Lanugine ariosa. Quando raggiungi questo punto, non ti sembra di provare davvero qualcosa di reale. Stai solo testando le interazioni tra le classi.
Judah Gabriel Himango,

6
Questa risposta è sbagliata. Il test unitario non è come la glassa, è più simile allo zucchero. È cotto nella torta. Fa parte della scrittura del codice ... un'attività di progettazione. Pertanto, non "inquini" mai il tuo codice con qualcosa che "faciliterebbe i test" perché il test è ciò che ti facilita a scrivere il tuo codice. Il 99% delle volte è difficile scrivere un test perché lo sviluppatore ha scritto il codice prima del test e ha finito per scrivere codice non testabile malefico
Christopher Perry

1
@Christopher: per estendere la tua analogia, non voglio che la mia torta finisca per assomigliare a una fetta di vaniglia solo per poter usare lo zucchero. Tutto ciò che sto sostenendo è il pragmatismo.
Kent Boogaart,

1
@Christopher: la tua biografia dice tutto: "I'm a TDD zealot". Io invece sono pragmatico. Faccio TDD dove si adatta e non dove non lo fa - niente nella mia risposta suggerisce che non faccio TDD, anche se sembra che pensi che lo faccia. E che si tratti di TDD o meno, non introdurrò grandi quantità di complessità per facilitare i test.
Kent Boogaart,

3
@ChristopherPerry Puoi spiegare come risolvere il problema originale dell'OP in modo TDD? Mi imbatto in questo tutto il tempo; Devo scrivere una funzione il cui unico scopo è eseguire un'azione con una dipendenza esterna, come in questa domanda. Quindi, anche nello scenario in cui si scrive il test, quale sarebbe anche quel test?
Dax Fohl,

8

Non c'è niente di sbagliato nel colpire il file system, basta considerarlo un test di integrazione piuttosto che un test unitario. Scamberei il percorso hard coded con un percorso relativo e creerei una sottocartella TestData per contenere le zip per i test unitari.

Se i test di integrazione impiegano troppo tempo per essere eseguiti, separali in modo che non vengano eseguiti con la stessa frequenza dei test rapidi dell'unità.

Sono d'accordo, a volte penso che i test basati sulle interazioni possano causare troppi accoppiamenti e spesso finiscano per non fornire abbastanza valore. Vuoi davvero provare a decomprimere il file qui non solo per verificare che stai chiamando i metodi giusti.


Quanto spesso corrono è poco preoccupante; usiamo un server di integrazione continua che li esegue automaticamente per noi. Non ci interessa davvero quanto tempo impiegano. Se "per quanto tempo eseguire" non è un problema, c'è qualche motivo per distinguere tra test unitari e test di integrazione?
Judah Gabriel Himango,

4
Non proprio. Ma se gli sviluppatori vogliono eseguire rapidamente tutti i test unitari a livello locale, è bello avere un modo semplice per farlo.
JC.

6

Un modo sarebbe quello di scrivere il metodo di decompressione per prendere InputStreams. Quindi il test unitario potrebbe costruire un tale InputStream da un array di byte usando ByteArrayInputStream. Il contenuto di tale array di byte potrebbe essere una costante nel codice di unit test.


Ok, questo consente l'iniezione del flusso. Iniezione delle dipendenze / CIO. Che ne dici della parte di decomprimere il flusso in file, caricare una dll tra quei file e chiamare un metodo in quella dll?
Judah Gabriel Himango,

3

Questo sembra essere più un test di integrazione in quanto si dipende da un dettaglio specifico (il file system) che potrebbe cambiare, in teoria.

Vorrei astrarre il codice che si occupa del sistema operativo nel suo modulo (classe, assembly, jar, qualunque cosa). Nel tuo caso vuoi caricare una DLL specifica se trovata, quindi crea un'interfaccia IDllLoader e la classe DllLoader. La tua app ha acquisito la DLL dal DllLoader usando l'interfaccia e testalo ... dopotutto non sei responsabile del codice di decompressione?


2

Supponendo che le "interazioni del file system" siano ben testate nel framework stesso, creare il metodo per lavorare con i flussi e testarlo. L'apertura di un FileStream e il passaggio al metodo possono essere esclusi dai test, poiché FileStream.Open è ben testato dai creatori del framework.


Tu e nsayer avete essenzialmente lo stesso suggerimento: far funzionare il mio codice con i flussi. Che ne dici della parte su decomprimere il contenuto del flusso in file DLL, aprendo quella DLL e chiamando una funzione in esso? Cosa faresti lì?
Judah Gabriel Himango,

3
@JudahHimango. Tali parti potrebbero non essere necessariamente testabili. Non puoi testare tutto. Estrarre i componenti non testabili nei loro blocchi funzionali e supporre che funzioneranno. Quando ti imbatti in un bug nel modo in cui funziona questo blocco, quindi escogita un test per farlo e voilà. Test unitari NON significa che devi testare tutto. La copertura del codice al 100% non è realistica in alcuni scenari.
Zoran Pavlovic,

1

Non testare l'interazione di classe e la chiamata di funzione. invece dovresti considerare i test di integrazione. Testare il risultato richiesto e non l'operazione di caricamento del file.


1

Per unit test suggerirei di includere il file di test nel progetto (file EAR o equivalente), quindi utilizzare un percorso relativo nei test unitari, ad esempio "../testdata/testfile".

Finché il progetto viene esportato / importato correttamente, il test unitario dovrebbe funzionare.


0

Come altri hanno già detto, il primo va bene come test di integrazione. Il secondo verifica solo ciò che la funzione dovrebbe effettivamente fare, che è tutto ciò che un test unitario dovrebbe fare.

Come mostrato, il secondo esempio sembra un po 'inutile, ma ti dà l'opportunità di testare come la funzione risponde agli errori in uno qualsiasi dei passaggi. Nell'esempio non si verifica alcun errore, ma nel sistema reale si potrebbe avere e l'iniezione di dipendenza consente di testare tutte le risposte a eventuali errori. Quindi ne sarà valsa la pena.

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.