Domanda

Ho la seguente sfida e non ho trovato una buona risposta. Sto usando un framework Mocking (in questo caso JMock) per consentire l'isolamento dei test unitari dal codice del database. Sto deridendo l'accesso alle classi che coinvolgono la logica del database e testando separatamente le classi del database usando DBUnit.

Il problema che sto riscontrando è che sto notando un modello in cui la logica è concettualmente duplicata in più punti. Ad esempio, devo rilevare che non esiste un valore nel database, quindi in questo caso potrei restituire un valore nullo da un metodo. Quindi ho una classe di accesso al database che fa l'interazione del database e restituisce null in modo appropriato. Quindi ho la classe di business logic che riceve null dalla simulazione e quindi viene testato per agire in modo appropriato se il valore è null.

Ora cosa succede se in futuro quel comportamento deve cambiare e restituire null non è più appropriato, diciamo perché lo stato è diventato più complicato, quindi dovrò restituire un oggetto che segnala che il valore non esiste e alcuni fatto aggiuntivo dal database.

Ora, se cambio il comportamento della classe del database per non restituire più null in quel caso, la classe della logica di business sembrerebbe comunque funzionare e il bug verrebbe rilevato solo nel QA, a meno che qualcuno non si ricordasse dell'accoppiamento o ha seguito correttamente gli usi del metodo.

Sono caduto come se mi mancasse qualcosa, e ci deve essere un modo migliore per evitare questa duplicazione concettuale, o almeno averlo sotto test in modo che se cambia, il fatto che il cambiamento non sia propagato fallisce un'unità prova.

Qualche suggerimento?

UPDATE:

Fammi provare a chiarire la mia domanda. Sto pensando a quando il codice si evolve nel tempo, come garantire che l'integrazione non si interrompa tra le classi testate tramite la simulazione e l'implementazione effettiva della classe rappresentata dalla simulazione.

