Test the entire system — end to end tests.
Does the system as a whole work behave as expected?
Test large scale components and libraries by actually using them.
Do my components work behave as expected?
Test the smallest units of your application.
For most applications built with an OO language, this means a class.
Do my individual routines and abstractions work behave as expected?
We're going to write tests for a shopping cart. It's a bad shopping cart — way too simple. Ignore that.
We're going to test the Cart object.
<?php // Product.php interface Product { public function name(); public function cost(); } // StubProduct.php class StubProduct implements Product { private $name; private $cost; public function __construct($name, $cost) { $this->name = $name; $this->cost = $cost; } public function name() { return $this->name; } public function cost() { return $this->cost; } }
<?php interface Product { public function name(); public function cost(); }
<?php // Cart.php interface Cart { public function addProduct(Product $prod); public function total(); } // DefaultCart.php class DefaultCart implements Cart { private $products = array(); public function addProduct(Product $prod) { $this->products[] = $prod; } public function total() { $total = 0.0; foreach ($this->products as $product) { $total += $product->amount(); } return $total; } }
<?php // Cart.php interface Cart { public function addProduct(Product $prod); public function total(); }
<?php class DefaultCartTest { use \Counterpart\Assert; // we will change this name public function testTotal() { } }
We want to make sure total works, so we're going to call it.
This will fail. We'll get to where $cart
comes from in a minute.
// we will change this name public function testTotal() { $total = $cart->total(); }
Now we need to check $total
. Let's test the simplest case:
an empty cart should have a zero total.
public function testTotal() { $total = $cart->total(); $this->assertEquals( 0.0, $total, 'Total should be zero for empty carts' ); }
A test's job is to fail. When it does fail, a test should give as much context as possible about what failed.
Name your tests based on behavior. Not methods.
Describe the behavor as a sentence, then put it in a test name.
Empty carts should have a zero total.
public function testEmptyCartHasZeroTotal() { $total = $cart->total(); $this->assertEquals( 0.0, $total, 'Total should be zero for empty carts' ); }
Lots of context on what behavior was incorrect or broken.
Now we can finally make the test pass: create the setup code necessary to run the test.
Simply creating the $cart
object is all we need to do.
public function testEmptyCartHasZeroTotal() { $cart = new DefaultCart(); $total = $cart->total(); $this->assertEquals( 0.0, $total, 'Total should be zero for empty carts' ); }
public function testEmptyCartHasZeroTotal() { // Setup $cart = new DefaultCart(); // Action $total = $cart->total(); // Verification $this->assertEquals( 0.0, $total, 'Total should be zero for empty carts' ); }
This is an entirely different behavior, so it should be a different test.
Cart total should be the sum of all its product costs.
<?php class DefaultCartTest { // ... public function testTotal() { } }
public function testTotal() { $total = $cart->total(); }
public function testTotal() { $total = $cart->total(); $this->assertEquals( 21.0, $total, 'Total should be the sum of all product costs' ); }
public function testTotalShouldBeTheSumOfProductCostsInCart() { $total = $cart->total(); $this->assertEquals( 21.0, $total, 'Total should be the sum of all product costs' ); }
public function testTotalShouldBeTheSumOfProductCostsInCart() { $cart = new DefaultCart(); $cart->addProduct(new StubProduct( 'one product', 10.50 )); $cart->addProduct(new StubProduct( 'two product', 10.50 )); $total = $cart->total(); $this->assertEquals( 21.0, $total, 'Total should be the sum of all product costs' ); }
Stick with it until you become fluent, then make it your own.
The more you practice, the more transparent the system becomes.
Like learning to cook a new recipe.
Behavior of an object rarely changes; it's to tied to its name and methods.
Tests driven by behavior have value as the application grows.
The shopping cart is going international, so simple float values won't fly any more.
Add a library! https://github.com/sebastianbergmann/money
<?php use SebastianBergmann\Money\Money; class StubProduct implements Product { private $name; private $cost; public function __construct($name, Money $cost) { $this->name = $name; $this->cost = $cost; } public function name() { return $this->name; } public function cost() { return $this->cost; } }
<?php use SebastianBergmann\Money\Currency; class DefaultCart implements Cart { const DEFAULT_CURRENCY = 'USD'; private $products = array(); private $currency; public function __construct(Currency $currency=null) { if (!$currency) { $currency = new Currency(self::DEFAULT_CURRENCY); } $this->currency = $currency; } public function addProduct(Product $prod) { $this->products[] = $prod; } public function total() { $total = new Money(0, $this->currency); foreach ($this->products as $product) { $total = $total->add($product->cost()); } return $total; } }
But the behavior they verify is still relevant.
Cart::total
to return a money object
<?php class DefaultCartTest { private function assertMoney($object) { $this->assertInstanceOf( 'SebastianBergmann\\Money\\Money', $object ); } }
<?php class DefaultCartTest { public function testEmptyCartHasZeroTotal() { // ... $this->assertMoney($total); // ... } public function testTotalShouldBeTheSumOfProductCostsInCart() { // ... $this->assertMoney($total); // ... } }
use SebastianBergmann\Money\Money; use SebastianBergmann\Money\Currency; public function testEmptyCartHasZeroTotal() { $currency = new Currency('USD'); $cart = new DefaultCart($currency); $total = $cart->total(); $this->assertMoney($total); $this->assertTrue( (new Money(0, $currency))->equals($total), 'Total should be zero for empty carts' ); }
use SebastianBergmann\Money\Money; use SebastianBergmann\Money\Currency; public function testTotalShouldBeTheSumOfProductCostsInCart() { $currency = new Currency('USD'); $cart = new DefaultCart($currency); $cart->addProduct(new StubProduct( 'one product', new Money(1050, $currency) )); $cart->addProduct(new StubProduct( 'two product', new Money(1050, $currency) )); $total = $cart->total(); $this->assertMoney($total); $this->assertTrue( (new Money(2100, $currency))->equals($total), 'Total should be the sum of all product costs' ); }
The action part of the tests stayed the same.
Nature of the assertions stayed (mostly) the same.
Every collaborator we've used so far has been a value object or another type of test double.
The product objects we've been using are stubs.
They let you check and define how messages are passed to collaborators.
https://github.com/padraic/mockery
<?php use SebastianBergmann\Money\Money; use SebastianBergmann\Money\Currency; $cart = \Mockery::mock('Chrisguitarguy\\RealUnitTesting\\Cart') ->shouldRecieve('total') ->once() ->andReturn(new Money(100, new Currency('USD'))); // ... $total = $cart->total(); // ... // throws if expectations weren't met \Mockery::close();
https://github.com/phpspec/prophecy — Prophecy is a full-featured test double framework.
<?php $prophet = new \Prophecy\Prophet(); $cartProph = $prophet->prophesize( 'Chrisguitarguy\\RealUnitTesting\\Cart' ); $cart = $cartProph->reveal(); $cart->total(); // throws if the method wasn't called $cartProph->total()->shouldHaveBeenCalled();
Mocks set up expectations before.
Spies verify expectations after.
<?php interface PaymentGateway { public function charge($amount); } interface Cart { // ... public function pay(PaymentGateway $gateway); } class DefaultCart implements Cart { // ... public function pay(PaymentGateway $gateway) { if (!$this->products) { throw new EmptyCartException(); } $gateway->charge($this->total()); } }
interface PaymentGateway { public function charge($amount); }
interface Cart { // ... public function pay(PaymentGateway $gateway); }
They are convenient.
Cart::pay
With a Spy<?php class PaymentGatewaySpy implements PaymentGateway { use \Counterpart\Assert; private $amount = null; public function charge($amount) { $this->amount = $amount; } public function assertCharged($amount) { $this->assertEquals($amount, $this->amount); } }
public function testPayChargesTheTotalAmountInTheCart() { $gateway = new PaymentGatewaySpy(); $currency = new Currency('USD'); $cart = new DefaultCart($currency); $cart->addProduct(new StubProduct( 'one product', new Money(1000, $currency) )); $cart->pay($gateway); $gateway->assertCharged(new Money(1000, $currency)); }
<?php public function testPayChargesTheTotalAmountInTheCart() { $currency = new Currency('USD'); $gateway = \Mockery::mock( 'Chrisguitarguy\\RealUnitTesting\\PaymentGateway' ); $gateway->shouldReceive('charge') ->once() ->with(\Mockery::mustBe( new Money(1000, $currency) )); $cart = new DefaultCart($currency); $cart->addProduct(new StubProduct( 'one product', new Money(1000, $currency) )); $cart->pay($gateway); }
In both, we care that the cart sent a message to the payment gateway — the behavior under test.
Especially for mocks you'll be creating a lot.
<?php class DefaultCartTest { // ... public function testPayChargesTheTotalAmountInTheCart() { $gateway = $this->paymentGateway(); // ... } private function paymentGateway() { return \Mockery::mock( 'Chrisguitarguy\\RealUnitTesting\\PaymentGateway' ); } }
Using the language of your domain.
Do Extract Method on your tests.
<?php public function testPayChargesTheTotalAmountInTheCart() { $currency = new Currency('USD'); $gateway = $this->paymentGateway(); $this->gatewayCharges( $gateway, new Money(1000, $currency) ); // ... }
<?php private function gatewayCharges( $paymentGateway, Money $expected ) { $paymentGateway->shouldReceive('charge') ->once() ->with(\Mockery::mustBe($expected)); }
Why haven't we mocked Money
objects?
Money
is a Value Object
As long as currency and amount are equal, Money
objects
are equal.
Just use them.
No need to mock every collaborator.
Do you need to mock that logger?
<?php use Psr\Log\NullLogger; use Psr\Log\LoggerInterface; use SebastianBergmann\Money\Money; use SebastianBergmann\Money\Currency; class DefaultCart implements Cart { // ... private $logger; public function __construct( Currency $currency=null, LoggerInterface $logger=null ) { // ... $this->logger = $logger ?: new NullLogger(); } public function total() { $total = new Money(0, $this->currency); foreach ($this->products as $product) { $total = $total->add($product->cost()); } $this->logger->info('Calculated total {total}{cur}', [ 'total' => $total->getAmount(), 'cur' => $total->getCurrency(), ]); return $total; } }
Has no impact on the object behavior.
It's a notification. Fire and forget.
<?php $this->logger->info('Calculated total {total}{cur}', [ 'total' => $total->getAmount(), 'cur' => $total->getCurrency(), ]);
Just use a dummy or null object.
Sometimes objects that are traditionally notifications are valuable to the business
Depends on the situation.
Don't write tests to cover lines.
Will instantly improve and clarify intent.
/