TDD e copertura completa dei test dove sono necessari casi di test esponenziali


18

Sto lavorando a un comparatore di elenchi per aiutare a ordinare un elenco non ordinato di risultati di ricerca per requisiti molto specifici dal nostro cliente. I requisiti richiedono un algoritmo di pertinenza classificato con le seguenti regole in ordine di importanza:

  1. Corrispondenza esatta sul nome
  2. Tutte le parole della query di ricerca nel nome o un sinonimo del risultato
  3. Alcune parole della query di ricerca nel nome o nel sinonimo del risultato (% decrescente)
  4. Tutte le parole della query di ricerca nella descrizione
  5. Alcune parole della query di ricerca nella descrizione (% decrescente)
  6. Data dell'ultima modifica decrescente

La scelta naturale del design per questo comparatore sembrava essere una classifica basata su potenze di 2. La somma di regole meno importanti non può mai essere più di una corrispondenza positiva su una regola di importanza maggiore. Ciò si ottiene con il seguente punteggio:

  1. 32
  2. 16
  3. 8 (Punteggio secondario del tie-breaker basato su% decrescente)
  4. 4
  5. 2 (Punteggio secondario del tie-breaker basato su% decrescente)
  6. 1

Nello spirito del TDD ho deciso di iniziare prima con i miei test unitari. Avere un caso di test per ogni scenario unico equivarrebbe ad almeno 63 casi di test univoci che non tengono conto di casi di test aggiuntivi per la logica del tie breaker secondario sulle regole 3 e 5. Ciò sembra prepotente.

I test effettivi saranno in realtà meno però. Sulla base delle stesse regole stesse alcune regole assicurano che le regole più basse siano sempre vere (ad esempio, quando "Tutte le parole della query di ricerca vengono visualizzate nella descrizione", la regola "Alcune parole della query di ricerca vengono visualizzate nella descrizione" sarà sempre vera). Vale comunque la pena impegnarsi nello scrivere ciascuno di questi casi di test? È questo il livello di test generalmente richiesto quando si parla della copertura del test al 100% in TDD? In caso contrario, quale sarebbe una strategia di test alternativa accettabile?


1
Questo scenario e simili è il motivo per cui ho sviluppato un "TMatrixTestCase" e un enumeratore per i quali è possibile scrivere una volta il codice di test e alimentare due o più array contenenti gli input e il risultato previsto.
Marjan Venema,

Risposte:


17

La tua domanda implica che TDD ha qualcosa a che fare con "scrivere prima tutti i casi di test". IMHO che non è "nello spirito del TDD", in realtà è contrario . Ricorda che TDD sta per " sviluppo guidato dai test ", quindi hai bisogno solo di quei casi di test che "guidano" davvero la tua implementazione, non di più. E finché l'implementazione non è progettata in modo tale che il numero di blocchi di codice cresca in modo esponenziale con ogni nuovo requisito, non sarà necessario nemmeno un numero esponenziale di casi di test. Nel tuo esempio, il ciclo TDD probabilmente avrà questo aspetto:

  • inizia con il primo requisito della tua lista: le parole con "Corrispondenza esatta sul nome" devono ottenere un punteggio più alto di tutto il resto
  • ora scrivi un primo caso di test per questo (ad esempio: una parola corrispondente a una determinata query) e implementa la quantità minima di codice funzionante che fa passare quel test
  • aggiungere un secondo banco di prova per il primo requisito (ad esempio: una parola che non corrisponde alla query), e prima di aggiungere un nuovo banco di prova , modificare il codice esistente fino al 2 passaggi di prova
  • a seconda dei dettagli dell'implementazione, sentiti libero di aggiungere altri casi di test, ad esempio una query vuota, una parola vuota ecc. (ricorda: TDD è un approccio a scatola bianca , puoi sfruttare il fatto che conosci l'implementazione quando progetta i tuoi casi di test).

