Skip to content

Helper containers #974

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

Merged
merged 14 commits into from
Dec 25, 2016
Merged

Helper containers #974

merged 14 commits into from
Dec 25, 2016

Conversation

everzet
Copy link
Member

@everzet 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 everzet force-pushed the feature/helper-containers branch from 4282186 to 7e3b84e Compare December 20, 2016 22:46
@everzet everzet force-pushed the feature/helper-containers branch from 2050b58 to 91d0b72 Compare December 20, 2016 23:02
"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",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Member Author

@everzet everzet Dec 21, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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')) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Member Author

@everzet everzet Dec 21, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here about using the string

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm actually not super-attached to it.

Copy link
Member Author

@everzet everzet Dec 21, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@everzet
Copy link
Member Author

everzet commented Dec 21, 2016

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

Copy link
Contributor

@ciaranmcnulty ciaranmcnulty left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'Having a container"

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@everzet
Copy link
Member Author

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
Copy link
Contributor

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

@everzet
Copy link
Member Author

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 everzet merged commit 5957537 into master Dec 25, 2016
@everzet everzet deleted the feature/helper-containers branch December 25, 2016 13:39
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 mentioned this pull request Jan 26, 2017
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants