$30 off During Our Annual Pro Sale. View Details »

Lightning fast Symfony tests

Jakub Zalas
December 04, 2015

Lightning fast Symfony tests

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

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

Jakub Zalas

December 04, 2015
Tweet

More Decks by Jakub Zalas

Other Decks in Programming

Transcript

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

    View Slide

  2. WHAT MAKES TESTS SLOW?
    https://www.flickr.com/photos/davemedia/6158362891/

    View Slide

  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
    ➤ Unit

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  7. FEEDBACK

    View Slide

  8. RELIABILITY

    View Slide

  9. TEST PYRAMID
    Unit
    Service
    UI
    Succeeding with Agile: Software Development Using Scrum, Mike Cohn

    View Slide

  10. INVERTED TEST PYRAMID
    Unit
    Service
    UI

    View Slide

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

    View Slide

  12. 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

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

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

    View Slide

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

    {

    /**

    * @param string $uuid

    *

    * @return Product

    *

    * @throws ProductNotFoundException

    */

    public function findOneByUuid($uuid);

    }
    ProductRepository

    View Slide

  19. 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

    View Slide

  20. 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

    View Slide

  21. 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

    View Slide

  22. 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

    View Slide

  23. 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

    View Slide

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

    View Slide

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

    View Slide

  26. 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

    View Slide

  27. 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

    View Slide

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

    View Slide

  29. 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

    View Slide

  30. 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

    View Slide

  31. 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

    View Slide

  32. 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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  36. PriceCalculator CurrencyConverter
    ProductRepository AcmeApiCurrencyConverter
    AcmeApi
    DoctrineProductRepository
    GuzzleCurrencyConverter
    Doctrine Manager
    Guzzle Client
    InMemoryRepository
    PriceCalculatorController
    Symfony
    BehatContext
    Behat

    View Slide

  37. @jakub_zalas @jakzal
    Thank you.
    Rate my talk, please:
    https://joind.in/16482

    View Slide