Refactoring Legacy Code Part 5 - Metodi testabili del gioco

Vecchio codice Codice brutto. Codice complicato Codice degli spaghetti Assurdità spudorate. In due parole, Codice legacy. Questa è una serie che ti aiuterà a lavorare e ad affrontarla.

Nel nostro precedente tutorial, abbiamo testato le nostre funzioni Runner. In questa lezione, è tempo di continuare da dove avevamo interrotto testando il nostro Gioco classe. Ora, quando inizi con una grossa porzione di codice come quella che abbiamo qui, è allettante iniziare a testare in un modo top-down, metodo per metodo. Questo è, il più delle volte, impossibile. È molto meglio iniziare a testarlo con i suoi metodi brevi e testabili. Questo è ciò che faremo in questa lezione: trovare e testare questi metodi.

Creare un gioco

Per testare una classe, è necessario inizializzare un oggetto di quel tipo specifico. Possiamo considerare che il nostro primo test è creare un oggetto così nuovo. Sarai sorpreso di quanti segreti i costruttori possano nascondere.

require_once __DIR__. '/ ... /trivia/php/Game.php'; class GameTest estende PHPUnit_Framework_TestCase function testWeCanCreateAGame () $ game = new Game (); 

Con nostra sorpresa, Gioco può effettivamente essere creato abbastanza facilmente. Nessun problema durante l'esecuzione nuovo gioco(). Niente si rompe Questo è un ottimo inizio, soprattutto considerando questo GiocoIl costruttore è abbastanza grande e fa molte cose.

Trovare il primo metodo testabile

Si sta tentando di semplificare il costruttore in questo momento. Ma abbiamo solo il maestro d'oro per assicurarci di non rompere nulla. Prima di andare al costruttore, dobbiamo testare la maggior parte del resto della classe. Quindi, da dove dovremmo iniziare?

Cerca il primo metodo che restituisce un valore e chiediti: "Posso chiamare e controllare il valore restituito da questo metodo?". Se la risposta è sì, è un buon candidato per il nostro test.

function isPlayable () $ minimumNumberOfPlayers = 2; return ($ this-> howManyPlayers ()> = $ minimumNumberOfPlayers); 

Che dire di questo metodo? Sembra un buon candidato. Solo due righe e restituisce un valore booleano. Ma aspetta, chiama un altro metodo, howManyPlayers ().

function howManyPlayers () return count ($ this-> players); 

Questo è fondamentalmente solo un metodo che conta gli elementi nella classe ' Giocatori array. OK, quindi se non aggiungiamo nessun giocatore, dovrebbe essere zero. isPlayable () dovrebbe restituire falso. Vediamo se la nostra ipotesi è corretta.

function testAJustCreatedNewGameIsNotPlayable () $ game = new Game (); $ This-> assertFalse ($ gioco-> isPlayable ()); 

Abbiamo rinominato il nostro metodo di test precedente per riflettere ciò che vogliamo veramente testare. Quindi abbiamo appena affermato che il gioco non è giocabile. Il test passa. Ma i falsi positivi sono comuni in molti casi. Quindi, per parte della mente, possiamo affermare la verità e assicurarci che il test fallisca.

$ This-> assertTrue ($ gioco-> isPlayable ());

E lo fa!

PHPUnit_Framework_ExpectationFailedException: impossibile affermare che falso sia vero.

Finora, abbastanza promettente. Siamo riusciti a testare il valore di ritorno iniziale del metodo, il valore rappresentato dall'iniziale stato del Gioco classe. Si prega di notare la parola sottolineata: "stato". Dobbiamo trovare un modo per controllare lo stato del gioco. Dobbiamo cambiarlo, quindi avrà il numero minimo di giocatori.

Se analizziamo Gioco'S Inserisci() metodo, vedremo che aggiunge elementi al nostro array.

array_push ($ this-> players, $ playerName);

La nostra ipotesi è rafforzata dal modo in cui il Inserisci() il metodo è usato in RunnerFunctions.php.

function run () $ aGame = new Game (); $ AGame-> aggiungere ( "Chet"); $ AGame-> aggiungere ( "Pat"); $ AGame-> aggiungere ( "Sue"); // ... //

Sulla base di queste osservazioni, possiamo concludere che usando Inserisci() due volte, dovremmo essere in grado di portare il nostro Gioco in uno stato con due giocatori.

function testAfterAddingTwoPlayersToANewGameItIsPlayable () $ game = new Game (); $ game-> add ('Primo giocatore'); $ game-> add ('Second Player'); $ This-> assertTrue ($ gioco-> isPlayable ()); 

Aggiungendo questo secondo metodo di prova, possiamo garantire isPlayable () ritorna vero, se le condizioni sono soddisfatte.

Ma potresti pensare che non si tratti di un test unitario. Noi usiamo il Inserisci() metodo! Esercitiamo più del minimo del codice. Potremmo invece aggiungere gli elementi al $ giocatori array e non fare affidamento sul Inserisci() metodo a tutti.

Bene, la risposta è sì e no. Potremmo farlo, da un punto di vista tecnico. Avrà il vantaggio del controllo diretto sull'array. Tuttavia, avrà lo svantaggio della duplicazione del codice tra codice e test. Quindi, scegli una delle cattive opzioni che pensi di poter convivere e usarla. Personalmente preferisco riutilizzare metodi come Inserisci().

Test di refactoring

Siamo su green, siamo refactoring. Possiamo migliorare i nostri test? Ebbene sì, possiamo. Potremmo trasformare il nostro primo test per verificare tutte le condizioni di non abbastanza giocatori.

function testAGameWithNotEnoughPlayersIsNotPlayable () $ game = new Game (); $ This-> assertFalse ($ gioco-> isPlayable ()); $ game-> add ('A player'); $ This-> assertFalse ($ gioco-> isPlayable ()); 

Potresti aver sentito parlare del concetto di "One assertion per test". Sono per lo più d'accordo, ma se hai un test che verifica un singolo concetto e richiede più asserzioni per fare la sua verifica, penso che sia accettabile usare più di un'asserzione. Questo punto di vista è anche fortemente promosso da Robert C. Martin nei suoi insegnamenti.

Ma per quanto riguarda il nostro secondo metodo di prova? È abbastanza buono? Io dico di no.

$ game-> add ('Primo giocatore'); $ game-> add ('Second Player');

Queste due chiamate mi infastidiscono un po '. Sono un'implementazione dettagliata senza una spiegazione esplicita nel nostro metodo. Perché non estrarli in un metodo privato?

function testAfterAddingEnoughPlayersToANewGameItIsPlayable () $ game = new Game (); $ This-> addEnoughPlayers ($ di gioco); $ This-> assertTrue ($ gioco-> isPlayable ());  funzione privata addEnoughPlayers ($ game) $ game-> add ('First Player'); $ game-> add ('Second Player'); 

Questo è molto meglio e ci porta anche a un altro concetto che abbiamo perso. In entrambi i test, abbiamo espresso in un modo o nell'altro il concetto di "abbastanza giocatori". Ma quanto è abbastanza? Sono due? Sì, per ora lo è. Ma vogliamo che il nostro test fallisca se il GiocoLa logica richiederà almeno tre giocatori? Non vogliamo che ciò accada. Possiamo introdurre un campo di classe statico pubblico per questo.

class Game static $ minimumNumberOfPlayers = 2; // ... // function __construct () // ... // function isPlayable () return ($ this-> howManyPlayers ()> = self :: $ minimumNumberOfPlayers);  // ... //

Questo ci permetterà di usarlo nei nostri test.

funzione privata addEnoughPlayers ($ game) for ($ i = 0; $ i < Game::$minimumNumberOfPlayers; $i++)  $game->aggiungi ('A Player'); 

Il nostro piccolo metodo di supporto aggiungerà solo i giocatori fino a quando non verrà aggiunto abbastanza. Possiamo persino creare un altro metodo simile per il nostro primo test, quindi aggiungiamo abbastanza giocatori.

function testAGameWithNotEnoughPlayersIsNotPlayable () $ game = new Game (); $ This-> assertFalse ($ gioco-> isPlayable ()); $ This-> addJustNothEnoughPlayers ($ di gioco); $ This-> assertFalse ($ gioco-> isPlayable ());  funzione privata addJustNothEnoughPlayers ($ game) for ($ i = 0; $ i < Game::$minimumNumberOfPlayers - 1; $i++)  $game->aggiungi ('Un giocatore'); 

Ma questo ha introdotto alcune duplicazioni. I nostri due metodi di supporto sono abbastanza simili. Non possiamo estrarne un terzo da loro?

funzione privata addEnoughPlayers ($ game) $ this-> addManyPlayers ($ game, Game :: $ minimumNumberOfPlayers);  funzione privata addJustNothEnoughPlayers ($ game) $ this-> addManyPlayers ($ game, Game :: $ minimumNumberOfPlayers - 1);  funzione privata addManyPlayers ($ game, $ numberOfPlayers) for ($ i = 0; $ i < $numberOfPlayers; $i++)  $game->aggiungi ('A Player'); 

