Lightning fast tests at Dutch PHP Conference

Lightning fast tests at Dutch PHP Conference

One of the benefits of having an automated test suite is the feedback given when code is being changed. As the project grows, the test suite becomes slower and slower every day, until it’s so slow it stops being useful. Tests are disabled, skipped and finally removed.

Huge part of the problem lies in getting the testing pyramid wrong and putting to much effort into wrong type of testing.

Learn how to structure your project to benefit from lightning fast tests. Apply the right amount of testing on appropriate levels, and run your tests in seconds, not hours.

1a4e1f98f3aeef310273366c8c785207?s=128

Jakub Zalas

June 25, 2016
Tweet

Transcript

  1. LIGHTNING FAST TESTS Jakub Zalas https://www.flickr.com/photos/captainkimo/7934118876/ @jakub_zalas @jakzal DUTCH PHP

    CONFERENCE 25th of JUNE 2016 - RAI - AMSTERDAM
  2. WHAT MAKES TESTS SLOW? https://www.flickr.com/photos/davemedia/6158362891/

  3. Selenium Web server + PHP Symfony WebDriverTest DB API MyCode

    Controller Service A Service B GoutteTest BrowserKitTest ServiceTest TYPES OF AUTOMATED TESTS ➤ Integration ➤ UI ➤ Service ➤ Acceptance ➤ etc ➤ Unit
  4. if ($serviceA->isEnabled()) {
 $serviceB->callFoo();
 } else {
 $serviceB->callBar();
 }
 


    if ($serviceA->has('foo') && $serviceA->has('bar')) {
 $serviceB->callBaz();
 } COUNT EXECUTION PATHS 2 4 2 x 4 = 8
  5. EXECUTION PATHS IN AN INTEGRATION TEST MyCode Controller Repository Service

    A Service B 4 2 5 2 4 x 2 x 5 x 2 = 80
  6. EXECUTION PATHS IN AN INTEGRATION TEST MyCode Controller Repository Service

    A Service B 4 2 2 2 4 x 2 x 5 x 2 = 80 Service C 3 4 x 2 x 2 x 3 x 2 = 96
  7. FEEDBACK

  8. RELIABILITY

  9. SPEED’S NOT THE ONLY ISSUE https://www.flickr.com/photos/hsing/3580898648/ Unit Integration Fast Slow

    Reliable Brittle Good feedback Poor feedback Grow linearly Grow exponentially Force better design Put pressure off the design Isolated Can simulate user journeys
  10. TEST PYRAMID Unit Service UI Succeeding with Agile: Software Development

    Using Scrum, Mike Cohn
  11. INVERTED TEST PYRAMID Unit Service UI

  12. TESTING ICE-CREAM CONE Unit Service UI Manual tests http://watirmelon.com/2012/01/31/introducing-the-software-testing-ice-cream-cone/

  13. UNIT TESTING https://www.flickr.com/photos/ghozttramp/15390041831/

  14. IMPLEMENTING A PRICE CALCULATOR ➤ A small service to calculate

    a product price in chosen currency ➤ Products are stored in a database ➤ Currency converter is provided as a third party API
  15. THE OBVIOUS IMPLEMENTATION PriceCalculator Guzzle Client use Doctrine\ORM\EntityManager;
 use GuzzleHttp\Client;


    
 final class PriceCalculator
 {
 /**
 * @var EntityManager
 */
 private $em;
 
 /**
 * @var Client
 */
 private $guzzle;
 
 public function __construct(EntityManager $em, Client $guzzle)
 {
 $this->em = $em;
 $this->guzzle = $guzzle;
 }
 
 public function price($productUuid, Currency $targetCurrency)
 {
 }
 } Doctrine EntityManager
  16. PriceCalculator Guzzle Client public function price($productUuid, Currency $targetCurrency)
 {
 $products

    = $this->em->getRepository(Product::class);
 $product = $products->findOneByUuid($productUuid);
 
 if (!$product) {
 throw new ProductNotFoundException();
 }
 
 if ($product->isOutOfStock()) {
 throw new OutOfStockException();
 }
 
 if ($product->isPriceInCurrency($targetCurrency)) {
 return $product->getPrice();
 }
 
 $price = $product->getPrice();
 $query = http_build_query([
 'amount' => $price->getAmount(),
 'from_currency' => $price->getCurrencyCode(),
 'to_currency' => $targetCurrency->getCode(),
 ]);
 $response = $this->guzzle->get('/convert?'.$query);
 
 $price = json_decode($response->getBody());
 
 return new Price($price->amount, $targetCurrency);
 } Doctrine EntityManager THE OBVIOUS IMPLEMENTATION
  17. // Dummy
 $dummy = $this->prophesize(Foo::class);
 
 // Stub
 $stub =

    $this->prophesize(Foo::class);
 $stub->something(‘abc’)->willReturn('123');
 
 // Mock
 $mock = $this->prophesize(Foo::class);
 $mock->somethingImportant()->shouldBeCalled(); TEST DOUBLES https://www.flickr.com/photos/loriwright/164567533/
  18. UNIT TESTING THE PRICE CALCULATOR PriceCalculator class PriceCalculatorTest extends \PHPUnit_Framework_TestCase


    {
 const UUID = 'foo-u-u-i-d';
 
 public function test_it_converts_price_to_the_target_currency()
 {
 $price = new Price(200, new Currency('GBP'));
 $product = new TestProduct($price);
 $expectedPrice = new Price(277, new Currency('EUR'));
 
 $em = $this->prophesize(EntityManager::class); $productRepository = $this->prophesize(EntityRepository::class); $em->getRepository(Product::class)->willReturn($productRepository);
 $productRepository->findOneByUuid(self::UUID)->willReturn($product);
 
 $guzzle = $this->prophesize(Client::class); $guzzle->get(‘/convert?amount=200&from_currency=GBP&to_currency=EUR’); ->willReturn($this->createResponse(227));
 
 $priceCalculator = new PriceCalculator(
 $productRepository->reveal(),
 $guzzle->reveal()
 );
 $price = $priceCalculator->price(self::UUID, new Currency('EUR'));
 
 $this->assertSame($expectedPrice, $price);
 }
 } Guzzle Client Doctrine EntityManager Don’t mock what you don’t own!
  19. None
  20. Doctrine Manager Guzzle Client DB HTTP Unit tests Integration tests

    PriceCalculator
  21. UNIT TESTING THE PRICE CALCULATOR PriceCalculator Guzzle Client interface ProductRepository


    {
 /**
 * @param string $uuid
 *
 * @return Product
 *
 * @throws ProductNotFoundException if there is no product * for the given uuid
 */
 public function findOneByUuid($uuid);
 } ProductRepository
  22. UNIT TESTING THE PRICE CALCULATOR PriceCalculator CurrencyConverter interface CurrencyConverter
 {


    /**
 * @param Price $price
 * @param Currency $targetCurrency
 *
 * @return Currency
 */
 public function convert(Price $price, Currency $targetCurrency);
 } ProductRepository
  23. UNIT TESTING THE PRICE CALCULATOR PriceCalculator CurrencyConverter class PriceCalculatorTest extends

    \PHPUnit_Framework_TestCase
 {
 const UUID = 'foo-u-u-i-d';
 
 public function test_it_converts_price_to_the_target_currency()
 {
 $price = new Price(200, new Currency('GBP'));
 $product = new TestProduct($price);
 $expectedPrice = new Price(277, new Currency('EUR'));
 
 $productRepository = $this->prophesize(ProductRepository::class);
 $productRepository->findOneByUuid(self::UUID)
 ->willReturn($product);
 
 $currencyConverter = $this->prophesize(CurrencyConverter::class);
 $currencyConverter->convert($price, new Currency('EUR'))
 ->willReturn($expectedPrice);
 
 $priceCalculator = new PriceCalculator(
 $productRepository->reveal(),
 $currencyConverter->reveal()
 );
 $price = $priceCalculator->price(self::UUID, new Currency('EUR')); 
 $this->assertSame($expectedPrice, $price);
 }
 } ProductRepository
  24. UNIT TESTING THE PRICE CALCULATOR PriceCalculator CurrencyConverter final class PriceCalculator


    {
 /**
 * @var ProductRepository
 */
 private $products;
 
 /**
 * @var CurrencyConverter
 */
 private $converter;
 
 public function __construct(
 ProductRepository $products,
 CurrencyConverter $converter
 ) {
 $this->products = $products;
 $this->converter = $converter;
 }
 
 public function price($productUuid, Currency $targetCurrency)
 {
 $product = $this->products->findOneByUuid($productUuid);
 
 return $this->converter->convert(
 $product->getPrice(),
 $targetCurrency
 );
 }
 } ProductRepository
  25. UNIT TESTING THE PRICE CALCULATOR PriceCalculator CurrencyConverter /**
 * @expectedException

    OutOfStockException
 */
 public function test_it_throws_an_exception_if_product_is_out_of_stock() {
 $product = new OutOfStockProduct(
 new Price(200, new Currency('GBP'))
 );
 
 $productRepository = $this->prophesize(ProductRepository::class);
 $productRepository->findOneByUuid(self::UUID)
 ->willReturn($product);
 
 $currencyConverter = $this->prophesize(CurrencyConverter::class);
 
 $priceCalculator = new PriceCalculator(
 $productRepository->reveal(),
 $currencyConverter->reveal()
 );
 $priceCalculator->price(self::UUID, new Currency('EUR'));
 } ProductRepository
  26. UNIT TESTING THE PRICE CALCULATOR PriceCalculator CurrencyConverter final class PriceCalculator


    {
 // ...
 
 public function price($productUuid, Currency $targetCurrency)
 {
 $product = $this->products->findOneByUuid($productUuid);
 
 if ($product->isOutOfStock()) {
 throw new OutOfStockException($product);
 }
 
 return $this->converter->convert(
 $product->getPrice(),
 $targetCurrency
 );
 }
 } ProductRepository
  27. Doctrine Manager Guzzle Client DB HTTP Unit tests Integration tests

    PriceCalculator
  28. Doctrine Manager Guzzle Client DB HTTP Unit tests Integration tests

    PriceCalculator
  29. Doctrine Manager Guzzle Client DB HTTP Unit tests Integration tests

    CurrencyConverter ProductRepository PriceCalculator
  30. Doctrine Manager Guzzle Client DB HTTP Unit tests Integration tests

    CurrencyConverter ProductRepository DoctrineProductRepository GuzzleCurrencyConverter PriceCalculator
  31. INTEGRATION TESTING THE REPOSITORY PriceCalculator CurrencyConverter class DoctrineProductRepositoryTest extends \PHPUnit_Framework_TestCase


    {
 protected function setUp()
 {
 // set up the database with fixtures
 }
 
 public function test_it_returns_a_product_by_its_uuid()
 {
 $repository = new DoctrineProductRepository(
 $this->createEntityManager()
 );
 
 $product = $repository->findByUuid('known-u-u-i-d');
 
 $this->assertInstanceOf(Product::class, $product);
 }
 
 /**
 * @expectedException ProductNotFound
 */
 public function test_it_throws_an_exception_if_product_is_not_found( {
 $repository = new DoctrineProductRepository(
 $this->createEntityManager()
 );
 
 $repository->findByUuid('unknown-u-u-i-d');
 }
 } ProductRepository DoctrineProductRepository
  32. TWO SIDES OF THE CONTRACT ➤ An interface is a

    contract ➤ User of the interface makes assumptions about its behaviour in unit tests with test doubles ➤ The implementor of the interface needs to prove it implements the contract properly (test double assumptions are true) ➤ For each expectation in the unit test there should be at least one test to verify that expectation PriceCalculator CurrencyConverter ProductRepository DoctrineProductRepository
  33. Doctrine Manager Guzzle Client DB HTTP Unit tests Integration tests

    CurrencyConverter ProductRepository DoctrineProductRepository GuzzleCurrencyConverter PriceCalculator
  34. UNIT TESTING THE CURRENCY CONVERTER PriceCalculator CurrencyConverter interface AcmeApi
 {


    /**
 * @param int $price
 * @param string $fromCurrency
 * @param string $toCurrency
 *
 * @return int
 */
 public function convert($price, $fromCurrency, $toCurrency);
 } ProductRepository DoctrineProductRepository AcmeApiCurrencyConverter GuzzleCurrencyConverter AcmeApi
  35. UNIT TESTING THE CURRENCY CONVERTER PriceCalculator CurrencyConverter class AcmeApiCurrencyConverterTest extends

    \PHPUnit_Framework_TestCase
 {
 public function test_it_returns_the_original_price_if_no_conversion_ {
 $price = new Price(200, new Currency('GBP'));
 $targetCurrency = new Currency('GBP');
 
 $acmeApi = $this->prophesize(AcmeApi::class);
 
 $converter = new AcmeApiCurrencyConverter($acmeApi);
 $actualPrice = $converter->convert($price, $targetCurrency);
 
 $this->assertSame($price, $actualPrice);
 }
 
 public function test_it_calls_the_api_to_convert_the_currency()
 {
 $price = new Price(200, new Currency('GBP'));
 $targetCurrency = new Currency('GBP');
 
 $acmeApi = $this->prophesize(AcmeApi::class);
 $acmeApi->convert(200, 'GBP', 'EUR')->willReturn(277);
 
 $converter = new AcmeApiCurrencyConverter($acmeApi);
 $actualPrice = $converter->convert($price, $targetCurrency);
 
 $this->assertEquals(new Price(277, 'EUR'), $actualPrice);
 }
 } ProductRepository DoctrineProductRepository AcmeApiCurrencyConverter GuzzleCurrencyConverter AcmeApi
  36. INTEGRATION TESTING THE CURRENCY CONVERTER PriceCalculator CurrencyConverter use GuzzleHttp\Client;
 


    class GuzzleAcmeApiTest extends \PHPUnit_Framework_TestCase
 {
 public function test_it_converts_the_price()
 {
 $acmeApi = new GuzzleAcmeApi(new Client());
 
 $amount = $acmeApi->convert(200, 'GBP', 'EUR');
 
 $this->assertInternalType('int', $amount);
 }
 } ProductRepository DoctrineProductRepository AcmeApiCurrencyConverter GuzzleCurrencyConverter AcmeApi
  37. INTEGRATION TESTING THE CURRENCY CONVERTER PriceCalculator CurrencyConverter use GuzzleHttp\Client;
 


    final class GuzzleAcmeApi implements AcmeApi
 {
 /**
 * @var Client
 */
 private $guzzle;
 
 public function __construct(Client $guzzle)
 {
 $this->guzzle = $guzzle;
 }
 
 public function convert($price, $fromCurrency, $toCurrency)
 {
 $query = http_build_query([
 'amount' => $price,
 'from_currency' => $fromCurrency,
 'to_currency' => $toCurrency,
 ]);
 $response = $this->guzzle->get('/convert?'.$query);
 
 $price = json_decode($response->getBody());
 
 return (int) $price->amount;
 }
 } ProductRepository DoctrineProductRepository AcmeApiCurrencyConverter GuzzleCurrencyConverter AcmeApi
  38. Doctrine Manager Guzzle Client DB HTTP Unit tests Integration tests

    CurrencyConverter ProductRepository DoctrineProductRepository GuzzleCurrencyConverter PriceCalculator
  39. Doctrine Manager Guzzle Client DB HTTP Unit tests Integration tests

    CurrencyConverter ProductRepository DoctrineProductRepository GuzzleCurrencyConverter PriceCalculator
  40. Doctrine Manager Guzzle Client DB HTTP Unit tests Integration tests

    CurrencyConverter ProductRepository DoctrineProductRepository GuzzleCurrencyConverter PriceCalculator AcmeApi
  41. Doctrine Manager Guzzle Client DB HTTP Unit tests Integration tests

    CurrencyConverter ProductRepository DoctrineProductRepository GuzzleCurrencyConverter PriceCalculator AcmeApiCurrencyConverter AcmeApi
  42. HEXAGONAL ARCHITECTURE Ports & Adapters https://www.flickr.com/photos/ghozttramp/15390041831/ https://www.flickr.com/photos/25689440@N06/2537853794/

  43. PriceCalculator CurrencyConverter ProductRepository AcmeApiCurrencyConverter AcmeApi

  44. PriceCalculator CurrencyConverter ProductRepository AcmeApiCurrencyConverter AcmeApi DoctrineProductRepository Doctrine Manager

  45. PriceCalculator CurrencyConverter ProductRepository AcmeApiCurrencyConverter AcmeApi DoctrineProductRepository GuzzleCurrencyConverter Doctrine Manager Guzzle

    Client
  46. PriceCalculator CurrencyConverter ProductRepository AcmeApiCurrencyConverter AcmeApi DoctrineProductRepository GuzzleCurrencyConverter Doctrine Manager Guzzle

    Client InMemoryRepository
  47. PriceCalculator CurrencyConverter ProductRepository AcmeApiCurrencyConverter AcmeApi DoctrineProductRepository GuzzleCurrencyConverter Doctrine Manager Guzzle

    Client InMemoryRepository UI
  48. PriceCalculator CurrencyConverter ProductRepository AcmeApiCurrencyConverter AcmeApi DoctrineProductRepository GuzzleCurrencyConverter Doctrine Manager Guzzle

    Client InMemoryRepository UI PriceCalculatorController Symfony
  49. PriceCalculator CurrencyConverter ProductRepository AcmeApiCurrencyConverter AcmeApi DoctrineProductRepository GuzzleCurrencyConverter Doctrine Manager Guzzle

    Client InMemoryRepository UI PriceCalculatorController Symfony BehatContext Behat
  50. @jakub_zalas @jakzal Thank you. Rate my talk, please: https://joind.in/talk/998fb