“静态方法对可测试性来说是死亡的”——替代构造函数的替代品?

"Static methods are death to testability" - alternatives for alternative constructors?

提问人:deceze 提问时间:1/23/2012 最后编辑:deceze 更新时间:1/23/2012 访问量:2695

问:

有人说“静态方法对可测试性来说是死亡”。如果是这样,那么以下可行的替代模式是什么?

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)、创建用户的日期等。其他属性只能根据严格的业务规则进行更改,这些规则都有严格的验证 setter 和 getter。

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

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

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

当现有用户从永久存储还原时,如下所示:

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

如果我认真对待“死于可测试性”这句话,这应该是不好的。不过,我完全能够测试这个对象,因为它是完全依赖注入的,并且方法没有状态。我怎样才能以不同的方式做到这一点并避免使用方法?或者更确切地说,在这种情况下究竟反对什么?是什么让这种特殊的方法使用如此难以测试?staticstaticstaticstatic

php 单元测试 oop 设计模式 static-methods

评论

1赞 Phil 1/23/2012
对于上述情况,我会使用 Repository 模式
0赞 zerkms 1/23/2012
在这种特殊情况下,静态方法是 。你最好通过接口而不是实现来绑定你的应用程序部分death to extensibility
0赞 deceze 1/23/2012
@Phil 如果我理解正确的话,存储库似乎主要是抽象数据库查询,我同意这将是一件有用的事情。如何在没有静态构造函数的情况下启动属性,同时仍然封装我的所有私有?private
1赞 deceze 1/23/2012
@zerkms 不过,在某些时候,我必须对某处的使用进行“硬编码”。如果可以接受,为什么不可以?它们都充当构造函数。Usernew UserUser::getByPhone
1赞 zerkms 1/23/2012
@deceze:将数据传递给实体的构造函数

答:

1赞 Phil 1/23/2012 #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、测试数据等)

评论

0赞 deceze 1/23/2012
听起来不错,我同意存储机制当然可以更好地抽象。但是,我可以通过反过来抽象存储对象来做同样的事情。,其中可以子类化为不同的后端。该类可以使用它来从存储中获取和设置其数据,并且仍然负责它自己的所有属性。通过反射设置属性意味着用户的业务逻辑分布在不同的类之间,这是我想避免的。User::save(Storage $storage)StorageUser
0赞 Phil 1/23/2012
那有什么意义呢? 只会打电话.按原样构建域模型,但不要包含任何持久性逻辑。如果由于模型中的业务逻辑,持久层必须使用反射,我看不出有什么问题。UserStorage::save($this)
0赞 deceze 1/23/2012
不,会打电话.它只会将其数据移交给存储,存储不需要知道对象。关键是需要执行某些业务规则(状态),想想 FSM。允许代码的其他部分更改对象的内部状态会使强制执行正确的状态变得更加困难。UserStorage::save(array('phone' => $this->phone, ...))User
1赞 Francis Avila 1/23/2012 #2

我认为你给出的引文有一个很好的观点,但太难了。

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

另一种方法是数据映射器,一个对象,它知道数据库之间的关系以及如何存储在数据库中。例: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);
    }   
}

这是一个非常强大的模式(例如,您不再需要像 Active Record 模式那样具有 1-1 类型<>表、实例<行>行对应关系),并且您可以完全更改序列化方法,而无需更改域对象。映射器测试起来也很明显。但在许多情况下,这种模式也被过度设计,超出了您的需求。毕竟,大多数网站都使用更简单的活动记录模式。

评论

0赞 deceze 1/23/2012
我同意你的看法。我当然可以看到作者的一般观点,但说所有静态方法都无法测试有点过于夸张的IMO。:)
0赞 zerkms 1/23/2012
@deceze:这可能会使测试更加困难。假设您要测试使用静态方法的方法。你怎么能嘲笑它?
1赞 Francis Avila 1/23/2012
您需要确定更通用的模式的好处是否值得额外的架构复杂性和代码行数。或者,您可以使用已经使用映射器模式的 ORM,并且可以为常见情况生成简单的映射器,但如果以后需要,仍会为增长留出空间。
0赞 Francis Avila 1/23/2012
@zerkms,OP 可以传递一个模拟对象,因为他的静态方法巧妙地使用了依赖注入。$pdo
2赞 Francis Avila 1/23/2012
有时,维护依赖关系树比管理完全抽象的架构的复杂性更容易。我们已经将一个简单的 User 类(好吧,可能有一些难以测试的位)变成了一个需要 User、UserFactory、UserDBMapper 和这三者之间定义的接口的应用程序。如果我们在应用程序中不需要这么多抽象,我们只是为了吞下一头骆驼而拉紧了 gnat,并且随着代码的增加,需要测试的代码也更多。
0赞 Paul 1/23/2012 #3

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

为了解决手头的问题,我让事情变得非常简单,但它们可以很容易地扩展以处理任意复杂的问题。

这里有一个简单的 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 - 要求每个新的或静态调用都有一个存根,以阻止设备到达自身外部。

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

评论

0赞 deceze 1/23/2012
感谢您的见解。但现在,这允许对象很容易进入不一致的状态。我正在尝试使用类对业务规则进行建模,该类的工作方式类似于 FSM。在特定条件下,用户只能设置为某些状态(高级状态等)。仅仅为了一个抽象级别而将其视为活动记录,则很难强制执行。正如我所说,在这种情况下,静态方法充当替代构造函数,从序列化状态设置内部状态。User
0赞 deceze 1/23/2012
这东西几乎是唯一没有注入的物体。而且它在PHP中默认可用,并且用途非常有限,无需抽象或模拟IMO。DateTime
0赞 Paul 1/23/2012
我可能对公共界面过于自由。它可以替换为 only 和 。它确实需要两行 ($user = new User(); $user->createNew();) 才能有一个有效的用户,但你是程序员。当您尝试对用户执行尚无效的操作时,可以选择抛出异常。createNewselectByPhone
0赞 deceze 1/23/2012
如果你想使用工厂,我建议一个更好的工厂,可以使用备用构造函数。喜欢。这样就可以以最有意义的方式编写类,允许您模拟您的工厂。静态方法不是问题。$objectGenerator->getNewWith('User', 'getByPhone', $pdo, 42)User
2赞 zerkms 1/23/2012 #4

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

好吧,测试静态方法本身并不难,但测试使用静态方法的方法更难。

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

假设我们有一个类

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

它执行一些工作,需要设置额外的环境(在本例中指定 API 密钥)和与 FB API 服务的互联网连接。测试这种方法需要一些时间(只是因为网络和 API 滞后),但测试它绝对很容易。

现在,我们实现一个使用 method 的类: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';
    }
}

现在我们不依赖于特定的实现,而是依赖于接口。现在我们可以很容易地模拟数据源。

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

这些步骤有助于我们快速进行单元测试。虽然我们仍然可以进行验收、负载和功能测试(但这是另一回事),以测试我们的应用程序是否按预期工作

评论

0赞 Phil 1/23/2012
您可以使用 Mockery 模拟具体类
0赞 zerkms 1/23/2012
@Phil:它可以模拟静态方法吗?
1赞 deceze 1/23/2012
这是花花公子,但这意味着我的物品的完整性被打破了。该类目前是以完全独立的方式编写的,它允许我说“每当我有一个 User 类型的对象时,它都可以保证处于良好、一致的状态。这使得类型提示很有用,错误处理也变得简单。这是 OOP 的一个相当大的观点。如果我需要能够创建“空”实例,我需要使用单独的非静态调用进行初始化,那么这种保证就消失了。在您的示例中,您在哪里实例化?User$ds
0赞 zerkms 1/23/2012
@deceze:它来自依赖注入工具(在测试中,我手动创建并通过了一个模拟)
0赞 zerkms 1/23/2012
“该类目前是以完全独立的方式编写的”---这并不重要。任何使用你的类都很难测试。这是事实,这就是你问的;-)User::staticmethod()
2赞 FtDRbwLXw6 1/23/2012 #5

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

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

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

评论

0赞 zerkms 1/23/2012
即使你不依赖于状态 - 如果其他类使用这种静态方法,也会使测试变得更糟。有关详细信息,请参阅我的回答
2赞 FtDRbwLXw6 1/23/2012
@zerkms:这不是真的,你的回答没有给出任何支持这一论点的理由。如果调用静态方法,并且不依赖于状态,那么绝对没有理由会降低可测试性。事实上,PHP中的每个股票函数都等同于一个“静态方法”。你是说任何使用不依赖于状态(即)的PHP函数的类方法在某种程度上比那些不依赖于状态的类方法更不可测试吗?这太荒谬了。Foo::bar()Bar::baz()Bar::baz()Foo::bar()strlen()
0赞 zerkms 1/24/2012
函数可以计算 MD5() 的 10 亿次迭代。这需要时间。并且很难测试,因为执行一个测试用例需要一个小时。同时,该函数是无状态的Foo::bar()
0赞 zerkms 1/24/2012
另一个例子:该函数连接到远程 RESTful 服务以评估一些数学运算。仍然没有状态,测试起来很糟糕Bar::baz()
1赞 FtDRbwLXw6 1/24/2012
@zerkms:处理一个方法所花费的时间与它是否是静态的无关。就 RESTful 服务参数而言,Web 服务将是一个依赖项。如果静态方法不包含注入该依赖项的方法,那么你的可测试性就会受到影响,因为无法注入(因此,模拟)依赖项,而不是因为它是一个静态方法。Bar::baz()
4赞 deceze 1/23/2012 #6

这主要是对我和@zerkms之间随后的聊天的总结(我的观点):

争论的焦点实际上是这样的:

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

    // do something with user

    return $someData;
}

这使得测试变得困难,因为它对类进行了硬编码,该类可能有很多依赖项,也可能没有很多依赖项。但这实际上与使用非静态方法实例化对象相同:doSomethingUser

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

这两个表达式都返回一个实例,并且都对类进行“硬编码”。无论如何,这只需要在某个时候发生。UserUser

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

评论

0赞 FtDRbwLXw6 1/23/2012
为了有一个不通过方法参数注入的依赖项(如连接),它必须保持某种状态。静态方法仅在允许它们维护/使用状态时才会引起问题。只要你的静态方法不这样做,那么可测试性就不会受到任何影响。请看我的回答。User::getByPhone()PDO
0赞 deceze 1/24/2012
@drrcknlsn 这是真的,但是:静态方法不能被嘲笑。即使它们是 100% 注入依赖项并且依赖关系可以被模拟,静态方法本身也不能被模拟。如果该方法正在做大量工作,或者(注入的)依赖项非常广泛以至于难以模拟,这可能很重要。在这种情况下,仅模拟方法本身会容易得多,这样可以更轻松地测试使用此方法的代码。这都是真的。只有使用静态方法的对象构造才是特例,这不会影响可模拟性。
0赞 deceze 11/6/2012
经过一段时间的禁欲后,我又仔细地看了一遍这里的所有答案。不幸的是,没有一个真正解决我在问题中提出的问题,即关于静态构造函数以及为什么它们不利于可测试性。我讨厌在这里用别人写的所有好东西来这样做,但我会接受我自己对此的回答。我还把这个问题写成了一篇文章:如何不使用静态来扼杀你的可测试性