“静态方法是可测试性的死亡” - 替代构造函数的替代方案?

时间:2012-01-23 01:46:35

标签: php unit-testing oop design-patterns static-methods

据说"static methods are death to testability"。如果是这样,那么下面的可行替代模式是什么?

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);
    }

    ...

}

这只是一个减少的例子。该类有一些方法和属性,并且在写入数据库时​​有更多的验证等。这个类背后的指导设计原则是它将用户建模为对象。某些对象的属性在创建后无法修改,例如电话号码(充当主要ID),创建用户的日期等等。其他属性只能根据严格的业务规则进行更改,这些规则都严格验证了设置者和获取者。

该对象本身并不代表数据库记录,数据库仅被视为一种可能的永久存储形式。因此,数据库连接器不存储在对象中,而是每次对象需要与数据库交互时都需要注入。

创建新用户时,如下所示:

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

从永久存储中恢复现有用户时,如下所示:

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

如果我认真对待“死亡可测性”系列,这应该是坏事。我完全能够测试这个对象,因为它完全依赖注入并且static方法没有状态。我怎么能这样做,避免使用static方法?或者更确切地说,在这种情况下究竟反对static的是什么?是什么让static方法特别难以测试?

6 个答案:

答案 0 :(得分:3)

这主要是(我的观点)the chat that ensued between me and @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;
}

我们没有使用静态方法,但它仍然无法解决。实际上,情况变得更糟 答案是使用工厂:

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

    // do something with user

    return $someData;
}

现在工厂可以依赖注入和模拟,User类不再是硬编码的。您可能会或可能不会认为这有点过分,但它肯定会提高可模拟性。

虽然这个工厂可以使用静态方法很好地实例化实际的用户对象,但这并没有改变这个事实:

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

这里使用静态方法或常规构造函数没有区别。

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

两个表达式都返回User的实例,并且两个都“硬编码”User类。无论如何,这只需要在某个时刻发生。

对于我的用例,static构造函数方法对该对象最有意义。正如所展示的那样,static方法不是问题。在哪里调用它们是争用的焦点,而不是它们的存在。我还没有看到一个令人信服的论据,即不使用静态构造函数,因为它们可以包装在工厂中,这可以减轻任何可模拟性问题,就像它对常规对象实例化一样。

答案 1 :(得分:2)

只要OP询问一般问题,而不询问如何改进他的特定代码 - 我会尝试使用一些抽象的小类来回答:

嗯,测试静态方法本身并不困难,但 更难以测试使用静态方法的方法。

让我们看一下小例子的区别。

假设我们有一个班级

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

它做了一些工作,需要设置其他环境(在这种情况下指定API密钥)和与FB API服务的Internet连接。测试此方法需要一些时间(仅仅因为网络和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';
    }
}

现在我们不依赖于具体的实现,但我们在接口上做。现在我们可以轻松地模拟数据源。

这样的抽象有助于保持我们的测试更多地关注测试本身,而不是创建必要的环境。

此类步骤有助于我们快速完成单元测试。虽然我们仍然可以接受,加载和功能测试(但这是另一个故事),测试我们的应用程序按预期工作

答案 2 :(得分:1)

正如评论中所提到的,我将为此案例实现一个存储库模式。

例如,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,测试数据等)

答案 3 :(得分:1)

我认为你给出的引用有一个很好的观点但是需要太过刻线。

你的静态方法就是他所谓的“叶子”方法。在这种情况下,我认为你很好,只要你的静态方法没有任何外部依赖。

替代方案是数据映射器,它是一个知道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类型< - >表,实例< - >行对应,如Active Record模式需要),您可以完全更改序列化方法根本不改变你的域对象。显而易见的是,映射器要测试的容易程度。但在许多情况下,这种模式也过度设计,超出了您的需求。毕竟,大多数网站使用更简单的Active Record模式。

答案 4 :(得分:1)

如果静态方法依赖于状态,则它们只是“可测试性死亡”。如果你开始避免编写这样的方法(你应该这样做),那么这个问题就会消失。

给出的Math.abs()示例是静态方法的良好使用之一。它不依赖于状态,因此它非常容易测试。

那就是说,你是否认为静态方法应该被使用是另一个故事。有些人不喜欢他们看似程序化的本性。我同意那些说OOP是工具而不是目标的人。如果编写“正确”的OO代码对于特定情况没有意义(例如Math.abs()),那么不会这样做。我保证那个boogey man不会吃你的应用程序只是因为你使用了静态方法。 : - )

答案 5 :(得分:0)

首先,DateTime类是一个很好的(棘手的)类,因为它是一个可怕的类。它的所有重要工作都在构造函数中完成,并且在构造之后无法设置日期/时间。这要求我们有一个objectGenerator,它可以在正确的时间构建DateTime对象。我们仍然可以在不在User类中调用new的情况下管理它。

我已经很简单地解决了手头的问题,但是它们可以很容易地扩展到处理任意复杂的问题。

这是一个简单的objectGenerator,用于删除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();

因此,用户类中没有新的或静态的。尝试测试两种解决方案测试代码很乐意在没有调用new的情况下编写。所有使用User的类也很容易测试而不需要静态调用它。

测试代码的差异是:

new / static - 为每个新的或静态的调用都需要一个存根来阻止该单元到达自身之外。

依赖注入 - 可以注入模拟对象。这是无痛的。