Разработчик базового функционала заложил только $id. Атрибут $ref заложил разработчик плагина 1, атрибут $email — разработчик плагина 2. Разработчики друг с другом не знакомы.
Я не могу вам предложить конкретное решение вашей проблемы потому что не знаю особенностей архитектуры вашего приложение и не знаю на что вы можете влиять. Судя по тому что вы предлагаете в статье, вы можете влиять на код всех 3-х компонентов: «базовый функционал», «плагина 1» и «плагина 2».
Самое простое решение, если вы не можете влиять на код плагинов, это добавить в базовый функционал атрибут $ref из плагина 1 и атрибут $email из плагина 2. Базовый класс будет описывать реальную сущность из БД, а сущности из плагинов будут ориентироваться на свой интерфейсом как и раньше, а дополнительные атрибуты из другого плагина будут просто игнорировать.
Вот. С этого и стоило начинать. Это уже нормальное описание проблемы. Его и нужно было приводить в статье. Тогда и вопросов было бы меньше.
Пример решения с использованием декораторов и включенным strict mode:
Структура классов
declare(strict_types=1);
interface CustomerInterface
{
public function getId(): int;
public function setId(int $id);
}
interface CustomerPluginInterface extends CustomerInterface
{
public function getCustomer();
}
// This is base object.
class Customer implements CustomerInterface
{
private $id;
private $ref;
private $email;
public function getId(): int
{
return $this->id;
}
public function setId(int $id)
{
$this->id = $id;
return $this;
}
public function getRef(): string
{
return $this->ref;
}
public function setRef(string $ref)
{
$this->ref = $ref;
return $this;
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email)
{
$this->email = $email;
return $this;
}
}
// This is extended customer (plugin 1).
class CustomerRef implements CustomerPluginInterface
{
private $customer;
public function __construct(Customer $customer)
{
$this->customer = $customer;
}
public function getId(): int
{
return $this->customer->getId();
}
public function setId(int $id)
{
$this->customer->setId($id);
return $this;
}
public function getRef(): string
{
return $this->customer->getRef();
}
public function setRef(string $ref)
{
$this->customer->setRef($ref);
return $this;
}
public function getCustomer()
{
return $this->customer;
}
}
// This is extended customer (plugin 2).
class CustomerEmail implements CustomerPluginInterface
{
private $customer;
public function __construct(Customer $customer)
{
$this->customer = $customer;
}
public function getId(): int
{
return $this->customer->getId();
}
public function setId(int $id)
{
$this->customer->setId($id);
return $this;
}
public function getEmail(): string
{
return $this->customer->getEmail();
}
public function setEmail(string $email)
{
$this->customer->setEmail($email);
return $this;
}
public function getCustomer()
{
return $this->customer;
}
}
под рукой нет php7 чтобы проверить, но должно работать
исполняемый код полностью копирует ваш:
$base = $repo->load('Customer', 21);
// plugin1 code on event 1
$cust1 = new CustomerRef($base);
$id1 = $cust1->getId();
$ref = $cust1->getRef();
// plugin2 code on event 2
$cust2 = new CustomerEmail($base);
$id2 = $cust2->getId();
$cust2->setEmail('any@email.com');
// data saver (base impl.)
$repo->save('Customer', $cust2->getCustomer()); // <-- различие только здесь
// но можно и так, ибо изменив $cust2 мы изменили $base
//$repo->save('Customer', $base);
С использованием DataObject нельзя контролировать тип хранящихся в нем данных и список самих данных. Например что будет при выполнении следующего кода? Вы будете в методе save() проверять список доступных полей на запись или будете писать данные как есть в БД и получать fatal error из-за отсутствия соответствующей колонки в таблице?
Ну вот! Так это то, о чем я и писал!!! Делая класс на базе DataObject вы делаете его:
а) типизируемым;
б) дополняемым;
Для этого создается обычный класс. DataObject здесь не нужен. Он не решает никаких проблем, а только создает их
Проблема в том, что в некоторых случаях множественное наследование не работает. Например, когда два-три расширения переопределяют один и тот же класс основного функционала.
Множественное наследование и не должно работать. В вашем случае нужно использовать Адаптор. Использование DataObject в описанном вами случае больше похоже на костыль
Так я и думал. Модель это не только данные, но еще и бизнес логика. Бизнес логика должна находится как можно ближе к данным. Именно по этому (еще из-за производительности) некоторые размещают бизнес логику в тригерах и процедурах БД, рядом с хранимыми данными. И именно по этому класс модели должен быть типизирован и хранить данные и бизнес логику одной конкретной сущности. Смотрите тот же ActiveRecord.
Если же нужен контейнер для временного хранения данных, то, как и говорили michael_vostrikov и lair, достаточно обычного массива
Когда вы говорите «мухи», «котлеты», у меня создается ощущение что вы не слышали про MVC.
По поводу вашего DataObject. Я подобное создавал в первый год изучения прогркммирования и очень бытсро отказался от этого решения. Основная проблема это отсутствие типизации и жесткой структуры объектов и с автодополнением в те годы были проблемы.
У меня вопрос. Правильно ли я понимаю что при использовании HTTPS не будет работать HTTP кэширование? Это вполне ожидаемо в случае HTTPS, но вызывает дополнительную нагрузку на сервер и канал. Мало того что придется шифровать запрос/ответ, но еще и контент придется отдавать на каждый запрос. Всю статику придется тянуть каждый раз.
Получается мы повышаем безопастность сайта в ущерб производительности. По моему это не совсем правильно. Поправьте меня если я ошибаюсь.
Этот вопрос разбирался в статье: «Согласно TDD, тесты предназначены для проверки функционала (feature), а не путей исполнения программы.». Не считайте пути исполнения программы (или ветки развития, как вы их называете), считайте сколько тестов вам нужно для тестирования конкретного функционала.
Хорошо. Тогда объясните что вы имеете в виду под словом функционал (feature)? Тестирование только особенностей тестируемого метода, без привязки к зависимостям? Если да, то тестирование получается не полным как и в случае с модульным. И мы опять возвращаемся к проблеме с Петей и John, ибо неполный тест может не отлавливать ситуации при которых Петя == John.
Что-то я ничего не понял. По-вашему, тесты к тестированию не имеют отношения? Что вы вообще хотите спросить\сказать?
Если что, речь шла про тесты в рамках TDD.
я говорил вот об этом
Доказательство:
Модульное тестирование, в отличие от интеграционного, вынуждает программистов инжектировать зависимости через конструктор или свойства. А если использовать интеграционное тестирование вместо модульного, то джуниор может зависимости прямо в коде класса инстанцировать.
Если повсеместно использовать DI то этой проблемы не будет и не будет разницы какие тесты вы пишете, интеграционные или модульные. Это организация процесса разработки и архитектуры приложения, ну и обучения джунов. Этот вопрос не имеет прямого отношения к тестированию и TDD.
Есть у вас в программе Петя и John, и функция isEqual, которая их различает. И вот в один прекрасный день вы изменяете isEqual и тест на неё, и теперь она их не различает. А ещё есть тест на то, что жена Пети не пускает в три часа ночи незнакомых пьяных мужчин. И вот после изменения isEqual этот тест краснеет. Вы ругаете интеграционное тестирование, и удаляете этот тест, ведь «сделанные изменения ничего не ломают и ни как не влияют на корректность результата». Примерно через год после этого приходит Петя и слёзно спрашивает вас, почему его жена пускает по ночам пьяных незнакомых мужчин, и почему в его семье родился негр, хотя ни у него в роду, ни у его жены негров отродясь не было.
Об этом я говорил ниже. Интеграционные тесты позволяют тестировать контекст в котором вызывается тестируемая функция, но это не всегда нужно. Например у нас есть функция А которая проверяет какое-то условие и есть функция Б которая использует ее и с ее помощью определяет нужно ли запустить подпрограмму С. И вот мы решили изменить условия в функции А. Мы все также знаем чту функция А может возвращать true|false в разных условиях, это мы проверили через тесты. Это значит что функция Б все также будет запускать подпрограмму С, но уже в других ситуациях и это нормально. Так и должно быть.
Можно написать интеграционный тест который будет гарантировать мне что в определенных условиях функция А вернет true и функция Б соответственно запустит подпрограмму С. То есть через интеграционные тесты можно задать жесткое поведение программы. Описать все варианты развития сценария. Шаг в право, шаг в лево — расстрел. Любое изменение в коде означает изменение десятка, а то и сотни тестов.
С модульными тестами у нас больше свободы, писать их быстрее и проще, тестов меньше и выполняются они быстрее, поддерживать их в зеленом состоянии проще и меньше затрат ресурсов.
Я не спорю, интеграционные тесты это хорошо и они нужны, но они требуют столько ресурсов что выгода может не окупится. Я не говорю что их не надо писать, я говорю что нужно начать с малого, с модульных тестов. И нужно оценивать свои ресурсы. Многие компании не готовы выделить время и деньги на написание тестов вместо того что бы писать новый функционал и приносить в компании больше денег. Есть такие которые готовы потерять N $ например на уязвимости в проекте и заработать на новом функционале N^3 $.
Если DI и IOC не имеют никакого отношения к проектированию, то я — испанский лётчик.
к тестированию не имеют отношения, к тестированию
Код, реализующий функционал А, использует функционал Б. Код, реализующий функционал Б, использует функционал В. Соответственно вы можете написать 6 модульных тестов на А и Б, по 3 на штуку, и точно также можете вместо них написать 6 интеграционных тестов на А и Б.
да, только здесь умножение, а не сложение:
Функция А имеет 3 ветки развития алгоритма.
Функция А использует функцию Б.
Функция Б имеет 3 ветки развития алгоритма.
Соответственно для каждой ветки развития в функции А есть по 3 ветки развития из функции Б.
3 * 3
Функция Б использует функцию В.
Функция В имеет 3 ветки развития алгоритма.
Соответственно для каждой ветки развития в функции Б есть по 3 ветки развития из функции В.
3 * 3
Соответственно для каждой ветки развития в функции А есть по 3 ветки развития из функции Б и для каждой из них есть по 3 ветки развития из функции В.
3 * 3 * 3
Итог: 3 * 3 * 3 = 27 веток развития алгоритма и как результат 27 вариантов результат и как результат 27 тестов функции А.
Естественно это идеализированный пример ибо не в каждой ветке развития алгоритма будет использоваться вложенная функция, но общая мысль должна быть понятна.
А если серьезно по теме. Я не против интеграционного тестирования, хотя на мой взгляд интеграционное тестирование имеет больше минусов чем плюсов по сравнению с модульным. Недавно обсуждались проблемы black-box testing и преимущества white-box testing.
Высказывание 1. Интеграционные тесты в меньшей степени помогают в поиске ошибок, нежели юнит-тесты
Тут довольно спорный вопрос больше или меньше.
Например, если меняется внутренняя реализация метода isEqual возвращающая в некоторых случаях false там где возвращала true, то у нас поломается только 1 юнит-тест и нужно будет дополнить его в связи с изменениями. В случае же интеграционного тестирования у нас может половина тестов покраснеть, хотя реально сделанные изменения ничего не ломают и ни как не влияют на корректность результата.
Из ваших же примеров. Добавление колонки в результат означает изменение теста измененного метода, изменение методов которые используют измененный метод, изменение тестов которые используют измененный метод т.д. В не зависимости от метода тестирования придется менять все. В случае с интеграционными тестами тесты которые нужно менять сразу покраснеют. В случае с юнит-тестами эти тесты придется искать через поиск, но это не проблема (Ctrl+F и название метода/класса), а поскольку тестов в случае юнит-тестирования в разы меньше то и результатов в поиске будет совсем чуть-чуть и тесты будут исправлены быстрее чем в случае интеграционного тестирования.
Второй приведенный вам пример это изменение стата в игре. Стат это как константа, параметры, условие. Его изменение не должно влиять на работу программы. В случае юнит-тестирования нам нужно будет изменить только тест который тестирует конкретный метод, а в случае интеграционного тестирования придется еще изменять все тесты где используется этот метод что увеличивает объем работы.
Высказывание 2. Интеграционные тесты в меньшей степени помогают в проектировании, нежели модульные
Это вообще не относится к проектированию и тестированию. Просто везде используется DI и все. А как вы будите тестировать класс с зависимостями это уже другая история.
Высказывание 3. Чтобы покрыть тестами один и тот же функционал, интеграционных тестов потребуется гораздо больше, чем модульных
Просто пишите интеграционные тесты также, как вы писали бы модульные тесты, но не мокируйте классы, реализованные в вашей программе, и вы не столкнётесь с проблемой экспоненциального увеличения количества тестов.
Честно говоря я не понял как при интеграционном тестировании мы не столкнемся с экспоненциальным увеличением количества тестов. Ведь мы же используем реальные зависимости, а не моки, соответственно мы должны протестировать не только функциональность тестируемого метода, но и функциональность используемых зависимостей (в этом же весь смысл), а наши зависимости могут иметь свои зависимости, которые тоже могут иметь зависимости и т.д. Вот уже и получаем экспоненциальное увеличение. Это известная проблема black-box testing.
Простая математика:
для тестирования метода нужно написать 5 тестов
метод имеет 2 зависимости и для тестирования каждой из них нужно еще по 5 тестов
первая из зависимостей имеет тоже 2 зависимости и для тестирования каждой из них нужно еще 5
Итог: 5 * 5 * 5 * 5 * 5 = 3125 тестов для того что бы покрыть 1 единственный метод против 5 тестов в случае юнит-тестирования. 3125 тестов Карл.
И это еще простой пример с малым количеством зависимостей. Подсчитал тут интереса ради для одного своего реального метода и получил примерно 15504 теста против 57 юнит-теста.
Ну и есть же такое понятие как интерфейсное программирование которое очень хорошо укладывается в DI. Суть зависимостей в том что бы они для нас стали черным ящиком и мы ориентировались на их интерфейс, а не на реализацию и при замене зависимости наша программа продолжала работать также. И вот тут очень хорошо укладывается модульное тестирование.
Мой вывод: Единственный плюс который дает интеграционное тестирование это тестирование работы методов в контексте их использования. Это безусловно очень важный плюс, который не получит при использовании модульного тестирования, но я бы не стал зацикливаться на интеграционном тестировании только из-за него ибо для меня минусы перевешивают этот плюс.
Ну да. Понятно что в данном случае мы можем мокнуть наши сервисы, написать юнит-тест и применить test-first. Мне было интересно как применять black-box тестирование в данном случае. Получается нужно делать какие-то хуки на уровне окружения. Проверять лог отправки. В случае парсинга RSS и внешнего API подменять хост в hosts и дописывать дополнительный обработчик который будет отправлять/сохранять тестовые данные для подмененных внешних сервисов. Запускать придется в песочнице. А в случае запроса к сервисам по IP придется еще заморачиваться с перенаправлением трафика.
Та еще развлекуха.
На тему test-first. Я согласен что во многих случаях написать тест до реализации не проблема. Тут я не спорю. Я просто приведу пример простого на вид кода и его теста, возможно на их примере будет понятно что написать тест до реализации может быть не так уж и просто.
Вот кстати я не до конца понимаю этот момент. Если мы мочим реализацию public методов зависимостей, которые потом использует
тест, это считается black box или нет? С одной стороны мы предполагаем что должно происходить внутри метода, с другой стороны нас это не интересует, так как нам важно получить нужный assert и не важно как метод это будет делать.
1. Не разумно выносить в функцию? Вам необходимо вводить отдельный класс, обязанностью которого будет осуществление рассылки и подключать его как зависимость.
А почему мне именно «необходимо» вводить отдельный класс. Если при рассылке делается что-то больше чем проход по циклу и передача каждого значения функции отправки, то да, скорей всего понадобится вводить еще один уровень абстракции.
Если ваша функция называется «Получить всех пользователей И отправить им сообщение», то нужно разделять, а если функция называется «Отправить все пользователям сообщение» то разносить нечего. Я согласен что здесь можно, а иногда и нужно, вводить еще один уровень абстракции, но почему вы считаете что это единственно верный способ? Так ли необходимо создавать еще один класс из-за этих 3 строчек:
foreach ($this->rep->getUserEmails() as $email) {
$this->mailer->send($email, $message);
}
2. Не единственное: тесты тоже будут использовать этот метод. Это также аргумент в сторону тех кто не хочет создавать интерфейсы для только одной реализации. Stubs тоже будут использовать этот интерфейс.
Тесты будут использовать конечный метод. По сути нет разницы будешь ты тестировать получение списка email и рассылку сообщений по отдельности или в рамках одного метода. Скажу даже больше, код теста в обоих случаях будет схожий.
Если вы пишите тесты перед реализацией то реализация представляет собой чистейший черный ящик, потому что реализации ещё нет.
Тут вы не правы. Тестирование по принципу black-box означает тестирование реакции на воздействие абстрагированное от внутренней реализации. То есть мы тестируем только выходные значения для заданных входных.
В случае же test-first, мы действительно не знаем ничего о внутренней реализации, потому что ее еще нет, но мы можем управлять внутренней реализацией. Мокнуть сервис, проверить передаваемые ему параметры.
Говоря другими словами, в случае black-box если тестируемая функция записывает что то в БД, то мы должны проверить наличие этой записи в бд, а в случае test-first мы должны проверить что сервису БД были переданные данные на запись.
Тут, как минимум, две отдельные функции:
— рассылка письма по списку/массиву/коллекции/итератору/… адресов
— получение списка/массива/коллекции/итератора/… адресов
В общем случае функция реализуется в 4 строчки. Выносит рассылку в отдельную функцию не совсем разумно, особенно если это единственное место в проекте где есть рассылка. Ну а даже если разносить, мы всегда можем подняться на уровень выше и встретить там все тоже самое. Например в контроллере приходит запрос от пользователя и далее:
мы валидируем данные от пользователя;
создает сообщение на основе данные от пользователя;
получаем список получаетелей сообщения;
отправляем каждому получателю сообщение;
отдаем пользователю результат отправки.
Я привожу очень условный пример. Понятно что если адресов 100500, то это как минимум cli команда, а в идеале еще и очередь используется что-то типа RabbitMQ, но суть то не в этом. Суть в том что функции высокого уровня абстракции может быть очень сложно протестировать по принципу black-box, а порой и вовсе невозможно.
Как это не можем точно знать? Зачем мы их вводим, если не знаем как будем использовать?
Пример я привел. Мы точно знаем что нам необходимо получить данные из бд, то есть будет использоваться драйвер бд (например EntityManager из Doctrine), но мы не знаем какой из методов получения будет использоваться и как. В некоторых случаях мы можем точно знать как будет использоваться зависимость, но не во всех.
Мне всё время было интересно как это, тестирование по принципу черного ящика. Везде пишут простые примеры сложения и перемножения переменых, а как на счёт чего-то действительно сложного, например:
Рассылка письма из параметров функции по базе из 100500 email адресов;
Парсинг стороннего RSS и сохранение новостей в бд;
Push данных в стороннее API.
В случае например с email рассылкой. Понятно что функция использует какой-то драйвер БД и для отправки сообщения конкретному получателю используется какой-то сервис. Но если мы тестируем по принципу чёрного ящика, то мы ничего не знаем о внутренней реализации и завистмостях и мы должны вызывать эту функцию и проверять все 100500 ящиков.
Понятно что в обычном тестировании мы можем мокнуть сервис отправки сообщений и драйвер БД, но в этом случае мы уже начинаем привязываться к реализации. Дальше больше. Не стоит забывать о том что в нормальных драйверах БД есть несколько способов получит данные, например в Doctrine:
Метод из репозитория;
ORM QueryBuilder;
DBAL QueryBuilder;
DQL;
SQL.
Любой из этих способов может быть применен для получения списка адресов. Если мы функцию тестируем как черный ящик мы не знаем как нам замокать драйвер БД так что бы он не тянул даные из БД, а пытался отправить письмо на нужные нам адреса. А в случае изменения метода получения адресов нам придется менять тест, что противоречит принципу черного ящика.
В случае test-first мы можем знать о том что методу понадобятся конкретные зависимости, но не имея реализации мы не можем точно знать как именно будут использоваться эти зависимости. Точнее в принципе мы можем предположить и зарание написать тест который полностью ограничивает реализацию, но это очень сложно. В этом случае гараздо проще написпть реализацию и потом по ней тест, чем тест по предпологаемой реализации, а потом реализацию по тесту и в процессе написании реализации выяснить что тест написан криво и с ошибками, написать нормальную реализауию и потом правит тест под нормальную реализацию.
Я себе представляю как я буду писать тест на ещё не созданый запрос построеный с помощью QueryBuilder.
PS: Если кому интересно, могу привести пример как запрос на 15 строк превращается в тест на 93 с жёсткой привязкой к порядку вызова методов.
Я не могу вам предложить конкретное решение вашей проблемы потому что не знаю особенностей архитектуры вашего приложение и не знаю на что вы можете влиять. Судя по тому что вы предлагаете в статье, вы можете влиять на код всех 3-х компонентов: «базовый функционал», «плагина 1» и «плагина 2».
Самое простое решение, если вы не можете влиять на код плагинов, это добавить в базовый функционал атрибут $ref из плагина 1 и атрибут $email из плагина 2. Базовый класс будет описывать реальную сущность из БД, а сущности из плагинов будут ориентироваться на свой интерфейсом как и раньше, а дополнительные атрибуты из другого плагина будут просто игнорировать.
Пример решения с использованием декораторов и включенным strict mode:
под рукой нет php7 чтобы проверить, но должно работать
исполняемый код полностью копирует ваш:
С использованием DataObject нельзя контролировать тип хранящихся в нем данных и список самих данных. Например что будет при выполнении следующего кода? Вы будете в методе save() проверять список доступных полей на запись или будете писать данные как есть в БД и получать fatal error из-за отсутствия соответствующей колонки в таблице?
Для этого создается обычный класс. DataObject здесь не нужен. Он не решает никаких проблем, а только создает их
Множественное наследование и не должно работать. В вашем случае нужно использовать Адаптор. Использование DataObject в описанном вами случае больше похоже на костыль
Поэтому нужно использовать классы которые будут описывать конкретную структуру «известных данных», а не ваш DataObject и массив.
Пример по вашем же данным
И при правильном описании аннотации будет работать автодополнение на протчжении всей цепочки вызовов
Если же нужен контейнер для временного хранения данных, то, как и говорили michael_vostrikov и lair, достаточно обычного массива
По поводу вашего DataObject. Я подобное создавал в первый год изучения прогркммирования и очень бытсро отказался от этого решения. Основная проблема это отсутствие типизации и жесткой структуры объектов и с автодополнением в те годы были проблемы.
Voter может быть полезен при использования SonataAdminBundle.
В конфигах включаешь проверку
и в Voter::getSupportedAttributes() можно возвращать стандартные роли типа:
это позволит управлять доступом в админке без явного вызова функции denyAccessUnlessGranted и не меняя код админки вообще
Получается мы повышаем безопастность сайта в ущерб производительности. По моему это не совсем правильно. Поправьте меня если я ошибаюсь.
В таком случае интеграционные тесты имеют чуть больший процент покрытия чем модульные, но все так же далеки от 100%.
Хорошо. Тогда объясните что вы имеете в виду под словом функционал (feature)? Тестирование только особенностей тестируемого метода, без привязки к зависимостям? Если да, то тестирование получается не полным как и в случае с модульным. И мы опять возвращаемся к проблеме с Петей и John, ибо неполный тест может не отлавливать ситуации при которых Петя == John.
я говорил вот об этом
Если повсеместно использовать DI то этой проблемы не будет и не будет разницы какие тесты вы пишете, интеграционные или модульные. Это организация процесса разработки и архитектуры приложения, ну и обучения джунов. Этот вопрос не имеет прямого отношения к тестированию и TDD.
Об этом я говорил ниже. Интеграционные тесты позволяют тестировать контекст в котором вызывается тестируемая функция, но это не всегда нужно. Например у нас есть функция А которая проверяет какое-то условие и есть функция Б которая использует ее и с ее помощью определяет нужно ли запустить подпрограмму С. И вот мы решили изменить условия в функции А. Мы все также знаем чту функция А может возвращать true|false в разных условиях, это мы проверили через тесты. Это значит что функция Б все также будет запускать подпрограмму С, но уже в других ситуациях и это нормально. Так и должно быть.
Можно написать интеграционный тест который будет гарантировать мне что в определенных условиях функция А вернет true и функция Б соответственно запустит подпрограмму С. То есть через интеграционные тесты можно задать жесткое поведение программы. Описать все варианты развития сценария. Шаг в право, шаг в лево — расстрел. Любое изменение в коде означает изменение десятка, а то и сотни тестов.
С модульными тестами у нас больше свободы, писать их быстрее и проще, тестов меньше и выполняются они быстрее, поддерживать их в зеленом состоянии проще и меньше затрат ресурсов.
Я не спорю, интеграционные тесты это хорошо и они нужны, но они требуют столько ресурсов что выгода может не окупится. Я не говорю что их не надо писать, я говорю что нужно начать с малого, с модульных тестов. И нужно оценивать свои ресурсы. Многие компании не готовы выделить время и деньги на написание тестов вместо того что бы писать новый функционал и приносить в компании больше денег. Есть такие которые готовы потерять N $ например на уязвимости в проекте и заработать на новом функционале N^3 $.
к тестированию не имеют отношения, к тестированию
да, только здесь умножение, а не сложение:
Итог: 3 * 3 * 3 = 27 веток развития алгоритма и как результат 27 вариантов результат и как результат 27 тестов функции А.
Естественно это идеализированный пример ибо не в каждой ветке развития алгоритма будет использоваться вложенная функция, но общая мысль должна быть понятна.
Тут довольно спорный вопрос больше или меньше.
Например, если меняется внутренняя реализация метода isEqual возвращающая в некоторых случаях false там где возвращала true, то у нас поломается только 1 юнит-тест и нужно будет дополнить его в связи с изменениями. В случае же интеграционного тестирования у нас может половина тестов покраснеть, хотя реально сделанные изменения ничего не ломают и ни как не влияют на корректность результата.
Из ваших же примеров. Добавление колонки в результат означает изменение теста измененного метода, изменение методов которые используют измененный метод, изменение тестов которые используют измененный метод т.д. В не зависимости от метода тестирования придется менять все. В случае с интеграционными тестами тесты которые нужно менять сразу покраснеют. В случае с юнит-тестами эти тесты придется искать через поиск, но это не проблема (Ctrl+F и название метода/класса), а поскольку тестов в случае юнит-тестирования в разы меньше то и результатов в поиске будет совсем чуть-чуть и тесты будут исправлены быстрее чем в случае интеграционного тестирования.
Второй приведенный вам пример это изменение стата в игре. Стат это как константа, параметры, условие. Его изменение не должно влиять на работу программы. В случае юнит-тестирования нам нужно будет изменить только тест который тестирует конкретный метод, а в случае интеграционного тестирования придется еще изменять все тесты где используется этот метод что увеличивает объем работы.
Это вообще не относится к проектированию и тестированию. Просто везде используется DI и все. А как вы будите тестировать класс с зависимостями это уже другая история.
Честно говоря я не понял как при интеграционном тестировании мы не столкнемся с экспоненциальным увеличением количества тестов. Ведь мы же используем реальные зависимости, а не моки, соответственно мы должны протестировать не только функциональность тестируемого метода, но и функциональность используемых зависимостей (в этом же весь смысл), а наши зависимости могут иметь свои зависимости, которые тоже могут иметь зависимости и т.д. Вот уже и получаем экспоненциальное увеличение. Это известная проблема black-box testing.
Простая математика:
Итог: 5 * 5 * 5 * 5 * 5 = 3125 тестов для того что бы покрыть 1 единственный метод против 5 тестов в случае юнит-тестирования. 3125 тестов Карл.
И это еще простой пример с малым количеством зависимостей. Подсчитал тут интереса ради для одного своего реального метода и получил примерно 15504 теста против 57 юнит-теста.
Ну и есть же такое понятие как интерфейсное программирование которое очень хорошо укладывается в DI. Суть зависимостей в том что бы они для нас стали черным ящиком и мы ориентировались на их интерфейс, а не на реализацию и при замене зависимости наша программа продолжала работать также. И вот тут очень хорошо укладывается модульное тестирование.
Мой вывод: Единственный плюс который дает интеграционное тестирование это тестирование работы методов в контексте их использования. Это безусловно очень важный плюс, который не получит при использовании модульного тестирования, но я бы не стал зацикливаться на интеграционном тестировании только из-за него ибо для меня минусы перевешивают этот плюс.
Та еще развлекуха.
На тему test-first. Я согласен что во многих случаях написать тест до реализации не проблема. Тут я не спорю. Я просто приведу пример простого на вид кода и его теста, возможно на их примере будет понятно что написать тест до реализации может быть не так уж и просто.
это считается white-box testing
А почему мне именно «необходимо» вводить отдельный класс. Если при рассылке делается что-то больше чем проход по циклу и передача каждого значения функции отправки, то да, скорей всего понадобится вводить еще один уровень абстракции.
Если ваша функция называется «Получить всех пользователей И отправить им сообщение», то нужно разделять, а если функция называется «Отправить все пользователям сообщение» то разносить нечего. Я согласен что здесь можно, а иногда и нужно, вводить еще один уровень абстракции, но почему вы считаете что это единственно верный способ? Так ли необходимо создавать еще один класс из-за этих 3 строчек:
Тесты будут использовать конечный метод. По сути нет разницы будешь ты тестировать получение списка email и рассылку сообщений по отдельности или в рамках одного метода. Скажу даже больше, код теста в обоих случаях будет схожий.
Тут вы не правы. Тестирование по принципу black-box означает тестирование реакции на воздействие абстрагированное от внутренней реализации. То есть мы тестируем только выходные значения для заданных входных.
В случае же test-first, мы действительно не знаем ничего о внутренней реализации, потому что ее еще нет, но мы можем управлять внутренней реализацией. Мокнуть сервис, проверить передаваемые ему параметры.
Говоря другими словами, в случае black-box если тестируемая функция записывает что то в БД, то мы должны проверить наличие этой записи в бд, а в случае test-first мы должны проверить что сервису БД были переданные данные на запись.
В общем случае функция реализуется в 4 строчки. Выносит рассылку в отдельную функцию не совсем разумно, особенно если это единственное место в проекте где есть рассылка. Ну а даже если разносить, мы всегда можем подняться на уровень выше и встретить там все тоже самое. Например в контроллере приходит запрос от пользователя и далее:
Я привожу очень условный пример. Понятно что если адресов 100500, то это как минимум cli команда, а в идеале еще и очередь используется что-то типа RabbitMQ, но суть то не в этом. Суть в том что функции высокого уровня абстракции может быть очень сложно протестировать по принципу black-box, а порой и вовсе невозможно.
Пример я привел. Мы точно знаем что нам необходимо получить данные из бд, то есть будет использоваться драйвер бд (например EntityManager из Doctrine), но мы не знаем какой из методов получения будет использоваться и как. В некоторых случаях мы можем точно знать как будет использоваться зависимость, но не во всех.
В случае например с email рассылкой. Понятно что функция использует какой-то драйвер БД и для отправки сообщения конкретному получателю используется какой-то сервис. Но если мы тестируем по принципу чёрного ящика, то мы ничего не знаем о внутренней реализации и завистмостях и мы должны вызывать эту функцию и проверять все 100500 ящиков.
Понятно что в обычном тестировании мы можем мокнуть сервис отправки сообщений и драйвер БД, но в этом случае мы уже начинаем привязываться к реализации. Дальше больше. Не стоит забывать о том что в нормальных драйверах БД есть несколько способов получит данные, например в Doctrine:
Любой из этих способов может быть применен для получения списка адресов. Если мы функцию тестируем как черный ящик мы не знаем как нам замокать драйвер БД так что бы он не тянул даные из БД, а пытался отправить письмо на нужные нам адреса. А в случае изменения метода получения адресов нам придется менять тест, что противоречит принципу черного ящика.
В случае test-first мы можем знать о том что методу понадобятся конкретные зависимости, но не имея реализации мы не можем точно знать как именно будут использоваться эти зависимости. Точнее в принципе мы можем предположить и зарание написать тест который полностью ограничивает реализацию, но это очень сложно. В этом случае гараздо проще написпть реализацию и потом по ней тест, чем тест по предпологаемой реализации, а потом реализацию по тесту и в процессе написании реализации выяснить что тест написан криво и с ошибками, написать нормальную реализауию и потом правит тест под нормальную реализацию.
Я себе представляю как я буду писать тест на ещё не созданый запрос построеный с помощью QueryBuilder.
PS: Если кому интересно, могу привести пример как запрос на 15 строк превращается в тест на 93 с жёсткой привязкой к порядку вызова методов.