Skip to content

Instantly share code, notes, and snippets.

@kriswallsmith
Last active November 30, 2016 05:12
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kriswallsmith/f2ab2e95f8cf6df87bac334c8c528114 to your computer and use it in GitHub Desktop.
Save kriswallsmith/f2ab2e95f8cf6df87bac334c8c528114 to your computer and use it in GitHub Desktop.
<?php
class WidgetManager
{
private $em;
private $factory;
private $manipulator;
private $dispatcher;
public function __construct(ObjectManager $em, SnapshotFactory $factory, WidgetManipulator $manipulator, EventDispatcherInterface $dispatcher)
{
$this->em = $em;
$this->factory = $factory;
$this->manipulator = $manipulator;
$this->dispatcher = $dispatcher;
}
public function manipulate(Widget $widget)
{
$snapshot = $this->factory->snapshotWidget($widget);
$this->em->persist($snapshot);
$this->manipulator->manipulate($widget);
$this->dispatcher->dispatch(Events::WIDGET_MANIPULATE, new WidgetEvent($widget));
}
}
// meanwhile in WidgetManagerTest...
$scenario = $this->createScenario('widget_manager')
->obj(SnapshotFactory::class)
->any('snapshotWidget', obj(Widget::class), obj(WidgetSnapshot::class))
->obj(ObjectManager::class)
->once('persist', obj(WidgetSnapshot::class))
->obj(WidgetManipulator::class)
->once('manipulate', obj(Widget::class))
->obj(EventDispatcherInterface::class)
->once('dispatch'
Events::WIDGET_MANIPULATE,
$this->isInstanceOf(WidgetEvent::class)
)
;
/** @var WidgetManager $manager */
$manager = $scenario->getSubject();
$manager->manipulate(
$scenario->getObject(Widget::class)
);
<?php
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\DependencyInjection\Reference;
class Scenario
{
private $testCase;
private $container;
private $serviceId;
private $aliases;
private $returnMaps;
/** @var \PHPUnit_Framework_MockObject_MockObject[] */
private $objects;
/** @var InvocationMatcher[] */
private $matchers;
/** @var \PHPUnit_Framework_MockObject_MockObject */
private $object;
/** @var \PHPUnit_Framework_MockObject_Builder_InvocationMocker */
private $method;
public function __construct(\PHPUnit_Framework_TestCase $testCase, ContainerBuilder $container, $serviceId)
{
$this->testCase = $testCase;
$this->container = $container;
$this->serviceId = $serviceId;
$this->aliases = [];
$this->returnMaps = [];
$this->objects = [];
$this->matchers = [];
$this->validate();
// mock the dependencies early so we can refer to them by service id in
// the scenario, if needed. this also allows dependencies to be
// referenced in a scenario using an interface, even though they are
// registered by class name.
$this->initialize();
}
/** @return Scenario */
public function obj($class, $alias = null)
{
// register a new mock if we do not have one for this class yet, or if
// there is an alias provided which we do not have registered
if (!isset($this->objects[$class]) && !isset($this->aliases[$class])) {
$this->register($class, $alias);
} elseif ($alias && !isset($this->aliases[$alias])) {
$this->register($class, $alias);
}
$this->object = $this->lookup($alias ?: $class);
$this->method = null;
return $this;
}
/** @return Scenario */
public function any($method, $return)
{
$oid = spl_object_hash($this->object);
$magic = $this->isMagic($method);
$this->method = $this->object->method($magic ? '__call' : $method);
if (func_num_args() > 2) {
$args = func_get_args();
// remove $method and capture the actual $return
array_shift($args);
$return = array_pop($args);
} else {
$args = [];
}
if ($magic) {
$this->returnMaps[$oid]['__call'][] = [
[$method, $this->resolve($args)],
$this->resolve($return)
];
} else {
$this->returnMaps[$oid][$method][] = [
$this->resolve($args),
$this->resolve($return)
];
}
// phpunit's native ->with() functionality does not work if you call it
// more than once, and the native return map functionality doesn't
// evaluate any constraints in the map, so we brew our own...
$this->method->willReturnCallback(function() use($oid, $method) {
if (!isset($this->returnMaps[$oid][$method])) {
return;
}
// find a matching map
$actualArgs = func_get_args();
foreach ($this->returnMaps[$oid][$method] as list($expectedArgs, $return)) {
if (InvocationMatcher::argumentsMatch($expectedArgs, $actualArgs)) {
return $return;
}
}
});
return $this;
}
/** @return Scenario */
public function once($method)
{
$oid = spl_object_hash($this->object);
$magic = $this->isMagic($method);
// we use a custom invocation matcher because the native once() matcher
// does not support consecutive calls (i.e. once with these args, once
// with these other args, etc)
$expectedMethod = $magic ? '__call' : $method;
if (isset($this->matchers[$oid][$expectedMethod])) {
$matcher = $this->matchers[$oid][$expectedMethod];
} else {
$this->matchers[$oid][$expectedMethod] = $matcher = new InvocationMatcher();
}
$this->method = $this->object->expects($matcher);
$this->method->method($magic ? '__call' : $method);
if (func_num_args() > 1) {
$args = func_get_args();
// remove $method
array_shift($args);
$args = $this->resolve($args);
} else {
$args = [];
}
if ($magic) {
$matcher->add($args ? [$method, $args] : [$method]);
} else {
$matcher->add($args);
}
return $this;
}
/** @return Scenario */
public function returns($return)
{
$this->method->willReturn($this->resolve($return));
return $this;
}
/** @return Scenario */
public function fluent()
{
$this->method->willReturn($this->object);
return $this;
}
public function getObject($id)
{
return $this->lookup($id);
}
public function getSubject()
{
$definition = $this->container->findDefinition($this->serviceId);
$reflection = new \ReflectionClass($definition->getClass());
$arguments = array_map(function(Reference $reference) {
return $this->lookup((string) $reference);
}, $definition->getArguments());
return $reflection->newInstanceArgs($arguments);
}
// private
private function validate()
{
$definition = $this->container->findDefinition($this->serviceId);
if ($definition->getFactoryClass() || $definition->getFactoryService()) {
throw new \InvalidArgumentException('Factories are not supported yet.');
}
if ($definition->getMethodCalls()) {
throw new \InvalidArgumentException('Method calls are not supported yet.');
}
if ($definition->getConfigurator()) {
throw new \InvalidArgumentException('Configurators are not supported yet.');
}
foreach ($definition->getArguments() as $argument) {
if (!$argument instanceof Reference) {
throw new \InvalidArgumentException('Non-service arguments are not supported yet.');
}
}
}
private function initialize()
{
$definition = $this->container->findDefinition($this->serviceId);
foreach ($definition->getArguments() as $reference) {
$serviceId = (string) $reference;
$dependency = $this->container->findDefinition($serviceId);
if ('service_container' === $serviceId) {
$class = ContainerInterface::class;
} elseif (!$class = $dependency->getClass()) {
throw new \RuntimeException("There is no class for the '$serviceId' service");
}
$this->register($class, $serviceId);
}
}
private function register($class, $alias = null)
{
// add each interface as an alias
if (!isset($this->objects[$class])) {
foreach (class_implements($class) as $interface) {
$this->aliases[$interface] = [$class, 0];
}
}
$this->objects[$class][] = $this->testCase->getMockBuilder($class)
->disableOriginalConstructor()
->disableOriginalClone()
->getMock();
if ($alias) {
$i = count($this->objects[$class]) - 1;
$this->aliases[$alias] = [$class, $i];
}
}
private function lookup($id)
{
if (isset($this->aliases[$id])) {
list($class, $i) = $this->aliases[$id];
} else {
$class = $id;
$i = 0;
}
if (!isset($this->objects[$class][$i])) {
throw new \RuntimeException("Unable to lookup '$id' object");
}
return $this->objects[$class][$i];
}
private function resolve($return)
{
if (is_array($return)) {
return array_map([$this, 'resolve'], $return);
}
if ($return instanceof Lookup) {
$return = $this->ensure($return->getId());
}
return $return;
}
private function ensure($class)
{
try {
return $this->lookup($class);
} catch (\RuntimeException $e) {
$this->register($class);
return $this->lookup($class);
}
}
private function isMagic($method)
{
$class = get_parent_class($this->object);
if (method_exists($class, $method)) {
return false;
}
return method_exists($class, '__call');
}
}
class Lookup
{
private $id;
public function __construct($id)
{
$this->id = $id;
}
public function getId()
{
return $this->id;
}
}
class InvocationMatcher extends \PHPUnit_Framework_MockObject_Matcher_InvokedRecorder
{
private $expectedCalls;
/** @internal */
public static function argumentsMatch(array $expected, array $actual)
{
$success = true;
foreach ($expected as $i => $constraint) {
if (!$constraint instanceof \PHPUnit_Framework_Constraint) {
$constraint = is_object($constraint)
? new \PHPUnit_Framework_Constraint_IsIdentical($constraint)
: new \PHPUnit_Framework_Constraint_IsEqual($constraint);
}
if (!$constraint->evaluate($actual[$i], '', true)) {
$success = false;
break;
}
}
return $success;
}
public function __construct()
{
$this->expectedCalls = [];
}
public function add(array $args)
{
$this->expectedCalls[] = $args;
}
public function toString()
{
return 'invoked '.count($this->expectedCalls).' time(s) with specific arguments';
}
public function verify()
{
/** @var \PHPUnit_Framework_MockObject_Invocation_Static[] $actualCalls */
$actualCalls = $this->invocations;
$expectedCalls = $this->expectedCalls;
foreach ($expectedCalls as $i => $expectedArgs) {
foreach ($actualCalls as $ii => $actualCall) {
if (self::argumentsMatch($expectedArgs, $actualCall->parameters)) {
unset($expectedCalls[$i], $actualCalls[$ii]);
}
}
}
if ($actualCalls || $expectedCalls) {
// TODO: a better failure message
throw new \PHPUnit_Framework_ExpectationFailedException(
'Method was not called as expected.'
);
}
}
}
function obj($id)
{
return new Lookup($id);
}
@reyaz
Copy link

reyaz commented Nov 29, 2016

Is L193 intended to throw “…not supported yet.”?

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