PHPUnit тестирование

Vano

Новичок
PHP:
class Human {
    protected $name;
    protected $age;

    public function __construct(string $name, int $age) {
        $this->setName($name);
        $this->setAge($age);
    }

    protected function setName(string $name) {
        //regexp на то чтоб минимум 2 символа, только чарактерс и дефис,
        // в случае провала throw Exception('пашолты');
        $this->name = $name;
    }

    //С методом setAge(int $age) та же идея, в случае меньше 18ти throw Exception
}
Подскажите, пожалуйста, как оттестировать?

Почему именно протектед методы? Допустим, хочу чтобы простой set$attribute() нельзя вызвать было, а только через change$attribute().

Как тестировать в таких случаях, когда в конструкторе происходит вызов протекдет методов?
 

Vano

Новичок
Идея создать два теста:
1) testCreatingObjecT(), в котором буду засовывать данные с провайдера, который буду считать правильным; (и если обьект не создался - fail('Обьект не создался')
2) tessFailCreatingObject(), в котором данные с провайдера будут неправильными и ожидать метод будет Exception().

Норм? Или есть альтернатива по-лучше?
 
Последнее редактирование:

AnrDaemon

Продвинутый новичок
Ты вопрос неправильно ставишь.
Надо тестировать не "как" а "что".
Что ты тут собираешься тестировать?
 

Вурдалак

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

Тестировать класс в том виде, что он есть у тебя бессмысленно: тут нет логики. Ну, типа ты тестируешь, что PHP вызовет метод и что выполнится тривиальное условие.

Но я могу показать, как примерно выглядит тест, если появится какая-то логика и кое-что поменять.

Тебя интересует соблюдение инвариантов. OK, скачиваешь эту библиотеку и пишешь:
PHP:
final class Human
{
    public function __construct(int $id, string $name, int $age)
    {
        Assertion::regex($name, '/^[\p{L}-]{2,}$/', sprintf('Username is not valid: %s', $name));
        Assertion::greaterOrEqThan($age, 18);

        $this->id = $id;
        $this->name = $name;
        $this->age = $age;
    }
}
Далее, возможно, для твоей модели действительно подходит именно возраст и он не предполагает изменения в течение времени, но в 99% случаев возраст должен меняться:
PHP:
final class Human
{
    public function __construct(int $id, string $name, DateTime $dateOfBirth, DateTime $now)
    {
        Assertion::regex($name, '/^[\p{L}-]{2,}$/', sprintf('Username is not valid: %s', $name));
        Assertion::greaterOrEqThan($now->diff($dateOfBirth)->y, 18);

        $this->id = $id;
        $this->name = $name;
        $this->dateOfBirth = $dateOfBirth;
    }
}
Далее, ты хочешь иметь возможность менять имя и ДР. Тут на самом деле отмечу, но ДР многие сайты менять не позволяют, потому что в реальности он меняться никак не может. Может, конечно, корректироваться, вероятно даже можно при наличии доказательств поменять дату в паспорте, но обычно такой кейс просто не учитывают. А вот имя человек может запросто поменять. Поэтому я для примера буду считать, что ДР мы менять не можем (даже как админы; в самых исключительных случаях это может сделать программист прямо в БД и такое будет, допустим, раз в 5 лет).

Позволяем менять имя:
PHP:
final class Human
{
    public function __construct(int $id, string $name, DateTime $dateOfBirth, DateTime $now)
    {
        Assertion::regex($name, '/^[\p{L}-]{2,}$/', sprintf('Username is not valid: %s', $name));
        Assertion::greaterOrEqThan($now->diff($dateOfBirth)->y, 18);

        $this->id = $id;
        $this->name = $name;
        $this->dateOfBirth = $dateOfBirth;
    }

    public function rename(string $newName): void
    {
        Assertion::regex($name, '/^[\p{L}-]{2,}$/', sprintf('Username is not valid: %s', $name));
        $this->name = $name;
    }
}
И тут мы видим дублирование инварианта. В принципе, это не страшно, но если мы не поленимся, но выделим «имя» в отдельный VO:
PHP:
final class Name
{
    private $name;

    public function __construct(string $name)
    {
        Assertion::regex($name, '/^[\p{L}-]{2,}$/', sprintf('Username is not valid: %s', $name));

        $this->name = $name;
    }

    public function __toString()
    {
        return $this->name;
    }
}

final class Human
{
    public function __construct(int $id, Name $name, DateTime $dateOfBirth, DateTime $now)
    {
        Assertion::greaterOrEqThan($now->diff($dateOfBirth)->y, 18);

        $this->id = $id;
        $this->name = $name;
        $this->dateOfBirth = $dateOfBirth;
    }

    public function rename(Name $newName): void
    {
        $this->name = $newName;
    }
}
OK, но тестировать по-прежнему нечего: ведь мы по сути будем тестировать сам PHP и библиотеку beberlei/assert.

Чтобы пример с тестированием имел смысл, добавим немного логики а-ля ВКонтакте: нельзя менять имя чаще, чем раз в 24 часа + имя должно быть подтверждено модератором (заодно поменяю Human -> VkUser):
PHP:
final class VkUser
{
    public function rename(Name $newName, DateTime $now): void
    {
        if ($this->name->equals($newName)) {
            return;
        }

        if ($now->getDiffInHours($this->lastTimeNameChangedAt) < 24) {
            throw new NameCannotBeChangedFrequently();
        }

        $this->nameOnModeration = $newName;
    }

    public function acceptName(DateTime $now): void
    {
        if ($this->nameOnModeration === null) {
            return;
        }

        $this->name = $this->nameOnModeration;
        $this->lastTimeNameChangedAt = $now;
        $this->nameOnModeration = null;
    }
}
Соответственно, команду «VkUser->rename()» может вызывать сам пользователь, а «VkUser->acceptName()» — модератор. Это контролируется где-то на уровне выше с помощью штатных средств фреймворка, всяких ACL и т.д.

Но как теперь тестировать? Добавить геттеры и проверять состояние? Как правило, там где есть логика, есть и события. Именно по событиям мы понимаем, что нужно добавить что-то в очередь на модерацию, отправить email/push/SMS, добавить данные в статистику и т.д.

Введём события:
PHP:
final class VkUser
{
    use Events;

    public function rename(Name $newName, DateTime $now): void
    {
        if ($this->name->equals($newName)) {
            return;
        }

        if ($now->getDiffInHours($this->lastTimeNameChangedAt) < 24) {
            throw new NameCannotBeChangedFrequently();
            // or $this->apply(new NameChangingRequestWasDeclined($this->id, ...));
        }

        $this->apply(new UserRequestedNewName($this->id, $newName));
    }

    public function acceptName(DateTime $now): void
    {
        if ($this->nameOnModeration === null) {
            return;
        }

        $this->apply(new UserNameWasAccepted($this->id, $this->nameOnModeration, $now));
    }

    private function onUserRequestedNewName(UserRequestedNewName $event): void
    {
        $this->nameOnModeration = $event->getNewName();
    }

    private function onUserNameWasAccepted(UserNameWasAccepted $event): void
    {
        $this->name = $event->getNewName();
        $this->lastTimeNameChangedAt = $event->getAcceptingDate();
        $this->nameOnModeration = null;
    }
}
OK, теперь мы можем по событию UserRequestedNewName добавить имя в очередь на модерацию (это может быть отдельной SQL-таблицей, из которой мы порциями будем давать модераторам имена; мы же взяли в пример ВК, а там таких запросов будет много), а по событию UserNameWasAccepted писать в статистику, также логгировать, чтобы было понятно какой именно модератор заппрувил имя, чтобы потом можно было такого модератора уволить за недобросовестность.

И теперь то, о чём ты спрашивал: ну а как это тестировать-то? А тесты с событиями пишутся как-то так: https://github.com/broadway/broadway/blob/master/examples/event-sourced-domain-with-tests/InvitesTest.php
PHP:
    /**
     * @test
     */
    public function user_can_request_name_changing()
    {      
        $this->scenario
            ->given(function () {
                return VkUser::register(42, 'Vano', ...);
            })
            ->when(function (VkUser $user) {
                $user->rename('Ivan', new DateTime('2000-0'));
            })
            ->then([
                new UserRequestedNewName(42, 'Ivan')
            ]);

        $this->scenario
            ->given(function () {
                $user = VkUser::register(42, 'Vano', ...);
                $user->rename('Ivan');
                $user->acceptName(...);
            })
            ->when(function (VkUser $user) {
                $user->rename('VanoVano', new DateTime(...));
            })
            ->then([
                new NameChaningRequestWasDeclined(42)
            ]);
    }