È meglio, ma introduce un problema diverso. Abbiamo ridotto la duplicazione in questi metodi, ma i nostri $ gioco l'oggetto è ora tramandato di tre livelli. Sta diventando difficile da gestire. È ora di inizializzarlo nel test impostare() metodo e riutilizzarlo.

class GameTest estende PHPUnit_Framework_TestCase private $ game; function setUp () $ this-> game = new Game;  function testAGameWithNotEnoughPlayersIsNotPlayable () $ this-> assertFalse ($ this-> game-> isPlayable ()); $ This-> addJustNothEnoughPlayers (); $ This-> assertFalse ($ this-> gioco-> isPlayable ());  function testAfterAddingEnoughPlayersToANewGameItIsPlayable () $ this-> addEnoughPlayers ($ this-> game); $ This-> assertTrue ($ this-> gioco-> isPlayable ());  funzione privata addEnoughPlayers () $ this-> addManyPlayers (Game :: $ minimumNumberOfPlayers);  funzione privata addJustNothEnoughPlayers () $ this-> addManyPlayers (Game :: $ minimumNumberOfPlayers - 1);  funzione privata addManyPlayers ($ numberOfPlayers) for ($ i = 0; $ i < $numberOfPlayers; $i++)  $this->game-> add ('A Player'); 

Molto meglio. Tutto il codice irrilevante è in metodi privati, $ gioco è inizializzato in impostare() e un sacco di inquinamento è stato rimosso dai metodi di prova. Tuttavia, abbiamo dovuto fare un compromesso qui. Nel nostro primo test, iniziamo con un'affermazione. Questo lo presume impostare() creerà sempre un gioco vuoto. Questo è OK per ora. Ma alla fine della giornata, devi capire che non esiste un codice perfetto. C'è solo il codice con i compromessi che sei disposto a convivere.

Il secondo metodo testabile

Se stiamo scandendo il nostro Gioco classe dall'alto verso il basso, il prossimo metodo sulla nostra lista è Inserisci(). Sì, lo stesso metodo che abbiamo usato nei nostri test nel paragrafo precedente. Ma possiamo testarlo?

function testItCanAddANewPlayer () $ this-> game-> add ('A player'); $ this-> assertEquals (1, count ($ this-> game-> players)); 

Ora questo è un modo diverso di testare gli oggetti. Chiamiamo il nostro metodo e quindi verifichiamo lo stato dell'oggetto. Come Inserisci() ritorna sempre vero, non c'è modo di testarne l'output. Ma possiamo iniziare con un vuoto Gioco oggetto e quindi verificare se c'è un singolo utente dopo aver aggiunto uno. Ma è abbastanza verifica?

function testItCanAddANewPlayer () $ this-> assertEquals (0, count ($ this-> game-> players)); $ this-> game-> add ('A player'); $ this-> assertEquals (1, count ($ this-> game-> players)); 

Non sarebbe meglio verificare anche se non ci sono giocatori prima di chiamare Inserisci()? Beh, potrebbe essere un po 'troppo qui, ma come puoi vedere nel codice qui sopra, potremmo farlo. E ogni volta che non sei sicuro dello stato iniziale, dovresti fare un'affermazione su di esso. Questo ti protegge anche dalle future modifiche al codice che potrebbero cambiare lo stato iniziale dell'oggetto.

Ma stiamo testando tutte le cose Inserisci() il metodo fa? Io dico di no. Oltre ad aggiungere un utente, imposta anche molte impostazioni per questo. Dovremmo anche controllare quelli.

function testItCanAddANewPlayer () $ this-> assertEquals (0, count ($ this-> game-> players)); $ this-> game-> add ('A player'); $ this-> assertEquals (1, count ($ this-> game-> players)); $ this-> assertEquals (0, $ this-> game-> places [1]); $ this-> assertEquals (0, $ this-> game-> purses [1]); $ This-> assertFalse ($ this-> gioco-> inPenaltyBox [1]); 

Questo è meglio. Verifichiamo ogni azione che il Inserisci() il metodo fa. Questa volta, ho preferito testare direttamente il $ giocatori array. Perché? Avremmo potuto usare il howManyPlayers () metodo che fondamentalmente fa la stessa cosa, giusto? Bene, in questo caso abbiamo considerato che è più importante descrivere le nostre asserzioni dagli effetti che il Inserisci() il metodo ha lo stato dell'oggetto. Se dobbiamo cambiare Inserisci(), ci aspetteremmo che il test che sta testando il suo comportamento severo fallirà. Ho avuto infiniti dibattiti con i miei colleghi di Syneto su questo. Soprattutto perché questo tipo di test introduce un forte accoppiamento tra il test e come Inserisci() il metodo è effettivamente implementato. Quindi, se preferisci testarlo al contrario, ciò non significa che le tue idee siano sbagliate.

Possiamo tranquillamente ignorare il test dell'output, il echoln () Linee. Stanno solo trasmettendo il contenuto sullo schermo. Non vogliamo ancora toccare questi metodi. Il nostro maestro d'oro si affida totalmente a questo risultato.

Test di refactoring (Bis)

Abbiamo un altro metodo testato con un test di passaggio nuovo di zecca. È ora di refactoring entrambi, solo un po '. Iniziamo con i nostri test. Le ultime tre asserzioni non sono un po 'confuse? Non sembrano essere strettamente correlati all'aggiunta di un giocatore. Cambiamolo:

function testItCanAddANewPlayer () $ this-> assertEquals (0, count ($ this-> game-> players)); $ this-> game-> add ('A player'); $ this-> assertEquals (1, count ($ this-> game-> players)); $ This-> assertDefaultPlayerParametersAreSetFor (1); 

Così va meglio. Il metodo ora è più astratto, riutilizzabile, chiamato in modo esplicito e nasconde tutti i dettagli non importanti.

Refactoring del Inserisci() Metodo

Possiamo fare qualcosa di simile con il nostro codice di produzione.

