Last active
November 30, 2016 05:12
-
-
Save kriswallsmith/f2ab2e95f8cf6df87bac334c8c528114 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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) | |
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Is L193 intended to
throw
“…not supported yet.”?