Usamos cookies para medir audiência e melhorar sua experiência. Você pode aceitar ou recusar a qualquer momento. Veja sobre o iMasters.
Se todos Fleeps são Sloops e todos Sloops são Loopies, então todos Fleeps são definitivamente Loopies
No artigo sobre O.C.P., foi dito que as entidades de software devem ser abertas para extensão, mas fechadas para edição. Foram mostrados alguns problemas causados pelo acoplamento, como a dificuldade em se manter o código, e como refatorar o código inicial para que pudéssemos evitar edições durante seu ciclo de vida. Naquele artigo, você viu um código todo coberto por testes e resolveu seguir aquelas dicas na construção de um sistema de pagamentos. Seguindo a linha T.D.D., você começa a escrever seus testes e fica feliz com o resultado. Seu código ficou elegante, tem uma boa cobertura por testes, o acoplamento é baixo e a manutenção não terá tantos problemas, pelo menos não aparentemente.
Então, um novo requisito surge e, aquilo que parecia adequado, se mostra problemático. Após alguma depuração, você percebe que, apesar de todos os testes estarem passando, a aplicação falha. Isso ocorre porque os testes unitários trabalham com unidades isoladas, encapsuladas ou “mockadas”. Com isso, os testes unitários passam quando a aplicação, que é um conjunto de unidades que trabalham juntas, está falhando. O comportamento esperado de uma unidade pode parecer correto quando ela está sendo testada isoladamente, mas quando confrontada com seu tipo base, o comportamento continua como esperado? Nossos Fleeps estão se comportando como os Loopies que são?
Por volta 1994, Barbara Liskov e Jeannette Wing formularam um princípio de design que ficou conhecido como L.S.P., ou Princípio da Substituição de Liskov, e afirma o seguinte:
Let q(x) be a property provable about objects x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T.
Ou seja:
1. Se a variável x é do tipo T, então q(x) será verdadeiro.
2. Se a variável y é do tipo S e todo S é T, então q(y) deve ser verdadeiro.
Um sistema de pagamentos online
Vamos imaginar que tenhamos um e-commerce e que, após escolher alguns itens na loja, o cliente vá fazer o checkout. Além disso, nossa loja vai aceitar, nesse instante, pagamentos via Cartão de Crédito, comunicando diretamente com o serviço da Cielo. Como se trata de um checkout, começamos a implementação por ele:
O teste
<?php
namespace Neto\Commerce;
use Neto\Commerce\Shipping\ShippingMethod;
use Neto\Commerce\Payment\PaymentMethod;
class CheckoutTest extends \PHPUnit_Framework_TestCase
{
private function createConfiguredCheckout(ShoppingCart $shoppingCart,
ShippingMethod $shippingMethod,
PaymentMethod $paymentMethod)
{
$shippingFrom = '14400000';
$shippingTo = '01000000';
$checkout = new Checkout();
$checkout->configure($shoppingCart,
$paymentMethod,
$shippingMethod,
$shippingFrom,
$shippingTo);
return $checkout;
}
private function createShippingMethod($shippingAmount = 0)
{
$shippingMethod = $this->getMock('\Neto\Commerce\Shipping\ShippingMethod',
array('getShippingAmount'));
if ($shippingAmount > 0) {
$shippingMethod->expects($this->once())
->method('getShippingAmount')
->will($this->returnValue($shippingAmount));
}
return $shippingMethod;
}
private function createShoppingCart()
{
$shoppingCart = new ShoppingCart();
$shoppingCart->addItem(new Product(123, 'item 1', 100, 1, 2, 15, 30));
$shoppingCart->addItem(new Product(456, 'item 2', 100, 1, 2, 15, 30));
return $shoppingCart;
}
/**
* @testdox Checkout::configure() will throw an Exception if the cart is empty.
* @expectedException \LogicException
* @expectedExceptionMessage Cart is empty
*/
public function testConfigureWillThrowAnExceptionIfCartIsEmpty()
{
$shippingMethod = $this->createShippingMethod();
$paymentMethod = $this->getMock('\Neto\Commerce\Payment\PaymentMethod');
$shoppingCart = new ShoppingCart();
$this->createConfiguredCheckout($shoppingCart,
$shippingMethod,
$paymentMethod);
}
/**
* @testdox Checkout::configure() will configure the PaymentMethod with item total and shipping amount.
*/
public function testConfigureWillConfigureThePaymentTotalsWithCartTotals()
{
$shippingAmount = 10;
$shippingMethod = $this->createShippingMethod($shippingAmount);
$shoppingCart = $this->createShoppingCart();
$itemTotal = $shoppingCart->getItemTotal();
$paymentMethod = $this->getMock('\Neto\Commerce\Payment\PaymentMethod',
array('configure'));
$paymentMethod->expects($this->at(0))
->method('configure')
->with($itemTotal + $shippingAmount,
$itemTotal,
$shippingAmount);
$this->createConfiguredCheckout($shoppingCart,
$shippingMethod,
$paymentMethod);
}
/**
* @testdox Checkout::pay() will throw an Exception if called before Checkout::configure()
* @expectedException \BadMethodCallException
* @expectedExceptionMessage Checkout must be configured first
*/
public function testPayWillThrowAnExceptionIfNotConfiguredFirst()
{
$checkout = new Checkout();
$checkout->pay();
}
/**
* @testdox Checkout::pay() will calls PaymentMethod::pay()
*/
public function testPayWillCallsPaymentMethodPay()
{
$shippingMethod = $this->createShippingMethod();
$shoppingCart = $this->createShoppingCart();
$paymentMethod = $this->getMock('\Neto\Commerce\Payment\PaymentMethod',
array('pay', 'configure'));
$paymentMethod->expects($this->once())
->method('pay');
$checkout = $this->createConfiguredCheckout($shoppingCart,
$shippingMethod,
$paymentMethod);
$checkout->pay();
}
public function testCheckoutPayWillReturnTheValueReturnedByPaymentMethodPay()
{
$shippingMethod = $this->createShippingMethod();
$shoppingCart = $this->createShoppingCart();
$paymentMethod = $this->getMock('\Neto\Commerce\Payment\PaymentMethod',
array('pay', 'configure'));
$paymentMethod->expects($this->any())
->method('pay')
->will($this->onConsecutiveCalls(true, false));
$checkout = $this->createConfiguredCheckout($shoppingCart,
$shippingMethod,
$paymentMethod);
$this->assertTrue($checkout->pay());
$this->assertFalse($checkout->pay());
}
}
O código
<?php
namespace Neto\Commerce;
use Neto\Commerce\Shipping\ShippingMethod;
use Neto\Commerce\Payment\PaymentMethod;
class Checkout
{
/**
* @var \Neto\Commerce\Payment\PaymentMethod
*/
private $paymentMethod;
public function configure(ShoppingCart $shoppingCart,
PaymentMethod $paymentMethod,
ShippingMethod $shippingMethod,
$shippingFrom,
$shippingTo)
{
if (count($shoppingCart) == 0) {
throw new \LogicException('Cart is empty');
}
$itemTotal = $shoppingCart->getItemTotal();
$shippingAmount = $shoppingCart->getShippingAmount($shippingMethod,
$shippingFrom,
$shippingTo);
$paymentMethod->configure($itemTotal + $shippingAmount,
$itemTotal,
$shippingAmount);
$this->paymentMethod = $paymentMethod;
}
public function pay()
{
if ($this->paymentMethod === null) {
throw new \BadMethodCallException('Checkout must be configured first');
}
return $this->paymentMethod->pay();
}
}
O problema
Como estamos recebendo os dados do cartão do cliente diretamente na loja e utilizando o serviço da Cielo para autorizar o pagamento, esperamos que o método ‘PaymentMethod::pay()’ retorne ‘TRUE’ se a transação tiver autorizada, ou ‘FALSE’, caso a transação não tenha sido autorizada. De fato, a solução é simples e funciona perfeitamente para esse caso de uso.
Passado algum tempo, devido a uma questão de mercado, a loja resolve utilizar PayPal como solução de pagamento. Com o novo requisito, pegamos a interface PaymentMethod e fazemos a implementação do PayPal Express Checkout. Essa implementação é feita utilizando o método PaymentMethod::configure() para executar a operação SetExpressCheckout que configurará a transação, e o método PaymentMethod::pay() para executar executar a operação DoExpressCheckoutPayment, que finalizará a transação. Executando os testes unitários, veremos que a abordagem com PayPal funciona, todos os testes unitários passam e ficamos felizes, afinal, temos a possibilidade de pagamentos com Cielo e PayPal.
Mas realmente funciona?
O problema vai aparecer, quando fizermos um teste comportamental. Apesar dos testes unitários passarem, um pagamento via PayPal não se comporta como um pagamento via Cielo. Quando utilizamos a implementação PayPal segundo a interface PaymentMethod, percebemos que, para executar o método PaymentMethod::pay(), o método PaymentMethod::configure() precisará ser chamado antes. Como o método PaymentMethod::configure() cria um novo TOKEN, então passamos a ter uma nova transação criada na PayPal.
Isso acontece pois, isoladamente, os códigos estão corretos. Mas quando confrontamos a nova implementação com a abordagem inicial, um participante comporta-se de forma diferente do outro. Segundo a abordagem inicial, o novo participante é mais forte, ou seja, faz mais coisas do que deveria fazer. Quando estávamos trabalhando com Cielo, havia uma única chamada ao serviço, que criava a transação e verificava a autorização. Com a nova implementação, duas chamadas são feitas, uma para configurar a transação, que cria um novo TOKEN e consequentemente uma nova transação, e uma para finalizar a transação.
A implementação original abstraiu o método de pagamento e, de fato, Checkout não tem conhecimento sobre como a implementação é feita. Qualquer implementação síncrona, teoricamente, deve funcionar, mas como Checkout faz suposições sobre o fluxo de pagamento, a implementação falha quando é assíncrona.
Refatoração
O primeiro passo na refatoração desse código, é remover as suposições que o participante Checkout faz sobre o participante PaymentMethod. Isso é importante, pois somente assim conseguiremos trabalhar na refatoração do participante PaymentMethod. Se analisarmos o processo de checkout, veremos alguns passos comuns, independentemente do método de pagamento, por exemplo:
1. Cliente informa seus dados cadastrais;
2. Cliente escolhe o método de pagamento.
3. Cliente paga.
Apesar de não aparecer na lista acima, existe a criação do pedido. A criação desse pedido, naquele momento, tem um status que indica que ainda não foi pago. Após o cliente efetuar o pagamento, também existe um novo passo, que muda o status de pagamento para “pago”, “não autorizado”, etc.
O novo código:
<?php
namespace Neto\Commerce;
use \PDO;
use Neto\Commerce\Shipping\ShippingMethod;
use Neto\Commerce\Payment\PaymentSystem;
class Checkout
{
/**
* @var integer
*/
private $orderId;
/**
* @var \Neto\Commerce\PaymentSystem
*/
private $paymentSystem;
public function configure(ShoppingCart $shoppingCart,
PaymentSystem $paymentSystem,
ShippingMethod $shippingMethod,
$shippingFrom,
$shippingTo
{
//...
$this->paymentSystem = $paymentSystem;
$this->paymentSystem->attach($this);
}
//...
public function update(PaymentSystem $paymentSystem)
{
//update order with payment status
}
}
Se observarmos o novo Checkout, invertemos a responsabilidade pelo fluxo do checkout. O método configure fica responsável por configurar o checkout e dizer para o `PaymentSystem`: “Quando o status de um pagamento mudar, me atualize”. Dessa forma, qualquer mudança de status de pagamento, o método `Checkout::update` será chamado com as informações sobre o pagamento.
A nova interface ‘PaymentSystem’
<?php
namespace Neto\Commerce\Payment;
use Neto\Commerce\Checkout;
interface PaymentSystem
{
public function attach(Checkout $checkout);
public function notify();
}
Conclusão
O princípio O.C.P. nos diz que as entidades de software devem ser fechadas para edição, mas abertas para extensão. Quando estivermos estendendo uma abstração, devemos tomar cuidado sobre como isso está sendo feito. Se o novo participante for mais forte, ou mais fraco que o participante anterior, isso pode causar problemas, muitas vezes, muito difíceis de se notar. De fato, muitas vezes, uma violação ao princípio da substituição de Liskov fará a aplicação falhar, mesmo com todos os testes unitários passando.
Esse é, realmente, um dos princípios mais difíceis de se ilustrar de uma forma simples. O problema é lógico, mas demonstrar isso de uma forma simplista, é extremamente complexo, pelo menos com um caso de uso útil.
Um exemplo clássico muito utilizado, é o caso de uso do quadrado e do retângulo. Veja só:
<?php
class Rectangle
{
private $width;
private $height;
public function getHeight()
{
return $this->height;
}
public function getWidth()
{
return $this->width;
}
public function setHeight($height)
{
$this->height = $height;
}
public function setWidth($width)
{
$this->width = $width;
}
}
Ai, utilizando esse retângulo, temos um participante para calcular sua área:
<?php
class RectangleArea
{
public function calcArea(Rectangle $rectangle)
{
return $rectangle->getWidth() * $rectangle->getHeight();
}
}
Ai temos um teste para garantir que isso funciona:
<?php
class RectangleAreaTest
{
public function testCalcArea(Rectangle $rectangle)
{
$width = 10;
$height = 20;
//Definimos as dimensões do retângulo com valores conhecidos
//para que possamos comparar com a área esperada.
$rectangle->setWidth($width);
$rectangle->setHeight($height);
$rectangleArea = new RectangleArea();
//comparamos o valor calculado com o valor conhecido e verificamos
//se o resultado é o esperado.
if ($rectangleArea->calcArea($rectangle) == $width * $height) {
echo 'o teste passou';
} else {
echo 'o teste falhou';
}
}
}
$test = new RectangleAreaTest();
$test->testCalcArea(new Rectangle()); //o teste passou
Tudo certo, até aqui!
Porém, num belo dia, precisamos ter um quadrado também. Se pensarmos do ponto de vista matemático, um quadrado é um retângulo que possui os quatro lados com as mesmas dimensões. Então, derivamos a classe retângulo para criar o quadrado:
<?php
class Square extends Rectangle
{
public function setHeight($height)
{
parent::setHeight($height);
parent::setWidth($height);
}
public function setWidth($width)
{
parent::setHeight($width);
parent::setWidth($width);
}
}
Como pode ver, o participante quadrado possui os métodos Square::setHeight() e Square::setWidth() mais fortes que seu tipo base. Se pensarmos matematicamente, a implementação está correta. De fato, se fizermos um teste isolado, o comportamento esperado também está como o esperado:
<?php
$square = new Square();
$square->setWidth(10);
$area = new RectangleArea();
echo $area->calcArea($square); //100
Porém, se utilizarmos esse novo Square de forma não isolada, tentando substituir o tipo base pelo novo tipo, veremos que falha:
<?php
$test = new RectangleAreaTest();
$test->testCalcArea(new Square()); //o teste falhou
E falhou, pois apesar dos testes isolados estarem passando, o participante que realmente utilizava o tipo base, confiava em sua interface. Com o Square tendo métodos mas fortes que seu tipo base, o participante Square não substitui o seu tipo base, ou seja:
1. Se a variável x é do tipo T, então q(x) será verdadeiro.
2. Se a variável y é do tipo S e todo S é T, então q(y) deve ser verdadeiro.
Se você observar, o participante Square faz a segunda sentença ser falsa. Como o participante anterior define as dimensões em 10x20, q(y) deveria retornar 200, mas o participante Square é mais forte e o retorno é diferente do esperado.
:D
Esse exemplo é perfeito.
Bruno, acho que fica fácil entender assim: o problema nesse caso é a falta de confiança pois você pode "quebrar" a RectangleArea.
Bruno, acho que fica fácil entender assim: o problema nesse caso é a falta de confiança pois você pode "quebrar" a RectangleArea.
Exato!
O pior, é que você construir o teste unitário para o Square, ele vai passar, pois o teste verifica se o código da entidade testada, está correto.
Porém, se quando você deixa o isolamento do teste e coloca a nova entidade para trabalhar, substituindo seu tipo base, a aplicação falha.
Acho que essa parte eu entendi.
Então, o Teste passa pelo primeiro cenário porque o conceito matemático está correto, já que define um valor fixo para executar um cálculo matemático.
Já ao testar num caso isolado, que seria o mundo real, os valores do teste não são conhecidos e justamente o fato de o teste ter definido um valor fixo, a comparação jamais retornaria verdadeiro.
Certo?
Já ao testar num caso isolado, que seria o mundo real, os valores do teste não são conhecidos e justamente o fato de o teste ter definido um valor fixo, a comparação jamais retornaria verdadeiro.
Tá meio confuso isso, mas se eu entendi bem, acho que vc captou a coisa.
Numa definição mais informal do princípio de substituição Liskov, objetos de classes filhas devem ter o mesmo comportamento que os das classes mães.
Outro exemplo de violação do LSP:
class Bar {
public function something() {
return "something";
}
}
class Foo extends Bar {
public function something() {
throw new Exception('Not implemented yet');
}
}class BarTest {
public function testSomethingReturn(Bar $bar) {
echo $bar->something() == 'something' ? 'Ok' : 'Falhou';
}
}
(new BarTest())->testSomethingReturn(new Foo());
Haverá um erro fatal de exceção não tratada.
O que fazer então?
Se há alguma chance de as classes filhas da minha classe lancem uma exceção, eu preciso declarar isso na minha classe.
Em PHP não temos a cláusula como o [inline]throws[/inline] do Java, que já permite detectar automaticamente que o código acima dará erro na compilação, só nos resta então fazer isso a nível de documentação, com o [inline]@throws[/inline].
Sabendo (vendo da documentação) que a invocação do método PODE gerar uma exceção, agora podemos reescrever o teste como:
class BarTest {
public function testSomethingReturn(Bar $bar) {
try {
echo $bar->something() == 'something' ? 'Ok' : 'Falhou';
} catch (Exception $e) {
echo 'Falhou';
}
}
}
(new BarTest())->testSomethingReturn(new Foo());
Sim, o teste precisa falhar neste exemplo, pois aquela exceção não deveria estar ali. Se ela está, há grandes chances de que a relação de herança entre [inline]Foo[/inline] e [inline]Bar[/inline] precisar ser revista.
Viu como um exemplo mais palpável ajuda. ^_^
Vish... Eu devo estar com muito sono porque só entendi o Observer aí.
Se eu sacasse de Testes Unitários talvez entendesse melhor.