New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Helper containers #974

Merged
merged 14 commits into from Dec 25, 2016

Conversation

Projects
None yet
3 participants
@everzet
Member

everzet commented Dec 20, 2016

Narrative

Since I introduced support for multiple contexts in 3.0, the most asked question among the Behat newcomers goes along the lines of "how do I share state between multiple contexts?". I was reluctant to provide a systematic, built-in answer to this question for two reasons:

  1. Behat is not your dependency management framework. Wiring applications together is usually a responsibility of application frameworks. I considered that it is better left to Symfony2Extension and friends to provide servicing configuration.
  2. I generally consider sharing state between contexts to be an anti-pattern. It often highlights wrongly drawn context boundaries and trades off organizational simplicity for a short-term convenience.

Late push for container interoperability made me believe I finally have an answer to sharing state without giving Behat responsibility it ultimately wasn't built for. On top of that, recently conducted by me coaching, pairing and conversations with people inside and across the communities (thanks @tooky) made me realize that state/behaviour sharing still has very legitimate use-cases in many situations. Especially for Modelling by Example and similar workflows.

I believe this PR to be an elegant answer to the need without breaking very clear responsibility context Behat has. This PR is me finally trying to answer the question most community members keep asking.

General principles

Before starting with the spec, I decided on two balancing principles that the solution must follow:

  1. Convenience limited to simple cases. It should be simple enough to never get to the point where Behat gets confused with the application framework. Service sharing should work seamlessly for simple cases, but nudge people towards proper dependency management approaches in more complicated ones. If all you need is to instantiate an isolated environment with 1-2 independent, but shared services, you should be able to bootstrap in seconds.
  2. Complex cases served via interoperability. In case of complex cases Behat must favour reuse. Whatever artefact Behat users end up producing as part of their service sharing procedure should be fairly decoupled from Behat and reusable with other, unaware of Behat frameworks. If you spend a lot of time wiring up your complex dependency tree for Behat, that work shouldn't be lost on your tests alone.

Enter helper containers

Helper containers are service containers that you can configure to provide shared services for your contexts. And those services indeed be shared between your contexts, with all state and behaviour. Here's simple rules I followed in designing them:

  • A single optional container is allowed per suite
  • Having container enables you to use its services as context arguments via @name syntax
  • Container is rebuilt and is isolated between scenarios
  • Container is configured via suite's services option
  • Container is a class implementing Interop\Container\ContainerInterface
  • There is a built-in container if you need a very simple service-sharing, configurable through the same services setting
  • There is an extension point that allows Behat extensions provide their own containers for end-users via @name syntax

How to use a helper container

The rest of this PR will go in more general details on how to use the feature. For more detailed info and examples, please read the feature file.

Built-in container

The simplest way of using helper container is to define a service (or services) that would be injected (and shared) between your contexts in the scope of a single scenario:

default:
  suites:
    default:
      contexts:
        - FirstContext:
          - "@shared_service"
        - SecondContext:
          - "@shared_service"

      services:
        shared_service: "SharedService"

This definition will make Behat automatically instantiate SharedService before each scenario and pass it as a first argument (via @... notation) to both FirstContext and SecondContext - exact same instance.

There's even a bit more verbose syntax that allows you to provide custom arguments and even factory method for each class:

      services:
        shared_service:
          class: "SharedService"
          factory_method: "fromNothing"
          arguments: []

Please note that built-in container does not allow you to use defined services as other service arguments. This is to both keep the built-in container away from becoming a full-blown service container and to nudge you towards external container in cases where you do need a deeply nested, highly-wired dependency tree.

External container class

Alternatively, you can use a custom container class that implements Interop\Container\ContainerInterface:

default:
  suites:
    default:
      contexts:
        - FirstContext:
          - "@shared_service"
        - SecondContext:
          - "@shared_service"

      services: "MyContainer"

Don't forget the class itself. You can even place it near you context classes (in features/bootstrap):

<?php

use Interop\Container\ContainerInterface;

final class MyContainer implements ContainerInterface
{
    // ...
}

And yes, you can utilise custom factory methods in those too:

default:
  suites:
    default:
      contexts:
        - FirstContext:
          - "@shared_service"
        - SecondContext:
          - "@shared_service"

      services: "MyContainer::fromEnvironment"

Container from extension

The third and the last way is to use containers provided by your favourite extensions. Like that:

default:
  suites:
    default:
      contexts:
        - FirstContext:
          - "@shared_service"
        - SecondContext:
          - "@shared_service"

      services: "@symfony_extension.container"

The example above doesn't yet work, because it requires extension authors to actually provide that container explicitly from withing their config. But I hope they'll soon follow. Feel free to help them :).

Further details

For further details, as usual, I advise you to dive into the feature file itself. It provides ultimate information, reasoining and examples behind this feature. Oh, yeah, it also is a stellar automated test to make sure I implemented feature correctly in the first place :)

As usual, feedback is greatly appreciated.

everzet added some commits Dec 20, 2016

@everzet everzet added the feature label Dec 20, 2016

everzet added some commits Dec 20, 2016

"symfony/event-dispatcher": "~2.1||~3.0",
"symfony/translation": "~2.3||~3.0",
"symfony/yaml": "~2.1||~3.0",
"symfony/class-loader": "~2.1||~3.0",

This comment has been minimized.

@stof

stof Dec 21, 2016

Member

I would actually stop aligning constraint to avoid such kind of diffs

This comment has been minimized.

@everzet

everzet Dec 21, 2016

Member

Agreed. I grown to dislike this style too. Was just going through motions.

{
if (method_exists($definition, 'isShared')) {
return $definition->isShared();
} else if (method_exists($definition, 'getScope')) {

This comment has been minimized.

@stof

stof Dec 21, 2016

Member

if is enough thanks to the return. And I'm not even sure we need this second check

This comment has been minimized.

@everzet

everzet Dec 21, 2016

Member

To stop static analysis tools (like Scrutinizer) from complaining about non-existing method :(

*
* @return bool
*
* @deprecated remove after upgrading to Symfony 2.8+

This comment has been minimized.

@stof

stof Dec 21, 2016

Member

you should not mark it as deprecated, as you should not call deprecated APIs internally. And this is a private function anyway.
This looks more like a TODO than a deprecation here btw

This comment has been minimized.

@everzet

everzet Dec 21, 2016

Member

Good shout. I kinda use these interchangeably.

$references = $this->processor->findAndSortTaggedServices($container, self::HELPER_CONTAINER_TAG);
foreach ($references as $reference) {
if ($this->isDefinitionShared($container->getDefinition($reference))) {

This comment has been minimized.

@stof

stof Dec 21, 2016

Member

should be (string) $reference as getDefinition expects a string (the id), not a Reference object

This comment has been minimized.

@everzet

everzet Dec 21, 2016

Member

Implicit conversion VS explicit conversion. I'm easier with this case than the one you mentioned next. In this particular case it is indeed unclear that what I am expected to pass to $container->getDefinition(...) is a string, not an object. I'd be happy to make it explicit.

This comment has been minimized.

@stof

stof Dec 21, 2016

Member

Thus Symfony never explicitly mentions that getDefinition supports castable objects. If it works today, it is purely because of an implementation detail (probably because of using lowercase which supports it). So it means that passing a castable object is not covered by the BC policy (as it is a case not respecting the documented contract). So making it explicit is necessary to be future-proof

This comment has been minimized.

@everzet

everzet Dec 21, 2016

Member

Thanks for explanation. Makes perfect sense and I would change it in this case.

foreach ($references as $reference) {
if ($this->isDefinitionShared($container->getDefinition($reference))) {
throw new WrongServicesConfigurationException(sprintf(
'Container services must not be configured as shared, but `@%s` is.', $reference

This comment has been minimized.

@stof

stof Dec 21, 2016

Member

same here about using the string

This comment has been minimized.

@everzet

everzet Dec 21, 2016

Member

This again can be considered implicit VS explicit. Except in this case I obviously tell sprintf I need a string (@%s). Hence, I'd argue conversion is already explicit here and additional conversion would only confuse things.

That is unless you have a different tech/clarity reason I haven't thought of yet :)

This comment has been minimized.

@stof

stof Dec 21, 2016

Member

yeah, I was not sure about it being necessary here.

This comment has been minimized.

@everzet

everzet Dec 21, 2016

Member

Cool. I'll introduce casting only to the getDefinition(...) case then.

*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
interface ServiceContainer extends ContainerInterface

This comment has been minimized.

@stof

stof Dec 21, 2016

Member

what is the goal of this interface, when the code actually expects Interop\Container\ContainerInterface anyway ?

This comment has been minimized.

@everzet

everzet Dec 21, 2016

Member

I was trying to give people who don't care about Interop\Container\ContainerInterface a clear, transparent entry.

This comment has been minimized.

@everzet

everzet Dec 21, 2016

Member

I'm actually not super-attached to it.

This comment has been minimized.

@everzet

everzet Dec 21, 2016

Member

Actually, having separate interface breaks the second general principle. So I'll go ahead and drop Behat\Behat\HelperContainer\ServiceContainer.

@everzet

This comment has been minimized.

Member

everzet commented Dec 21, 2016

@stof thanks for your feedback, as always. I fixed everything you highlighted.

@ciaranmcnulty

This looks good - have you checked to see how it interacts with Symfony2Extension?

Specialised syntax in the config file, i.e. @-prefixed values, may not come under the normal BC rules but we should consider this.

Rules:
- A single optional container is allowed per suite
- Having container enables you to use its services as context arguments via `@name` syntax

This comment has been minimized.

@ciaranmcnulty

ciaranmcnulty Dec 21, 2016

Contributor

'Having a container"

*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
final class AggregateFactory implements SuiteScopedResolverFactory

This comment has been minimized.

@ciaranmcnulty

ciaranmcnulty Dec 21, 2016

Contributor

I'd rather this was called Composite, after the design pattern. Aggregate risks confusion with the DDD concept, or other usages where an aggregate is a collection

*
* @author Konstantin Kudryashov <ever.zet@gmail.com>
*/
final class NullFactory implements SuiteScopedResolverFactory

This comment has been minimized.

@ciaranmcnulty

ciaranmcnulty Dec 21, 2016

Contributor

There might be better naming - noop from the comment makes sense, or maybe empty?

This comment has been minimized.

@everzet

everzet Dec 21, 2016

Member

If we follow the pattern naming from the previous comment, we keep following it for null objects :)

*/
private function createContainerFromString($settings)
{
if ('@' === mb_substr($settings, 0, 1)) {

This comment has been minimized.

@ciaranmcnulty

ciaranmcnulty Dec 21, 2016

Contributor

A similar check takes place in ServicesResolverFactory - can these be done in one place? Perhaps a value object?

This comment has been minimized.

@everzet

everzet Dec 21, 2016

Member

Potentially. But that's too small of an op to worry about reuse at this point.

@everzet

This comment has been minimized.

Member

everzet commented Dec 21, 2016

Specialised syntax in the config file, i.e. @-prefixed values, may not come under the normal BC rules but we should consider this.

@ciaranmcnulty @ prefix only works when you have services: configuration option provided. So no problems for BC

@ciaranmcnulty

This comment has been minimized.

Contributor

ciaranmcnulty commented Dec 21, 2016

If I have services option and the symfony2extension is there fallback, or are all @-params interpreted as non-Symfony services?

@everzet

This comment has been minimized.

Member

everzet commented Dec 21, 2016

@ciaranmcnulty service-based resolvers come first as they are more specific (as in local to suites). I'd avoid using services with Symfony2Extension until it is updated to support new functionality in proper way.

everzet added some commits Dec 25, 2016

@everzet everzet merged commit 5957537 into master Dec 25, 2016

0 of 2 checks passed

Scrutinizer Installing Code
Details
continuous-integration/travis-ci/pr The Travis CI build is in progress
Details

@everzet everzet deleted the feature/helper-containers branch Dec 25, 2016

Taluu added a commit to Taluu/Behapi that referenced this pull request Jan 25, 2017

Taluu added a commit to Taluu/Behapi that referenced this pull request Jan 26, 2017

@Taluu Taluu referenced this pull request Jan 26, 2017

Merged

HelperContainer #10

@everzet everzet referenced this pull request Sep 2, 2017

Merged

Services autowiring #1071

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment