Come mantenere i test unitari ben progettati quando la struttura del codice di produzione cambia mentre si evita il rischio correlato?

3

Uno dei motivi principali per cui scrivere test di unità è assicurarsi che il codice si comporti ancora nello stesso modo dopo il refactoring. Tuttavia, se le interfacce cambiano durante il refactoring, anche i test devono essere aggiornati. Cambiare test e codice di produzione allo stesso tempo introduce dei rischi. Come lo eviti?

Ad esempio, diciamo che si incontra un metodo troppo grande, che ha troppa logica condizionale e che parti di esso possono essere suddivise in classi proprie. Il metodo CreateNextNumber di seguito è maleodorante. L'istruzione switch viola il principio open-close, richiedendo che il metodo sia "aperto" per aggiungere un nuovo tipo di operazione. Forse sono stato incaricato di aggiungere Division come opzione? Quindi sto iniziando con il refactoring di questo metodo.

class NumberCreator
{
    INumberRepository _numberRepository;

    public NumberCreator(INumberRepository numberRepository)
    {
        _numberRepository = numberRepository;
    }

    public void CreateNextNumber(OperationType operationType)
    {
        double firstNumber = _numberRepository.GetFirstNumber();
        double secondNumber = _numberRepository.GetSecondNumber();

        // In real life, this switch statement could be only a tiny part
        // of a much larger method
        double newValue;
        switch(operationType)
        {
            case OperationType.Addition:
                newValue = firstNumber + secondNumber;
                break;
            case OperationType.Subtraction:
                newValue = firstNumber - secondNumber;
                break;
            case OperationType.Multiplication:
                newValue = firstNumber * secondNumber;
                break;
            default:
                throw new ArgumentException("Unsupported operation", nameof(operationType));
        }

        _numberRepository.SaveNumber(newValue);
    }
}

Ovviamente, prima di iniziare il refactoring, mi assicuro che sia coperto passando i test unitari. Fortunatamente, è:

[TestFixture]
public class NumberCreatorTest
{
    NumberCreator _creator;
    INumberRepository _repository;

    [SetUp]
    public void SetUp()
    {
        _repository = Substitute.For<INumberRepository>();
        _repository.GetFirstNumber().Returns(2);
        _repository.GetSecondNumber().Returns(3);

        _creator = new NumberCreator(_repository);
    }

    [Test]
    public void Add()
    {
        _creator.CreateNextNumber(OperationType.Addition);

        _repository.Received().SaveNumber(5);
    }

    [Test]
    public void Subtraction()
    {
        _creator.CreateNextNumber(OperationType.Subtraction);

        _repository.Received().SaveNumber(-1);
    }

    [Test]
    public void Multiplication()
    {
        _creator.CreateNextNumber(OperationType.Multiplication);

        _repository.Received().SaveNumber(6);
    }

    [Test]
    public void Unsupported()
    {
        Assert.Throws<ArgumentException>(() => _creator.CreateNextNumber((OperationType)999));
    }
}

Passaggio 1. Riformo il metodo in modo che non prenda il valore enum ma un'interfaccia IOperation . Per mantenere la compilazione di qualsiasi codice esistente (e dei test), mantengo la vecchia firma del metodo ma la contrassegno come [Obsolete] .

class NumberCreator
{
    INumberRepository _numberRepository;

    public NumberCreator(INumberRepository numberRepository)
    {
        _numberRepository = numberRepository;
    }

    [Obsolete]
    public void CreateNextNumber(OperationType operationType)
    {
        IOperation operation;
        switch(operationType)
        {
            case OperationType.Addition:
                operation = new Addition();
                break;
            case OperationType.Subtraction:
                operation = new Subtraction();
                break;
            case OperationType.Multiplication:
                operation = new Multiplication();
                break;
            default:
                throw new ArgumentException("Unsupported operation", nameof(operationType));
        }

        CreateNextNumber(operation);
    }

    public void CreateNextNumber(IOperation operation)
    {
        double firstNumber = _numberRepository.GetFirstNumber();
        double secondNumber = _numberRepository.GetSecondNumber();

        double newValue = operation.Perform(firstNumber, secondNumber);

        _numberRepository.SaveNumber(newValue);
    }
}

public interface IOperation
{
    double Perform(double left, double right);
}

public class Addition : IOperation
{
    public double Perform(double left, double right)
    {
        return left + right;
    }
}

public class Subtraction : IOperation
{
    public double Perform(double left, double right)
    {
        return left - right;
    }
}

public class Multiplication : IOperation
{
    public double Perform(double left, double right)
    {
        return left * right;
    }
}

Rieseguo i test: sono ancora verdi! Quindi il mio refactoring ha avuto successo. Tuttavia, a questo punto i test che ho scritto in precedenza hanno già testato più classi rispetto al NumberCreator .

Passaggio 2. Decido di eliminare il metodo Obsolete . Tuttavia, ciò interromperà i test. Quindi, prima di farlo, essendo pigro come sono, aggiorno i test nel modo più piccolo possibile per mantenerli compilare e passare.

[TestFixture]
public class NumberCreatorTest
{
    NumberCreator _creator;
    INumberRepository _repository;

    [SetUp]
    public void SetUp()
    {
        _repository = Substitute.For<INumberRepository>();
        _repository.GetFirstNumber().Returns(2);
        _repository.GetSecondNumber().Returns(3);

        _creator = new NumberCreator(_repository);
    }

    [Test]
    public void Add()
    {
        _creator.CreateNextNumber(new Addition());

        _repository.Received().SaveNumber(5);
    }