funzione add ($ playerName) array_push ($ this-> players, $ playerName); $ This-> setDefaultPlayerParametersFor ($ this-> howManyPlayers ()); è stato aggiunto echoln ($ playerName. "); echoln ("Sono il numero del giocatore". count ($ this-> players)); ritorna vero; 

Abbiamo estratto i dettagli insignificanti in setDefaultPlayerParametersFor ().

funzione privata setDefaultPlayerParametersFor ($ playerId) $ this-> places [$ playerId] = 0; $ this-> purses [$ playerId] = 0; $ this-> inPenaltyBox [$ playerId] = false; 

In realtà questa idea mi è venuta dopo aver scritto il test. Questo è un altro bell'esempio di come i test ci obbligano a pensare al nostro codice da un altro punto di vista. Questa diversa angolazione del problema è ciò che dobbiamo sfruttare e lasciare che i nostri test guidino la nostra progettazione del codice di produzione.

Il terzo metodo testabile

Scopriamo il nostro terzo candidato per i test. howManyPlayers () è troppo semplice e indirettamente già testato. rotolo () è troppo complesso per essere testato direttamente. Inoltre restituisce nullo. fare domande() sembra interessante a prima vista, ma è tutto presentazione, nessun valore di ritorno.

currentCategory () è testabile, ma è carina difficile testare. È un enorme selezionatore con dieci condizioni. Abbiamo bisogno di un test lungo dieci righe e quindi dobbiamo seriamente refactoring questo metodo e sicuramente anche i test. Dovremmo prendere atto di questo metodo e tornare ad esso dopo aver finito con quelli più facili. Per noi, questo sarà nel nostro prossimo tutorial.

wasCorrectlyAnswered () è di nuovo complicato. Dovremo estrarre da esso, piccoli pezzi di codice verificabili. però, risposta sbagliata() sembra promettente. Emette cose sullo schermo, ma cambia anche lo stato del nostro oggetto. Vediamo se possiamo controllarlo e testarlo.

function testWhenAPlayerEntersAWrongAnswerItIsSentToThePenaltyBox () $ this-> game-> add ('A player'); $ this-> game-> currentPlayer = 0; $ This-> gioco-> wrongAnswer (); $ This-> assertTrue ($ this-> gioco-> inPenaltyBox [0]); 

Grrr ... E 'stato abbastanza difficile scrivere questo metodo di prova. risposta sbagliata() si affida $ This-> currentPlayer per la sua logica comportamentale, ma usa anche $ This-> giocatori nella sua parte di presentazione. Un brutto esempio del perché non si dovrebbe mescolare la logica e la presentazione. Ci occuperemo di questo in un futuro tutorial. Per ora, abbiamo verificato che l'utente inserisca la penalità. Dobbiamo anche osservare che c'è un Se() affermazione nel metodo. Questa è una condizione che non testiamo ancora, poiché abbiamo un solo giocatore e quindi non stiamo soddisfacendo la condizione. Potremmo testare il valore finale di $ currentPlayer anche se. Ma aggiungere questa linea di codice al test lo farà fallire.

$ this-> assertEquals (1, $ this-> game-> currentPlayer);

Uno sguardo più da vicino al metodo privato shouldResetCurrentPlayer () rivela il problema Se l'indice del giocatore corrente è uguale al numero di giocatori, verrà azzerato. Aaaahhh! In realtà entriamo nel Se()!

function testWhenAPlayerEntersAWrongAnswerItIsSentToThePenaltyBox () $ this-> game-> add ('A player'); $ this-> game-> currentPlayer = 0; $ This-> gioco-> wrongAnswer (); $ This-> assertTrue ($ this-> gioco-> inPenaltyBox [0]); $ this-> assertEquals (0, $ this-> game-> currentPlayer);  function testCurrentPlayerIsNotResetAfterWrongAnswerIfOtherPlayersDidNotYetPlay () $ this-> addManyPlayers (2); $ this-> game-> currentPlayer = 0; $ This-> gioco-> wrongAnswer (); $ this-> assertEquals (1, $ this-> game-> currentPlayer); 

Buono. Abbiamo creato un secondo test, per testare il caso specifico quando ci sono ancora giocatori che non hanno giocato. Non ci interessa per il inPenaltyBox stato per il secondo test. Siamo interessati solo all'indice del giocatore attuale.

Il metodo finale testabile

L'ultimo metodo che possiamo testare e poi refactoring è didPlayerWin ().

function didPlayerWin () $ numberOfCoinsToWin = 6; return! ($ this-> purses [$ this-> currentPlayer] == $ numberOfCoinsToWin); 

Possiamo immediatamente osservare che la sua struttura del codice è molto simile a isPlayable (), il metodo che abbiamo testato per primo Anche la nostra soluzione dovrebbe essere qualcosa di simile. Quando il tuo codice è così breve, solo due o tre righe, fare più di un piccolo passo non è un grosso rischio. Negli scenari peggiori, si ripristinano tre righe di codice. Quindi facciamolo in un solo passaggio.

function testTestPlayerWinsWithTheCorrectNumberOfCoins () $ this-> game-> currentPlayer = 0; $ this-> game-> purses [0] = Game :: $ numberOfCoinsToWin; $ This-> assertTrue ($ this-> gioco-> didPlayerWin ()); 

Ma aspetta! Questo fallisce. Come è possibile? Non dovrebbe passare? Abbiamo fornito il numero corretto di monete. Se studiamo il nostro metodo, scopriremo un piccolo dato fuorviante.

return! ($ this-> purses [$ this-> currentPlayer] == $ numberOfCoinsToWin);

Il valore di ritorno è in realtà negato. Quindi il metodo non ci dice se un giocatore ha vinto, ci dice se un giocatore non ha vinto la partita. Potremmo entrare e trovare i luoghi in cui viene utilizzato questo metodo e negare il suo valore lì. Quindi cambia il suo comportamento qui, per non negare falsamente la risposta. Ma è usato in wasCorrectlyAnswered (), un metodo che non possiamo ancora testare unitamente. Forse per il momento sarà sufficiente una semplice modifica del nome per evidenziare la corretta funzionalità.

function didPlayerNotWin () return! ($ this-> purses [$ this-> currentPlayer] == self :: $ numberOfCoinsToWin); 

Pensieri e conclusioni

Quindi questo avvolge il tutorial. Mentre non ci piace la negazione nel nome, questo è un compromesso che possiamo fare a questo punto. Questo nome cambierà sicuramente quando iniziamo a ridefinire altre parti del codice. Inoltre, se dai un'occhiata ai nostri test, ora sembrano strani:

function testTestPlayerWinsWithTheCorrectNumberOfCoins () $ this-> game-> currentPlayer = 0; $ this-> game-> purses [0] = Game :: $ numberOfCoinsToWin; $ This-> assertFalse ($ this-> gioco-> didPlayerNotWin ()); 

Testando il falso su un metodo negato, esercitato con un valore che suggerisce un risultato reale, abbiamo introdotto un sacco di confusione per la nostra leggibilità dei codici. Ma per ora va bene, visto che dobbiamo fermarci ad un certo punto, giusto?

Nel nostro prossimo tutorial, inizieremo a lavorare su alcuni dei metodi più difficili all'interno di Gioco classe. Grazie per aver letto.