Dov'è la linea tra la logica dell'applicazione di unit test e costrutti linguistici diffidenti?


87

Considera una funzione come questa:

function savePeople(dataStore, people) {
    people.forEach(person => dataStore.savePerson(person));
}

Potrebbe essere usato in questo modo:

myDataStore = new Store('some connection string', 'password');
myPeople = ['Joe', 'Maggie', 'John'];
savePeople(myDataStore, myPeople);

Cerchiamo di supporre che Storeha i propri test di unità, o è vendor-fornito. In ogni caso, ci fidiamo Store. E supponiamo inoltre che la gestione degli errori - ad esempio, degli errori di disconnessione del database - non sia responsabilità di savePeople. In effetti, supponiamo che il negozio stesso sia un database magico che non può assolutamente sbagliare. Alla luce di questi presupposti, la domanda è:

Dovrebbero savePeople()essere testati in unità o tali test equivarrebbero a testare il forEachcostrutto del linguaggio integrato ?

Potremmo, ovviamente, passare in giro dataStoree affermare che dataStore.savePerson()viene chiamato una volta per ogni persona. Potresti certamente sostenere che un tale test fornisce sicurezza contro le modifiche all'implementazione: ad esempio, se decidessimo di sostituirlo forEachcon un forciclo tradizionale o qualche altro metodo di iterazione. Quindi il test non è del tutto banale. Eppure sembra terribilmente vicino ...


Ecco un altro esempio che potrebbe essere più fruttuoso. Considera una funzione che non fa altro che coordinare altri oggetti o funzioni. Per esempio:

function bakeCookies(dough, pan, oven) {
    panWithRawCookies = pan.add(dough);
    oven.addPan(panWithRawCookies);
    oven.bakeCookies();
    oven.removePan();
}

Come dovrebbe essere testata una funzione come questa, supponendo che tu debba farlo? E 'difficile per me immaginare qualsiasi tipo di test di unità che non si limita a deridono dough, pane oven, e poi affermano che i metodi vengono chiamati su di loro. Ma un tale test non sta facendo altro che duplicare l'esatta implementazione della funzione.

Questa incapacità di testare la funzione in un modo scatola nera significativa indica un difetto di progettazione con la funzione stessa? In tal caso, come potrebbe essere migliorato?


Per dare ancora più chiarezza alla domanda che motiva l' bakeCookiesesempio, aggiungerò uno scenario più realistico, che è quello che ho incontrato quando ho tentato di aggiungere test e refactoring codice legacy.

Quando un utente crea un nuovo account, devono accadere diverse cose: 1) è necessario creare un nuovo record utente nel database 2) è necessario inviare un'e-mail di benvenuto 3) l'indirizzo IP dell'utente deve essere registrato per frode scopi.

Quindi vogliamo creare un metodo che colleghi tutti i passaggi del "nuovo utente":

function createNewUser(validatedUserData, emailService, dataStore) {
  userId = dataStore.insertUserRecord(validateduserData);
  emailService.sendWelcomeEmail(validatedUserData);
  dataStore.recordIpAddress(userId, validatedUserData.ip);
}

Si noti che se uno di questi metodi genera un errore, vogliamo che l'errore raggiunga il codice chiamante, in modo che possa gestire l'errore come ritiene opportuno. Se viene chiamato dal codice API, potrebbe tradurre l'errore in un codice di risposta http appropriato. Se viene chiamato da un'interfaccia Web, potrebbe tradurre l'errore in un messaggio appropriato da visualizzare all'utente e così via. Il punto è che questa funzione non sa come gestire gli errori che possono essere generati.

L'essenza della mia confusione è che per testare una tale funzione sembra necessario ripetere l'esatta implementazione nel test stesso (specificando che i metodi sono chiamati in derisione in un certo ordine) e questo sembra sbagliato.


44
Dopo che corre. Hai dei biscotti
Ewan,

6
per quanto riguarda l'aggiornamento: perché mai vorresti deridere una padella? o pasta? questi sembrano semplici oggetti in memoria che dovrebbero essere banali da creare, e quindi non c'è motivo per cui non dovresti testarli come una sola unità. ricorda, "unità" in "unit test" non significa "una singola classe". significa "la più piccola unità di codice possibile per fare qualcosa". una padella probabilmente non è altro che un contenitore per oggetti di pasta, quindi sarebbe stata concepita per testarla in isolamento invece di testare il metodo bakeCookies dall'esterno.
sara

11
Alla fine della giornata, il principio fondamentale al lavoro qui è che si scrivono abbastanza test per assicurarsi che il codice funzioni e che sia un adeguato "canarino nella miniera di carbone" quando qualcuno cambia qualcosa. Questo è tutto. Non ci sono incantesimi magici, supposizioni formulaiche o asserzioni dogmatiche, motivo per cui la copertura del codice dall'85% al ​​90% (non il 100%) è ampiamente considerata eccellente.
Robert Harvey,

