From 669546713b22875c7f26bc508b2b02833db954eb Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Tue, 25 Mar 2025 20:13:09 +0100 Subject: [PATCH 01/22] Add Test wrapper --- README.md | 64 +++++++++++++++++++ src/AbstractClassHelper.php | 72 +++++++++++++++++++++ src/FileIterator.php | 1 + src/TestCase.php | 6 ++ src/TestMocker.php | 109 +++++++++++++++++++++++++++++++ src/TestUnit.php | 2 +- src/TestWrapper.php | 124 ++++++++++++++++++++++++++++++++++++ src/Unit.php | 12 +--- 8 files changed, 379 insertions(+), 11 deletions(-) create mode 100644 src/AbstractClassHelper.php create mode 100755 src/TestMocker.php create mode 100755 src/TestWrapper.php diff --git a/README.md b/README.md index 2c6b2ea..2400ae2 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,70 @@ php vendor/bin/unitary With that, you are ready to create your own tests! + +## Integration tests: Test Wrapper +The TestWrapper allows you to wrap an existing class, override its methods, and inject dependencies dynamically. +It is useful for integration testing, debugging, and extending existing functionality without the need of +modifying the original class. + +### The problem +Imagine we have a PaymentProcessor class that communicates with an external payment gateway to +capture a customer's payment. We would like to test this with its own functionallity to keep the test useful +but avoid making any charges to customer. +```php +class PaymentProcessor +{ + public function __construct( + private OrderService $orderService, + private PaymentGateway $gateway, + private Logger $logger + ) {} + + public function capture(string $orderID) + { + $order = $this->orderService->getOrder($orderID); + + if (!$order) { + throw new Exception("Order not found: $orderID"); + } + + $this->logger->info("Capturing payment for Order ID: " . $order->id); + + $response = $this->gateway->capture($order->id); + + if ($response['status'] !== 'success') { + throw new Exception("Payment capture failed: " . $response['message']); + } + + return "Transaction ID: " . $response['transaction_id']; + } +} + +``` + +### Use the Test Wrapper +Use wrapper()->bind() to Mock API Calls but Keep Business Logic + +With TestWrapper, we can simulate an order and intercept the payment capture while keeping access to $this inside the closure. + +```php +$dispatch = $this->wrapper(PaymentProcessor::class)->bind(function ($orderID) use ($inst) { + // Simulate order retrieval + $order = $this->orderService->getOrder($orderID); + $response = $inst->mock('gatewayCapture')->capture($order->id); + if ($response['status'] !== 'success') { + // Log action within the PaymentProcessor instance + $this->logger->info("Mocked: Capturing payment for Order ID: " . $order->id ?? 0); + // Has successfully found order and logged message + return true; + } + // Failed to find order + return false; +}); +``` + + + ## Configurations ### Select a Test File to Run diff --git a/src/AbstractClassHelper.php b/src/AbstractClassHelper.php new file mode 100644 index 0000000..a676b60 --- /dev/null +++ b/src/AbstractClassHelper.php @@ -0,0 +1,72 @@ +reflectionPool = new Reflection($className); + $this->reflection = $this->reflection->getReflect(); + //$this->constructor = $this->reflection->getConstructor(); + //$reflectParam = ($this->constructor) ? $this->constructor->getParameters() : []; + if (count($classArgs) > 0) { + $this->instance = $this->reflection->newInstanceArgs($classArgs); + } + } + + public function inspectMethod(string $method): array + { + if (!$this->reflection || !$this->reflection->hasMethod($method)) { + throw new Exception("Method '$method' does not exist."); + } + + $methodReflection = $this->reflection->getMethod($method); + $parameters = []; + foreach ($methodReflection->getParameters() as $param) { + $paramType = $param->hasType() ? $param->getType()->getName() : 'mixed'; + $parameters[] = [ + 'name' => $param->getName(), + 'type' => $paramType, + 'is_optional' => $param->isOptional(), + 'default_value' => $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null + ]; + } + + return [ + 'name' => $methodReflection->getName(), + 'visibility' => implode(' ', \Reflection::getModifierNames($methodReflection->getModifiers())), + 'is_static' => $methodReflection->isStatic(), + 'return_type' => $methodReflection->hasReturnType() ? $methodReflection->getReturnType()->getName() : 'mixed', + 'parameters' => $parameters + ]; + } + + /** + * Will create the main instance with dependency injection support + * + * @param string $className + * @param array $args + * @return mixed|object + * @throws \ReflectionException + */ + final protected function createInstance(string $className, array $args) + { + if(count($args) === 0) { + return $this->reflection->dependencyInjector(); + } + return new $className(...$args); + } +} \ No newline at end of file diff --git a/src/FileIterator.php b/src/FileIterator.php index d0aaf9c..48e4af4 100755 --- a/src/FileIterator.php +++ b/src/FileIterator.php @@ -150,6 +150,7 @@ private function requireUnitFile(string $file): ?Closure $cli->enableTraceLines(true); } $run = new Run($cli); + $run->setExitCode(1); $run->load(); //ob_start(); diff --git a/src/TestCase.php b/src/TestCase.php index 66a79c8..e1c801f 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -76,6 +76,12 @@ public function add(mixed $expect, array|Closure $validation, ?string $message = return $this; } + public function wrapper($className): TestWrapper + { + return new class($className) extends TestWrapper { + }; + } + /** * Get failed test counts diff --git a/src/TestMocker.php b/src/TestMocker.php new file mode 100755 index 0000000..b76350b --- /dev/null +++ b/src/TestMocker.php @@ -0,0 +1,109 @@ +instance = $this->createInstance($className, $args); + } + + /** + * Will bind Closure to class instance and directly return the Closure + * + * @param Closure $call + * @return Closure + */ + public function bind(Closure $call): Closure + { + return $call->bindTo($this->instance); + } + + /** + * Overrides a method in the instance + * + * @param string $method + * @param Closure $call + * @return $this + */ + public function override(string $method, Closure $call): self + { + if( !method_exists($this->instance, $method)) { + throw new \BadMethodCallException( + "Method '$method' does not exist in the class '" . get_class($this->instance) . + "' and therefore cannot be overridden or called." + ); + } + $call = $call->bindTo($this->instance); + $this->methods[$method] = $call; + return $this; + } + + /** + * Add a method to the instance, allowing it to be called as if it were a real method. + * + * @param string $method + * @param Closure $call + * @return $this + */ + public function add(string $method, Closure $call): self + { + if(method_exists($this->instance, $method)) { + throw new \BadMethodCallException( + "Method '$method' already exists in the class '" . get_class($this->instance) . + "'. Use the 'override' method in TestWrapper instead." + ); + } + $call = $call->bindTo($this->instance); + $this->methods[$method] = $call; + return $this; + } + + /** + * Proxies calls to the wrapped instance or bound methods. + * + * @param string $name + * @param array $arguments + * @return mixed + * @throws Exception + */ + public function __call(string $name, array $arguments): mixed + { + if (isset($this->methods[$name])) { + return $this->methods[$name](...$arguments); + } + + if (method_exists($this->instance, $name)) { + return call_user_func_array([$this->instance, $name], $arguments); + } + throw new Exception("Method $name does not exist."); + } + + +} \ No newline at end of file diff --git a/src/TestUnit.php b/src/TestUnit.php index 70f0a3c..3190c2f 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -137,7 +137,7 @@ public function getReadValue(): string final protected function excerpt(string $value): string { $format = new Str($value); - return (string)$format->excerpt(42)->get(); + return (string)$format->excerpt(70)->get(); } } diff --git a/src/TestWrapper.php b/src/TestWrapper.php new file mode 100755 index 0000000..c325afd --- /dev/null +++ b/src/TestWrapper.php @@ -0,0 +1,124 @@ +instance = $this->createInstance($className, $args); + } + + /** + * Will bind Closure to class instance and directly return the Closure + * + * @param Closure $call + * @return Closure + */ + public function bind(Closure $call): Closure + { + return $call->bindTo($this->instance); + } + + /** + * Overrides a method in the instance + * + * @param string $method + * @param Closure $call + * @return $this + */ + public function override(string $method, Closure $call): self + { + if( !method_exists($this->instance, $method)) { + throw new \BadMethodCallException( + "Method '$method' does not exist in the class '" . get_class($this->instance) . + "' and therefore cannot be overridden or called." + ); + } + $call = $call->bindTo($this->instance); + $this->methods[$method] = $call; + return $this; + } + + /** + * Add a method to the instance, allowing it to be called as if it were a real method. + * + * @param string $method + * @param Closure $call + * @return $this + */ + public function add(string $method, Closure $call): self + { + if(method_exists($this->instance, $method)) { + throw new \BadMethodCallException( + "Method '$method' already exists in the class '" . get_class($this->instance) . + "'. Use the 'override' method in TestWrapper instead." + ); + } + $call = $call->bindTo($this->instance); + $this->methods[$method] = $call; + return $this; + } + + /** + * Proxies calls to the wrapped instance or bound methods. + * + * @param string $name + * @param array $arguments + * @return mixed + * @throws Exception + */ + public function __call(string $name, array $arguments): mixed + { + if (isset($this->methods[$name])) { + return $this->methods[$name](...$arguments); + } + + if (method_exists($this->instance, $name)) { + return call_user_func_array([$this->instance, $name], $arguments); + } + throw new Exception("Method $name does not exist."); + } + + /** + * Will create the main instance with dependency injection support + * + * @param string $className + * @param array $args + * @return mixed|object + * @throws \ReflectionException + */ + final protected function createInstance(string $className, array $args) + { + if(count($args) === 0) { + $ref = new Reflection($className); + return $ref->dependencyInjector(); + } + return new $className(...$args); + } +} \ No newline at end of file diff --git a/src/Unit.php b/src/Unit.php index a87e314..1959eee 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -185,18 +185,10 @@ public function execute(): bool // LOOP through each case ob_start(); foreach($this->cases as $row) { - if(!($row instanceof TestCase)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestCase."); } - - try { - $tests = $row->dispatchTest(); - } catch (Throwable $e) { - $file = $this->formatFileTitle((string)(self::$headers['file'] ?? ""), 5, false); - throw new RuntimeException($e->getMessage() . ". Error originated from: ". $file, (int)$e->getCode(), $e); - } - + $tests = $row->dispatchTest(); $color = ($row->hasFailed() ? "brightRed" : "brightBlue"); $flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); if($row->hasFailed()) { @@ -211,7 +203,7 @@ public function execute(): bool $this->command->getAnsi()->style(["bold", $color], (string)$row->getMessage()) ); - foreach($tests as $test) { + if(isset($tests)) foreach($tests as $test) { if(!($test instanceof TestUnit)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); } From 5cd7418d9a530146a7dccbf30415dc0f8d6e38fd Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 30 Mar 2025 21:45:58 +0200 Subject: [PATCH 02/22] Add mocking capabillities --- README.md | 1 - src/TestCase.php | 23 +++- src/TestMocker.php | 241 ++++++++++++++++++++++++++++++-------- src/TestWrapper.php | 11 +- tests/unitary-unitary.php | 43 +++++++ 5 files changed, 262 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 2400ae2..d6cca3d 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,6 @@ I will show you three different ways to test your application below. $unit = new MaplePHP\Unitary\Unit(); -// If you build your library correctly, it will become very easy to mock, as I have below. $request = new MaplePHP\Http\Request( "GET", "https://admin:mypass@example.com:65535/test.php?id=5221&greeting=hello", diff --git a/src/TestCase.php b/src/TestCase.php index e1c801f..6826626 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -8,6 +8,7 @@ use ErrorException; use RuntimeException; use Closure; +use stdClass; use Throwable; use MaplePHP\Validate\Inp; @@ -76,12 +77,32 @@ public function add(mixed $expect, array|Closure $validation, ?string $message = return $this; } - public function wrapper($className): TestWrapper + /** + * Init a test wrapper + * + * @param string $className + * @return TestWrapper + */ + public function wrapper(string $className): TestWrapper { return new class($className) extends TestWrapper { }; } + public function mock(string $className, null|array|Closure $validate = null): object + { + + $mocker = new TestMocker($className); + if(is_array($validate)) { + $mocker->validate($validate); + } + if(is_callable($validate)) { + $fn = $validate->bindTo($mocker); + $fn($mocker); + } + return $mocker->execute(); + } + /** * Get failed test counts diff --git a/src/TestMocker.php b/src/TestMocker.php index b76350b..302bba0 100755 --- a/src/TestMocker.php +++ b/src/TestMocker.php @@ -10,79 +10,201 @@ namespace MaplePHP\Unitary; +use ArrayIterator; use Closure; use Exception; -use MaplePHP\Container\Reflection; +use MaplePHP\Log\InvalidArgumentException; +use ReflectionClass; +use ReflectionIntersectionType; +use ReflectionMethod; +use ReflectionNamedType; +use ReflectionProperty; +use ReflectionUnionType; -abstract class TestMocker +class TestMocker { protected object $instance; - private array $methods = []; + + static private mixed $return; + + protected $reflection; + + protected $methods; + + function __construct(string $className, array $args = []) + { + $this->reflection = new ReflectionClass($className); + $this->methods = $this->reflection->getMethods(ReflectionMethod::IS_PUBLIC); + + } /** - * Pass class and the class arguments if exists + * Executes the creation of a dynamic mock class and returns an instance of the mock. * - * @param string $className - * @param array $args - * @throws Exception + * @return mixed + */ + function execute(): mixed + { + $className = $this->reflection->getName(); + $mockClassName = 'UnitaryMockery_' . uniqid(); + $overrides = $this->overrideMethods(); + $code = " + class {$mockClassName} extends {$className} { + {$overrides} + } + "; + eval($code); + return new $mockClassName(); + } + + function return(mixed $returnValue): self + { + + + self::$return = $returnValue; + return $this; + } + + + static public function getReturn(): mixed + { + return self::$return; + } + + /** + * @param array $types + * @return string + * @throws \ReflectionException */ - public function __construct(string $className, array $args = []) + function getReturnValue(array $types): string { - if (!class_exists($className)) { - throw new Exception("Class $className does not exist."); + $property = new ReflectionProperty($this, 'return'); + if ($property->isInitialized($this)) { + $type = gettype(self::getReturn()); + if($types && !in_array($type, $types) && !in_array("mixed", $types)) { + throw new InvalidArgumentException("Mock value \"" . self::getReturn() . "\" should return data type: " . implode(', ', $types)); + } + + return $this->getMockValueForType($type, self::getReturn()); + } + if ($types) { + return $this->getMockValueForType($types[0]); } - $this->instance = $this->createInstance($className, $args); + return "return 'MockedValue';"; } /** - * Will bind Closure to class instance and directly return the Closure + * Overrides all methods in class * - * @param Closure $call - * @return Closure + * @return string */ - public function bind(Closure $call): Closure + protected function overrideMethods(): string { - return $call->bindTo($this->instance); + $overrides = ''; + foreach ($this->methods as $method) { + if ($method->isConstructor()) { + continue; + } + + $params = []; + $methodName = $method->getName(); + $types = $this->getReturnType($method); + $returnValue = $this->getReturnValue($types); + + foreach ($method->getParameters() as $param) { + $paramStr = ''; + if ($param->hasType()) { + $paramStr .= $param->getType() . ' '; + } + if ($param->isPassedByReference()) { + $paramStr .= '&'; + } + $paramStr .= '$' . $param->getName(); + if ($param->isDefaultValueAvailable()) { + $paramStr .= ' = ' . var_export($param->getDefaultValue(), true); + } + $params[] = $paramStr; + } + + $paramList = implode(', ', $params); + $returnType = ($types) ? ': ' . implode('|', $types) : ''; + $overrides .= " + public function {$methodName}({$paramList}){$returnType} + { + {$returnValue} + } + "; + } + + return $overrides; } /** - * Overrides a method in the instance + * Get expected return types * - * @param string $method - * @param Closure $call - * @return $this + * @param $method + * @return array */ - public function override(string $method, Closure $call): self + protected function getReturnType($method): array { - if( !method_exists($this->instance, $method)) { - throw new \BadMethodCallException( - "Method '$method' does not exist in the class '" . get_class($this->instance) . - "' and therefore cannot be overridden or called." - ); + $types = []; + $returnType = $method->getReturnType(); + if ($returnType instanceof ReflectionNamedType) { + $types[] = $returnType->getName(); + } elseif ($returnType instanceof ReflectionUnionType) { + foreach ($returnType->getTypes() as $type) { + $types[] = $type->getName(); + } + + } elseif ($returnType instanceof ReflectionIntersectionType) { + $intersect = array_map(fn($type) => $type->getName(), $returnType->getTypes()); + $types[] = $intersect; } - $call = $call->bindTo($this->instance); - $this->methods[$method] = $call; - return $this; + if(!in_array("mixed", $types) && $returnType->allowsNull()) { + $types[] = "null"; + } + return $types; } /** - * Add a method to the instance, allowing it to be called as if it were a real method. + * Generates a mock value for the specified type. * - * @param string $method - * @param Closure $call - * @return $this + * @param string $typeName The name of the type for which to generate the mock value. + * @param bool $nullable Indicates if the returned value can be nullable. + * @return mixed Returns a mock value corresponding to the given type, or null if nullable and conditions allow. */ - public function add(string $method, Closure $call): self + protected function getMockValueForType(string $typeName, mixed $value = null, bool $nullable = false): mixed { - if(method_exists($this->instance, $method)) { - throw new \BadMethodCallException( - "Method '$method' already exists in the class '" . get_class($this->instance) . - "'. Use the 'override' method in TestWrapper instead." - ); + $typeName = strtolower($typeName); + if(!is_null($value)) { + return "return \MaplePHP\Unitary\TestMocker::getReturn();"; } - $call = $call->bindTo($this->instance); - $this->methods[$method] = $call; - return $this; + $mock = match ($typeName) { + 'integer' => "return 123456;", + 'double' => "return 3.14;", + 'string' => "return 'mockString';", + 'boolean' => "return true;", + 'array' => "return ['item'];", + 'object' => "return (object)['item'];", + 'resource' => "return fopen('php://memory', 'r+');", + 'callable' => "return fn() => 'called';", + 'iterable' => "return new ArrayIterator(['a', 'b']);", + 'null' => "return null;", + 'void' => "", + default => 'return class_exists($typeName) ? new class($typeName) extends TestMocker {} : null;', + }; + return $nullable && rand(0, 1) ? null : $mock; + } + + + /** + * Will return a streamable content + * @param $resourceValue + * @return string|null + */ + protected function handleResourceContent($resourceValue) + { + return var_export(stream_get_contents($resourceValue), true); } /** @@ -95,15 +217,34 @@ public function add(string $method, Closure $call): self */ public function __call(string $name, array $arguments): mixed { - if (isset($this->methods[$name])) { - return $this->methods[$name](...$arguments); - } - if (method_exists($this->instance, $name)) { - return call_user_func_array([$this->instance, $name], $arguments); - } - throw new Exception("Method $name does not exist."); - } + $types = $this->getReturnType($name); + if(!isset($types[0]) && is_null($this->return)) { + throw new Exception("Could automatically mock Method \"$name\". " . + "You will need to manually mock it with ->return([value]) mock method!"); + } + if (!is_null($this->return)) { + return $this->return; + } + + if(isset($types[0]) && is_array($types[0]) && count($types[0]) > 0) { + $last = end($types[0]); + return new self($last); + } + + $mockValue = $this->getMockValueForType($types[0]); + if($mockValue instanceof self) { + return $mockValue; + } + + if(!in_array(gettype($mockValue), $types)) { + throw new Exception("Mock value $mockValue is not in the return type " . implode(', ', $types)); + } + return $mockValue; + } + + throw new \BadMethodCallException("Method \"$name\" does not exist in class \"" . $this->instance::class . "\"."); + } } \ No newline at end of file diff --git a/src/TestWrapper.php b/src/TestWrapper.php index c325afd..4f31241 100755 --- a/src/TestWrapper.php +++ b/src/TestWrapper.php @@ -16,6 +16,7 @@ abstract class TestWrapper { + protected Reflection $ref; protected object $instance; private array $methods = []; @@ -31,7 +32,8 @@ public function __construct(string $className, array $args = []) if (!class_exists($className)) { throw new Exception("Class $className does not exist."); } - $this->instance = $this->createInstance($className, $args); + $this->ref = new Reflection($className); + $this->instance = $this->createInstance($this->ref, $args); } /** @@ -108,17 +110,16 @@ public function __call(string $name, array $arguments): mixed /** * Will create the main instance with dependency injection support * - * @param string $className + * @param Reflection $ref * @param array $args * @return mixed|object * @throws \ReflectionException */ - final protected function createInstance(string $className, array $args) + final protected function createInstance(Reflection $ref, array $args) { if(count($args) === 0) { - $ref = new Reflection($className); return $ref->dependencyInjector(); } - return new $className(...$args); + return $ref->getReflect()->newInstanceArgs($args); } } \ No newline at end of file diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 37e4984..d07f958 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -1,10 +1,53 @@ mailer->sendEmail($email)."\n"; + echo $this->mailer->sendEmail($email); + } +} + + $unit = new Unit(); $unit->add("Unitary test", function () { + + $mock = $this->mock(Mailer::class, function ($mock) { + //$mock->method("sendEmail")->return("SENT2121"); + }); + $service = new UserService($mock); + + $service->registerUser('user@example.com'); + + + /* + * $mock = $this->mock(Mailer::class); +echo "ww"; + + $service = new UserService($test); + $service->registerUser('user@example.com'); + var_dump($mock instanceof Mailer); + $service = new UserService($mock); + $service->registerUser('user@example.com'); + */ + $this->add("Lorem ipsum dolor", [ "isString" => [], "length" => [1,200] From 9f9fd4bb874fa5022cb14622ba1d4aa662a8b23c Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Tue, 1 Apr 2025 22:15:22 +0200 Subject: [PATCH 03/22] Mocker and structure improvements --- src/TestCase.php | 63 +++++++++++++++++++++++++++++++-------- src/TestMocker.php | 2 -- src/Unit.php | 8 ++++- tests/unitary-unitary.php | 39 +++++++++++++++++++++--- 4 files changed, 93 insertions(+), 19 deletions(-) diff --git a/src/TestCase.php b/src/TestCase.php index 6826626..b84f024 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -6,9 +6,9 @@ use BadMethodCallException; use ErrorException; +use MaplePHP\Validate\ValidatePool; use RuntimeException; use Closure; -use stdClass; use Throwable; use MaplePHP\Validate\Inp; @@ -48,6 +48,12 @@ public function dispatchTest(): array return $this->test; } + public function validate($expect, Closure $validation): self + { + $this->add($expect, $validation); + return $this; + } + /** * Create a test * @param mixed $expect @@ -61,7 +67,10 @@ public function add(mixed $expect, array|Closure $validation, ?string $message = $this->value = $expect; $test = new TestUnit($this->value, $message); if($validation instanceof Closure) { - $test->setUnit($this->buildClosureTest($validation)); + $list = $this->buildClosureTest($validation); + foreach($list as $method => $valid) { + $test->setUnit(!$list, $method, []); + } } else { foreach($validation as $method => $args) { if(!($args instanceof Closure) && !is_array($args)) { @@ -91,7 +100,6 @@ public function wrapper(string $className): TestWrapper public function mock(string $className, null|array|Closure $validate = null): object { - $mocker = new TestMocker($className); if(is_array($validate)) { $mocker->validate($validate); @@ -169,30 +177,35 @@ public function getTest(): array /** * This will build the closure test + * * @param Closure $validation - * @return bool - * @throws ErrorException + * @return array */ - public function buildClosureTest(Closure $validation): bool + public function buildClosureTest(Closure $validation): array { $bool = false; - $validation = $validation->bindTo($this->valid($this->value)); + $validPool = new ValidatePool($this->value); + $validation = $validation->bindTo($validPool); + + $error = []; if(!is_null($validation)) { - $bool = $validation($this->value); - } - if(!is_bool($bool)) { - throw new RuntimeException("A callable validation must return a boolean!"); + $bool = $validation($validPool, $this->value); + $error = $validPool->getError(); + if(is_bool($bool) && !$bool) { + $error['customError'] = $bool; + } } if(is_null($this->message)) { throw new RuntimeException("When testing with closure the third argument message is required"); } - return $bool; + return $error; } /** * This will build the array test + * * @param string $method * @param array|Closure $args * @return bool @@ -235,6 +248,32 @@ protected function valid(mixed $value): Inp return new Inp($value); } + /** + * This is a helper function that will list all inherited proxy methods + * + * @param string $class + * @return void + * @throws \ReflectionException + */ + public function listAllProxyMethods(string $class, ?string $prefixMethods = null): void + { + $reflection = new \ReflectionClass($class); + foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->isConstructor()) continue; + $params = array_map(function($param) { + $type = $param->hasType() ? $param->getType() . ' ' : ''; + return $type . '$' . $param->getName(); + }, $method->getParameters()); + + $name = $method->getName(); + if(!$method->isStatic() && !str_starts_with($name, '__')) { + if(!is_null($prefixMethods)) { + $name = $prefixMethods . ucfirst($name); + } + echo "@method self $name(" . implode(', ', $params) . ")\n"; + } + } + } } diff --git a/src/TestMocker.php b/src/TestMocker.php index 302bba0..41ecad8 100755 --- a/src/TestMocker.php +++ b/src/TestMocker.php @@ -59,8 +59,6 @@ class {$mockClassName} extends {$className} { function return(mixed $returnValue): self { - - self::$return = $returnValue; return $this; } diff --git a/src/Unit.php b/src/Unit.php index 1959eee..fd99e75 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -130,8 +130,9 @@ public function add(string $message, Closure $callback): void /** * Add a test unit/group + * * @param string $message - * @param Closure $callback + * @param Closure(TestCase):void $callback * @return void */ public function case(string $message, Closure $callback): void @@ -142,6 +143,11 @@ public function case(string $message, Closure $callback): void $this->index++; } + public function group(string $message, Closure $callback): void + { + $this->case($message, $callback); + } + public function performance(Closure $func, ?string $title = null): void { $start = new TestMem(); diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index d07f958..ee68345 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -1,7 +1,10 @@ add("Unitary test", function () { +$unit->group("Unitary test", function (TestCase $inst) { - $mock = $this->mock(Mailer::class, function ($mock) { - //$mock->method("sendEmail")->return("SENT2121"); + // Example 1 + /* + $mock = $this->mock(Mailer::class, function ($mock) { + $mock->method("testMethod1")->count(1)->return("lorem1"); + $mock->method("testMethod2")->count(1)->return("lorem1"); }); $service = new UserService($mock); + // Example 2 + $mock = $this->mock(Mailer::class, [ + "testMethod1" => [ + "count" => 1, + "validate" => [ + "equal" => "lorem1", + "contains" => "lorem", + "length" => [1,6] + ] + ] + ]); + $service = new UserService($mock); $service->registerUser('user@example.com'); + */ + + $inst->validate("yourTestValue", function(ValidatePool $inst, mixed $value) { + $inst->isBool(); + $inst->isInt(); + $inst->isJson(); + + return ($value === "yourTestValue1"); + }); + + //$inst->listAllProxyMethods(Inp::class); +//->error("Failed to validate yourTestValue (optional error message)") + /* @@ -50,7 +81,7 @@ public function registerUser(string $email): void { $this->add("Lorem ipsum dolor", [ "isString" => [], - "length" => [1,200] + "length" => [1,300] ])->add(92928, [ "isInt" => [] From f3c05db204ea43897f0213adae3d9eff8a45ba0a Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Wed, 2 Apr 2025 23:03:10 +0200 Subject: [PATCH 04/22] Prompt semantics --- README.md | 8 +++- src/TestCase.php | 73 +++++++++++++++++++++++++++++---- src/TestUnit.php | 86 ++++++++++++++++++++++++++++++++++----- src/Unit.php | 30 +++++++++++--- tests/unitary-unitary.php | 6 ++- 5 files changed, 176 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index d6cca3d..f3a7fdd 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ class PaymentProcessor ``` ### Use the Test Wrapper -Use wrapper()->bind() to Mock API Calls but Keep Business Logic +Use wrapper()->bind() to make integration tests easier. Test wrapper will bind a callable to specified class in wrapper, in this case to PaymentProcessor and will be accessible with `$dispatch("OR827262")`. With TestWrapper, we can simulate an order and intercept the payment capture while keeping access to $this inside the closure. @@ -179,9 +179,13 @@ $dispatch = $this->wrapper(PaymentProcessor::class)->bind(function ($orderID) us ``` - ## Configurations +### Show only errors +```bash +php vendor/bin/unitary --errors-only +``` + ### Select a Test File to Run After each test, a hash key is shown, allowing you to run specific tests instead of all. diff --git a/src/TestCase.php b/src/TestCase.php index b84f024..6e4225a 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -4,13 +4,13 @@ namespace MaplePHP\Unitary; +use MaplePHP\Validate\ValidatePool; +use MaplePHP\Validate\Inp; use BadMethodCallException; use ErrorException; -use MaplePHP\Validate\ValidatePool; use RuntimeException; use Closure; use Throwable; -use MaplePHP\Validate\Inp; class TestCase { @@ -19,7 +19,14 @@ class TestCase private array $test = []; private int $count = 0; private ?Closure $bind = null; + private ?string $errorMessage = null; + + /** + * Initialize a new TestCase instance with an optional message. + * + * @param string|null $message A message to associate with the test case. + */ public function __construct(?string $message = null) { $this->message = $message; @@ -27,6 +34,7 @@ public function __construct(?string $message = null) /** * Bind the test case to the Closure + * * @param Closure $bind * @return void */ @@ -37,6 +45,7 @@ public function bind(Closure $bind): void /** * Will dispatch the case tests and return them as an array + * * @return array */ public function dispatchTest(): array @@ -48,21 +57,59 @@ public function dispatchTest(): array return $this->test; } - public function validate($expect, Closure $validation): self + /** + * Add custom error message if validation fails + * + * @param string $message + * @return $this + */ + public function error(string $message): self + { + $this->errorMessage = $message; + return $this; + } + + /** + * Add a test unit validation using the provided expectation and validation logic + * + * @param mixed $expect The expected value + * @param Closure(ValidatePool, mixed): bool $validation The validation logic + * @return $this + * @throws ErrorException + */ + public function validate(mixed $expect, Closure $validation): self { - $this->add($expect, $validation); + $this->addTestUnit($expect, function(mixed $value, ValidatePool $inst) use($validation) { + return $validation($inst, $value); + }, $this->errorMessage); + return $this; } + + /** + * Same as "addTestUnit" but is public and will make sure the validation can be + * properly registered and traceable + * + * @param mixed $expect The expected value + * @param array|Closure $validation The validation logic + * @param string|null $message An optional descriptive message for the test + * @return $this + * @throws ErrorException + */ + public function add(mixed $expect, array|Closure $validation, ?string $message = null) { + return $this->addTestUnit($expect, $validation, $message); + } /** * Create a test + * * @param mixed $expect * @param array|Closure $validation * @param string|null $message * @return TestCase * @throws ErrorException */ - public function add(mixed $expect, array|Closure $validation, ?string $message = null): self + protected function addTestUnit(mixed $expect, array|Closure $validation, ?string $message = null): self { $this->value = $expect; $test = new TestUnit($this->value, $message); @@ -80,12 +127,16 @@ public function add(mixed $expect, array|Closure $validation, ?string $message = } } if(!$test->isValid()) { + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; + $test->setCodeLine($trace); $this->count++; } $this->test[] = $test; + $this->errorMessage = null; return $this; } + /** * Init a test wrapper * @@ -114,6 +165,7 @@ public function mock(string $className, null|array|Closure $validate = null): ob /** * Get failed test counts + * * @return int */ public function getTotal(): int @@ -123,6 +175,7 @@ public function getTotal(): int /** * Get failed test counts + * * @return int */ public function getCount(): int @@ -132,6 +185,7 @@ public function getCount(): int /** * Get failed test counts + * * @return int */ public function getFailedCount(): int @@ -141,6 +195,7 @@ public function getFailedCount(): int /** * Check if it has failed tests + * * @return bool */ public function hasFailed(): bool @@ -150,6 +205,7 @@ public function hasFailed(): bool /** * Get original value + * * @return mixed */ public function getValue(): mixed @@ -159,6 +215,7 @@ public function getValue(): mixed /** * Get user added message + * * @return string|null */ public function getMessage(): ?string @@ -168,6 +225,7 @@ public function getMessage(): ?string /** * Get test array object + * * @return array */ public function getTest(): array @@ -189,7 +247,7 @@ public function buildClosureTest(Closure $validation): array $error = []; if(!is_null($validation)) { - $bool = $validation($validPool, $this->value); + $bool = $validation($this->value, $validPool); $error = $validPool->getError(); if(is_bool($bool) && !$bool) { $error['customError'] = $bool; @@ -239,6 +297,7 @@ public function buildArrayTest(string $method, array|Closure $args): bool /** * Init MaplePHP validation + * * @param mixed $value * @return Inp * @throws ErrorException @@ -252,6 +311,7 @@ protected function valid(mixed $value): Inp * This is a helper function that will list all inherited proxy methods * * @param string $class + * @param string|null $prefixMethods * @return void * @throws \ReflectionException */ @@ -275,5 +335,4 @@ public function listAllProxyMethods(string $class, ?string $prefixMethods = null } } } - } diff --git a/src/TestUnit.php b/src/TestUnit.php index 3190c2f..a0cebde 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -14,9 +14,12 @@ class TestUnit private ?string $message; private array $unit = []; private int $count = 0; + private int $valLength = 0; + private array $codeLine = ['line' => 0, 'code' => '', 'file' => '']; /** * Initiate the test + * * @param mixed $value * @param string|null $message */ @@ -29,6 +32,7 @@ public function __construct(mixed $value, ?string $message = null) /** * Set the test unit + * * @param bool $valid * @param string|null $validation * @param array $args @@ -40,6 +44,12 @@ public function setUnit(bool $valid, ?string $validation = null, array $args = [ $this->valid = false; $this->count++; } + + $valLength = strlen((string)$validation); + if($validation && $this->valLength < $valLength) { + $this->valLength = $valLength; + } + $this->unit[] = [ 'valid' => $valid, 'validation' => $validation, @@ -48,8 +58,57 @@ public function setUnit(bool $valid, ?string $validation = null, array $args = [ return $this; } + /** + * Get the length of the validation string with the maximum length + * + * @return int + */ + public function getValidationLength(): int + { + return $this->valLength; + } + + /** + * Set the code line from a backtrace + * + * @param array $trace + * @return $this + * @throws ErrorException + */ + function setCodeLine(array $trace): self + { + $this->codeLine = []; + $file = $trace['file'] ?? ''; + $line = $trace['line'] ?? 0; + if ($file && $line) { + $lines = file($file); + $code = trim($lines[$line - 1] ?? ''); + if(str_starts_with($code, '->')) { + $code = substr($code, 2); + } + $code = $this->excerpt($code); + + $this->codeLine['line'] = $line; + $this->codeLine['file'] = $file; + $this->codeLine['code'] = $code; + } + return $this; + } + + + /** + * Get the code line from a backtrace + * + * @return array + */ + public function getCodeLine(): array + { + return $this->codeLine; + } + /** * Get ever test unit item array data + * * @return array */ public function getUnits(): array @@ -59,6 +118,7 @@ public function getUnits(): array /** * Get failed test count + * * @return int */ public function getFailedTestCount(): int @@ -68,6 +128,7 @@ public function getFailedTestCount(): int /** * Get test message + * * @return string|null */ public function getMessage(): ?string @@ -77,6 +138,7 @@ public function getMessage(): ?string /** * Get if test is valid + * * @return bool */ public function isValid(): bool @@ -86,6 +148,7 @@ public function isValid(): bool /** * Gte the original value + * * @return mixed */ public function getValue(): mixed @@ -95,34 +158,35 @@ public function getValue(): mixed /** * Used to get a readable value + * * @return string * @throws ErrorException */ public function getReadValue(): string { if (is_bool($this->value)) { - return "(bool): " . ($this->value ? "true" : "false"); + return '"' . ($this->value ? "true" : "false") . '"' . " (type: bool)"; } if (is_int($this->value)) { - return "(int): " . $this->excerpt((string)$this->value); + return '"' . $this->excerpt((string)$this->value) . '"' . " (type: int)"; } if (is_float($this->value)) { - return "(float): " . $this->excerpt((string)$this->value); + return '"' . $this->excerpt((string)$this->value) . '"' . " (type: float)"; } if (is_string($this->value)) { - return "(string): " . $this->excerpt($this->value); + return '"' . $this->excerpt($this->value) . '"' . " (type: string)"; } if (is_array($this->value)) { - return "(array): " . $this->excerpt(json_encode($this->value)); + return '"' . $this->excerpt(json_encode($this->value)) . '"' . " (type: array)"; } if (is_object($this->value)) { - return "(object): " . $this->excerpt(get_class($this->value)); + return '"' . $this->excerpt(get_class($this->value)) . '"' . " (type: object)"; } if (is_null($this->value)) { - return "(null)"; + return '"null" (type: null)'; } if (is_resource($this->value)) { - return "(resource): " . $this->excerpt(get_resource_type($this->value)); + return '"' . $this->excerpt(get_resource_type($this->value)) . '"' . " (type: resource)"; } return "(unknown type)"; @@ -130,14 +194,16 @@ public function getReadValue(): string /** * Used to get exception to the readable value + * * @param string $value + * @param int $length * @return string * @throws ErrorException */ - final protected function excerpt(string $value): string + final protected function excerpt(string $value, int $length = 80): string { $format = new Str($value); - return (string)$format->excerpt(70)->get(); + return (string)$format->excerpt($length)->get(); } } diff --git a/src/Unit.php b/src/Unit.php index fd99e75..4b69637 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -194,6 +194,8 @@ public function execute(): bool if(!($row instanceof TestCase)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestCase."); } + + $errArg = self::getArgs("errors-only"); $tests = $row->dispatchTest(); $color = ($row->hasFailed() ? "brightRed" : "brightBlue"); $flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); @@ -201,6 +203,10 @@ public function execute(): bool $flag = $this->command->getAnsi()->style(['redBg', 'brightWhite'], " FAIL "); } + if($errArg !== false && !$row->hasFailed()) { + continue; + } + $this->command->message(""); $this->command->message( $flag . " " . @@ -217,17 +223,30 @@ public function execute(): bool if(!$test->isValid()) { $msg = (string)$test->getMessage(); $this->command->message(""); - $this->command->message($this->command->getAnsi()->style(["bold", "brightRed"], "Error: " . $msg)); + $this->command->message( + $this->command->getAnsi()->style(["bold", "brightRed"], "Error: ") . + $this->command->getAnsi()->bold($msg) + ); + $this->command->message(""); + + $trace = $test->getCodeLine(); + if(!empty($trace['code'])) { + $this->command->message($this->command->getAnsi()->style(["bold", "grey"], "Failed on line {$trace['line']}: ")); + $this->command->message($this->command->getAnsi()->style(["grey"], " → {$trace['code']}")); + + } + /** @var array $unit */ foreach($test->getUnits() as $unit) { + $title = str_pad($unit['validation'], $test->getValidationLength() + 1); $this->command->message( - $this->command->getAnsi()->bold("Validation: ") . $this->command->getAnsi()->style( ((!$unit['valid']) ? "brightRed" : null), - $unit['validation'] . ((!$unit['valid']) ? " (fail)" : "") + " " .$title . ((!$unit['valid']) ? " → failed" : "") ) ); } + $this->command->message(""); $this->command->message($this->command->getAnsi()->bold("Value: ") . $test->getReadValue()); } } @@ -322,11 +341,10 @@ private function formatFileTitle(string $file, int $length = 3, bool $removeSuff $pop = array_pop($file); $file[] = substr($pop, (int)strpos($pop, 'unitary') + 8); } - $file = array_chunk(array_reverse($file), $length); $file = implode("\\", array_reverse($file[0])); - $exp = explode('.', $file); - $file = reset($exp); + //$exp = explode('.', $file); + //$file = reset($exp); return ".." . $file; } diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index ee68345..40f47ea 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -59,12 +59,14 @@ public function registerUser(string $email): void { $inst->isBool(); $inst->isInt(); $inst->isJson(); - + $inst->isString(); return ($value === "yourTestValue1"); }); + $inst->validate("yourTestValue", fn(ValidatePool $inst) => $inst->isfloat()); + //$inst->listAllProxyMethods(Inp::class); -//->error("Failed to validate yourTestValue (optional error message)") + //->error("Failed to validate yourTestValue (optional error message)") From a1b38c9c66113c7eae100b53681f2711fe7b13b1 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 13 Apr 2025 17:30:04 +0200 Subject: [PATCH 05/22] Add mocking capabilities --- README.md | 114 +++++++---- src/Mocker/MethodItem.php | 313 ++++++++++++++++++++++++++++ src/Mocker/MethodPool.php | 60 ++++++ src/Mocker/Mocker.php | 351 ++++++++++++++++++++++++++++++++ src/Mocker/MockerController.php | 54 +++++ src/TestCase.php | 192 +++++++++++++---- src/TestMocker.php | 248 ---------------------- src/TestUnit.php | 95 ++++++--- src/Unit.php | 51 +++-- tests/unitary-unitary.php | 85 ++++++-- 10 files changed, 1185 insertions(+), 378 deletions(-) create mode 100644 src/Mocker/MethodItem.php create mode 100644 src/Mocker/MethodPool.php create mode 100755 src/Mocker/Mocker.php create mode 100644 src/Mocker/MockerController.php delete mode 100755 src/TestMocker.php diff --git a/README.md b/README.md index f3a7fdd..48f7504 100644 --- a/README.md +++ b/README.md @@ -117,53 +117,93 @@ php vendor/bin/unitary With that, you are ready to create your own tests! -## Integration tests: Test Wrapper -The TestWrapper allows you to wrap an existing class, override its methods, and inject dependencies dynamically. -It is useful for integration testing, debugging, and extending existing functionality without the need of -modifying the original class. - -### The problem -Imagine we have a PaymentProcessor class that communicates with an external payment gateway to -capture a customer's payment. We would like to test this with its own functionallity to keep the test useful -but avoid making any charges to customer. -```php -class PaymentProcessor -{ - public function __construct( - private OrderService $orderService, - private PaymentGateway $gateway, - private Logger $logger - ) {} - - public function capture(string $orderID) - { - $order = $this->orderService->getOrder($orderID); - - if (!$order) { - throw new Exception("Order not found: $orderID"); - } +## Mocking +Unitary comes with a built-in mocker that makes it super simple for you to mock classes. - $this->logger->info("Capturing payment for Order ID: " . $order->id); - $response = $this->gateway->capture($order->id); +### Auto mocking +What is super cool with Unitary Mocker will try to automatically mock the class that you pass and +it will do it will do it quite accurate as long as the class and its methods that you are mocking is +using data type in arguments and return type. - if ($response['status'] !== 'success') { - throw new Exception("Payment capture failed: " . $response['message']); - } +```php +$unit->group("Testing user service", function (TestCase $inst) { + + // Just call the unitary mock and pass in class name + $mock = $inst->mock(Mailer::class); + // Mailer class is not mocked! + + // Pass argument to Mailer constructor e.g. new Mailer('john.doe@gmail.com', 'John Doe'); + //$mock = $inst->mock([Mailer::class, ['john.doe@gmail.com', 'John Doe']); + // Mailer class is not mocked again! + + // Then just pass the mocked library to what ever service or controller you wish + $service = new UserService($mock); +}); +``` +_Why? Sometimes you just want to quick mock so that a Mailer library will not send a mail_ - return "Transaction ID: " . $response['transaction_id']; - } -} +### Custom mocking +As I said Unitary mocker will try to automatically mock every method but might not successes in some user-cases +then you can just tell Unitary how those failed methods should load. +```php +use MaplePHP\Validate\ValidatePool; +use \MaplePHP\Unitary\Mocker\MethodPool; + +$unit->group("Testing user service", function (TestCase $inst) { + $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { + // Quick way to tell Unitary that this method should return 'john.doe' + $pool->method("getFromEmail")->return('john.doe@gmail.com'); + + // Or we can acctually pass a callable to it and tell it what it should return + // But we can also validate the argumnets! + $pool->method("addFromEmail")->wrap(function($email) use($inst) { + $inst->validate($email, function(ValidatePool $valid) { + $valid->email(); + $valid->isString(); + }); + return true; + }); + }); + + // Then just pass the mocked library to what ever service or controller you wish + $service = new UserService($mock); +}); ``` -### Use the Test Wrapper -Use wrapper()->bind() to make integration tests easier. Test wrapper will bind a callable to specified class in wrapper, in this case to PaymentProcessor and will be accessible with `$dispatch("OR827262")`. +### Mocking: Add Consistency validation +What is really cool is that you can also use Unitary mocker to make sure consistencies is followed and +validate that the method is built and loaded correctly. + +```php +use \MaplePHP\Unitary\Mocker\MethodPool; + +$unit->group("Unitary test", function (TestCase $inst) { + $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { + $pool->method("addFromEmail") + ->isPublic() + ->hasDocComment() + ->hasReturnType() + ->count(1); + + $pool->method("addBCC") + ->isPublic() + ->count(3); + }); + $service = new UserService($mock); +}); +``` + + +### Integration tests: Test Wrapper +Test wrapper is great to make integration test easier. -With TestWrapper, we can simulate an order and intercept the payment capture while keeping access to $this inside the closure. +Most libraries or services has a method that executes the service and runs all the logic. The test wrapper we +can high-jack that execution method and overwrite it with our own logic. ```php -$dispatch = $this->wrapper(PaymentProcessor::class)->bind(function ($orderID) use ($inst) { +$dispatch = $this->wrap(PaymentProcessor::class)->bind(function ($orderID) use ($inst) { // Simulate order retrieval $order = $this->orderService->getOrder($orderID); $response = $inst->mock('gatewayCapture')->capture($order->id); diff --git a/src/Mocker/MethodItem.php b/src/Mocker/MethodItem.php new file mode 100644 index 0000000..d44e5e0 --- /dev/null +++ b/src/Mocker/MethodItem.php @@ -0,0 +1,313 @@ +mocker = $mocker; + } + + public function wrap($call): self + { + $inst = $this; + $wrap = new class($this->mocker->getClassName()) extends TestWrapper { + }; + $call->bindTo($this->mocker); + $this->wrapper = $wrap->bind($call); + return $inst; + } + + public function getWrap(): ?Closure + { + return $this->wrapper; + } + + public function hasReturn(): bool + { + return $this->hasReturn; + } + + /** + * Check if method has been called x times + * @param int $count + * @return $this + */ + public function count(int $count): self + { + $inst = $this; + $inst->count = $count; + return $inst; + } + + /** + * Change what the method should return + * + * @param mixed $value + * @return $this + */ + public function return(mixed $value): self + { + $inst = $this; + $inst->hasReturn = true; + $inst->return = $value; + return $inst; + } + + /** + * Set the class name. + * + * @param string $class + * @return self + */ + public function class(string $class): self + { + $inst = $this; + $inst->class = $class; + return $inst; + } + + /** + * Set the method name. + * + * @param string $name + * @return self + */ + public function name(string $name): self + { + $inst = $this; + $inst->name = $name; + return $inst; + } + + /** + * Mark the method as static. + * + * @return self + */ + public function isStatic(): self + { + $inst = $this; + $inst->isStatic = true; + return $inst; + } + + /** + * Mark the method as public. + * + * @return self + */ + public function isPublic(): self + { + $inst = $this; + $inst->isPublic = true; + return $inst; + } + + /** + * Mark the method as private. + * + * @return self + */ + public function isPrivate(): self + { + $inst = $this; + $inst->isPrivate = true; + return $inst; + } + + /** + * Mark the method as protected. + * + * @return self + */ + public function isProtected(): self + { + $inst = $this; + $inst->isProtected = true; + return $inst; + } + + /** + * Mark the method as abstract. + * + * @return self + */ + public function isAbstract(): self + { + $inst = $this; + $inst->isAbstract = true; + return $inst; + } + + /** + * Mark the method as final. + * + * @return self + */ + public function isFinal(): self + { + $inst = $this; + $inst->isFinal = true; + return $inst; + } + + /** + * Mark the method as returning by reference. + * + * @return self + */ + public function returnsReference(): self + { + $inst = $this; + $inst->returnsReference = true; + return $inst; + } + + /** + * Mark the method as having a return type. + * + * @return self + */ + public function hasReturnType(): self + { + $inst = $this; + $inst->hasReturnType = true; + return $inst; + } + + /** + * Set the return type of the method. + * + * @param string $type + * @return self + */ + public function returnType(string $type): self + { + $inst = $this; + $inst->returnType = $type; + return $inst; + } + + /** + * Mark the method as a constructor. + * + * @return self + */ + public function isConstructor(): self + { + $inst = $this; + $inst->isConstructor = true; + return $inst; + } + + /** + * Mark the method as a destructor. + * + * @return self + */ + public function isDestructor(): self + { + $inst = $this; + $inst->isDestructor = true; + return $inst; + } + + /** + * Not yet working + * Set the parameters of the method. + * + * @param array $parameters + * @return self + */ + public function parameters(array $parameters): self + { + throw new \BadMethodCallException('Method Item::parameters() does not "YET" exist.'); + $inst = $this; + $inst->parameters = $parameters; + return $inst; + } + + /** + * Set the doc comment for the method. + * + * @return self + */ + public function hasDocComment(): self + { + $inst = $this; + $inst->hasDocComment = [ + "isString" => [], + "startsWith" => ["/**"] + ]; + return $inst; + } + + /** + * Set the starting line number of the method. + * + * @param int $line + * @return self + */ + public function startLine(int $line): self + { + $inst = $this; + $inst->startLine = $line; + return $inst; + } + + /** + * Set the ending line number of the method. + * + * @param int $line + * @return self + */ + public function endLine(int $line): self + { + $inst = $this; + $inst->endLine = $line; + return $inst; + } + + /** + * Set the file name where the method is declared. + * + * @param string $file + * @return self + */ + public function fileName(string $file): self + { + $inst = $this; + $inst->fileName = $file; + return $inst; + } +} \ No newline at end of file diff --git a/src/Mocker/MethodPool.php b/src/Mocker/MethodPool.php new file mode 100644 index 0000000..9abf45b --- /dev/null +++ b/src/Mocker/MethodPool.php @@ -0,0 +1,60 @@ +mocker = $mocker; + } + + /** + * This method adds a new method to the pool with a given name and + * returns the corresponding MethodItem instance. + * + * @param string $name The name of the method to add. + * @return MethodItem The newly created MethodItem instance. + */ + public function method(string $name): MethodItem + { + $this->methods[$name] = new MethodItem($this->mocker); + return $this->methods[$name]; + } + + /** + * Get method + * + * @param string $key + * @return MethodItem|null + */ + public function get(string $key): MethodItem|null + { + return $this->methods[$key] ?? null; + } + + /** + * Get all methods + * + * @return array True if the method exists, false otherwise. + */ + public function getAll(): array + { + return $this->methods; + } + + /** + * Checks if a method with the given name exists in the pool. + * + * @param string $name The name of the method to check. + * @return bool True if the method exists, false otherwise. + */ + public function has(string $name): bool + { + return isset($this->methods[$name]); + } + +} \ No newline at end of file diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php new file mode 100755 index 0000000..eb31c77 --- /dev/null +++ b/src/Mocker/Mocker.php @@ -0,0 +1,351 @@ +className = $className; + $this->reflection = new ReflectionClass($className); + $this->methods = $this->reflection->getMethods(); + $this->constructorArgs = $args; + } + + public function getClassName(): string + { + return $this->className; + } + + /** + * Override the default method overrides with your own mock logic and validation rules + * + * @return MethodPool + */ + public function getMethodPool(): MethodPool + { + if(is_null(self::$methodPool)) { + self::$methodPool = new MethodPool($this); + } + return self::$methodPool; + } + + public function getMockedClassName(): string + { + return $this->mockClassName; + } + + /** + * Executes the creation of a dynamic mock class and returns an instance of the mock. + * + * @return object An instance of the dynamically created mock class. + * @throws \ReflectionException + */ + public function execute(): object + { + $className = $this->reflection->getName(); + + $shortClassName = explode("\\", $className); + $shortClassName = end($shortClassName); + + $this->mockClassName = 'Unitary_' . uniqid() . "_Mock_" . $shortClassName; + $overrides = $this->generateMockMethodOverrides($this->mockClassName); + $unknownMethod = $this->errorHandleUnknownMethod($className); + $code = " + class {$this->mockClassName} extends {$className} { + {$overrides} + {$unknownMethod} + } + "; + + eval($code); + return new $this->mockClassName(...$this->constructorArgs); + } + + /** + * Handles the situation where an unknown method is called on the mock class. + * If the base class defines a __call method, it will delegate to it. + * Otherwise, it throws a BadMethodCallException. + * + * @param string $className The name of the class for which the mock is created. + * @return string The generated PHP code for handling unknown method calls. + */ + private function errorHandleUnknownMethod(string $className): string + { + if(!in_array('__call', $this->methodList)) { + return " + public function __call(string \$name, array \$arguments) { + if (method_exists(get_parent_class(\$this), '__call')) { + return parent::__call(\$name, \$arguments); + } + throw new \\BadMethodCallException(\"Method '\$name' does not exist in class '{$className}'.\"); + } + "; + } + return ""; + } + + /** + * @param array $types + * @param mixed $method + * @param MethodItem|null $methodItem + * @return string + */ + protected function getReturnValue(array $types, mixed $method, ?MethodItem $methodItem = null): string + { + // Will overwrite the auto generated value + if($methodItem && $methodItem->hasReturn()) { + return "return " . var_export($methodItem->return, true) . ";"; + } + if ($types) { + return $this->getMockValueForType($types[0], $method); + } + return "return 'MockedValue';"; + } + + /** + * Builds and returns PHP code that overrides all public methods in the class being mocked. + * Each overridden method returns a predefined mock value or delegates to the original logic. + * + * @return string PHP code defining the overridden methods. + * @throws \ReflectionException + */ + protected function generateMockMethodOverrides(string $mockClassName): string + { + $overrides = ''; + foreach ($this->methods as $method) { + if ($method->isConstructor() || $method->isFinal()) { + continue; + } + + $methodName = $method->getName(); + $this->methodList[] = $methodName; + + // The MethodItem contains all items that are validatable + $methodItem = $this->getMethodPool()->get($methodName); + $types = $this->getReturnType($method); + $returnValue = $this->getReturnValue($types, $method, $methodItem); + $paramList = $this->generateMethodSignature($method); + $returnType = ($types) ? ': ' . implode('|', $types) : ''; + $modifiersArr = Reflection::getModifierNames($method->getModifiers()); + $modifiers = implode(" ", $modifiersArr); + + $return = ($methodItem && $methodItem->hasReturn()) ? $methodItem->return : eval($returnValue); + $arr = $this->getMethodInfoAsArray($method); + $arr['mocker'] = $mockClassName; + $arr['return'] = $return; + + $info = json_encode($arr); + MockerController::getInstance()->buildMethodData($info); + + if($methodItem && !in_array("void", $types)) { + $returnValue = $this->generateWrapperReturn($methodItem->getWrap(), $methodName, $returnValue); + } + + $overrides .= " + {$modifiers} function {$methodName}({$paramList}){$returnType} + { + \$obj = \\MaplePHP\\Unitary\\Mocker\\MockerController::getInstance()->buildMethodData('{$info}'); + \$data = \\MaplePHP\\Unitary\\Mocker\\MockerController::getDataItem(\$obj->mocker, \$obj->name); + {$returnValue} + } + "; + } + + return $overrides; + } + + + protected function generateWrapperReturn(?\Closure $wrapper, string $methodName, string $returnValue) { + MockerController::addData($this->mockClassName, $methodName, 'wrapper', $wrapper); + return " + if (isset(\$data->wrapper) && \$data->wrapper instanceof \\Closure) { + return call_user_func_array(\$data->wrapper, func_get_args()); + } + {$returnValue} + "; + } + + /** + * Generates the signature for a method, including type hints, default values, and by-reference indicators. + * + * @param ReflectionMethod $method The reflection object for the method to analyze. + * @return string The generated method signature. + */ + protected function generateMethodSignature(ReflectionMethod $method): string + { + $params = []; + foreach ($method->getParameters() as $param) { + $paramStr = ''; + if ($param->hasType()) { + $paramStr .= $param->getType() . ' '; + } + if ($param->isPassedByReference()) { + $paramStr .= '&'; + } + $paramStr .= '$' . $param->getName(); + if ($param->isDefaultValueAvailable()) { + $paramStr .= ' = ' . var_export($param->getDefaultValue(), true); + } + $params[] = $paramStr; + } + return implode(', ', $params); + } + + /** + * Determines and retrieves the expected return types of a given method. + * + * @param ReflectionMethod $method The reflection object for the method to inspect. + * @return array An array of the expected return types for the given method. + */ + protected function getReturnType($method): array + { + $types = []; + $returnType = $method->getReturnType(); + if ($returnType instanceof ReflectionNamedType) { + $types[] = $returnType->getName(); + } elseif ($returnType instanceof ReflectionUnionType) { + foreach ($returnType->getTypes() as $type) { + $types[] = $type->getName(); + } + + } elseif ($returnType instanceof ReflectionIntersectionType) { + $intersect = array_map(fn($type) => $type->getName(), $returnType->getTypes()); + $types[] = $intersect; + } + + if(!in_array("mixed", $types) && $returnType && $returnType->allowsNull()) { + $types[] = "null"; + } + return $types; + } + + /** + * Generates a mock value for the specified type. + * + * @param string $typeName The name of the type for which to generate the mock value. + * @param bool $nullable Indicates if the returned value can be nullable. + * @return mixed Returns a mock value corresponding to the given type, or null if nullable and conditions allow. + */ + protected function getMockValueForType(string $typeName, mixed $method, mixed $value = null, bool $nullable = false): mixed + { + $typeName = strtolower($typeName); + if(!is_null($value)) { + return "return " . var_export($value, true) . ";"; + } + + $mock = match ($typeName) { + 'int' => "return 123456;", + 'integer' => "return 123456;", + 'float' => "return 3.14;", + 'double' => "return 3.14;", + 'string' => "return 'mockString';", + 'bool' => "return true;", + 'boolean' => "return true;", + 'array' => "return ['item'];", + 'object' => "return (object)['item'];", + 'resource' => "return fopen('php://memory', 'r+');", + 'callable' => "return fn() => 'called';", + 'iterable' => "return new ArrayIterator(['a', 'b']);", + 'null' => "return null;", + 'void' => "", + 'self' => ($method->isStatic()) ? 'return new self();' : 'return $this;', + default => (is_string($typeName) && class_exists($typeName)) + ? "return new class() extends " . $typeName . " {};" + : "return null;", + + }; + return $nullable && rand(0, 1) ? null : $mock; + } + + /** + * Will return a streamable content + * + * @param $resourceValue + * @return string|null + */ + protected function handleResourceContent($resourceValue): ?string + { + return var_export(stream_get_contents($resourceValue), true); + } + + /** + * Build a method information array form ReflectionMethod instance + * + * @param ReflectionMethod $refMethod + * @return array + */ + function getMethodInfoAsArray(ReflectionMethod $refMethod): array + { + $params = []; + foreach ($refMethod->getParameters() as $param) { + $params[] = [ + 'name' => $param->getName(), + 'position' => $param->getPosition(), + 'hasType' => $param->hasType(), + 'type' => $param->hasType() ? $param->getType()->__toString() : null, + 'isOptional' => $param->isOptional(), + 'isVariadic' => $param->isVariadic(), + 'isPassedByReference' => $param->isPassedByReference(), + 'default' => $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null, + ]; + } + + return [ + 'class' => $refMethod->getDeclaringClass()->getName(), + 'name' => $refMethod->getName(), + 'isStatic' => $refMethod->isStatic(), + 'isPublic' => $refMethod->isPublic(), + 'isPrivate' => $refMethod->isPrivate(), + 'isProtected' => $refMethod->isProtected(), + 'isAbstract' => $refMethod->isAbstract(), + 'isFinal' => $refMethod->isFinal(), + 'returnsReference' => $refMethod->returnsReference(), + 'hasReturnType' => $refMethod->hasReturnType(), + 'returnType' => $refMethod->hasReturnType() ? $refMethod->getReturnType()->__toString() : null, + 'isConstructor' => $refMethod->isConstructor(), + 'isDestructor' => $refMethod->isDestructor(), + 'parameters' => $params, + 'hasDocComment' => $refMethod->getDocComment(), + 'startLine' => $refMethod->getStartLine(), + 'endLine' => $refMethod->getEndLine(), + 'fileName' => $refMethod->getFileName(), + ]; + } + +} \ No newline at end of file diff --git a/src/Mocker/MockerController.php b/src/Mocker/MockerController.php new file mode 100644 index 0000000..5eaf1c2 --- /dev/null +++ b/src/Mocker/MockerController.php @@ -0,0 +1,54 @@ +{$key} = $value; + } + + public function buildMethodData(string $method): object + { + $data = json_decode($method); + if(empty(self::$data[$data->mocker][$data->name])) { + $data->count = 0; + self::$data[$data->mocker][$data->name] = $data; + } else { + self::$data[$data->mocker][$data->name]->count++; + } + return $data; + } + +} \ No newline at end of file diff --git a/src/TestCase.php b/src/TestCase.php index 6e4225a..1cc11eb 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -4,12 +4,14 @@ namespace MaplePHP\Unitary; -use MaplePHP\Validate\ValidatePool; -use MaplePHP\Validate\Inp; use BadMethodCallException; +use Closure; use ErrorException; +use MaplePHP\Unitary\Mocker\Mocker; +use MaplePHP\Unitary\Mocker\MockerController; +use MaplePHP\Validate\Inp; +use MaplePHP\Validate\ValidatePool; use RuntimeException; -use Closure; use Throwable; class TestCase @@ -21,6 +23,8 @@ class TestCase private ?Closure $bind = null; private ?string $errorMessage = null; + private array $deferredValidation = []; + /** * Initialize a new TestCase instance with an optional message. @@ -79,40 +83,35 @@ public function error(string $message): self */ public function validate(mixed $expect, Closure $validation): self { - $this->addTestUnit($expect, function(mixed $value, ValidatePool $inst) use($validation) { + $this->expectAndValidate($expect, function(mixed $value, ValidatePool $inst) use($validation) { return $validation($inst, $value); }, $this->errorMessage); return $this; } - + /** - * Same as "addTestUnit" but is public and will make sure the validation can be - * properly registered and traceable + * Executes a test case at runtime by validating the expected value. * - * @param mixed $expect The expected value - * @param array|Closure $validation The validation logic - * @param string|null $message An optional descriptive message for the test + * Accepts either a validation array (method => arguments) or a Closure + * containing multiple inline assertions. If any validation fails, the test + * is marked as invalid and added to the list of failed tests. + * + * @param mixed $expect The value to test. + * @param array|Closure $validation A list of validation methods with arguments, + * or a closure defining the test logic. + * @param string|null $message Optional custom message for test reporting. * @return $this - * @throws ErrorException + * @throws ErrorException If validation fails during runtime execution. */ - public function add(mixed $expect, array|Closure $validation, ?string $message = null) { - return $this->addTestUnit($expect, $validation, $message); - } - - /** - * Create a test - * - * @param mixed $expect - * @param array|Closure $validation - * @param string|null $message - * @return TestCase - * @throws ErrorException - */ - protected function addTestUnit(mixed $expect, array|Closure $validation, ?string $message = null): self - { + protected function expectAndValidate( + mixed $expect, + array|Closure $validation, + ?string $message = null + ): self { $this->value = $expect; - $test = new TestUnit($this->value, $message); + $test = new TestUnit($message); + $test->setTestValue($this->value); if($validation instanceof Closure) { $list = $this->buildClosureTest($validation); foreach($list as $method => $valid) { @@ -136,6 +135,38 @@ protected function addTestUnit(mixed $expect, array|Closure $validation, ?string return $this; } + /** + * Adds a deferred validation to be executed after all immediate tests. + * + * Use this to queue up validations that depend on external factors or should + * run after the main test suite. These will be executed in the order they were added. + * + * @param Closure $validation A closure containing the deferred test logic. + * @return void + */ + public function deferValidation(Closure $validation) + { + // This will add a cursor to the possible line and file where error occurred + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; + $this->deferredValidation[] = [ + "trace" => $trace, + "call" => $validation + ]; + } + + /** + * Same as "addTestUnit" but is public and will make sure the validation can be + * properly registered and traceable + * + * @param mixed $expect The expected value + * @param array|Closure $validation The validation logic + * @param string|null $message An optional descriptive message for the test + * @return $this + * @throws ErrorException + */ + public function add(mixed $expect, array|Closure $validation, ?string $message = null) { + return $this->expectAndValidate($expect, $validation, $message); + } /** * Init a test wrapper @@ -143,25 +174,112 @@ protected function addTestUnit(mixed $expect, array|Closure $validation, ?string * @param string $className * @return TestWrapper */ - public function wrapper(string $className): TestWrapper + public function wrap(string $className): TestWrapper { return new class($className) extends TestWrapper { }; } - public function mock(string $className, null|array|Closure $validate = null): object + /** + * Creates and returns an instance of a dynamically generated mock class. + * + * The mock class is based on the provided class name and optional constructor arguments. + * A validation closure can also be provided to define mock expectations. These + * validations are deferred and will be executed later via runDeferredValidations(). + * + * @param string|array $classArg Either the class name as a string, + * or an array with [className, constructorArgs]. + * @param Closure|null $validate Optional closure to define expectations on the mock. + * @return object An instance of the dynamically created mock class. + * @throws \ReflectionException If the class or constructor cannot be reflected. + */ + public function mock(string|array $classArg, null|Closure $validate = null): object { - $mocker = new TestMocker($className); - if(is_array($validate)) { - $mocker->validate($validate); + $args = []; + $className = $classArg; + if(is_array($classArg)) { + $className = $classArg[0]; + $args = ($classArg[1] ?? []); } + + $mocker = new Mocker($className, $args); if(is_callable($validate)) { - $fn = $validate->bindTo($mocker); - $fn($mocker); + $pool = $mocker->getMethodPool(); + $fn = $validate->bindTo($pool); + $fn($pool); + + $this->deferValidation(function() use($mocker, $pool) { + $error = []; + $data = MockerController::getData($mocker->getMockedClassName()); + + foreach($data as $row) { + $item = $pool->get($row->name); + if($item) { + foreach (get_object_vars($item) as $property => $value) { + if(!is_null($value)) { + + + $currentValue = $row->{$property}; + if(is_array($value)) { + $validPool = new ValidatePool($currentValue); + foreach($value as $method => $args) { + $validPool->{$method}(...$args); + } + $valid = $validPool->isValid(); + } else { + $valid = Inp::value($currentValue)->equal($value); + } + + $error[$row->name][] = [ + "property" => $property, + "currentValue" => $currentValue, + "expectedValue" => $value, + "valid" => $valid + ]; + } + } + } + } + return $error; + }); } return $mocker->execute(); } + /** + * Executes all deferred validations that were registered earlier using deferValidation(). + * + * This method runs each queued validation closure, collects their results, + * and converts them into individual TestUnit instances. If a validation fails, + * it increases the internal failure count and stores the test details for later reporting. + * + * @return TestUnit[] A list of TestUnit results from the deferred validations. + * @throws ErrorException If any validation logic throws an error during execution. + */ + public function runDeferredValidations() + { + foreach($this->deferredValidation as $row) { + $error = $row['call'](); + foreach($error as $method => $arr) { + $test = new TestUnit("Mock method \"{$method}\" failed"); + if(is_array($row['trace'] ?? "")) { + $test->setCodeLine($row['trace']); + } + foreach($arr as $data) { + $test->setUnit($data['valid'], $data['property'], [], [ + $data['expectedValue'], $data['currentValue'] + ]); + if (!$data['valid']) { + $this->count++; + } + } + $this->test[] = $test; + } + } + + return $this->test; + } + /** * Get failed test counts @@ -239,9 +357,9 @@ public function getTest(): array * @param Closure $validation * @return array */ - public function buildClosureTest(Closure $validation): array + protected function buildClosureTest(Closure $validation): array { - $bool = false; + //$bool = false; $validPool = new ValidatePool($this->value); $validation = $validation->bindTo($validPool); @@ -269,7 +387,7 @@ public function buildClosureTest(Closure $validation): array * @return bool * @throws ErrorException */ - public function buildArrayTest(string $method, array|Closure $args): bool + protected function buildArrayTest(string $method, array|Closure $args): bool { if($args instanceof Closure) { $args = $args->bindTo($this->valid($this->value)); diff --git a/src/TestMocker.php b/src/TestMocker.php deleted file mode 100755 index 41ecad8..0000000 --- a/src/TestMocker.php +++ /dev/null @@ -1,248 +0,0 @@ -reflection = new ReflectionClass($className); - $this->methods = $this->reflection->getMethods(ReflectionMethod::IS_PUBLIC); - - } - - /** - * Executes the creation of a dynamic mock class and returns an instance of the mock. - * - * @return mixed - */ - function execute(): mixed - { - $className = $this->reflection->getName(); - $mockClassName = 'UnitaryMockery_' . uniqid(); - $overrides = $this->overrideMethods(); - $code = " - class {$mockClassName} extends {$className} { - {$overrides} - } - "; - eval($code); - return new $mockClassName(); - } - - function return(mixed $returnValue): self - { - self::$return = $returnValue; - return $this; - } - - - static public function getReturn(): mixed - { - return self::$return; - } - - /** - * @param array $types - * @return string - * @throws \ReflectionException - */ - function getReturnValue(array $types): string - { - $property = new ReflectionProperty($this, 'return'); - if ($property->isInitialized($this)) { - $type = gettype(self::getReturn()); - if($types && !in_array($type, $types) && !in_array("mixed", $types)) { - throw new InvalidArgumentException("Mock value \"" . self::getReturn() . "\" should return data type: " . implode(', ', $types)); - } - - return $this->getMockValueForType($type, self::getReturn()); - } - if ($types) { - return $this->getMockValueForType($types[0]); - } - return "return 'MockedValue';"; - } - - /** - * Overrides all methods in class - * - * @return string - */ - protected function overrideMethods(): string - { - $overrides = ''; - foreach ($this->methods as $method) { - if ($method->isConstructor()) { - continue; - } - - $params = []; - $methodName = $method->getName(); - $types = $this->getReturnType($method); - $returnValue = $this->getReturnValue($types); - - foreach ($method->getParameters() as $param) { - $paramStr = ''; - if ($param->hasType()) { - $paramStr .= $param->getType() . ' '; - } - if ($param->isPassedByReference()) { - $paramStr .= '&'; - } - $paramStr .= '$' . $param->getName(); - if ($param->isDefaultValueAvailable()) { - $paramStr .= ' = ' . var_export($param->getDefaultValue(), true); - } - $params[] = $paramStr; - } - - $paramList = implode(', ', $params); - $returnType = ($types) ? ': ' . implode('|', $types) : ''; - $overrides .= " - public function {$methodName}({$paramList}){$returnType} - { - {$returnValue} - } - "; - } - - return $overrides; - } - - /** - * Get expected return types - * - * @param $method - * @return array - */ - protected function getReturnType($method): array - { - $types = []; - $returnType = $method->getReturnType(); - if ($returnType instanceof ReflectionNamedType) { - $types[] = $returnType->getName(); - } elseif ($returnType instanceof ReflectionUnionType) { - foreach ($returnType->getTypes() as $type) { - $types[] = $type->getName(); - } - - } elseif ($returnType instanceof ReflectionIntersectionType) { - $intersect = array_map(fn($type) => $type->getName(), $returnType->getTypes()); - $types[] = $intersect; - } - if(!in_array("mixed", $types) && $returnType->allowsNull()) { - $types[] = "null"; - } - return $types; - } - - /** - * Generates a mock value for the specified type. - * - * @param string $typeName The name of the type for which to generate the mock value. - * @param bool $nullable Indicates if the returned value can be nullable. - * @return mixed Returns a mock value corresponding to the given type, or null if nullable and conditions allow. - */ - protected function getMockValueForType(string $typeName, mixed $value = null, bool $nullable = false): mixed - { - $typeName = strtolower($typeName); - if(!is_null($value)) { - return "return \MaplePHP\Unitary\TestMocker::getReturn();"; - } - $mock = match ($typeName) { - 'integer' => "return 123456;", - 'double' => "return 3.14;", - 'string' => "return 'mockString';", - 'boolean' => "return true;", - 'array' => "return ['item'];", - 'object' => "return (object)['item'];", - 'resource' => "return fopen('php://memory', 'r+');", - 'callable' => "return fn() => 'called';", - 'iterable' => "return new ArrayIterator(['a', 'b']);", - 'null' => "return null;", - 'void' => "", - default => 'return class_exists($typeName) ? new class($typeName) extends TestMocker {} : null;', - }; - return $nullable && rand(0, 1) ? null : $mock; - } - - - /** - * Will return a streamable content - * @param $resourceValue - * @return string|null - */ - protected function handleResourceContent($resourceValue) - { - return var_export(stream_get_contents($resourceValue), true); - } - - /** - * Proxies calls to the wrapped instance or bound methods. - * - * @param string $name - * @param array $arguments - * @return mixed - * @throws Exception - */ - public function __call(string $name, array $arguments): mixed - { - if (method_exists($this->instance, $name)) { - - $types = $this->getReturnType($name); - if(!isset($types[0]) && is_null($this->return)) { - throw new Exception("Could automatically mock Method \"$name\". " . - "You will need to manually mock it with ->return([value]) mock method!"); - } - - if (!is_null($this->return)) { - return $this->return; - } - - if(isset($types[0]) && is_array($types[0]) && count($types[0]) > 0) { - $last = end($types[0]); - return new self($last); - } - - $mockValue = $this->getMockValueForType($types[0]); - if($mockValue instanceof self) { - return $mockValue; - } - - if(!in_array(gettype($mockValue), $types)) { - throw new Exception("Mock value $mockValue is not in the return type " . implode(', ', $types)); - } - return $mockValue; - } - - throw new \BadMethodCallException("Method \"$name\" does not exist in class \"" . $this->instance::class . "\"."); - } -} \ No newline at end of file diff --git a/src/TestUnit.php b/src/TestUnit.php index a0cebde..e02112a 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -10,7 +10,8 @@ class TestUnit { private bool $valid; - private mixed $value; + private mixed $value = null; + private bool $hasValue = false; private ?string $message; private array $unit = []; private int $count = 0; @@ -23,37 +24,70 @@ class TestUnit * @param mixed $value * @param string|null $message */ - public function __construct(mixed $value, ?string $message = null) + public function __construct(?string $message = null) { $this->valid = true; - $this->value = $value; $this->message = is_null($message) ? "Could not validate" : $message; } + /** + * Check if value should be presented + * + * @return bool + */ + public function hasValue(): bool + { + return $this->hasValue; + } + + /** + * Set a test value + * + * @param mixed $value + * @return void + */ + public function setTestValue(mixed $value) + { + $this->value = $value; + $this->hasValue = true; + } + /** * Set the test unit * - * @param bool $valid - * @param string|null $validation + * @param bool|null $valid can be null if validation should execute later + * @param string|null|\Closure $validation * @param array $args + * @param array $compare * @return $this + * @throws ErrorException */ - public function setUnit(bool $valid, ?string $validation = null, array $args = []): self + public function setUnit( + bool|null $valid, + null|string|\Closure $validation = null, + array $args = [], + array $compare = []): self { if(!$valid) { $this->valid = false; $this->count++; } - $valLength = strlen((string)$validation); - if($validation && $this->valLength < $valLength) { - $this->valLength = $valLength; + if(!is_callable($validation)) { + $valLength = strlen((string)$validation); + if($validation && $this->valLength < $valLength) { + $this->valLength = $valLength; + } + } + + if($compare && count($compare) > 0) { + $compare = array_map(fn($value) => $this->getReadValue($value, true), $compare); } - $this->unit[] = [ 'valid' => $valid, 'validation' => $validation, - 'args' => $args + 'args' => $args, + 'compare' => $compare ]; return $this; } @@ -159,34 +193,37 @@ public function getValue(): mixed /** * Used to get a readable value * - * @return string + * @param mixed|null $value + * @param bool $minify + * @return string|bool * @throws ErrorException */ - public function getReadValue(): string + public function getReadValue(mixed $value = null, bool $minify = false): string|bool { - if (is_bool($this->value)) { - return '"' . ($this->value ? "true" : "false") . '"' . " (type: bool)"; + $value = is_null($value) ? $this->value : $value; + if (is_bool($value)) { + return '"' . ($value ? "true" : "false") . '"' . ($minify ? "" : " (type: bool)"); } - if (is_int($this->value)) { - return '"' . $this->excerpt((string)$this->value) . '"' . " (type: int)"; + if (is_int($value)) { + return '"' . $this->excerpt((string)$value) . '"' . ($minify ? "" : " (type: int)"); } - if (is_float($this->value)) { - return '"' . $this->excerpt((string)$this->value) . '"' . " (type: float)"; + if (is_float($value)) { + return '"' . $this->excerpt((string)$value) . '"' . ($minify ? "" : " (type: float)"); } - if (is_string($this->value)) { - return '"' . $this->excerpt($this->value) . '"' . " (type: string)"; + if (is_string($value)) { + return '"' . $this->excerpt($value) . '"' . ($minify ? "" : " (type: string)"); } - if (is_array($this->value)) { - return '"' . $this->excerpt(json_encode($this->value)) . '"' . " (type: array)"; + if (is_array($value)) { + return '"' . $this->excerpt(json_encode($value)) . '"' . ($minify ? "" : " (type: array)"); } - if (is_object($this->value)) { - return '"' . $this->excerpt(get_class($this->value)) . '"' . " (type: object)"; + if (is_object($value)) { + return '"' . $this->excerpt(get_class($value)) . '"' . ($minify ? "" : " (type: object)"); } - if (is_null($this->value)) { - return '"null" (type: null)'; + if (is_null($value)) { + return '"null"'. ($minify ? '' : ' (type: null)'); } - if (is_resource($this->value)) { - return '"' . $this->excerpt(get_resource_type($this->value)) . '"' . " (type: resource)"; + if (is_resource($value)) { + return '"' . $this->excerpt(get_resource_type($value)) . '"' . ($minify ? "" : " (type: resource)"); } return "(unknown type)"; diff --git a/src/Unit.php b/src/Unit.php index 4b69637..826bb14 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -7,6 +7,7 @@ use Closure; use ErrorException; use Exception; +use MaplePHP\Unitary\Mocker\MockerController; use RuntimeException; use MaplePHP\Unitary\Handlers\HandlerInterface; use MaplePHP\Http\Interfaces\StreamInterface; @@ -153,7 +154,7 @@ public function performance(Closure $func, ?string $title = null): void $start = new TestMem(); $func = $func->bindTo($this); if(!is_null($func)) { - $func(); + $func($this); } $line = $this->command->getAnsi()->line(80); $this->command->message(""); @@ -179,6 +180,7 @@ public function performance(Closure $func, ?string $title = null): void /** * Execute tests suite + * * @return bool * @throws ErrorException */ @@ -196,7 +198,8 @@ public function execute(): bool } $errArg = self::getArgs("errors-only"); - $tests = $row->dispatchTest(); + $row->dispatchTest(); + $tests = $row->runDeferredValidations(); $color = ($row->hasFailed() ? "brightRed" : "brightBlue"); $flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); if($row->hasFailed()) { @@ -233,21 +236,41 @@ public function execute(): bool if(!empty($trace['code'])) { $this->command->message($this->command->getAnsi()->style(["bold", "grey"], "Failed on line {$trace['line']}: ")); $this->command->message($this->command->getAnsi()->style(["grey"], " → {$trace['code']}")); - } /** @var array $unit */ foreach($test->getUnits() as $unit) { - $title = str_pad($unit['validation'], $test->getValidationLength() + 1); - $this->command->message( - $this->command->getAnsi()->style( - ((!$unit['valid']) ? "brightRed" : null), - " " .$title . ((!$unit['valid']) ? " → failed" : "") - ) - ); + if(is_string($unit['validation']) && !$unit['valid']) { + $lengthA = $test->getValidationLength() + 1; + $title = str_pad($unit['validation'], $lengthA); + + $compare = ""; + if($unit['compare']) { + $expectedValue = array_shift($unit['compare']); + $compare = "Expected: {$expectedValue} | Actual: " . implode(":", $unit['compare']); + } + + $failedMsg = " " .$title . ((!$unit['valid']) ? " → failed" : ""); + $this->command->message( + $this->command->getAnsi()->style( + ((!$unit['valid']) ? "brightRed" : null), + $failedMsg + ) + ); + + if(!$unit['valid'] && $compare) { + $lengthB = (strlen($compare) + strlen($failedMsg) - 8); + $comparePad = str_pad($compare, $lengthB, " ", STR_PAD_LEFT); + $this->command->message( + $this->command->getAnsi()->style("brightRed", $comparePad) + ); + } + } + } + if($test->hasValue()) { + $this->command->message(""); + $this->command->message($this->command->getAnsi()->bold("Value: ") . $test->getReadValue()); } - $this->command->message(""); - $this->command->message($this->command->getAnsi()->bold("Value: ") . $test->getReadValue()); } } @@ -276,7 +299,8 @@ public function execute(): bool } /** - * Will reset the execute and stream if is a seekable stream. + * Will reset the execute and stream if is a seekable stream + * * @return bool */ public function resetExecute(): bool @@ -293,6 +317,7 @@ public function resetExecute(): bool /** * Validate before execute test + * * @return bool */ private function validate(): bool diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 40f47ea..fdce99c 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -1,43 +1,96 @@ + isValidEmail($email)) { + throw new \Exception("Invalid email"); + } + return "Sent email"; + } + + public function isValidEmail(string $email): bool + { + return filter_var($email, FILTER_VALIDATE_EMAIL); + } + + public function getFromEmail(string $email): string + { + return $this->from; + } + + /** + * Add from email address + * + * @param string $email + * @return void + */ + public function addFromEmail(string $email): void + { + $this->from = $email; + } + + public function addBCC(string $email): void { - echo "Sent email to $email"; - return "SENT!!"; + $this->bcc = $email; } + } class UserService { public function __construct(private Mailer $mailer) {} - public function registerUser(string $email): void { + public function registerUser(string $email, string $name = "Daniel"): void { // register user logic... - echo $this->mailer->sendEmail($email)."\n"; - echo $this->mailer->sendEmail($email); + + if(!$this->mailer->isValidEmail($email)) { + throw new \Exception("Invalid email"); + } + echo $this->mailer->sendEmail($email, $name)."\n"; + echo $this->mailer->sendEmail($email, $name); } } $unit = new Unit(); +$unit->group("Unitary test 2", function (TestCase $inst) { + + $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { + $pool->method("addFromEmail") + ->isPublic() + ->hasDocComment() + ->hasReturnType() + ->count(0); + + $pool->method("addBCC") + ->isPublic() + ->hasDocComment() + ->count(0); + }); + $service = new UserService($mock); -$unit->group("Unitary test", function (TestCase $inst) { + $inst->validate("yourTestValue", function(ValidatePool $inst) { + $inst->isBool(); + $inst->isInt(); + $inst->isJson(); + $inst->isString(); + $inst->isResource(); + }); // Example 1 /* - $mock = $this->mock(Mailer::class, function ($mock) { - $mock->method("testMethod1")->count(1)->return("lorem1"); - $mock->method("testMethod2")->count(1)->return("lorem1"); - }); + $service = new UserService($mock); // Example 2 @@ -55,15 +108,18 @@ public function registerUser(string $email): void { $service->registerUser('user@example.com'); */ - $inst->validate("yourTestValue", function(ValidatePool $inst, mixed $value) { + /* + $inst->validate("yourTestValue", function(ValidatePool $inst, mixed $value) { $inst->isBool(); $inst->isInt(); $inst->isJson(); $inst->isString(); + $inst->isResource(); return ($value === "yourTestValue1"); }); $inst->validate("yourTestValue", fn(ValidatePool $inst) => $inst->isfloat()); + */ //$inst->listAllProxyMethods(Inp::class); //->error("Failed to validate yourTestValue (optional error message)") @@ -96,3 +152,4 @@ public function registerUser(string $email): void { ], "The length is not correct!"); }); + From b8a81d8d9540904e97c364c05feb6362980b264e Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Mon, 14 Apr 2025 19:53:24 +0200 Subject: [PATCH 06/22] Add mock validations to method params Add spread support to method in mocked class --- src/Mocker/MethodItem.php | 97 ++++++++++++++++++++++++++++++++++++--- src/Mocker/Mocker.php | 9 +++- src/TestCase.php | 4 +- tests/unitary-unitary.php | 19 +++++++- 4 files changed, 116 insertions(+), 13 deletions(-) diff --git a/src/Mocker/MethodItem.php b/src/Mocker/MethodItem.php index d44e5e0..490f05a 100644 --- a/src/Mocker/MethodItem.php +++ b/src/Mocker/MethodItem.php @@ -243,20 +243,103 @@ public function isDestructor(): self } /** - * Not yet working - * Set the parameters of the method. + * Check parameter type for method * - * @param array $parameters - * @return self + * @param int $paramPosition + * @param string $dataType + * @return $this + */ + public function paramType(int $paramPosition, string $dataType): self + { + $inst = $this; + $inst->parameters = [ + "validateInData" => ["{$paramPosition}.type", "equal", [$dataType]], + ]; + return $inst; + } + + /** + * Check parameter default value for method + * + * @param int $paramPosition + * @param string $defaultArgValue + * @return $this + */ + public function paramDefault(int $paramPosition, string $defaultArgValue): self + { + $inst = $this; + $inst->parameters = [ + "validateInData" => ["{$paramPosition}.default", "equal", [$defaultArgValue]], + ]; + return $inst; + } + + /** + * Check parameter type for method + * + * @param int $paramPosition + * @return $this + */ + public function paramHasType(int $paramPosition): self + { + $inst = $this; + $inst->parameters = [ + "validateInData" => ["{$paramPosition}.hasType", "equal", [true]], + ]; + return $inst; + } + + /** + * Check parameter type for method + * + * @param int $paramPosition + * @return $this + */ + public function paramIsOptional(int $paramPosition): self + { + $inst = $this; + $inst->parameters = [ + "validateInData" => ["{$paramPosition}.isOptional", "equal", [true]], + ]; + return $inst; + } + + /** + * Check parameter is Reference for method + * + * @param int $paramPosition + * @return $this */ - public function parameters(array $parameters): self + public function paramIsReference(int $paramPosition): self { - throw new \BadMethodCallException('Method Item::parameters() does not "YET" exist.'); $inst = $this; - $inst->parameters = $parameters; + $inst->parameters = [ + "validateInData" => ["{$paramPosition}.isReference", "equal", [true]], + ]; + return $inst; + } + + /** + * Check parameter is variadic (spread) for method + * + * @param int $paramPosition + * @return $this + */ + public function paramIsVariadic(int $paramPosition): self + { + $inst = $this; + $inst->parameters = [ + "validateInData" => ["{$paramPosition}.isVariadic", "equal", [true]], + ]; return $inst; } + // Symlink to paramIsVariadic + public function paramIsSpread(int $paramPosition): self + { + return $this->paramIsVariadic($paramPosition); + } + /** * Set the doc comment for the method. * diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php index eb31c77..58f6a91 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/Mocker.php @@ -222,6 +222,11 @@ protected function generateMethodSignature(ReflectionMethod $method): string if ($param->isDefaultValueAvailable()) { $paramStr .= ' = ' . var_export($param->getDefaultValue(), true); } + + if ($param->isVariadic()) { + $paramStr = "...{$paramStr}"; + } + $params[] = $paramStr; } return implode(', ', $params); @@ -233,7 +238,7 @@ protected function generateMethodSignature(ReflectionMethod $method): string * @param ReflectionMethod $method The reflection object for the method to inspect. * @return array An array of the expected return types for the given method. */ - protected function getReturnType($method): array + protected function getReturnType(ReflectionMethod $method): array { $types = []; $returnType = $method->getReturnType(); @@ -321,7 +326,7 @@ function getMethodInfoAsArray(ReflectionMethod $refMethod): array 'type' => $param->hasType() ? $param->getType()->__toString() : null, 'isOptional' => $param->isOptional(), 'isVariadic' => $param->isVariadic(), - 'isPassedByReference' => $param->isPassedByReference(), + 'isReference' => $param->isPassedByReference(), 'default' => $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null, ]; } diff --git a/src/TestCase.php b/src/TestCase.php index 1cc11eb..80b7ae1 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -217,8 +217,6 @@ public function mock(string|array $classArg, null|Closure $validate = null): obj if($item) { foreach (get_object_vars($item) as $property => $value) { if(!is_null($value)) { - - $currentValue = $row->{$property}; if(is_array($value)) { $validPool = new ValidatePool($currentValue); @@ -226,6 +224,8 @@ public function mock(string|array $classArg, null|Closure $validate = null): obj $validPool->{$method}(...$args); } $valid = $validPool->isValid(); + $currentValue = $validPool->getValue(); + } else { $valid = Inp::value($currentValue)->equal($value); } diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index fdce99c..b58c197 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -35,16 +35,21 @@ public function getFromEmail(string $email): string * @param string $email * @return void */ - public function addFromEmail(string $email): void + public function addFromEmail($email): void { $this->from = $email; } - public function addBCC(string $email): void + public function addBCC(string $email, &$name = "Daniel"): void { $this->bcc = $email; } + public function test(...$params): void + { + + } + } class UserService { @@ -65,6 +70,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { $unit = new Unit(); $unit->group("Unitary test 2", function (TestCase $inst) { + $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { $pool->method("addFromEmail") ->isPublic() @@ -75,6 +81,15 @@ public function registerUser(string $email, string $name = "Daniel"): void { $pool->method("addBCC") ->isPublic() ->hasDocComment() + ->paramHasType(0) + ->paramType(0, "string") + ->paramDefault(1, "Daniel") + ->paramIsOptional(1) + ->paramIsReference(1) + ->count(0); + + $pool->method("test") + ->paramIsSpread(0) // Same as ->paramIsVariadic() ->count(0); }); $service = new UserService($mock); From 1543977ae12f860daec00613021758fc07007a09 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Mon, 14 Apr 2025 23:30:49 +0200 Subject: [PATCH 07/22] Code quality improvements Add constructor arguments support for wrappers --- src/Mocker/MethodItem.php | 36 ++++++++++++++++++++------ src/Mocker/Mocker.php | 30 +++++++++++++++++++--- src/TestCase.php | 33 ++++++++++++------------ tests/unitary-unitary.php | 53 +++++++++++++++++++++++++-------------- 4 files changed, 104 insertions(+), 48 deletions(-) diff --git a/src/Mocker/MethodItem.php b/src/Mocker/MethodItem.php index 490f05a..8397275 100644 --- a/src/Mocker/MethodItem.php +++ b/src/Mocker/MethodItem.php @@ -7,7 +7,7 @@ class MethodItem { - private ?Mocker $mocker = null; + private ?Mocker $mocker; public mixed $return = null; public ?int $count = null; @@ -37,21 +37,41 @@ public function __construct(?Mocker $mocker = null) $this->mocker = $mocker; } + /** + * Will create a method wrapper making it possible to mock + * + * @param $call + * @return $this + */ public function wrap($call): self { $inst = $this; - $wrap = new class($this->mocker->getClassName()) extends TestWrapper { + $wrap = new class($this->mocker->getClassName(), $this->mocker->getClassArgs()) extends TestWrapper { + function __construct(string $class, array $args = []) + { + parent::__construct($class, $args); + } }; $call->bindTo($this->mocker); $this->wrapper = $wrap->bind($call); return $inst; } + /** + * Get the wrapper if added as Closure else null + * + * @return Closure|null + */ public function getWrap(): ?Closure { return $this->wrapper; } + /** + * Check if a return value has been added + * + * @return bool + */ public function hasReturn(): bool { return $this->hasReturn; @@ -253,7 +273,7 @@ public function paramType(int $paramPosition, string $dataType): self { $inst = $this; $inst->parameters = [ - "validateInData" => ["{$paramPosition}.type", "equal", [$dataType]], + "validateInData" => ["$paramPosition.type", "equal", [$dataType]], ]; return $inst; } @@ -269,7 +289,7 @@ public function paramDefault(int $paramPosition, string $defaultArgValue): self { $inst = $this; $inst->parameters = [ - "validateInData" => ["{$paramPosition}.default", "equal", [$defaultArgValue]], + "validateInData" => ["$paramPosition.default", "equal", [$defaultArgValue]], ]; return $inst; } @@ -284,7 +304,7 @@ public function paramHasType(int $paramPosition): self { $inst = $this; $inst->parameters = [ - "validateInData" => ["{$paramPosition}.hasType", "equal", [true]], + "validateInData" => ["$paramPosition.hasType", "equal", [true]], ]; return $inst; } @@ -299,7 +319,7 @@ public function paramIsOptional(int $paramPosition): self { $inst = $this; $inst->parameters = [ - "validateInData" => ["{$paramPosition}.isOptional", "equal", [true]], + "validateInData" => ["$paramPosition.isOptional", "equal", [true]], ]; return $inst; } @@ -314,7 +334,7 @@ public function paramIsReference(int $paramPosition): self { $inst = $this; $inst->parameters = [ - "validateInData" => ["{$paramPosition}.isReference", "equal", [true]], + "validateInData" => ["$paramPosition.isReference", "equal", [true]], ]; return $inst; } @@ -329,7 +349,7 @@ public function paramIsVariadic(int $paramPosition): self { $inst = $this; $inst->parameters = [ - "validateInData" => ["{$paramPosition}.isVariadic", "equal", [true]], + "validateInData" => ["$paramPosition.isVariadic", "equal", [true]], ]; return $inst; } diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php index 58f6a91..13674a8 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/Mocker.php @@ -43,6 +43,14 @@ public function __construct(string $className, array $args = []) { $this->className = $className; $this->reflection = new ReflectionClass($className); + + /* + // Auto fill the Constructor args! + $test = $this->reflection->getConstructor(); + $test = $this->generateMethodSignature($test); + $param = $test->getParameters(); + */ + $this->methods = $this->reflection->getMethods(); $this->constructorArgs = $args; } @@ -52,6 +60,11 @@ public function getClassName(): string return $this->className; } + public function getClassArgs(): array + { + return $this->constructorArgs; + } + /** * Override the default method overrides with your own mock logic and validation rules * @@ -73,10 +86,10 @@ public function getMockedClassName(): string /** * Executes the creation of a dynamic mock class and returns an instance of the mock. * - * @return object An instance of the dynamically created mock class. + * @return mixed An instance of the dynamically created mock class. * @throws \ReflectionException */ - public function execute(): object + public function execute(?callable $call = null): mixed { $className = $this->reflection->getName(); @@ -173,7 +186,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string $info = json_encode($arr); MockerController::getInstance()->buildMethodData($info); - if($methodItem && !in_array("void", $types)) { + if($methodItem) { $returnValue = $this->generateWrapperReturn($methodItem->getWrap(), $methodName, $returnValue); } @@ -191,11 +204,20 @@ protected function generateMockMethodOverrides(string $mockClassName): string } + /** + * Will build the wrapper return + * + * @param \Closure|null $wrapper + * @param string $methodName + * @param string $returnValue + * @return string + */ protected function generateWrapperReturn(?\Closure $wrapper, string $methodName, string $returnValue) { MockerController::addData($this->mockClassName, $methodName, 'wrapper', $wrapper); + $return = ($returnValue) ? "return " : ""; return " if (isset(\$data->wrapper) && \$data->wrapper instanceof \\Closure) { - return call_user_func_array(\$data->wrapper, func_get_args()); + {$return}call_user_func_array(\$data->wrapper, func_get_args()); } {$returnValue} "; diff --git a/src/TestCase.php b/src/TestCase.php index 80b7ae1..658ae26 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -171,12 +171,17 @@ public function add(mixed $expect, array|Closure $validation, ?string $message = /** * Init a test wrapper * - * @param string $className + * @param string $class + * @param array $args * @return TestWrapper */ - public function wrap(string $className): TestWrapper + public function wrap(string $class, array $args = []): TestWrapper { - return new class($className) extends TestWrapper { + return new class($class, $args) extends TestWrapper { + function __construct(string $class, array $args = []) + { + parent::__construct($class, $args); + } }; } @@ -187,22 +192,16 @@ public function wrap(string $className): TestWrapper * A validation closure can also be provided to define mock expectations. These * validations are deferred and will be executed later via runDeferredValidations(). * - * @param string|array $classArg Either the class name as a string, - * or an array with [className, constructorArgs]. - * @param Closure|null $validate Optional closure to define expectations on the mock. - * @return object An instance of the dynamically created mock class. - * @throws \ReflectionException If the class or constructor cannot be reflected. + * @template T of object + * @param class-string $class + * @param Closure|null $validate + * @param array $args + * @return T + * @throws \ReflectionException */ - public function mock(string|array $classArg, null|Closure $validate = null): object + public function mock(string $class, ?Closure $validate = null, array $args = []): mixed { - $args = []; - $className = $classArg; - if(is_array($classArg)) { - $className = $classArg[0]; - $args = ($classArg[1] ?? []); - } - - $mocker = new Mocker($className, $args); + $mocker = new Mocker($class, $args); if(is_callable($validate)) { $pool = $mocker->getMethodPool(); $fn = $validate->bindTo($pool); diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index b58c197..723128f 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -11,6 +11,13 @@ class Mailer { public $from = ""; public $bcc = ""; + + + public function __construct(string $arg1) + { + + } + public function sendEmail(string $email, string $name = "daniel"): string { if(!$this->isValidEmail($email)) { @@ -47,7 +54,6 @@ public function addBCC(string $email, &$name = "Daniel"): void public function test(...$params): void { - } } @@ -70,7 +76,6 @@ public function registerUser(string $email, string $name = "Daniel"): void { $unit = new Unit(); $unit->group("Unitary test 2", function (TestCase $inst) { - $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { $pool->method("addFromEmail") ->isPublic() @@ -90,11 +95,24 @@ public function registerUser(string $email, string $name = "Daniel"): void { $pool->method("test") ->paramIsSpread(0) // Same as ->paramIsVariadic() - ->count(0); - }); + ->wrap(function($args) use($inst) { + echo "World -> $args\n"; + }) + ->count(1); + + }, ["Arg 1"]); + + $mock->test("Hello"); $service = new UserService($mock); + + + // Example 1 + /* + + + $inst->validate("yourTestValue", function(ValidatePool $inst) { $inst->isBool(); $inst->isInt(); @@ -103,8 +121,18 @@ public function registerUser(string $email, string $name = "Daniel"): void { $inst->isResource(); }); - // Example 1 - /* + $arr = [ + "user" => [ + "name" => "John Doe", + "email" => "john.doe@gmail.com", + ] + ]; + + $inst->validate($arr, function(ValidatePool $inst) { + $inst->validateInData("user.name", "email"); + $inst->validateInData("user.email", "length", [1, 200]); + }); + $service = new UserService($mock); @@ -152,19 +180,6 @@ public function registerUser(string $email, string $name = "Daniel"): void { $service->registerUser('user@example.com'); */ - $this->add("Lorem ipsum dolor", [ - "isString" => [], - "length" => [1,300] - - ])->add(92928, [ - "isInt" => [] - - ])->add("Lorem", [ - "isString" => [], - "length" => function () { - return $this->length(1, 50); - } - ], "The length is not correct!"); }); From 7ab63d623de58420a8d47752327fdf3b18c7233d Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Tue, 15 Apr 2025 22:54:31 +0200 Subject: [PATCH 08/22] Add more validations --- src/Mocker/MethodItem.php | 61 +++++++++++++++++++++++++++++++++++++-- tests/unitary-unitary.php | 17 +++++++++-- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/Mocker/MethodItem.php b/src/Mocker/MethodItem.php index 8397275..812180f 100644 --- a/src/Mocker/MethodItem.php +++ b/src/Mocker/MethodItem.php @@ -262,6 +262,63 @@ public function isDestructor(): self return $inst; } + /** + * Check if parameter exists + * + * @return $this + */ + public function hasParams(): self + { + $inst = $this; + $inst->parameters = [ + "isCountMoreThan" => [0], + ]; + return $inst; + } + + /** + * Check if all parameters has a data type + * + * @return $this + */ + public function hasParamsTypes(): self + { + $inst = $this; + $inst->parameters = [ + "itemsAreTruthy" => ['hasType', true], + ]; + return $inst; + } + + /** + * Check if parameter do not exist + * + * @return $this + */ + public function hasNotParams(): self + { + $inst = $this; + $inst->parameters = [ + "isArrayEmpty" => [], + ]; + return $inst; + } + + /** + * Check parameter type for method + * + * @param int $length + * @return $this + */ + public function hasParamsCount(int $length): self + { + $inst = $this; + $inst->parameters = [ + "isCountEqualTo" => [$length], + ]; + return $inst; + } + /** * Check parameter type for method * @@ -269,7 +326,7 @@ public function isDestructor(): self * @param string $dataType * @return $this */ - public function paramType(int $paramPosition, string $dataType): self + public function paramIsType(int $paramPosition, string $dataType): self { $inst = $this; $inst->parameters = [ @@ -285,7 +342,7 @@ public function paramType(int $paramPosition, string $dataType): self * @param string $defaultArgValue * @return $this */ - public function paramDefault(int $paramPosition, string $defaultArgValue): self + public function paramHasDefault(int $paramPosition, string $defaultArgValue): self { $inst = $this; $inst->parameters = [ diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 723128f..dbc032d 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -42,7 +42,7 @@ public function getFromEmail(string $email): string * @param string $email * @return void */ - public function addFromEmail($email): void + public function addFromEmail(string $email, string $name = ""): void { $this->from = $email; } @@ -56,6 +56,10 @@ public function test(...$params): void { } + public function test2(): void + { + } + } class UserService { @@ -78,6 +82,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { $pool->method("addFromEmail") + ->hasParamsTypes() ->isPublic() ->hasDocComment() ->hasReturnType() @@ -86,20 +91,26 @@ public function registerUser(string $email, string $name = "Daniel"): void { $pool->method("addBCC") ->isPublic() ->hasDocComment() + ->hasParams() ->paramHasType(0) - ->paramType(0, "string") - ->paramDefault(1, "Daniel") + ->paramIsType(0, "string") + ->paramHasDefault(1, "Daniel") ->paramIsOptional(1) ->paramIsReference(1) ->count(0); $pool->method("test") + ->hasParams() ->paramIsSpread(0) // Same as ->paramIsVariadic() ->wrap(function($args) use($inst) { echo "World -> $args\n"; }) ->count(1); + $pool->method("test2") + ->hasNotParams() + ->count(0); + }, ["Arg 1"]); $mock->test("Hello"); From fed292004fd4db9048fbd58e9b81f80dd884df3b Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Thu, 17 Apr 2025 23:06:21 +0200 Subject: [PATCH 09/22] Add method parameter validation --- src/Mocker/MethodItem.php | 20 ++++----- src/TestCase.php | 85 +++++++++++++++++++++++++++++++------ tests/unitary-unitary.php | 89 ++++++++++----------------------------- 3 files changed, 103 insertions(+), 91 deletions(-) diff --git a/src/Mocker/MethodItem.php b/src/Mocker/MethodItem.php index 812180f..cae940f 100644 --- a/src/Mocker/MethodItem.php +++ b/src/Mocker/MethodItem.php @@ -270,7 +270,7 @@ public function isDestructor(): self public function hasParams(): self { $inst = $this; - $inst->parameters = [ + $inst->parameters[] = [ "isCountMoreThan" => [0], ]; return $inst; @@ -284,7 +284,7 @@ public function hasParams(): self public function hasParamsTypes(): self { $inst = $this; - $inst->parameters = [ + $inst->parameters[] = [ "itemsAreTruthy" => ['hasType', true], ]; return $inst; @@ -298,7 +298,7 @@ public function hasParamsTypes(): self public function hasNotParams(): self { $inst = $this; - $inst->parameters = [ + $inst->parameters[] = [ "isArrayEmpty" => [], ]; return $inst; @@ -313,7 +313,7 @@ public function hasNotParams(): self public function hasParamsCount(int $length): self { $inst = $this; - $inst->parameters = [ + $inst->parameters[] = [ "isCountEqualTo" => [$length], ]; return $inst; @@ -329,7 +329,7 @@ public function hasParamsCount(int $length): self public function paramIsType(int $paramPosition, string $dataType): self { $inst = $this; - $inst->parameters = [ + $inst->parameters[] = [ "validateInData" => ["$paramPosition.type", "equal", [$dataType]], ]; return $inst; @@ -345,7 +345,7 @@ public function paramIsType(int $paramPosition, string $dataType): self public function paramHasDefault(int $paramPosition, string $defaultArgValue): self { $inst = $this; - $inst->parameters = [ + $inst->parameters[] = [ "validateInData" => ["$paramPosition.default", "equal", [$defaultArgValue]], ]; return $inst; @@ -360,7 +360,7 @@ public function paramHasDefault(int $paramPosition, string $defaultArgValue): se public function paramHasType(int $paramPosition): self { $inst = $this; - $inst->parameters = [ + $inst->parameters[] = [ "validateInData" => ["$paramPosition.hasType", "equal", [true]], ]; return $inst; @@ -375,7 +375,7 @@ public function paramHasType(int $paramPosition): self public function paramIsOptional(int $paramPosition): self { $inst = $this; - $inst->parameters = [ + $inst->parameters[] = [ "validateInData" => ["$paramPosition.isOptional", "equal", [true]], ]; return $inst; @@ -390,7 +390,7 @@ public function paramIsOptional(int $paramPosition): self public function paramIsReference(int $paramPosition): self { $inst = $this; - $inst->parameters = [ + $inst->parameters[] = [ "validateInData" => ["$paramPosition.isReference", "equal", [true]], ]; return $inst; @@ -405,7 +405,7 @@ public function paramIsReference(int $paramPosition): self public function paramIsVariadic(int $paramPosition): self { $inst = $this; - $inst->parameters = [ + $inst->parameters[] = [ "validateInData" => ["$paramPosition.isVariadic", "equal", [true]], ]; return $inst; diff --git a/src/TestCase.php b/src/TestCase.php index 658ae26..89b29ee 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -7,10 +7,14 @@ use BadMethodCallException; use Closure; use ErrorException; +use MaplePHP\DTO\Traverse; use MaplePHP\Unitary\Mocker\Mocker; use MaplePHP\Unitary\Mocker\MockerController; use MaplePHP\Validate\Inp; use MaplePHP\Validate\ValidatePool; +use ReflectionClass; +use ReflectionException; +use ReflectionMethod; use RuntimeException; use Throwable; @@ -115,7 +119,7 @@ protected function expectAndValidate( if($validation instanceof Closure) { $list = $this->buildClosureTest($validation); foreach($list as $method => $valid) { - $test->setUnit(!$list, $method, []); + $test->setUnit(!$list, $method); } } else { foreach($validation as $method => $args) { @@ -144,7 +148,7 @@ protected function expectAndValidate( * @param Closure $validation A closure containing the deferred test logic. * @return void */ - public function deferValidation(Closure $validation) + public function deferValidation(Closure $validation): void { // This will add a cursor to the possible line and file where error occurred $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; @@ -164,7 +168,8 @@ public function deferValidation(Closure $validation) * @return $this * @throws ErrorException */ - public function add(mixed $expect, array|Closure $validation, ?string $message = null) { + public function add(mixed $expect, array|Closure $validation, ?string $message = null): static + { return $this->expectAndValidate($expect, $validation, $message); } @@ -197,7 +202,7 @@ function __construct(string $class, array $args = []) * @param Closure|null $validate * @param array $args * @return T - * @throws \ReflectionException + * @throws ReflectionException */ public function mock(string $class, ?Closure $validate = null, array $args = []): mixed { @@ -206,11 +211,9 @@ public function mock(string $class, ?Closure $validate = null, array $args = []) $pool = $mocker->getMethodPool(); $fn = $validate->bindTo($pool); $fn($pool); - $this->deferValidation(function() use($mocker, $pool) { $error = []; $data = MockerController::getData($mocker->getMockedClassName()); - foreach($data as $row) { $item = $pool->get($row->name); if($item) { @@ -220,15 +223,31 @@ public function mock(string $class, ?Closure $validate = null, array $args = []) if(is_array($value)) { $validPool = new ValidatePool($currentValue); foreach($value as $method => $args) { - $validPool->{$method}(...$args); + if(is_int($method)) { + foreach($args as $methodB => $argsB) { + $validPool + ->mapErrorToKey($argsB[0]) + ->mapErrorValidationName($argsB[1]) + ->{$methodB}(...$argsB); + } + } else { + $validPool->{$method}(...$args); + } } $valid = $validPool->isValid(); - $currentValue = $validPool->getValue(); } else { $valid = Inp::value($currentValue)->equal($value); } + if(is_array($value)) { + $this->compareFromValidCollection( + $validPool, + $value, + $currentValue + ); + } + $error[$row->name][] = [ "property" => $property, "currentValue" => $currentValue, @@ -245,6 +264,44 @@ public function mock(string $class, ?Closure $validate = null, array $args = []) return $mocker->execute(); } + /** + * Create a comparison from a validation collection + * + * @param ValidatePool $validPool + * @param array $value + * @param array $currentValue + * @return void + */ + protected function compareFromValidCollection(ValidatePool $validPool, array &$value, array &$currentValue): void + { + $new = []; + $error = $validPool->getError(); + $value = $this->mapValueToCollectionError($error, $value); + foreach($value as $eqIndex => $validator) { + $new[] = Traverse::value($currentValue)->eq($eqIndex)->get(); + } + $currentValue = $new; + } + + /** + * Will map collection value to error + * + * @param array $error + * @param array $value + * @return array + */ + protected function mapValueToCollectionError(array $error, array $value): array + { + foreach($value as $item) { + foreach($item as $value) { + if(isset($error[$value[0]])) { + $error[$value[0]] = $value[2]; + } + } + } + return $error; + } + /** * Executes all deferred validations that were registered earlier using deferValidation(). * @@ -255,12 +312,12 @@ public function mock(string $class, ?Closure $validate = null, array $args = []) * @return TestUnit[] A list of TestUnit results from the deferred validations. * @throws ErrorException If any validation logic throws an error during execution. */ - public function runDeferredValidations() + public function runDeferredValidations(): array { foreach($this->deferredValidation as $row) { $error = $row['call'](); foreach($error as $method => $arr) { - $test = new TestUnit("Mock method \"{$method}\" failed"); + $test = new TestUnit("Mock method \"$method\" failed"); if(is_array($row['trace'] ?? "")) { $test->setCodeLine($row['trace']); } @@ -367,7 +424,7 @@ protected function buildClosureTest(Closure $validation): array $bool = $validation($this->value, $validPool); $error = $validPool->getError(); if(is_bool($bool) && !$bool) { - $error['customError'] = $bool; + $error['customError'] = false; } } @@ -430,12 +487,12 @@ protected function valid(mixed $value): Inp * @param string $class * @param string|null $prefixMethods * @return void - * @throws \ReflectionException + * @throws ReflectionException */ public function listAllProxyMethods(string $class, ?string $prefixMethods = null): void { - $reflection = new \ReflectionClass($class); - foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + $reflection = new ReflectionClass($class); + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { if ($method->isConstructor()) continue; $params = array_map(function($param) { $type = $param->hasType() ? $param->getType() . ' ' : ''; diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index dbc032d..aa984de 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -76,6 +76,20 @@ public function registerUser(string $email, string $name = "Daniel"): void { } } +$unit = new Unit(); +$unit->group("Unitary test 2", function (TestCase $inst) { + + $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { + $pool->method("addBCC") + ->paramIsType(0, "striwng") + ->paramHasDefault(1, "Daniwel") + ->paramIsReference(1) + ->count(1); + }, ["Arg 1"]); + $mock->addBCC("World"); +}); + +/* $unit = new Unit(); $unit->group("Unitary test 2", function (TestCase $inst) { @@ -116,13 +130,12 @@ public function registerUser(string $email, string $name = "Daniel"): void { $mock->test("Hello"); $service = new UserService($mock); - - - - // Example 1 - /* - - + $validPool = new ValidatePool("dwqdqw"); + $validPool + ->isEmail() + ->length(1, 200) + ->endsWith(".com"); + $isValid = $validPool->isValid(); $inst->validate("yourTestValue", function(ValidatePool $inst) { $inst->isBool(); @@ -132,65 +145,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { $inst->isResource(); }); - $arr = [ - "user" => [ - "name" => "John Doe", - "email" => "john.doe@gmail.com", - ] - ]; - - $inst->validate($arr, function(ValidatePool $inst) { - $inst->validateInData("user.name", "email"); - $inst->validateInData("user.email", "length", [1, 200]); - }); - - - $service = new UserService($mock); - - // Example 2 - $mock = $this->mock(Mailer::class, [ - "testMethod1" => [ - "count" => 1, - "validate" => [ - "equal" => "lorem1", - "contains" => "lorem", - "length" => [1,6] - ] - ] - ]); - $service = new UserService($mock); - $service->registerUser('user@example.com'); - */ - - /* - $inst->validate("yourTestValue", function(ValidatePool $inst, mixed $value) { - $inst->isBool(); - $inst->isInt(); - $inst->isJson(); - $inst->isString(); - $inst->isResource(); - return ($value === "yourTestValue1"); - }); - - $inst->validate("yourTestValue", fn(ValidatePool $inst) => $inst->isfloat()); - */ - - //$inst->listAllProxyMethods(Inp::class); - //->error("Failed to validate yourTestValue (optional error message)") - - - - /* - * $mock = $this->mock(Mailer::class); -echo "ww"; - - $service = new UserService($test); - $service->registerUser('user@example.com'); - var_dump($mock instanceof Mailer); - $service = new UserService($mock); - $service->registerUser('user@example.com'); - */ - - }); +*/ + From 06b5c4862bead4ab4575cd8c1e1c115b778bc8d1 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 27 Apr 2025 21:14:36 +0200 Subject: [PATCH 10/22] Add default variable values to method generator --- src/TestCase.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/TestCase.php b/src/TestCase.php index 89b29ee..26159c7 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -7,6 +7,7 @@ use BadMethodCallException; use Closure; use ErrorException; +use MaplePHP\DTO\Format\Str; use MaplePHP\DTO\Traverse; use MaplePHP\Unitary\Mocker\Mocker; use MaplePHP\Unitary\Mocker\MockerController; @@ -494,13 +495,14 @@ public function listAllProxyMethods(string $class, ?string $prefixMethods = null $reflection = new ReflectionClass($class); foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { if ($method->isConstructor()) continue; + $params = array_map(function($param) { $type = $param->hasType() ? $param->getType() . ' ' : ''; - return $type . '$' . $param->getName(); + $value = $param->isDefaultValueAvailable() ? ' = ' . Str::value($param->getDefaultValue())->exportReadableValue()->get() : null; + return $type . '$' . $param->getName() . $value; }, $method->getParameters()); $name = $method->getName(); - if(!$method->isStatic() && !str_starts_with($name, '__')) { if(!is_null($prefixMethods)) { $name = $prefixMethods . ucfirst($name); From 23ed4ba98386ae21d4fe219af3aa3a9668a505c3 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Mon, 28 Apr 2025 22:40:35 +0200 Subject: [PATCH 11/22] Change validation class name --- README.md | 6 ++-- src/TestCase.php | 58 +++++++++++++++++++++++++++------------ tests/unitary-unitary.php | 6 ++-- 3 files changed, 47 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 48f7504..c0811f5 100644 --- a/README.md +++ b/README.md @@ -145,10 +145,10 @@ _Why? Sometimes you just want to quick mock so that a Mailer library will not se ### Custom mocking As I said Unitary mocker will try to automatically mock every method but might not successes in some user-cases -then you can just tell Unitary how those failed methods should load. +then you can just tell Unitary how those failed methods should load. ```php -use MaplePHP\Validate\ValidatePool; +use MaplePHP\Validate\ValidationChain; use \MaplePHP\Unitary\Mocker\MethodPool; $unit->group("Testing user service", function (TestCase $inst) { @@ -159,7 +159,7 @@ $unit->group("Testing user service", function (TestCase $inst) { // Or we can acctually pass a callable to it and tell it what it should return // But we can also validate the argumnets! $pool->method("addFromEmail")->wrap(function($email) use($inst) { - $inst->validate($email, function(ValidatePool $valid) { + $inst->validate($email, function(ValidationChain $valid) { $valid->email(); $valid->isString(); }); diff --git a/src/TestCase.php b/src/TestCase.php index 26159c7..690b3c7 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -11,8 +11,8 @@ use MaplePHP\DTO\Traverse; use MaplePHP\Unitary\Mocker\Mocker; use MaplePHP\Unitary\Mocker\MockerController; -use MaplePHP\Validate\Inp; -use MaplePHP\Validate\ValidatePool; +use MaplePHP\Validate\Validator; +use MaplePHP\Validate\ValidationChain; use ReflectionClass; use ReflectionException; use ReflectionMethod; @@ -82,13 +82,13 @@ public function error(string $message): self * Add a test unit validation using the provided expectation and validation logic * * @param mixed $expect The expected value - * @param Closure(ValidatePool, mixed): bool $validation The validation logic + * @param Closure(ValidationChain, mixed): bool $validation The validation logic * @return $this * @throws ErrorException */ public function validate(mixed $expect, Closure $validation): self { - $this->expectAndValidate($expect, function(mixed $value, ValidatePool $inst) use($validation) { + $this->expectAndValidate($expect, function(mixed $value, ValidationChain $inst) use($validation) { return $validation($inst, $value); }, $this->errorMessage); @@ -222,7 +222,7 @@ public function mock(string $class, ?Closure $validate = null, array $args = []) if(!is_null($value)) { $currentValue = $row->{$property}; if(is_array($value)) { - $validPool = new ValidatePool($currentValue); + $validPool = new ValidationChain($currentValue); foreach($value as $method => $args) { if(is_int($method)) { foreach($args as $methodB => $argsB) { @@ -238,7 +238,7 @@ public function mock(string $class, ?Closure $validate = null, array $args = []) $valid = $validPool->isValid(); } else { - $valid = Inp::value($currentValue)->equal($value); + $valid = Validator::value($currentValue)->equal($value); } if(is_array($value)) { @@ -268,12 +268,12 @@ public function mock(string $class, ?Closure $validate = null, array $args = []) /** * Create a comparison from a validation collection * - * @param ValidatePool $validPool + * @param ValidationChain $validPool * @param array $value * @param array $currentValue * @return void */ - protected function compareFromValidCollection(ValidatePool $validPool, array &$value, array &$currentValue): void + protected function compareFromValidCollection(ValidationChain $validPool, array &$value, array &$currentValue): void { $new = []; $error = $validPool->getError(); @@ -417,7 +417,7 @@ public function getTest(): array protected function buildClosureTest(Closure $validation): array { //$bool = false; - $validPool = new ValidatePool($this->value); + $validPool = new ValidationChain($this->value); $validation = $validation->bindTo($validPool); $error = []; @@ -456,7 +456,7 @@ protected function buildArrayTest(string $method, array|Closure $args): bool throw new RuntimeException("A callable validation must return a boolean!"); } } else { - if(!method_exists(Inp::class, $method)) { + if(!method_exists(Validator::class, $method)) { throw new BadMethodCallException("The validation $method does not exist!"); } @@ -474,12 +474,12 @@ protected function buildArrayTest(string $method, array|Closure $args): bool * Init MaplePHP validation * * @param mixed $value - * @return Inp + * @return Validator * @throws ErrorException */ - protected function valid(mixed $value): Inp + protected function valid(mixed $value): Validator { - return new Inp($value); + return new Validator($value); } /** @@ -490,11 +490,23 @@ protected function valid(mixed $value): Inp * @return void * @throws ReflectionException */ - public function listAllProxyMethods(string $class, ?string $prefixMethods = null): void + public function listAllProxyMethods(string $class, ?string $prefixMethods = null, bool $isolateClass = false): void { $reflection = new ReflectionClass($class); + $traitMethods = $isolateClass ? $this->getAllTraitMethods($reflection) : []; + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { - if ($method->isConstructor()) continue; + if ($method->isConstructor()) { + continue; + } + + if (in_array($method->getName(), $traitMethods, true)) { + continue; + } + + if ($isolateClass && $method->getDeclaringClass()->getName() !== $class) { + continue; + } $params = array_map(function($param) { $type = $param->hasType() ? $param->getType() . ' ' : ''; @@ -503,12 +515,24 @@ public function listAllProxyMethods(string $class, ?string $prefixMethods = null }, $method->getParameters()); $name = $method->getName(); - if(!$method->isStatic() && !str_starts_with($name, '__')) { - if(!is_null($prefixMethods)) { + if (!$method->isStatic() && !str_starts_with($name, '__')) { + if (!is_null($prefixMethods)) { $name = $prefixMethods . ucfirst($name); } echo "@method self $name(" . implode(', ', $params) . ")\n"; } } } + + public function getAllTraitMethods(ReflectionClass $reflection): array + { + $traitMethods = []; + foreach ($reflection->getTraits() as $trait) { + foreach ($trait->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + $traitMethods[] = $method->getName(); + } + } + return $traitMethods; + } + } diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index aa984de..b42e0fe 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -3,7 +3,7 @@ use MaplePHP\Unitary\TestCase; use MaplePHP\Unitary\Unit; -use MaplePHP\Validate\ValidatePool; +use MaplePHP\Validate\ValidationChain; use MaplePHP\Unitary\Mocker\MethodPool; @@ -130,14 +130,14 @@ public function registerUser(string $email, string $name = "Daniel"): void { $mock->test("Hello"); $service = new UserService($mock); - $validPool = new ValidatePool("dwqdqw"); + $validPool = new ValidationChain("dwqdqw"); $validPool ->isEmail() ->length(1, 200) ->endsWith(".com"); $isValid = $validPool->isValid(); - $inst->validate("yourTestValue", function(ValidatePool $inst) { + $inst->validate("yourTestValue", function(ValidationChain $inst) { $inst->isBool(); $inst->isInt(); $inst->isJson(); From 9f6cadea2510b8a001f777934f5eaada635b8600 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Wed, 30 Apr 2025 23:37:22 +0200 Subject: [PATCH 12/22] List validation error names collected from closure --- src/TestCase.php | 8 +++++--- src/Unit.php | 17 +++++++++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/TestCase.php b/src/TestCase.php index 690b3c7..44c1f5a 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -118,9 +118,11 @@ protected function expectAndValidate( $test = new TestUnit($message); $test->setTestValue($this->value); if($validation instanceof Closure) { - $list = $this->buildClosureTest($validation); - foreach($list as $method => $valid) { - $test->setUnit(!$list, $method); + $listArr = $this->buildClosureTest($validation); + foreach($listArr as $list) { + foreach($list as $method => $valid) { + $test->setUnit(!$list, $method); + } } } else { foreach($validation as $method => $args) { diff --git a/src/Unit.php b/src/Unit.php index 826bb14..d5245f7 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -186,7 +186,7 @@ public function performance(Closure $func, ?string $title = null): void */ public function execute(): bool { - if($this->executed || !$this->validate()) { + if($this->executed || !$this->createValidate()) { return false; } @@ -315,12 +315,25 @@ public function resetExecute(): bool return false; } + + /** + * Validate method that must be called within a group method + * + * @return self + * @throws RuntimeException When called outside a group method + */ + public function validate(): self + { + throw new RuntimeException("The validate() method must be called inside a group() method! " . + "Move this validate() call inside your group() callback function."); + } + /** * Validate before execute test * * @return bool */ - private function validate(): bool + private function createValidate(): bool { $args = (array)(self::$headers['args'] ?? []); $manual = isset($args['show']) ? (string)$args['show'] : ""; From 725ea9b1fe6941af2f0c3ae3295ada840ddbafe2 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Thu, 1 May 2025 21:41:04 +0200 Subject: [PATCH 13/22] Code quality improvements --- src/FileIterator.php | 13 +-- src/Mocker/Mocker.php | 49 +++++------ src/TestCase.php | 185 +++++++++++++++++++++++++++++------------- src/TestWrapper.php | 2 +- src/Unit.php | 9 ++ 5 files changed, 170 insertions(+), 88 deletions(-) diff --git a/src/FileIterator.php b/src/FileIterator.php index 48e4af4..68d2318 100755 --- a/src/FileIterator.php +++ b/src/FileIterator.php @@ -5,6 +5,7 @@ namespace MaplePHP\Unitary; use Closure; +use Exception; use RuntimeException; use MaplePHP\Blunder\Handlers\CliHandler; use MaplePHP\Blunder\Run; @@ -33,7 +34,7 @@ public function executeAll(string $directory): void { $files = $this->findFiles($directory); if (empty($files)) { - throw new RuntimeException("No files found matching the pattern \"" . (string)(static::PATTERN ?? "") . "\" in directory \"$directory\" "); + throw new RuntimeException("No files found matching the pattern \"" . (static::PATTERN ?? "") . "\" in directory \"$directory\" "); } else { foreach ($files as $file) { extract($this->args, EXTR_PREFIX_SAME, "wddx"); @@ -107,7 +108,7 @@ public function exclude(): array } /** - * Validate a exclude path + * Validate an exclude path * @param array $exclArr * @param string $relativeDir * @param string $file @@ -117,7 +118,7 @@ public function findExcluded(array $exclArr, string $relativeDir, string $file): { $file = $this->getNaturalPath($file); foreach ($exclArr as $excl) { - $relativeExclPath = $this->getNaturalPath($relativeDir . DIRECTORY_SEPARATOR . (string)$excl); + $relativeExclPath = $this->getNaturalPath($relativeDir . DIRECTORY_SEPARATOR . $excl); if(fnmatch($relativeExclPath, $file)) { return true; } @@ -126,7 +127,7 @@ public function findExcluded(array $exclArr, string $relativeDir, string $file): } /** - * Get path as natural path + * Get a path as a natural path * @param string $path * @return string */ @@ -136,7 +137,7 @@ public function getNaturalPath(string $path): string } /** - * Require file without inheriting any class information + * Require a file without inheriting any class information * @param string $file * @return Closure|null */ @@ -173,7 +174,7 @@ private function requireUnitFile(string $file): ?Closure /** * @return Unit - * @throws RuntimeException|\Exception + * @throws RuntimeException|Exception */ protected function getUnit(): Unit { diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php index 13674a8..b9787e8 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/Mocker.php @@ -8,8 +8,10 @@ namespace MaplePHP\Unitary\Mocker; +use Closure; use Reflection; use ReflectionClass; +use ReflectionException; use ReflectionIntersectionType; use ReflectionMethod; use ReflectionNamedType; @@ -19,8 +21,6 @@ class Mocker { protected object $instance; - static private mixed $return; - protected ReflectionClass $reflection; protected string $className; @@ -37,7 +37,7 @@ class Mocker /** * @param string $className * @param array $args - * @throws \ReflectionException + * @throws ReflectionException */ public function __construct(string $className, array $args = []) { @@ -87,9 +87,9 @@ public function getMockedClassName(): string * Executes the creation of a dynamic mock class and returns an instance of the mock. * * @return mixed An instance of the dynamically created mock class. - * @throws \ReflectionException + * @throws ReflectionException */ - public function execute(?callable $call = null): mixed + public function execute(): mixed { $className = $this->reflection->getName(); @@ -100,7 +100,7 @@ public function execute(?callable $call = null): mixed $overrides = $this->generateMockMethodOverrides($this->mockClassName); $unknownMethod = $this->errorHandleUnknownMethod($className); $code = " - class {$this->mockClassName} extends {$className} { + class $this->mockClassName extends $className { {$overrides} {$unknownMethod} } @@ -126,7 +126,7 @@ public function __call(string \$name, array \$arguments) { if (method_exists(get_parent_class(\$this), '__call')) { return parent::__call(\$name, \$arguments); } - throw new \\BadMethodCallException(\"Method '\$name' does not exist in class '{$className}'.\"); + throw new \\BadMethodCallException(\"Method '\$name' does not exist in class '$className'.\"); } "; } @@ -156,7 +156,7 @@ protected function getReturnValue(array $types, mixed $method, ?MethodItem $meth * Each overridden method returns a predefined mock value or delegates to the original logic. * * @return string PHP code defining the overridden methods. - * @throws \ReflectionException + * @throws ReflectionException */ protected function generateMockMethodOverrides(string $mockClassName): string { @@ -191,9 +191,9 @@ protected function generateMockMethodOverrides(string $mockClassName): string } $overrides .= " - {$modifiers} function {$methodName}({$paramList}){$returnType} + $modifiers function $methodName($paramList){$returnType} { - \$obj = \\MaplePHP\\Unitary\\Mocker\\MockerController::getInstance()->buildMethodData('{$info}'); + \$obj = \\MaplePHP\\Unitary\\Mocker\\MockerController::getInstance()->buildMethodData('$info'); \$data = \\MaplePHP\\Unitary\\Mocker\\MockerController::getDataItem(\$obj->mocker, \$obj->name); {$returnValue} } @@ -207,12 +207,13 @@ protected function generateMockMethodOverrides(string $mockClassName): string /** * Will build the wrapper return * - * @param \Closure|null $wrapper + * @param Closure|null $wrapper * @param string $methodName * @param string $returnValue * @return string */ - protected function generateWrapperReturn(?\Closure $wrapper, string $methodName, string $returnValue) { + protected function generateWrapperReturn(?Closure $wrapper, string $methodName, string $returnValue): string + { MockerController::addData($this->mockClassName, $methodName, 'wrapper', $wrapper); $return = ($returnValue) ? "return " : ""; return " @@ -246,7 +247,7 @@ protected function generateMethodSignature(ReflectionMethod $method): string } if ($param->isVariadic()) { - $paramStr = "...{$paramStr}"; + $paramStr = "...$paramStr"; } $params[] = $paramStr; @@ -272,7 +273,10 @@ protected function getReturnType(ReflectionMethod $method): array } } elseif ($returnType instanceof ReflectionIntersectionType) { - $intersect = array_map(fn($type) => $type->getName(), $returnType->getTypes()); + $intersect = array_map( + fn($type) => method_exists($type, "getName") ? $type->getName() : null, + $returnType->getTypes() + ); $types[] = $intersect; } @@ -287,9 +291,9 @@ protected function getReturnType(ReflectionMethod $method): array * * @param string $typeName The name of the type for which to generate the mock value. * @param bool $nullable Indicates if the returned value can be nullable. - * @return mixed Returns a mock value corresponding to the given type, or null if nullable and conditions allow. + * @return string|null Returns a mock value corresponding to the given type, or null if nullable and conditions allow. */ - protected function getMockValueForType(string $typeName, mixed $method, mixed $value = null, bool $nullable = false): mixed + protected function getMockValueForType(string $typeName, mixed $method, mixed $value = null, bool $nullable = false): ?string { $typeName = strtolower($typeName); if(!is_null($value)) { @@ -297,13 +301,10 @@ protected function getMockValueForType(string $typeName, mixed $method, mixed $v } $mock = match ($typeName) { - 'int' => "return 123456;", - 'integer' => "return 123456;", - 'float' => "return 3.14;", - 'double' => "return 3.14;", + 'int', 'integer' => "return 123456;", + 'float', 'double' => "return 3.14;", 'string' => "return 'mockString';", - 'bool' => "return true;", - 'boolean' => "return true;", + 'bool', 'boolean' => "return true;", 'array' => "return ['item'];", 'object' => "return (object)['item'];", 'resource' => "return fopen('php://memory', 'r+');", @@ -312,7 +313,7 @@ protected function getMockValueForType(string $typeName, mixed $method, mixed $v 'null' => "return null;", 'void' => "", 'self' => ($method->isStatic()) ? 'return new self();' : 'return $this;', - default => (is_string($typeName) && class_exists($typeName)) + default => (class_exists($typeName)) ? "return new class() extends " . $typeName . " {};" : "return null;", @@ -332,7 +333,7 @@ protected function handleResourceContent($resourceValue): ?string } /** - * Build a method information array form ReflectionMethod instance + * Build a method information array from a ReflectionMethod instance * * @param ReflectionMethod $refMethod * @return array diff --git a/src/TestCase.php b/src/TestCase.php index 44c1f5a..21cb019 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -9,6 +9,7 @@ use ErrorException; use MaplePHP\DTO\Format\Str; use MaplePHP\DTO\Traverse; +use MaplePHP\Unitary\Mocker\MethodPool; use MaplePHP\Unitary\Mocker\Mocker; use MaplePHP\Unitary\Mocker\MockerController; use MaplePHP\Validate\Validator; @@ -121,7 +122,7 @@ protected function expectAndValidate( $listArr = $this->buildClosureTest($validation); foreach($listArr as $list) { foreach($list as $method => $valid) { - $test->setUnit(!$list, $method); + $test->setUnit(false, $method); } } } else { @@ -153,7 +154,7 @@ protected function expectAndValidate( */ public function deferValidation(Closure $validation): void { - // This will add a cursor to the possible line and file where error occurred + // This will add a cursor to the possible line and file where the error occurred $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; $this->deferredValidation[] = [ "trace" => $trace, @@ -210,62 +211,131 @@ function __construct(string $class, array $args = []) public function mock(string $class, ?Closure $validate = null, array $args = []): mixed { $mocker = new Mocker($class, $args); - if(is_callable($validate)) { - $pool = $mocker->getMethodPool(); - $fn = $validate->bindTo($pool); - $fn($pool); - $this->deferValidation(function() use($mocker, $pool) { - $error = []; - $data = MockerController::getData($mocker->getMockedClassName()); - foreach($data as $row) { - $item = $pool->get($row->name); - if($item) { - foreach (get_object_vars($item) as $property => $value) { - if(!is_null($value)) { - $currentValue = $row->{$property}; - if(is_array($value)) { - $validPool = new ValidationChain($currentValue); - foreach($value as $method => $args) { - if(is_int($method)) { - foreach($args as $methodB => $argsB) { - $validPool - ->mapErrorToKey($argsB[0]) - ->mapErrorValidationName($argsB[1]) - ->{$methodB}(...$argsB); - } - } else { - $validPool->{$method}(...$args); - } - } - $valid = $validPool->isValid(); - - } else { - $valid = Validator::value($currentValue)->equal($value); - } - - if(is_array($value)) { - $this->compareFromValidCollection( - $validPool, - $value, - $currentValue - ); - } - - $error[$row->name][] = [ - "property" => $property, - "currentValue" => $currentValue, - "expectedValue" => $value, - "valid" => $valid - ]; - } - } - } - } - return $error; - }); + + if (is_callable($validate)) { + $this->prepareValidation($mocker, $validate); } + return $mocker->execute(); } + + /** + * Prepares validation for a mock object by binding validation rules and deferring their execution + * + * This method takes a mocker instance and a validation closure, binds the validation + * to the method pool, and schedules the validation to run later via deferValidation. + * This allows for mock expectations to be defined and validated after the test execution. + * + * @param Mocker $mocker The mocker instance containing the mock object + * @param Closure $validate The closure containing validation rules + * @return void + */ + private function prepareValidation(Mocker $mocker, Closure $validate): void + { + $pool = $mocker->getMethodPool(); + $fn = $validate->bindTo($pool); + $fn($pool); + + $this->deferValidation(fn() => $this->runValidation($mocker, $pool)); + } + + /** + * Executes validation for a mocked class by comparing actual method calls against expectations + * + * This method retrieves all method call data for a mocked class and validates each call + * against the expectations defined in the method pool. The validation results are collected + * and returned as an array of errors indexed by method name. + * + * @param Mocker $mocker The mocker instance containing the mocked class + * @param MethodPool $pool The pool containing method expectations + * @return array An array of validation errors indexed by method name + * @throws ErrorException + */ + private function runValidation(Mocker $mocker, MethodPool $pool): array + { + $error = []; + $data = MockerController::getData($mocker->getMockedClassName()); + foreach ($data as $row) { + $error[$row->name] = $this->validateRow($row, $pool); + } + return $error; + } + + /** + * Validates a specific method row against the method pool expectations + * + * This method compares the actual method call data with the expected validation + * rules defined in the method pool. It handles both simple value comparisons + * and complex array validations. + * + * @param object $row The method call data to validate + * @param MethodPool $pool The pool containing validation expectations + * @return array Array of validation results containing property comparisons + * @throws ErrorException + */ + private function validateRow(object $row, MethodPool $pool): array + { + $item = $pool->get($row->name); + if (!$item) { + return []; + } + + $errors = []; + + foreach (get_object_vars($item) as $property => $value) { + if (is_null($value)) { + continue; + } + + $currentValue = $row->{$property}; + + if (is_array($value)) { + $validPool = $this->validateArrayValue($value, $currentValue); + $valid = $validPool->isValid(); + $this->compareFromValidCollection($validPool, $value, $currentValue); + } else { + $valid = Validator::value($currentValue)->equal($value); + } + + $errors[] = [ + "property" => $property, + "currentValue" => $currentValue, + "expectedValue" => $value, + "valid" => $valid + ]; + } + + return $errors; + } + + /** + * Validates an array value against a validation chain configuration. + * + * This method processes an array of validation rules and applies them to the current value. + * It handles both direct method calls and nested validation configurations. + * + * @param array $value The validation configuration array + * @param mixed $currentValue The value to validate + * @return ValidationChain The validation chain instance with applied validations + */ + private function validateArrayValue(array $value, mixed $currentValue): ValidationChain + { + $validPool = new ValidationChain($currentValue); + foreach ($value as $method => $args) { + if (is_int($method)) { + foreach ($args as $methodB => $argsB) { + $validPool + ->mapErrorToKey($argsB[0]) + ->mapErrorValidationName($argsB[1]) + ->{$methodB}(...$argsB); + } + } else { + $validPool->{$method}(...$args); + } + } + + return $validPool; + } /** * Create a comparison from a validation collection @@ -306,7 +376,7 @@ protected function mapValueToCollectionError(array $error, array $value): array } /** - * Executes all deferred validations that were registered earlier using deferValidation(). + * Executes all deferred validations registered earlier using deferValidation(). * * This method runs each queued validation closure, collects their results, * and converts them into individual TestUnit instances. If a validation fails, @@ -401,7 +471,7 @@ public function getMessage(): ?string } /** - * Get test array object + * Get a test array object * * @return array */ @@ -489,6 +559,7 @@ protected function valid(mixed $value): Validator * * @param string $class * @param string|null $prefixMethods + * @param bool $isolateClass * @return void * @throws ReflectionException */ diff --git a/src/TestWrapper.php b/src/TestWrapper.php index 4f31241..e3ac096 100755 --- a/src/TestWrapper.php +++ b/src/TestWrapper.php @@ -115,7 +115,7 @@ public function __call(string $name, array $arguments): mixed * @return mixed|object * @throws \ReflectionException */ - final protected function createInstance(Reflection $ref, array $args) + final protected function createInstance(Reflection $ref, array $args): mixed { if(count($args) === 0) { return $ref->dependencyInjector(); diff --git a/src/Unit.php b/src/Unit.php index d5245f7..cff3a3b 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -29,6 +29,15 @@ class Unit public static int $totalPassedTests = 0; public static int $totalTests = 0; + + /** + * Initialize Unit test instance with optional handler + * + * @param HandlerInterface|StreamInterface|null $handler Optional handler for test execution + * If HandlerInterface is provided, uses its command + * If StreamInterface is provided, creates a new Command with it + * If null, creates a new Command without a stream + */ public function __construct(HandlerInterface|StreamInterface|null $handler = null) { if($handler instanceof HandlerInterface) { From 19c373bc31cd78d4ab4605bc3c4415bd6875b04b Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Thu, 1 May 2025 21:43:02 +0200 Subject: [PATCH 14/22] Code quality improvements --- src/TestCase.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/TestCase.php b/src/TestCase.php index 21cb019..2c5b0ab 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -268,7 +268,7 @@ private function runValidation(Mocker $mocker, MethodPool $pool): array * rules defined in the method pool. It handles both simple value comparisons * and complex array validations. * - * @param object $row The method call data to validate + * @param object $row The method calls data to validate * @param MethodPool $pool The pool containing validation expectations * @return array Array of validation results containing property comparisons * @throws ErrorException @@ -597,6 +597,15 @@ public function listAllProxyMethods(string $class, ?string $prefixMethods = null } } + /** + * Retrieves all public methods from the traits used by a given class. + * + * This method collects and returns the names of all public methods + * defined in the traits used by the provided ReflectionClass instance. + * + * @param ReflectionClass $reflection The reflection instance of the class to inspect + * @return array An array of method names defined in the traits + */ public function getAllTraitMethods(ReflectionClass $reflection): array { $traitMethods = []; From 27d8dde0ce91dc323450f4d67eb27b25e61b3408 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Thu, 1 May 2025 23:37:10 +0200 Subject: [PATCH 15/22] Code quality improvements --- src/AbstractClassHelper.php | 72 ----------------- src/FileIterator.php | 20 ++--- src/Handlers/FileHandler.php | 2 +- src/Handlers/HtmlHandler.php | 2 +- src/Mocker/MethodItem.php | 37 +++++---- src/Mocker/MethodPool.php | 3 +- src/Mocker/Mocker.php | 95 +++++++++++++++-------- src/Mocker/MockerController.php | 16 ++-- src/TestCase.php | 64 +++++++-------- src/TestUnit.php | 20 ++--- src/TestWrapper.php | 9 ++- src/Unit.php | 133 ++++++++++++++++---------------- tests/unitary-unitary.php | 3 +- 13 files changed, 226 insertions(+), 250 deletions(-) delete mode 100644 src/AbstractClassHelper.php diff --git a/src/AbstractClassHelper.php b/src/AbstractClassHelper.php deleted file mode 100644 index a676b60..0000000 --- a/src/AbstractClassHelper.php +++ /dev/null @@ -1,72 +0,0 @@ -reflectionPool = new Reflection($className); - $this->reflection = $this->reflection->getReflect(); - //$this->constructor = $this->reflection->getConstructor(); - //$reflectParam = ($this->constructor) ? $this->constructor->getParameters() : []; - if (count($classArgs) > 0) { - $this->instance = $this->reflection->newInstanceArgs($classArgs); - } - } - - public function inspectMethod(string $method): array - { - if (!$this->reflection || !$this->reflection->hasMethod($method)) { - throw new Exception("Method '$method' does not exist."); - } - - $methodReflection = $this->reflection->getMethod($method); - $parameters = []; - foreach ($methodReflection->getParameters() as $param) { - $paramType = $param->hasType() ? $param->getType()->getName() : 'mixed'; - $parameters[] = [ - 'name' => $param->getName(), - 'type' => $paramType, - 'is_optional' => $param->isOptional(), - 'default_value' => $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null - ]; - } - - return [ - 'name' => $methodReflection->getName(), - 'visibility' => implode(' ', \Reflection::getModifierNames($methodReflection->getModifiers())), - 'is_static' => $methodReflection->isStatic(), - 'return_type' => $methodReflection->hasReturnType() ? $methodReflection->getReturnType()->getName() : 'mixed', - 'parameters' => $parameters - ]; - } - - /** - * Will create the main instance with dependency injection support - * - * @param string $className - * @param array $args - * @return mixed|object - * @throws \ReflectionException - */ - final protected function createInstance(string $className, array $args) - { - if(count($args) === 0) { - return $this->reflection->dependencyInjector(); - } - return new $className(...$args); - } -} \ No newline at end of file diff --git a/src/FileIterator.php b/src/FileIterator.php index 68d2318..057ea99 100755 --- a/src/FileIterator.php +++ b/src/FileIterator.php @@ -13,7 +13,7 @@ use RecursiveIteratorIterator; use SplFileInfo; -class FileIterator +final class FileIterator { public const PATTERN = 'unitary-*.php'; @@ -34,6 +34,7 @@ public function executeAll(string $directory): void { $files = $this->findFiles($directory); if (empty($files)) { + /* @var string static::PATTERN */ throw new RuntimeException("No files found matching the pattern \"" . (static::PATTERN ?? "") . "\" in directory \"$directory\" "); } else { foreach ($files as $file) { @@ -49,7 +50,7 @@ public function executeAll(string $directory): void if (!is_null($call)) { $call(); } - if(!Unit::hasUnit()) { + if (!Unit::hasUnit()) { throw new RuntimeException("The Unitary Unit class has not been initiated inside \"$file\"."); } } @@ -67,7 +68,7 @@ private function findFiles(string $dir): array { $files = []; $realDir = realpath($dir); - if($realDir === false) { + if ($realDir === false) { throw new RuntimeException("Directory \"$dir\" does not exist. Try using a absolut path!"); } $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir)); @@ -77,7 +78,7 @@ private function findFiles(string $dir): array foreach ($iterator as $file) { if (($file instanceof SplFileInfo) && fnmatch($pattern, $file->getFilename()) && (isset($this->args['path']) || !str_contains($file->getPathname(), DIRECTORY_SEPARATOR . "vendor" . DIRECTORY_SEPARATOR))) { - if(!$this->findExcluded($this->exclude(), $dir, $file->getPathname())) { + if (!$this->findExcluded($this->exclude(), $dir, $file->getPathname())) { $files[] = $file->getPathname(); } } @@ -92,13 +93,13 @@ private function findFiles(string $dir): array public function exclude(): array { $excl = []; - if(isset($this->args['exclude']) && is_string($this->args['exclude'])) { + if (isset($this->args['exclude']) && is_string($this->args['exclude'])) { $exclude = explode(',', $this->args['exclude']); foreach ($exclude as $file) { $file = str_replace(['"', "'"], "", $file); $new = trim($file); $lastChar = substr($new, -1); - if($lastChar === DIRECTORY_SEPARATOR) { + if ($lastChar === DIRECTORY_SEPARATOR) { $new .= "*"; } $excl[] = trim($new); @@ -118,8 +119,9 @@ public function findExcluded(array $exclArr, string $relativeDir, string $file): { $file = $this->getNaturalPath($file); foreach ($exclArr as $excl) { - $relativeExclPath = $this->getNaturalPath($relativeDir . DIRECTORY_SEPARATOR . $excl); - if(fnmatch($relativeExclPath, $file)) { + /* @var string $excl */ + $relativeExclPath = $this->getNaturalPath($relativeDir . DIRECTORY_SEPARATOR . (string)$excl); + if (fnmatch($relativeExclPath, $file)) { return true; } } @@ -147,7 +149,7 @@ private function requireUnitFile(string $file): ?Closure $call = function () use ($file, $clone): void { $cli = new CliHandler(); - if(Unit::getArgs('trace') !== false) { + if (Unit::getArgs('trace') !== false) { $cli->enableTraceLines(true); } $run = new Run($cli); diff --git a/src/Handlers/FileHandler.php b/src/Handlers/FileHandler.php index f599c03..f77472c 100755 --- a/src/Handlers/FileHandler.php +++ b/src/Handlers/FileHandler.php @@ -8,7 +8,7 @@ use MaplePHP\Http\UploadedFile; use MaplePHP\Prompts\Command; -class FileHandler implements HandlerInterface +final class FileHandler implements HandlerInterface { private string $file; private Stream $stream; diff --git a/src/Handlers/HtmlHandler.php b/src/Handlers/HtmlHandler.php index c3f729b..ded9229 100755 --- a/src/Handlers/HtmlHandler.php +++ b/src/Handlers/HtmlHandler.php @@ -7,7 +7,7 @@ use MaplePHP\Http\Stream; use MaplePHP\Prompts\Command; -class HtmlHandler implements HandlerInterface +final class HtmlHandler implements HandlerInterface { private Stream $stream; private Command $command; diff --git a/src/Mocker/MethodItem.php b/src/Mocker/MethodItem.php index cae940f..350b339 100644 --- a/src/Mocker/MethodItem.php +++ b/src/Mocker/MethodItem.php @@ -2,10 +2,14 @@ namespace MaplePHP\Unitary\Mocker; +use BadMethodCallException; use Closure; use MaplePHP\Unitary\TestWrapper; -class MethodItem +/** + * @psalm-suppress PossiblyUnusedProperty + */ +final class MethodItem { private ?Mocker $mocker; public mixed $return = null; @@ -40,19 +44,22 @@ public function __construct(?Mocker $mocker = null) /** * Will create a method wrapper making it possible to mock * - * @param $call + * @param Closure $call * @return $this */ - public function wrap($call): self + public function wrap(Closure $call): self { + if(is_null($this->mocker)) { + throw new BadMethodCallException('Mocker is not set. Use the method "mock" to set the mocker.'); + } + $inst = $this; - $wrap = new class($this->mocker->getClassName(), $this->mocker->getClassArgs()) extends TestWrapper { - function __construct(string $class, array $args = []) + $wrap = new class ($this->mocker->getClassName(), $this->mocker->getClassArgs()) extends TestWrapper { + public function __construct(string $class, array $args = []) { parent::__construct($class, $args); } }; - $call->bindTo($this->mocker); $this->wrapper = $wrap->bind($call); return $inst; } @@ -78,7 +85,7 @@ public function hasReturn(): bool } /** - * Check if method has been called x times + * Check if a method has been called x times * @param int $count * @return $this */ @@ -277,7 +284,7 @@ public function hasParams(): self } /** - * Check if all parameters has a data type + * Check if all parameters have a data type * * @return $this */ @@ -291,7 +298,7 @@ public function hasParamsTypes(): self } /** - * Check if parameter do not exist + * Check if parameter does not exist * * @return $this */ @@ -305,7 +312,7 @@ public function hasNotParams(): self } /** - * Check parameter type for method + * Check a parameter type for method * * @param int $length * @return $this @@ -320,7 +327,7 @@ public function hasParamsCount(int $length): self } /** - * Check parameter type for method + * Check a parameter type for method * * @param int $paramPosition * @param string $dataType @@ -352,7 +359,7 @@ public function paramHasDefault(int $paramPosition, string $defaultArgValue): se } /** - * Check parameter type for method + * Check a parameter type for method * * @param int $paramPosition * @return $this @@ -367,7 +374,7 @@ public function paramHasType(int $paramPosition): self } /** - * Check parameter type for method + * Check a parameter type for method * * @param int $paramPosition * @return $this @@ -397,7 +404,7 @@ public function paramIsReference(int $paramPosition): self } /** - * Check parameter is variadic (spread) for method + * Check the parameter is variadic (spread) for a method * * @param int $paramPosition * @return $this @@ -470,4 +477,4 @@ public function fileName(string $file): self $inst->fileName = $file; return $inst; } -} \ No newline at end of file +} diff --git a/src/Mocker/MethodPool.php b/src/Mocker/MethodPool.php index 9abf45b..6a3a007 100644 --- a/src/Mocker/MethodPool.php +++ b/src/Mocker/MethodPool.php @@ -5,6 +5,7 @@ class MethodPool { private ?Mocker $mocker = null; + /** @var array */ private array $methods = []; public function __construct(?Mocker $mocker = null) @@ -57,4 +58,4 @@ public function has(string $name): bool return isset($this->methods[$name]); } -} \ No newline at end of file +} diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php index b9787e8..67cf7f6 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/Mocker.php @@ -1,4 +1,5 @@ */ protected array $constructorArgs = []; - - protected array $overrides = []; - protected array $methods; protected array $methodList = []; - protected static ?MethodPool $methodPool = null; /** * @param string $className * @param array $args - * @throws ReflectionException */ public function __construct(string $className, array $args = []) { $this->className = $className; + /** @var class-string $className */ $this->reflection = new ReflectionClass($className); /* @@ -72,14 +71,20 @@ public function getClassArgs(): array */ public function getMethodPool(): MethodPool { - if(is_null(self::$methodPool)) { + if (is_null(self::$methodPool)) { self::$methodPool = new MethodPool($this); } return self::$methodPool; } + /** + * @throws Exception + */ public function getMockedClassName(): string { + if(!$this->mockClassName) { + throw new Exception("Mock class name is not set"); + } return $this->mockClassName; } @@ -87,7 +92,7 @@ public function getMockedClassName(): string * Executes the creation of a dynamic mock class and returns an instance of the mock. * * @return mixed An instance of the dynamically created mock class. - * @throws ReflectionException + * @throws Exception */ public function execute(): mixed { @@ -96,6 +101,10 @@ public function execute(): mixed $shortClassName = explode("\\", $className); $shortClassName = end($shortClassName); + /** + * @var class-string $shortClassName + * @psalm-suppress PropertyTypeCoercion + */ $this->mockClassName = 'Unitary_' . uniqid() . "_Mock_" . $shortClassName; $overrides = $this->generateMockMethodOverrides($this->mockClassName); $unknownMethod = $this->errorHandleUnknownMethod($className); @@ -107,6 +116,11 @@ class $this->mockClassName extends $className { "; eval($code); + + /** + * @psalm-suppress MixedMethodCall + * @psalm-suppress InvalidStringClass + */ return new $this->mockClassName(...$this->constructorArgs); } @@ -120,7 +134,7 @@ class $this->mockClassName extends $className { */ private function errorHandleUnknownMethod(string $className): string { - if(!in_array('__call', $this->methodList)) { + if (!in_array('__call', $this->methodList)) { return " public function __call(string \$name, array \$arguments) { if (method_exists(get_parent_class(\$this), '__call')) { @@ -142,11 +156,11 @@ public function __call(string \$name, array \$arguments) { protected function getReturnValue(array $types, mixed $method, ?MethodItem $methodItem = null): string { // Will overwrite the auto generated value - if($methodItem && $methodItem->hasReturn()) { + if ($methodItem && $methodItem->hasReturn()) { return "return " . var_export($methodItem->return, true) . ";"; } if ($types) { - return $this->getMockValueForType($types[0], $method); + return (string)$this->getMockValueForType((string)$types[0], $method); } return "return 'MockedValue';"; } @@ -155,13 +169,19 @@ protected function getReturnValue(array $types, mixed $method, ?MethodItem $meth * Builds and returns PHP code that overrides all public methods in the class being mocked. * Each overridden method returns a predefined mock value or delegates to the original logic. * + * @param string $mockClassName * @return string PHP code defining the overridden methods. - * @throws ReflectionException + * @throws Exception */ protected function generateMockMethodOverrides(string $mockClassName): string { $overrides = ''; foreach ($this->methods as $method) { + + if(!($method instanceof ReflectionMethod)) { + throw new Exception("Method is not a ReflectionMethod"); + } + if ($method->isConstructor() || $method->isFinal()) { continue; } @@ -184,9 +204,12 @@ protected function generateMockMethodOverrides(string $mockClassName): string $arr['return'] = $return; $info = json_encode($arr); + if ($info === false) { + throw new RuntimeException('JSON encoding failed: ' . json_last_error_msg(), json_last_error()); + } MockerController::getInstance()->buildMethodData($info); - if($methodItem) { + if ($methodItem) { $returnValue = $this->generateWrapperReturn($methodItem->getWrap(), $methodName, $returnValue); } @@ -214,7 +237,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string */ protected function generateWrapperReturn(?Closure $wrapper, string $methodName, string $returnValue): string { - MockerController::addData($this->mockClassName, $methodName, 'wrapper', $wrapper); + MockerController::addData((string)$this->mockClassName, $methodName, 'wrapper', $wrapper); $return = ($returnValue) ? "return " : ""; return " if (isset(\$data->wrapper) && \$data->wrapper instanceof \\Closure) { @@ -236,7 +259,8 @@ protected function generateMethodSignature(ReflectionMethod $method): string foreach ($method->getParameters() as $param) { $paramStr = ''; if ($param->hasType()) { - $paramStr .= $param->getType() . ' '; + $getType = (string)$param->getType(); + $paramStr .= $getType . ' '; } if ($param->isPassedByReference()) { $paramStr .= '&'; @@ -269,18 +293,19 @@ protected function getReturnType(ReflectionMethod $method): array $types[] = $returnType->getName(); } elseif ($returnType instanceof ReflectionUnionType) { foreach ($returnType->getTypes() as $type) { - $types[] = $type->getName(); + if(method_exists($type, "getName")) { + $types[] = $type->getName(); + } } } elseif ($returnType instanceof ReflectionIntersectionType) { $intersect = array_map( - fn($type) => method_exists($type, "getName") ? $type->getName() : null, - $returnType->getTypes() + fn ($type) => $type->getName(), $returnType->getTypes() ); $types[] = $intersect; } - if(!in_array("mixed", $types) && $returnType && $returnType->allowsNull()) { + if (!in_array("mixed", $types) && $returnType && $returnType->allowsNull()) { $types[] = "null"; } return $types; @@ -295,12 +320,12 @@ protected function getReturnType(ReflectionMethod $method): array */ protected function getMockValueForType(string $typeName, mixed $method, mixed $value = null, bool $nullable = false): ?string { - $typeName = strtolower($typeName); - if(!is_null($value)) { + $dataTypeName = strtolower($typeName); + if (!is_null($value)) { return "return " . var_export($value, true) . ";"; } - $mock = match ($typeName) { + $mock = match ($dataTypeName) { 'int', 'integer' => "return 123456;", 'float', 'double' => "return 3.14;", 'string' => "return 'mockString';", @@ -312,7 +337,8 @@ protected function getMockValueForType(string $typeName, mixed $method, mixed $v 'iterable' => "return new ArrayIterator(['a', 'b']);", 'null' => "return null;", 'void' => "", - 'self' => ($method->isStatic()) ? 'return new self();' : 'return $this;', + 'self' => (is_object($method) && method_exists($method, "isStatic") && $method->isStatic()) ? 'return new self();' : 'return $this;', + /** @var class-string $typeName */ default => (class_exists($typeName)) ? "return new class() extends " . $typeName . " {};" : "return null;", @@ -323,12 +349,15 @@ protected function getMockValueForType(string $typeName, mixed $method, mixed $v /** * Will return a streamable content - * - * @param $resourceValue + * + * @param mixed $resourceValue * @return string|null */ - protected function handleResourceContent($resourceValue): ?string + protected function handleResourceContent(mixed $resourceValue): ?string { + if (!is_resource($resourceValue)) { + return null; + } return var_export(stream_get_contents($resourceValue), true); } @@ -338,7 +367,7 @@ protected function handleResourceContent($resourceValue): ?string * @param ReflectionMethod $refMethod * @return array */ - function getMethodInfoAsArray(ReflectionMethod $refMethod): array + public function getMethodInfoAsArray(ReflectionMethod $refMethod): array { $params = []; foreach ($refMethod->getParameters() as $param) { @@ -376,4 +405,4 @@ function getMethodInfoAsArray(ReflectionMethod $refMethod): array ]; } -} \ No newline at end of file +} diff --git a/src/Mocker/MockerController.php b/src/Mocker/MockerController.php index 5eaf1c2..90e0d7b 100644 --- a/src/Mocker/MockerController.php +++ b/src/Mocker/MockerController.php @@ -2,17 +2,17 @@ namespace MaplePHP\Unitary\Mocker; -class MockerController extends MethodPool +final class MockerController extends MethodPool { private static ?MockerController $instance = null; private static array $data = []; - private array $methods = []; + //private array $methods = []; public static function getInstance(): self { - if(is_null(self::$instance)) { + if (is_null(self::$instance)) { self::$instance = new self(); } return self::$instance; @@ -26,7 +26,11 @@ public static function getInstance(): self */ public static function getData(string $mockIdentifier): array|bool { - return (self::$data[$mockIdentifier] ?? false); + $data = isset(self::$data[$mockIdentifier]) ? self::$data[$mockIdentifier] : false; + if(!is_array($data)) { + return false; + } + return $data; } public static function getDataItem(string $mockIdentifier, string $method): mixed @@ -42,7 +46,7 @@ public static function addData(string $mockIdentifier, string $method, string $k public function buildMethodData(string $method): object { $data = json_decode($method); - if(empty(self::$data[$data->mocker][$data->name])) { + if (empty(self::$data[$data->mocker][$data->name])) { $data->count = 0; self::$data[$data->mocker][$data->name] = $data; } else { @@ -51,4 +55,4 @@ public function buildMethodData(string $method): object return $data; } -} \ No newline at end of file +} diff --git a/src/TestCase.php b/src/TestCase.php index 2c5b0ab..7b77d89 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -44,7 +44,7 @@ public function __construct(?string $message = null) /** * Bind the test case to the Closure - * + * * @param Closure $bind * @return void */ @@ -55,7 +55,7 @@ public function bind(Closure $bind): void /** * Will dispatch the case tests and return them as an array - * + * * @return array */ public function dispatchTest(): array @@ -69,7 +69,7 @@ public function dispatchTest(): array /** * Add custom error message if validation fails - * + * * @param string $message * @return $this */ @@ -89,7 +89,7 @@ public function error(string $message): self */ public function validate(mixed $expect, Closure $validation): self { - $this->expectAndValidate($expect, function(mixed $value, ValidationChain $inst) use($validation) { + $this->expectAndValidate($expect, function (mixed $value, ValidationChain $inst) use ($validation) { return $validation($inst, $value); }, $this->errorMessage); @@ -118,22 +118,22 @@ protected function expectAndValidate( $this->value = $expect; $test = new TestUnit($message); $test->setTestValue($this->value); - if($validation instanceof Closure) { + if ($validation instanceof Closure) { $listArr = $this->buildClosureTest($validation); - foreach($listArr as $list) { - foreach($list as $method => $valid) { + foreach ($listArr as $list) { + foreach ($list as $method => $valid) { $test->setUnit(false, $method); } } } else { - foreach($validation as $method => $args) { - if(!($args instanceof Closure) && !is_array($args)) { + foreach ($validation as $method => $args) { + if (!($args instanceof Closure) && !is_array($args)) { $args = [$args]; } $test->setUnit($this->buildArrayTest($method, $args), $method, (is_array($args) ? $args : [])); } } - if(!$test->isValid()) { + if (!$test->isValid()) { $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; $test->setCodeLine($trace); $this->count++; @@ -161,7 +161,7 @@ public function deferValidation(Closure $validation): void "call" => $validation ]; } - + /** * Same as "addTestUnit" but is public and will make sure the validation can be * properly registered and traceable @@ -186,8 +186,8 @@ public function add(mixed $expect, array|Closure $validation, ?string $message = */ public function wrap(string $class, array $args = []): TestWrapper { - return new class($class, $args) extends TestWrapper { - function __construct(string $class, array $args = []) + return new class ($class, $args) extends TestWrapper { + public function __construct(string $class, array $args = []) { parent::__construct($class, $args); } @@ -218,7 +218,7 @@ public function mock(string $class, ?Closure $validate = null, array $args = []) return $mocker->execute(); } - + /** * Prepares validation for a mock object by binding validation rules and deferring their execution * @@ -236,7 +236,7 @@ private function prepareValidation(Mocker $mocker, Closure $validate): void $fn = $validate->bindTo($pool); $fn($pool); - $this->deferValidation(fn() => $this->runValidation($mocker, $pool)); + $this->deferValidation(fn () => $this->runValidation($mocker, $pool)); } /** @@ -307,7 +307,7 @@ private function validateRow(object $row, MethodPool $pool): array return $errors; } - + /** * Validates an array value against a validation chain configuration. * @@ -350,7 +350,7 @@ protected function compareFromValidCollection(ValidationChain $validPool, array $new = []; $error = $validPool->getError(); $value = $this->mapValueToCollectionError($error, $value); - foreach($value as $eqIndex => $validator) { + foreach ($value as $eqIndex => $validator) { $new[] = Traverse::value($currentValue)->eq($eqIndex)->get(); } $currentValue = $new; @@ -365,9 +365,9 @@ protected function compareFromValidCollection(ValidationChain $validPool, array */ protected function mapValueToCollectionError(array $error, array $value): array { - foreach($value as $item) { - foreach($item as $value) { - if(isset($error[$value[0]])) { + foreach ($value as $item) { + foreach ($item as $value) { + if (isset($error[$value[0]])) { $error[$value[0]] = $value[2]; } } @@ -387,14 +387,14 @@ protected function mapValueToCollectionError(array $error, array $value): array */ public function runDeferredValidations(): array { - foreach($this->deferredValidation as $row) { + foreach ($this->deferredValidation as $row) { $error = $row['call'](); - foreach($error as $method => $arr) { + foreach ($error as $method => $arr) { $test = new TestUnit("Mock method \"$method\" failed"); - if(is_array($row['trace'] ?? "")) { + if (is_array($row['trace'] ?? "")) { $test->setCodeLine($row['trace']); } - foreach($arr as $data) { + foreach ($arr as $data) { $test->setUnit($data['valid'], $data['property'], [], [ $data['expectedValue'], $data['currentValue'] ]); @@ -493,15 +493,15 @@ protected function buildClosureTest(Closure $validation): array $validation = $validation->bindTo($validPool); $error = []; - if(!is_null($validation)) { + if (!is_null($validation)) { $bool = $validation($this->value, $validPool); $error = $validPool->getError(); - if(is_bool($bool) && !$bool) { + if (is_bool($bool) && !$bool) { $error['customError'] = false; } } - if(is_null($this->message)) { + if (is_null($this->message)) { throw new RuntimeException("When testing with closure the third argument message is required"); } @@ -518,17 +518,17 @@ protected function buildClosureTest(Closure $validation): array */ protected function buildArrayTest(string $method, array|Closure $args): bool { - if($args instanceof Closure) { + if ($args instanceof Closure) { $args = $args->bindTo($this->valid($this->value)); - if(is_null($args)) { + if (is_null($args)) { throw new ErrorException("The argument is not returning a callable Closure!"); } $bool = $args($this->value); - if(!is_bool($bool)) { + if (!is_bool($bool)) { throw new RuntimeException("A callable validation must return a boolean!"); } } else { - if(!method_exists(Validator::class, $method)) { + if (!method_exists(Validator::class, $method)) { throw new BadMethodCallException("The validation $method does not exist!"); } @@ -581,7 +581,7 @@ public function listAllProxyMethods(string $class, ?string $prefixMethods = null continue; } - $params = array_map(function($param) { + $params = array_map(function ($param) { $type = $param->hasType() ? $param->getType() . ' ' : ''; $value = $param->isDefaultValueAvailable() ? ' = ' . Str::value($param->getDefaultValue())->exportReadableValue()->get() : null; return $type . '$' . $param->getName() . $value; diff --git a/src/TestUnit.php b/src/TestUnit.php index e02112a..76de644 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -66,22 +66,22 @@ public function setUnit( bool|null $valid, null|string|\Closure $validation = null, array $args = [], - array $compare = []): self - { - if(!$valid) { + array $compare = [] + ): self { + if (!$valid) { $this->valid = false; $this->count++; } - - if(!is_callable($validation)) { + + if (!is_callable($validation)) { $valLength = strlen((string)$validation); - if($validation && $this->valLength < $valLength) { + if ($validation && $this->valLength < $valLength) { $this->valLength = $valLength; } } - if($compare && count($compare) > 0) { - $compare = array_map(fn($value) => $this->getReadValue($value, true), $compare); + if ($compare && count($compare) > 0) { + $compare = array_map(fn ($value) => $this->getReadValue($value, true), $compare); } $this->unit[] = [ 'valid' => $valid, @@ -109,7 +109,7 @@ public function getValidationLength(): int * @return $this * @throws ErrorException */ - function setCodeLine(array $trace): self + public function setCodeLine(array $trace): self { $this->codeLine = []; $file = $trace['file'] ?? ''; @@ -117,7 +117,7 @@ function setCodeLine(array $trace): self if ($file && $line) { $lines = file($file); $code = trim($lines[$line - 1] ?? ''); - if(str_starts_with($code, '->')) { + if (str_starts_with($code, '->')) { $code = substr($code, 2); } $code = $this->excerpt($code); diff --git a/src/TestWrapper.php b/src/TestWrapper.php index e3ac096..2b8a2e4 100755 --- a/src/TestWrapper.php +++ b/src/TestWrapper.php @@ -1,4 +1,5 @@ instance, $method)) { + if (!method_exists($this->instance, $method)) { throw new \BadMethodCallException( "Method '$method' does not exist in the class '" . get_class($this->instance) . "' and therefore cannot be overridden or called." @@ -76,7 +77,7 @@ public function override(string $method, Closure $call): self */ public function add(string $method, Closure $call): self { - if(method_exists($this->instance, $method)) { + if (method_exists($this->instance, $method)) { throw new \BadMethodCallException( "Method '$method' already exists in the class '" . get_class($this->instance) . "'. Use the 'override' method in TestWrapper instead." @@ -117,9 +118,9 @@ public function __call(string $name, array $arguments): mixed */ final protected function createInstance(Reflection $ref, array $args): mixed { - if(count($args) === 0) { + if (count($args) === 0) { return $ref->dependencyInjector(); } return $ref->getReflect()->newInstanceArgs($args); } -} \ No newline at end of file +} diff --git a/src/Unit.php b/src/Unit.php index cff3a3b..b0238fb 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -40,7 +40,7 @@ class Unit */ public function __construct(HandlerInterface|StreamInterface|null $handler = null) { - if($handler instanceof HandlerInterface) { + if ($handler instanceof HandlerInterface) { $this->handler = $handler; $this->command = $this->handler->getCommand(); } else { @@ -67,7 +67,7 @@ public function skip(bool $skip): self */ public function manual(string $key): self { - if(isset(self::$manual[$key])) { + if (isset(self::$manual[$key])) { $file = (string)(self::$headers['file'] ?? "none"); throw new RuntimeException("The manual key \"$key\" already exists. Please set a unique key in the " . $file. " file."); @@ -145,7 +145,7 @@ public function add(string $message, Closure $callback): void * @param Closure(TestCase):void $callback * @return void */ - public function case(string $message, Closure $callback): void + public function group(string $message, Closure $callback): void { $testCase = new TestCase($message); $testCase->bind($callback); @@ -153,16 +153,17 @@ public function case(string $message, Closure $callback): void $this->index++; } - public function group(string $message, Closure $callback): void + // Alias to group + public function case(string $message, Closure $callback): void { - $this->case($message, $callback); + $this->group($message, $callback); } public function performance(Closure $func, ?string $title = null): void { $start = new TestMem(); $func = $func->bindTo($this); - if(!is_null($func)) { + if (!is_null($func)) { $func($this); } $line = $this->command->getAnsi()->line(80); @@ -195,14 +196,14 @@ public function performance(Closure $func, ?string $title = null): void */ public function execute(): bool { - if($this->executed || !$this->createValidate()) { + if ($this->executed || !$this->createValidate()) { return false; } // LOOP through each case ob_start(); - foreach($this->cases as $row) { - if(!($row instanceof TestCase)) { + foreach ($this->cases as $row) { + if (!($row instanceof TestCase)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestCase."); } @@ -211,11 +212,11 @@ public function execute(): bool $tests = $row->runDeferredValidations(); $color = ($row->hasFailed() ? "brightRed" : "brightBlue"); $flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); - if($row->hasFailed()) { + if ($row->hasFailed()) { $flag = $this->command->getAnsi()->style(['redBg', 'brightWhite'], " FAIL "); } - if($errArg !== false && !$row->hasFailed()) { + if ($errArg !== false && !$row->hasFailed()) { continue; } @@ -227,58 +228,60 @@ public function execute(): bool $this->command->getAnsi()->style(["bold", $color], (string)$row->getMessage()) ); - if(isset($tests)) foreach($tests as $test) { - if(!($test instanceof TestUnit)) { - throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); - } - - if(!$test->isValid()) { - $msg = (string)$test->getMessage(); - $this->command->message(""); - $this->command->message( - $this->command->getAnsi()->style(["bold", "brightRed"], "Error: ") . - $this->command->getAnsi()->bold($msg) - ); - $this->command->message(""); - - $trace = $test->getCodeLine(); - if(!empty($trace['code'])) { - $this->command->message($this->command->getAnsi()->style(["bold", "grey"], "Failed on line {$trace['line']}: ")); - $this->command->message($this->command->getAnsi()->style(["grey"], " → {$trace['code']}")); + if (isset($tests)) { + foreach ($tests as $test) { + if (!($test instanceof TestUnit)) { + throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); } - /** @var array $unit */ - foreach($test->getUnits() as $unit) { - if(is_string($unit['validation']) && !$unit['valid']) { - $lengthA = $test->getValidationLength() + 1; - $title = str_pad($unit['validation'], $lengthA); + if (!$test->isValid()) { + $msg = (string)$test->getMessage(); + $this->command->message(""); + $this->command->message( + $this->command->getAnsi()->style(["bold", "brightRed"], "Error: ") . + $this->command->getAnsi()->bold($msg) + ); + $this->command->message(""); - $compare = ""; - if($unit['compare']) { - $expectedValue = array_shift($unit['compare']); - $compare = "Expected: {$expectedValue} | Actual: " . implode(":", $unit['compare']); - } + $trace = $test->getCodeLine(); + if (!empty($trace['code'])) { + $this->command->message($this->command->getAnsi()->style(["bold", "grey"], "Failed on line {$trace['line']}: ")); + $this->command->message($this->command->getAnsi()->style(["grey"], " → {$trace['code']}")); + } + + /** @var array $unit */ + foreach ($test->getUnits() as $unit) { + if (is_string($unit['validation']) && !$unit['valid']) { + $lengthA = $test->getValidationLength() + 1; + $title = str_pad($unit['validation'], $lengthA); + + $compare = ""; + if ($unit['compare']) { + $expectedValue = array_shift($unit['compare']); + $compare = "Expected: {$expectedValue} | Actual: " . implode(":", $unit['compare']); + } - $failedMsg = " " .$title . ((!$unit['valid']) ? " → failed" : ""); - $this->command->message( - $this->command->getAnsi()->style( - ((!$unit['valid']) ? "brightRed" : null), - $failedMsg - ) - ); - - if(!$unit['valid'] && $compare) { - $lengthB = (strlen($compare) + strlen($failedMsg) - 8); - $comparePad = str_pad($compare, $lengthB, " ", STR_PAD_LEFT); + $failedMsg = " " .$title . ((!$unit['valid']) ? " → failed" : ""); $this->command->message( - $this->command->getAnsi()->style("brightRed", $comparePad) + $this->command->getAnsi()->style( + ((!$unit['valid']) ? "brightRed" : null), + $failedMsg + ) ); + + if (!$unit['valid'] && $compare) { + $lengthB = (strlen($compare) + strlen($failedMsg) - 8); + $comparePad = str_pad($compare, $lengthB, " ", STR_PAD_LEFT); + $this->command->message( + $this->command->getAnsi()->style("brightRed", $comparePad) + ); + } } } - } - if($test->hasValue()) { - $this->command->message(""); - $this->command->message($this->command->getAnsi()->bold("Value: ") . $test->getReadValue()); + if ($test->hasValue()) { + $this->command->message(""); + $this->command->message($this->command->getAnsi()->bold("Value: ") . $test->getReadValue()); + } } } } @@ -297,10 +300,10 @@ public function execute(): bool } $this->output .= ob_get_clean(); - if($this->output) { + if ($this->output) { $this->buildNotice("Note:", $this->output, 80); } - if(!is_null($this->handler)) { + if (!is_null($this->handler)) { $this->handler->execute(); } $this->executed = true; @@ -314,8 +317,8 @@ public function execute(): bool */ public function resetExecute(): bool { - if($this->executed) { - if($this->getStream()->isSeekable()) { + if ($this->executed) { + if ($this->getStream()->isSeekable()) { $this->getStream()->rewind(); } $this->executed = false; @@ -324,10 +327,10 @@ public function resetExecute(): bool return false; } - + /** * Validate method that must be called within a group method - * + * * @return self * @throws RuntimeException When called outside a group method */ @@ -346,10 +349,10 @@ private function createValidate(): bool { $args = (array)(self::$headers['args'] ?? []); $manual = isset($args['show']) ? (string)$args['show'] : ""; - if(isset($args['show'])) { + if (isset($args['show'])) { return !((self::$manual[$manual] ?? "") !== self::$headers['checksum'] && $manual !== self::$headers['checksum']); } - if($this->skip) { + if ($this->skip) { return false; } return true; @@ -451,7 +454,7 @@ public static function hasUnit(): bool */ public static function getUnit(): ?Unit { - if(is_null(self::hasUnit())) { + if (is_null(self::hasUnit())) { throw new Exception("Unit has not been set yet. It needs to be set first."); } return self::$current; @@ -463,7 +466,7 @@ public static function getUnit(): ?Unit */ public static function completed(): void { - if(!is_null(self::$current) && is_null(self::$current->handler)) { + if (!is_null(self::$current) && is_null(self::$current->handler)) { $dot = self::$current->command->getAnsi()->middot(); self::$current->command->message(""); diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index b42e0fe..c3abe47 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -77,7 +77,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { } $unit = new Unit(); -$unit->group("Unitary test 2", function (TestCase $inst) { +$unit->group("Unitary test 2", function (TestCase $inst) use($unit) { $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { $pool->method("addBCC") @@ -87,6 +87,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { ->count(1); }, ["Arg 1"]); $mock->addBCC("World"); + }); /* From 365ffb11da2c0f293eb3e9bc21e63ef4460f7518 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Fri, 2 May 2025 00:24:31 +0200 Subject: [PATCH 16/22] Code quality improvements --- src/Mocker/MockerController.php | 60 ++++++++++++++++++++++++++------- src/TestCase.php | 33 ++++++++++++------ 2 files changed, 71 insertions(+), 22 deletions(-) diff --git a/src/Mocker/MockerController.php b/src/Mocker/MockerController.php index 90e0d7b..27c8caa 100644 --- a/src/Mocker/MockerController.php +++ b/src/Mocker/MockerController.php @@ -5,11 +5,15 @@ final class MockerController extends MethodPool { private static ?MockerController $instance = null; - + /** @var array> */ private static array $data = []; - //private array $methods = []; - + /** + * Get singleton instance of MockerController + * Creates new instance if none exists + * + * @return static The singleton instance of MockerController + */ public static function getInstance(): self { if (is_null(self::$instance)) { @@ -32,25 +36,57 @@ public static function getData(string $mockIdentifier): array|bool } return $data; } - + + /** + * Get specific data item by mock identifier and method name + * + * @param string $mockIdentifier The identifier of the mock + * @param string $method The method name to retrieve + * @return mixed Returns the data item if found, false otherwise + */ public static function getDataItem(string $mockIdentifier, string $method): mixed { - return self::$data[$mockIdentifier][$method]; + return self::$data[$mockIdentifier][$method] ?? false; } + + /** + * Add or update data for a specific mock method + * + * @param string $mockIdentifier The identifier of the mock + * @param string $method The method name to add data to + * @param string $key The key of the data to add + * @param mixed $value The value to add + * @return void + */ public static function addData(string $mockIdentifier, string $method, string $key, mixed $value): void { - self::$data[$mockIdentifier][$method]->{$key} = $value; + if(isset(self::$data[$mockIdentifier][$method])) { + self::$data[$mockIdentifier][$method]->{$key} = $value; + } } + /** + * Builds and manages method data for mocking + * Decodes JSON method string and handles mock data storage with count tracking + * + * @param string $method JSON string containing mock method data + * @return object Decoded method data object with updated count if applicable + */ public function buildMethodData(string $method): object { - $data = json_decode($method); - if (empty(self::$data[$data->mocker][$data->name])) { - $data->count = 0; - self::$data[$data->mocker][$data->name] = $data; - } else { - self::$data[$data->mocker][$data->name]->count++; + $data = (object)json_decode($method); + if(isset($data->mocker) && isset($data->name)) { + $mocker = (string)$data->mocker; + $name = (string)$data->name; + if (empty(self::$data[$mocker][$name])) { + $data->count = 0; + self::$data[$mocker][$name] = $data; + } else { + if (isset(self::$data[$mocker][$name])) { + self::$data[$mocker][$name]->count = (int)self::$data[$mocker][$name]->count + 1; + } + } } return $data; } diff --git a/src/TestCase.php b/src/TestCase.php index 7b77d89..8a2d83d 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -20,7 +20,7 @@ use RuntimeException; use Throwable; -class TestCase +final class TestCase { private mixed $value; private ?string $message; @@ -121,8 +121,8 @@ protected function expectAndValidate( if ($validation instanceof Closure) { $listArr = $this->buildClosureTest($validation); foreach ($listArr as $list) { - foreach ($list as $method => $valid) { - $test->setUnit(false, $method); + foreach ($list as $method => $_valid) { + $test->setUnit(false, (string)$method); } } } else { @@ -216,6 +216,7 @@ public function mock(string $class, ?Closure $validate = null, array $args = []) $this->prepareValidation($mocker, $validate); } + /** @psalm-suppress MixedReturnStatement */ return $mocker->execute(); } @@ -234,6 +235,9 @@ private function prepareValidation(Mocker $mocker, Closure $validate): void { $pool = $mocker->getMethodPool(); $fn = $validate->bindTo($pool); + if(is_null($fn)) { + throw new ErrorException("A callable Closure could not be bound to the method pool!"); + } $fn($pool); $this->deferValidation(fn () => $this->runValidation($mocker, $pool)); @@ -255,8 +259,13 @@ private function runValidation(Mocker $mocker, MethodPool $pool): array { $error = []; $data = MockerController::getData($mocker->getMockedClassName()); + if(!is_array($data)) { + throw new ErrorException("Could not get data from mocker!"); + } foreach ($data as $row) { - $error[$row->name] = $this->validateRow($row, $pool); + if (is_object($row) && isset($row->name)) { + $error[(string)$row->name] = $this->validateRow($row, $pool); + } } return $error; } @@ -275,7 +284,7 @@ private function runValidation(Mocker $mocker, MethodPool $pool): array */ private function validateRow(object $row, MethodPool $pool): array { - $item = $pool->get($row->name); + $item = $pool->get((string)($row->name ?? "")); if (!$item) { return []; } @@ -290,10 +299,12 @@ private function validateRow(object $row, MethodPool $pool): array $currentValue = $row->{$property}; if (is_array($value)) { + assert(is_array($currentValue), 'The $currentValue variable is not!'); $validPool = $this->validateArrayValue($value, $currentValue); $valid = $validPool->isValid(); $this->compareFromValidCollection($validPool, $value, $currentValue); } else { + /** @psalm-suppress MixedArgument */ $valid = Validator::value($currentValue)->equal($value); } @@ -324,10 +335,12 @@ private function validateArrayValue(array $value, mixed $currentValue): Validati foreach ($value as $method => $args) { if (is_int($method)) { foreach ($args as $methodB => $argsB) { - $validPool - ->mapErrorToKey($argsB[0]) - ->mapErrorValidationName($argsB[1]) - ->{$methodB}(...$argsB); + if(is_array($argsB) && count($argsB) >= 2) { + $validPool + ->mapErrorToKey((string)$argsB[0]) + ->mapErrorValidationName((string)$argsB[1]) + ->{$methodB}(...$argsB); + } } } else { $validPool->{$method}(...$args); @@ -350,7 +363,7 @@ protected function compareFromValidCollection(ValidationChain $validPool, array $new = []; $error = $validPool->getError(); $value = $this->mapValueToCollectionError($error, $value); - foreach ($value as $eqIndex => $validator) { + foreach ($value as $eqIndex => $_validator) { $new[] = Traverse::value($currentValue)->eq($eqIndex)->get(); } $currentValue = $new; From a9b89df1043893f10130c35618c1c2278f7db3e8 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sat, 3 May 2025 00:34:18 +0200 Subject: [PATCH 17/22] Add mocking improvements --- src/Mocker/MethodItem.php | 2 +- src/Mocker/Mocker.php | 28 +++++++++++++------ src/Mocker/MockerController.php | 15 +++++----- src/TestCase.php | 49 +++++++++++++++++++++------------ tests/unitary-unitary.php | 4 ++- 5 files changed, 64 insertions(+), 34 deletions(-) diff --git a/src/Mocker/MethodItem.php b/src/Mocker/MethodItem.php index 350b339..6a29a00 100644 --- a/src/Mocker/MethodItem.php +++ b/src/Mocker/MethodItem.php @@ -49,7 +49,7 @@ public function __construct(?Mocker $mocker = null) */ public function wrap(Closure $call): self { - if(is_null($this->mocker)) { + if (is_null($this->mocker)) { throw new BadMethodCallException('Mocker is not set. Use the method "mock" to set the mocker.'); } diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php index 67cf7f6..23063fc 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/Mocker.php @@ -82,7 +82,7 @@ public function getMethodPool(): MethodPool */ public function getMockedClassName(): string { - if(!$this->mockClassName) { + if (!$this->mockClassName) { throw new Exception("Mock class name is not set"); } return $this->mockClassName; @@ -108,15 +108,22 @@ public function execute(): mixed $this->mockClassName = 'Unitary_' . uniqid() . "_Mock_" . $shortClassName; $overrides = $this->generateMockMethodOverrides($this->mockClassName); $unknownMethod = $this->errorHandleUnknownMethod($className); + $code = " class $this->mockClassName extends $className { {$overrides} {$unknownMethod} + public static function __set_state(array \$an_array): self + { + \$obj = new self(..." . var_export($this->constructorArgs, true) . "); + return \$obj; + } } "; eval($code); + /** * @psalm-suppress MixedMethodCall * @psalm-suppress InvalidStringClass @@ -178,11 +185,11 @@ protected function generateMockMethodOverrides(string $mockClassName): string $overrides = ''; foreach ($this->methods as $method) { - if(!($method instanceof ReflectionMethod)) { + if (!($method instanceof ReflectionMethod)) { throw new Exception("Method is not a ReflectionMethod"); } - if ($method->isConstructor() || $method->isFinal()) { + if ($method->isFinal()) { continue; } @@ -193,6 +200,10 @@ protected function generateMockMethodOverrides(string $mockClassName): string $methodItem = $this->getMethodPool()->get($methodName); $types = $this->getReturnType($method); $returnValue = $this->getReturnValue($types, $method, $methodItem); + if($method->isConstructor()) { + $types = []; + $returnValue = ""; + } $paramList = $this->generateMethodSignature($method); $returnType = ($types) ? ': ' . implode('|', $types) : ''; $modifiersArr = Reflection::getModifierNames($method->getModifiers()); @@ -212,11 +223,11 @@ protected function generateMockMethodOverrides(string $mockClassName): string if ($methodItem) { $returnValue = $this->generateWrapperReturn($methodItem->getWrap(), $methodName, $returnValue); } - + $safeJson = base64_encode($info); $overrides .= " $modifiers function $methodName($paramList){$returnType} { - \$obj = \\MaplePHP\\Unitary\\Mocker\\MockerController::getInstance()->buildMethodData('$info'); + \$obj = \\MaplePHP\\Unitary\\Mocker\\MockerController::getInstance()->buildMethodData('$safeJson', true); \$data = \\MaplePHP\\Unitary\\Mocker\\MockerController::getDataItem(\$obj->mocker, \$obj->name); {$returnValue} } @@ -293,14 +304,15 @@ protected function getReturnType(ReflectionMethod $method): array $types[] = $returnType->getName(); } elseif ($returnType instanceof ReflectionUnionType) { foreach ($returnType->getTypes() as $type) { - if(method_exists($type, "getName")) { + if (method_exists($type, "getName")) { $types[] = $type->getName(); } } } elseif ($returnType instanceof ReflectionIntersectionType) { $intersect = array_map( - fn ($type) => $type->getName(), $returnType->getTypes() + fn ($type) => $type->getName(), + $returnType->getTypes() ); $types[] = $intersect; } @@ -308,7 +320,7 @@ protected function getReturnType(ReflectionMethod $method): array if (!in_array("mixed", $types) && $returnType && $returnType->allowsNull()) { $types[] = "null"; } - return $types; + return array_unique($types); } /** diff --git a/src/Mocker/MockerController.php b/src/Mocker/MockerController.php index 27c8caa..ea2c2b9 100644 --- a/src/Mocker/MockerController.php +++ b/src/Mocker/MockerController.php @@ -31,15 +31,15 @@ public static function getInstance(): self public static function getData(string $mockIdentifier): array|bool { $data = isset(self::$data[$mockIdentifier]) ? self::$data[$mockIdentifier] : false; - if(!is_array($data)) { + if (!is_array($data)) { return false; } return $data; } - + /** * Get specific data item by mock identifier and method name - * + * * @param string $mockIdentifier The identifier of the mock * @param string $method The method name to retrieve * @return mixed Returns the data item if found, false otherwise @@ -48,7 +48,7 @@ public static function getDataItem(string $mockIdentifier, string $method): mixe { return self::$data[$mockIdentifier][$method] ?? false; } - + /** * Add or update data for a specific mock method @@ -61,7 +61,7 @@ public static function getDataItem(string $mockIdentifier, string $method): mixe */ public static function addData(string $mockIdentifier, string $method, string $key, mixed $value): void { - if(isset(self::$data[$mockIdentifier][$method])) { + if (isset(self::$data[$mockIdentifier][$method])) { self::$data[$mockIdentifier][$method]->{$key} = $value; } } @@ -73,10 +73,11 @@ public static function addData(string $mockIdentifier, string $method, string $k * @param string $method JSON string containing mock method data * @return object Decoded method data object with updated count if applicable */ - public function buildMethodData(string $method): object + public function buildMethodData(string $method, bool $isBase64Encoded = false): object { + $method = $isBase64Encoded ? base64_decode($method) : $method; $data = (object)json_decode($method); - if(isset($data->mocker) && isset($data->name)) { + if (isset($data->mocker) && isset($data->name)) { $mocker = (string)$data->mocker; $name = (string)$data->name; if (empty(self::$data[$mocker][$name])) { diff --git a/src/TestCase.php b/src/TestCase.php index 8a2d83d..0503f9c 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -4,9 +4,6 @@ namespace MaplePHP\Unitary; -use BadMethodCallException; -use Closure; -use ErrorException; use MaplePHP\DTO\Format\Str; use MaplePHP\DTO\Traverse; use MaplePHP\Unitary\Mocker\MethodPool; @@ -15,10 +12,14 @@ use MaplePHP\Validate\Validator; use MaplePHP\Validate\ValidationChain; use ReflectionClass; -use ReflectionException; use ReflectionMethod; -use RuntimeException; use Throwable; +use Exception; +use ReflectionException; +use RuntimeException; +use BadMethodCallException; +use ErrorException; +use Closure; final class TestCase { @@ -172,7 +173,7 @@ public function deferValidation(Closure $validation): void * @return $this * @throws ErrorException */ - public function add(mixed $expect, array|Closure $validation, ?string $message = null): static + public function add(mixed $expect, array|Closure $validation, ?string $message = null): TestCase { return $this->expectAndValidate($expect, $validation, $message); } @@ -206,7 +207,7 @@ public function __construct(string $class, array $args = []) * @param Closure|null $validate * @param array $args * @return T - * @throws ReflectionException + * @throws Exception */ public function mock(string $class, ?Closure $validate = null, array $args = []): mixed { @@ -230,12 +231,13 @@ public function mock(string $class, ?Closure $validate = null, array $args = []) * @param Mocker $mocker The mocker instance containing the mock object * @param Closure $validate The closure containing validation rules * @return void + * @throws ErrorException */ private function prepareValidation(Mocker $mocker, Closure $validate): void { $pool = $mocker->getMethodPool(); $fn = $validate->bindTo($pool); - if(is_null($fn)) { + if (is_null($fn)) { throw new ErrorException("A callable Closure could not be bound to the method pool!"); } $fn($pool); @@ -254,12 +256,13 @@ private function prepareValidation(Mocker $mocker, Closure $validate): void * @param MethodPool $pool The pool containing method expectations * @return array An array of validation errors indexed by method name * @throws ErrorException + * @throws Exception */ private function runValidation(Mocker $mocker, MethodPool $pool): array { $error = []; $data = MockerController::getData($mocker->getMockedClassName()); - if(!is_array($data)) { + if (!is_array($data)) { throw new ErrorException("Could not get data from mocker!"); } foreach ($data as $row) { @@ -299,7 +302,9 @@ private function validateRow(object $row, MethodPool $pool): array $currentValue = $row->{$property}; if (is_array($value)) { - assert(is_array($currentValue), 'The $currentValue variable is not!'); + if (!is_array($currentValue)) { + throw new ErrorException("The $property property is not an array!"); + } $validPool = $this->validateArrayValue($value, $currentValue); $valid = $validPool->isValid(); $this->compareFromValidCollection($validPool, $value, $currentValue); @@ -335,7 +340,7 @@ private function validateArrayValue(array $value, mixed $currentValue): Validati foreach ($value as $method => $args) { if (is_int($method)) { foreach ($args as $methodB => $argsB) { - if(is_array($argsB) && count($argsB) >= 2) { + if (is_array($argsB) && count($argsB) >= 2) { $validPool ->mapErrorToKey((string)$argsB[0]) ->mapErrorValidationName((string)$argsB[1]) @@ -380,8 +385,8 @@ protected function mapValueToCollectionError(array $error, array $value): array { foreach ($value as $item) { foreach ($item as $value) { - if (isset($error[$value[0]])) { - $error[$value[0]] = $value[2]; + if (isset($value[0]) && isset($value[2]) && isset($error[(string)$value[0]])) { + $error[(string)$value[0]] = $value[2]; } } } @@ -395,23 +400,32 @@ protected function mapValueToCollectionError(array $error, array $value): array * and converts them into individual TestUnit instances. If a validation fails, * it increases the internal failure count and stores the test details for later reporting. * - * @return TestUnit[] A list of TestUnit results from the deferred validations. + * @return array A list of TestUnit results from the deferred validations. * @throws ErrorException If any validation logic throws an error during execution. */ public function runDeferredValidations(): array { foreach ($this->deferredValidation as $row) { + + if (!isset($row['call']) || !is_callable($row['call'])) { + throw new ErrorException("The validation call is not callable!"); + } + /** @var callable $row['call'] */ $error = $row['call'](); foreach ($error as $method => $arr) { $test = new TestUnit("Mock method \"$method\" failed"); - if (is_array($row['trace'] ?? "")) { + if (isset($row['trace']) && is_array($row['trace'])) { $test->setCodeLine($row['trace']); } + foreach ($arr as $data) { - $test->setUnit($data['valid'], $data['property'], [], [ + $obj = new Traverse($data); + $isValid = $obj->valid->toBool(); + /** @var array{expectedValue: mixed, currentValue: mixed} $data */ + $test->setUnit($isValid, $obj->propert->acceptType(['string', 'closure', 'null']), [], [ $data['expectedValue'], $data['currentValue'] ]); - if (!$data['valid']) { + if (!$isValid) { $this->count++; } } @@ -578,6 +592,7 @@ protected function valid(mixed $value): Validator */ public function listAllProxyMethods(string $class, ?string $prefixMethods = null, bool $isolateClass = false): void { + /** @var class-string $class */ $reflection = new ReflectionClass($class); $traitMethods = $isolateClass ? $this->getAllTraitMethods($reflection) : []; diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index c3abe47..d78dfb7 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -79,7 +79,8 @@ public function registerUser(string $email, string $name = "Daniel"): void { $unit = new Unit(); $unit->group("Unitary test 2", function (TestCase $inst) use($unit) { - $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { + /* + $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { $pool->method("addBCC") ->paramIsType(0, "striwng") ->paramHasDefault(1, "Daniwel") @@ -87,6 +88,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { ->count(1); }, ["Arg 1"]); $mock->addBCC("World"); + */ }); From 1f240f148c2f47b8b6738e3b1eda74c7bfcf659f Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sat, 3 May 2025 00:47:47 +0200 Subject: [PATCH 18/22] Allow setting mock constructor args --- src/Mocker/Mocker.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php index 23063fc..575e597 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/Mocker.php @@ -200,11 +200,14 @@ protected function generateMockMethodOverrides(string $mockClassName): string $methodItem = $this->getMethodPool()->get($methodName); $types = $this->getReturnType($method); $returnValue = $this->getReturnValue($types, $method, $methodItem); + $paramList = $this->generateMethodSignature($method); if($method->isConstructor()) { $types = []; $returnValue = ""; + if(count($this->constructorArgs) === 0) { + $paramList = ""; + } } - $paramList = $this->generateMethodSignature($method); $returnType = ($types) ? ': ' . implode('|', $types) : ''; $modifiersArr = Reflection::getModifierNames($method->getModifiers()); $modifiers = implode(" ", $modifiersArr); From eeb68e21e9b534ceea6be6fcbdda0469de68a253 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 4 May 2025 00:24:16 +0200 Subject: [PATCH 19/22] Fix validation count for mock Update README.md Add help command --- README.md | 207 ++--------------------------------- src/Handlers/FileHandler.php | 3 + src/Mocker/Mocker.php | 6 +- src/TestCase.php | 18 ++- src/Unit.php | 60 +++++++++- tests/unitary-unitary.php | 15 +-- 6 files changed, 91 insertions(+), 218 deletions(-) diff --git a/README.md b/README.md index c0811f5..a569351 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ PHP Unitary is a **user-friendly** and robust unit testing library designed to make writing and running tests for your PHP code easy. With an intuitive CLI interface that works on all platforms and robust validation options, Unitary makes it easy for you as a developer to ensure your code is reliable and functions as intended. ![Prompt demo](http://wazabii.se/github-assets/maplephp-unitary.png) +_Do you like the CLI theme? [Download it here](https://github.com/MaplePHP/DarkBark)_ + ### Syntax You Will Love ```php @@ -221,6 +223,11 @@ $dispatch = $this->wrap(PaymentProcessor::class)->bind(function ($orderID) use ( ## Configurations +### Show help +```bash +php vendor/bin/unitary --help +``` + ### Show only errors ```bash php vendor/bin/unitary --errors-only @@ -267,201 +274,7 @@ The exclude argument will always be a relative path from the `--path` argument's php vendor/bin/unitary --exclude="./tests/unitary-query-php, tests/otherTests/*, */extras/*" ``` +## Like The CLI Theme? +That’s DarkBark. Dark, quiet, confident, like a rainy-night synthwave playlist for your CLI. -## Validation List - -Each prompt can have validation rules and custom error messages. Validation can be defined using built-in rules (e.g., length, email) or custom functions. Errors can be specified as static messages or dynamic functions based on the error type. - -### Data Type Checks -1. **isString** - - **Description**: Checks if the value is a string. - - **Usage**: `"isString" => []` - -2. **isInt** - - **Description**: Checks if the value is an integer. - - **Usage**: `"isInt" => []` - -3. **isFloat** - - **Description**: Checks if the value is a float. - - **Usage**: `"isFloat" => []` - -4. **isBool** - - **Description**: Checks if the value is a boolean. - - **Usage**: `"isBool" => []` - -5. **isArray** - - **Description**: Checks if the value is an array. - - **Usage**: `"isArray" => []` - -6. **isObject** - - **Description**: Checks if the value is an object. - - **Usage**: `"isObject" => []` - -7. **isFile** - - **Description**: Checks if the value is a valid file. - - **Usage**: `"isFile" => []` - -8. **isDir** - - **Description**: Checks if the value is a valid directory. - - **Usage**: `"isDir" => []` - -9. **isResource** - - **Description**: Checks if the value is a valid resource. - - **Usage**: `"isResource" => []` - -10. **number** - - **Description**: Checks if the value is numeric. - - **Usage**: `"number" => []` - -### Equality and Length Checks -11. **equal** - - **Description**: Checks if the value is equal to a specified value. - - **Usage**: `"equal" => ["someValue"]` - -12. **notEqual** - - **Description**: Checks if the value is not equal to a specified value. - - **Usage**: `"notEqual" => ["someValue"]` - -13. **length** - - **Description**: Checks if the string length is between a specified start and end length. - - **Usage**: `"length" => [1, 200]` - -14. **equalLength** - - **Description**: Checks if the string length is equal to a specified length. - - **Usage**: `"equalLength" => [10]` - -### Numeric Range Checks -15. **min** - - **Description**: Checks if the value is greater than or equal to a specified minimum. - - **Usage**: `"min" => [10]` - -16. **max** - - **Description**: Checks if the value is less than or equal to a specified maximum. - - **Usage**: `"max" => [100]` - -17. **positive** - - **Description**: Checks if the value is a positive number. - - **Usage**: `"positive" => []` - -18. **negative** - - **Description**: Checks if the value is a negative number. - - **Usage**: `"negative" => []` - -### String and Pattern Checks -19. **pregMatch** - - **Description**: Validates if the value matches a given regular expression pattern. - - **Usage**: `"pregMatch" => ["a-zA-Z"]` - -20. **atoZ (lower and upper)** - - **Description**: Checks if the value consists of characters between `a-z` or `A-Z`. - - **Usage**: `"atoZ" => []` - -21. **lowerAtoZ** - - **Description**: Checks if the value consists of lowercase characters between `a-z`. - - **Usage**: `"lowerAtoZ" => []` - -22. **upperAtoZ** - - **Description**: Checks if the value consists of uppercase characters between `A-Z`. - - **Usage**: `"upperAtoZ" => []` - -23. **hex** - - **Description**: Checks if the value is a valid hex color code. - - **Usage**: `"hex" => []` - -24. **email** - - **Description**: Validates email addresses. - - **Usage**: `"email" => []` - -25. **url** - - **Description**: Checks if the value is a valid URL (http|https is required). - - **Usage**: `"url" => []` - -26. **phone** - - **Description**: Validates phone numbers. - - **Usage**: `"phone" => []` - -27. **zip** - - **Description**: Validates ZIP codes within a specified length range. - - **Usage**: `"zip" => [5, 9]` - -28. **domain** - - **Description**: Checks if the value is a valid domain. - - **Usage**: `"domain" => [true]` - -29. **dns** - - **Description**: Checks if the host/domain has a valid DNS record (A, AAAA, MX). - - **Usage**: `"dns" => []` - -30. **matchDNS** - - **Description**: Matches DNS records by searching for a specific type and value. - - **Usage**: `"matchDNS" => [DNS_A]` - -31. **lossyPassword** - - **Description**: Validates a password with allowed characters `[a-zA-Z\d$@$!%*?&]` and a minimum length. - - **Usage**: `"lossyPassword" => [8]` - -32. **strictPassword** - - **Description**: Validates a strict password with specific character requirements and a minimum length. - - **Usage**: `"strictPassword" => [8]` - -### Required and Boolean-Like Checks -33. **required** - - **Description**: Checks if the value is not empty (e.g., not `""`, `0`, `NULL`). - - **Usage**: `"required" => []` - -34. **isBoolVal** - - **Description**: Checks if the value is a boolean-like value (e.g., "on", "yes", "1", "true"). - - **Usage**: `"isBoolVal" => []` - -35. **hasValue** - - **Description**: Checks if the value itself is interpreted as having value (e.g., 0 is valid). - - **Usage**: `"hasValue" => []` - -36. **isNull** - - **Description**: Checks if the value is null. - - **Usage**: `"isNull" => []` - -### Date and Time Checks -37. **date** - - **Description**: Checks if the value is a valid date with the specified format. - - **Usage**: `"date" => ["Y-m-d"]` - -38. **dateTime** - - **Description**: Checks if the value is a valid date and time with the specified format. - - **Usage**: `"dateTime" => ["Y-m-d H:i"]` - -39. **time** - - **Description**: Checks if the value is a valid time with the specified format. - - **Usage**: `"time" => ["H:i"]` - -40. **age** - - **Description**: Checks if the value represents an age equal to or greater than the specified minimum. - - **Usage**: `"age" => [18]` - -### Version Checks -41. **validVersion** - - **Description**: Checks if the value is a valid version number. - - **Usage**: `"validVersion" => [true]` - -42. **versionCompare** - - **Description**: Validates and compares if a version is equal/more/equalMore/less than a specified version. - - **Usage**: `"versionCompare" => ["1.0.0", ">="]` - -### Logical Checks -43. **oneOf** - - **Description**: Validates if one of the provided conditions is met. - - **Usage**: `"oneOf" => [["length", [1, 200]], "email"]` - -44. **allOf** - - **Description**: Validates if all the provided conditions are met. - - **Usage**: `"allOf" => [["length", [1, 200]], "email"]` - -### Additional Validations - -45. **creditCard** - - **Description**: Validates credit card numbers. - - **Usage**: `"creditCard" => []` - -56. **vatNumber** - - **Description**: Validates Swedish VAT numbers. - - **Usage**: `"vatNumber" => []` +[Download it here](https://github.com/MaplePHP/DarkBark) \ No newline at end of file diff --git a/src/Handlers/FileHandler.php b/src/Handlers/FileHandler.php index f77472c..2acc8dd 100755 --- a/src/Handlers/FileHandler.php +++ b/src/Handlers/FileHandler.php @@ -17,6 +17,7 @@ final class FileHandler implements HandlerInterface /** * Construct the file handler * The handler will pass stream to a file + * * @param string $file */ public function __construct(string $file) @@ -29,6 +30,7 @@ public function __construct(string $file) /** * Access the command stream + * * @return Command */ public function getCommand(): Command @@ -39,6 +41,7 @@ public function getCommand(): Command /** * Execute the handler * This will automatically be called inside the Unit execution + * * @return void */ public function execute(): void diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php index 575e597..f08c167 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/Mocker.php @@ -184,11 +184,9 @@ protected function generateMockMethodOverrides(string $mockClassName): string { $overrides = ''; foreach ($this->methods as $method) { - if (!($method instanceof ReflectionMethod)) { throw new Exception("Method is not a ReflectionMethod"); } - if ($method->isFinal()) { continue; } @@ -221,11 +219,12 @@ protected function generateMockMethodOverrides(string $mockClassName): string if ($info === false) { throw new RuntimeException('JSON encoding failed: ' . json_last_error_msg(), json_last_error()); } - MockerController::getInstance()->buildMethodData($info); + MockerController::getInstance()->buildMethodData($info); if ($methodItem) { $returnValue = $this->generateWrapperReturn($methodItem->getWrap(), $methodName, $returnValue); } + $safeJson = base64_encode($info); $overrides .= " $modifiers function $methodName($paramList){$returnType} @@ -236,7 +235,6 @@ protected function generateMockMethodOverrides(string $mockClassName): string } "; } - return $overrides; } diff --git a/src/TestCase.php b/src/TestCase.php index 0503f9c..1045f92 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -156,7 +156,7 @@ protected function expectAndValidate( public function deferValidation(Closure $validation): void { // This will add a cursor to the possible line and file where the error occurred - $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]; $this->deferredValidation[] = [ "trace" => $trace, "call" => $validation @@ -179,7 +179,11 @@ public function add(mixed $expect, array|Closure $validation, ?string $message = } /** - * Init a test wrapper + * initialize a test wrapper + * + * NOTICE: When mocking a class with required constructor arguments, those arguments must be + * specified in the mock initialization method or it will fail. This is because the mock + * creates and simulates an actual instance of the original class with its real constructor. * * @param string $class * @param array $args @@ -266,7 +270,7 @@ private function runValidation(Mocker $mocker, MethodPool $pool): array throw new ErrorException("Could not get data from mocker!"); } foreach ($data as $row) { - if (is_object($row) && isset($row->name)) { + if (is_object($row) && isset($row->name) && $pool->has($row->name)) { $error[(string)$row->name] = $this->validateRow($row, $pool); } } @@ -410,22 +414,24 @@ public function runDeferredValidations(): array if (!isset($row['call']) || !is_callable($row['call'])) { throw new ErrorException("The validation call is not callable!"); } + /** @var callable $row['call'] */ $error = $row['call'](); + $hasValidated = []; foreach ($error as $method => $arr) { $test = new TestUnit("Mock method \"$method\" failed"); if (isset($row['trace']) && is_array($row['trace'])) { $test->setCodeLine($row['trace']); } - foreach ($arr as $data) { $obj = new Traverse($data); $isValid = $obj->valid->toBool(); /** @var array{expectedValue: mixed, currentValue: mixed} $data */ - $test->setUnit($isValid, $obj->propert->acceptType(['string', 'closure', 'null']), [], [ + $test->setUnit($isValid, $obj->property->acceptType(['string', 'closure', 'null']), [], [ $data['expectedValue'], $data['currentValue'] ]); - if (!$isValid) { + if (!isset($hasValidated[$method]) && !$isValid) { + $hasValidated[$method] = true; $this->count++; } } diff --git a/src/Unit.php b/src/Unit.php index b0238fb..fa98f4b 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -7,12 +7,11 @@ use Closure; use ErrorException; use Exception; -use MaplePHP\Unitary\Mocker\MockerController; -use RuntimeException; -use MaplePHP\Unitary\Handlers\HandlerInterface; use MaplePHP\Http\Interfaces\StreamInterface; use MaplePHP\Prompts\Command; -use Throwable; +use MaplePHP\Prompts\Themes\Blocks; +use MaplePHP\Unitary\Handlers\HandlerInterface; +use RuntimeException; class Unit { @@ -196,6 +195,9 @@ public function performance(Closure $func, ?string $title = null): void */ public function execute(): bool { + + $this->help(); + if ($this->executed || !$this->createValidate()) { return false; } @@ -489,6 +491,56 @@ public static function isSuccessful(): bool return (self::$totalPassedTests !== self::$totalTests); } + /** + * Display help information for the Unitary testing tool + * Shows usage instructions, available options and examples + * Only displays if --help argument is provided + * + * @return void True if help was displayed, false otherwise + */ + private function help(): void + { + if (self::getArgs("help") !== false) { + + $blocks = new Blocks($this->command); + $blocks->addHeadline("Unitary - Help"); + $blocks->addSection("Usage", "php vendor/bin/unitary [options]"); + + $blocks->addSection("Options", function(Blocks $inst) { + $inst = $inst + ->addOption("help", "Show this help message") + ->addOption("show=", "Run a specific test by hash or manual test name") + ->addOption("errors-only", "Show only failing tests and skip passed test output") + ->addOption("path=", "Specify test path (absolute or relative)") + ->addOption("exclude=", "Exclude files or directories (comma-separated, relative to --path)"); + return $inst; + }); + + $blocks->addSection("Examples", function(Blocks $inst) { + $inst = $inst + ->addExamples( + "php vendor/bin/unitary", + "Run all tests in the default path (./tests)" + )->addExamples( + "php vendor/bin/unitary --show=b0620ca8ef6ea7598eaed56a530b1983", + "Run the test with a specific hash ID" + )->addExamples( + "php vendor/bin/unitary --errors-only", + "Run all tests in the default path (./tests)" + )->addExamples( + "php vendor/bin/unitary --show=maplePHPRequest", + "Run a manually named test case" + )->addExamples( + 'php vendor/bin/unitary --path="tests/" --exclude="tests/legacy/*,*/extras/*"', + 'Run all tests under "tests/" excluding specified directories' + ) + ; + return $inst; + }); + exit(0); + } + } + /** * DEPRECATED: Not used anymore * @return $this diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index d78dfb7..aaed9c4 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -78,18 +78,19 @@ public function registerUser(string $email, string $name = "Daniel"): void { $unit = new Unit(); $unit->group("Unitary test 2", function (TestCase $inst) use($unit) { - - /* $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { $pool->method("addBCC") - ->paramIsType(0, "striwng") - ->paramHasDefault(1, "Daniwel") + ->isAbstract() + ->paramIsType(0, "string") + ->paramHasDefault(1, "Daniel") + ->paramIsOptional(0) ->paramIsReference(1) ->count(1); - }, ["Arg 1"]); - $mock->addBCC("World"); - */ + $pool->method("test") + ->count(1); + }); + $mock->addBCC("World"); }); /* From 84fc6ea59d59355867648b6140a1450499dbc4ed Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 4 May 2025 14:01:47 +0200 Subject: [PATCH 20/22] Add Default data type mock --- src/Mocker/Mocker.php | 72 +++++++++----- src/TestUtils/DataTypeMock.php | 167 +++++++++++++++++++++++++++++++++ tests/unitary-unitary.php | 20 +++- 3 files changed, 230 insertions(+), 29 deletions(-) create mode 100644 src/TestUtils/DataTypeMock.php diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php index f08c167..c8d776f 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/Mocker.php @@ -9,8 +9,10 @@ namespace MaplePHP\Unitary\Mocker; +use ArrayIterator; use Closure; use Exception; +use MaplePHP\Unitary\TestUtils\DataTypeMock; use Reflection; use ReflectionClass; use ReflectionIntersectionType; @@ -32,6 +34,8 @@ final class Mocker protected array $methods; protected array $methodList = []; protected static ?MethodPool $methodPool = null; + protected array $defaultArguments = []; + private DataTypeMock $dataTypeMock; /** * @param string $className @@ -43,6 +47,7 @@ public function __construct(string $className, array $args = []) /** @var class-string $className */ $this->reflection = new ReflectionClass($className); + $this->dataTypeMock = new DataTypeMock(); /* // Auto fill the Constructor args! $test = $this->reflection->getConstructor(); @@ -88,6 +93,25 @@ public function getMockedClassName(): string return $this->mockClassName; } + /** + * Sets a custom mock value for a specific data type. The mock value can be bound to a specific method + * or used as a global default for the data type. + * + * @param string $dataType The data type to mock (e.g., 'int', 'string', 'bool') + * @param mixed $value The value to use when mocking this data type + * @param string|null $bindToMethod Optional method name to bind this mock value to + * @return self Returns the current instance for method chaining + */ + public function mockDataType(string $dataType, mixed $value, ?string $bindToMethod = null): self + { + if($bindToMethod) { + $this->dataTypeMock = $this->dataTypeMock->withCustomBoundDefault($bindToMethod, $dataType, $value); + } else { + $this->dataTypeMock = $this->dataTypeMock->withCustomDefault($dataType, $value); + } + return $this; + } + /** * Executes the creation of a dynamic mock class and returns an instance of the mock. * @@ -333,22 +357,33 @@ protected function getReturnType(ReflectionMethod $method): array */ protected function getMockValueForType(string $typeName, mixed $method, mixed $value = null, bool $nullable = false): ?string { + + $dataTypeName = strtolower($typeName); if (!is_null($value)) { - return "return " . var_export($value, true) . ";"; + return "return " . DataTypeMock::exportValue($value) . ";"; + } + + $methodName = ($method instanceof ReflectionMethod) ? $method->getName() : null; + + /* + $this->dataTypeMock = $this->dataTypeMock->withCustomDefault('int', $value); + if($method instanceof ReflectionMethod) { + $this->dataTypeMock = $this->dataTypeMock->withCustomBoundDefault($method->getName(), 'int', $value); } + */ $mock = match ($dataTypeName) { - 'int', 'integer' => "return 123456;", - 'float', 'double' => "return 3.14;", - 'string' => "return 'mockString';", - 'bool', 'boolean' => "return true;", - 'array' => "return ['item'];", - 'object' => "return (object)['item'];", - 'resource' => "return fopen('php://memory', 'r+');", - 'callable' => "return fn() => 'called';", - 'iterable' => "return new ArrayIterator(['a', 'b']);", - 'null' => "return null;", + 'int', 'integer' => "return " . $this->dataTypeMock->getDataTypeValue('int', $methodName) . ";", + 'float', 'double' => "return " . $this->dataTypeMock->getDataTypeValue('float', $methodName) . ";", + 'string' => "return " . $this->dataTypeMock->getDataTypeValue('string', $methodName) . ";", + 'bool', 'boolean' => "return " . $this->dataTypeMock->getDataTypeValue('bool', $methodName) . ";", + 'array' => "return " . $this->dataTypeMock->getDataTypeValue('array', $methodName) . ";", + 'object' => "return " . $this->dataTypeMock->getDataTypeValue('object', $methodName) . ";", + 'resource' => "return " . $this->dataTypeMock->getDataTypeValue('resource', $methodName) . ";", + 'callable' => "return " . $this->dataTypeMock->getDataTypeValue('callable', $methodName) . ";", + 'iterable' => "return " . $this->dataTypeMock->getDataTypeValue('iterable', $methodName) . ";", + 'null' => "return " . $this->dataTypeMock->getDataTypeValue('null', $methodName) . ";", 'void' => "", 'self' => (is_object($method) && method_exists($method, "isStatic") && $method->isStatic()) ? 'return new self();' : 'return $this;', /** @var class-string $typeName */ @@ -360,20 +395,6 @@ protected function getMockValueForType(string $typeName, mixed $method, mixed $v return $nullable && rand(0, 1) ? null : $mock; } - /** - * Will return a streamable content - * - * @param mixed $resourceValue - * @return string|null - */ - protected function handleResourceContent(mixed $resourceValue): ?string - { - if (!is_resource($resourceValue)) { - return null; - } - return var_export(stream_get_contents($resourceValue), true); - } - /** * Build a method information array from a ReflectionMethod instance * @@ -417,5 +438,4 @@ public function getMethodInfoAsArray(ReflectionMethod $refMethod): array 'fileName' => $refMethod->getFileName(), ]; } - } diff --git a/src/TestUtils/DataTypeMock.php b/src/TestUtils/DataTypeMock.php new file mode 100644 index 0000000..e61e7c7 --- /dev/null +++ b/src/TestUtils/DataTypeMock.php @@ -0,0 +1,167 @@ + 123456, + 'float' => 3.14, + 'string' => "mockString", + 'bool' => true, + 'array' => ['item'], + 'object' => (object)['item'], + 'resource' => "fopen('php://memory', 'r+')", + 'callable' => fn() => 'called', + 'iterable' => new ArrayIterator(['a', 'b']), + 'null' => null, + ], $this->defaultArguments); + } + + /** + * Exports a value to a parsable string representation + * + * @param mixed $value The value to be exported + * @return string The string representation of the value + */ + public static function exportValue(mixed $value): string + { + return var_export($value, true); + + } + + /** + * Creates a new instance with merged default and custom arguments. + * Handles resource type arguments separately by converting them to string content. + * + * @param array $dataTypeArgs Custom arguments to merge with defaults + * @return self New instance with updated arguments + */ + public function withCustomDefaults(array $dataTypeArgs): self + { + $inst = clone $this; + foreach($dataTypeArgs as $key => $value) { + $inst = $this->withCustomDefault($key, $value); + } + return $inst; + } + + + /** + * Sets a custom default value for a specific data type. + * If the value is a resource, it will be converted to its string content. + * + * @param string $dataType The data type to set the custom default for + * @param mixed $value The value to set as default for the data type + * @return self New instance with updated custom default + */ + public function withCustomDefault(string $dataType, mixed $value): self + { + $inst = clone $this; + if(isset($value) && is_resource($value)) { + $value= $this->handleResourceContent($value); + } + $inst->defaultArguments[$dataType] = $value; + return $inst; + } + + /** + * Sets a custom default value for a specific data type with a binding key. + * Creates a new instance with the bound value stored in bindArguments array. + * + * @param string $key The binding key to store the value under + * @param string $dataType The data type to set the custom default for + * @param mixed $value The value to set as default for the data type + * @return self New instance with the bound value + */ + public function withCustomBoundDefault(string $key, string $dataType, mixed $value): self + { + $inst = clone $this; + $tempInst = $this->withCustomDefault($dataType, $value); + $inst->bindArguments[$key][$dataType] = $tempInst->defaultArguments[$dataType]; + return $inst; + } + + /** + * Converts default argument values to their string representations + * using var_export for each value in the default arguments array + * + * @return array Array of stringify default argument values + */ + public function getDataTypeListToString(): array + { + return array_map(fn($value) => self::exportValue($value), $this->getMockValues()); + } + + /** + * Retrieves the string representation of a value for a given data type + * Initializes types' array if not already set + * + * @param string $dataType The data type to get the value for + * @return mixed The string representation of the value for the specified data type + * @throws InvalidArgumentException If the specified data type is invalid + */ + public function getDataTypeValue(string $dataType, ?string $bindKey = null): mixed + { + if(is_string($bindKey) && isset($this->bindArguments[$bindKey][$dataType])) { + return $this->bindArguments[$bindKey][$dataType]; + } + + if(is_null($this->types)) { + $this->types = $this->getDataTypeListToString(); + } + + if(!isset($this->types[$dataType])) { + throw new InvalidArgumentException("Invalid data type: $dataType"); + } + return $this->types[$dataType]; + + } + + /** + * Will return a streamable content + * + * @param mixed $resourceValue + * @return string|null + */ + public function handleResourceContent(mixed $resourceValue): ?string + { + if (!is_resource($resourceValue)) { + return null; + } + return var_export(stream_get_contents($resourceValue), true); + } +} \ No newline at end of file diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index aaed9c4..c7c9429 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -77,7 +77,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { } $unit = new Unit(); -$unit->group("Unitary test 2", function (TestCase $inst) use($unit) { +$unit->group("Unitary test 1", function (TestCase $inst) use($unit) { $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { $pool->method("addBCC") ->isAbstract() @@ -86,12 +86,26 @@ public function registerUser(string $email, string $name = "Daniel"): void { ->paramIsOptional(0) ->paramIsReference(1) ->count(1); + }); + $mock->addBCC("World"); +}); - $pool->method("test") - ->count(1); + +/* + $unit->group("Unitary test 2", function (TestCase $inst) use($unit) { + $mock = $inst->mocker(Mailer::class)->mock(function (MethodPool $pool) use($inst) { + $pool->method("addBCC") + ->paramHasDefault(1, "DanielRonkainen") + ->count(1); }); + + $mock->mockDataType(); $mock->addBCC("World"); }); + */ + + + /* From 4c84d09703bca86fd1cd960cc426a0965d65f18b Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 4 May 2025 16:52:42 +0200 Subject: [PATCH 21/22] Improve mocking and error handling --- src/Mocker/Mocker.php | 4 +- src/TestCase.php | 88 +++++++++++++++++++++++++-------- src/TestUnit.php | 2 +- src/TestUtils/DataTypeMock.php | 18 +++++-- src/Unit.php | 2 +- tests/unitary-unitary.php | 89 +++++++++++++++++++++++++++------- 6 files changed, 157 insertions(+), 46 deletions(-) diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php index c8d776f..22eb427 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/Mocker.php @@ -147,7 +147,6 @@ public static function __set_state(array \$an_array): self eval($code); - /** * @psalm-suppress MixedMethodCall * @psalm-suppress InvalidStringClass @@ -223,6 +222,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string $types = $this->getReturnType($method); $returnValue = $this->getReturnValue($types, $method, $methodItem); $paramList = $this->generateMethodSignature($method); + if($method->isConstructor()) { $types = []; $returnValue = ""; @@ -357,8 +357,6 @@ protected function getReturnType(ReflectionMethod $method): array */ protected function getMockValueForType(string $typeName, mixed $method, mixed $value = null, bool $nullable = false): ?string { - - $dataTypeName = strtolower($typeName); if (!is_null($value)) { return "return " . DataTypeMock::exportValue($value) . ";"; diff --git a/src/TestCase.php b/src/TestCase.php index 1045f92..d8c3745 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -4,6 +4,7 @@ namespace MaplePHP\Unitary; +use MaplePHP\Blunder\BlunderErrorException; use MaplePHP\DTO\Format\Str; use MaplePHP\DTO\Traverse; use MaplePHP\Unitary\Mocker\MethodPool; @@ -21,6 +22,9 @@ use ErrorException; use Closure; +/** + * @template T of object + */ final class TestCase { private mixed $value; @@ -29,9 +33,9 @@ final class TestCase private int $count = 0; private ?Closure $bind = null; private ?string $errorMessage = null; - private array $deferredValidation = []; - + /** @var Mocker */ + private Mocker $mocker; /** * Initialize a new TestCase instance with an optional message. @@ -57,13 +61,23 @@ public function bind(Closure $bind): void /** * Will dispatch the case tests and return them as an array * + * @param self $row * @return array + * @throws BlunderErrorException */ - public function dispatchTest(): array + public function dispatchTest(self &$row): array { + $row = $this; $test = $this->bind; if (!is_null($test)) { - $test($this); + try { + $newInst = $test($this); + } catch (Throwable $e) { + throw new BlunderErrorException($e->getMessage(), $e->getCode()); + } + if ($newInst instanceof self) { + $row = $newInst; + } } return $this->test; } @@ -199,6 +213,39 @@ public function __construct(string $class, array $args = []) }; } + /** + * @param class-string $class + * @param array $args + * @return self + */ + public function withMock(string $class, array $args = []): self + { + $inst = clone $this; + $inst->mocker = new Mocker($class, $args); + return $inst; + } + + /** + * @param Closure|null $validate + * @return T + * @throws ErrorException + * @throws Exception + */ + public function buildMock(?Closure $validate = null): mixed + { + if (is_callable($validate)) { + $this->prepareValidation($this->mocker, $validate); + } + + try { + /** @psalm-suppress MixedReturnStatement */ + return $this->mocker->execute(); + } catch (Throwable $e) { + throw new BlunderErrorException($e->getMessage(), $e->getCode()); + } + } + + /** * Creates and returns an instance of a dynamically generated mock class. * @@ -213,16 +260,16 @@ public function __construct(string $class, array $args = []) * @return T * @throws Exception */ - public function mock(string $class, ?Closure $validate = null, array $args = []): mixed + public function mock(string $class, ?Closure $validate = null, array $args = []) { - $mocker = new Mocker($class, $args); + $this->mocker = new Mocker($class, $args); + return $this->buildMock($validate); + } - if (is_callable($validate)) { - $this->prepareValidation($mocker, $validate); - } - /** @psalm-suppress MixedReturnStatement */ - return $mocker->execute(); + public function getMocker(): Mocker + { + return $this->mocker; } /** @@ -424,15 +471,16 @@ public function runDeferredValidations(): array $test->setCodeLine($row['trace']); } foreach ($arr as $data) { - $obj = new Traverse($data); - $isValid = $obj->valid->toBool(); - /** @var array{expectedValue: mixed, currentValue: mixed} $data */ - $test->setUnit($isValid, $obj->property->acceptType(['string', 'closure', 'null']), [], [ - $data['expectedValue'], $data['currentValue'] - ]); - if (!isset($hasValidated[$method]) && !$isValid) { - $hasValidated[$method] = true; - $this->count++; + // We do not want to validate the return here automatically + if($data['property'] !== "return") { + /** @var array{expectedValue: mixed, currentValue: mixed} $data */ + $test->setUnit($data['valid'], $data['property'], [], [ + $data['expectedValue'], $data['currentValue'] + ]); + if (!isset($hasValidated[$method]) && !$data['valid']) { + $hasValidated[$method] = true; + $this->count++; + } } } $this->test[] = $test; diff --git a/src/TestUnit.php b/src/TestUnit.php index 76de644..a87b1db 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -21,7 +21,6 @@ class TestUnit /** * Initiate the test * - * @param mixed $value * @param string|null $message */ public function __construct(?string $message = null) @@ -68,6 +67,7 @@ public function setUnit( array $args = [], array $compare = [] ): self { + if (!$valid) { $this->valid = false; $this->count++; diff --git a/src/TestUtils/DataTypeMock.php b/src/TestUtils/DataTypeMock.php index e61e7c7..be8d24a 100644 --- a/src/TestUtils/DataTypeMock.php +++ b/src/TestUtils/DataTypeMock.php @@ -30,6 +30,16 @@ class DataTypeMock */ private ?array $bindArguments = null; + private static ?self $inst = null; + + public static function inst(): self + { + if (is_null(self::$inst)) { + self::$inst = new self(); + } + return self::$inst; + } + /** * Returns an array of default arguments for different data types * @@ -42,8 +52,8 @@ public function getMockValues(): array 'float' => 3.14, 'string' => "mockString", 'bool' => true, - 'array' => ['item'], - 'object' => (object)['item'], + 'array' => ['item1', 'item2', 'item3'], + 'object' => (object)['item1' => 'value1', 'item2' => 'value2', 'item3' => 'value3'], 'resource' => "fopen('php://memory', 'r+')", 'callable' => fn() => 'called', 'iterable' => new ArrayIterator(['a', 'b']), @@ -92,7 +102,7 @@ public function withCustomDefault(string $dataType, mixed $value): self { $inst = clone $this; if(isset($value) && is_resource($value)) { - $value= $this->handleResourceContent($value); + $value = $this->handleResourceContent($value); } $inst->defaultArguments[$dataType] = $value; return $inst; @@ -137,7 +147,7 @@ public function getDataTypeListToString(): array public function getDataTypeValue(string $dataType, ?string $bindKey = null): mixed { if(is_string($bindKey) && isset($this->bindArguments[$bindKey][$dataType])) { - return $this->bindArguments[$bindKey][$dataType]; + return self::exportValue($this->bindArguments[$bindKey][$dataType]); } if(is_null($this->types)) { diff --git a/src/Unit.php b/src/Unit.php index fa98f4b..869ea15 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -210,7 +210,7 @@ public function execute(): bool } $errArg = self::getArgs("errors-only"); - $row->dispatchTest(); + $row->dispatchTest($row); $tests = $row->runDeferredValidations(); $color = ($row->hasFailed() ? "brightRed" : "brightBlue"); $flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index c7c9429..02552e7 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -5,6 +5,8 @@ use MaplePHP\Unitary\Unit; use MaplePHP\Validate\ValidationChain; use MaplePHP\Unitary\Mocker\MethodPool; +use MaplePHP\Http\Response; +use MaplePHP\Http\Stream; class Mailer @@ -77,32 +79,85 @@ public function registerUser(string $email, string $name = "Daniel"): void { } $unit = new Unit(); -$unit->group("Unitary test 1", function (TestCase $inst) use($unit) { - $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { - $pool->method("addBCC") - ->isAbstract() - ->paramIsType(0, "string") - ->paramHasDefault(1, "Daniel") - ->paramIsOptional(0) - ->paramIsReference(1) - ->count(1); + + + +$unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { + + + // Quickly mock the Stream class + $stream = $case->mock(Stream::class); + + // Mock with configuration + // + // Notice: this will handle TestCase as immutable, and because of this + // the new instance of TestCase must be return to the group callable below + // + // By passing the mocked Stream class to the Response constructor, we + // will actually also test that the argument has the right data type + $case = $case->withMock(Response::class, [$stream]); + + // We can override all "default" mocking values tide to TestCase Instance + // to use later on in out in the validations, you can also tie the mock + // value to a method + $case->getMocker() + ->mockDataType("string", "myCustomMockStringValue") + ->mockDataType("array", ["myCustomMockArrayItem"]) + ->mockDataType("int", 200, "getStatusCode"); + + // List all default mock values that will be automatically used in + // parameters and return values + //print_r(\MaplePHP\Unitary\TestUtils\DataTypeMock::inst()->getMockValues()); + + $response = $case->buildMock(function (MethodPool $pool) use($stream) { + // Even tho Unitary mocker tries to automatically mock the return type of methods, + // it might fail if the return type is an expected Class instance, then you will + // need to manually set the return type to tell Unitary mocker what class to expect, + // which is in this example a class named "Stream". + // You can do this by either passing the expected class directly into the `return` method + // or even better by mocking the expected class and then passing the mocked class. + $pool->method("getBody")->return($stream); }); - $mock->addBCC("World"); + + $case->validate($response->getHeader("lorem"), function(ValidationChain $inst) { + // Validate against the new default array item value + // If we weren't overriding the default the array would be ['item1', 'item2', 'item3'] + $inst->isInArray(["myCustomMockArrayItem"]); + }); + + $case->validate($response->getStatusCode(), function(ValidationChain $inst) { + // Will validate to the default int data type set above + // and bounded to "getStatusCode" method + $inst->isEqualTo(200); + }); + + $case->validate($response->getProtocolVersion(), function(ValidationChain $inst) { + // MockedValue is the default value that the mocked class will return + // if you do not specify otherwise, either by specify what the method should return + // or buy overrides the default mocking data type values. + $inst->isEqualTo("MockedValue"); + }); + + $case->validate($response->getBody(), function(ValidationChain $inst) { + $inst->isInstanceOf(Stream::class); + }); + + // You need to return a new instance of TestCase for new mocking settings + return $case; }); -/* - $unit->group("Unitary test 2", function (TestCase $inst) use($unit) { - $mock = $inst->mocker(Mailer::class)->mock(function (MethodPool $pool) use($inst) { +$unit->group("Mailer test", function (TestCase $inst) use($unit) { + $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { $pool->method("addBCC") - ->paramHasDefault(1, "DanielRonkainen") + ->paramIsType(0, "string") + ->paramHasDefault(1, "Daniel") + ->paramIsOptional(1) + ->paramIsReference(1) ->count(1); }); - - $mock->mockDataType(); $mock->addBCC("World"); }); - */ From 9e8abaf426b5a700d929b756b61d3a17afb603c4 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Mon, 5 May 2025 09:59:22 +0200 Subject: [PATCH 22/22] Add DTO traverse to value in validate --- src/TestCase.php | 15 +++++++++++---- tests/unitary-unitary.php | 25 +++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/TestCase.php b/src/TestCase.php index d8c3745..d95cbb9 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -73,7 +73,10 @@ public function dispatchTest(self &$row): array try { $newInst = $test($this); } catch (Throwable $e) { - throw new BlunderErrorException($e->getMessage(), $e->getCode()); + if(str_contains($e->getFile(), "eval()")) { + throw new BlunderErrorException($e->getMessage(), $e->getCode()); + } + throw $e; } if ($newInst instanceof self) { $row = $newInst; @@ -105,7 +108,7 @@ public function error(string $message): self public function validate(mixed $expect, Closure $validation): self { $this->expectAndValidate($expect, function (mixed $value, ValidationChain $inst) use ($validation) { - return $validation($inst, $value); + return $validation($inst, new Traverse($value)); }, $this->errorMessage); return $this; @@ -136,8 +139,12 @@ protected function expectAndValidate( if ($validation instanceof Closure) { $listArr = $this->buildClosureTest($validation); foreach ($listArr as $list) { - foreach ($list as $method => $_valid) { - $test->setUnit(false, (string)$method); + if(is_bool($list)) { + $test->setUnit($list, "Validation"); + } else { + foreach ($list as $method => $_valid) { + $test->setUnit(false, (string)$method); + } } } } else { diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 02552e7..407208e 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -1,6 +1,7 @@ group("Advanced App Response Test", function (TestCase $case) use($unit) { + + $stream = $case->mock(Stream::class); + $response = new Response($stream); + + $case->validate($response->getBody()->getContents(), function(ValidationChain $inst) { + $inst->hasResponse(); + }); +}); + $unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { // Quickly mock the Stream class - $stream = $case->mock(Stream::class); + $stream = $case->mock(Stream::class, function (MethodPool $pool) { + $pool->method("getContents") + ->return('{"test":"test"}'); + }); // Mock with configuration // @@ -119,6 +133,13 @@ public function registerUser(string $email, string $name = "Daniel"): void { $pool->method("getBody")->return($stream); }); + + $case->validate($response->getBody()->getContents(), function(ValidationChain $inst, Traverse $collection) { + $inst->isString(); + $inst->isJson(); + return $collection->strJsonDecode()->test->valid("isString"); + }); + $case->validate($response->getHeader("lorem"), function(ValidationChain $inst) { // Validate against the new default array item value // If we weren't overriding the default the array would be ['item1', 'item2', 'item3'] @@ -128,7 +149,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { $case->validate($response->getStatusCode(), function(ValidationChain $inst) { // Will validate to the default int data type set above // and bounded to "getStatusCode" method - $inst->isEqualTo(200); + $inst->isHttpSuccess(); }); $case->validate($response->getProtocolVersion(), function(ValidationChain $inst) {