TDD: нарушает все существующие тестовые примеры при рефакторинге кода.

Я начал следовать TDD в своем проекте. Но с тех пор, как я начал, даже после прочтения некоторых статей, я сбит с толку, поскольку разработка замедлилась. Всякий раз, когда я реорганизую свой код, мне нужно изменить существующие тестовые примеры, которые я написал ранее, потому что в противном случае они начнут давать сбой.

Ниже приведен пример класса, который я недавно реорганизовал:

public class SalaryManager
{
    public string CalculateSalaryAndSendMessage(int daysWorked, int monthlySalary)
    {
        int salary = 0, tempSalary = 0;
        if (daysWorked < 15)
        {
            tempSalary = (monthlySalary / 30) * daysWorked;
            salary = tempSalary - 0.1 * tempSalary;
        }
        else
        {
            tempSalary = (monthlySalary / 30) * daysWorked;
            salary = tempSalary + 0.1 * tempSalary;
        }

        string message = string.Empty;
        if (salary < (monthlySalary / 30))
        {
            message = "Salary cannot be generated. It should be greater than 1 day salary.";
        }
        else
        {
            message = "Salary generated as per the policy.";
        }

        return message;
    }
}

Но теперь я много чего делаю в одном методе, поэтому, чтобы следовать принципу единой ответственности (SRP), я реорганизовал его на что-то вроде ниже:


public class SalaryManager
{
    private readonly ISalaryCalculator _salaryCalculator;        
    private readonly SalaryMessageFormatter _messageFormatter;
    public SalaryManager(ISalaryCalculator salaryCalculator, ISalaryMessageFormatter _messageFormatter){
        _salaryCalculator = salaryCalculator;
        _messageFormatter = messageFormatter;
    }

    public string CalculateSalaryAndSendMessage(int daysWorked, int monthlySalary)
    {
        int salary = _salaryCalculator.CalculateSalary(daysWorked, monthlySalary);
        string message = _messageFormatter.FormatSalaryCalculationMessage(salary);

        return message;
    }
}

public class SalaryCalculator
{
    public int CalculateSalary(int daysWorked, int monthlySalary)
    {
        int salary = 0, tempSalary = 0;
        if (daysWorked < 15)
        {
            tempSalary = (monthlySalary / 30) * daysWorked;
            salary = tempSalary - 0.1 * tempSalary;
        }
        else
        {
            tempSalary = (monthlySalary / 30) * daysWorked;
            salary = tempSalary + 0.1 * tempSalary;
        }
        return salary;
    }
}

public class SalaryMessageFormatter
{
    public string FormatSalaryCalculationMessage(int salary)
    {
        string message = string.Empty;
        if (salary < (monthlySalary / 30))
        {
            message = "Salary cannot be generated. It should be greater than 1 day salary.";
        }
        else
        {
            message = "Salary generated as per the policy.";
        }
        return message;
    }
}

Возможно, это не лучший из примеров. Но главное в том, что как только я провел рефакторинг, мои существующие тестовые примеры, которые я написал для SalaryManager, начали давать сбои, и мне пришлось исправить их с помощью имитации.

Это происходит постоянно в сценариях времени чтения, и вместе с этим увеличивается время разработки. Я не уверен, правильно ли я делаю TDD. Пожалуйста, помогите мне понять.


person Saurabh Bhasin    schedule 18.03.2018    source источник
comment
Каков пример теста, который прошел бы в первом случае и не прошел бы во втором случае? Рефакторинг выглядит так, как будто это должно быть нормально на концептуальном уровне, хотя код на самом деле не компилируется.   -  person Mike Zboray    schedule 19.03.2018
comment
CalculateSalaryAndSendMessage принимает два целых числа и возвращает string. У вас нет других зависимостей, поэтому ваши тесты должны соответствовать любому рефакторингу. Единственное возможное изменение, которое я вижу - добавьте новые аргументы для конструктора SalaryManager. Вам даже не нужно писать макет - пройдите реальные реализации, и тесты будут работать.   -  person Fabio    schedule 19.03.2018
comment
@Fabio: Я изменил код, чтобы представить зависимости и сделать свой вопрос более понятным. Теперь не могли бы вы помочь мне с тремя вопросами: 1) Как только я перенесу код в SalaryCalculator, мне нужно будет делать насмешку, верно? 2) После насмешки тестовые примеры расчета зарплаты не будут актуальны для менеджера по зарплате, поскольку логика больше не существует в классе SalaryManager 3) Нужно ли мне перемещать тестовые примеры, поскольку тесты расчета зарплаты становятся более актуальными для SalaryCalculator?   -  person Saurabh Bhasin    schedule 19.03.2018
comment
2. Не надо издеваться над SalarayCalculator - передать SalaryManager в тестах. Имитируйте только зависимости, которые замедляют ваши тесты (чтение / запись файлов, баз данных, веб-сервисов или других внешних ресурсов). 3. - без имитации ваши тесты останутся прежними, и вы сможете рефакторировать SalaryManager класс, не касаясь тестов.   -  person Fabio    schedule 19.03.2018