5
@RobertHarvey sfortunatamente banalità formali e morsi sonori TDD, sebbene sicuri di farti annuire entusiasta di accordo, non aiutano a risolvere i problemi del mondo reale. per questo devi sporcarti le mani e rischiare di rispondere a una vera domanda
Giona

4
Test unitario in ordine decrescente di complessità ciclomatica. Fidati di me, finirai il tempo prima di arrivare a questa funzione
Neil McGuigan,

Risposte:


118

Dovrebbe savePeople()essere testato in unità? Sì. Non stai testando che dataStore.savePersonfunzioni o che la connessione db funzioni o che foreachfunzioni. Stai testando che savePeoplesoddisfa la promessa che fa attraverso il suo contratto.

Immagina questo scenario: qualcuno fa un grande refactor della base di codice e rimuove accidentalmente la forEachparte dell'implementazione in modo che salvi sempre solo il primo elemento. Non vorresti che un test unitario lo rilevasse?


20
@RobertHarvey: C'è molta area grigia e fare la distinzione, IMO, non è importante. Hai ragione però - non è davvero importante verificare che "chiama le funzioni giuste", ma piuttosto "fa la cosa giusta" indipendentemente da come lo fa. Ciò che è importante è testare che, dato un set specifico di input per la funzione, si ottiene un set specifico di output. Tuttavia, posso vedere come quest'ultima frase possa creare confusione, quindi l'ho rimossa.
Bryan Oakley,

64
"Stai testando che savePeople mantiene la promessa che fa attraverso il suo contratto." Questo. Questo così tanto.
Lovis,

2
A meno che tu non abbia un test di sistema "end to end" che lo copre.
Ian,

6
@Ian I test end-to-end non sostituiscono i test unitari, sono gratuiti. Solo perché potresti avere un test end-to-end che ti assicura di salvare un elenco di persone non significa che non dovresti avere un test unitario per coprirlo.
Vincent Savard,

4
@VincentSavard, ma il costo / beneficio di un test unitario è ridotto se il rischio è controllo in un altro modo.
Ian,

36

Di solito questo tipo di domanda sorge quando le persone fanno lo sviluppo "test-after". Affronta questo problema dal punto di vista del TDD, in cui i test vengono prima dell'implementazione e poniti di nuovo questa domanda come esercizio.

Almeno nella mia applicazione di TDD, che di solito è fuori-dentro, non implementerei una funzione come savePeopledopo averla implementata savePerson. Le funzioni savePeoplee savePersoninizierebbero come una sola e verrebbero guidate dai test delle stesse unit unit; la separazione tra i due sorgerebbe dopo alcuni test, nella fase di refactoring. Questa modalità di lavoro pone anche la questione di dove savePeopledovrebbe essere la funzione - se si tratta di una funzione libera o parte di dataStore.

Alla fine, i test non controllerebbero solo se è possibile salvare correttamente un file Personin Store, ma anche molte persone. Ciò mi porterebbe anche a chiedermi se sono necessari altri controlli, ad esempio "Devo assicurarmi che la savePeoplefunzione sia atomica, salvando tutto o nessuno?", "Può solo in qualche modo restituire errori per le persone che non potrebbero ' t essere salvato? Come sarebbero questi errori? ", e così via. Tutto ciò equivale a molto più del semplice controllo dell'uso di una forEacho di altre forme di iterazione.

Tuttavia, se il requisito di salvare più di una persona alla volta arrivasse solo dopo che savePersonera già stato consegnato, aggiornerei i test esistenti savePersonper eseguire la nuova funzione savePeople, assicurandomi che possa ancora salvare una persona semplicemente delegando all'inizio, quindi testare il comportamento per più di una persona attraverso nuovi test, pensando se sarebbe necessario rendere il comportamento atomico o meno.


4
Fondamentalmente testare l'interfaccia, non l'implementazione.
Snoop,

8
Punti giusti e perspicaci. Eppure sento che in qualche modo la mia vera domanda è stata schivata :) La tua risposta sta dicendo: "Nel mondo reale, in un sistema ben progettato, non credo che questa versione semplificata del tuo problema esisterebbe". Ancora una volta, giusto, ma ho creato appositamente questa versione semplificata per evidenziare l'essenza di un problema più generale. Se non riesci a superare la natura artificiale dell'esempio, potresti forse immaginare un altro esempio in cui hai avuto una buona ragione per una funzione simile, che ha fatto solo iterazione e delega. O forse pensi che sia semplicemente impossibile?
Giona,

@Jonah aggiornato. Spero che risponda un po 'meglio alla tua domanda. Tutto questo è molto basato sull'opinione e può essere contro l'obiettivo di questo sito, ma è sicuramente una discussione molto interessante da avere. A proposito, ho cercato di rispondere dal punto di vista del lavoro professionale, in cui dobbiamo sforzarci di lasciare unit test per tutto il comportamento dell'applicazione, indipendentemente da quanto banale possa essere l'implementazione, perché abbiamo il dovere di costruire un test ben collaudato e sistema documentato per i nuovi manutentori se lasciamo. Per progetti personali o, per esempio, non critici (anche il denaro è fondamentale), ho un'opinione molto diversa.
MichelHenrich,

