Quali sono i buoni test unitari per coprire il caso d'uso del lancio di un dado?


18

Sto cercando di fare i conti con i test unitari.

Supponiamo di avere un dado che può avere un numero predefinito di lati pari a 6 (ma può essere a 4, 5 facce ecc.):

import random
class Die():
    def __init__(self, sides=6):
        self._sides = sides

    def roll(self):
        return random.randint(1, self._sides)

Quanto segue sarebbe unit test validi / utili?

  • testare un tiro nell'intervallo 1-6 per un dado a 6 facce
  • prova un tiro di 0 per un dado a 6 facce
  • prova un tiro di 7 per un dado a 6 facce
  • prova un tiro nel range 1-3 per un dado a 3 facce
  • prova un tiro di 0 per un dado a 3 facce
  • prova un tiro di 4 per un dado a 3 facce

Sto solo pensando che si tratta di una perdita di tempo in quanto il modulo casuale è in circolazione da abbastanza tempo ma poi penso che se il modulo casuale viene aggiornato (diciamo che aggiorno la mia versione di Python), almeno sono coperto.

Inoltre, devo anche testare altre variazioni dei tiri di dado, ad esempio il 3 in questo caso, o è utile coprire un altro stato di dado inizializzato?


1
Che dire di un dado a 5 facce negativo o di un dado a faccia nulla?
JensG,

Risposte:


22

Hai ragione, i tuoi test non dovrebbero verificare che il randommodulo stia facendo il suo lavoro; unittest dovrebbe solo testare la classe stessa, non il modo in cui interagisce con altri codici (che dovrebbero essere testati separatamente).

Naturalmente è del tutto possibile che il codice usi in modo random.randint()errato; o invece stai chiamando random.randrange(1, self._sides)e il tuo dado non lancia mai il valore più alto, ma sarebbe un diverso tipo di bug, non uno che potresti catturare con unittest. In tal caso, l' die unità funziona come previsto, ma il design stesso era difettoso.

In questo caso, userei il derisione per sostituire la randint()funzione e verificherei solo che è stata chiamata correttamente. Python 3.3 e versioni successive vengono forniti con il unittest.mockmodulo per gestire questo tipo di test, ma è possibile installare il mockpacchetto esterno su versioni precedenti per ottenere la stessa identica funzionalità

import unittest
try:
    from unittest.mock import patch
except ImportError:
    # < python 3.3
    from mock import patch


@patch('random.randint', return_value=3)
class TestDice(unittest.TestCase):
    def _make_one(self, *args, **kw):
        from die import Die
        return Die(*args, **kw)

    def test_standard_size(self, mocked_randint):
        die = self._make_one()
        result = die.roll()

        mocked_randint.assert_called_with(1, 6)
        self.assertEqual(result, 3)

    def test_custom_size(self, mocked_randint):
        die = self._make_one(sides=42)
        result = die.roll()

        mocked_randint.assert_called_with(1, 42)
        self.assertEqual(result, 3)


if __name__ == '__main__':
    unittest.main()

Con derisione, il tuo test ora è molto semplice; ci sono solo 2 casi, davvero. Il caso predefinito per un dado a 6 facce e il caso di lati personalizzati.

Esistono altri modi per sostituire temporaneamente la randint()funzione nello spazio dei nomi globale di Die, ma il mockmodulo lo rende più semplice. Il @mock.patchdecoratore qui si applica a tutti i metodi di prova nel caso di test; ad ogni metodo di test viene passato un argomento in più, la random.randint()funzione derisa , in modo che possiamo testare contro la simulazione per vedere se è stata effettivamente chiamata correttamente. L' return_valueargomento specifica cosa viene restituito dalla simulazione quando viene chiamato, quindi possiamo verificare che il die.roll()metodo abbia effettivamente restituito il risultato "casuale" a noi.

Ho usato un'altra best practice unittesting di Python qui: importare la classe sotto test come parte del test. Il _make_onemetodo esegue l'importazione e la creazione di istanze all'interno di un test , in modo che il modulo di test continui a caricarsi anche se si è commesso un errore di sintassi o un altro errore che impedirà l'importazione del modulo originale.

In questo modo, se si commette un errore nel codice del modulo stesso, i test verranno comunque eseguiti; falliranno, raccontandoti l'errore nel tuo codice.

Per essere chiari, i test di cui sopra sono semplicistici all'estremo. L'obiettivo qui non è quello di verificare che random.randint()è stato chiamato con gli argomenti giusti, per esempio. Invece, l'obiettivo è verificare che l'unità stia producendo i risultati giusti dati determinati input, in cui tali input includono i risultati di altre unità non sotto test. Deridendo il random.randint()metodo si ottiene il controllo di un altro input al codice.