Quindi, inizia con il secondo requisito:

  • "Tutte le parole della query di ricerca nel nome o un sinonimo del risultato" devono ottenere un punteggio più basso di "Corrispondenza esatta sul nome", ma un punteggio più alto di tutto il resto.
  • ora crea casi di test per questo nuovo requisito, proprio come sopra, uno dopo l'altro, e implementa la parte successiva del tuo codice dopo ogni nuovo test. Non dimenticare di effettuare il refactoring tra il tuo codice e i casi di test.

Ecco il problema : quando aggiungi casi di test per il numero categoria / requisito "n", dovrai solo aggiungere test per assicurarti che il punteggio della categoria "n-1" sia superiore al punteggio per la categoria "n" . Non dovrai aggiungere casi di test per ogni altra combinazione delle categorie 1, ..., n-1, poiché i test che hai scritto in precedenza assicureranno che i punteggi di tali categorie siano ancora nell'ordine corretto.

Quindi questo ti darà un numero di casi di test che diventa approssimativamente lineare con il numero di requisiti, non esponenzialmente.


1
Mi piace molto questa risposta. Fornisce una strategia di unit test chiara e concisa per affrontare questo problema tenendo presente TDD. Lo abbatti abbastanza bene.
maple_shaft

@maple_shaft: grazie e mi piace molto la tua domanda. Mi piace aggiungere che suppongo che anche con il tuo approccio alla progettazione di tutti i casi di test prima, la tecnica classica di costruzione di classi di equivalenza per i test potrebbe essere sufficiente per ridurre la crescita esponenziale (ma finora non ci sono riuscito).
Doc Brown,

13

Prendi in considerazione la possibilità di scrivere una classe che passi attraverso un elenco predefinito di condizioni e moltiplichi un punteggio corrente per 2 per ogni controllo riuscito.

Questo può essere testato molto facilmente, usando solo un paio di test derisi.

Quindi puoi scrivere una classe per ogni condizione e ci sono solo 2 test per ogni caso.

Non capisco davvero il tuo caso d'uso, ma spero che questo esempio possa aiutarti.

public class ScoreBuilder
{
    private ISingleScorableCondition[] _conditions;
    public ScoreBuilder (ISingleScorableCondition[] conditions)
    {
        _conditions = conditions;
    }

    public int GetScore(string toBeScored)
    {
        foreach (var condition in _conditions)
        {
            if (_conditions.Test(toBeScored))
            {
                // score this somehow
            }
        }
    }
}

public class ExactMatchOnNameCondition : ISingleScorableCondition
{
    private IDataSource _dataSource;
    public ExactMatchOnNameCondition(IDataSource dataSource)
    {
        _dataSource = dataSource;
    }

    public bool Test(string toBeTested)
    {
        return _dataSource.Contains(toBeTested);
    }
}

// etc

Noterai che i tuoi test delle 2 ^ condizioni si riducono rapidamente a 4+ (2 * condizioni). 20 è molto meno prepotente di 64. E se ne aggiungi un'altra in seguito, non devi cambiare NESSUNA delle classi esistenti (principio aperto-chiuso), quindi non devi scrivere 64 nuovi test, devi solo per aggiungere un'altra classe con 2 nuovi test e iniettarla nella classe ScoreBuilder.


Approccio interessante Per tutto il tempo la mia mente non ha mai considerato un approccio OOP poiché ero bloccato nella mente di un singolo componente di confronto. Non stavo davvero cercando consigli sugli algoritmi ma questo è molto utile a prescindere.
maple_shaft

4
@maple_shaft: No, ma stavi cercando consigli TDD e questo tipo di algoritmi sono perfetti per rimuovere la questione se valga la pena, riducendo notevolmente lo sforzo. Ridurre la complessità è la chiave per TDD.
pdr

+1, ottima risposta. Anche se credo anche senza una soluzione così sofisticata, il numero di casi di test non deve crescere esponenzialmente (vedi la mia risposta di seguito).
Doc Brown,

Non ho accettato la tua risposta perché ho sentito che un'altra risposta rispondeva meglio alla domanda reale, ma mi è piaciuto così tanto il tuo approccio progettuale che lo sto implementando come mi hai suggerito. Ciò riduce la complessità e la rende più estensibile a lungo termine.
maple_shaft

4

Vale comunque la pena impegnarsi nello scrivere ciascuno di questi casi di test?

