It is being said that "static methods are death to testability". If that is so, what is a viable alternative pattern for the below?
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);
}
...
}
This is just a cut down example. The class has a few more methods and properties and there's more validation when writing to the database etc. The guiding design principle behind this class is that it models a user as an object. Some of the object's properties cannot be modified after it has been created, like the phone number (which acts as a primary id), the date the user was created and so on. Other properties can only be altered according to strict business rules, which all have strictly validating setters and getters.
The object does not represent a database record per se, the database is only seen as one possible form of permanent storage. As such, a database connector is not stored in the object but rather needs to be injected every time the object needs to interact with the database.
When a new user is created, this looks like:
$user = new User('+123456789');
When an existing user is restored from permanent storage, that looks like:
$pdo = new PDO('...');
$user = User::getByPhone($pdo, '+123456789');
If I were to take the "death to testability" line seriously, this is supposedly bad. I am perfectly able to test this object though, since it is fully dependency injected and the static
methods have no state. How could I do this any differently and avoid the use of static
methods? Or rather, what exactly argues against static
in this case? What makes this particular use of static
methods so hard to test?
Static methods themselves aren't untestable, but if the object being tested calls a static method then a test cannot "get in between" and make it call a stub method instead. If the object being tested instead calls a regular method, the test can give it an alternative object with a stub implementation of that method.
A static constructor is used to initialize any static data, or to perform a particular action that needs to be performed only once. It is called automatically before the first instance is created or any static members are referenced.
Constructors can't have any return type not even void. Static factory methods can return the same type that implements the method, a subtype, and also primitives. Inside the constructor, we can only perform the initialization of objects.
They are faster — Static methods are slightly faster than instance methods because in instance methods, you are also working with an implicit this parameter. Eliminating that parameter gives a slight performance boost in most programming languages.
This is mostly a summary of (my perspective of) the chat that ensued between me and @zerkms:
The point of contention is actually this:
public function doSomething($id) {
$user = User::getByPhone($this->pdo, $id);
// do something with user
return $someData;
}
This makes it hard to test doSomething
since it hardcodes the User
class, which may or may not have a lot of dependencies. But this is in fact the same as instantiating the object using a non-static method:
public function doSomething($id) {
$user = new User;
$user->initializeFromDb($this->pdo, $id);
// do something with user
return $someData;
}
We're not using a static method, but it's still unmockable. Actually, it got worse.
The answer is to use a factory:
public function doSomething($id) {
$user = $this->UserFactory->byPhone($id);
// do something with user
return $someData;
}
Now the factory can be dependency injected and mocked and the User
class is no longer hardcoded. You may or may not think this overkill, but it certainly improves mockability.
That does not change the fact though that this factory may very well instantiate the actual user object using a static method:
public function byPhone($id) {
return User::getByPhone($this->db, $id);
}
There's no difference between using a static method or a regular constructor here.
$user = new User($db, $id);
$user = User::getByPhone($db, $id);
Both expressions return an instance of User
and both "hardcode" the User
class. Which simply needs to happen at some point anyway.
For my use case, a static
constructor method makes the most sense for the object. And as was demonstrated, static
methods are not the problem. Where to call them is the point of contention, not that they exist at all. And I have yet to see a convincing argument for not using static constructors, since they can be wrapped in a factory, which alleviates any problem with mockability, the same as it does for regular object instantiation.
Static methods are only "death to testability" if they depend on state. If you avoid writing such methods to begin with (which you should), then this issue simply goes away.
The Math.abs()
example given is one of a good use of a static method. It does not depend on state, therefor it is super easily tested.
That said, whether or not you think static methods should be used is another story. Some people dislike their seemingly procedural nature. I agree with those who say that OOP is a tool, not a goal. If writing "proper" OO code doesn't make sense for a particular situation (e.g. Math.abs()
), then don't do it. I promise the boogey man won't eat your application just because you used a static method. :-)
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With