Nei test del mondo reale , il codice effettivo dell'unità sotto test sarà più complesso; la relazione con gli input passati all'API e il modo in cui vengono successivamente invocate altre unità può essere ancora interessante e il derisione ti darà accesso a risultati intermedi e ti consentirà di impostare i valori di ritorno per quelle chiamate.

Ad esempio, nel codice che autentica gli utenti rispetto a un servizio OAuth2 di terze parti (un'interazione a più fasi), si desidera verificare che il codice trasmetta i dati corretti a quel servizio di terze parti e che consenta di deridere diverse risposte di errore che Il servizio di terze parti ritornerebbe, permettendoti di simulare diversi scenari senza dover costruire tu stesso un server OAuth2 completo. Qui è importante testare che le informazioni di una prima risposta sono state gestite correttamente e sono state passate a una chiamata del secondo stadio, quindi si desidera vedere che il servizio deriso viene chiamato correttamente.


1
Hai più di 2 casi di test ... i risultati controllano il valore predefinito: inferiore (1), superiore (6), inferiore (0), superiore (7) e risultati per i numeri specificati dall'utente come max_int ecc. anche l'input non è validato, che potrebbe essere necessario testare ad un certo punto ...
James Snell,

2
No, quelli sono test per randint(), non il codice in Die.roll().
Martijn Pieters,

Esiste in realtà un modo per garantire che non solo il randint sia chiamato correttamente ma che anche il suo risultato sia usato correttamente: deridilo per restituire un sentinel.dieesempio (anche l'oggetto sentinella unittest.mock) e quindi verifica che sia quello che è stato restituito dal tuo metodo roll. Questo in realtà consente solo un modo di implementare il metodo testato.
Aragaer,

@aragaer: certo, se si desidera verificare che il valore venga restituito invariato, sentinel.diesarebbe un ottimo modo per garantirlo.
Martijn Pieters,

Non capisco perché vorresti assicurarti che mocked_randint sia chiamato_con determinati valori. Comprendo di voler deridere randint per restituire valori prevedibili, ma la preoccupazione non è solo che restituisca valori prevedibili e non con quali valori viene chiamato? Mi sembra che il controllo dei valori chiamati stia inutilmente legando il test a dettagli di implementazione. Inoltre, perché ci preoccupiamo che il dado restituisca il valore esatto di randint? Non ci interessa davvero che restituisca un valore> 1 e inferiore a uguale al massimo?
bdrx,

16

La risposta di Martijn è come lo faresti se davvero volessi eseguire un test che dimostra che stai chiamando random.randint. Tuttavia, a rischio di essere detto "che non risponde alla domanda", ritengo che questo non dovrebbe essere testato affatto. La derisione di randint non è più il test di una scatola nera - stai specificatamente dimostrando che alcune cose stanno accadendo nell'implementazione . Il test della scatola nera non è nemmeno un'opzione: non è possibile eseguire test che provino che il risultato non sarà mai inferiore a 1 o superiore a 6.

Puoi deridere randint? Si, puoi. Ma cosa stai dimostrando? Che l'hai chiamato con argomenti 1 e lati. Che cosa significa che la media? Sei tornato al punto di partenza - alla fine della giornata dovrai dimostrare - formalmente o informalmente - che la chiamata random.randint(1, sides)implementa correttamente un tiro di dadi.

Sono tutto per i test unitari. Sono fantastici controlli di sanità mentale ed espongono la presenza di bug. Tuttavia, non possono mai dimostrare la loro assenza, e ci sono cose che non possono assolutamente essere asserite attraverso i test (ad es. Che una determinata funzione non genera mai un'eccezione o termina sempre). In questo caso particolare, sento che c'è ben poco guadagno. Per comportamenti deterministici, i test unitari hanno senso perché in realtà sai quale sarà la risposta che ti aspetti.


I test unitari non sono test in scatola nera, davvero. Ecco a cosa servono i test di integrazione, per assicurarsi che le varie parti interagiscano come previsto. È una questione di opinione, ovviamente (la maggior parte della filosofia dei test è), vedi "Test unitari" rientra nei test della scatola bianca o della scatola nera? e Black Box Unit Testing per alcune prospettive (Stack Overflow).
Martijn Pieters,

@MartijnPieters Non sono d'accordo sul fatto che "sono questi i test di integrazione". I test di integrazione servono per verificare che tutti i componenti del sistema interagiscano correttamente. Non sono il luogo per testare che un determinato componente fornisce l'output corretto per un determinato input. Per quanto riguarda i test delle unità della scatola nera rispetto a quelli della scatola bianca, i test delle unità della scatola bianca finiranno per rompersi con le modifiche all'implementazione e qualsiasi ipotesi che hai fatto nell'implementazione probabilmente verrà riportata nel test. Convalidare ciò che random.randintviene chiamato 1, sidesè inutile se questa è la cosa sbagliata da fare.
Doval,

Sì, questa è una limitazione di un test unitario in scatola bianca. Tuttavia, non ha senso eseguire test che random.randint()restituiranno correttamente valori nell'intervallo [1, lati] (incluso), che spetta agli sviluppatori Python assicurarsi che l' randomunità funzioni correttamente.
Martijn Pieters,

E come dici tu stesso, i test unitari non possono garantire che il tuo codice sia privo di bug; se il tuo codice utilizza altre unità in modo errato (ad esempio, ti aspettavi random.randint()di comportarti come tale random.randrange()e quindi chiamalo con random.randint(1, sides + 1), quindi sei affondato comunque.
Martijn Pieters

2
@MartijnPieters Sono d'accordo con te lì, ma non è ciò a cui mi oppongo. Sto obiettando a provare che random.randint viene chiamato con argomenti (1, lati) . Nell'implementazione hai assunto che questa è la cosa giusta da fare e ora stai ripetendo tale presupposto nel test. Se tale presupposto dovesse essere errato, il test sarà passato ma l'implementazione è ancora errata. È una prova a metà che è una vera seccatura da scrivere e mantenere.
Doval,

6

Correggi semi casuali. Per i dadi a 1, 2, 5 e 12 facce, conferma che alcune migliaia di tiri danno risultati inclusi 1 e N, e non inclusi 0 o N + 1. Se per caso sembra strano, ottieni una serie di risultati casuali che non lo fanno coprire l'intervallo previsto, passare a un seme diverso.

Gli strumenti beffardi sono fantastici, ma solo perché ti permettono di fare una cosa non significa che quella cosa dovrebbe essere fatta. YAGNI si applica ai dispositivi di prova tanto quanto alle caratteristiche.

Se riesci facilmente a provare con dipendenze non smozzate, dovresti quasi sempre; in questo modo i test si concentreranno sulla riduzione del conteggio dei difetti, non solo sull'aumento del conteggio dei test. L'eccessiva derisione rischia di creare cifre di copertura fuorvianti, che a loro volta possono portare a rimandare i test effettivi a una fase successiva, forse non avrai mai il tempo di aggirare ...


3

Che cosa è un Diese ci pensi? - non più di un involucro in giro random. Si incapsula random.randinte rietichettatura in termini di vocabolario dell'applicazione: Die.Roll.

Non trovo rilevante inserire un altro livello di astrazione tra Diee randomperché esso Diestesso è già questo livello di indiretta tra l'applicazione e la piattaforma.

Se vuoi risultati di dadi in scatola, prendi in giro Die, non deridererandom .

In generale, non collaudo unità i miei oggetti wrapper che comunicano con sistemi esterni, scrivo test di integrazione per loro. Potresti scriverne un paio per quelli, Diema come hai sottolineato, a causa della natura casuale dell'oggetto sottostante, non saranno significativi. Inoltre, qui non è coinvolta alcuna configurazione o comunicazione di rete, quindi non c'è molto da testare tranne una chiamata sulla piattaforma.

=> Considerando che Diesono solo alcune banali righe di codice e aggiunge poca o nessuna logica rispetto a randomse stesso, salterei il test in questo esempio specifico.


2

Seminare il generatore di numeri casuali e verificare i risultati previsti NON è, per quanto posso vedere, un test valido. Fa ipotesi su come i tuoi dadi funzionano internamente, il che è cattivo-cattivo. Gli sviluppatori di Python potrebbero cambiare il generatore di numeri casuali, oppure il dado (NOTA: "dadi" è plurale, "die" è singolare. A meno che la tua classe non implementi più tiri di dado in una chiamata, probabilmente dovrebbe essere chiamata "die") utilizzare un diverso generatore di numeri casuali.

Allo stesso modo, deridere la funzione casuale presuppone che l'implementazione della classe funzioni esattamente come previsto. Perché potrebbe non essere così? Qualcuno potrebbe prendere il controllo del generatore di numeri casuali di Python predefinito e, per evitarlo, una versione futura del tuo dado potrebbe recuperare diversi numeri casuali, o numeri casuali più grandi, per mescolare più dati casuali. Uno schema simile è stato usato dai produttori del sistema operativo FreeBSD, quando sospettavano che l'NSA stesse manomettendo i generatori di numeri casuali hardware integrati nelle CPU.

Se fossi in me, eseguirei, diciamo, 6000 tiri, li classificherei e farei in modo che ogni numero compreso tra 1 e 6 sia compreso tra 500 e 1500 volte. Verificherei inoltre che non vengano restituiti numeri al di fuori di tale intervallo. Potrei anche verificare che, per un secondo set di 6000 rotoli, quando si ordina [1..6] in ordine di frequenza, il risultato è diverso (questo fallirà una volta su 720, se i numeri sono casuali!). Se vuoi essere accurato, potresti trovare la frequenza dei numeri che seguono un 1, che segue un 2, ecc .; ma assicurati che la dimensione del tuo campione sia abbastanza grande e che tu abbia abbastanza varianza. Gli umani si aspettano che i numeri casuali abbiano meno schemi di quanto non facciano realmente.

Ripetere l'operazione per un dado a 12 facce e 2 facce (6 è il più usato, quindi è il più atteso per chiunque scriva questo codice).

Infine, testerei per vedere cosa succede con un dado a 1 faccia, un dado a 0 facce, un dado a 1 faccia, un dado a 2.3 facce, un dado a [1,2,3,4,5,6] e un dado a lato "blah". Naturalmente, tutti questi dovrebbero fallire; falliscono in modo utile? Probabilmente questi dovrebbero fallire nella creazione, non nel rotolamento.

O, forse, anche tu vuoi gestirli in modo diverso - forse la creazione di un dado con [1,2,3,4,5,6] dovrebbe essere accettabile - e forse anche "blah"; questo potrebbe essere un dado con 4 facce e ogni faccia con una lettera. Mi viene in mente il gioco "Boggle", così come una magica otto palle.

E infine, potresti voler considerare questo: http://lh6.ggpht.com/-fAGXwbJbYRM/UJA_31ACOLI/AAAAAAAAAPg/2FxOWzo96KE/s1600-h/random%25255B3%25255D.jpg


2

A rischio di nuotare contro corrente, ho risolto questo esatto problema diversi anni fa usando un metodo non menzionato finora.

La mia strategia era semplicemente quella di deridere l'RNG con uno che produce un flusso prevedibile di valori che coprono l'intero spazio. Se (diciamo) lato = 6 e l'RNG produce valori da 0 a 5 in sequenza, posso prevedere come dovrebbe comportarsi la mia classe e test unitario di conseguenza.

La logica è che questo mette alla prova la logica solo in questa classe, partendo dal presupposto che l'RNG alla fine produrrà ciascuno di quei valori e senza testare l'RNG stesso.

È semplice, deterministico, riproducibile e cattura bug. Userei di nuovo la stessa strategia.


La domanda non specifica quali dovrebbero essere i test, ma solo quali dati potrebbero essere utilizzati per i test, data la presenza di un GNR. Il mio suggerimento è semplicemente quello di testare in modo esaustivo deridendo l'RNG. La domanda su cosa vale la pena testare dipende dalle informazioni non fornite nella domanda.


Di 'che deridi l'RNG per essere prevedibile. Bene, cosa testerai? La domanda chiede "Sarebbero le seguenti unit test valide / utili?" Deriderlo per restituire 0-5 non è un test ma piuttosto una configurazione di test. Come "test unitario di conseguenza"? Non riesco a capire come "cattura i bug". Sto facendo fatica a capire di cosa ho bisogno per testare l'unità.
bdrx,

@bdrx: Questo era un po 'di tempo fa: risponderei diversamente adesso. Ma vedi modifica.
david.pfx,

1

I test che suggerisci nella tua domanda non rilevano un contatore aritmetico modulare come implementazione. E non rilevano errori di implementazione comuni nel codice relativo alla distribuzione di probabilità come return 1 + (random.randint(1,maxint) % sides). O una modifica al generatore che si traduce in modelli bidimensionali.

Se vuoi effettivamente verificare che stai generando numeri apparentemente casuali distribuiti in modo uniforme, devi controllare una vasta gamma di proprietà. Per fare un buon lavoro in questo senso, potresti eseguire http://www.phy.duke.edu/~rgb/General/dieharder.php sui tuoi numeri generati. Oppure scrivi una suite unit-test allo stesso modo complessa.

Non è colpa del test unitario o del TDD, la casualità sembra essere una proprietà molto difficile da verificare. E un argomento popolare per esempi.


-1

Il test più semplice di un tiro di dado è semplicemente ripeterlo diverse centinaia di migliaia di volte e confermare che ogni possibile risultato è stato colpito approssimativamente (1 / numero di lati) volte. Nel caso di un dado a 6 facce, dovresti vedere ogni possibile valore colpito circa il 16,6% delle volte. Se uno è spento di oltre un percento, allora hai un problema.

In questo modo si evita il refactoring del meccanico sottostante per generare facilmente un numero casuale e, soprattutto, senza modificare il test.


1
questo test passerebbe per un'implementazione totalmente non casuale che scorre semplicemente attraverso i lati uno per uno in un ordine predefinito
moscerino

1
Se un programmatore è intenzionato a implementare qualcosa in malafede (non usando un agente randomizzante su un dado), e semplicemente cercando di trovare qualcosa per "rendere verdi le luci rosse", hai più problemi di quanti i test unitari possano davvero risolvere.
ChristopherBrown,
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.