Немного веселья на серьезную тему правильного нейминга тестов и 100500 их типов и видов.

Определение юнит теста

Пару определений с англицкой вики:

Unit testing, a.k.a. component / module testing, is a form of software testing by which isolated source code is tested to validate expected behavior.

Integration testing, is a form of software testing in which multiple parts of a software system are tested as a group.

Если упростить, то:

  • юниты - изолированный тестинг исходного кода;

  • интеграционные - тестинг нескольких частей как единой группы;

Юнит тест для функции

Допустим у нас есть функция myFunction, которая принимает на вход два числа, а потом возвращает их сумму.
Её юнит тест может выглядеть таким образом:

public function testCool(int $a, int $b, int $c): void
{
    $this->assertEquals($c, myFunction($a, $b));
}

Юнит тест для класса

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

public function testCool(int $a, int $b, int $c): void
{
    $object = new MyClass($a, $b);
    $this->assertEquals($c, $object->calc());
}

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

Фрай - начал что-то подозревать
Фрай - начал что-то подозревать

Но благо соблюдается условие юнита isolated source code и не соблюдается условие интеграционного теста multiple parts of a software system (свойства класса, это всё же не части системы).
Фух, пока держимся.

Юнит тест для контроллера

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

public function testCool(int $a, int $b, int $c): void
{
    $request = new Request([
        'a' => $a,
        'b' => $b,
    ]);
    $response = (new MyController())->actionCalc($request);
    $this->assertEquals($c, $response->value);
}

Ну теперь, то уже точно не юнит. Ведь да?
Это теперь тест шрёдингера, т.к. юнит он или интеграционный зависит напрямую от содержимого контроллера:

  1. если внутри не используется БД (т.е. мы все решаем на уровне исходного кода) - это юнит;

  2. если внутри используется БД - это интеграционный;

Для обоих случаев - код теста никак не меняется. Вот это класс!

Сильвестр - оценил
Сильвестр - оценил

НО даже если мы используем БД внутри контроллера, мы можем её спокойно замокать, и наш интеграционный тест становиться юнитом ;-)

Юнит тест для одной страницы

Допустим у нас есть страница на сайте. Страница это же часть системы (сайта). Т.е. мы и для него можем написать юнит?
Ну давайте попробуем:

public function testCool(Tester $I, int $a, int $b, int $c): void
{
    $I->amOnPage('/tested-page');
    $I->submitForm('#my-form', [
        'a' => $a,
        'b' => $b,
    ]);
    $I->see('#result', $c);
}

Ну всё, фиаско. Какой же это юнит, это приемочный/системный тест, вон я захожу на страницу, заполняю форму и потом проверяю результат.
Ведь, да?
Неа :)

Это опять тест Шрёдингера:

  1. если класс Tester выполняет запрос к какому-то серверу, то это действительно приемочный;

  2. если класс Tester не выполняет никаких запросов, а все делает на уровне исходного кода, то он интеграционный;

На примере codeception все эта история решается на уровне конфига:

  1. если мы конфигурируем WebDriver, то вышеуказанный тест будет приемочным: будет отправляться запрос куда-то на физический сервер;

  2. если мы конфигурируем PhpBrowser, то вышеуказанный тест будет интеграционным (в нейминге CE "функциональный"): никакой запрос не будет отправляться, а просто окружение сэмитирует его и останется в рамках исходного кода;

И опять же, для обоих случаев - код теста никак не меняется.

Ольга - которая ничего не понимает
Ольга - которая ничего не понимает

Да, можно заметить что по итогу у нас не юнит, а интеграционный.
Но мы же помним, что если замокать все лишнее взаимодействие с другими и тестировать только изолированный код.
Сделать это можно через например DI/ServiceLocator:

public function testCool(Tester $I, int $a, int $b, int $c): void
{
    // вариант DI зависит от фреймворка ;)
    ServiceLocator::get()->set('my-service', $this->getMyServiceMock());
    
    $I->amOnPage('/tested-page');
    $I->submitForm('#my-form', [
        'a' => $a,
        'b' => $b,
    ]);
    $I->see('#result', $c);
}

Теперь юнит: тестируем изолированный кусок

Резюме

Было огромное желание написать примеры тестов для целого сайта (на компонентной диаграмме C4 его вполне можно назвать "частью" проекта), а также тест всего интернета.

Но опускаться в такой абсурд я уже не стал, т.к. сложнее было подвязывать условия юнитов :)

В чём собственно посыл этого чтива:

  1. во-первых, не грусти и улыбнись ;)

  2. во-вторых, забей уже как называются тесты: юниты, интеграционные, приемочные и т.д. Просто пиши ТЕСТЫ!!!

Комментарии (14)


  1. pes_loxmaty
    07.06.2024 10:42
    +4

    Дочитал только до контроллера, дальше желание пропало.

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

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


    1. rsashka
      07.06.2024 10:42

      Юнит-тест, это небольшой тест функциональности без необходимости запускать всю системы целиком.

      Делать внутри юнит-теста моки или нет, зависит от объема и локализации зависимостей. Например, если для тестов нужно поднимать сервер БД, тогда логично заменить его на мок, а если в проекте используется SQLLite, то в некоторых тестах его можно и не мокать.


      1. Kanut
        07.06.2024 10:42
        +4

        Речь о том что если вы для теста "используете SQLLite", то юнит-тест у вас уже не особо получится.

        Потому что у вас появляется дополнительная сущность которую вы тестируете. И если тест выдаёт ошибку, то вы не знаете является ли причиной ошибки ваш "функционал" или содержание вашей SQLLite.

        Если вы поднимаете сервер БД, то там уже всё немного сложнее. Если БД для каждого теста создаётся заново или каждый раз "ресетается" до одного и того же состояния, то это ещё можно считать юнит-тестом. Если нет, то у вас опять появляется дополнительная сущность.


        1. rsashka
          07.06.2024 10:42
          +1

          Если БД для каждого теста создаётся заново или каждый раз "ресетается" до одного и того же состояния, то это ещё можно считать юнит-тестом.

          Да, примерно это и имеется ввиду.

          Потому что у вас появляется дополнительная сущность которую вы тестируете. И если тест выдаёт ошибку, то вы не знаете является ли причиной ошибки ваш "функционал" или содержание вашей SQLLite.

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

          И в первом случае могут быть как моки вместо БД, так и непосредственное использование встроенной БД, так и оба варианта тестов одновременно (и с моками и без них). И какой тест запускать, это зависит от решаемой разработчиком задачи. Например, для регрессионного тестирования достаточно сразу запускать тесты со встроенной БД, т.к. требуется только подтверждение работоспособности, а не локализация ошибки.


          1. Kanut
            07.06.2024 10:42
            +1

            Конечно, название типов тестирования, это больше вопрос терминологии

            Называть вы это можете как хотите. Но весь смысл юнит-тестов в том чтобы проверять только какую-то небольшую изолированную часть функционала.

            И вы либо соблюдаете это правило, либо нет.

            которые может запустить разработчик на своем компьютере автономно

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


            1. rsashka
              07.06.2024 10:42

              Но от этого такие тесты не становятся юнит-тестами.

              А как тогда вы их называете?


              1. Kanut
                07.06.2024 10:42

                Тесты, в которых тестируется всё приложение целиком? "Functional tests" или "acceptance tests".


                1. rsashka
                  07.06.2024 10:42

                  Функциональные тесты, это подтверждение работы конкретной функциональности согласно ТЗ или беклогу.

                  Приемочное тестирование, это тестирование функциональность продукта методом «чёрного ящика» на соответствие заданным критериям.

                  Описанные вами тесты не соответствуют ни одному их этих определений.


                  1. Kanut
                    07.06.2024 10:42
                    +1

                    Функциональные тесты, это подтверждение работы конкретной функциональности согласно ТЗ или беклогу

                    Абсолютно верно. Пилится новая фича, добавляется в приложение и потом запускается всё приложение и проверяется как работает эта новая фича.

                    А когда готовится релиз новой версии, то запускается всё приложение и проверяется как работает всё приложение в целом. В том числе проверяется не сломалось ли что-то из "старого" функционала. Это у нас называется "acceptance tests". Но это у нас. В принципе это можно называть и как-то по другому.

                    При этом новая фича может состоять из кучи новых классов и методов. И юнит-тест это когда например берётся один единственный метод, изолируется и проверяется. И под "изолируется" я имею в виду что делается так, что от всех своих зависимостей он получает одни и те же данные при каждом запуске теста. И обычно это делается при помощи моков.

                    Описанные вами тесты не соответствуют ни одному их этих определений.

                    Какие "описаннве мною тесты" вы имеете в виду?


                    1. rsashka
                      07.06.2024 10:42

                      Какие "описаннве мною тесты" вы имеете в виду?

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

                      Я тоже пишу в том числе и для десктоп приложения и практически все тесты у меня на google test framework. И там есть тесты и с моками, а есть и без них. Просто уровень изоляции может быть разный. Для одного теста обязательно нужен mock (чтобы можно было локализовать ошибку), а для теста более высокого уровня mock может и не потребоваться.

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


                      1. Kanut
                        07.06.2024 10:42
                        +2

                        Просто уровень изоляции может быть разный.

                        У изоляции нет уровней. Она либо есть, либо её нет. У вас есть тест. Если при любом повторе этого теста "входные данные" для юнита не меняются, то он изолирован. Если они могут измениться, то не изолирован. И это вне зависимости от того используете вы моки или нет.

                        И если честно я не даже не знаю как их можно назвать, ведь ни к функциональным, ни к приемочным тестам их отнести не получится

                        Если вы одновременно тестируете два или более "юнитов", то на мой взгляд это уже интеграционные тесты.


                      1. rsashka
                        07.06.2024 10:42

                        У изоляции нет уровней

                        Я писал про уровень изоляции для иерархии классов приложения. Например при нисходящем тестировании (когда вызывается интерфейс высокого уровня, а реализация нижележащих классов постепенно заменяется с MOCK реальную).

                        Если вы одновременно тестируете два или более "юнитов", то на мой взгляд это уже интеграционные тесты.

                        GTF тесты можно запустить хоть все стразу, хоть по отдельности, и это точно не интеграционные тесты, так как все "юниты" должны быть изолированы. Хотя отдельные тесты действительно можно назвать интеграционными, если внутри используются не mock объекты, а другие модули.

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


  1. alexeystarchikov
    07.06.2024 10:42

    Здравый смысл есть, но все же идёт подмена понятий. Если замолкать вызовы то это юнит тест. Если вместо соков уже какая то реализация, то это не юнит. Есть ещё компонентные тесты, но тут сложно провести границу, где начнутся инновационные. Так что если вместо большого pgsql вы поставите SQLite, то юнит тестом это вы никак не можете назвать


  1. rpsv Автор
    07.06.2024 10:42

    Эх, видимо люди не то что "дальше контроллера перестали читать", но и самую первую строку тоже пропустили, раз такой холивар поднялся на тему "что такое юнит тест" :(

    Для душнил продублирую тут основной посыл статьи, который в конце как раз отражён:

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

    Задача тестов, проверить реализованный функционал. Если я могу написать интеграционный/системный/приемочный, причем это будет быстрее чем написать юнит со всей кучей моков, то почему я должен писать юнит вместо подходящего теста? Поднять стенд с базой вроде ни у кого проблем не должно вызывать, либо по полноценному окружению гонять ночные тесты.

    Дочитал только до контроллера, дальше желание пропало.

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

    @pes_loxmaty а тест контроллера почему не удовлетворяет вашим требованиям? Тестируем конкретную функциональность, изолированно, отдельно от всего проекта aka в сферическом вакууме. Не вижу тут проблем. Или проблема в нейминге "контроллер" ?

    Функциональные тесты, это подтверждение работы конкретной функциональности согласно ТЗ или беклогу.

    Приемочное тестирование, это тестирование функциональность продукта методом «чёрного ящика» на соответствие заданным критериям.

    @rsashka А функциональные тесты разве не могут тестировать функциональность на соответствие заданным критериям методом черного ящика? Из ваших определений следует что и функциональные и приемочные тесты могут внутри содержать абсолютно идентичные конструкции :)

    Кстати вопрос, а чего такое тогда системные тесты? ;)

    В том числе проверяется не сломалось ли что-то из "старого" функционала. Это у нас называется "acceptance tests". Но это у нас. В принципе это можно называть и как-то по другому.

    @Kanutа разве проверка "что ничего не сломалось из старого" не называется регрессионными тестами? Что ж такое, опять какая-то путаница в этих определениях :o

    На всякий случай оставлю тут эту картинку. Если кто-то захочет поискать другие подобные изображения, то уверяю они будут отличаться :)