    [Test]
    public void Subtraction()
    {
        _creator.CreateNextNumber(new Subtraction());

        _repository.Received().SaveNumber(-1);
    }

    [Test]
    public void Multiplication()
    {
        _creator.CreateNextNumber(new Multiplication());

        _repository.Received().SaveNumber(6);
    }
}

Ma ora i miei test per NumberCreator hanno finito per testare le implementazioni di IOperation più di ogni altra cosa! Avevo progettato NumberCreator e IOperation come questo dall'inizio, ogni IOperation avrebbe la sua propria suite di test e il test di NumberCreator userebbe un mock:

[TestFixture]
public class NumberCreatorTest
{
    NumberCreator _creator;
    INumberRepository _repository;

    [SetUp]
    public void SetUp()
    {
        _repository = Substitute.For<INumberRepository>();
        _repository.GetFirstNumber().Returns(2);
        _repository.GetSecondNumber().Returns(3);

        _creator = new NumberCreator(_repository);
    }

    [Test]
    public void CreateNextNumber()
    {
        var operation = Substitute.For<IOperation>();
        operation.Perform(2, 3).Returns(10);

        _creator.CreateNextNumber(operation);

        _repository.Received().SaveNumber(10);
    }
}

[TestFixture]
public class AdditionTest
{
    [Test]
    public void Perform()
    {
        var operation = new Addition();
        double result = operation.Perform(2, 3);
        Assert.AreEqual(5, result);
    }
}

[TestFixture]
public class SubtractionTest
{
    [Test]
    public void Perform()
    {
        var operation = new Subtraction();
        double result = operation.Perform(2, 3);
        Assert.AreEqual(-1, result);
    }
}

[TestFixture]
public class MultiplicationTest
{
    [Test]
    public void Perform()
    {
        var operation = new Multiplication();
        double result = operation.Perform(2, 3);
        Assert.AreEqual(6, result);
    }
}

Devo mantenere i test con cui ho finito dopo il Passo 2 nonostante la loro bruttezza?

Avrei dovuto scartare i miei vecchi test unitari e scriverne di nuovi, invece di provare a ricucire quello che avevo? Il problema qui è che avrei scritto dei test per il codice che era già stato scritto (avevo già eseguito il passaggio 1) quindi non avrei avuto alcuna garanzia che i test non sarebbero passati indipendentemente dal fatto che il mio codice funzionasse o meno. Non sarei mai nello stato 'rosso' del ciclo 'red-green-refactor'.

Dovrei aver scritto nuovi test unitari prima di eseguire il passaggio 1? Ma all'epoca pensavo di fare la cosa giusta - ho sfruttato la suite di test esistente per assicurarmi che il mio codice funzionasse dopo il refactoring. Ecco a cosa servono i test!

Qual è il modo corretto di aderire ai metodi di TDD e ha il minimo rischio di invalidare i test?

modifica: La domanda collegata non è quasi la stessa cosa della mia . Non mi occupo di aggiornare i test quando apporto modifiche funzionali al codice. Il poster dell'altro quesito riguarda la quantità di lavoro che impiega per mantenere i test: la mia preoccupazione è assicurarsi che i test siano ben progettati e continuare a testare la cosa giusta. L'altra domanda è piuttosto laconica e generale, mentre apprezzerei l'aiuto con l'esempio specifico.

    
posta kamilk 16.10.2016 - 23:55
fonte

2 risposte

4

Un modo possibile per evitare di modificare contemporaneamente sia il codice di produzione che il codice di test è iniziare il refactoring apportando le modifiche interne all'oggetto in prova, mantenendo intatta l'interfaccia esterna.

Nell'esempio, il metodo di produzione CreateNextNumber riceve l'enum OperationType , una conoscenza che è presente anche nei test. Puoi iniziare creando un secondo metodo temporaneo entro NumberCreator , ad es. NewCreateNextNumber , che si aspetta un'istanza di IOperation anziché l'enum. Puoi iniziare a estrarre le implementazioni operative dal metodo originale CreateNextNumber in modo sicuro, rendendo ChangeNextNumber convertire l'enum OperationType nella rispettiva implementazione IOperation , quindi delegare a NewCreateNextNumber .

Pur facendo quanto sopra, i test continueranno a funzionare contro ChangeNextNumber , quindi sei ancora sotto la loro rete di sicurezza. Dopo aver completato il refactoring interno di NumberCreator , puoi spostare il metodo ChangeNextNumber , che ora delega solo a quello nuovo, al file di test. La conversione dall'enumerazione a ciascuna rispettiva istanza IOperation sarà quindi parte della logica di test, e potrai quindi refactificare questa logica completamente, se lo desideri, perché ora puoi cambiare i test in isolamento. In ogni caso, dopo aver spostato la vecchia firma nel file di test, puoi quindi rinominare NewCreateNextNumber con il nome corretto e farli utilizzare dai tuoi client.

    
risposta data 17.10.2016 - 00:40
fonte
0

Perché ti sei fermato dopo un solo refactoring? "Refactoring" significa apportare molte modifiche a piccoli passi. Quindi fermarsi dopo un singolo refactoring non ha senso. Se ritieni che il tuo codice sia brutto, continua a eseguire il refactoring del codice finché non ritieni che sia abbastanza buono.

Che cosa ti impedisce di refactoring il tuo codice in modo che diventi il codice che pensi di iniziare? Ora che lo sai, dovrebbe essere facile scriverlo.

    
risposta data 17.10.2016 - 08:15
fonte

Leggi altre domande sui tag