"Статические методы - это смерть для проверки" - альтернативы альтернативным конструкторам?

Говорят, что "статические методы - это смерть для проверки" . Если это так, какова жизнеспособная альтернативная схема для ниже?

class User {

    private $phone,
            $status = 'default',
            $created,
            $modified;

    public function __construct($phone) {
        $this->phone    = $phone;
        $this->created  = new DateTime;
        $this->modified = new DateTime;
    }

    public static function getByPhone(PDO $pdo, $phone) {
        $stmt = $pdo->prepare('SELECT * FROM `users` WHERE `phone` = :phone');
        $stmt->execute(compact('phone'));
        if (!$stmt->rowCount()) {
            return false;
        }

        $record         = $stmt->fetch(PDO::FETCH_ASSOC);
        $user           = new self($record['phone']);
        $user->status   = $record['status'];
        $user->created  = new DateTime($record['created']);
        $user->modified = new DateTime($record['modified']);
        return $user;
    }

    public function save(PDO $pdo) {
        $stmt = $pdo->prepare(
            'INSERT INTO `users` (`phone`, `status`, `created`, `modified`)
                  VALUES         (:phone,  :status,  :created,  :modified)
             ON DUPLICATE KEY UPDATE `status`   = :status,
                                     `modified` = :modified');

        $data = array(
            'phone'    => $this->phone,
            'status'   => $this->status,
            'created'  => $this->created->format('Y-m-d H:i:s'),
            'modified' => date('Y-m-d H:i:s')
        );

        return $stmt->execute($data);
    }

    ...

}

Это просто сокращенный пример. Класс имеет еще несколько методов и свойств, а также более валидацию при записи в базу данных и т.д. Принцип руководящего принципа этого класса заключается в том, что он моделирует пользователя как объект. Некоторые из свойств объекта не могут быть изменены после его создания, например номер телефона (который действует как первичный идентификатор), дату создания пользователя и т.д. Другие свойства могут быть изменены только в соответствии со строгими бизнес-правилами, которые все строго проверяют сеттеры и геттеры.

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

Когда создается новый пользователь, это выглядит так:

$user = new User('+123456789');

Когда существующий пользователь восстанавливается из постоянного хранилища, это выглядит так:

$pdo  = new PDO('...');
$user = User::getByPhone($pdo, '+123456789');

Если бы я серьезно относился к теме "смерть к испытанию", это, предположительно, плохо. Я отлично могу протестировать этот объект, потому что он полностью вливается и методы static не имеют состояния. Как я могу сделать это по-другому и избегать использования методов static? Вернее, что именно противоречит static в этом случае? Что делает такое использование методов static настолько трудным для тестирования?

Ответ 1

В основном это сводка (моя перспектива) чата, которая была между мной и @zerkms:

Точка спора на самом деле такова:

public function doSomething($id) {
    $user = User::getByPhone($this->pdo, $id);

    // do something with user

    return $someData;
}

Это затрудняет тестирование doSomething, поскольку он жестко кодирует класс User, который может иметь или не иметь много зависимостей. Но это фактически то же самое, что и экземпляр объекта с использованием нестатического метода:

public function doSomething($id) {
    $user = new User;
    $user->initializeFromDb($this->pdo, $id);

    // do something with user

    return $someData;
}

Мы не используем статический метод, но он все еще недоступен. На самом деле, стало еще хуже.
Ответ заключается в использовании factory:

public function doSomething($id) {
    $user = $this->UserFactory->byPhone($id);

    // do something with user

    return $someData;
}

Теперь factory может быть введена в зависимость и высмеиваться, а класс User больше не является жестко запрограммированным. Вы можете или не можете думать об этом излишестве, но это, безусловно, улучшает насмешку.

Это не меняет факт, хотя этот factory может очень точно создать экземпляр фактического объекта пользователя с помощью статического метода:

public function byPhone($id) {
    return User::getByPhone($this->db, $id);
}

Нет никакой разницы между использованием статического метода или обычного конструктора здесь.

$user = new User($db, $id);
$user = User::getByPhone($db, $id);

Оба выражения возвращают экземпляр User и оба "hardcode" класса User. Что-то просто должно произойти в какой-то момент.

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

Ответ 2

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

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

Посмотрим разницу на небольшом примере.

Пусть, скажем, класс

class A
{
    public static function weird()
    {
        return 'some things that depends on 3rd party resource, like Facebook API';
    }
}

Он выполняет некоторую работу, требующую настройки дополнительной среды (в этом случае указывая ключи API) и подключения к Интернету с услугами FB API. Потребуется некоторое время, чтобы протестировать этот метод (только из-за нехватки сети и API), но его достаточно легко проверить.

Теперь мы реализуем класс, который использует метод A::weird():

class TestMe
{
    public function methodYouNeedToTest()
    {
        $data = A::weird();

        return 'do something with $data and return';
    }
}

В настоящее время - мы не можем протестировать TestMe::methodYouNeedToTest() без дополнительных шагов, необходимых для выполнения A::weird(). Да, вместо тестирования methodYouNeedToTest нам также нужно делать вещи, которые напрямую не связаны с этим классом, а с другим.

Если бы мы пошли по другому пути с самого начала:

class B implements IDataSource
{
    public function weird()
    {
        return 'some things that depends on 3rd party resource, like Facebook API';
    }
}

вы видите - ключевое различие в том, что мы реализовали интерфейс IDataSource и сделали метод нормальным, а не статическим. На данный момент мы можем переписать наш код выше следующим образом:

class TestMe
{
    public function methodYouNeedToTest(IDataSource $ds)
    {
        $data = $ds->weird();

        return 'do something with $data and return';
    }
}

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

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

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

Ответ 3

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

Например, User будет простой моделью с свойствами только для чтения

class User {
    private $phone,
            $status = 'default',
            $created,
            $modified;

    public function __construct($phone) {
        $this->setPhone($phone);
        $this->created  = new DateTime;
        $this->modified = new DateTime;
    }

    private function setPhone($phone) {
        // validate phone here

        $this->phone = $phone;
    }

    public function getPhone() {
        return $this->phone;
    }

    public function getCreated() {
        return $this->created;
    }

    public function getModified() {
        return $this->modified;
    }
}

Теперь ваш интерфейс репозитория может выглядеть следующим образом:

interface UserRepository {

    /**
     * @return User
     */
    public function findByPhone($phone);

    public function save(User $user);
}

Конкретная реализация этого интерфейса может выглядеть примерно так:

class DbUserRepository implements UserRepository {
    private $pdo;

    public function __construct(PDO $pdo) {
        $this->pdo = $pdo;
    }

    public function findByPhone($phone) {
        // query db and get results, return null for not found, etc

        $user = new User($phone);

        // example setting the created date
        $reflectionClass = new ReflectionClass('User');

        $reflectionProperty = $reflectionClass->getProperty('created');
        $reflectionProperty->setAccessible(true);

        $created = new DateTime($res['created']); // create from DB value (simplified)
        $reflectionProperty->setValue($user, $created);

        return $user;
    }

    public function save(User $user) {
        // prepare statement and fetch values from model getters
        // execute statement, return result, throw errors as exceptions, etc
    }
}

Самое интересное, что вы можете реализовать множество разных репозиториев, все с различными стратегиями сохранения (XML, тестовые данные и т.д.)

Ответ 4

Я думаю, что цитата, которую вы даете, имеет хорошую точку зрения, но занимает слишком много строк.

Ваш статический метод - это то, что он называет "листовым" методом. В этом случае я думаю, что все в порядке, если у вашего статического метода нет внешних зависимостей.

Альтернативой является mapper данных, объект, который знает о связи между User и тем, как он хранится в базе данных. Пример:

class UserDBMapper {
    protected $pdo;
    protected $userclass;
    function __construct(PDO $pdo, $userclass) {
        $this->db = $db;
        // Note we can even dependency-inject a User class name if it obeys the interface that UserMapper expects.
        // You can formalize this requirement with instanceof, interface_exists() etc if you are really keen...
        $this->userclass = $userclass;  
    }

    function getByPhone($phone) {
        // fetches users from $pdo
        $stmt = $this->db->query(...);
        $userinfo = $stmt->fetch()....
        // creates an intermediary structure that can be used to create a User object
        // could even just be an array with all the data types converted, e.g. your DateTimes.
        $userargs = array(
            'name' => $userinfo['name'],
            'created' => $userinfo['created'],
            // etc
        );

        // Now pass this structure to the $userclass, which should know how to create itself from $userargs
        return new $this->userclass($userargs);
    }

    function save($userobj) {
        // save method goes in the Mapper, too. The mapper knows how to "serialize" a User to the DB.
        // User objects should not have find/save methods, instead do:
        // $usermapper->save($userobj);
    }   
}

Это очень мощный шаблон (например, вам больше не нужно иметь таблицу типов 1-1 type ↔ table, instance ↔ , такую ​​как шаблон Active Record), и вы можете полностью изменить свой метод сериализации без изменения ваши объекты домена вообще. Также должно быть очевидно, насколько проще тестировать транслятор. Но во многих случаях эта модель также чрезмерно спроектирована и больше, чем вам нужно. В конце концов, большинство сайтов используют гораздо более простой шаблон Active Record.

Ответ 5

Статические методы - это только "смерть для проверки", если они зависят от состояния. Если вы избежите писать такие методы для начала (что вам нужно), то этот вопрос просто уходит.

Приведенный пример Math.abs() является хорошим использованием статического метода. Он не зависит от состояния, поэтому он легко тестируется.

Тем не менее, следует ли думать, что статические методы следует использовать, - это еще одна история. Некоторым людям не нравится их, казалось бы, процессуальный характер. Я согласен с теми, кто говорит, что ООП - это инструмент, а не цель. Если писать "правильный" OO-код не имеет смысла для конкретной ситуации (например, Math.abs()), тогда не делайте этого. Я обещаю, что человек-боксер не будет есть ваше приложение только потому, что вы использовали статический метод.: -)

Ответ 6

Во-первых, класс DateTime был хорошим (сложным) классом, потому что это ужасный класс. Вся его важная работа выполняется в конструкторе, и нет возможности установить дату/время после ее создания. Это требует от нас наличия объекта-генератора, который может построить объект DateTime в нужное время. Мы все еще можем управлять этим, но не вызываем new в классе User.

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

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

    class ObjectGenerator {
       public function getNew($className) {
          return new $className;
       }
    }

Теперь мы вставляем все зависимости в конструктор. Конструктор не должен выполнять настоящую работу, а только настраивать объект.

class User {

    private $phone,
            $status = 'default',
            $created,
            $modified,
            $pdo,
            $objectGenerator;

    public function __construct(PDO $pdo, $objectGenerator) {
       $this->pdo = $pdo;
       $this->objectGenerator = $objectGenerator;
       $this->created = $this->objectGenerator->getNew('DateTime');
    }

    public function createNew() {
       $this->phone = '';
       $this->status = 'default';
       $this->created = $this->objectGenerator->getNew('DateTime');
    }

    public function selectByPhone($phone) {
        $stmt = $this->pdo->prepare('SELECT * FROM `users` WHERE `phone` = :phone');
        $stmt->execute(compact('phone'));
        if (!$stmt->rowCount()) {
            return false;
        }

        $record         = $stmt->fetch(PDO::FETCH_ASSOC);
        $this->phone    = $record['phone'];
        $this->status   = $record['status'];
        $this->created  = $record['created'];
        $this->modified = $record['modified'];
    }

    public function setPhone($phone) {
       $this->phone = $phone;
    }

    public function setStatus($status) {
       $this->status = $status;
    }

    public function save() {
        $stmt = $this->pdo->prepare(
            'INSERT INTO `users` (`phone`, `status`, `created`, `modified`)
                  VALUES         (:phone,  :status,  :created,  :modified)
             ON DUPLICATE KEY UPDATE `status`   = :status,
                                     `modified` = :modified');

    $modified = $this->objectGenerator->getNew('DateTime');

    $data = array(
            'phone'    => $this->phone,
            'status'   => $this->status,
            'created'  => $this->created->format('Y-m-d H:i:s'),
            'modified' => $modified->format('Y-m-d H:i:s')
        );

        return $stmt->execute($data);
    }
}

Использование:

$objectGenerator = new ObjectGenerator();

$pdo = new PDO();
// OR
$pdo = $objectGenerator->getNew('PDO');

$user = new User($pdo, $objectGenerator);
$user->setPhone('123456789');
$user->save();

$user->selectByPhone('5555555');
$user->setPhone('5552222');
$user->save();

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

Различия в тестовом коде:

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

инъекция зависимостей. Могут быть введены макетные объекты. Это безболезненно.