提问人:deceze 提问时间:1/23/2012 最后编辑:deceze 更新时间:1/23/2012 访问量:2695
“静态方法对可测试性来说是死亡的”——替代构造函数的替代品?
"Static methods are death to testability" - alternatives for alternative constructors?
问:
有人说“静态方法对可测试性来说是死亡”。如果是这样,那么以下可行的替代模式是什么?
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');
如果我认真对待“死于可测试性”这句话,这应该是不好的。不过,我完全能够测试这个对象,因为它是完全依赖注入的,并且方法没有状态。我怎样才能以不同的方式做到这一点并避免使用方法?或者更确切地说,在这种情况下究竟反对什么?是什么让这种特殊的方法使用如此难以测试?static
static
static
static
答:
正如评论中提到的,我会为这种情况实现一个存储库模式。
例如,将是一个具有只读属性的简单模型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、测试数据等)
评论
User::save(Storage $storage)
Storage
User
User
Storage::save($this)
User
Storage::save(array('phone' => $this->phone, ...))
User
我认为你给出的引文有一个很好的观点,但太难了。
你的静态方法就是他所说的“叶子”方法。在这种情况下,我认为你很好,只要你的静态方法没有任何外部依赖项。
另一种方法是数据映射器,一个对象,它知道数据库之间的关系以及如何存储在数据库中。例: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 类型<>表、实例<行>行对应关系),并且您可以完全更改序列化方法,而无需更改域对象。映射器测试起来也很明显。但在许多情况下,这种模式也被过度设计,超出了您的需求。毕竟,大多数网站都使用更简单的活动记录模式。
评论
$pdo
首先,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 - 要求每个新的或静态调用都有一个存根,以阻止设备到达自身外部。
依赖注入 - 可以注入模拟对象。它是无痛的。
评论
User
DateTime
createNew
selectByPhone
$objectGenerator->getNewWith('User', 'getByPhone', $pdo, 42)
User
只要 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';
}
}
现在我们不依赖于特定的实现,而是依赖于接口。现在我们可以很容易地模拟数据源。
这种抽象有助于使我们的测试更多地关注测试本身,而不是创建必要的环境。
这些步骤有助于我们快速进行单元测试。虽然我们仍然可以进行验收、负载和功能测试(但这是另一回事),以测试我们的应用程序是否按预期工作
评论
User
类型的对象时,它都可以保证处于良好、一致的状态。这使得类型提示很有用,错误处理也变得简单。这是 OOP 的一个相当大的观点。如果我需要能够创建“空”实例,我需要使用单独的非静态调用进行初始化,那么这种保证就消失了。在您的示例中,您在哪里实例化?User
$ds
User::staticmethod()
静态方法只有在依赖于状态时才是“可测试性的死亡”。如果你一开始就避免编写这样的方法(你应该这样做),那么这个问题就会消失。
给出的示例是静态方法的良好使用之一。它不依赖于状态,因此它非常容易测试。Math.abs()
也就是说,你是否认为应该使用静态方法是另一回事。有些人不喜欢他们看似程序化的性质。我同意那些说OOP是一种工具,而不是目标的人。如果编写“适当的”OO代码对特定情况没有意义(例如),那么就不要这样做。我保证,这个笨蛋不会因为你使用了静态方法而吃掉你的应用程序。:-)Math.abs()
评论
Foo::bar()
Bar::baz()
Bar::baz()
Foo::bar()
strlen()
Foo::bar()
Bar::baz()
Bar::baz()
这主要是对我和@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
评论
User::getByPhone()
PDO
评论
death to extensibility
private
User
new User
User::getByPhone