Ответы (3)


Всякий раз, когда я реорганизую свой код, мне нужно изменить существующие тестовые примеры, которые я написал ранее, потому что они начнут давать сбой.

Это определенно показатель того, что что-то идет не так. Популярное определение рефакторинга звучит примерно так: это

РЕФАКТОРИЯ - это дисциплинированная методика реструктуризации существующего тела кода, изменения его внутренней структуры без изменения его внешнего поведения.

Частично суть модульных тестов заключается в том, что модульные тесты оценивают внешнее поведение вашей реализации. Неудачный модульный тест указывает на то, что изменение реализации каким-то образом изменило внешне наблюдаемое поведение.

В этом конкретном случае похоже, что вы изменили свой API - в частности, вы удалили конструктор по умолчанию, который был частью API для создания экземпляров SalaryManager; это не «рефакторинг», это изменение в обратном направлении.

Нет ничего плохого в том, чтобы вводить новых соавторов во время рефакторинга, но вы должны делать это так, чтобы не нарушать текущий контракт API.

public class SalaryManager
{
    public SalaryManager(ISalaryCalculator salaryCalculator, ISalaryMessageFormatter _messageFormatter){
        _salaryCalculator = salaryCalculator;
        _messageFormatter = messageFormatter;
    }

    public SalaryManager() {
        this(new SalaryCalculator(), new SalaryMessageFormatter())
    }

где SalaryCalculator и SalaryMessageFormatter должны быть реализациями, которые производят то же наблюдаемое поведение, что и у вас изначально.

Конечно, бывают случаи, когда нам нужно провести обратное изменение. Однако «рефакторинг» не подходит для этого случая. Во многих случаях вы можете достичь желаемого результата в несколько этапов: сначала расширение вашего API новыми тестами (рефакторинг для удаления дублирования с существующей реализацией), затем удаление тестов, оценивающих старый API, и, наконец, удаление старого API.

person VoiceOfUnreason    schedule 19.03.2018

Эта проблема возникает, когда рефакторинг изменяет обязанности существующих модулей, особенно за счет введения новых или удаления существующих модулей.

Вы можете сделать это в стиле TDD, но вам необходимо:

  1. делать небольшие шаги (это исключает изменения, которые извлекают оба класса одновременно)
  2. рефакторинг (сюда входит и тестовый код рефакторинга!)

Отправная точка

В вашем случае у вас есть (я использую более абстрактный синтаксис, похожий на Python, чтобы иметь меньше шаблонов, эта проблема не зависит от языка):

class SalaryManager:
    def CalculateSalaryAndSendMessage(daysWorked, monthlySalary):
      // code that has two responsibilities calculation and formatting

У вас есть тестовый класс для этого. Если у вас нет тестов, вам нужно сначала создать эти тесты (здесь вы можете найти Эффективная работа с устаревшим кодом действительно полезна) или во многих случаях вместе с некоторым рефакторингом, чтобы иметь возможность еще больше реорганизовать ваш код (рефакторинг - это изменение структуры кода без изменения его функциональности, поэтому вам необходимо пройти тестирование чтобы убедиться, что вы не измените функциональность).

class SalaryManagerTest:
    def test_calculation_1():
      // some test for calculation

    def test_calculation_2():
      // another test for calculation

    def test_formatting_1():
      // some test for formatting

    def test_formatting_2():
      // another test for calculation

    def test_that_checks_both_formatting_and_calculation():
      // some test for both

Извлечение расчета в класс

Теперь давайте вам, что вывести за расчет ответственности за класс.

Вы можете сделать это сразу, не меняя API SalaryManager. В классическом TDD вы делаете это маленькими шагами и запускаете тесты после каждого шага, примерно так:

  1. извлечь вычисление в функцию (скажем, calculateSalary) от SalaryManager
  2. создать пустой SalaryCalculator класс
  3. создать экземпляр класса SalaryCalculator в SalaryManager
  4. переместить calculateSalary в SalaryCalculator

Иногда (если SalaryCalculator просто и его взаимодействие с SalaryManager просто) вы можете остановиться на этом и вообще не менять тесты. Так что тесты для расчетов по-прежнему будут частью SalaryManager. С увеличением сложности SalaryCalculator будет сложно / непрактично тестировать его через SalaryManager, поэтому вам нужно будет выполнить второй шаг - также тесты рефакторинга.

Рефакторинг тестов

Я бы сделал что-то вроде этого:

  1. разделить SalaryManagerTest на SalaryManagerTest и SalaryCalculatorTest в основном путем копирования класса
  2. удалить test_calculation_1 и test_calculation_1 из SalaryManagerTest
  3. оставьте только test_calculation_1 и test_calculation_1 в SalaryCalculatorTest

Теперь тестирует SalaryCalculatorTest тестовую функциональность для расчета, но делайте это через SalaryManager. Вам нужно сделать две вещи:

  1. убедитесь, что у вас есть интеграционный тест, который проверяет, что расчет вообще происходит
  2. измените SalaryCalculatorTest, чтобы он не использовал SalaryManager

Интеграционный тест

  1. Если у вас еще нет такого теста (test_that_checks_both_formatting_and_calculation может быть таким тестом), создайте тест, который выполняет некоторые простые варианты использования, когда вычисление задействовано из SalaryManager
  2. Вы можете переместить этот тест на SalaryManagerIntegrationTest, если хотите

Сделать SalaryCalculatorTest использовать SalaryCalculator

Тесты в SalaryCalculatorTest - это все о расчетах, поэтому даже если они имеют дело с менеджером, их суть и важная часть - это ввод данных для расчета, а затем проверка его результата.

Теперь наша цель - провести рефакторинг тестов таким образом, чтобы можно было легко переключить менеджер на калькулятор.

Тест на расчет может выглядеть так:

class SalaryCalculatorTest:

    def test_short_period_calculation(self):
       manager = new SalaryManager()
       DAYS_WORKED = 1
       result = manager.CalculateSalaryAndSendMessage(DAYS_WORKED, SALARY)
       assertEquals(result.contains('Salary cannot be generated'), True)

Здесь есть три вещи:

  1. подготовка объектов к испытаниям
  2. призыв к действию
  3. проверка результата

Обратите внимание, что такая проверка каким-то образом проверяет результат расчета. Это может сбивать с толку и хрупко, но каким-то образом это поможет. Поскольку должен быть какой-то внешне видимый способ отличить, как закончился расчет. В противном случае (если это не дает видимого эффекта) такой расчет не имеет смысла.

Вы можете выполнить рефакторинг так:

  1. извлеките создание manager в функцию createCalculator (можно называть это так, поскольку объект, который создается с точки зрения тестирования, является калькулятором)
  2. переименовать manager -> sut (тестируемая система)
  3. извлечь manager.CalculateSalaryAndSendMessage вызов в функцию `вычислить (калькулятор, дни, зарплата)
  4. извлеките чек в функцию assertPeriodIsTooShort(result)

Теперь тест не имеет прямого отношения к менеджеру, он отражает суть тестируемого.

Такой рефакторинг должен выполняться со всеми тестами и функциями в этом тестовом классе. Не упустите возможность повторно использовать некоторые из них, например createCalculator.

Теперь вы можете изменить, какой объект создается в createCalculator и какой объект ожидается (и как выполняется проверка) в assertPeriodIsTooShort. Уловка здесь в том, чтобы по-прежнему контролировать размер этого изменения. Если он слишком большой (то есть вы не можете сделать тест зеленым после изменения в течение пары минут в классическом TDD), вам может потребоваться создать копию createCalculator и assert... и использовать их сначала только в одном тесте, а затем постепенно заменять старые с одним в других тестах.

person Roman Konoval    schedule 24.03.2018
comment
Спасибо, Роман, за то, что ты объяснил это методом TDD. Думаю, я делаю это так же, как вы объяснили. Единственное, что я чувствую, это то, что модульные тесты слишком хрупкие по своей природе, и рефакторинг также требует рефакторинга тестов в сложных случаях, что отнимает больше времени. Я сейчас руковожу командой, и разные люди работают по-разному, делая эту практику единообразной. - person Saurabh Bhasin; 02.06.2019

Ваше замешательство вызвано общим неправильным пониманием модульного тестирования. В какой-то момент люди начали распространять миф о том, что единица - это что-то вроде одного класса или даже одного метода. Этому способствовала соответствующая литература, статьи и сообщения в блогах, в которых показано, как это делается с очень маленькими изолированными классами. Было упущено из виду, что причиной этого был тот факт, что вы не можете представить процесс разработки в книге или статье с реальным приложением, поскольку оно было бы слишком большим для формата.

Это правда, что модульный тест нужно изолировать. Это не означает, что агрегат необходимо полностью изолировать. Сам тест нужно изолировать от других тестов. Это означает, что тесты должны зависеть друг от друга, и должна быть возможность запускать их отдельно или даже параллельно.

Соответствующий модуль для модульного теста должен быть прецедентом. Вариант использования - это особенность вашего приложения. В движке блога создание сообщения в блоге - это вариант использования. В системе бронирования отелей найти свободный номер - это один из вариантов использования. Обычно вы найдете для них точку входа в так называемые службы приложений. Это та степень детализации, к которой вы должны стремиться. Избавьтесь от уродливых внешних зависимостей, таких как базы данных, файловые системы и внешние службы. Если вы реорганизуете внутреннюю структуру вашего приложения, тест не сломается, потому что вы не измените поведение варианта использования. Хотите разделить или объединить классы в своем домене? Вариант использования будет стабильным. Измените способ взаимодействия объектов домена друг с другом. Тест остается зеленым.

person EricSchaefer    schedule 07.03.2021