Как написать правильные модульные тесты для уже написанного кода.

Эти тесты также известны как характеристические тесты.

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

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

Как начать?

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

1. Что вы хотите протестировать?

Узнай утверждения. Создайте тестовый файл для своего класса и метод тестирования для функции, которую вы хотите протестировать.

Подсказка:
Если у нас есть следующий метод `applySomeLogic (): ReturnType`,
тест, который мы напишем, должен быть` testApplySomeLogic (): void`.

final class MyBusinessLogic
{
    private DependencyInterface dependencyInterface;
    private ConcreteDependency concrete;

    public function __construct(
        DependencyInterface dependencyInterface,
        ConcreteDependency concrete
    ) {
        this.dependencyInterface = dependencyInterface;
        this.concrete = concrete;
    }

    public function applySomeLogic(Input input): ReturnType
    {
        // black box responsible to create a ReturnType
        // based on the given Input
        return returnType;
    }
}
final class MyBusinessLogicTest extends TestCase
{
    public function testApplySomeLogic(): void
    {
        // I want to assert that "applying some logic"
        // from MyBusinessLogic with the given Input
        // I will receive a concrete ReturnType with a 
        // certain value as its property. Something like:
        returnType = myBusinessLogic.applySomeLogic(input);
        assertEquals('expected', returnType.getProperty());
    }
}

2. Создайте конкретный / последний класс, который вы хотите протестировать.

Не глумитесь над вашими конкретными классами. Особенно ваш бизнес-домен. Имитировать только интерфейсы. В противном случае вы можете непреднамеренно скрыть ошибки (с зеленым цветом / прохождение тестов!). Относитесь к классам предметной области своего бизнеса как к окончательным.

Либо смоделируйте интерфейс, либо создайте экземпляр анонимного класса, если вы хотите создать заглушку:

Заглушки предоставляют стандартные ответы на звонки, сделанные во время теста, обычно не отвечая ни на что, кроме того, что запрограммировано для теста.

final class MyBusinessLogicTest extends TestCase
{
    public function testApplySomeLogic(): void
    {
        myBusinessLogic = new MyBusinessLogic(
            this.createMock(DependencyInterface.class),
            new ConcreteDependency(/* ... */)
        );
        
        // OR
        
        myBusinessLogic = new MyBusinessLogic(
            new class implements DependencyInterface {
                public function getSomeValue(): string
                {
                    return 'A value for your test';
                }
            },
            new ConcreteDependency(/* ... */)
        );
        
        // ...
    }
}

3. Вызовите метод из этого класса, предоставив требуемый ввод.

Результат будет определяться начальным состоянием класса бизнес-логики, который мы хотим протестировать, ПЛЮС входные аргументы, которые мы используем.

input = new Input(/* ... */);
returnType = myBusinessLogic.applySomeLogic(input);

4. Подтвердите результат с ожидаемым значением.

Начиная с шага 1, вам нужно знать, чего вы хотите. Примените утверждение (я) сейчас.

final class MyBusinessLogicTest extends TestCase
{
    public function testApplySomeLogic(): void
    {
        myBusinessLogic = new MyBusinessLogic(
            this.createMock(DependencyInterface::class),
            new ConcreteDependency(/* ... */)
        );

        input = new Input(/* ... */);
        returnType = myBusinessLogic.applySomeLogic(input);
        assertEquals('expected', returnType.getProperty());
    }
}

5. Возможно, вы захотите установить разные ожидаемые значения.

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

final class MyBusinessLogicTest extends TestCase
{
    /** 
     * @dataProvider providerApplySomeLogic 
     */
    public function testApplySomeLogic(
        array concreteMapping,
        string argInput,
        string expectedValue
    ): void {
        myBusinessLogic = new MyBusinessLogic(
            this.createMock(DependencyInterface.class),
            new ConcreteDependency(concreteMapping),
            /* ... */
        );

        input = new Input(
            argInput
            /* ... */
        );

        actual = myBusinessLogic.applySomeLogic(input);
        assertEquals($expectedValue, actual.getProperty());
    }

    public function providerApplySomeLogic(): Generator
    {
        yield [
            'concreteMapping' => ['key' => 'value'],
            'argInput' => 'something',
            'expectedValue' => 'expected-value-A',
        ];

        yield [
            'concreteMapping' => ['key2' => 'value2'],
            'argInput' => 'something-else',
            'expectedValue' => 'expected-value-B',
        ];
    }
}

Наконец: очистите то, что вы сделали.

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

Например, вы можете применить рефакторинг метода извлечения, чтобы убрать детали реализации (создания различных объектов) и сохранить тот же уровень абстракции при чтении тестового кода.

myBusinessLogic = this.createBusinessLogic(concreteMapping);
input = this.createInput(argInput);
actual = myBusinessLogic.applySomeLogic(input);
assertEquals(expectedValue, actual.getProperty());

Конечно, все зависит от контекста. Есть ли смысл извлекать в частный метод createBusinessLogic() или даже createInput()? Что ж, решать тебе. Это зависит от количества строк и, что наиболее важно, от уровня абстракции, который принадлежит этому контексту.

Только помните: держите свои методы маленькими.

Теперь вы можете провести рефакторинг производственного кода, который вы покрыли тестами, не боясь его сломать.

«Устаревший код - это код без тестов»

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

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

При работе с кодом вам понадобится обратная связь. Автоматическая обратная связь - лучшее. Таким образом, это первое, что вам нужно сделать: написать тесты.

Сначала добавьте тесты, затем внесите изменения.

Измените как можно меньше кода, чтобы тесты соответствовали рецепту:

  1. Определите «точки изменения», чтобы разрушить зависимости вашего кода.
  2. Разорвать зависимости.
  3. Напишите тесты.
  4. Внесите свои изменения.
  5. Рефакторинг.

использованная литература