49
DIVERSIFIED APPLICATION TESTING BASED ON A SYLIUS PROJECT Łukasz Chruściel

Diversified application testing based on a Sylius project

Embed Size (px)

Citation preview

DIVERSIFIED APPLICATION TESTING

BASED ON A SYLIUS PROJECT

Łukasz Chruściel

AGENDA• Sylius in a nutshell

• Tests and their types

• PHPSpec usage for unit testing

• API testing with ApiTestCase

• New approach to Behat

• Summary

SYLIUSIN A NUTSHELL

WHAT IS SYLIUS?330+ contributors 10k+ contributions

5 years of development

Still in dev

A few revolutions behind us

60+ packages Full stack BDD

OSS Project

E-commerce platform

REVOLUTIONS?

Fixtures systemShop

Admin panel

Repositories

Factories

Translations

New approach of Behat

User handling

Products

Checkout flow

State machines

HOW DID WE HANDLE IT?

TESTS

TYPES OF TESTS• Unit

• Integration

• Functional

• Stress

• Performance

• Usability

• Acceptance

• Regression

• API

• GUI

TYPES OF TESTS• Unit

• Integration

• Functional

• Acceptance

• API

• GUI

PHPSPEC TO THE RESCUE!Unit Tests?

EASY TO UNDERSTAND

final class TypicalCalculatorSpec extends ObjectBehavior { function it_is_initializable() { $this->shouldHaveType(Calculator::class); }

function it_adds_two_numbers() { $this->add(2, 4)->shouldReturn(6); } }

Acme\AppBundle\Calculator\TypicalCalculator

25 ✔ is initializable 35 ✔ adds two numbers

1 specs 3 examples (3 passed) 11ms

final class TypicalCalculator { public function add($a, $b) { return $a + $b; } }

SOMETHING MORE SOPHISTICATED

class AverageRatingCalculator implements ReviewableRatingCalculatorInterface { public function calculate(ReviewableInterface $reviewable) { $sum = 0; $reviewsNumber = 0; $reviews = $reviewable->getReviews();

foreach ($reviews as $review) { if (ReviewInterface::STATUS_ACCEPTED === $review->getStatus()) { ++$reviewsNumber;

$sum += $review->getRating(); } }

return 0 !== $reviewsNumber ? $sum / $reviewsNumber : 0; } }

SOMETHING MORE SOPHISTICATED

function it_calculates_average_price( ReviewableInterface $reviewable, ReviewInterface $review1, ReviewInterface $review2 ) { $reviewable->getReviews()->willReturn([$review1, $review2]);

$review1 ->getStatus() ->willReturn(ReviewInterface::STATUS_ACCEPTED);

$review2 ->getStatus() ->willReturn(ReviewInterface::STATUS_ACCEPTED);

$review1->getRating()->willReturn(4); $review2->getRating()->willReturn(5);

$this->calculate($reviewable)->shouldReturn(4.5); }

SOMETHING MORE SOPHISTICATED

function it_returns_zero_if_given_reviewable_object_has_no_reviews( ReviewableInterface $reviewable

){ $reviewable->getReviews()->willReturn([]));

$this->calculate($reviewable)->shouldReturn(0); }

SOMETHING MORE SOPHISTICATED

function it_returns_zero_if_given_reviewable_object_has_reviews_but_none_of_them_is_accepted( ReviewableInterface $reviewable, ReviewInterface $review ) { $reviewable->getReviews()->willReturn([$review]); $review->getStatus()->willReturn(ReviewInterface::STATUS_NEW);

$this->calculate($reviewable)->shouldReturn(0); }

SOMETHING MORE SOPHISTICATED

class AverageRatingCalculator implements ReviewableRatingCalculatorInterface { public function calculate(ReviewableInterface $reviewable) { $sum = 0; $reviewsNumber = 0; $reviews = $reviewable->getReviews();

foreach ($reviews as $review) { if (ReviewInterface::STATUS_ACCEPTED === $review->getStatus()) { ++$reviewsNumber;

$sum += $review->getRating(); } }

return 0 !== $reviewsNumber ? $sum / $reviewsNumber : 0; } }

COLLABORATORS?namespace Sylius\Bundle\OrderBundle\NumberAssigner;

final class OrderNumberAssigner implements OrderNumberAssignerInterface { private $numberGenerator;

public function __construct(OrderNumberGeneratorInterface $numberGenerator) { $this->numberGenerator = $numberGenerator; }

public function assignNumber(OrderInterface $order) { if (null !== $order->getNumber()) { return; }

$order->setNumber($this->numberGenerator->generate($order)); } }

COLLABORATORS?namespace spec\Sylius\Bundle\OrderBundle\NumberAssigner;