Dovrai definire "ne vale la pena". Il problema con questo tipo di scenario è che i test avranno un ritorno sull'utilità decrescente. Sicuramente ne varrà la pena il primo test che scrivi. Può trovare evidenti errori nella priorità e persino cose come l'analisi degli errori quando si tenta di rompere le parole.

Il secondo test ne varrà la pena perché copre un percorso diverso attraverso il codice, probabilmente controllando un'altra relazione prioritaria.

Il 63 ° test probabilmente non ne varrà la pena perché è qualcosa di cui sei sicuro al 99,99% è coperto dalla logica del tuo codice o da un altro test.

È questo il livello di test generalmente richiesto quando si parla della copertura del test al 100% in TDD?

La mia comprensione è che la copertura al 100% significa che tutti i percorsi del codice sono esercitati. Questo non significa che tu faccia tutte le combinazioni delle tue regole, ma tutti i diversi percorsi che il tuo codice potrebbe percorrere (come fai notare, alcune combinazioni non possono esistere nel codice). Ma dal momento che stai facendo TDD, non esiste ancora un "codice" per controllare i percorsi. La lettera del processo direbbe fare tutto 63+.

Personalmente, trovo che il 100% di copertura sia un sogno irrealizzabile. Oltre a ciò, è pragmatico. Esistono test unitari per servirti, non viceversa. Man mano che si eseguono più test, si ottengono rendimenti decrescenti sul vantaggio (la probabilità che il test prevenga un bug + la sicurezza che il codice sia corretto). A seconda di ciò che il tuo codice definisce dove su quella scala mobile smetti di fare test. Se il tuo codice esegue un reattore nucleare, allora forse ne valgono la pena per tutti i 63+ test. Se il tuo codice sta organizzando il tuo archivio musicale, probabilmente potresti cavartela con molto meno.


"copertura" in genere si riferisce alla copertura del codice (viene eseguita ogni riga di codice) o alla copertura del ramo (ogni ramo viene eseguito almeno una volta in qualsiasi direzione possibile). Per entrambi i tipi di copertura non sono necessari 64 diversi casi di test. Almeno, non con un'implementazione seria che non contiene parti di codice individuali per ciascuno dei 64 casi. Quindi una copertura del 100% è completamente possibile.
Doc Brown,

@DocBrown - certo, in questo caso - altre cose sono più difficili / impossibili da testare; considerare percorsi di eccezione di memoria esaurita. Tutti i 64 non sarebbero richiesti nel TDD "dalla lettera" per far rispettare il comportamento ignorato dell'implementazione?
Telastyn,

bene, il mio commento era correlato alla domanda e la tua risposta dà l'impressione che potrebbe essere difficile ottenere una copertura del 100% nel caso del PO . Ne dubito. E sono d'accordo con te sul fatto che si possano costruire casi in cui è difficile ottenere una copertura del 100%, ma ciò non è stato chiesto.
Doc Brown,

4

Direi che questo è un caso perfetto per TDD.

È necessario verificare una serie di criteri, con una suddivisione logica di tali casi. Supponendo che li testerai o ora o più tardi, sembra sensato prendere il risultato noto e costruirlo attorno, assicurando, in effetti, che stai coprendo ciascuna delle regole in modo indipendente.

Inoltre, puoi scoprire mentre procedi se l'aggiunta di una nuova regola di ricerca interrompe una regola esistente. Se fai tutto questo alla fine della codifica, presumibilmente corri il rischio maggiore di doverne cambiare uno per risolverne uno, che rompe un altro, che rompe un altro ... E, impari mentre implementi le regole se il tuo disegno è valido o ha bisogno di modifiche.


1

Non sono un fan di interpretare rigorosamente la copertura del test al 100% come scrivere specifiche su ogni singolo metodo o testare ogni permutazione del codice. In questo modo, fanaticamente, si tende a condurre a un progetto di test guidato dalle classi che non incapsula correttamente la logica aziendale e produce test / specifiche generalmente privi di significato in termini di descrizione della logica aziendale supportata. Invece, mi concentro sulla strutturazione dei test in modo molto simile alle regole aziendali stesse e mi sforzo di esercitare ogni ramo condizionale del codice con i test con l'aspettativa esplicita che i test siano facilmente comprensibili dal tester come generalmente sarebbero casi d'uso e in realtà descrivono i regole aziendali che sono state implementate.

