Come si suddivide un grande metodo in metodi più piccoli per migliorare la testabilità delle unità quando i metodi sono tutti privati?

6

Attualmente sto leggendo Building Maintainable Software di Joost Visser e alcune delle linee guida di manutenzione che raccomandano includono: A) ogni unità / metodo dovrebbe essere breve (meno di 15 linee per metodo) e B) i metodi dovrebbero avere un basso < a href="https://en.wikipedia.org/wiki/Cyclomatic_complexity" title="Cyclomatic_complexity"> complessità ciclomatica . Suggerisce che entrambe queste linee guida aiutano con i test.

L'esempio che segue è tratto dal libro che spiega come avrebbero rifattorizzato un metodo complesso per ridurre la complessità ciclomatica per metodo.

Prima:

public static int calculateDepth(BinaryTreeNode<Integer> t, int n) {
    int depth = 0;
    if (t.getValue() == n) {
        return depth;
    } else {
        if (n < t.getValue()) {
            BinaryTreeNode<Integer> left = t.getLeft();
            if (left == null) {
                throw new TreeException("Value not found in tree!");
            } else {
                return 1 + calculateDepth(left, n);
            }
        } else {
             BinaryTreeNode<Integer> right = t.getRight();
             if (right == null) {
                throw new TreeException("Value not found in tree!");
             } else {
                return 1 + calculateDepth(right, n);
             }
        }
    }
}

Dopo:

public static int calculateDepth(BinaryTreeNode<Integer> t, int n) {
     int depth = 0;
     if (t.getValue() == n)
        return depth;
     else
        return traverseByValue(t, n);
}
private static int traverseByValue(BinaryTreeNode<Integer> t, int n) {
     BinaryTreeNode<Integer> childNode = getChildNode(t, n);
     if (childNode == null) {
        throw new TreeException("Value not found in tree!");
     } else {
        return 1 + calculateDepth(childNode, n);
     }
}
private static BinaryTreeNode<Integer> getChildNode(
     BinaryTreeNode<Integer> t, int n) {
     if (n < t.getValue()) {
        return t.getLeft();
     } else {
        return t.getRight();
     }
}

Nella loro giustificazione dichiarano (sottolineatura mia):

Argomento:

“Replacing one method with McCabe 15 by three methods with McCabe 5 each means that overall McCabe is still 15 (and therefore, there are 15 control ow branches overall). So nothing is gained.”

Argomento del contatore:

Of course, you will not decrease the overall McCabe complexity of a system by refactoring a method into several new methods. But from a maintainability perspective, there is an advantage to doing so: it will become easier to test and understand the code that was written. So, as we already mentioned, newly written unit tests allow you to more easily identify the root cause of your failing tests.

Domanda: come è più facile testare?

In base alle risposte a questa domanda, < a href="https://stackoverflow.com/questions/1583363/how-to-unit-test-private-methods-in-bdd-tdd"> questa domanda, questo domanda, questo domanda e this blog non dovremmo testare direttamente i metodi privati. Il che significa che dobbiamo testarli tramite i metodi pubblici che li usano. Quindi, tornando all'esempio nel libro, se stiamo testando i metodi privati tramite il metodo pubblico, allora come fa l'unità a migliorare le funzionalità o a cambiare per quello?

    
posta Adrian773 18.01.2016 - 00:11
fonte

2 risposte

6

Dopo aver scritto un sacco di test, sono strongmente a favore della suddivisione di metodi di grandi dimensioni e di test di metodi privati. La suddivisione delle funzionalità in passaggi più piccoli ha due grandi vantaggi:

  1. Introducendo un nome per un'operazione, il codice diventa più auto-documentante.

  2. Usando metodi più piccoli, il codice è più semplice e quindi più probabilmente corretto. Per esempio. puoi capire immediatamente getChildNode() .

Mentre la complessità ciclomatica del programma complessivo non è ridotta, questi due vantaggi superano il bit di codice extra nel mio libro. C'è un terzo vantaggio: il codice diventa molto più facile da testare, supponendo che possiamo aggirare il modificatore di accesso private .

Le persone che sconsigliano di testare i dettagli di implementazione privata sono un buon punto: il test dovrebbe mostrare che l'implementazione aderisce alla sua interfaccia pubblica, ma questo può essere fatto solo dai test black-box dei metodi pubblici. Tali test non richiedono di ottenere una copertura del 100%, ma la copertura mancante è un'indicazione del codice morto che non è richiesto dalle specifiche. Poiché i test TDD dovrebbero definire un comportamento osservabile esternamente, tali test rientrano in questa categoria.

Ma puoi anche usare un approccio diverso ai test: mostrare che un'implementazione esistente è probabilmente corretta e funziona come previsto dal programmatore. Poiché abbiamo già il codice, possiamo progettare i nostri test per massimizzare la copertura. Possiamo usare i valori al contorno per esercitare meticolosamente il comportamento del programma. In altre parole, possiamo scrivere test white-box che conoscono i dettagli di implementazione del sistema sotto test. Questi test sono altamente accoppiati all'implementazione, ma va bene purché si abbiano anche test di black-box più generali.

Di conseguenza, preferisco alcuni test black-box che descrivono il "percorso felice" e le garanzie di base dell'interfaccia. Ma è un modo ingombrante per esercitare tutte le possibilità: con ogni parametro di input o variabile di stato nella funzione, lo spazio di test aumenta in modo esponenziale! Una funzione f(bool) potrebbe richiedere due test, una funzione f(bool, bool) 2² = 4 e f(bool, bool, bool, bool) già 2 4 = 16. Questo è insostenibile. Ma dividendo una grande funzione in funzioni più piccole, devo solo mostrare che ogni funzione più piccola funziona come previsto, e che le funzioni funzionano correttamente insieme (io chiamo questo test per induzione). Il mio carico di lavoro ora aggiunge, invece di moltiplicare, un grande miglioramento se vuoi essere completo!

Nel tuo esempio concreto, entrambe le possibilità sono non ottimali perché al primo tentativo ci sono un sacco di duplicazione del codice che richiede test duplicati, e nel secondo tentativo ci sono interdipendenze tra le funzioni che non possono essere derise. Solo getChildNode() è facile da testare, ma questa funzione non è corretta se n è uguale a t.getValue() , il che non succederà mai se tale funzione viene chiamata sempre solo da traverseByValue() . Un'alternativa facile da testare sarebbe:

public static int calculateDepth(BinaryTreeNode<Integer> t, int n) {
    if (t == null) {
        throw new TreeException("Value not found in tree!");
    }

    if (t.getValue() == n) {
        return 0;
    }

    BinaryTreeNode<Integer> child = null;
    if (n < t.getValue()) {
        child = t.getLeft();
    } else {
        child = t.getRight();
    }

    return 1 + calculateDepth(child, n);
}

Questo caso specifico non utilizza nemmeno alcuna funzione di supporto, perché è abbastanza semplice da fare a meno - ci sono solo 4 percorsi attraverso questo codice. L'implementazione precedente l'ha nascosta condizionando i condizionali quando l'altro ramo era già stato terminato da un throw o return e da una duplicazione di codice non necessaria.

Tuttavia, testare una ricorsione o un loop può essere difficile. Mentre potremmo creare un albero abbastanza complesso e controllare il risultato corretto, vorremmo un modo per verificare l'invarianza del ciclo. In una lingua con funzioni di ordine superiore, potrebbe essere:

public static int calculateDepth(
        BinaryTreeNode<Integer> t,
        int n)
{
    return calculateDepthLoop(t, n, calculateDepthLoop);
}

type Recurser = int(BinaryTreeNode<Integer>, int, Recurser);

private static int calculateDepthLoop(
         BinaryTreeNode<Integer> t,
         int n,
         Recurser recurse)
{
    if (t == null) {
        throw new TreeException("Value not found in tree!");
    }

    if (t.getValue() == n) {
        return 0;
    }

    BinaryTreeNode<Integer> child = null;
    if (n < t.getValue()) {
        child = t.getLeft();
    } else {
        child = t.getRight();
    }

    return 1 + recurse(child, n, recurse);
}

Ora potremmo eseguire un piano di test come:

  • calculateDepthLoop(null, ANY, ANY) di tiri.
  • calculateDepthLoop(Tree(x, ANY, ANY), x, ANY) is 0' .
  • calculateDepthLoop(Tree(x, left, ANY), y, callback) per y < x richiama result = callback(left, y, callback) e restituisce result + 1 .
  • calculateDepthLoop(Tree(x, ANY, right), y, callback) per x < y richiama result = callback(right, y, callback) e restituisce result + 1 .

con solo 4 test (uno per ogni percorso) possiamo essere sicuri che calculateDepthLoop() funzioni come previsto. Potremmo volerne ancora un paio solo per essere sicuri che tutto funzioni per tutti i valori validi di x e y . Ora abbiamo solo bisogno di un altro test per verificare che tutto si integri come dovrebbe, questo può essere fatto con un test black-box di calculateDepth() , che farei creando un albero moderatamente complesso che richiede che la funzione riceva sia a destra sia a sinistra e restituire un valore.

    
risposta data 18.01.2016 - 01:42
fonte
0

Posso vedere 2 possibilità:

  • L'autore fa riferimento a nomi di metodi privati inclusi nello stack trace quando un test di un'unità fallisce in modo imprevisto.

    Quindi

    as we already mentioned, newly written unit tests allow you to more easily identify the root cause of your failing tests.

    Ciò che ancora mi imbarazza sono i test di unità appena scritti . Che cosa intende, e che cosa ha già menzionato ?

  • L'autore è stato trascinato dal modo in cui è meraviglioso il refactoring ai metodi privati e ha dimenticato di essere estremamente difficili da testare separatamente.

Sospetto che quest'ultimo. L'altro vantaggio che menziona (migliore comprensibilità) rimane comunque valido.

    
risposta data 18.01.2016 - 14:07
fonte

Leggi altre domande sui tag