Grazie per l'aggiornamento. Come testeresti esattamente savePeople? Come ho descritto nell'ultimo paragrafo di OP o in qualche altro modo?
Giona,

1
Mi dispiace, non mi sono chiarito con la parte "niente scherno coinvolti". Volevo dire che non avrei usato un finto per la savePersonfunzione come mi avevi suggerito, invece lo avrei testato in modo più generale savePeople. I test unitari Storeverrebbero cambiati per essere eseguiti savePeopleanziché chiamare direttamente savePerson, quindi per questo non vengono utilizzati simulazioni. Ma il database ovviamente non dovrebbe essere presente, poiché vorremmo isolare i problemi di codifica dai vari problemi di integrazione che si verificano con i database effettivi, quindi qui abbiamo ancora una finta.
MichelHenrich,

21

SavePeople () deve essere testato sull'unità

Sì, dovrebbe. Ma prova a scrivere le condizioni del test in modo indipendente dall'implementazione. Ad esempio, trasformando il tuo esempio di utilizzo in un test unitario:

function testSavePeople() {
    myDataStore = new Store('some connection string', 'password');
    myPeople = ['Joe', 'Maggie', 'John'];
    savePeople(myDataStore, myPeople);
    assert(myDataStore.containsPerson('Joe'));
    assert(myDataStore.containsPerson('Maggie'));
    assert(myDataStore.containsPerson('John'));
}

Questo test fa più cose:

  • verifica il contratto della funzione savePeople()
  • non si preoccupa per l'implementazione di savePeople()
  • documenta l'utilizzo esemplificativo di savePeople()

Tieni presente che puoi ancora deridere / stub / falsificare l'archivio dati. In questo caso non verificherei le chiamate di funzione esplicite, ma il risultato dell'operazione. In questo modo il mio test è preparato per futuri cambiamenti / refactor.

Ad esempio, l'implementazione dell'archivio dati potrebbe fornire un saveBulkPerson()metodo in futuro: ora una modifica all'implementazione savePeople()da utilizzare saveBulkPerson()non interromperebbe il test unitario fintanto che saveBulkPerson()funziona come previsto. E se in saveBulkPerson()qualche modo non funziona come previsto, il test unitario lo capirà.

o tali test equivarrebbero a testare il costrutto integrato per ogni linguaggio?

Come detto, prova a verificare i risultati attesi e l'interfaccia della funzione, non per l'implementazione (a meno che non stia eseguendo test di integrazione, quindi potrebbe essere utile prendere chiamate a funzioni specifiche). Se esistono diversi modi per implementare una funzione, tutti dovrebbero funzionare con il test unitario.

Per quanto riguarda l'aggiornamento della domanda:

Test per i cambiamenti di stato! Ad esempio, parte dell'impasto verrà utilizzato. In base all'implementazione, asserisci che la quantità di usato si doughadatta pano asserisce che doughè esaurito. Asserire che pancontiene i cookie dopo la chiamata di funzione. Asserire che ovenè vuoto / nello stesso stato di prima.

Per ulteriori test, verificare i casi limite: cosa succede se ovennon è vuoto prima della chiamata? Cosa succede se non c'è abbastanza dough? Se panè già pieno?

Dovresti essere in grado di dedurre tutti i dati richiesti per questi test dagli oggetti impasto, padella e forno stessi. Non è necessario acquisire le chiamate di funzione. Tratta la funzione come se la sua implementazione non fosse disponibile per te!

In effetti, la maggior parte degli utenti TDD scrive i propri test prima di scrivere la funzione in modo da non dipendere dall'implementazione effettiva.


Per la tua ultima aggiunta:

Quando un utente crea un nuovo account, devono accadere diverse cose: 1) è necessario creare un nuovo record utente nel database 2) è necessario inviare un'e-mail di benvenuto 3) l'indirizzo IP dell'utente deve essere registrato per frode scopi.

Quindi vogliamo creare un metodo che colleghi tutti i passaggi del "nuovo utente":

function createNewUser(validatedUserData, emailService, dataStore) {
    userId = dataStore.insertUserRecord(validateduserData);
    emailService.sendWelcomeEmail(validatedUserData);
    dataStore.recordIpAddress(userId, validatedUserData.ip);
}

Per una funzione come questa deriderei / stub / fake (qualunque cosa sembri più generale) i parametri dataStoree emailService. Questa funzione non esegue alcuna transizione di stato su nessun parametro da sola, ma li delega ai metodi di alcuni di essi. Vorrei provare a verificare che la chiamata alla funzione abbia fatto 4 cose:

  • ha inserito un utente nell'archivio dati
  • ha inviato (o almeno chiamato il metodo corrispondente) un'e-mail di benvenuto
  • ha registrato l'IP dell'utente nell'archivio dati
  • ha delegato qualsiasi eccezione / errore riscontrato (se presente)