final class OrderNumberAssignerSpec extends ObjectBehavior { function let(OrderNumberGeneratorInterface $numberGenerator) { $this->beConstructedWith($numberGenerator); }

function it_assigns_number_to_order( OrderInterface $order, OrderNumberGeneratorInterface $numberGenerator ) { $order->getNumber()->willReturn(null);

$numberGenerator->generate($order)->willReturn('00000007'); $order->setNumber('00000007')->shouldBeCalled();

$this->assignNumber($order); }

function it_does_not_assign_number_to_order_with_number( OrderInterface $order, OrderNumberGeneratorInterface $numberGenerator ) { $order->getNumber()->willReturn('00000007');

$numberGenerator->generate($order)->shouldNotBeCalled(); $order->setNumber(Argument::any())->shouldNotBeCalled();

$this->assignNumber($order); } }

WHAT DO WE TEST WITH PHPSPEC?

*Although we don’t spec repositories, nor forms.

EVERYTHING*

„BUT IT IS HARD/IMPOSSIBLE TO SPEC MY CODE!”

THEN SOMETHING SMELLS REALLY, REALLY BAD

API TESTING WITH APITESTCASE

JSON?

WHAT DO WE HAVE?

URL: Raw data to send:

Expected response:

POST /api/countries/ {"code": "BE"} { "id": 1, "code": "BE" }

namespace Sylius\Test\Controller;

class CountryApiTest extends JsonApiTestCase { public function testCreateCountryResponse() { $data = <<<EOT { "code": "BE" } EOT;

$this->client->request('POST', '/api/countries/', [], [], [], $data); $response = $this->client->getResponse(); $this->assertResponse($response, 'country/create_response', Response::HTTP_CREATED); } }

// test/Responses/Expected/country/create_response.json { "id": @integer@, "code": "BE" }

XML?

WHAT DO WE HAVE?

URL: Expected response:

GET /sitemap.xml <?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <url> <loc>http://sylius.org/products/mug-star-wars</loc> <lastmod>2015-10-10T00:00:00+02:00</lastmod> <changefreq>always</changefreq> <priority>0.5</priority> </url> <url> <loc>http://sylius.org/products/mug-lotr</loc> <lastmod>2015-10-04T00:00:00+02:00</lastmod> <changefreq>always</changefreq> <priority>0.5</priority> </url> <url> <loc>http://sylius.org/products/mug-breaking-bad</loc> <lastmod>2015-10-05T00:00:00+02:00</lastmod> <changefreq>always</changefreq> <priority>0.5</priority> </url> </urlset>

// test/Responses/Expected/sitemap/show_sitemap.xml <?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <url> <loc>http://sylius.org/products/mug-star-wars</loc> <lastmod>@[email protected]()</lastmod> <changefreq>always</changefreq> <priority>0.5</priority> </url> <url> <loc>http://sylius.org/products/mug-lotr</loc> <lastmod>@[email protected]()</lastmod> <changefreq>always</changefreq> <priority>0.5</priority> </url> <url> <loc>http://sylius.org/products/mug-breaking-bad</loc> <lastmod>@[email protected]()</lastmod> <changefreq>always</changefreq> <priority>0.5</priority> </url> </urlset>

namespace Sylius\Test\Controller;

class SitemapControllerApiTest extends XmlApiTestCase { public function testShowActionResponse() { $this->loadFixturesFromFile('resources/product.yml'); $this->client->request('GET', '/sitemap.xml');

$response = $this->client->getResponse();

$this->assertResponse($response, 'sitemap/show_sitemap'); } }

HELPS IN DEBUGGING

1) Sylius\Tests\Controller\OauthTokenApiTest::it_provides_an_access_token "3600" does not match "7200". @@ -1,7 +1,7 @@ { - "expires_in": 7200, + "expires_in": 3600, "token_type": "bearer", "scope": null, }

NEW APPROACH TO BEHAT

GOOD, OLD BEHAT

WHAT WAS THAT?

Uber Behat contexts

800+ lines of code

Many ancestors

Each Behat context got an `F` degree on scrutinizer

WAS IT WRONG?

/** * @Given /^I am on the page of ([^""(w)]*) "([^""]*)"$/ * @Given /^I go to the page of ([^""(w)]*) "([^""]*)"$/ */ public function iAmOnTheResourcePageByName($type, $name) { if ('country' === $type) { $this->iAmOnTheCountryPageByName($name);

return; } $this->iAmOnTheResourcePage($type, 'name', $name); }

WAS IT WRONG?

IT WAS TERRIBLE!

SOLUTION?

STYLE

@promotionsFeature: Checkout fixed discount promotions In order to handle product promotions As a store owner I want to apply promotion discounts during checkout

Background: Given store has default configuration And the following countries exist: | name | | Germany | | Poland | And there are following taxonomies defined: | name | | Category | And taxonomy "Category" has following taxons: | Clothing > Debian T-Shirts | And the following products exist: | name | price | taxons | | Woody | 125 | Debian T-Shirts | And the following promotions exist: | name | description | | 3 items | Discount for orders with at least 3 items | And all products are assigned to the default channel And all promotions are assigned to the default channel And promotion "3 items" has following rules defined: | type | configuration | | Item count | Count: 3,Equal: true | And promotion "3 items" has following actions defined: | type | configuration | | Fixed discount | Amount: 15 | And I am logged in as user "[email protected]"