Con questa idea in mente, testerei in modo esaustivo i 6 fattori di classificazione che hai elencato separatamente, seguiti da 2 o 3 test di stile di integrazione che assicurano che stai arrotolando i risultati ai valori di classifica generali previsti. Ad esempio, caso n. 1, corrispondenza esatta sul nome, avrei almeno due test unitari da testare quando è esatto e quando non lo è e che i due scenari restituiscono il punteggio previsto. Se fa distinzione tra maiuscole e minuscole, anche un caso per testare "Corrispondenza esatta" rispetto a "Corrispondenza esatta" e possibilmente altre variazioni di input come punteggiatura, spazi extra, ecc. Restituisce anche i punteggi previsti.

Dopo aver esaminato tutti i singoli fattori che contribuiscono ai punteggi delle classifiche, presumo essenzialmente che funzionino correttamente a livello di integrazione e mi concentro sull'assicurare che i loro fattori combinati contribuiscano correttamente al punteggio di classifica finale previsto.

Supponendo che i casi n. 2 / n. 3 e n. 4 / n. 5 siano generalizzati agli stessi metodi sottostanti, ma passando diversi campi, è necessario scrivere un solo set di unit test per i metodi sottostanti e scrivere semplici unit test addizionali per testare lo specifico campi (titolo, nome, descrizione, ecc.) e punteggio nel factoring designato, in modo da ridurre ulteriormente la ridondanza del lavoro complessivo di test.

Con questo approccio, l'approccio sopra descritto probabilmente produrrebbe 3 o 4 test unitari sul caso n. 1, forse 10 specifiche su alcuni / tutti con sinonimi considerati - più 4 specifiche sul punteggio corretto dei casi # 2 - # 5 e 2 a 3 specifiche sulla data ordinata per la data finale, quindi 3-4 test a livello di integrazione che misurano tutti e 6 i casi combinati in modi probabili (per ora dimentica i casi limite oscuri a meno che tu non veda chiaramente un problema nel tuo codice che deve essere esercitato per garantire tale condizione viene gestita) o assicurarsi che non venga violato / rotto da revisioni successive. Ciò produce circa 25 specifiche circa per esercitare il 100% del codice scritto (anche se non hai chiamato direttamente il 100% dei metodi scritti).


1

Non sono mai stato un fan della copertura dei test al 100%. Nella mia esperienza, se qualcosa è abbastanza semplice da testare con solo uno o due casi di test, allora è abbastanza semplice fallire raramente. Quando fallisce, di solito è dovuto a modifiche architettoniche che richiederebbero comunque modifiche al test.

Detto questo, per requisiti come il tuo, collaudo sempre l'unità a fondo, anche su progetti personali in cui nessuno mi sta realizzando, perché in questi casi i test unitari ti fanno risparmiare tempo e difficoltà. Più test unitari sono necessari per testare qualcosa, più tempo risparmierà i test unitari.

Questo perché puoi tenere solo tante cose in testa contemporaneamente. Se stai cercando di scrivere un codice che funzioni per 63 combinazioni diverse, è spesso difficile correggere una combinazione senza romperne un'altra. Finisci per testare manualmente altre combinazioni ancora e ancora. Il test manuale è molto più lento, il che ti fa non voler ripetere ogni possibile combinazione ogni volta che apporti una modifica. Ciò ti rende più propenso a perdere qualcosa e più a perdere tempo a perseguire percorsi che non funzionano in tutti i casi.

A parte il tempo risparmiato rispetto ai test manuali, c'è molta meno tensione mentale, che rende più facile concentrarsi sul problema attuale senza preoccuparsi di introdurre accidentalmente regressioni. Ciò ti consente di lavorare più velocemente e più a lungo senza burnout. A mio avviso, i soli benefici per la salute mentale valgono il costo del test di unità di codice complesso, anche se non ti ha fatto risparmiare tempo.

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.