I primi 3 controlli possono essere fatti con mock, stub o falsi di dataStoree emailService(non vuoi davvero inviare e-mail durante il test). Dal momento che ho dovuto cercare questo per alcuni dei commenti, queste sono le differenze:

  • Un falso è un oggetto che si comporta come l'originale ed è in una certa misura indistinguibile. Il suo codice può normalmente essere riutilizzato attraverso i test. Questo può, ad esempio, essere un semplice database in memoria per un wrapper di database.
  • Uno stub implementa solo quanto necessario per eseguire le operazioni richieste di questo test. Nella maggior parte dei casi, uno stub è specifico di un test o di un gruppo di test che richiedono solo una piccola serie di metodi dell'originale. In questo esempio, potrebbe essere uno dataStoreche implementa solo una versione adatta di insertUserRecord()e recordIpAddress().
  • Un mock è un oggetto che ti consente di verificare come viene utilizzato (il più delle volte permettendoti di valutare le chiamate ai suoi metodi). Proverei a usarli con parsimonia nei test unitari poiché usandoli in realtà provi a testare l'implementazione della funzione e non l'aderenza alla sua interfaccia, ma hanno ancora i loro usi. Esistono molti framework finti per aiutarti a creare solo il modello di cui hai bisogno.

Si noti che se uno di questi metodi genera un errore, vogliamo che l'errore raggiunga il codice chiamante, in modo che possa gestire l'errore come ritiene opportuno. Se viene chiamato dal codice API, potrebbe tradurre l'errore in un codice di risposta HTTP appropriato. Se viene chiamato da un'interfaccia Web, potrebbe tradurre l'errore in un messaggio appropriato da visualizzare all'utente e così via. Il punto è che questa funzione non sa come gestire gli errori che possono essere generati.

Le eccezioni / errori previsti sono casi di test validi: si conferma che, nel caso in cui si verifichi un evento del genere, la funzione si comporta come previsto. Ciò può essere ottenuto lasciando cadere l'oggetto corrispondente finto / falso / mozzicone quando desiderato.

L'essenza della mia confusione è che per testare una tale funzione sembra necessario ripetere l'esatta implementazione nel test stesso (specificando che i metodi sono chiamati in derisione in un certo ordine) e questo sembra sbagliato.

A volte questo deve essere fatto (anche se ti interessa soprattutto questo nei test di integrazione). Più spesso, ci sono altri modi per verificare gli effetti collaterali / i cambiamenti di stato previsti.

La verifica delle chiamate esatte alle funzioni comporta test unitari piuttosto fragili: solo piccole modifiche alla funzione originale causano il fallimento. Questo può essere desiderato o meno, ma richiede una modifica ai corrispondenti test unitari ogni volta che si cambia una funzione (sia che si tratti di refactoring, ottimizzazione, correzione di bug, ...).

Purtroppo, in tal caso il test unitario perde parte della sua credibilità: poiché è stato modificato, non conferma la funzione dopo che la modifica si comporta allo stesso modo di prima.