Scenario: Fixed discount promotion is applied when the cart has the required amount Given I am on the store homepage When I add product "Woody" to cart, with quantity "3" Then I should be on the cart summary page And "Promotion total: -€40.00" should appear on the page And "Grand total: €295.00" should appear on the page

@receiving_discountFeature: Receiving fixed discount on cart In order to pay proper amount while buying promoted goods As a Visitor I want to have promotions applied to my cart

Background: Given the store operates on a single channel in "France" And the store has a product "PHP T-Shirt" priced at "€100.00" And the store has a product "PHP Mug" priced at "€6.00"

@ui Scenario: Receiving fixed discount for my cart Given there is a promotion "Holiday promotion" And it gives "€10.00" discount to every order When I add product "PHP T-Shirt" to the cart Then my cart total should be "€90.00" And my discount should be "-€10.00"

@receiving_discountFeature: Receiving percentage discount on shipping In order to pay decreased amount for shipping As a Customer I want to have shipping promotion applied to my cart

Background: Given the store operates on a single channel in "France" And the store has "DHL" shipping method with "€10.00" fee And the store has a product "PHP T-Shirt" priced at "€100.00" And there is a promotion "Holiday promotion" And I am a logged in customer

@ui Scenario: Receiving percentage discount on shipping Given the promotion gives "20%" discount on shipping to every order When I add product "PHP T-Shirt" to the cart And I proceed selecting "DHL" shipping method Then my cart total should be "€108.00" And my cart shipping total should be "€8.00"

100% BEHAT

TRANSFORMERS

<?php

final class PromotionContext implements Context { /** * @Then promotion :promotion should still exist in the registry */ public function promotionShouldStillExistInTheRegistry(PromotionInterface $promotion) { Assert::notNull($this->promotionRepository->find($promotion->getId())); } }

<?php

final class PromotionContext implements Context { /** * @Transform :promotion */ public function getPromotionByName($promotionName) { return $this->promotionRepository->findOneBy(['name' => $promotionName]); } }

@domain @uiScenario: Being unable to delete a promotion that was applied to an order When I try to delete a "Christmas sale" promotion Then I should be notified that it is in use and cannot be deleted And promotion "Christmas sale" should still exist in the registry

TAGSnamespace Sylius\Behat\Context\Domain;

final class OrderContext implements Context { /** * @When I delete the order :order */ public function iDeleteTheOrder(OrderInterface $order) { $this->orderRepository->remove($order); } }

namespace Sylius\Behat\Context\Ui;

final class OrderContext implements Context { /** * @When I delete the order :order */ public function iDeleteTheOrder(OrderInterface $order) { $this->showPage->open(['id' => $order->getId()]); $this->showPage->deleteOrder(); } }

@domain @ui Scenario: Deleted order should disappear from the registry When I delete the order "#00000022" Then I should be notified that it has been successfully deleted And this order should not exist in the registry

RECOMENDATIONS

Tags steps should be written in such a waythat can be interpreted in many contexts

Do not create a new object with Transformers

Shared storage container to keep data between contexts

Use ubiquitous language

PAGE OBJECT PATTERN

PAGE AS A SERVICE

namespace Sylius\Behat\Page\Admin\Account;

class LoginPage implements LoginPageInterface { public function specifyPassword($password) { $this->getDocument()->fillField('Password', $password); }

public function specifyUsername($username) { $this->getDocument()->fillField('Username', $username); } }

namespace Sylius\Behat\Context\Ui\Admin;

final class LoginContext implements Context { public function __construct(LoginPageInterface $loginPage) { $this->loginPage = $loginPage; }

/** @When I specify the username as :username */

public function iSpecifyTheUsername($username) { $this->loginPage->specifyUsername($username); }

/** @When I specify the password as :password */

public function iSpecifyThePasswordAs($password) { $this->loginPage->specifyPassword($password); } }

CONTEXT AS A SERVICEdefault: suites: ui_administrator_login: contexts_as_services: - sylius.behat.context.hook.doctrine_orm

- sylius.behat.context.transform.user

- sylius.behat.context.setup.channel - sylius.behat.context.setup.admin_user - sylius.behat.context.setup.user

- sylius.behat.context.ui.admin.login filters: tags: "@administrator_login && @ui"

namespace Sylius\Behat\Context\Ui\Admin;

final class LoginContext implements Context { public function __construct(LoginPageInterface $loginPage) { $this->loginPage = $loginPage; }

/** * @Given I want to log in */ public function iWantToLogIn(){…}

/** * @When I specify the username as :username */ public function iSpecifyTheUsername($username){…}

/** * @When I specify the password as :password */ public function iSpecifyThePasswordAs($password){…}

/** * @When I log in */ public function iLogIn(){…} }

SERVICES FTW!

SUMMARY

QUESTIONS?

Łukasz Chruściel @lukaszchruscielhttps://github.com/lchrusciel

Worth to see:http://www.phpspec.net/en/stable/http://docs.behat.org/en/v3.0/https://github.com/Lakion/ApiTestCasehttps://github.com/Sylius/Syliushttp://martinfowler.com/bliki/PageObject.htmlhttp://mink.behat.org/en/latest/https://github.com/FriendsOfBehat