behat best practices with symfony
TRANSCRIPT
Behat Best Practices with SymfonyCiaran McNulty
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
Behaviour Driven Development
@ciaranmcnulty | #symfony_live
BDD is the art of using examples in conversations to illustrate behaviour
— Liz Keogh
@ciaranmcnulty | #symfony_live
BDD is the art of using examples in
conversations to illustrate behaviour
@ciaranmcnulty | #symfony_live
BDD is the art of using examples in
conversations to illustrate behaviour
@ciaranmcnulty | #symfony_live
BDD is the art of using examples in
conversations to illustrate behaviour
@ciaranmcnulty | #symfony_live
Example2 - Something that serves to illustrate or explain a rule— Wiktionary
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
Rule:We charge our customers sales tax at a rate of 20%
@ciaranmcnulty | #symfony_live
Rule:We charge our customers sales tax at a rate of 20%
Example:So, if an item is priced at $10, we charge $10 + $2 tax for a total of $12
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
Rule:We charge our customers sales tax at a rate of 20%
Example:So, if an item is priced at $10, we charge $10 + $2 tax for a total of $12
@ciaranmcnulty | #symfony_live
Rule:We charge our customers sales tax at a rate of 20%
Example:No! If an item is priced at £10, we charge £10and allocate £1.67 of that as sales tax
@ciaranmcnulty | #symfony_live
Capturing ExamplesInput -> Rules -> Output
@ciaranmcnulty | #symfony_live
Capturing ExamplesInput: When an action is takenOutput: Then an outcome should occur
@ciaranmcnulty | #symfony_live
Capturing ExamplesWhen I buy a pair of Levi 501sThen I am charged £32.99
@ciaranmcnulty | #symfony_live
Capturing ExamplesContext: Given some situationInput: When an action is takenOutput: Then an outcome should occur
@ciaranmcnulty | #symfony_live
Capturing ExamplesGiven Levi 501s are listed at £32.99When I buy a pair of Levi 501sThen I am charged £32.99
@ciaranmcnulty | #symfony_live
Context Questioning“Is there any other context which, when this event happens, will produce a different outcome?” - Liz Keogh
@ciaranmcnulty | #symfony_live
Context QuestioningGiven Levi 501s are listed at £32.99When I buy a pair of Levi 501sThen I am charged £32.99
“Is there any situation where I could buy these jeans and pay a different amount?”
@ciaranmcnulty | #symfony_live
Context QuestioningGiven Levi 501s are listed at £32.99When I buy a pair of Levi 501sThen I am charged £32.99
“Is there any situation where I could buy these jeans and pay a different amount?”
» When they are on sale
» When I get a staff discount
» When they are ex-display@ciaranmcnulty | #symfony_live
Outcome Questioning“Given this context, when this event happens, is there another outcome that’s important? Something we missed, perhaps?” - Liz Keogh
@ciaranmcnulty | #symfony_live
Outcome QuestioningGiven Levi 501s are listed at £32.99When I buy a pair of Levi 501sThen I am charged £32.99
"Aside from me being charged for the jeans, does something else happen that we need to care about?"
@ciaranmcnulty | #symfony_live
Outcome QuestioningGiven Levi 501s are listed at £32.99When I buy a pair of Levi 501sThen I am charged £32.99
"Aside from me being charged for the jeans, does something else happen that we need to care about?"
» I get given a pair of jeans!
» Stock control has to be told we sold them
@ciaranmcnulty | #symfony_live
Validating examples
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
Behat
@ciaranmcnulty | #symfony_live
Feature: Scheduling a training course
As a trainer In order to be able to cancel courses or schedule new ones I should be able to specify a maximum and minimum class size
Rules: - Course is proposed with size limits - When enough enrolments happen, course is considered viable - When maximum class size is reached, further enrolments are not allowed
Background: Given "BDD for Beginners" was proposed with a class size of 2 to 3 people
Scenario: Course does not get enough enrolments to be viable When only Alice enrols on this course Then this course will not be viable
Scenario: Course gets enough enrolments to be viable Given Alice has already enrolled on this course When Bob enrols on this course Then this course will be viable
Scenario: Enrolments are stopped when class size is reached Given Alice, Bob and Charlie have already enrolled on this course When Derek tries to enrol on this course Then he should not be able to enrol
@ciaranmcnulty | #symfony_live
Feature: Scheduling a training course
As a trainer In order to be able to cancel courses or schedule new ones I should be able to specify a maximum and minimum class size
Rules: - Course is proposed with size limits - When enough enrolments happen, course is considered viable - When maximum class size is reached, further enrolments are not allowed
@ciaranmcnulty | #symfony_live
Background: Given "BDD for Beginners" was proposed with a class size of 2 to 3 people
Scenario: Course does not get enough enrolments to be viable When only Alice enrols on this course Then this course will not be viable
@ciaranmcnulty | #symfony_live
Background: Given "BDD for Beginners" was proposed with a class size of 2 to 3 people
Scenario: Course does not get enough enrolments to be viable When only Alice enrols on this course Then this course will not be viable
@ciaranmcnulty | #symfony_live
Background: Given "BDD for Beginners" was proposed with a class size of 2 to 3 people
Scenario: Course gets enough enrolments to be viable Given Alice has already enrolled on this course When Bob enrols on this course Then this course will be viable
@ciaranmcnulty | #symfony_live
Background: Given "BDD for Beginners" was proposed with a class size of 2 to 3 people
Scenario: Enrolments are stopped when class size is reached Given Alice, Bob and Charlie have already enrolled on this course When Derek tries to enrol on this course Then he should not be able to enrol
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
Gherkin:
Given a thing happens to Ciaran
PHP:
/** * @Given a thing happens to :person */public function doAThing(string $person){ // you have to write this}
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
Driving the Domain layer» Drive PHP objects directly from scenario
» Proves domain supports business actions
» Aligns domain model with business language
» Executes quickly with few dependencies
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
Given "BDD for Beginners" was proposed with a class size of 2 to 3 peopleWhen only Alice enrols on this courseThen this course will not be viable
class FeatureContext implements Context{ /** * @Given :courseTitle was proposed with a class size of :min to :max people */ public function courseWasProposedWithAClassSizeOfToPeople(string $courseTitle, int $min, int $max) { $this->course = Course::propose( $courseTitle, ClassSize::between($min, $max) ); }}
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
Given "BDD for Beginners" was proposed with a class size of 2 to 3 peopleWhen only Alice enrols on this courseThen this course will not be viable
class FeatureContext implements Context{ /** @Transform */ public function transformLearner(string $name) : Learner { return Learner::called($name); }
/** * @When only :learner enrols on this course */ public function learnerEnrolsOnCourse(Learner $learner) { $this->course->enrol($learner); }}
@ciaranmcnulty | #symfony_live
Given "BDD for Beginners" was proposed with a class size of 2 to 3 peopleWhen only Alice enrols on this courseThen this course will not be viable
class FeatureContext implements Context{ /** * @Then this course will not be viable */ public function thisCourseWillNotBeViable() { assert($this->course->isViable() == false); }}
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
Given "BDD for Beginners" was proposed with a class size of 2 to 3 peopleGiven Alice has already enrolled on this courseWhen Bob enrols on this courseThen this course will be viable
class FeatureContext implements Context{ /** * @When only :learner enrols on this course * @Given :learner has already enrolled on this course */ public function learnerEnrolsOnCourse(Learner $learner) { $this->course->enrol($learner); }}
@ciaranmcnulty | #symfony_live
Given "BDD for Beginners" was proposed with a class size of 2 to 3 peopleGiven Alice has already enrolled on this courseWhen Bob enrols on this courseThen this course will be viable
class FeatureContext implements Context{ /** * @When (only) :learner enrols on this course * @Given :learner has already enrolled on this course */ public function learnerEnrolsOnCourse(Learner $learner) { $this->course->enrol($learner); }}
@ciaranmcnulty | #symfony_live
Given "BDD for Beginners" was proposed with a class size of 2 to 3 peopleGiven Alice has already enrolled on this courseWhen Bob enrols on this courseThen this course will be viable
class FeatureContext implements Context{ /** * @Then this course will be viable */ public function thisCourseWillBeViable() { assert($this->course->isViable() == true); }}
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
class Course{ //... public function isViable(): bool { return $this->classSize->isViable($this->learners); }}
class ClassSize{ //... public function isViable(int $size) : bool { return $size >= $this->min; }}
@ciaranmcnulty | #symfony_live
class Course{ //... public function isViable() { return $this->classSize->isViable($this->learners); }}
class ClassSize{ //... public function isViable(int $size) { return $size >= $this->min; }}
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
Given Alice, Bob and Charlie have already enrolled on this courseWhen Derek tries to enrol on this courseThen he should not be able to enrol
class FeatureContext implements Context{ /** * @Given :learner1, :learner2 and :learner3 have already enrolled on this course */ public function learnersHaveAlreadyEnrolledOnThisCourse( Learner $learner1, Learner $learner2, Learner $learner3 ) { $this->course->enrol($learner1); $this->course->enrol($learner2); $this->course->enrol($learner3); }}
@ciaranmcnulty | #symfony_live
Given Alice, Bob and Charlie have already enrolled on this courseWhen Derek tries to enrol on this courseThen he should not be able to enrol
class FeatureContext implements Context{ /** * @When :learner tries to enrol on this course */ public function learnerTriesToEnrolOnCourse(Learner $learner) { try { $this->course->enrol($learner); } catch (\Exception $e) { $this->enrolmentProblem = $e; } }}
@ciaranmcnulty | #symfony_live
Given Alice, Bob and Charlie have already enrolled on this courseWhen Derek tries to enrol on this courseThen he should not be able to enrol
class FeatureContext implements Context{ /** * @Then (s)he should not be able to enrol */ public function learnerShouldNotBeAbleToEnrol() { assert($this->enrolmentProblem instanceof Cjm\Training\EnrolmentProblem); }}
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
final class Course{ public function enrol(Learner $learner) { if (!$this->classSize->hasMoreCapacity($this->learners)) { throw new EnrolmentProblem('Class is already at capacity'); }
$this->learners++; }}
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
Driving the Service layer» Configure services in test SF environment
» Inject services into Behat with behat/symfony2extension
» Interact with domain model via the services
» Aligns service layer with business use cases
@ciaranmcnulty | #symfony_live
# behat.ymldefault: suites: domain: contexts: [ DomainContext ]
services: contexts: [ ServiceContext ]
extensions: Behat\Symfony2Extension: ~
@ciaranmcnulty | #symfony_live
Given "BDD for Beginners" was proposed with a class size of 2 to 3 peopleWhen only Alice enrols on this courseThen this course will not be viable
class ServiceContext implements Context{ public function __construct(CourseEnrolments $courseEnrolments) { $this->courseEnrolments = $courseEnrolments; }
/** * @Given :course was proposed with a class size of :min to :max people */ public function wasProposedWithAClassSizeOfToPeople(string $course, int $min, int $max) { $this->course = $course; $this->courseEnrolments->propose($course, $min, $max); }}
@ciaranmcnulty | #symfony_live
Given "BDD for Beginners" was proposed with a class size of 2 to 3 peopleWhen only Alice enrols on this courseThen this course will not be viable
class ServiceContext implements Context{ public function __construct(CourseEnrolments $courseEnrolments) { $this->courseEnrolments = $courseEnrolments; }
/** * @Given :course was proposed with a class size of :min to :max people */ public function wasProposedWithAClassSizeOfToPeople(string $course, int $min, int $max) { $this->course = $course; $this->courseEnrolments->propose($course, $min, $max); }}
@ciaranmcnulty | #symfony_live
# behat.ymldefault: suites: domain: contexts: - DomainContext
services: contexts: - ServiceContext: courseEnrolments: "@cjm.training.enrolment.course_enrolments"
extensions: Behat\Symfony2Extension: ~
@ciaranmcnulty | #symfony_live
class CourseEnrolments{ public function propose(string $title, int $minimum, int $maximum) { $this->courses->add( Course::propose( $title, ClassSize::between($minimum, $maximum) ) ); }}
@ciaranmcnulty | #symfony_live
Repositories» Using real infrastructure is slow
» Using fake infrastructure can lower confidence
» Use fake infrastructure but sync via contract tests
@ciaranmcnulty | #symfony_live
final class Courses implements \Cjm\Training\Enrolment\Model\Courses{ public function add(Course $course) : void { $this->courses[] = $course; }
public function findByTitle(string $title): Course { return $this->courses[0]; }}
@ciaranmcnulty | #symfony_live
Given "BDD for Beginners" was proposed with a class size of 2 to 3 peopleWhen only Alice enrols on this courseThen this course will not be viable
class ServiceContext implements Context{ /** * @When (only) :learner enrols on this course */ public function learnerEnrolsOnThisCourse(string $learner) { $this->courseEnrolments->enrol($learner, $this->course); }}
@ciaranmcnulty | #symfony_live
Given "BDD for Beginners" was proposed with a class size of 2 to 3 peopleWhen only Alice enrols on this courseThen this course will not be viable
class ServiceContext implements Context{ /** * @Then this course will not be viable */ public function thisCourseWillNotBeViable() { assert($this->courseEnrolments->isCourseViable($this->course) == false); }}
@ciaranmcnulty | #symfony_live
Domain vs Service layer» Start by driving domain layer
» Refactor to services when confidence grows
» Drop back to domain layer when remodelling
@ciaranmcnulty | #symfony_live
@ciaranmcnulty | #symfony_live
Driving the UI layer» Simulate a browser with behat/minkextension
» Interact with domain model via the UI
» Ensures UI supports business actions
» Slow, brittle, flakey...
» Does not constrain API
@ciaranmcnulty | #symfony_live
Don't do thisScenario: Buying a pair of jeans Given I am on "/products/levi-501" When I click on "#add-form input[type=submit]" Then "#basket ul" should contain "jeans"
@ciaranmcnulty | #symfony_live
Mink» Browser driver abstraction
» Supports Selenium, Goutte, Browserkit
@ciaranmcnulty | #symfony_live
# behat.ymlendtoend: filters: tags: "@endtoend" suites: domain: false services: false endtoend: contexts: - EndToEndContext: courseEnrolments: "@cjm.training.enrolment.course_enrolments"
@ciaranmcnulty | #symfony_live
# behat.yml extensions: Behat\Symfony2Extension: env: test_e2e Behat\MinkExtension: sessions: symfony: symfony2: ~
@ciaranmcnulty | #symfony_live
Given "BDD for Beginners" was proposed with a class size of 2 to 3 peopleWhen only Alice enrols on this courseThen this course will not be viable
class EndToEndContext implements RawMinkContext{ /** * @Given :course was proposed with a class size of :min to :max people */ public function wasProposedWithAClassSizeOfToPeople(string $course, int $min, int $max) { $this->course = $course; $this->courseEnrolments->propose($course, $min, $max); }}
@ciaranmcnulty | #symfony_live
Given "BDD for Beginners" was proposed with a class size of 2 to 3 peopleWhen only Alice enrols on this courseThen this course will not be viable
class EndToEndContext implements RawMinkContext{ /** * @When only :learner enrols on this course */ public function learnerEnrolsOnCourse(string $learner) { $this->visitPath('/courses/' . $this->course);
$page = $this->getSession()->getPage(); $page->fillField('Your name', $learner); $page->pressButton('Enrol'); }}
@ciaranmcnulty | #symfony_live
Given "BDD for Beginners" was proposed with a class size of 2 to 3 peopleWhen only Alice enrols on this courseThen this course will not be viable
class EndToEndContext implements RawMinkContext{ /** * @Then this course will not be viable */ public function thisCourseWillNotBeViable() { $this->visitPath('/courses/'.$this->course);
$this->assertSession()->elementExists('css', '#not-viable-warning'); }}
@ciaranmcnulty | #symfony_live
Automating a real browser» Avoid or minimise
» Orders of magnitude slower
» Required for end-to-end with JS
» Replace with JS cucumber stack
@ciaranmcnulty | #symfony_live
# behat.yml extensions: Behat\MinkExtension: sessions: symfony: symfony2: ~ selenium2: browser: chrome capabilities: chrome: switches: - "--headless" - "--disable-gpu"
@ciaranmcnulty | #symfony_live
Summary» Drive domain objects directly to explore model
» Refactor to services when model is stable
» Add minimal UI coverage
@ciaranmcnulty | #symfony_live
Thanks» @ciaranmcnulty
» @Inviqa
» @PhpSpec
» @BDDLondon
github.com/ciaranmcnulty/behat-symfony-demo
@ciaranmcnulty | #symfony_live