Per un esempio, considera qualcuno che aggiunge una chiamata a oven.preheat()(ottimizzazione!) Nell'esempio di cottura dei cookie:

  • Se hai deriso l'oggetto forno, non si aspetterà quella chiamata e fallirà il test, anche se il comportamento osservabile del metodo non è cambiato (hai ancora una padella di biscotti, si spera).
  • Uno stub potrebbe non funzionare, a seconda che siano stati aggiunti solo i metodi da testare o l'intera interfaccia con alcuni metodi fittizi.
  • Un falso non dovrebbe fallire, dal momento che dovrebbe implementare il metodo (secondo l'interfaccia)

Nei miei test unitari, cerco di essere il più generale possibile: se l'implementazione cambia, ma il comportamento visibile (dal punto di vista del chiamante) è sempre lo stesso, i miei test dovrebbero passare. Idealmente, l'unico caso di cui ho bisogno per cambiare un test unitario esistente dovrebbe essere una correzione di bug (del test, non della funzione sotto test).


1
Il problema è che non appena si scrive myDataStore.containsPerson('Joe')si presuppone l'esistenza di un database di test funzionale. Una volta fatto ciò, stai scrivendo un test di integrazione e non un test unitario.
Giona,

Suppongo di poter fare affidamento su un archivio di dati di test (non mi importa se è reale o finto) e che tutto funziona come impostato (dal momento che dovrei avere già unit test per quei casi). L'unica cosa che il test vuole testare è che in savePeople()realtà aggiunge quelle persone a qualsiasi archivio dati fornito, purché tale archivio implementi l'interfaccia prevista. Un test di integrazione sarebbe, ad esempio, verificare che il mio wrapper di database esegua effettivamente le chiamate di database corrette per una chiamata di metodo.
hoffmale,

Per chiarire, se stai usando un mock, tutto ciò che puoi fare è affermare che è stato chiamato un metodo su quel mock , magari con qualche parametro specifico. Successivamente non è possibile affermare lo stato della derisione. Quindi, se vuoi fare affermazioni sullo stato del database dopo aver chiamato il metodo sotto test, come in myDataStore.containsPerson('Joe'), devi usare un db funzionale di qualche tipo. Una volta fatto questo passo, non è più un test unitario.
Giona,

1
non deve essere un vero database - solo un oggetto che implementa la stessa interfaccia dell'archivio dati reale (leggi: supera i test unitari rilevanti per l'interfaccia dell'archivio dati). Lo considererei ancora finto. Lascia che il finto memorizzi tutto ciò che viene aggiunto da qualsiasi metodo per farlo in un array e controlla se i dati di test (elementi di myPeople) sono nell'array. IMHO un mock dovrebbe comunque avere lo stesso comportamento osservabile previsto da un oggetto reale, altrimenti stai testando la conformità con l'interfaccia mock, non con quella reale.
hoffmale,

"Lascia che il finto memorizzi tutto ciò che viene aggiunto da qualsiasi metodo per farlo in un array e controlla se i dati di test (elementi di myPeople) sono nell'array" - è ancora un database "reale", solo un ad-hoc, in memoria uno che hai costruito. "IMHO un mock dovrebbe comunque avere lo stesso comportamento osservabile che ci si aspetta da un oggetto reale" - Suppongo che tu possa difenderlo, ma non è questo che "mock" significa nella letteratura di test o in nessuna delle biblioteche popolari che ho " ho visto. Una simulazione verifica semplicemente che i metodi previsti vengano chiamati con i parametri previsti.
Giona,

13

Il valore principale fornito da questo test è che rende la tua implementazione rifattibile.

Nella mia carriera ho usato molte ottimizzazioni delle prestazioni e spesso ho riscontrato problemi con lo schema esatto che hai dimostrato: per salvare N entità nel database, eseguire N inserimenti. Di solito è più efficiente eseguire un inserimento in blocco usando una singola istruzione.

D'altra parte, non vogliamo neanche ottimizzare prematuramente. Se in genere si salvano solo 1-3 persone alla volta, la scrittura di un batch ottimizzato potrebbe essere eccessiva.

Con un test unitario adeguato, puoi scriverlo nel modo in cui lo hai implementato sopra e, se ritieni che sia necessario ottimizzarlo, sei libero di farlo con la rete di sicurezza di un test automatizzato per rilevare eventuali errori. Naturalmente, questo varia in base alla qualità dei test, quindi test liberamente e testare bene.

Il vantaggio secondario di test unitari di questo comportamento è quello di servire da documentazione per quale sia il suo scopo. Questo banale esempio può essere ovvio, ma dato il punto seguente di seguito, potrebbe essere molto importante.

Il terzo vantaggio, che altri hanno sottolineato, è che puoi testare i dettagli sotto copertura che sono molto difficili da testare con i test di integrazione o accettazione. Ad esempio, se è necessario che tutti gli utenti vengano salvati in modo atomico, è possibile scrivere un test case per questo, che fornisce un modo per sapere che si comporta come previsto e serve anche come documentazione per un requisito che potrebbe non essere ovvio ai nuovi sviluppatori.

Aggiungerò un pensiero che ho avuto da un istruttore TDD. Non testare il metodo Metti alla prova il comportamento. In altre parole, non testate che savePeoplefunziona, state testando che più utenti possono essere salvati in una singola chiamata.

Ho scoperto che la mia capacità di eseguire test di unità di qualità e TDD migliora quando smetto di pensare ai test di unità come verifica del funzionamento di un programma, ma piuttosto, verificano che un'unità di codice faccia quello che mi aspetto . Quelli sono diversi. Non verificano che funzioni, ma verificano che faccia quello che penso faccia. Quando ho iniziato a pensare in quel modo, la mia prospettiva è cambiata.


L'esempio di refactoring per inserti di massa è valido. Il possibile test unitario che ho suggerito nel PO - che un simulatore di DataStore lo ha savePersoninvocato per ogni persona nell'elenco - si romperà comunque con un refactoring di inserti in blocco. Il che per me indica che è un test unitario scadente. Tuttavia, non ne vedo uno alternativo che passi entrambe le implementazioni bulk e one-save-per-person, senza utilizzare un database di test effettivo e affermando contro quello, che sembra sbagliato. Potresti fornire un test che funzioni per entrambe le implementazioni?
Giona,

1
@ jpmc26 Che dire di un test che verifica che le persone sono state salvate ...?
user253751

1
@immibis Non capisco cosa significhi. Presumibilmente, il negozio reale è supportato da un database, quindi dovresti prenderlo in giro o stub per un test di unità. Quindi a quel punto, verifichi che il tuo mock o stub può memorizzare oggetti. È assolutamente inutile. Il meglio che puoi fare è affermare che il savePersonmetodo è stato chiamato per ogni input e che se sostituissi il loop con un inserimento in blocco, non chiameresti più quel metodo. Quindi il tuo test si interromperebbe. Se hai in mente qualcos'altro, sono aperto ad esso, ma non lo vedo ancora. (E non vederlo era il mio punto.)
jpmc26

1
@immibis Non lo considero un test utile. L'uso dell'archivio dati falso non mi dà la certezza che funzionerà con la cosa reale. Come faccio a sapere se i miei lavori falsi sono reali? Preferirei solo lasciarlo coprire da una serie di test di integrazione. (Probabilmente dovrei chiarire che intendevo "qualsiasi test unitario " nel mio primo commento qui.)
jpmc26

1
@immibis In realtà sto riconsiderando la mia posizione. Sono stato scettico sul valore dei test unitari a causa di problemi come questo, ma forse sto sottovalutando il valore anche se falsi un input. Io non so che il test di ingresso / uscita tende ad essere molto più utile di test pesanti finto, ma forse un rifiuto di sostituire un ingresso con un falso è in realtà parte del problema qui.
jpmc26,

6

Dovrebbe bakeCookies()essere testato? Sì.

Come dovrebbe essere testata una funzione come questa, supponendo che tu debba farlo? È difficile per me immaginare qualsiasi tipo di test unitario che non deride semplicemente la pasta, la padella e il forno, e quindi affermare che i metodi vengono chiamati su di loro.

Non proprio. Osserva attentamente CHE COSA dovrebbe fare la funzione: dovrebbe impostare l' ovenoggetto su uno stato specifico. Guardando il codice sembra che gli stati degli oggetti pane doughnon contino molto. Quindi dovresti passare un ovenoggetto (o deriderlo) e asserire che si trova in uno stato particolare alla fine della chiamata di funzione.

In altre parole, dovresti affermare che bakeCookies()i biscotti sono al forno .

Per funzioni molto brevi, i test unitari potrebbero sembrare poco più che tautologia. Ma non dimenticare, il tuo programma durerà molto più a lungo del tempo impiegato per scriverlo. Tale funzione potrebbe cambiare o meno in futuro.

I test unitari hanno due funzioni:

  1. Verifica che tutto funzioni. Questa è la funzione di test dell'unità funzionale meno utile e sembra che sembri considerare questa funzionalità solo quando fai la domanda.

  2. Verifica che le future modifiche del programma non interrompano la funzionalità precedentemente implementata. Questa è la funzione più utile dei test unitari e impedisce l'introduzione di bug in programmi di grandi dimensioni. È utile nella normale codifica quando si aggiungono funzionalità al programma, ma è più utile nel refactoring e nelle ottimizzazioni in cui gli algoritmi core che implementano il programma vengono cambiati radicalmente senza cambiare alcun comportamento osservabile del programma.

Non testare il codice all'interno della funzione. Prova invece che la funzione fa quello che dice di fare. Quando osservi i test unitari in questo modo (funzioni di test, non di codice), ti accorgerai di non testare mai i costrutti del linguaggio o la logica dell'applicazione. Stai testando un'API.


Ciao, grazie per la tua risposta. Ti dispiacerebbe guardare il mio secondo aggiornamento e dare le tue opinioni su come testare l'unità la funzione in quell'esempio?
Giona,

Trovo che questo possa essere efficace quando puoi usare un forno reale o un forno falso, ma è significativamente meno efficace con un forno finto. Le beffe (secondo le definizioni di Meszaros) non hanno alcuno stato, a parte una registrazione dei metodi che sono stati chiamati su di loro. La mia esperienza è che quando funzioni come bakeCookiesvengono testate in questo modo, tendono a rompersi durante i refactor che non influirebbero sul comportamento osservabile dell'applicazione.
James_pic,

@James_pic, esattamente. E sì, questa è la finta definizione che sto usando. Quindi, dato il tuo commento, cosa fai in un caso come questo? Hai rinunciato al test? Scrivi comunque il test fragile e ripetitivo di implementazione? Qualcos'altro?
Giona,

@Jonah Generalmente esaminerò il test di quel componente con i test di integrazione (ho trovato che gli avvertimenti che rendono più difficile il debug del debug siano esagerati, probabilmente a causa della qualità degli strumenti moderni), o andrò nella difficoltà di creare un falso semi-convincente.
James_pic,

3

SavePeople () dovrebbe essere testato in unità o equivarrebbe a testare il costrutto integrato per ogni linguaggio?

Sì. Ma si potrebbe farlo in un modo che sarebbe solo ripetere il test del costrutto.

La cosa da notare qui è come si comporta questa funzione in caso di savePersonfallimento a metà strada? Come dovrebbe funzionare?

Questo è il tipo di comportamento sottile che la funzione prevede di applicare con unit test.


Sì, sono d'accordo che si dovrebbero verificare condizioni di errore sottili , ma non è una domanda interessante: la risposta è chiara. Da qui il motivo per cui ho affermato espressamente che, ai fini della mia domanda, savePeoplenon dovrebbe essere responsabile della gestione degli errori. Per chiarire di nuovo, supponendo che savePeoplesia responsabile solo per iterare l'elenco e delegare il salvataggio di ciascun elemento in un altro metodo, dovrebbe ancora essere testato?
Giona,

@Jonah: se vuoi insistere nel limitare il test unitario solo al foreachcostrutto e non a condizioni, effetti collaterali o comportamenti al di fuori di esso, allora hai ragione; il nuovo test unitario non è poi così interessante.
Robert Harvey,

1
@jonah: dovrebbe scorrere e salvare il maggior numero possibile o interrompere l'errore? Il singolo salvataggio non può decidere, dal momento che non può sapere come viene utilizzato.
Telastyn,

1
@jonah - benvenuto nel sito. Uno dei componenti chiave della nostra Q & A formato è che non siamo qui per aiutare voi . La tua domanda ti aiuta ovviamente, ma aiuta anche molte altre persone che vengono sul sito alla ricerca di risposte alle loro domande. Ho risposto alla domanda che hai posto. Non è colpa mia se non ti piace la risposta o preferisci spostare i pali della porta. E francamente, sembra che le altre risposte dicano la stessa cosa di base, anche se in modo più eloquente.
Telastyn,

1
@Telastyn, sto cercando di ottenere informazioni dettagliate sui test unitari. La mia domanda iniziale non era abbastanza chiara, quindi sto aggiungendo dei chiarimenti per indirizzare la conversazione verso la mia vera domanda. Stai scegliendo di interpretarlo come se in qualche modo ti tradissi nel gioco di "avere ragione". Ho trascorso centinaia di ore a rispondere a domande sulla revisione del codice e SO. Il mio scopo è sempre quello di aiutare le persone a cui rispondo. Se il tuo non lo è, questa è la tua scelta. Non devi rispondere alle mie domande.
Giona,

3

La chiave qui è la tua prospettiva su una funzione particolare come banale. La maggior parte della programmazione è banale: assegnare un valore, fare un po 'di matematica, prendere una decisione: se questo poi quello, continuare un ciclo fino a ... In isolamento, tutto banale. Hai appena superato i primi 5 capitoli di qualsiasi libro che insegna un linguaggio di programmazione.

Il fatto che scrivere un test sia così semplice dovrebbe essere un segno che il tuo design non è poi così male. Preferiresti un design che non sia facile da testare?

"Questo non cambierà mai." è come inizia la maggior parte dei progetti falliti. Un test unitario determina solo se l'unità funziona come previsto in determinate circostanze. Fallo passare e poi puoi dimenticare i suoi dettagli di implementazione e semplicemente usarlo. Usa quello spazio del cervello per il prossimo compito.

Conoscere le cose funziona come previsto è molto importante e non banale in progetti di grandi dimensioni e soprattutto in team di grandi dimensioni. Se c'è una cosa che i programmatori hanno in comune, è il fatto che tutti abbiamo avuto a che fare con il codice terribile di qualcun altro. Il minimo che possiamo fare è fare alcuni test. In caso di dubbi, scrivi un test e vai avanti.


Grazie per il tuo feedback Punti buoni. La domanda a cui voglio davvero rispondere (ho appena aggiunto un altro aggiornamento per chiarire) è il modo appropriato per testare le funzioni che non fanno altro che chiamare una sequenza di altri servizi attraverso la delega. In tali casi, sembra che i test unitari appropriati per "documentare il contratto" siano solo una riaffermazione dell'implementazione della funzione, affermando che i metodi sono chiamati su varie beffe. Tuttavia il test essendo identico all'implementazione in quei casi sembra sbagliato ...
Giona

1

SavePeople () dovrebbe essere testato in unità o equivarrebbe a testare il costrutto integrato per ogni linguaggio?

@BryanOakley ha già risposto a questa domanda, ma ho alcuni argomenti in più (suppongo):

Innanzitutto un test unitario serve a testare l'adempimento di un contratto, non l'implementazione di un'API; il test dovrebbe stabilire le condizioni preliminari, quindi chiamare, quindi verificare la presenza di effetti, effetti collaterali, eventuali invarianti e condizioni post. Quando decidi cosa testare, l'implementazione dell'API non importa (e non dovrebbe) .

Secondo, il tuo test sarà lì per controllare gli invarianti quando la funzione cambia . Il fatto che non cambi ora non significa che non dovresti avere il test.

Terzo, è utile aver implementato un test banale, sia in un approccio TDD (che lo impone) sia al di fuori di esso.

Quando scrivo C ++, per le mie lezioni, tendo a scrivere un banale test che crea un'istanza di un oggetto e controlla invarianti (assegnabili, regolari, ecc.). Ho trovato sorprendente quante volte questo test viene interrotto durante lo sviluppo (ad esempio aggiungendo un membro non mobile in una classe, per errore).


1

Penso che la tua domanda si riduce a:

Come posso testare un'unità di una funzione nulla senza che sia un test di integrazione?

Se cambiamo la funzione di cottura dei cookie per restituire i cookie, ad esempio, diventa immediatamente ovvio quale dovrebbe essere il test.

Se dobbiamo chiamare pan.GetCookies dopo aver chiamato la funzione, anche se possiamo chiederci se è "davvero un test di integrazione" o "ma non stiamo testando l'oggetto pan?"

Penso che tu abbia ragione nel dire che avere test unitari con tutto ciò che è stato deriso e solo il controllo delle funzioni xy e z erano chiamati mancanza di valore.

Ma! Direi che in questi casi dovresti riformattare le funzioni del tuo vuoto per restituire un risultato verificabile O usare oggetti reali e fare un test di integrazione

--- Aggiornamento per l'esempio createNewUser

  • è necessario creare un nuovo record utente nel database
  • è necessario inviare un'e-mail di benvenuto
  • l'indirizzo IP dell'utente deve essere registrato a fini di frode.

OK, questa volta il risultato della funzione non viene restituito facilmente. Vogliamo cambiare lo stato dei parametri.

È qui che divento leggermente controverso. Creo implementazioni fittizie concrete per i parametri stateful

per favore, cari lettori, cercate di controllare la vostra rabbia!

così...

var validatedUserData = new UserData(); //we can use the real object for this
var emailService = new MockEmailService(); //a simple mock which saves sentEmails to a List<string>
var dataStore = new MockDataStore(); //a simple mock which saves ips to a List<string>

//run the test
target.createNewUser(validatedUserData, emailService, dataStore);

//check the results
Assert.AreEqual(1, emailService.EmailsSent.Count());
Assert.AreEqual(1, dataStore.IpsRecorded.Count());
Assert.AreEqual(1, dataStore.UsersSaved.Count());

Ciò separa i dettagli di implementazione del metodo testato dal comportamento desiderato. Un'implementazione alternativa:

function createNewUser(validatedUserData, emailService, dataStore) {
  userId = dataStore.bulkInsedrtUserRecords(new [] {validateduserData});
  emailService.addEmailToQueue(validatedUserData);
  emailService.ProcessQueue();
  dataStore.recordIpAddress(userId, validatedUserData.ip);
}

Passerà comunque il test unitario. Inoltre hai il vantaggio di essere in grado di riutilizzare gli oggetti finti attraverso i test e di iniettarli anche nella tua applicazione per l'interfaccia utente o i test di integrazione.


non è un test di integrazione semplicemente perché menzioni i nomi di 2 classi concrete ... i test di integrazione riguardano il test di integrazioni con sistemi esterni, come IO del disco, DB, servizi Web esterni e così via. la chiamata pan.getCookies () -memoria, veloce, verifica ciò che ci interessa, ecc. Sono d'accordo sul fatto che avere il metodo restituisce i cookie direttamente come se fosse un design migliore.
Sara

3
Aspettare. Per quanto ne sappiamo pan.getcookies invia una mail a un cuoco chiedendo loro di portare i biscotti fuori dal forno quando ne hanno la possibilità
Ewan

Immagino sia teoricamente possibile, ma sarebbe un nome piuttosto fuorviante. chi ha mai sentito parlare dell'attrezzatura del forno che ha inviato e-mail? ma ti vedo punto, dipende. Presumo che queste classi collaboranti siano oggetti fogliari o semplici cose in memoria, ma se fanno cose subdole, allora è necessaria cautela. Penso che l'invio di email sicuramente dovrebbe essere fatto a un livello superiore rispetto a questo però. questa sembra la logica di business sporca e sporca delle entità.
Sara

2
Era una domanda retorica, ma: "chi ha mai sentito parlare dell'attrezzatura del forno che ha inviato e-mail?" venturebeat.com/2016/03/08/...
clacke

Ciao Ewan, penso che questa risposta si stia avvicinando così tanto a quello che sto davvero chiedendo. Penso che il tuo punto sulla bakeCookiesrestituzione dei biscotti appena sfornati sia perfetto e ho riflettuto dopo averlo pubblicato. Quindi penso che non sia ancora un grande esempio. Ho aggiunto un altro aggiornamento che si spera fornisca un esempio più realistico di ciò che motiva la mia domanda. Apprezzerei il tuo contributo.
Giona,

0

Dovresti anche testare bakeCookies: cosa dovrebbe / dovrebbe bakeCookies(egg, pan, oven)produrre e..g ? Uovo fritto o un'eccezione? Da soli, nè panovensi preoccupano gli ingredienti effettivi, dal momento che nessuno di loro si suppone, ma bakeCookiesdi solito dovrebbe cedere i cookie. Più in generale si può dipendere da come doughsi ottiene e se non v'è alcuna possibilità che essi possano divenire mero eggo, ad esempio water, invece.

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.