А до тех пор, пока у тебя тупая CRUD-логика, это нахрен не нужно.
 
Последнее редактирование:

WMix

герр M:)ller
Партнер клуба
@Вурдалак, ну те в данном случае проверка на создание нужного события. Не спорю, тест нужен. Но логика бывает же намного проще. Глядя на твой пример хочется быть убежденным что регистрация возможна с 18 лет, а имя длинее 2х символов. Ну те я не понимаю высказывание "проверять php", я вижу некоторые условия/утверждения на которые можно опираться в других частях программы. Хочется завязать это описав в тесте. Или ты считаешь это лишним? Соглашусь что просто сеттер тестить глупо, не хватает еще одного метода, который бы это действительно утверждал. Те в твоем случае опять же можно попытаться создать 12летку и убедиться что событие "регистрация запрещена" было созданно, но может быть и проще, (полная ерунда но всеже) есть метод возвращающий количество годов с совершеннолетия. И это число всегда больше равно нулю. (Я не могу найти хороший кейс, в данном случае уже на создании будет ошибка, но хочется написать простое утверждение, что это так)
 
Последнее редактирование:

Вурдалак

Продвинутый новичок
@WMix assertion'ы — это декларации, которые недалеко ушли от type hint'ов. Ты же не пишешь тест на foo(int $bar), который проверяет, что туда нельзя передать массив или какой-то другой мусор? Чем это принципиально отличается от тестирования нечто вроде int<18,*>?

я вижу некоторые условия/утверждения на которые можно опираться в других частях программы
Не совсем, assertion'ы как правило пишут, чтобы контролировать состояние, но при этом до их провала доходить не должно. Вот эта логика с возрастом и именем всё равно будет продублирована где-то в UI (контроллер или JS), тот самый UI вполне можно потестировать соответствующими инструментами.

Тестировать декларации, конечно, можно, только лично я предпочитаю этим не заниматься.
 

WMix

герр M:)ller
Партнер клуба
возможно я неправильно выразился, тк пример гнилой. Те "утверждаю что число больше равно нулю" имеет смысл только в математических вычислениях, по этой причине я ввел доп. метод. Те, при вычислениях мы заранее хотим убедится что значение не отрицательное тк корень будет вычисляться или не ноль тк это используется в знаменателе, что не пи/2 ибо тангенс... опять не придумаю кейса, но к примеру при вычислении процентов отрицательная продолжительность не имеет смысла, все хуже, в этом случае исключение тоже не создается. Те либо метод который вернет продолжительность пообещает мне положительное число (подразумевает тест/доказательство), либо уже в своем методе придется делать проверку. К какому варианту ты склоняешься?
 

Adelf

Administrator
Команда форума
Есть подозрение что он говорит о контрактах. Таких как в яву потихоньку внедряют.

Код:
@NotNull
public ClassName getSomething()
- этот контракт обещает что этот метод не вернет null значение.

Код:
public void doSomething(@NotNull ClassName param)
Этот метод не даст себе дать нулльное значение.

Я видел в СиШарп библиотечки для контрактного программирования, которыми можно делать то, что надо. примерно так:
Код:
public void setAge(@MinValue(18) int param)
Но чот... оно как-то слишком. Хоть еще по интерфейсу метода видно что ему надо, но это ж бизнес-валидация. Она должна быть более... естественной чтоли :)
 

WMix

герр M:)ller
Партнер клуба
PHP:
function rate($leasing){ 
return $leasing->duration() * чтонить
}
ну те мы заранее подразумеваем что метод продолжительность вернет нам некое положительное число. Но обещания если тестом не описано, нет. до тех пор пока я в этом не уверен, мне придется писать проверку.
 

Вурдалак

Продвинутый новичок
Если для тебя это насколько важно, то ты можешь гарантировать это с помощью VO:
PHP:
public function rate(): Rate
{
    return new Rate(...);
}
 

WMix

герр M:)ller
Партнер клуба
мне кажется я понял к чему ты клонишь: юниты ты используешь исключительно или в большинстве случаев для обьектов и не паришься со скалярами, тк в большинстве случаев ты их не обрабатываешь. Или всеже не о том это "тестить пхп"? В предыдущем примере duration должна была вернуть проверенный на уровне конструктора обьект, судя по твоему ответу. Или лизинг изначально обещает продолжительность положительное число, иначе не создастся
 
Последнее редактирование:

WMix

герр M:)ller
Партнер клуба
На всякий случай, исходя из примера выше чтоб сообщить пользователю, что малолетком вход запрещен ты обернешь new VkUser(...) try catch'ем, это произойдет в контроллере или как это дойдет до response? или что произходит в случае Assertion::greaterOrEqThan($now->diff($dateOfBirth)->y, 18)?
 
Последнее редактирование:

Вурдалак

Продвинутый новичок
На всякий случай, исходя из примера выше чтоб сообщить пользователю, что малолетком вход запрещен ты обернешь new VkUser(...) try catch'ем, это произойдет в контроллере или как это дойдет до response? или что произходит в случае Assertion::greaterOrEqThan($now->diff($dateOfBirth)->y, 18)?
Нет, ничего в try .. catch я оборачивать не буду:
Не совсем, assertion'ы как правило пишут, чтобы контролировать состояние, но при этом до их провала доходить не должно. Вот эта логика с возрастом и именем всё равно будет продублирована где-то в UI (контроллер или JS), тот самый UI вполне можно потестировать соответствующими инструментами.
Если такой assertion провалился, то в коде есть либо баг, либо не хватает валидации клиентских данных. В контроллере будет, условно, if ($validator->isValid($request)), который тоже знает про 18 лет и про ограничения на имя.

или что произходит в случае Assertion::greaterOrEqThan($now->diff($dateOfBirth)->y, 18)?
В error log упадёт ошибка.
 
  • Like
Реакции: WMix

Adelf

Administrator
Команда форума
if ($validator->isValid($request)), который тоже знает про 18 лет и про ограничения на имя.
А валидатор знает, например, что для одной страны это ограничение в 18 лет, а для другой 16? Я вообще не понимаю почему эту явную бизнес-валидацию выносить в валидаторы? Пусть оно дойдет до домена и там упадет с эксепшеном. Который точно обьяснит суть проблемы.
 

Вурдалак

Продвинутый новичок
А валидатор знает, например, что для одной страны это ограничение в 18 лет, а для другой 16? Я вообще не понимаю почему эту явную бизнес-валидацию выносить в валидаторы? Пусть оно дойдет до домена и там упадет с эксепшеном. Который точно обьяснит суть проблемы.
Во-первых, здесь присутствует иллюзия того, что можно написать код так, чтобы вот в одном месте поменял и везде стало круто. Мы как-то уже говорили на эту тему, это всё вариация на тему «быстро, качественно, недорого» — набор взаимоисключающих условий, которые все одновременно выполниться не смогут. Если написать именно такой код с single source of truth, то обязательно пострадают другие элементы системы. Те же мобильные приложения часто делают валидацию прямо на клиенте, не делая запросов на сервер.

Во-вторых, полагаться на exception — это не так здорово, как кажется. Тут не проверишь разом все ошибки + в какой-то момент станет не очень понятно чья именно это ошибка: клиента или наша. В смысле, exception вылетит, но часть данных для команды мы передаём сами, а часть — от клиента. И мы будем отдавать клиенту 400 Bad Request?

В-третьих, домен домену рознь. Кто тебе сказал, что (саб)домен, который знает про VkUser, будет знать что-то про страны с их ограничениями? Вот этой логики может быть так много, что она будет загрязнять этот домен и лучше её вынести куда-то выше, а в VkUser сделать более мягкое условие типа 16 лет или даже 14, а более точное значение будет проверяться где-то выше. А если прибежит Павел Дуров и скажет, что вот для этого списка IP нужно сделать ограничение в 21 год? Ты прямо в VkUser будешь сравнивать IP-адреса?

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

Adelf

Administrator
Команда форума
Да я не против дублирования логики на клиенте.
Но я против того, что валидатор, который находится в HTTP слое, знает так много.
Да эта проверка не будет в VkUser. она будет выше, но где-то в домене(уж не знаю что мы вкладываем в это слово, но у меня в проектах обычно Anemic-style команды, работающие с активрекордами, так что проблемы проверить там где юзеру пытаются дать возраст - нет). По мне, слои типа HTTP должны быть максимально тупы касательно знаний о предметной области. Чтобы их потом не пришлось тестировать...
 
Сверху