Ad esempio, ho appena avuto un caso in cui avevo un metodo che era stato originariamente creato e non mi aspettavo valori nulli, quindi questo non era un test sull'oggetto reale. Quindi l'utente della classe (testato tramite un mock) è stato migliorato per passare un valore nullo come parametro in determinate circostanze. In caso di integrazione interrotta, perché la classe reale non è stata testata per null. Ora, quando si costruiscono queste classi all'inizio, questo non è un grosso problema, perché stai testando entrambe le estremità mentre costruisci, ma se il design deve evolversi due mesi dopo quando tendi a dimenticare i dettagli, come testeresti l'interazione tra questi due insiemi di oggetti (quello testato tramite una simulazione rispetto all'implementazione effettiva)?

Il problema di fondo sembra essere quello della duplicazione (che viola il principio DRY), le aspettative sono realmente mantenute in due punti, sebbene la relazione sia concettuale, non esiste un vero codice duplicato.

[Modifica dopo la seconda modifica di Aaron Digulla sulla sua risposta]:

Bene, questo è esattamente il tipo di cosa che sto facendo (tranne che c'è qualche ulteriore interazione con il DB in una classe che viene testata tramite DBUnit e interagisce con il database durante i suoi test, ma è la stessa idea) . Quindi ora supponiamo che dobbiamo modificare il comportamento del database in modo che i risultati siano diversi. Il test che utilizza la simulazione continuerà a passare a meno che 1) qualcuno si ricordi o 2) interrompa l'integrazione. Quindi i valori di ritorno della procedura memorizzata (diciamo) del database sono essenzialmente duplicati nei dati di test della simulazione. Ora ciò che mi preoccupa della duplicazione è che la logica è duplicata, ed è una sottile violazione di DRY. Potrebbe essere che sia così (dopo tutto c'è una ragione per i test di integrazione), ma sentivo che invece mi manca qualcosa.

[Modifica all'avvio della taglia]

Leggere l'interazione con Aaron arriva al punto della domanda, ma quello che sto davvero cercando è una visione di come evitare o gestire l'apparente duplicazione, in modo che un cambiamento nel comportamento o

È stato utile?

Soluzione

L'astrazione del database utilizza null per indicare " nessun risultato trovato " ;. Ignorando il fatto che è una cattiva idea passare null tra gli oggetti, i tuoi test non dovrebbero usare quel valore letterale null quando vogliono testare cosa succede quando non viene trovato nulla. Utilizza invece una costante o un generatore di dati di test in modo che i tuoi test si riferiscano solo a ciò che le informazioni vengono passate tra gli oggetti, non al modo in cui tali informazioni sono rappresentate. Quindi, se è necessario modificare il modo in cui il livello del database rappresenta " nessun risultato trovato " (o qualsiasi altra informazione su cui si basa il test) hai solo un posto nei tuoi test per cambiarlo.

Altri suggerimenti

Fondamentalmente stai chiedendo l'impossibile. Stai chiedendo che i tuoi test unitari prevedano e ti avvisino quando cambi il comportamento della risorsa esterna. Senza scrivere un test per produrre il nuovo comportamento, come possono saperlo?

Quello che stai descrivendo è l'aggiunta di uno stato nuovissimo che deve essere testato - invece di un risultato nullo, ora c'è qualche oggetto che esce dal database. Come può la tua suite di test sapere quale dovrebbe essere il comportamento previsto dell'oggetto in prova per un nuovo oggetto casuale? Devi scrivere un nuovo test.

La finta non è "comportarsi male", come hai commentato. Il finto sta facendo esattamente quello che hai impostato per fare. Il fatto che le specifiche siano cambiate non ha alcuna conseguenza per la simulazione. L'unico problema in questo scenario è che la persona che ha implementato la modifica ha dimenticato di aggiornare i test unitari. In realtà non sono troppo sicuro del perché pensi che ci siano duplicazioni di preoccupazioni in corso.

Il programmatore che sta aggiungendo alcuni nuovi risultati di ritorno al sistema è responsabile dell'aggiunta di un test unitario per gestire questo caso. Se anche quel codice è sicuro al 100% che non è possibile che il risultato null potrebbe essere restituito ora, allora potrebbe anche cancellare il vecchio test unitario. Ma perché dovresti? Il test unitario descrive correttamente il comportamento dell'oggetto in prova quando riceve un risultato nullo. Cosa succede se si modifica il back-end del sistema in un nuovo database che restituisce un valore null? Cosa succederebbe se la specifica tornasse a restituire null? Potresti anche continuare il test, dal momento che per quanto riguarda l'oggetto, potrebbe davvero recuperare qualcosa dalla risorsa esterna e dovrebbe gestire con garbo ogni possibile caso.

L'intero scopo del deridere è di separare i tuoi test da risorse reali. Non ti salverà automaticamente dall'introduzione di bug nel sistema. Se il tuo test unitario descrive accuratamente il comportamento quando riceve un valore nullo, fantastico! Ma questo test non dovrebbe avere alcuna conoscenza di nessun altro stato e certamente non dovrebbe essere in qualche modo informato che la risorsa esterna non invierà più null.

Se stai eseguendo un design corretto, liberamente accoppiato, il tuo sistema potrebbe avere qualsiasi backend che tu possa immaginare. Non dovresti scrivere test con una sola risorsa esterna in mente. Sembra che potresti essere più felice se hai aggiunto alcuni test di integrazione che utilizzano il tuo vero database, eliminando così il livello beffardo. Questa è sempre un'ottima idea da usare per fare una build o test di sanità mentale / fumo, ma di solito è ostruttiva per lo sviluppo quotidiano.

Non ti stai perdendo qualcosa qui. Questa è una debolezza nei test unitari con oggetti finti. Sembra che tu stia rompendo correttamente i test unitari in unità di dimensioni ragionevoli. Questa è una buona cosa; è molto più comune trovare persone che testano troppo in una "unità" prova.

Sfortunatamente, quando testate a questo livello di granularità, i test unitari non coprono l'interazione tra oggetti che collaborano. È necessario disporre di alcuni test di integrazione o test funzionali per coprire questo. Non conosco davvero una risposta migliore di quella.

A volte è pratico usare il vero collaboratore invece di una simulazione nel test unitario. Ad esempio, se stai testando un'unità di un oggetto di accesso ai dati, l'utilizzo dell'oggetto di dominio reale nel test di unità invece di una simulazione è spesso abbastanza facile da configurare ed eseguire altrettanto. Spesso non è vero il contrario: gli oggetti di accesso ai dati in genere necessitano di una connessione al database, di un file o di una rete e sono piuttosto complicati e richiedono tempo per essere configurati; l'utilizzo di un oggetto dati reale durante il test di unità dell'oggetto del dominio trasformerà un test di unità che impiega microsecondi in uno che richiede centinaia o migliaia di millisecondi.

Quindi, per riassumere:

  1. Scrivi alcuni test di integrazione / funzionali per rilevare i problemi con gli oggetti collaboranti
  2. Non è sempre necessario deridere i collaboratori: usa il tuo miglior giudizio

I test unitari non possono dirti quando improvvisamente un metodo ha una serie più piccola di possibili risultati. Ecco a cosa serve la copertura del codice: ti dirà che il codice non viene più eseguito. Questo a sua volta porterà alla scoperta del codice morto nel livello applicazione.

[EDIT] Basato su un commento: un finto non deve fare altro che consentire di creare un'istanza della classe sotto test e consentire di raccogliere ulteriori informazioni. In particolare, non deve mai influenzare il risultato di ciò che si desidera testare.

[EDIT2] Deridere un database significa che non ti importa se il driver DB funziona. Quello che vuoi sapere è se il tuo codice può interpretare correttamente i dati restituiti dal DB. Inoltre, questo è l'unico modo per verificare se la tua gestione degli errori funziona correttamente perché non puoi dire al vero driver del DB "quando vedi questo SQL, lanciare questo errore." Questo è possibile solo con una simulazione.

Sono d'accordo, ci vuole un po 'di tempo per abituarsi. Ecco cosa faccio:

  • Ho dei test che controllano se l'SQL funziona. Ogni SQL viene eseguito una volta su un DB di test statico e verifico che i dati restituiti siano quelli che mi aspetto.
  • Tutti gli altri test vengono eseguiti con un connettore DB finto che restituisce risultati predefiniti. Mi piace ottenere questi risultati eseguendo il codice sul database, registrando le chiavi primarie da qualche parte. Scrivo quindi uno strumento che prende queste chiavi primarie e scarica il codice Java con il finto su System.out. In questo modo, posso creare nuovi casi di test molto rapidamente e i casi di test rispecchieranno la "verità".

    Ancora meglio, posso ricreare vecchi test (quando il DB cambia) eseguendo di nuovo i vecchi ID e il mio strumento

Vorrei restringere il problema al suo centro.

Il problema

Ovviamente, la maggior parte delle modifiche verranno rilevate dal test.
Ma esiste un sottoinsieme di scenari in cui il test non fallirà, anche se dovrebbe:

Mentre scrivi il codice, usi i tuoi metodi più volte. Si ottiene una relazione 1: n tra la definizione del metodo e l'uso. Ogni classe che utilizza quel metodo utilizzerà la simulazione nel test corrispondente. Quindi il finto è anche usato n volte.

Il risultato dei metodi una volta non dovrebbe essere mai null . Dopo averlo modificato, probabilmente ti ricorderai di correggere il test corrispondente. Fin qui tutto bene.

Esegui i test - passa tutto .

Ma nel tempo hai dimenticato qualcosa ... la simulazione non restituisce mai un null . Quindi n test per n classi che usano il finto non testano null .

Il tuo QA fallirà , sebbene i tuoi test non abbiano fallito.

Ovviamente dovrai modificare gli altri tuoi test. Ma non ci sono errori nel lavorare insieme. Quindi hai bisogno di una soluzione, che funzioni meglio di ricordare tutti i test di referenziazione.

Una soluzione

Per evitare problemi come questo, dovrai scrivere test migliori dall'inizio. Se perdi i casi in cui la classe testata deve gestire errori o valori null , hai semplicemente test incompleti . È come non testare tutte le funzioni della tua classe.

È difficile aggiungerlo in seguito. - Quindi inizia presto ed estendi i tuoi test.

Come menzionato da altri utenti, la copertura del codice rivela alcuni casi non testati. Ma manca il codice di gestione degli errori e il test secondo mancante non comparirà nella copertura del codice. (La copertura del codice del 100% non significa che non ti manchi qualcosa.)

Quindi scrivi un buon test: Supponi che il mondo esterno sia dannoso. Ciò non include solo il passaggio di parametri errati (come valori null ). Anche le tue beffe fanno parte del mondo esterno. Passa null e le eccezioni - e osserva la tua classe gestirle come previsto.

Se decidi che null è un valore valido, questi test falliranno in seguito (a causa di eccezioni mancanti). Quindi ottieni un elenco di errori che non funzionano.

Poiché ogni classe chiamante gestisce gli errori o null in modo diverso, non è possibile evitare il codice duplicato. Un trattamento diverso richiede test diversi.


Suggerimento: mantieni il tuo finto semplice e pulito. Spostare i valori di ritorno previsti sul metodo di test. (Il tuo finto può passarli semplicemente indietro.) Evita di testare le decisioni in derisione.

Ecco come capisco la tua domanda:

Stai usando oggetti simulati delle tue entità per testare il livello aziendale della tua applicazione usando JMock. Stai anche testando il tuo livello DAO (l'interfaccia tra la tua app e il tuo database) usando DBUnit e passando copie reali dei tuoi oggetti entità popolate con un insieme noto di valori. Poiché stai utilizzando 2 diversi metodi per preparare i tuoi oggetti test, il tuo codice sta violando DRY e rischi che i tuoi test si sincronizzino con la realtà quando cambiano il codice.

Folwer dice ...

Non è esattamente lo stesso, ma certamente mi ricorda gli Mock Aren't Stubs articolo. Vedo il percorso JMock come il modo beffardo e il percorso "oggetti reali" come il modo classicista per eseguire i test.

Un modo per essere il più SECCO possibile quando si affronta questo problema è quello di essere più un classicista che un beffardo . Forse puoi scendere a compromessi e utilizzare copie reali dei tuoi oggetti bean nei tuoi test.

Creatori utenti per evitare duplicazioni

Quello che abbiamo fatto su un progetto è creare Maker per ciascuno dei nostri oggetti business. Il creatore contiene metodi statici che costruiranno una copia di un dato oggetto entità, popolato con valori noti. Quindi, qualunque tipo di oggetto sia necessario, è possibile chiamare il produttore per quell'oggetto e ottenerne una copia con valori noti da utilizzare per i test. Se quell'oggetto ha oggetti figlio, il tuo creatore chiamerà i creatori per i bambini per costruirlo dall'alto verso il basso e otterrai indietro tutto il grafico oggetto completo di cui hai bisogno. È possibile utilizzare questi oggetti creatore per tutti i test, passandoli al DB durante il test del livello DAO, nonché passandoli alle chiamate di servizio durante il test dei servizi aziendali. Poiché i produttori sono riutilizzabili, è un approccio abbastanza ASCIUTTO.

Una cosa per la quale dovrai comunque usare JMock, tuttavia, è di deridere il tuo livello DAO durante il test del tuo livello di servizio. Se il tuo servizio effettua una chiamata al DAO, devi assicurarti che venga iniettato con un mocking. Ma puoi comunque usare i tuoi Maker allo stesso modo: quando imposti le tue aspettative, assicurati che il tuo DAO deriso restituisca il risultato atteso usando Maker per l'oggetto entità pertinente. In questo modo non stiamo ancora violando DRY.

Test ben scritti ti avviseranno quando cambiano i codici

Il mio ultimo consiglio per evitare il tuo problema con la modifica del codice nel tempo è di sempre fare un test che affronti input nulli. Supponiamo che quando si creano per la prima volta i metodi nulli non siano accettabili. È necessario disporre di un test per verificare che venga generata un'eccezione se si utilizza null. Se in un secondo momento i valori null diventano accettabili, il codice dell'app potrebbe cambiare in modo che i valori null vengano gestiti in un modo nuovo e l'eccezione non venga più generata. Quando ciò accade, il test inizierà a fallire e avrai un "avviso" " che le cose non sono sincronizzate.

Devi semplicemente decidere se il ritorno di null è una parte prevista dell'API esterna o se si tratta di un dettaglio di implementazione.

I test unitari non dovrebbero preoccuparsi dei dettagli di implementazione.

Se fa parte dell'API esterna prevista, poiché la modifica potrebbe potenzialmente interrompere i client, anche questo naturalmente dovrebbe interrompere il test unitario.

Ha senso da un POV esterno che questa cosa restituisce NULL o è una conseguenza conveniente perché nel client si possono fare ipotesi dirette sul significato di questo NULL? Un NULL dovrebbe significare void / nix / nada / non disponibile senza altri significati.

Se prevedi di granulare questa condizione in un secondo momento, dovresti racchiudere il controllo NULL in qualcosa che restituisca un'eccezione informativa, enum o un bool esplicitamente chiamato.

Una delle sfide con la scrittura di unit test è che anche le prime unit test scritte devono riflettere l'API completa nel prodotto finale. Devi visualizzare l'API completa e quindi programmare contro QUESTO.

Inoltre, è necessario mantenere la stessa disciplina nel codice di test dell'unità come nel codice di produzione evitando odori come la duplicazione e l'invidia delle caratteristiche.

Per lo scenario specifico, si sta modificando il tipo restituito del metodo, che verrà rilevato al momento della compilazione. In caso contrario, verrebbe visualizzato sulla copertura del codice (come indicato da Aaron). Anche in questo caso, dovresti avere test funzionali automatizzati, che verrebbero eseguiti subito dopo il check-in. Detto questo, faccio test automatici del fumo, quindi nel mio caso quelli lo avrebbero catturato :).

Senza pensare a quanto sopra, hai ancora 2 fattori importanti che giocano nello scenario iniziale. Volete dare al vostro codice di test unitario la stessa attenzione del resto del codice, il che significa che è ragionevole volerli mantenere ASCIUTTI. Se stessi facendo TDD, ciò spingerebbe anche questa preoccupazione al tuo design in primo luogo. Se non ti interessa, l'altro fattore opposto coinvolto è YAGNI, non vuoi ottenere tutti gli scenari (non) probabili nel tuo codice. Quindi, per me sarebbe: se i miei test mi dicono che mi manca qualcosa, ricontrollo che il test sia ok e procedo con la modifica. Mi assicuro di non fare cosa succede se gli scenari con i miei test, in quanto è una trappola.

Se capisco correttamente la domanda, hai un oggetto business che utilizza un modello. Esiste un test per l'interazione tra BO e modello (Test A) e un altro test che verifica l'interazione tra il modello e il database (Test B). Il test B cambia per restituire un oggetto, ma quel cambiamento non ha effetto sul test A perché il modello del test A è deriso.

L'unico modo in cui vedo che il test A fallisce quando il test B cambia è quello di non deridere il modello nel test A e combinare i due in un singolo test, il che non va bene perché testerai troppo (e stai utilizzando diversi framework).

Se conosci questa dipendenza quando scrivi i test, penso che una soluzione accettabile sarebbe quella di lasciare un commento in ogni test che descriva la dipendenza e come se uno cambia, devi cambiare l'altro. Dovrai cambiare Test B quando esegui il refactoring, il test corrente fallirà non appena effettuerai la modifica.

La tua domanda è piuttosto confusa e la quantità di testo non aiuta esattamente.

Ma il significato che potrei estrarre attraverso una lettura veloce ha poco senso per me, in quanto si desidera che un cambiamento senza contratto si ripercuota sul funzionamento della simulazione.

Il derisione ti consente di concentrarti sul test di una parte specifica del sistema. La parte derisa funzionerà sempre in un modo specificato e il test può concentrarsi sul test della logica specifica che dovrebbe. Quindi non sarai influenzato da logica non correlata, problemi di latenza, dati imprevisti, ecc.

Probabilmente avrai un numero separato di test che controllano la funzionalità derisa in un altro contesto.

Il punto è che non dovrebbe esistere alcuna connessione tra l'interfaccia derisa e l'implementazione reale di quella. Non ha alcun senso, dato che stai deridendo il contratto e gli stai dando un'implementazione da solo.

Penso che il tuo problema stia violando il principio di sostituzione di Liskov:

I sottotipi devono essere sostituibili con i loro tipi di base

Idealmente, avresti una classe, che dipende da un'astrazione. Un'astrazione che dice "per essere in grado di funzionare, ho bisogno di un'implementazione di questo metodo che accetta questo parametro, restituisce questo risultato e se faccio queste cose sbagliate, mi lancia questa eccezione". Questi sarebbero tutti definiti sulla tua interfaccia da cui dipendi, sia in base a vincoli di tempo di compilazione che in base a commenti.

Tecnicamente potresti sembrare dipendere da un'astrazione ma nello scenario che dici, non dipendi realmente da un'astrazione, dipendi da un'implementazione. Dici che "se questo metodo cambia comportamento, i suoi utenti si romperanno e i miei test non lo sapranno mai". A livello di test unitario, hai ragione. Ma a livello di contratto, cambiare il comportamento in questo modo è sbagliato. Perché cambiando il metodo, si viola chiaramente il contratto tra il metodo e i chiamanti.

Perché cambi metodo? È chiaro che i chiamanti di quel metodo hanno bisogno di un comportamento diverso ora. Quindi, la prima cosa che vuoi fare non è cambiare il metodo stesso, ma cambiare l'astrazione o il contratto da cui dipendono i tuoi clienti. Devono cambiare prima e iniziare a lavorare con il nuovo contratto: "OK, le mie esigenze sono cambiate, non voglio più questo metodo restituire che in questo particolare scenario, gli implementatori di questa interfaccia devono restituire questo invece". Quindi, vai a cambiare la tua interfaccia, vai a cambiare gli utenti dell'interfaccia come necessario, e questo include l'aggiornamento dei loro test e l'ultima cosa che fai è cambiare l'implementazione effettiva che passi ai tuoi clienti. In questo modo, non si verificherà l'errore di cui si parla.

class NeedsWork(IWorker b) { DoSth() { b.Work() }; }
...
AppBuilder() { INeedWork GetA() { return new NeedsWork(new Worker()); } }
  1. Modifica IWorker in modo che rifletta le nuove esigenze di NeedsWork.
  2. Modifica DoSth in modo che funzioni con la nuova astrazione che soddisfi le sue nuove esigenze.
  3. Prova NeedsWork e assicurati che funzioni con il nuovo comportamento.
  4. Modifica tutte le implementazioni (lavoratore in questo scenario) fornite per IWorker (che ora stai provando a fare prima).
  5. Test Worker in modo che soddisfi le nuove aspettative.

Sembra spaventoso, ma nella vita reale questo sarebbe banale per i piccoli cambiamenti e doloroso per i cambiamenti enormi come, in realtà, deve essere.

scroll top