From 669546713b22875c7f26bc508b2b02833db954eb Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Tue, 25 Mar 2025 20:13:09 +0100 Subject: [PATCH 01/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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) { From bbb5c125dfc580f70b9ecd9bb56f6b6c6d0b5f05 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Wed, 7 May 2025 21:34:24 +0200 Subject: [PATCH 23/53] Fix Mocking and MethodPool inheritance Minor code quality improvements --- README.md | 8 +-- src/Mocker/MethodItem.php | 107 +++++++++++++++++++++++++------- src/Mocker/MethodPool.php | 23 +++++-- src/Mocker/Mocker.php | 77 +++++++++++++---------- src/Mocker/MockerController.php | 17 +++-- src/TestCase.php | 18 ++++-- tests/unitary-unitary.php | 47 ++++++++++---- 7 files changed, 206 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index a569351..2f3e153 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ 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'); + $pool->method("getFromEmail")->willReturn('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! @@ -176,7 +176,7 @@ $unit->group("Testing user service", function (TestCase $inst) { ### 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. +validate that the method is built and loaded correctly. ```php use \MaplePHP\Unitary\Mocker\MethodPool; @@ -187,11 +187,11 @@ $unit->group("Unitary test", function (TestCase $inst) { ->isPublic() ->hasDocComment() ->hasReturnType() - ->count(1); + ->isTimes(1); $pool->method("addBCC") ->isPublic() - ->count(3); + ->isTimes(3); }); $service = new UserService($mock); }); diff --git a/src/Mocker/MethodItem.php b/src/Mocker/MethodItem.php index 6a29a00..a3723ec 100644 --- a/src/Mocker/MethodItem.php +++ b/src/Mocker/MethodItem.php @@ -13,7 +13,7 @@ final class MethodItem { private ?Mocker $mocker; public mixed $return = null; - public ?int $count = null; + public int|array|null $called = null; public ?string $class = null; public ?string $name = null; @@ -33,6 +33,7 @@ final class MethodItem public ?int $startLine = null; public ?int $endLine = null; public ?string $fileName = null; + public bool $keepOriginal = false; protected bool $hasReturn = false; protected ?Closure $wrapper = null; @@ -42,10 +43,12 @@ public function __construct(?Mocker $mocker = null) } /** - * Will create a method wrapper making it possible to mock + * Creates a proxy wrapper around a method to enable integration testing. + * The wrapper allows intercepting and modifying method behavior during tests. * - * @param Closure $call - * @return $this + * @param Closure $call The closure to be executed as the wrapper function + * @return $this Method chain + * @throws BadMethodCallException When mocker is not set */ public function wrap(Closure $call): self { @@ -84,15 +87,73 @@ public function hasReturn(): bool return $this->hasReturn; } + /** + * Preserve the original method functionality instead of mocking it. + * When this is set, the method will execute its original implementation instead of any mock behavior. + * + * @return $this Method chain + */ + public function keepOriginal(): self + { + $inst = $this; + $inst->keepOriginal = true; + return $inst; + } + /** * Check if a method has been called x times - * @param int $count + * + * @param int $times * @return $this */ - public function count(int $count): self + public function called(int $times): self { $inst = $this; - $inst->count = $count; + $inst->called = $times; + return $inst; + } + + /** + * Check if a method has been called x times + * + * @return $this + */ + public function hasBeenCalled(): self + { + $inst = $this; + $inst->called = [ + "isAtLeast" => [1], + ]; + return $inst; + } + + /** + * Check if a method has been called x times + * + * @param int $times + * @return $this + */ + public function calledAtLeast(int $times): self + { + $inst = $this; + $inst->called = [ + "isAtLeast" => [$times], + ]; + return $inst; + } + + /** + * Check if a method has been called x times + * + * @param int $times + * @return $this + */ + public function calledAtMost(int $times): self + { + $inst = $this; + $inst->called = [ + "isAtMost" => [$times], + ]; return $inst; } @@ -102,7 +163,7 @@ public function count(int $count): self * @param mixed $value * @return $this */ - public function return(mixed $value): self + public function willReturn(mixed $value): self { $inst = $this; $inst->hasReturn = true; @@ -116,7 +177,7 @@ public function return(mixed $value): self * @param string $class * @return self */ - public function class(string $class): self + public function hasClass(string $class): self { $inst = $this; $inst->class = $class; @@ -129,7 +190,7 @@ public function class(string $class): self * @param string $name * @return self */ - public function name(string $name): self + public function hasName(string $name): self { $inst = $this; $inst->name = $name; @@ -238,7 +299,7 @@ public function hasReturnType(): self * @param string $type * @return self */ - public function returnType(string $type): self + public function isReturnType(string $type): self { $inst = $this; $inst->returnType = $type; @@ -317,7 +378,7 @@ public function hasNotParams(): self * @param int $length * @return $this */ - public function hasParamsCount(int $length): self + public function paramsHasCount(int $length): self { $inst = $this; $inst->parameters[] = [ @@ -440,41 +501,41 @@ public function hasDocComment(): self } /** - * Set the starting line number of the method. + * Set the file name where the method is declared. * - * @param int $line + * @param string $file * @return self */ - public function startLine(int $line): self + public function hasFileName(string $file): self { $inst = $this; - $inst->startLine = $line; + $inst->fileName = $file; return $inst; } /** - * Set the ending line number of the method. + * Set the starting line number of the method. * * @param int $line * @return self */ - public function endLine(int $line): self + public function startLine(int $line): self { $inst = $this; - $inst->endLine = $line; + $inst->startLine = $line; return $inst; } /** - * Set the file name where the method is declared. + * Set the ending line number of the method. * - * @param string $file + * @param int $line * @return self */ - public function fileName(string $file): self + public function endLine(int $line): self { $inst = $this; - $inst->fileName = $file; + $inst->endLine = $line; return $inst; } } diff --git a/src/Mocker/MethodPool.php b/src/Mocker/MethodPool.php index 6a3a007..a45b5f4 100644 --- a/src/Mocker/MethodPool.php +++ b/src/Mocker/MethodPool.php @@ -6,13 +6,24 @@ class MethodPool { private ?Mocker $mocker = null; /** @var array */ - private array $methods = []; + private static array $methods = []; public function __construct(?Mocker $mocker = null) { $this->mocker = $mocker; } + /** + * Access method pool + * @param string $class + * @param string $name + * @return MethodItem|null + */ + public static function getMethod(string $class, string $name): ?MethodItem + { + return self::$methods[$class][$name] ?? null; + } + /** * This method adds a new method to the pool with a given name and * returns the corresponding MethodItem instance. @@ -22,8 +33,8 @@ public function __construct(?Mocker $mocker = null) */ public function method(string $name): MethodItem { - $this->methods[$name] = new MethodItem($this->mocker); - return $this->methods[$name]; + self::$methods[$this->mocker->getClassName()][$name] = new MethodItem($this->mocker); + return self::$methods[$this->mocker->getClassName()][$name]; } /** @@ -34,7 +45,7 @@ public function method(string $name): MethodItem */ public function get(string $key): MethodItem|null { - return $this->methods[$key] ?? null; + return self::$methods[$this->mocker->getClassName()][$key] ?? null; } /** @@ -44,7 +55,7 @@ public function get(string $key): MethodItem|null */ public function getAll(): array { - return $this->methods; + return self::$methods; } /** @@ -55,7 +66,7 @@ public function getAll(): array */ public function has(string $name): bool { - return isset($this->methods[$name]); + return isset(self::$methods[$this->mocker->getClassName()][$name]); } } diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php index 22eb427..ce6051c 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/Mocker.php @@ -9,7 +9,6 @@ namespace MaplePHP\Unitary\Mocker; -use ArrayIterator; use Closure; use Exception; use MaplePHP\Unitary\TestUtils\DataTypeMock; @@ -23,8 +22,6 @@ final class Mocker { - //protected object $instance; - //protected array $overrides = []; protected ReflectionClass $reflection; protected string $className; /** @var class-string|null */ @@ -33,8 +30,6 @@ final class Mocker protected array $constructorArgs = []; protected array $methods; protected array $methodList = []; - protected static ?MethodPool $methodPool = null; - protected array $defaultArguments = []; private DataTypeMock $dataTypeMock; /** @@ -46,44 +41,61 @@ public function __construct(string $className, array $args = []) $this->className = $className; /** @var class-string $className */ $this->reflection = new ReflectionClass($className); - $this->dataTypeMock = new DataTypeMock(); + $this->methods = $this->reflection->getMethods(); + $this->constructorArgs = $args; /* // Auto fill the Constructor args! $test = $this->reflection->getConstructor(); $test = $this->generateMethodSignature($test); $param = $test->getParameters(); */ - - $this->methods = $this->reflection->getMethods(); - $this->constructorArgs = $args; } - public function getClassName(): string + /** + * Adds metadata to the mock method, including the mock class name, return value, + * and a flag indicating whether to keep the original method implementation. + * + * @param array $data The base data array to add metadata to + * @param string $mockClassName The name of the mock class + * @param mixed $return The return value to be stored in metadata + * @return array The data array with added metadata + */ + protected function addMockMetadata(array $data, string $mockClassName, mixed $return): array { - return $this->className; + $data['mocker'] = $mockClassName; + $data['return'] = $return; + $data['keepOriginal'] = false; + return $data; } - - public function getClassArgs(): array + + /** + * Gets the fully qualified name of the class being mocked. + * + * @return string The class name that was provided during instantiation + */ + public function getClassName(): string { - return $this->constructorArgs; + return $this->className; } + /** - * Override the default method overrides with your own mock logic and validation rules + * Returns the constructor arguments provided during instantiation. * - * @return MethodPool + * @return array The array of constructor arguments used to create the mock instance */ - public function getMethodPool(): MethodPool + public function getClassArgs(): array { - if (is_null(self::$methodPool)) { - self::$methodPool = new MethodPool($this); - } - return self::$methodPool; + return $this->constructorArgs; } /** - * @throws Exception + * Gets the mock class name generated during mock creation. + * This method should only be called after execute() has been invoked. + * + * @return string The generated mock class name + * @throws Exception If the mock class name has not been set (execute() hasn't been called) */ public function getMockedClassName(): string { @@ -194,7 +206,7 @@ protected function getReturnValue(array $types, mixed $method, ?MethodItem $meth } 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. @@ -218,7 +230,11 @@ protected function generateMockMethodOverrides(string $mockClassName): string $this->methodList[] = $methodName; // The MethodItem contains all items that are validatable - $methodItem = $this->getMethodPool()->get($methodName); + $methodItem = MethodPool::getMethod($this->getClassName(), $methodName); + if($methodItem && $methodItem->keepOriginal) { + continue; + } + $types = $this->getReturnType($method); $returnValue = $this->getReturnValue($types, $method, $methodItem); $paramList = $this->generateMethodSignature($method); @@ -236,8 +252,8 @@ protected function generateMockMethodOverrides(string $mockClassName): string $return = ($methodItem && $methodItem->hasReturn()) ? $methodItem->return : eval($returnValue); $arr = $this->getMethodInfoAsArray($method); - $arr['mocker'] = $mockClassName; - $arr['return'] = $return; + $arr = $this->addMockMetadata($arr, $mockClassName, $return); + $info = json_encode($arr); if ($info === false) { @@ -336,7 +352,7 @@ protected function getReturnType(ReflectionMethod $method): array } elseif ($returnType instanceof ReflectionIntersectionType) { $intersect = array_map( - fn ($type) => $type->getName(), + fn ($type) => $type instanceof ReflectionNamedType ? $type->getName() : (string) $type, $returnType->getTypes() ); $types[] = $intersect; @@ -364,13 +380,6 @@ protected function getMockValueForType(string $typeName, mixed $method, mixed $v $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 " . $this->dataTypeMock->getDataTypeValue('int', $methodName) . ";", 'float', 'double' => "return " . $this->dataTypeMock->getDataTypeValue('float', $methodName) . ";", diff --git a/src/Mocker/MockerController.php b/src/Mocker/MockerController.php index ea2c2b9..6709c36 100644 --- a/src/Mocker/MockerController.php +++ b/src/Mocker/MockerController.php @@ -2,6 +2,10 @@ namespace MaplePHP\Unitary\Mocker; +/** + * A controller class responsible for managing mock data for methods. + * Provides methods to add, retrieve, and track mock data, including support for singleton access. + */ final class MockerController extends MethodPool { private static ?MockerController $instance = null; @@ -9,8 +13,8 @@ final class MockerController extends MethodPool private static array $data = []; /** - * Get singleton instance of MockerController - * Creates new instance if none exists + * Get a singleton instance of MockerController + * Creates a new instance if none exists * * @return static The singleton instance of MockerController */ @@ -48,8 +52,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 * @@ -81,11 +84,13 @@ public function buildMethodData(string $method, bool $isBase64Encoded = false): $mocker = (string)$data->mocker; $name = (string)$data->name; if (empty(self::$data[$mocker][$name])) { - $data->count = 0; + $data->called = 0; self::$data[$mocker][$name] = $data; + // Mocked method has trigger "once"! } else { if (isset(self::$data[$mocker][$name])) { - self::$data[$mocker][$name]->count = (int)self::$data[$mocker][$name]->count + 1; + self::$data[$mocker][$name]->called = (int)self::$data[$mocker][$name]->called + 1; + // Mocked method has trigger "More Than" once! } } } diff --git a/src/TestCase.php b/src/TestCase.php index d95cbb9..143f13f 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -293,7 +293,7 @@ public function getMocker(): Mocker */ private function prepareValidation(Mocker $mocker, Closure $validate): void { - $pool = $mocker->getMethodPool(); + $pool = new MethodPool($mocker); $fn = $validate->bindTo($pool); if (is_null($fn)) { throw new ErrorException("A callable Closure could not be bound to the method pool!"); @@ -357,15 +357,21 @@ private function validateRow(object $row, MethodPool $pool): array continue; } + if(!property_exists($row, $property)) { + throw new ErrorException( + "The mock method meta data property name '$property' is undefined in mock object. " . + "To resolve this either use MockerController::buildMethodData() to add the property dynamically " . + "or define a default value through Mocker::addMockMetadata()" + ); + } $currentValue = $row->{$property}; - if (is_array($value)) { - 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); + + if (is_array($currentValue)) { + $this->compareFromValidCollection($validPool, $value, $currentValue); + } } else { /** @psalm-suppress MixedArgument */ $valid = Validator::value($currentValue)->equal($value); diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 407208e..3aebc2c 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -16,11 +16,16 @@ class Mailer public $bcc = ""; - public function __construct(string $arg1) + public function __construct() { } + public function send() + { + echo $this->sendEmail($this->getFromEmail()); + } + public function sendEmail(string $email, string $name = "daniel"): string { if(!$this->isValidEmail($email)) { @@ -34,9 +39,15 @@ public function isValidEmail(string $email): bool return filter_var($email, FILTER_VALIDATE_EMAIL); } - public function getFromEmail(string $email): string + public function setFromEmail(string $email): self { - return $this->from; + $this->from = $email; + return $this; + } + + public function getFromEmail(): string + { + return !empty($this->from) ? $this->from : "empty email"; } /** @@ -57,10 +68,12 @@ public function addBCC(string $email, &$name = "Daniel"): void public function test(...$params): void { + $this->test2(); } public function test2(): void { + echo "Hello World\n"; } } @@ -82,6 +95,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { $unit = new Unit(); + $unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { $stream = $case->mock(Stream::class); @@ -93,15 +107,25 @@ public function registerUser(string $email, string $name = "Daniel"): void { }); +$unit->group("Advanced Mailer Test", function (TestCase $case) use($unit) { + $mail = $case->mock(Mailer::class, function (MethodPool $pool) { + $pool->method("send")->keepOriginal(); + $pool->method("sendEmail")->keepOriginal(); + }); + $mail->send(); +}); + $unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { // Quickly mock the Stream class $stream = $case->mock(Stream::class, function (MethodPool $pool) { $pool->method("getContents") - ->return('{"test":"test"}'); - }); + ->willReturn('{"test":"test"}') + ->calledAtLeast(1); + $pool->method("fopen")->isPrivate(); + }); // Mock with configuration // // Notice: this will handle TestCase as immutable, and because of this @@ -130,7 +154,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { // 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); + $pool->method("getBody")->willReturn($stream); }); @@ -175,9 +199,12 @@ public function registerUser(string $email, string $name = "Daniel"): void { ->paramHasDefault(1, "Daniel") ->paramIsOptional(1) ->paramIsReference(1) - ->count(1); + ->called(1); + + //$pool->method("test2")->called(1); }); $mock->addBCC("World"); + $mock->test(1); }); @@ -190,11 +217,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() - ->count(0); + ->isPublic(); $pool->method("addBCC") ->isPublic() From 8ba1ce97dfc48a01d02d5565bc35b7f70d805c68 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Fri, 9 May 2025 23:35:02 +0200 Subject: [PATCH 24/53] refactor: replace function null checks with strict comparisons --- src/FileIterator.php | 4 ++-- src/Mocker/MethodItem.php | 2 +- src/Mocker/Mocker.php | 2 +- src/Mocker/MockerController.php | 2 +- src/TestCase.php | 14 +++++++------- src/TestUnit.php | 6 +++--- src/TestUtils/DataTypeMock.php | 4 ++-- src/Unit.php | 12 ++++++------ 8 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/FileIterator.php b/src/FileIterator.php index 057ea99..346e7c0 100755 --- a/src/FileIterator.php +++ b/src/FileIterator.php @@ -47,7 +47,7 @@ public function executeAll(string $directory): void ]); $call = $this->requireUnitFile((string)$file); - if (!is_null($call)) { + if ($call !== null) { $call(); } if (!Unit::hasUnit()) { @@ -181,7 +181,7 @@ private function requireUnitFile(string $file): ?Closure protected function getUnit(): Unit { $unit = Unit::getUnit(); - if (is_null($unit)) { + if ($unit === null) { throw new RuntimeException("The Unit instance has not been initiated."); } return $unit; diff --git a/src/Mocker/MethodItem.php b/src/Mocker/MethodItem.php index a3723ec..ebdb2aa 100644 --- a/src/Mocker/MethodItem.php +++ b/src/Mocker/MethodItem.php @@ -52,7 +52,7 @@ public function __construct(?Mocker $mocker = null) */ public function wrap(Closure $call): self { - if (is_null($this->mocker)) { + if ($this->mocker === null) { 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 ce6051c..2637f4b 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/Mocker.php @@ -374,7 +374,7 @@ 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)) { + if ($value !== null) { return "return " . DataTypeMock::exportValue($value) . ";"; } diff --git a/src/Mocker/MockerController.php b/src/Mocker/MockerController.php index 6709c36..78fdb1e 100644 --- a/src/Mocker/MockerController.php +++ b/src/Mocker/MockerController.php @@ -20,7 +20,7 @@ final class MockerController extends MethodPool */ public static function getInstance(): self { - if (is_null(self::$instance)) { + if (self::$instance === null) { self::$instance = new self(); } return self::$instance; diff --git a/src/TestCase.php b/src/TestCase.php index 143f13f..907e7ea 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -69,7 +69,7 @@ public function dispatchTest(self &$row): array { $row = $this; $test = $this->bind; - if (!is_null($test)) { + if ($test !== null) { try { $newInst = $test($this); } catch (Throwable $e) { @@ -295,7 +295,7 @@ private function prepareValidation(Mocker $mocker, Closure $validate): void { $pool = new MethodPool($mocker); $fn = $validate->bindTo($pool); - if (is_null($fn)) { + if ($fn === null) { throw new ErrorException("A callable Closure could not be bound to the method pool!"); } $fn($pool); @@ -353,7 +353,7 @@ private function validateRow(object $row, MethodPool $pool): array $errors = []; foreach (get_object_vars($item) as $property => $value) { - if (is_null($value)) { + if ($value === null) { continue; } @@ -587,7 +587,7 @@ protected function buildClosureTest(Closure $validation): array $validation = $validation->bindTo($validPool); $error = []; - if (!is_null($validation)) { + if ($validation !== null) { $bool = $validation($this->value, $validPool); $error = $validPool->getError(); if (is_bool($bool) && !$bool) { @@ -595,7 +595,7 @@ protected function buildClosureTest(Closure $validation): array } } - if (is_null($this->message)) { + if ($this->message === null) { throw new RuntimeException("When testing with closure the third argument message is required"); } @@ -614,7 +614,7 @@ protected function buildArrayTest(string $method, array|Closure $args): bool { if ($args instanceof Closure) { $args = $args->bindTo($this->valid($this->value)); - if (is_null($args)) { + if ($args === null) { throw new ErrorException("The argument is not returning a callable Closure!"); } $bool = $args($this->value); @@ -684,7 +684,7 @@ public function listAllProxyMethods(string $class, ?string $prefixMethods = null $name = $method->getName(); if (!$method->isStatic() && !str_starts_with($name, '__')) { - if (!is_null($prefixMethods)) { + if ($prefixMethods !== null) { $name = $prefixMethods . ucfirst($name); } echo "@method self $name(" . implode(', ', $params) . ")\n"; diff --git a/src/TestUnit.php b/src/TestUnit.php index a87b1db..e1ab3f8 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -26,7 +26,7 @@ class TestUnit public function __construct(?string $message = null) { $this->valid = true; - $this->message = is_null($message) ? "Could not validate" : $message; + $this->message = $message === null ? "Could not validate" : $message; } /** @@ -200,7 +200,7 @@ public function getValue(): mixed */ public function getReadValue(mixed $value = null, bool $minify = false): string|bool { - $value = is_null($value) ? $this->value : $value; + $value = $value === null ? $this->value : $value; if (is_bool($value)) { return '"' . ($value ? "true" : "false") . '"' . ($minify ? "" : " (type: bool)"); } @@ -219,7 +219,7 @@ public function getReadValue(mixed $value = null, bool $minify = false): string| if (is_object($value)) { return '"' . $this->excerpt(get_class($value)) . '"' . ($minify ? "" : " (type: object)"); } - if (is_null($value)) { + if ($value === null) { return '"null"'. ($minify ? '' : ' (type: null)'); } if (is_resource($value)) { diff --git a/src/TestUtils/DataTypeMock.php b/src/TestUtils/DataTypeMock.php index be8d24a..b694994 100644 --- a/src/TestUtils/DataTypeMock.php +++ b/src/TestUtils/DataTypeMock.php @@ -34,7 +34,7 @@ class DataTypeMock public static function inst(): self { - if (is_null(self::$inst)) { + if (self::$inst === null) { self::$inst = new self(); } return self::$inst; @@ -150,7 +150,7 @@ public function getDataTypeValue(string $dataType, ?string $bindKey = null): mix return self::exportValue($this->bindArguments[$bindKey][$dataType]); } - if(is_null($this->types)) { + if($this->types === null) { $this->types = $this->getDataTypeListToString(); } diff --git a/src/Unit.php b/src/Unit.php index 869ea15..68dffcb 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -162,12 +162,12 @@ public function performance(Closure $func, ?string $title = null): void { $start = new TestMem(); $func = $func->bindTo($this); - if (!is_null($func)) { + if ($func !== null) { $func($this); } $line = $this->command->getAnsi()->line(80); $this->command->message(""); - $this->command->message($this->command->getAnsi()->style(["bold", "yellow"], "Performance" . (!is_null($title) ? " - $title:" : ":"))); + $this->command->message($this->command->getAnsi()->style(["bold", "yellow"], "Performance" . ($title !== null ? " - $title:" : ":"))); $this->command->message($line); $this->command->message( @@ -305,7 +305,7 @@ public function execute(): bool if ($this->output) { $this->buildNotice("Note:", $this->output, 80); } - if (!is_null($this->handler)) { + if ($this->handler !== null) { $this->handler->execute(); } $this->executed = true; @@ -446,7 +446,7 @@ public static function resetUnit(): void */ public static function hasUnit(): bool { - return !is_null(self::$current); + return self::$current !== null; } /** @@ -456,7 +456,7 @@ public static function hasUnit(): bool */ public static function getUnit(): ?Unit { - if (is_null(self::hasUnit())) { + if (self::hasUnit() === null) { throw new Exception("Unit has not been set yet. It needs to be set first."); } return self::$current; @@ -468,7 +468,7 @@ public static function getUnit(): ?Unit */ public static function completed(): void { - if (!is_null(self::$current) && is_null(self::$current->handler)) { + if (self::$current !== null && self::$current->handler === null) { $dot = self::$current->command->getAnsi()->middot(); self::$current->command->message(""); From 2c811db6e0fbc6a988f2a00f345f14fc92072a61 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Wed, 14 May 2025 21:03:02 +0200 Subject: [PATCH 25/53] refactor: restructure project layout and improve file naming for clarity --- README.md | 10 +- bin/unitary | 2 +- src/Expect.php | 10 ++ .../{MethodPool.php => MethodRegistry.php} | 22 ++-- src/Mocker/{Mocker.php => MockBuilder.php} | 16 +-- ...ockerController.php => MockController.php} | 8 +- .../{MethodItem.php => MockedMethod.php} | 10 +- src/TestCase.php | 104 ++++++++++-------- src/TestConfig.php | 64 +++++++++++ src/TestUnit.php | 12 +- src/TestUtils/DataTypeMock.php | 4 +- .../ExecutionWrapper.php} | 6 +- src/Unit.php | 102 ++++++++++------- src/{ => Utils}/FileIterator.php | 11 +- src/{TestMem.php => Utils/Performance.php} | 4 +- tests/unitary-unitary.php | 74 +++++++------ 16 files changed, 288 insertions(+), 171 deletions(-) create mode 100644 src/Expect.php rename src/Mocker/{MethodPool.php => MethodRegistry.php} (75%) rename src/Mocker/{Mocker.php => MockBuilder.php} (96%) rename src/Mocker/{MockerController.php => MockController.php} (93%) rename src/Mocker/{MethodItem.php => MockedMethod.php} (98%) create mode 100644 src/TestConfig.php rename src/{TestWrapper.php => TestUtils/ExecutionWrapper.php} (96%) rename src/{ => Utils}/FileIterator.php (95%) rename src/{TestMem.php => Utils/Performance.php} (94%) diff --git a/README.md b/README.md index 2f3e153..ed4c695 100644 --- a/README.md +++ b/README.md @@ -151,10 +151,10 @@ then you can just tell Unitary how those failed methods should load. ```php use MaplePHP\Validate\ValidationChain; -use \MaplePHP\Unitary\Mocker\MethodPool; +use \MaplePHP\Unitary\Mocker\MethodRegistry; $unit->group("Testing user service", function (TestCase $inst) { - $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { + $mock = $inst->mock(Mailer::class, function (MethodRegistry $pool) use($inst) { // Quick way to tell Unitary that this method should return 'john.doe' $pool->method("getFromEmail")->willReturn('john.doe@gmail.com'); @@ -179,10 +179,10 @@ What is really cool is that you can also use Unitary mocker to make sure consist validate that the method is built and loaded correctly. ```php -use \MaplePHP\Unitary\Mocker\MethodPool; +use \MaplePHP\Unitary\Mocker\MethodRegistry; $unit->group("Unitary test", function (TestCase $inst) { - $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { + $mock = $inst->mock(Mailer::class, function (MethodRegistry $pool) use($inst) { $pool->method("addFromEmail") ->isPublic() ->hasDocComment() @@ -246,7 +246,7 @@ php vendor/bin/unitary --show=b0620ca8ef6ea7598eaed56a530b1983 You can also mark a test case to run manually, excluding it from the main test batch. ```php -$unit->manual('maplePHPRequest')->case("MaplePHP Request URI path test", function() { +$unit->hide('maplePHPRequest')->case("MaplePHP Request URI path test", function() { ... }); ``` diff --git a/bin/unitary b/bin/unitary index 0ea0f98..699015c 100755 --- a/bin/unitary +++ b/bin/unitary @@ -11,7 +11,7 @@ use MaplePHP\Http\Environment; use MaplePHP\Http\ServerRequest; use MaplePHP\Http\Uri; use MaplePHP\Prompts\Command; -use MaplePHP\Unitary\FileIterator; +use MaplePHP\Unitary\Utils\FileIterator; $command = new Command(); $env = new Environment(); diff --git a/src/Expect.php b/src/Expect.php new file mode 100644 index 0000000..ce4433a --- /dev/null +++ b/src/Expect.php @@ -0,0 +1,10 @@ + */ + private ?MockBuilder $mocker = null; + /** @var array */ private static array $methods = []; - public function __construct(?Mocker $mocker = null) + public function __construct(?MockBuilder $mocker = null) { $this->mocker = $mocker; } @@ -17,9 +17,9 @@ public function __construct(?Mocker $mocker = null) * Access method pool * @param string $class * @param string $name - * @return MethodItem|null + * @return MockedMethod|null */ - public static function getMethod(string $class, string $name): ?MethodItem + public static function getMethod(string $class, string $name): ?MockedMethod { return self::$methods[$class][$name] ?? null; } @@ -29,11 +29,11 @@ public static function getMethod(string $class, string $name): ?MethodItem * returns the corresponding MethodItem instance. * * @param string $name The name of the method to add. - * @return MethodItem The newly created MethodItem instance. + * @return MockedMethod The newly created MethodItem instance. */ - public function method(string $name): MethodItem + public function method(string $name): MockedMethod { - self::$methods[$this->mocker->getClassName()][$name] = new MethodItem($this->mocker); + self::$methods[$this->mocker->getClassName()][$name] = new MockedMethod($this->mocker); return self::$methods[$this->mocker->getClassName()][$name]; } @@ -41,9 +41,9 @@ public function method(string $name): MethodItem * Get method * * @param string $key - * @return MethodItem|null + * @return MockedMethod|null */ - public function get(string $key): MethodItem|null + public function get(string $key): MockedMethod|null { return self::$methods[$this->mocker->getClassName()][$key] ?? null; } diff --git a/src/Mocker/Mocker.php b/src/Mocker/MockBuilder.php similarity index 96% rename from src/Mocker/Mocker.php rename to src/Mocker/MockBuilder.php index 2637f4b..1e02d3b 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/MockBuilder.php @@ -20,7 +20,7 @@ use ReflectionUnionType; use RuntimeException; -final class Mocker +final class MockBuilder { protected ReflectionClass $reflection; protected string $className; @@ -192,10 +192,10 @@ public function __call(string \$name, array \$arguments) { /** * @param array $types * @param mixed $method - * @param MethodItem|null $methodItem + * @param MockedMethod|null $methodItem * @return string */ - protected function getReturnValue(array $types, mixed $method, ?MethodItem $methodItem = null): string + protected function getReturnValue(array $types, mixed $method, ?MockedMethod $methodItem = null): string { // Will overwrite the auto generated value if ($methodItem && $methodItem->hasReturn()) { @@ -230,7 +230,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string $this->methodList[] = $methodName; // The MethodItem contains all items that are validatable - $methodItem = MethodPool::getMethod($this->getClassName(), $methodName); + $methodItem = MethodRegistry::getMethod($this->getClassName(), $methodName); if($methodItem && $methodItem->keepOriginal) { continue; } @@ -260,7 +260,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string throw new RuntimeException('JSON encoding failed: ' . json_last_error_msg(), json_last_error()); } - MockerController::getInstance()->buildMethodData($info); + MockController::getInstance()->buildMethodData($info); if ($methodItem) { $returnValue = $this->generateWrapperReturn($methodItem->getWrap(), $methodName, $returnValue); } @@ -269,8 +269,8 @@ protected function generateMockMethodOverrides(string $mockClassName): string $overrides .= " $modifiers function $methodName($paramList){$returnType} { - \$obj = \\MaplePHP\\Unitary\\Mocker\\MockerController::getInstance()->buildMethodData('$safeJson', true); - \$data = \\MaplePHP\\Unitary\\Mocker\\MockerController::getDataItem(\$obj->mocker, \$obj->name); + \$obj = \\MaplePHP\\Unitary\\Mocker\\MockController::getInstance()->buildMethodData('$safeJson', true); + \$data = \\MaplePHP\\Unitary\\Mocker\\MockController::getDataItem(\$obj->mocker, \$obj->name); {$returnValue} } "; @@ -289,7 +289,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string */ protected function generateWrapperReturn(?Closure $wrapper, string $methodName, string $returnValue): string { - MockerController::addData((string)$this->mockClassName, $methodName, 'wrapper', $wrapper); + MockController::addData((string)$this->mockClassName, $methodName, 'wrapper', $wrapper); $return = ($returnValue) ? "return " : ""; return " if (isset(\$data->wrapper) && \$data->wrapper instanceof \\Closure) { diff --git a/src/Mocker/MockerController.php b/src/Mocker/MockController.php similarity index 93% rename from src/Mocker/MockerController.php rename to src/Mocker/MockController.php index 78fdb1e..d486ff6 100644 --- a/src/Mocker/MockerController.php +++ b/src/Mocker/MockController.php @@ -6,17 +6,17 @@ * A controller class responsible for managing mock data for methods. * Provides methods to add, retrieve, and track mock data, including support for singleton access. */ -final class MockerController extends MethodPool +final class MockController extends MethodRegistry { - private static ?MockerController $instance = null; + private static ?MockController $instance = null; /** @var array> */ private static array $data = []; /** - * Get a singleton instance of MockerController + * Get a singleton instance of MockController * Creates a new instance if none exists * - * @return static The singleton instance of MockerController + * @return static The singleton instance of MockController */ public static function getInstance(): self { diff --git a/src/Mocker/MethodItem.php b/src/Mocker/MockedMethod.php similarity index 98% rename from src/Mocker/MethodItem.php rename to src/Mocker/MockedMethod.php index ebdb2aa..90bf0dd 100644 --- a/src/Mocker/MethodItem.php +++ b/src/Mocker/MockedMethod.php @@ -4,14 +4,14 @@ use BadMethodCallException; use Closure; -use MaplePHP\Unitary\TestWrapper; +use MaplePHP\Unitary\TestUtils\ExecutionWrapper; /** * @psalm-suppress PossiblyUnusedProperty */ -final class MethodItem +final class MockedMethod { - private ?Mocker $mocker; + private ?MockBuilder $mocker; public mixed $return = null; public int|array|null $called = null; @@ -37,7 +37,7 @@ final class MethodItem protected bool $hasReturn = false; protected ?Closure $wrapper = null; - public function __construct(?Mocker $mocker = null) + public function __construct(?MockBuilder $mocker = null) { $this->mocker = $mocker; } @@ -57,7 +57,7 @@ public function wrap(Closure $call): self } $inst = $this; - $wrap = new class ($this->mocker->getClassName(), $this->mocker->getClassArgs()) extends TestWrapper { + $wrap = new class ($this->mocker->getClassName(), $this->mocker->getClassArgs()) extends ExecutionWrapper { public function __construct(string $class, array $args = []) { parent::__construct($class, $args); diff --git a/src/TestCase.php b/src/TestCase.php index 907e7ea..5dc6659 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -4,23 +4,23 @@ namespace MaplePHP\Unitary; +use BadMethodCallException; +use Closure; +use ErrorException; +use Exception; use MaplePHP\Blunder\BlunderErrorException; 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\Unitary\Mocker\MethodRegistry; +use MaplePHP\Unitary\Mocker\MockBuilder; +use MaplePHP\Unitary\Mocker\MockController; +use MaplePHP\Unitary\TestUtils\ExecutionWrapper; use MaplePHP\Validate\Validator; -use MaplePHP\Validate\ValidationChain; use ReflectionClass; -use ReflectionMethod; -use Throwable; -use Exception; use ReflectionException; +use ReflectionMethod; use RuntimeException; -use BadMethodCallException; -use ErrorException; -use Closure; +use Throwable; /** * @template T of object @@ -28,23 +28,28 @@ final class TestCase { private mixed $value; - private ?string $message; + private TestConfig $config; + private ?string $message = null; private array $test = []; private int $count = 0; private ?Closure $bind = null; private ?string $errorMessage = null; private array $deferredValidation = []; - /** @var Mocker */ - private Mocker $mocker; + /** @var MockBuilder */ + private MockBuilder $mocker; /** * Initialize a new TestCase instance with an optional message. * - * @param string|null $message A message to associate with the test case. + * @param TestConfig|string|null $config */ - public function __construct(?string $message = null) + public function __construct(TestConfig|string|null $config = null) { - $this->message = $message; + if (!($config instanceof TestConfig)) { + $this->config = new TestConfig($config); + } else { + $this->config = $config; + } } /** @@ -64,6 +69,7 @@ public function bind(Closure $bind): void * @param self $row * @return array * @throws BlunderErrorException + * @throws Throwable */ public function dispatchTest(self &$row): array { @@ -101,13 +107,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(ValidationChain, mixed): bool $validation The validation logic + * @param Closure(Expect, mixed): bool $validation The validation logic * @return $this * @throws ErrorException */ public function validate(mixed $expect, Closure $validation): self { - $this->expectAndValidate($expect, function (mixed $value, ValidationChain $inst) use ($validation) { + $this->expectAndValidate($expect, function (mixed $value, Expect $inst) use ($validation) { return $validation($inst, new Traverse($value)); }, $this->errorMessage); @@ -208,11 +214,11 @@ public function add(mixed $expect, array|Closure $validation, ?string $message = * * @param string $class * @param array $args - * @return TestWrapper + * @return ExecutionWrapper */ - public function wrap(string $class, array $args = []): TestWrapper + public function wrap(string $class, array $args = []): ExecutionWrapper { - return new class ($class, $args) extends TestWrapper { + return new class ($class, $args) extends ExecutionWrapper { public function __construct(string $class, array $args = []) { parent::__construct($class, $args); @@ -228,7 +234,7 @@ public function __construct(string $class, array $args = []) public function withMock(string $class, array $args = []): self { $inst = clone $this; - $inst->mocker = new Mocker($class, $args); + $inst->mocker = new MockBuilder($class, $args); return $inst; } @@ -252,7 +258,6 @@ public function buildMock(?Closure $validate = null): mixed } } - /** * Creates and returns an instance of a dynamically generated mock class. * @@ -269,12 +274,11 @@ public function buildMock(?Closure $validate = null): mixed */ public function mock(string $class, ?Closure $validate = null, array $args = []) { - $this->mocker = new Mocker($class, $args); + $this->mocker = new MockBuilder($class, $args); return $this->buildMock($validate); } - - public function getMocker(): Mocker + public function getMocker(): MockBuilder { return $this->mocker; } @@ -286,14 +290,14 @@ public function getMocker(): Mocker * 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 MockBuilder $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 + private function prepareValidation(MockBuilder $mocker, Closure $validate): void { - $pool = new MethodPool($mocker); + $pool = new MethodRegistry($mocker); $fn = $validate->bindTo($pool); if ($fn === null) { throw new ErrorException("A callable Closure could not be bound to the method pool!"); @@ -310,16 +314,16 @@ private function prepareValidation(Mocker $mocker, Closure $validate): void * 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 + * @param MockBuilder $mocker The mocker instance containing the mocked class + * @param MethodRegistry $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 + private function runValidation(MockBuilder $mocker, MethodRegistry $pool): array { $error = []; - $data = MockerController::getData($mocker->getMockedClassName()); + $data = MockController::getData($mocker->getMockedClassName()); if (!is_array($data)) { throw new ErrorException("Could not get data from mocker!"); } @@ -339,11 +343,11 @@ private function runValidation(Mocker $mocker, MethodPool $pool): array * and complex array validations. * * @param object $row The method calls data to validate - * @param MethodPool $pool The pool containing validation expectations + * @param MethodRegistry $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 + private function validateRow(object $row, MethodRegistry $pool): array { $item = $pool->get((string)($row->name ?? "")); if (!$item) { @@ -360,7 +364,7 @@ private function validateRow(object $row, MethodPool $pool): array if(!property_exists($row, $property)) { throw new ErrorException( "The mock method meta data property name '$property' is undefined in mock object. " . - "To resolve this either use MockerController::buildMethodData() to add the property dynamically " . + "To resolve this either use MockController::buildMethodData() to add the property dynamically " . "or define a default value through Mocker::addMockMetadata()" ); } @@ -396,11 +400,11 @@ private function validateRow(object $row, MethodPool $pool): array * * @param array $value The validation configuration array * @param mixed $currentValue The value to validate - * @return ValidationChain The validation chain instance with applied validations + * @return Expect The validation chain instance with applied validations */ - private function validateArrayValue(array $value, mixed $currentValue): ValidationChain + private function validateArrayValue(array $value, mixed $currentValue): Expect { - $validPool = new ValidationChain($currentValue); + $validPool = new Expect($currentValue); foreach ($value as $method => $args) { if (is_int($method)) { foreach ($args as $methodB => $argsB) { @@ -422,12 +426,12 @@ private function validateArrayValue(array $value, mixed $currentValue): Validati /** * Create a comparison from a validation collection * - * @param ValidationChain $validPool + * @param Expect $validPool * @param array $value * @param array $currentValue * @return void */ - protected function compareFromValidCollection(ValidationChain $validPool, array &$value, array &$currentValue): void + protected function compareFromValidCollection(Expect $validPool, array &$value, array &$currentValue): void { $new = []; $error = $validPool->getError(); @@ -554,6 +558,16 @@ public function getValue(): mixed return $this->value; } + /** + * Get the test configuration + * + * @return TestConfig + */ + public function getConfig(): TestConfig + { + return $this->config; + } + /** * Get user added message * @@ -561,7 +575,7 @@ public function getValue(): mixed */ public function getMessage(): ?string { - return $this->message; + return $this->config->message; } /** @@ -583,7 +597,7 @@ public function getTest(): array protected function buildClosureTest(Closure $validation): array { //$bool = false; - $validPool = new ValidationChain($this->value); + $validPool = new Expect($this->value); $validation = $validation->bindTo($validPool); $error = []; @@ -595,8 +609,8 @@ protected function buildClosureTest(Closure $validation): array } } - if ($this->message === null) { - throw new RuntimeException("When testing with closure the third argument message is required"); + if ($this->getMessage() === null) { + throw new RuntimeException("You need to specify a \"message\" in first parameter of ->group(string|TestConfig \$message, ...)."); } return $error; diff --git a/src/TestConfig.php b/src/TestConfig.php new file mode 100644 index 0000000..4f97a33 --- /dev/null +++ b/src/TestConfig.php @@ -0,0 +1,64 @@ +message = $message; + } + + /** + * Statically make instance. + * + * @param string $message + * @return self + */ + public static function make(string $message): self + { + return new self($message); + } + + /** + * Sets the select state for the current instance. + * + * @param string $key The key to set. + * @return self + */ + public function setSelect(string $key): self + { + $this->select = $key; + return $this; + } + + /** + * Sets the message for the current instance. + * + * @param string $message The message to set. + * @return self + */ + public function setMessage(string $message): self + { + $this->message = $message; + return $this; + } + + /** + * Sets the skip state for the current instance. + * + * @param bool $bool Optional. The value to set for the skip state. Defaults to true. + * @return self + */ + public function setSkip(bool $bool = true): self + { + $this->skip = $bool; + return $this; + } + +} \ No newline at end of file diff --git a/src/TestUnit.php b/src/TestUnit.php index e1ab3f8..4f816ff 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -30,7 +30,7 @@ public function __construct(?string $message = null) } /** - * Check if value should be presented + * Check if the value should be presented * * @return bool */ @@ -45,7 +45,7 @@ public function hasValue(): bool * @param mixed $value * @return void */ - public function setTestValue(mixed $value) + public function setTestValue(mixed $value): void { $this->value = $value; $this->hasValue = true; @@ -129,7 +129,6 @@ public function setCodeLine(array $trace): self return $this; } - /** * Get the code line from a backtrace * @@ -151,7 +150,7 @@ public function getUnits(): array } /** - * Get failed test count + * Get a failed test count * * @return int */ @@ -161,7 +160,7 @@ public function getFailedTestCount(): int } /** - * Get test message + * Get a test message * * @return string|null */ @@ -171,7 +170,7 @@ public function getMessage(): ?string } /** - * Get if test is valid + * Get if the test is valid * * @return bool */ @@ -242,5 +241,4 @@ final protected function excerpt(string $value, int $length = 80): string $format = new Str($value); return (string)$format->excerpt($length)->get(); } - } diff --git a/src/TestUtils/DataTypeMock.php b/src/TestUtils/DataTypeMock.php index b694994..6e328b7 100644 --- a/src/TestUtils/DataTypeMock.php +++ b/src/TestUtils/DataTypeMock.php @@ -21,7 +21,7 @@ class DataTypeMock private array $defaultArguments = []; /** - * @var array|null Cache of stringified data type values + * @var array|null Cache of stringifies data type values */ private ?array $types = null; @@ -110,7 +110,7 @@ public function withCustomDefault(string $dataType, mixed $value): self /** * 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. + * Creates a new instance with the bound value stored in the 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 diff --git a/src/TestWrapper.php b/src/TestUtils/ExecutionWrapper.php similarity index 96% rename from src/TestWrapper.php rename to src/TestUtils/ExecutionWrapper.php index 2b8a2e4..e1dab81 100755 --- a/src/TestWrapper.php +++ b/src/TestUtils/ExecutionWrapper.php @@ -9,13 +9,13 @@ * Don't delete this comment, it's part of the license. */ -namespace MaplePHP\Unitary; +namespace MaplePHP\Unitary\TestUtils; use Closure; use Exception; use MaplePHP\Container\Reflection; -abstract class TestWrapper +abstract class ExecutionWrapper { protected Reflection $ref; protected object $instance; @@ -38,7 +38,7 @@ public function __construct(string $className, array $args = []) } /** - * Will bind Closure to class instance and directly return the Closure + * Will bind Closure to a class instance and directly return the Closure * * @param Closure $call * @return Closure diff --git a/src/Unit.php b/src/Unit.php index 68dffcb..a86f823 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -7,10 +7,12 @@ use Closure; use ErrorException; use Exception; +use MaplePHP\Blunder\BlunderErrorException; use MaplePHP\Http\Interfaces\StreamInterface; use MaplePHP\Prompts\Command; use MaplePHP\Prompts\Themes\Blocks; use MaplePHP\Unitary\Handlers\HandlerInterface; +use MaplePHP\Unitary\Utils\Performance; use RuntimeException; class Unit @@ -21,14 +23,15 @@ class Unit private int $index = 0; private array $cases = []; private bool $skip = false; + private string $select = ""; private bool $executed = false; private static array $headers = []; private static ?Unit $current; - private static array $manual = []; public static int $totalPassedTests = 0; public static int $totalTests = 0; + /** * Initialize Unit test instance with optional handler * @@ -49,7 +52,9 @@ public function __construct(HandlerInterface|StreamInterface|null $handler = nul } /** - * Skip you can add this if you want to turn of validation of a unit case + * This will skip "ALL" tests in the test file + * If you want to skip a specific test, use the TestConfig class instead + * * @param bool $skip * @return $this */ @@ -60,19 +65,28 @@ public function skip(bool $skip): self } /** - * Make script manually callable + * WIll hide the test case from the output but show it as a warning + * * @param string $key * @return $this */ - public function manual(string $key): self + public function select(string $key): self { - 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."); - } - self::$manual[$key] = self::$headers['checksum']; - return $this->skip(true); + $this->select = $key; + return $this; + } + + /** + * DEPRECATED: Use TestConfig::setSelect instead + * See documentation for more information + * + * @param string|null $key + * @return void + */ + public function manual(?string $key = null): void + { + throw new RuntimeException("Manual method has been deprecated, use TestConfig::setSelect instead. " . + "See documentation for more information."); } /** @@ -140,11 +154,11 @@ public function add(string $message, Closure $callback): void /** * Add a test unit/group * - * @param string $message + * @param string|TestConfig $message * @param Closure(TestCase):void $callback * @return void */ - public function group(string $message, Closure $callback): void + public function group(string|TestConfig $message, Closure $callback): void { $testCase = new TestCase($message); $testCase->bind($callback); @@ -160,7 +174,7 @@ public function case(string $message, Closure $callback): void public function performance(Closure $func, ?string $title = null): void { - $start = new TestMem(); + $start = new Performance(); $func = $func->bindTo($this); if ($func !== null) { $func($this); @@ -192,13 +206,16 @@ public function performance(Closure $func, ?string $title = null): void * * @return bool * @throws ErrorException + * @throws BlunderErrorException + * @throws \Throwable */ public function execute(): bool { $this->help(); - if ($this->executed || !$this->createValidate()) { + $show = self::getArgs('show') === (string)self::$headers['checksum']; + if ($this->executed || $this->skip) { return false; } @@ -217,7 +234,20 @@ public function execute(): bool if ($row->hasFailed()) { $flag = $this->command->getAnsi()->style(['redBg', 'brightWhite'], " FAIL "); } + if ($row->getConfig()->skip) { + $color = "yellow"; + $flag = $this->command->getAnsi()->style(['yellowBg', 'black'], " WARN "); + } + + // Will show test by hash or key, will open warn tests + if((self::getArgs('show') !== false && !$show) && $row->getConfig()->select !== self::getArgs('show')) { + continue; + } + if($row->getConfig()->select === self::getArgs('show')) { + $show = $row->getConfig()->select === self::getArgs('show'); + } + // Success, no need to try to show errors, continue with next test if ($errArg !== false && !$row->hasFailed()) { continue; } @@ -230,7 +260,7 @@ public function execute(): bool $this->command->getAnsi()->style(["bold", $color], (string)$row->getMessage()) ); - if (isset($tests)) { + if (isset($tests) && ($show || !$row->getConfig()->skip)) { foreach ($tests as $test) { if (!($test instanceof TestUnit)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); @@ -240,7 +270,7 @@ public function execute(): bool $msg = (string)$test->getMessage(); $this->command->message(""); $this->command->message( - $this->command->getAnsi()->style(["bold", "brightRed"], "Error: ") . + $this->command->getAnsi()->style(["bold", $color], "Error: ") . $this->command->getAnsi()->bold($msg) ); $this->command->message(""); @@ -266,7 +296,7 @@ public function execute(): bool $failedMsg = " " .$title . ((!$unit['valid']) ? " β†’ failed" : ""); $this->command->message( $this->command->getAnsi()->style( - ((!$unit['valid']) ? "brightRed" : null), + ((!$unit['valid']) ? $color : null), $failedMsg ) ); @@ -275,7 +305,7 @@ public function execute(): bool $lengthB = (strlen($compare) + strlen($failedMsg) - 8); $comparePad = str_pad($compare, $lengthB, " ", STR_PAD_LEFT); $this->command->message( - $this->command->getAnsi()->style("brightRed", $comparePad) + $this->command->getAnsi()->style($color, $comparePad) ); } } @@ -292,13 +322,21 @@ public function execute(): bool self::$totalTests += $row->getTotal(); $checksum = (string)(self::$headers['checksum'] ?? ""); + + if ($row->getConfig()->select) { + $checksum .= " (" . $row->getConfig()->select . ")"; + } + $this->command->message(""); - $this->command->message( - $this->command->getAnsi()->bold("Passed: ") . + $footer = $this->command->getAnsi()->bold("Passed: ") . $this->command->getAnsi()->style([$color], $row->getCount() . "/" . $row->getTotal()) . - $this->command->getAnsi()->style(["italic", "grey"], " - ". $checksum) - ); + $this->command->getAnsi()->style(["italic", "grey"], " - ". $checksum); + if (!$show && $row->getConfig()->skip) { + $footer = $this->command->getAnsi()->style(["italic", "grey"], $checksum); + } + + $this->command->message($footer); } $this->output .= ob_get_clean(); @@ -342,24 +380,6 @@ public function validate(): self "Move this validate() call inside your group() callback function."); } - /** - * Validate before execute test - * - * @return bool - */ - private function createValidate(): bool - { - $args = (array)(self::$headers['args'] ?? []); - $manual = isset($args['show']) ? (string)$args['show'] : ""; - if (isset($args['show'])) { - return !((self::$manual[$manual] ?? "") !== self::$headers['checksum'] && $manual !== self::$headers['checksum']); - } - if ($this->skip) { - return false; - } - return true; - } - /** * Build the notification stream * @param string $title diff --git a/src/FileIterator.php b/src/Utils/FileIterator.php similarity index 95% rename from src/FileIterator.php rename to src/Utils/FileIterator.php index 346e7c0..3614d9c 100755 --- a/src/FileIterator.php +++ b/src/Utils/FileIterator.php @@ -2,15 +2,16 @@ declare(strict_types=1); -namespace MaplePHP\Unitary; +namespace MaplePHP\Unitary\Utils; use Closure; use Exception; -use RuntimeException; use MaplePHP\Blunder\Handlers\CliHandler; use MaplePHP\Blunder\Run; +use MaplePHP\Unitary\Unit; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; +use RuntimeException; use SplFileInfo; final class FileIterator @@ -35,7 +36,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\" "); + throw new RuntimeException("No files found matching the pattern \"" . (FileIterator::PATTERN ?? "") . "\" in directory \"$directory\" "); } else { foreach ($files as $file) { extract($this->args, EXTR_PREFIX_SAME, "wddx"); @@ -74,7 +75,7 @@ private function findFiles(string $dir): array $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir)); /** @var string $pattern */ - $pattern = static::PATTERN; + $pattern = FileIterator::PATTERN; 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))) { @@ -120,7 +121,7 @@ public function findExcluded(array $exclArr, string $relativeDir, string $file): $file = $this->getNaturalPath($file); foreach ($exclArr as $excl) { /* @var string $excl */ - $relativeExclPath = $this->getNaturalPath($relativeDir . DIRECTORY_SEPARATOR . (string)$excl); + $relativeExclPath = $this->getNaturalPath($relativeDir . DIRECTORY_SEPARATOR . $excl); if (fnmatch($relativeExclPath, $file)) { return true; } diff --git a/src/TestMem.php b/src/Utils/Performance.php similarity index 94% rename from src/TestMem.php rename to src/Utils/Performance.php index fb516e2..9cbee9f 100755 --- a/src/TestMem.php +++ b/src/Utils/Performance.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace MaplePHP\Unitary; +namespace MaplePHP\Unitary\Utils; -class TestMem +class Performance { private float $startTime; private int $startMemory; diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 3aebc2c..69521f7 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -1,14 +1,10 @@ setSkip() + ->setSelect('unitary'); + +$unit->group($config, function (TestCase $case) use($unit) { + + $request = new Request("HSHHS", "https://example.com:443/?cat=25&page=1622"); + + $case->validate($request->getMethod(), function(Expect $inst) { + $inst->isRequestMethod(); + }); + + $case->validate($request->getPort(), function(Expect $inst) { + $inst->isEqualTo(443); + }); + + $case->validate($request->getUri()->getQuery(), function(Expect $inst) { + $inst->hasQueryParam("cat"); + $inst->hasQueryParam("page", 1622); + }); +}); + + $unit->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) { + $case->validate($response->getBody()->getContents(), function(Expect $inst) { $inst->hasResponse(); }); }); +/* + + $unit->group("Advanced Mailer Test", function (TestCase $case) use($unit) { @@ -158,32 +180,32 @@ public function registerUser(string $email, string $name = "Daniel"): void { }); - $case->validate($response->getBody()->getContents(), function(ValidationChain $inst, Traverse $collection) { + $case->validate($response->getBody()->getContents(), function(Validate $inst, Traverse $collection) { $inst->isString(); $inst->isJson(); return $collection->strJsonDecode()->test->valid("isString"); }); - $case->validate($response->getHeader("lorem"), function(ValidationChain $inst) { + $case->validate($response->getHeader("lorem"), function(Validate $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) { + $case->validate($response->getStatusCode(), function(Validate $inst) { // Will validate to the default int data type set above // and bounded to "getStatusCode" method $inst->isHttpSuccess(); }); - $case->validate($response->getProtocolVersion(), function(ValidationChain $inst) { + $case->validate($response->getProtocolVersion(), function(Validate $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) { + $case->validate($response->getBody(), function(Validate $inst) { $inst->isInstanceOf(Stream::class); }); @@ -208,11 +230,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { }); - - -/* - -$unit = new Unit(); +//$unit = new Unit(); $unit->group("Unitary test 2", function (TestCase $inst) { $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { @@ -228,7 +246,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { ->paramHasDefault(1, "Daniel") ->paramIsOptional(1) ->paramIsReference(1) - ->count(0); + ->called(0); $pool->method("test") ->hasParams() @@ -236,25 +254,18 @@ public function registerUser(string $email, string $name = "Daniel"): void { ->wrap(function($args) use($inst) { echo "World -> $args\n"; }) - ->count(1); + ->called(1); $pool->method("test2") ->hasNotParams() - ->count(0); + ->called(0); }, ["Arg 1"]); - $mock->test("Hello"); - $service = new UserService($mock); + //$mock->test("Hello"); + //$service = new UserService($mock); - $validPool = new ValidationChain("dwqdqw"); - $validPool - ->isEmail() - ->length(1, 200) - ->endsWith(".com"); - $isValid = $validPool->isValid(); - - $inst->validate("yourTestValue", function(ValidationChain $inst) { + $inst->validate("yourTestValue", function(Validate $inst) { $inst->isBool(); $inst->isInt(); $inst->isJson(); @@ -264,5 +275,4 @@ public function registerUser(string $email, string $name = "Daniel"): void { }); -*/ - + */ \ No newline at end of file From 9b1b2426e827908b2e6712ae24d1994752351625 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sat, 17 May 2025 13:22:29 +0200 Subject: [PATCH 26/53] Add assert support Add unit test boilerplate code --- README.md | 2 +- composer.json | 3 + src/Setup/assert-polyfill.php | 22 +++++++ src/TestCase.php | 63 ++++++++++++++++--- src/TestConfig.php | 8 ++- src/Unit.php | 115 +++++++++++++++++++--------------- tests/unitary-unitary.php | 86 +++++++++++-------------- 7 files changed, 188 insertions(+), 111 deletions(-) create mode 100644 src/Setup/assert-polyfill.php diff --git a/README.md b/README.md index ed4c695..08e6bdb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MaplePHP - Unitary -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. +PHP Unitary is a **user-friendly** and robust unit testing framework 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)_ diff --git a/composer.json b/composer.json index 58ebb2d..c003376 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,9 @@ "maplephp/prompts": "^1.0" }, "autoload": { + "files": [ + "src/Setup/assert-polyfill.php" + ], "psr-4": { "MaplePHP\\Unitary\\": "src" } diff --git a/src/Setup/assert-polyfill.php b/src/Setup/assert-polyfill.php new file mode 100644 index 0000000..17db104 --- /dev/null +++ b/src/Setup/assert-polyfill.php @@ -0,0 +1,22 @@ + */ private MockBuilder $mocker; + /** + * @var true + */ + private bool $hasAssertError = false; /** * Initialize a new TestCase instance with an optional message. @@ -63,6 +68,26 @@ public function bind(Closure $bind): void $this->bind = $bind->bindTo($this); } + /** + * Sets the assertion error flag to true + * + * @return void + */ + function setHasAssertError(): void + { + $this->hasAssertError = true; + } + + /** + * Gets the current state of the assertion error flag + * + * @return bool + */ + function getHasAssertError(): bool + { + return $this->hasAssertError; + } + /** * Will dispatch the case tests and return them as an array * @@ -78,6 +103,13 @@ public function dispatchTest(self &$row): array if ($test !== null) { try { $newInst = $test($this); + } catch (AssertionError $e) { + $newInst = clone $this; + $newInst->setHasAssertError(); + $msg = "Assertion failed"; + $newInst->expectAndValidate( + true, fn() => false, $msg, $e->getMessage(), $e->getTrace()[0] + ); } catch (Throwable $e) { if(str_contains($e->getFile(), "eval()")) { throw new BlunderErrorException($e->getMessage(), $e->getCode()); @@ -107,7 +139,7 @@ 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(Expect, mixed): bool $validation The validation logic + * @param Closure(Expect, Traverse): bool $validation * @return $this * @throws ErrorException */ @@ -137,13 +169,15 @@ public function validate(mixed $expect, Closure $validation): self protected function expectAndValidate( mixed $expect, array|Closure $validation, - ?string $message = null + ?string $message = null, + ?string $description = null, + ?array $trace = null ): self { $this->value = $expect; $test = new TestUnit($message); $test->setTestValue($this->value); if ($validation instanceof Closure) { - $listArr = $this->buildClosureTest($validation); + $listArr = $this->buildClosureTest($validation, $description); foreach ($listArr as $list) { if(is_bool($list)) { $test->setUnit($list, "Validation"); @@ -162,7 +196,10 @@ protected function expectAndValidate( } } if (!$test->isValid()) { - $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; + if(!$trace) { + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; + } + $test->setCodeLine($trace); $this->count++; } @@ -592,19 +629,29 @@ public function getTest(): array * This will build the closure test * * @param Closure $validation + * @param string|null $message * @return array */ - protected function buildClosureTest(Closure $validation): array + protected function buildClosureTest(Closure $validation, ?string $message = null): array { //$bool = false; $validPool = new Expect($this->value); $validation = $validation->bindTo($validPool); - $error = []; if ($validation !== null) { - $bool = $validation($this->value, $validPool); + try { + $bool = $validation($this->value, $validPool); + } catch (AssertionError $e) { + $bool = false; + $message = $e->getMessage(); + } + $error = $validPool->getError(); - if (is_bool($bool) && !$bool) { + if($bool === false && $message !== null) { + $error[] = [ + $message => true + ]; + } else if (is_bool($bool) && !$bool) { $error['customError'] = false; } } diff --git a/src/TestConfig.php b/src/TestConfig.php index 4f97a33..6fd67a3 100644 --- a/src/TestConfig.php +++ b/src/TestConfig.php @@ -31,12 +31,18 @@ public static function make(string $message): self * @param string $key The key to set. * @return self */ - public function setSelect(string $key): self + public function setName(string $key): self { $this->select = $key; return $this; } + // Alias for setName() + public function setSelect(string $key): self + { + return $this->setName($key); + } + /** * Sets the message for the current instance. * diff --git a/src/Unit.php b/src/Unit.php index a86f823..d78aa10 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -23,7 +23,6 @@ class Unit private int $index = 0; private array $cases = []; private bool $skip = false; - private string $select = ""; private bool $executed = false; private static array $headers = []; private static ?Unit $current; @@ -64,26 +63,13 @@ public function skip(bool $skip): self return $this; } - /** - * WIll hide the test case from the output but show it as a warning - * - * @param string $key - * @return $this - */ - public function select(string $key): self - { - $this->select = $key; - return $this; - } - /** * DEPRECATED: Use TestConfig::setSelect instead * See documentation for more information * - * @param string|null $key * @return void */ - public function manual(?string $key = null): void + public function manual(): void { throw new RuntimeException("Manual method has been deprecated, use TestConfig::setSelect instead. " . "See documentation for more information."); @@ -211,17 +197,15 @@ public function performance(Closure $func, ?string $title = null): void */ public function execute(): bool { - + $this->template(); $this->help(); - - $show = self::getArgs('show') === (string)self::$headers['checksum']; if ($this->executed || $this->skip) { return false; } // LOOP through each case ob_start(); - foreach ($this->cases as $row) { + foreach ($this->cases as $index => $row) { if (!($row instanceof TestCase)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestCase."); } @@ -229,6 +213,7 @@ public function execute(): bool $errArg = self::getArgs("errors-only"); $row->dispatchTest($row); $tests = $row->runDeferredValidations(); + $checksum = (self::$headers['checksum'] ?? "") . $index; $color = ($row->hasFailed() ? "brightRed" : "brightBlue"); $flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); if ($row->hasFailed()) { @@ -236,22 +221,21 @@ public function execute(): bool } if ($row->getConfig()->skip) { $color = "yellow"; - $flag = $this->command->getAnsi()->style(['yellowBg', 'black'], " WARN "); + $flag = $this->command->getAnsi()->style(['yellowBg', 'black'], " SKIP "); } - // Will show test by hash or key, will open warn tests - if((self::getArgs('show') !== false && !$show) && $row->getConfig()->select !== self::getArgs('show')) { + $show = ($row->getConfig()->select === self::getArgs('show') || self::getArgs('show') === $checksum); + if((self::getArgs('show') !== false) && !$show) { continue; } - if($row->getConfig()->select === self::getArgs('show')) { - $show = $row->getConfig()->select === self::getArgs('show'); - } - // Success, no need to try to show errors, continue with next test + // Success, no need to try to show errors, continue with the next test if ($errArg !== false && !$row->hasFailed()) { continue; } + + $this->command->message(""); $this->command->message( $flag . " " . @@ -260,7 +244,7 @@ public function execute(): bool $this->command->getAnsi()->style(["bold", $color], (string)$row->getMessage()) ); - if (isset($tests) && ($show || !$row->getConfig()->skip)) { + if (($show || !$row->getConfig()->skip)) { foreach ($tests as $test) { if (!($test instanceof TestUnit)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); @@ -290,7 +274,7 @@ public function execute(): bool $compare = ""; if ($unit['compare']) { $expectedValue = array_shift($unit['compare']); - $compare = "Expected: {$expectedValue} | Actual: " . implode(":", $unit['compare']); + $compare = "Expected: $expectedValue | Actual: " . implode(":", $unit['compare']); } $failedMsg = " " .$title . ((!$unit['valid']) ? " β†’ failed" : ""); @@ -312,7 +296,7 @@ public function execute(): bool } if ($test->hasValue()) { $this->command->message(""); - $this->command->message($this->command->getAnsi()->bold("Value: ") . $test->getReadValue()); + $this->command->message($this->command->getAnsi()->bold("Input value: ") . $test->getReadValue()); } } } @@ -320,17 +304,18 @@ public function execute(): bool self::$totalPassedTests += $row->getCount(); self::$totalTests += $row->getTotal(); - - $checksum = (string)(self::$headers['checksum'] ?? ""); - if ($row->getConfig()->select) { $checksum .= " (" . $row->getConfig()->select . ")"; } - $this->command->message(""); - $footer = $this->command->getAnsi()->bold("Passed: ") . - $this->command->getAnsi()->style([$color], $row->getCount() . "/" . $row->getTotal()) . + $passed = $this->command->getAnsi()->bold("Passed: "); + if ($row->getHasAssertError()) { + $passed .= $this->command->getAnsi()->style(["grey"], "N/A"); + } else { + $passed .= $this->command->getAnsi()->style([$color], $row->getCount() . "/" . $row->getTotal()); + } + $footer = $passed . $this->command->getAnsi()->style(["italic", "grey"], " - ". $checksum); if (!$show && $row->getConfig()->skip) { $footer = $this->command->getAnsi()->style(["italic", "grey"], $checksum); @@ -343,15 +328,13 @@ public function execute(): bool if ($this->output) { $this->buildNotice("Note:", $this->output, 80); } - if ($this->handler !== null) { - $this->handler->execute(); - } + $this->handler?->execute(); $this->executed = true; return true; } /** - * Will reset the execute and stream if is a seekable stream + * Will reset the executing and stream if is a seekable stream * * @return bool */ @@ -400,7 +383,7 @@ public function buildNotice(string $title, string $output, int $lineLength): voi } /** - * Make file path into a title + * Make a file path into a title * @param string $file * @param int $length * @param bool $removeSuffix @@ -452,7 +435,7 @@ public static function appendHeader(string $key, mixed $value): void } /** - * Used to reset current instance + * Used to reset the current instance * @return void */ public static function resetUnit(): void @@ -461,7 +444,7 @@ public static function resetUnit(): void } /** - * Used to check if instance is set + * Used to check if an instance is set * @return bool */ public static function hasUnit(): bool @@ -511,6 +494,37 @@ public static function isSuccessful(): bool return (self::$totalPassedTests !== self::$totalTests); } + /** + * Display a template for the Unitary testing tool + * Shows a basic template for the Unitary testing tool + * Only displays if --template argument is provided + * + * @return void + */ + private function template(): void + { + if (self::getArgs("template") !== false) { + + $blocks = new Blocks($this->command); + $blocks->addHeadline("\n--- Unitary template ---"); + $blocks->addCode( + <<<'PHP' + use MaplePHP\Unitary\{Unit, TestCase, TestConfig, Expect}; + + $unit = new Unit(); + $unit->group("Your test subject", function (TestCase $case) { + + $case->validate("Your test value", function(Expect $valid) { + $valid->isString(); + }); + + }); + PHP + ); + exit(0); + } + } + /** * Display help information for the Unitary testing tool * Shows usage instructions, available options and examples @@ -523,21 +537,21 @@ private function help(): void if (self::getArgs("help") !== false) { $blocks = new Blocks($this->command); - $blocks->addHeadline("Unitary - Help"); + $blocks->addHeadline("\n--- Unitary Help ---"); $blocks->addSection("Usage", "php vendor/bin/unitary [options]"); $blocks->addSection("Options", function(Blocks $inst) { - $inst = $inst + return $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("template", "Will give you a boilerplate test code") ->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 + return $inst ->addExamples( "php vendor/bin/unitary", "Run all tests in the default path (./tests)" @@ -548,14 +562,15 @@ private function help(): void "php vendor/bin/unitary --errors-only", "Run all tests in the default path (./tests)" )->addExamples( - "php vendor/bin/unitary --show=maplePHPRequest", + "php vendor/bin/unitary --show=YourNameHere", "Run a manually named test case" + )->addExamples( + "php vendor/bin/unitary --template", + "Run a and will give you template code for a new test" )->addExamples( 'php vendor/bin/unitary --path="tests/" --exclude="tests/legacy/*,*/extras/*"', 'Run all tests under "tests/" excluding specified directories' - ) - ; - return $inst; + ); }); exit(0); } diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 69521f7..089773f 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -1,10 +1,11 @@ sendEmail($this->getFromEmail()); + $this->sendEmail($this->getFromEmail()); } public function sendEmail(string $email, string $name = "daniel"): string @@ -91,48 +92,33 @@ public function registerUser(string $email, string $name = "Daniel"): void { $unit = new Unit(); -$config = TestConfig::make("This is a test message") - ->setSkip() - ->setSelect('unitary'); +$unit->group(TestConfig::make("Test 1")->setName("unitary")->setSkip(), function (TestCase $case) use($unit) { -$unit->group($config, function (TestCase $case) use($unit) { - - $request = new Request("HSHHS", "https://example.com:443/?cat=25&page=1622"); - - $case->validate($request->getMethod(), function(Expect $inst) { - $inst->isRequestMethod(); - }); - - $case->validate($request->getPort(), function(Expect $inst) { - $inst->isEqualTo(443); - }); + $stream = $case->mock(Stream::class); + $response = new Response($stream); - $case->validate($request->getUri()->getQuery(), function(Expect $inst) { - $inst->hasQueryParam("cat"); - $inst->hasQueryParam("page", 1622); + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + assert(1 === 2, "Lore"); + $inst->notHasResponse(); }); }); - - -$unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { - +$unit->group("Advanced App Response Test 2", function (TestCase $case) use($unit) { $stream = $case->mock(Stream::class); $response = new Response($stream); - $case->validate($response->getBody()->getContents(), function(Expect $inst) { $inst->hasResponse(); }); }); -/* +/* $unit->group("Advanced Mailer Test", function (TestCase $case) use($unit) { - $mail = $case->mock(Mailer::class, function (MethodPool $pool) { - $pool->method("send")->keepOriginal(); - $pool->method("sendEmail")->keepOriginal(); + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { + $method->method("send")->keepOriginal(); + $method->method("sendEmail")->keepOriginal(); }); $mail->send(); }); @@ -141,12 +127,12 @@ public function registerUser(string $email, string $name = "Daniel"): void { // Quickly mock the Stream class - $stream = $case->mock(Stream::class, function (MethodPool $pool) { - $pool->method("getContents") + $stream = $case->mock(Stream::class, function (MethodRegistry $method) { + $method->method("getContents") ->willReturn('{"test":"test"}') ->calledAtLeast(1); - $pool->method("fopen")->isPrivate(); + $method->method("fopen")->isPrivate(); }); // Mock with configuration // @@ -169,43 +155,42 @@ public function registerUser(string $email, string $name = "Daniel"): void { // parameters and return values //print_r(\MaplePHP\Unitary\TestUtils\DataTypeMock::inst()->getMockValues()); - $response = $case->buildMock(function (MethodPool $pool) use($stream) { + $response = $case->buildMock(function (MethodRegistry $method) 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")->willReturn($stream); + $method->method("getBody")->willReturn($stream); }); - $case->validate($response->getBody()->getContents(), function(Validate $inst, Traverse $collection) { + $case->validate($response->getBody()->getContents(), function(Expect $inst) { $inst->isString(); $inst->isJson(); - return $collection->strJsonDecode()->test->valid("isString"); }); - $case->validate($response->getHeader("lorem"), function(Validate $inst) { + $case->validate($response->getHeader("lorem"), function(Expect $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(Validate $inst) { + $case->validate($response->getStatusCode(), function(Expect $inst) { // Will validate to the default int data type set above // and bounded to "getStatusCode" method $inst->isHttpSuccess(); }); - $case->validate($response->getProtocolVersion(), function(Validate $inst) { + $case->validate($response->getProtocolVersion(), function(Expect $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(Validate $inst) { + $case->validate($response->getBody(), function(Expect $inst) { $inst->isInstanceOf(Stream::class); }); @@ -215,29 +200,26 @@ public function registerUser(string $email, string $name = "Daniel"): void { $unit->group("Mailer test", function (TestCase $inst) use($unit) { - $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { - $pool->method("addBCC") + $mock = $inst->mock(Mailer::class, function (MethodRegistry $method) use($inst) { + $method->method("addBCC") ->paramIsType(0, "string") ->paramHasDefault(1, "Daniel") ->paramIsOptional(1) ->paramIsReference(1) ->called(1); - - //$pool->method("test2")->called(1); }); $mock->addBCC("World"); $mock->test(1); }); -//$unit = new Unit(); $unit->group("Unitary test 2", function (TestCase $inst) { - $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { - $pool->method("addFromEmail") + $mock = $inst->mock(Mailer::class, function (MethodRegistry $method) use($inst) { + $method->method("addFromEmail") ->isPublic(); - $pool->method("addBCC") + $method->method("addBCC") ->isPublic() ->hasDocComment() ->hasParams() @@ -248,7 +230,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { ->paramIsReference(1) ->called(0); - $pool->method("test") + $method->method("test") ->hasParams() ->paramIsSpread(0) // Same as ->paramIsVariadic() ->wrap(function($args) use($inst) { @@ -256,7 +238,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { }) ->called(1); - $pool->method("test2") + $method->method("test2") ->hasNotParams() ->called(0); @@ -265,7 +247,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { //$mock->test("Hello"); //$service = new UserService($mock); - $inst->validate("yourTestValue", function(Validate $inst) { + $inst->validate("yourTestValue", function(Expect $inst) { $inst->isBool(); $inst->isInt(); $inst->isJson(); @@ -275,4 +257,6 @@ public function registerUser(string $email, string $name = "Daniel"): void { }); - */ \ No newline at end of file + */ + + From 77235926aa00ed1c419a5f20e259b62bafe03a33 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Mon, 19 May 2025 22:55:36 +0200 Subject: [PATCH 27/53] refactor: remove closure binding from group --- src/TestCase.php | 8 ++-- src/Unit.php | 46 +++++++++++++++------ tests/unitary-unitary.php | 86 ++++++++++++++++++++++++++------------- 3 files changed, 96 insertions(+), 44 deletions(-) diff --git a/src/TestCase.php b/src/TestCase.php index ae9e9de..43cf7ad 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -61,11 +61,13 @@ public function __construct(TestConfig|string|null $config = null) * Bind the test case to the Closure * * @param Closure $bind + * @param bool $bindToClosure choose bind to closure or not (Not recommended) + * Used primary as a fallback for older versions of Unitary * @return void */ - public function bind(Closure $bind): void + public function bind(Closure $bind, bool $bindToClosure = false): void { - $this->bind = $bind->bindTo($this); + $this->bind = ($bindToClosure) ? $bind->bindTo($this) : $bind; } /** @@ -220,7 +222,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, 3)[2]; + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 4)[3]; $this->deferredValidation[] = [ "trace" => $trace, "call" => $validation diff --git a/src/Unit.php b/src/Unit.php index d78aa10..5cb9cd7 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -125,37 +125,43 @@ public function confirm(string $message = "Do you wish to continue?"): bool } /** - * DEPRECATED: Name has been changed to case + * Name has been changed to case + * WILL BECOME DEPRECATED VERY SOON * @param string $message * @param Closure $callback * @return void */ public function add(string $message, Closure $callback): void { - // Might be trigger in future //trigger_error('Method ' . __METHOD__ . ' is deprecated', E_USER_DEPRECATED); $this->case($message, $callback); } /** - * Add a test unit/group + * Adds a test case to the collection (group() is preferred over case()) + * The key difference from group() is that this TestCase will NOT be bound the Closure * - * @param string|TestConfig $message - * @param Closure(TestCase):void $callback + * @param string|TestConfig $message The message or configuration for the test case. + * @param Closure $callback The closure containing the test case logic. * @return void */ public function group(string|TestConfig $message, Closure $callback): void { - $testCase = new TestCase($message); - $testCase->bind($callback); - $this->cases[$this->index] = $testCase; - $this->index++; + $this->addCase($message, $callback); } - // Alias to group - public function case(string $message, Closure $callback): void + /** + * Adds a test case to the collection. + * The key difference from group() is that this TestCase will be bound the Closure + * Not Deprecated but might be in the far future + * + * @param string|TestConfig $message The message or configuration for the test case. + * @param Closure $callback The closure containing the test case logic. + * @return void + */ + public function case(string|TestConfig $message, Closure $callback): void { - $this->group($message, $callback); + $this->addCase($message, $callback, true); } public function performance(Closure $func, ?string $title = null): void @@ -576,6 +582,22 @@ private function help(): void } } + /** + * Adds a test case to the collection. + * + * @param string|TestConfig $message The description or configuration of the test case. + * @param Closure $callback The closure that defines the test case logic. + * @param bool $bindToClosure Indicates whether the closure should be bound to TestCase. + * @return void + */ + protected function addCase(string|TestConfig $message, Closure $callback, bool $bindToClosure = false): void + { + $testCase = new TestCase($message); + $testCase->bind($callback, $bindToClosure); + $this->cases[$this->index] = $testCase; + $this->index++; + } + /** * DEPRECATED: Not used anymore * @return $this diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 089773f..a654f7a 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -58,7 +58,15 @@ public function addFromEmail(string $email, string $name = ""): void $this->from = $email; } - public function addBCC(string $email, &$name = "Daniel"): void + /** + * Add a BCC (blind carbon copy) email address + * + * @param string $email The email address to be added as BCC + * @param string $name The name associated with the email address, default is "Daniel" + * @param mixed $testRef A reference variable, default is "Daniel" + * @return void + */ + public function addBCC(string $email, string $name = "Daniel", &$testRef = "Daniel"): void { $this->bcc = $email; } @@ -78,14 +86,19 @@ public function test2(): void class UserService { public function __construct(private Mailer $mailer) {} - public function registerUser(string $email, string $name = "Daniel"): void { + public function registerUser(string $email): bool { // register user logic... - if(!$this->mailer->isValidEmail($email)) { + $this->mailer->addFromEmail($email); + $this->mailer->addBCC("jane.doe@hotmail.com", "Jane Doe"); + $this->mailer->addBCC("lane.doe@hotmail.com", "Lane Doe"); + + if(!filter_var($this->mailer->getFromEmail(), FILTER_VALIDATE_EMAIL)) { throw new \Exception("Invalid email"); } - echo $this->mailer->sendEmail($email, $name)."\n"; - echo $this->mailer->sendEmail($email, $name); + //echo $this->mailer->sendEmail($email, $name)."\n"; + //echo $this->mailer->sendEmail($email, $name); + return true; } } @@ -213,11 +226,11 @@ public function registerUser(string $email, string $name = "Daniel"): void { }); -$unit->group("Unitary test 2", function (TestCase $inst) { +$unit->group("Testing User service", function (TestCase $inst) { - $mock = $inst->mock(Mailer::class, function (MethodRegistry $method) use($inst) { + $mailer = $inst->mock(Mailer::class, function (MethodRegistry $method) use($inst) { $method->method("addFromEmail") - ->isPublic(); + ->called(1); $method->method("addBCC") ->isPublic() @@ -228,31 +241,17 @@ public function registerUser(string $email, string $name = "Daniel"): void { ->paramHasDefault(1, "Daniel") ->paramIsOptional(1) ->paramIsReference(1) - ->called(0); + ->called(2); - $method->method("test") - ->hasParams() - ->paramIsSpread(0) // Same as ->paramIsVariadic() - ->wrap(function($args) use($inst) { - echo "World -> $args\n"; - }) - ->called(1); - - $method->method("test2") - ->hasNotParams() - ->called(0); + $method->method("getFromEmail") + ->willReturn("john.doe@gmail.com"); - }, ["Arg 1"]); + }, [true // <- Mailer class constructor argument, enable debug]); - //$mock->test("Hello"); - //$service = new UserService($mock); + $service = new UserService($mailer); - $inst->validate("yourTestValue", function(Expect $inst) { - $inst->isBool(); - $inst->isInt(); - $inst->isJson(); - $inst->isString(); - $inst->isResource(); + $case->validate($service->send(), function(Expect $inst) { + $inst->isTrue(); }); }); @@ -260,3 +259,32 @@ public function registerUser(string $email, string $name = "Daniel"): void { */ +$unit->group("Testing User service", function (TestCase $case) { + + $mailer = $case->mock(Mailer::class, function (MethodRegistry $method) { + $method->method("addFromEmail") + ->called(1); + + $method->method("addBCC") + ->isPublic() + ->hasDocComment() + ->hasParams() + ->paramHasType(0) + ->paramIsType(0, "string") + ->paramHasDefault(2, "Daniel") + ->paramIsOptional(2) + ->paramIsReference(2) + ->called(1); + + $method->method("getFromEmail") + ->willReturn("john.doe@gmail.com"); + + }, [true]); // <-- true is passed as argument 1 to Mailer constructor + + $service = new UserService($mailer); + $case->validate($service->registerUser("john.doe@gmail.com"), function(Expect $inst) { + $inst->isTrue(); + }); + +}); + From 1f472d3753f691aee29a31c397d1a5bf6d2d2024 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Tue, 20 May 2025 22:17:12 +0200 Subject: [PATCH 28/53] Add more dynamic test search --- bin/unitary | 2 +- src/Utils/FileIterator.php | 65 +++++++++++++++++++++++++++----------- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/bin/unitary b/bin/unitary index 699015c..0ca8671 100755 --- a/bin/unitary +++ b/bin/unitary @@ -33,7 +33,7 @@ try { throw new Exception("Test directory '$testDir' does not exist"); } $unit = new FileIterator($data); - $unit->executeAll($testDir); + $unit->executeAll($testDir, $defaultPath); } catch (Exception $e) { $command->error($e->getMessage()); diff --git a/src/Utils/FileIterator.php b/src/Utils/FileIterator.php index 3614d9c..9061ba8 100755 --- a/src/Utils/FileIterator.php +++ b/src/Utils/FileIterator.php @@ -6,6 +6,7 @@ use Closure; use Exception; +use MaplePHP\Blunder\Exceptions\BlunderSoftException; use MaplePHP\Blunder\Handlers\CliHandler; use MaplePHP\Blunder\Run; use MaplePHP\Unitary\Unit; @@ -27,16 +28,27 @@ public function __construct(array $args = []) /** * Will Execute all unitary test files. - * @param string $directory + * @param string $path + * @param string|bool $rootDir * @return void - * @throws RuntimeException + * @throws BlunderSoftException */ - public function executeAll(string $directory): void + public function executeAll(string $path, string|bool $rootDir = false): void { - $files = $this->findFiles($directory); + + $rootDir = $rootDir !== false ? realpath($rootDir) : false; + $path = (!$path && $rootDir) ? $rootDir : $path; + + if($rootDir !== false && !str_starts_with($path, $rootDir)) { + throw new RuntimeException("The test search path (\$path) \"" . $path . "\" does not have the root director (\$rootDir) of \"" . $rootDir . "\"."); + } + + $files = $this->findFiles($path, $rootDir); if (empty($files)) { /* @var string static::PATTERN */ - throw new RuntimeException("No files found matching the pattern \"" . (FileIterator::PATTERN ?? "") . "\" in directory \"$directory\" "); + throw new BlunderSoftException("Unitary could not find any test files matching the pattern \"" . + (FileIterator::PATTERN ?? "") . "\" in directory \"" . dirname($path) . + "\" and its subdirectories."); } else { foreach ($files as $file) { extract($this->args, EXTR_PREFIX_SAME, "wddx"); @@ -62,28 +74,45 @@ public function executeAll(string $directory): void /** * Will Scan and find all unitary test files - * @param string $dir + * @param string $path + * @param string|false $rootDir * @return array */ - private function findFiles(string $dir): array + private function findFiles(string $path, string|bool $rootDir = false): array { $files = []; - $realDir = realpath($dir); + $realDir = realpath($path); if ($realDir === false) { - throw new RuntimeException("Directory \"$dir\" does not exist. Try using a absolut path!"); + throw new RuntimeException("Directory \"$path\" does not exist. Try using a absolut path!"); } - $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir)); - - /** @var string $pattern */ - $pattern = FileIterator::PATTERN; - 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())) { - $files[] = $file->getPathname(); + + if (is_file($path) && str_starts_with(basename($path), "unitary-")) { + $files[] = $path; + } else { + if(is_file($path)) { + $path = dirname($path) . "/"; + } + + if(is_dir($path)) { + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)); + /** @var string $pattern */ + $pattern = FileIterator::PATTERN; + foreach ($iterator as $file) { + if (($file instanceof SplFileInfo) && fnmatch($pattern, $file->getFilename()) && + (!empty($this->args['exclude']) || !str_contains($file->getPathname(), DIRECTORY_SEPARATOR . "vendor" . DIRECTORY_SEPARATOR))) { + if (!$this->findExcluded($this->exclude(), $path, $file->getPathname())) { + $files[] = $file->getPathname(); + } + } } } } + + if($rootDir && count($files) <= 0 && str_starts_with($path, $rootDir)) { + $path = realpath($path . "/..") . "/"; + return $this->findFiles($path, $rootDir); + } + return $files; } From 24f8a8a6b1fa5a6bcb9a6393a91dc1e8b6b86a76 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Tue, 20 May 2025 22:30:37 +0200 Subject: [PATCH 29/53] Alow both absolute and relative argv path when root dir is present --- src/Utils/FileIterator.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Utils/FileIterator.php b/src/Utils/FileIterator.php index 9061ba8..8ff0f59 100755 --- a/src/Utils/FileIterator.php +++ b/src/Utils/FileIterator.php @@ -35,14 +35,11 @@ public function __construct(array $args = []) */ public function executeAll(string $path, string|bool $rootDir = false): void { - $rootDir = $rootDir !== false ? realpath($rootDir) : false; $path = (!$path && $rootDir) ? $rootDir : $path; - - if($rootDir !== false && !str_starts_with($path, $rootDir)) { - throw new RuntimeException("The test search path (\$path) \"" . $path . "\" does not have the root director (\$rootDir) of \"" . $rootDir . "\"."); + if($rootDir !== false && !str_starts_with($path, "/") && !str_starts_with($path, $rootDir)) { + $path = $rootDir . "/" . $path; } - $files = $this->findFiles($path, $rootDir); if (empty($files)) { /* @var string static::PATTERN */ From 555fd20307aad26b075967011aacbf31ffe3bd5e Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Wed, 21 May 2025 22:07:24 +0200 Subject: [PATCH 30/53] Improve CLI styling Make TestConfig immutable Improve IDE interactions --- src/TestCase.php | 4 ++-- src/TestConfig.php | 15 +++++++++------ src/TestUnit.php | 5 +++-- src/Unit.php | 28 ++++++++++++++++++++-------- src/Utils/FileIterator.php | 2 +- tests/unitary-unitary.php | 37 +++++++++++++++++++++++++++++-------- 6 files changed, 64 insertions(+), 27 deletions(-) diff --git a/src/TestCase.php b/src/TestCase.php index 43cf7ad..97ddbfb 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -184,8 +184,8 @@ protected function expectAndValidate( if(is_bool($list)) { $test->setUnit($list, "Validation"); } else { - foreach ($list as $method => $_valid) { - $test->setUnit(false, (string)$method); + foreach ($list as $method => $valid) { + $test->setUnit(false, (string)$method, $valid); } } } diff --git a/src/TestConfig.php b/src/TestConfig.php index 6fd67a3..090e674 100644 --- a/src/TestConfig.php +++ b/src/TestConfig.php @@ -33,8 +33,9 @@ public static function make(string $message): self */ public function setName(string $key): self { - $this->select = $key; - return $this; + $inst = clone $this; + $inst->select = $key; + return $inst; } // Alias for setName() @@ -51,8 +52,9 @@ public function setSelect(string $key): self */ public function setMessage(string $message): self { - $this->message = $message; - return $this; + $inst = clone $this; + $inst->message = $message; + return $inst; } /** @@ -63,8 +65,9 @@ public function setMessage(string $message): self */ public function setSkip(bool $bool = true): self { - $this->skip = $bool; - return $this; + $inst = clone $this; + $inst->skip = $bool; + return $inst; } } \ No newline at end of file diff --git a/src/TestUnit.php b/src/TestUnit.php index 4f816ff..fb11bba 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -64,7 +64,7 @@ public function setTestValue(mixed $value): void public function setUnit( bool|null $valid, null|string|\Closure $validation = null, - array $args = [], + array|bool $args = [], array $compare = [] ): self { @@ -74,7 +74,8 @@ public function setUnit( } if (!is_callable($validation)) { - $valLength = strlen((string)$validation); + $addArgs = is_array($args) ? "(" . implode(", ", $args) . ")" : ""; + $valLength = strlen($validation . $addArgs); if ($validation && $this->valLength < $valLength) { $this->valLength = $valLength; } diff --git a/src/Unit.php b/src/Unit.php index 5cb9cd7..5dfaea3 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -211,6 +211,7 @@ public function execute(): bool // LOOP through each case ob_start(); + $countCases = count($this->cases); foreach ($this->cases as $index => $row) { if (!($row instanceof TestCase)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestCase."); @@ -240,8 +241,6 @@ public function execute(): bool continue; } - - $this->command->message(""); $this->command->message( $flag . " " . @@ -249,6 +248,12 @@ public function execute(): bool " - " . $this->command->getAnsi()->style(["bold", $color], (string)$row->getMessage()) ); + if($show && !$row->hasFailed()) { + $this->command->message(""); + $this->command->message( + $this->command->getAnsi()->style(["italic", $color], "Test file: " . self::$headers['file']) + ); + } if (($show || !$row->getConfig()->skip)) { foreach ($tests as $test) { @@ -267,15 +272,17 @@ public function execute(): bool $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(["bold", "grey"], "Failed on {$trace['file']}:{$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); + $lengthA = $test->getValidationLength(); + $addArgs = is_array($unit['args']) ? "(" . implode(", ", $unit['args']) . ")" : ""; + $validation = "{$unit['validation']}{$addArgs}"; + $title = str_pad($validation, $lengthA); $compare = ""; if ($unit['compare']) { @@ -302,7 +309,10 @@ public function execute(): bool } if ($test->hasValue()) { $this->command->message(""); - $this->command->message($this->command->getAnsi()->bold("Input value: ") . $test->getReadValue()); + $this->command->message( + $this->command->getAnsi()->bold("Input value: ") . + $test->getReadValue() + ); } } } @@ -321,13 +331,14 @@ public function execute(): bool } else { $passed .= $this->command->getAnsi()->style([$color], $row->getCount() . "/" . $row->getTotal()); } + $footer = $passed . $this->command->getAnsi()->style(["italic", "grey"], " - ". $checksum); if (!$show && $row->getConfig()->skip) { $footer = $this->command->getAnsi()->style(["italic", "grey"], $checksum); } - $this->command->message($footer); + $this->command->message(""); } $this->output .= ob_get_clean(); @@ -480,7 +491,7 @@ public static function completed(): void if (self::$current !== null && self::$current->handler === null) { $dot = self::$current->command->getAnsi()->middot(); - self::$current->command->message(""); + //self::$current->command->message(""); self::$current->command->message( self::$current->command->getAnsi()->style( ["italic", "grey"], @@ -488,6 +499,7 @@ public static function completed(): void "Peak memory usage: " . round(memory_get_peak_usage() / 1024, 2) . " KB" ) ); + self::$current->command->message(""); } } diff --git a/src/Utils/FileIterator.php b/src/Utils/FileIterator.php index 8ff0f59..c276264 100755 --- a/src/Utils/FileIterator.php +++ b/src/Utils/FileIterator.php @@ -105,7 +105,7 @@ private function findFiles(string $path, string|bool $rootDir = false): array } } - if($rootDir && count($files) <= 0 && str_starts_with($path, $rootDir)) { + if($rootDir && count($files) <= 0 && str_starts_with($path, $rootDir) && isset($this->args['smart-search'])) { $path = realpath($path . "/..") . "/"; return $this->findFiles($path, $rootDir); } diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index a654f7a..7b5c41f 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -104,19 +104,19 @@ public function registerUser(string $email): bool { $unit = new Unit(); +$config = TestConfig::make("Test 1")->setName("unitary"); -$unit->group(TestConfig::make("Test 1")->setName("unitary")->setSkip(), function (TestCase $case) use($unit) { +$unit->group($config->setSkip(), function (TestCase $case) use($unit) { $stream = $case->mock(Stream::class); $response = new Response($stream); $case->validate($response->getBody()->getContents(), function(Expect $inst) { - assert(1 === 2, "Lore"); + assert(1 == 1, "Lore"); $inst->notHasResponse(); }); -}); -$unit->group("Advanced App Response Test 2", function (TestCase $case) use($unit) { + $stream = $case->mock(Stream::class); $response = new Response($stream); $case->validate($response->getBody()->getContents(), function(Expect $inst) { @@ -124,6 +124,27 @@ public function registerUser(string $email): bool { }); }); +$unit->case($config->setMessage("Testing custom validations"), function ($case) { + + $case->validate("GET", function(Expect $inst) { + assert($inst->isEqualTo("GET")->isValid(), "Assert has failed"); + }); + +}); + +$unit->case($config->setMessage("Validate old Unitary case syntax"), function ($case) { + + $case->add("HelloWorld", [ + "isString" => [], + "User validation" => function($value) { + return $value === "HelloWorld"; + } + ], "Is not a valid port number"); + + $this->add("HelloWorld", [ + "isEqualTo" => ["HelloWorld"], + ], "Failed to validate");; +}); /* @@ -255,10 +276,6 @@ public function registerUser(string $email): bool { }); }); - - */ - - $unit->group("Testing User service", function (TestCase $case) { $mailer = $case->mock(Mailer::class, function (MethodRegistry $method) { @@ -287,4 +304,8 @@ public function registerUser(string $email): bool { }); }); + */ + + + From 702070fd8b8957650ce166b8e9723ca0145fb882 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Fri, 23 May 2025 23:36:34 +0200 Subject: [PATCH 31/53] Mock class identifiers --- src/Mocker/MethodRegistry.php | 9 +++++++++ src/TestCase.php | 9 +++++++++ tests/unitary-unitary.php | 24 +++++++++++++++--------- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/Mocker/MethodRegistry.php b/src/Mocker/MethodRegistry.php index 338ef84..a9b5d16 100644 --- a/src/Mocker/MethodRegistry.php +++ b/src/Mocker/MethodRegistry.php @@ -13,6 +13,15 @@ public function __construct(?MockBuilder $mocker = null) $this->mocker = $mocker; } + /** + * @param string $class + * @return void + */ + public static function reset(string $class): void + { + self::$methods[$class] = []; + } + /** * Access method pool * @param string $class diff --git a/src/TestCase.php b/src/TestCase.php index 97ddbfb..0abcc49 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -285,9 +285,18 @@ public function withMock(string $class, array $args = []): self */ public function buildMock(?Closure $validate = null): mixed { + // We can't make mock immutable as it would reduce usability if (is_callable($validate)) { $this->prepareValidation($this->mocker, $validate); } + /* + if (is_callable($validate)) { + $this->prepareValidation($this->mocker, $validate); + } else { + // However, tests execute linearly and are contained within groups, making reset an effective solution + MethodRegistry::reset($this->mocker->getClassName()); + } + */ try { /** @psalm-suppress MixedReturnStatement */ diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 7b5c41f..ebfdb46 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -104,19 +104,23 @@ public function registerUser(string $email): bool { $unit = new Unit(); -$config = TestConfig::make("Test 1")->setName("unitary"); +$config = TestConfig::make("Testing mocking library")->setName("unitary"); -$unit->group($config->setSkip(), function (TestCase $case) use($unit) { +$unit->group($config, function (TestCase $case) use($unit) { - $stream = $case->mock(Stream::class); + $stream = $case->mock(Stream::class, function (MethodRegistry $method) { + $method->method("getContents") + ->willReturn('') + ->calledAtLeast(1); + }); $response = new Response($stream); $case->validate($response->getBody()->getContents(), function(Expect $inst) { - assert(1 == 1, "Lore"); - $inst->notHasResponse(); + $inst->hasResponse(); }); +}); - +$unit->group($config, function (TestCase $case) use($unit) { $stream = $case->mock(Stream::class); $response = new Response($stream); $case->validate($response->getBody()->getContents(), function(Expect $inst) { @@ -124,12 +128,14 @@ public function registerUser(string $email): bool { }); }); -$unit->case($config->setMessage("Testing custom validations"), function ($case) { +$unit->group($config->setMessage("Testing custom validations"), function ($case) { - $case->validate("GET", function(Expect $inst) { - assert($inst->isEqualTo("GET")->isValid(), "Assert has failed"); + $case->validate("HelloWorld", function(Expect $inst) { + assert($inst->isEqualTo("HelloWorld")->isValid(), "Assert has failed"); }); + assert(1 === 1, "Assert has failed"); + }); $unit->case($config->setMessage("Validate old Unitary case syntax"), function ($case) { From fea5132698b44a26ff259ae865529417798178b4 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Fri, 23 May 2025 23:45:51 +0200 Subject: [PATCH 32/53] Add unique mock identifier --- src/Mocker/MethodRegistry.php | 10 +++++----- src/Mocker/MockBuilder.php | 23 +++++++++-------------- src/Mocker/MockedMethod.php | 2 +- src/TestCase.php | 14 ++------------ tests/unitary-unitary.php | 3 +-- 5 files changed, 18 insertions(+), 34 deletions(-) diff --git a/src/Mocker/MethodRegistry.php b/src/Mocker/MethodRegistry.php index a9b5d16..7080349 100644 --- a/src/Mocker/MethodRegistry.php +++ b/src/Mocker/MethodRegistry.php @@ -4,7 +4,7 @@ class MethodRegistry { - private ?MockBuilder $mocker = null; + private ?MockBuilder $mocker; /** @var array */ private static array $methods = []; @@ -42,8 +42,8 @@ public static function getMethod(string $class, string $name): ?MockedMethod */ public function method(string $name): MockedMethod { - self::$methods[$this->mocker->getClassName()][$name] = new MockedMethod($this->mocker); - return self::$methods[$this->mocker->getClassName()][$name]; + self::$methods[$this->mocker->getMockedClassName()][$name] = new MockedMethod($this->mocker); + return self::$methods[$this->mocker->getMockedClassName()][$name]; } /** @@ -54,7 +54,7 @@ public function method(string $name): MockedMethod */ public function get(string $key): MockedMethod|null { - return self::$methods[$this->mocker->getClassName()][$key] ?? null; + return self::$methods[$this->mocker->getMockedClassName()][$key] ?? null; } /** @@ -75,7 +75,7 @@ public function getAll(): array */ public function has(string $name): bool { - return isset(self::$methods[$this->mocker->getClassName()][$name]); + return isset(self::$methods[$this->mocker->getMockedClassName()][$name]); } } diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index 1e02d3b..276fc56 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -44,6 +44,14 @@ public function __construct(string $className, array $args = []) $this->dataTypeMock = new DataTypeMock(); $this->methods = $this->reflection->getMethods(); $this->constructorArgs = $args; + + $shortClassName = explode("\\", $className); + $shortClassName = end($shortClassName); + /** + * @var class-string $shortClassName + * @psalm-suppress PropertyTypeCoercion + */ + $this->mockClassName = 'Unitary_' . uniqid() . "_Mock_" . $shortClassName; /* // Auto fill the Constructor args! $test = $this->reflection->getConstructor(); @@ -95,13 +103,9 @@ public function getClassArgs(): array * This method should only be called after execute() has been invoked. * * @return string The generated mock class name - * @throws Exception If the mock class name has not been set (execute() hasn't been called) */ public function getMockedClassName(): string { - if (!$this->mockClassName) { - throw new Exception("Mock class name is not set"); - } return $this->mockClassName; } @@ -133,15 +137,6 @@ public function mockDataType(string $dataType, mixed $value, ?string $bindToMeth public function execute(): mixed { $className = $this->reflection->getName(); - - $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); @@ -230,7 +225,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string $this->methodList[] = $methodName; // The MethodItem contains all items that are validatable - $methodItem = MethodRegistry::getMethod($this->getClassName(), $methodName); + $methodItem = MethodRegistry::getMethod($this->getMockedClassName(), $methodName); if($methodItem && $methodItem->keepOriginal) { continue; } diff --git a/src/Mocker/MockedMethod.php b/src/Mocker/MockedMethod.php index 90bf0dd..7d1f38c 100644 --- a/src/Mocker/MockedMethod.php +++ b/src/Mocker/MockedMethod.php @@ -57,7 +57,7 @@ public function wrap(Closure $call): self } $inst = $this; - $wrap = new class ($this->mocker->getClassName(), $this->mocker->getClassArgs()) extends ExecutionWrapper { + $wrap = new class ($this->mocker->getMockedClassName(), $this->mocker->getClassArgs()) extends ExecutionWrapper { public function __construct(string $class, array $args = []) { parent::__construct($class, $args); diff --git a/src/TestCase.php b/src/TestCase.php index 0abcc49..5bbbdad 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -61,7 +61,7 @@ public function __construct(TestConfig|string|null $config = null) * Bind the test case to the Closure * * @param Closure $bind - * @param bool $bindToClosure choose bind to closure or not (Not recommended) + * @param bool $bindToClosure choose bind to closure or not (recommended) * Used primary as a fallback for older versions of Unitary * @return void */ @@ -245,7 +245,7 @@ public function add(mixed $expect, array|Closure $validation, ?string $message = } /** - * initialize 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 @@ -285,19 +285,9 @@ public function withMock(string $class, array $args = []): self */ public function buildMock(?Closure $validate = null): mixed { - // We can't make mock immutable as it would reduce usability if (is_callable($validate)) { $this->prepareValidation($this->mocker, $validate); } - /* - if (is_callable($validate)) { - $this->prepareValidation($this->mocker, $validate); - } else { - // However, tests execute linearly and are contained within groups, making reset an effective solution - MethodRegistry::reset($this->mocker->getClassName()); - } - */ - try { /** @psalm-suppress MixedReturnStatement */ return $this->mocker->execute(); diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index ebfdb46..7130097 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -118,9 +118,8 @@ public function registerUser(string $email): bool { $case->validate($response->getBody()->getContents(), function(Expect $inst) { $inst->hasResponse(); }); -}); -$unit->group($config, function (TestCase $case) use($unit) { + $stream = $case->mock(Stream::class); $response = new Response($stream); $case->validate($response->getBody()->getContents(), function(Expect $inst) { From 0b3ab9340e12c7c88011e9aeb41a15b1e74e653b Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 25 May 2025 13:13:22 +0200 Subject: [PATCH 33/53] Pass meta data to mocked and keep orignial Change TestConfig method names --- src/Mocker/MockBuilder.php | 27 ++++++++++++---------- src/Mocker/MockedMethod.php | 2 +- src/TestConfig.php | 12 +++++----- tests/unitary-unitary.php | 46 ++++++++++++++++++++++++++++--------- 4 files changed, 57 insertions(+), 30 deletions(-) diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index 276fc56..3cae060 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -61,19 +61,20 @@ public function __construct(string $className, array $args = []) } /** - * Adds metadata to the mock method, including the mock class name, return value, - * and a flag indicating whether to keep the original method implementation. + * Adds metadata to the mock method, including the mock class name, return value. + * This is possible custom-added data that "has to" validate against the MockedMethod instance * * @param array $data The base data array to add metadata to * @param string $mockClassName The name of the mock class - * @param mixed $return The return value to be stored in metadata + * @param mixed $returnValue + * @param mixed $methodItem * @return array The data array with added metadata */ - protected function addMockMetadata(array $data, string $mockClassName, mixed $return): array + protected function addMockMetadata(array $data, string $mockClassName, mixed $returnValue, mixed $methodItem): array { $data['mocker'] = $mockClassName; - $data['return'] = $return; - $data['keepOriginal'] = false; + $data['return'] = ($methodItem && $methodItem->hasReturn()) ? $methodItem->return : eval($returnValue); + $data['keepOriginal'] = ($methodItem && $methodItem->keepOriginal) ? $methodItem->keepOriginal : false; return $data; } @@ -226,9 +227,6 @@ protected function generateMockMethodOverrides(string $mockClassName): string // The MethodItem contains all items that are validatable $methodItem = MethodRegistry::getMethod($this->getMockedClassName(), $methodName); - if($methodItem && $methodItem->keepOriginal) { - continue; - } $types = $this->getReturnType($method); $returnValue = $this->getReturnValue($types, $method, $methodItem); @@ -245,10 +243,8 @@ protected function generateMockMethodOverrides(string $mockClassName): string $modifiersArr = Reflection::getModifierNames($method->getModifiers()); $modifiers = implode(" ", $modifiersArr); - $return = ($methodItem && $methodItem->hasReturn()) ? $methodItem->return : eval($returnValue); $arr = $this->getMethodInfoAsArray($method); - $arr = $this->addMockMetadata($arr, $mockClassName, $return); - + $arr = $this->addMockMetadata($arr, $mockClassName, $returnValue, $methodItem); $info = json_encode($arr); if ($info === false) { @@ -260,6 +256,13 @@ protected function generateMockMethodOverrides(string $mockClassName): string $returnValue = $this->generateWrapperReturn($methodItem->getWrap(), $methodName, $returnValue); } + if($methodItem && $methodItem->keepOriginal) { + $returnValue = "parent::$methodName($paramList);"; + if (!in_array('void', $types)) { + $returnValue = "return $returnValue"; + } + } + $safeJson = base64_encode($info); $overrides .= " $modifiers function $methodName($paramList){$returnType} diff --git a/src/Mocker/MockedMethod.php b/src/Mocker/MockedMethod.php index 7d1f38c..90bf0dd 100644 --- a/src/Mocker/MockedMethod.php +++ b/src/Mocker/MockedMethod.php @@ -57,7 +57,7 @@ public function wrap(Closure $call): self } $inst = $this; - $wrap = new class ($this->mocker->getMockedClassName(), $this->mocker->getClassArgs()) extends ExecutionWrapper { + $wrap = new class ($this->mocker->getClassName(), $this->mocker->getClassArgs()) extends ExecutionWrapper { public function __construct(string $class, array $args = []) { parent::__construct($class, $args); diff --git a/src/TestConfig.php b/src/TestConfig.php index 090e674..9f7c702 100644 --- a/src/TestConfig.php +++ b/src/TestConfig.php @@ -31,7 +31,7 @@ public static function make(string $message): self * @param string $key The key to set. * @return self */ - public function setName(string $key): self + public function withName(string $key): self { $inst = clone $this; $inst->select = $key; @@ -41,19 +41,19 @@ public function setName(string $key): self // Alias for setName() public function setSelect(string $key): self { - return $this->setName($key); + return $this->withName($key); } /** * Sets the message for the current instance. * - * @param string $message The message to set. + * @param string $subject The message to set. * @return self */ - public function setMessage(string $message): self + public function withSubject(string $subject): self { $inst = clone $this; - $inst->message = $message; + $inst->message = $subject; return $inst; } @@ -63,7 +63,7 @@ public function setMessage(string $message): self * @param bool $bool Optional. The value to set for the skip state. Defaults to true. * @return self */ - public function setSkip(bool $bool = true): self + public function withSkip(bool $bool = true): self { $inst = clone $this; $inst->skip = $bool; diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 7130097..ba91d0c 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -18,9 +18,11 @@ public function __construct() } - public function send() + public function send(): string { $this->sendEmail($this->getFromEmail()); + + return $this->privateMethod(); } public function sendEmail(string $email, string $name = "daniel"): string @@ -47,6 +49,11 @@ public function getFromEmail(): string return !empty($this->from) ? $this->from : "empty email"; } + private function privateMethod(): string + { + return "HEHEHE"; + } + /** * Add from email address * @@ -104,7 +111,30 @@ public function registerUser(string $email): bool { $unit = new Unit(); -$config = TestConfig::make("Testing mocking library")->setName("unitary"); + +$unit->group("Advanced Mailer Test", function (TestCase $case) use($unit) { + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { + $method->method("send")->keepOriginal(); + + }); + echo $mail->send(); +}); + +$unit->group("Example API Request", function(TestCase $case) { + + $request = new Request("GET", "https://example.com/?page=1&slug=hello-world"); + + $case->validate($request->getMethod(), function(Expect $expect) { + $expect->isRequestMethod(); + }); + + $case->validate($request->getUri()->getQuery(), function(Expect $expect) { + $expect->hasQueryParam("page", 1); + $expect->hasQueryParam("slug", "hello-world"); + }); +}); + +$config = TestConfig::make("Testing mocking library")->withName("unitary")->withSkip(); $unit->group($config, function (TestCase $case) use($unit) { @@ -118,16 +148,9 @@ public function registerUser(string $email): bool { $case->validate($response->getBody()->getContents(), function(Expect $inst) { $inst->hasResponse(); }); - - - $stream = $case->mock(Stream::class); - $response = new Response($stream); - $case->validate($response->getBody()->getContents(), function(Expect $inst) { - $inst->hasResponse(); - }); }); -$unit->group($config->setMessage("Testing custom validations"), function ($case) { +$unit->group($config->withSubject("Testing custom validations"), function ($case) { $case->validate("HelloWorld", function(Expect $inst) { assert($inst->isEqualTo("HelloWorld")->isValid(), "Assert has failed"); @@ -137,7 +160,7 @@ public function registerUser(string $email): bool { }); -$unit->case($config->setMessage("Validate old Unitary case syntax"), function ($case) { +$unit->case($config->withSubject("Validate old Unitary case syntax"), function ($case) { $case->add("HelloWorld", [ "isString" => [], @@ -151,6 +174,7 @@ public function registerUser(string $email): bool { ], "Failed to validate");; }); + /* From 26f8180e0e804e0a8b74aafc267f8c534a271d77 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 25 May 2025 19:58:24 +0200 Subject: [PATCH 34/53] Add interface support for mocking --- src/Mocker/MockBuilder.php | 21 +++++++++++++++------ tests/unitary-unitary.php | 12 ++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index 3cae060..e6cfef2 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -139,10 +139,11 @@ public function execute(): mixed { $className = $this->reflection->getName(); $overrides = $this->generateMockMethodOverrides($this->mockClassName); - $unknownMethod = $this->errorHandleUnknownMethod($className); + $unknownMethod = $this->errorHandleUnknownMethod($className, !$this->reflection->isInterface()); + $extends = $this->reflection->isInterface() ? "implements $className" : "extends $className"; $code = " - class $this->mockClassName extends $className { + class $this->mockClassName $extends { {$overrides} {$unknownMethod} public static function __set_state(array \$an_array): self @@ -153,6 +154,8 @@ public static function __set_state(array \$an_array): self } "; + //print_r($code); + //die; eval($code); /** @@ -170,14 +173,19 @@ public static function __set_state(array \$an_array): self * @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 + private function errorHandleUnknownMethod(string $className, bool $checkOriginal = true): string { if (!in_array('__call', $this->methodList)) { - return " - public function __call(string \$name, array \$arguments) { - if (method_exists(get_parent_class(\$this), '__call')) { + + $checkOriginalCall = $checkOriginal ? " + if (method_exists(get_parent_class(\$this), '__call')) { return parent::__call(\$name, \$arguments); } + " : ""; + + return " + public function __call(string \$name, array \$arguments) { + {$checkOriginalCall} throw new \\BadMethodCallException(\"Method '\$name' does not exist in class '$className'.\"); } "; @@ -241,6 +249,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string } $returnType = ($types) ? ': ' . implode('|', $types) : ''; $modifiersArr = Reflection::getModifierNames($method->getModifiers()); + $modifiersArr = array_filter($modifiersArr, fn($val) => $val !== "abstract"); $modifiers = implode(" ", $modifiersArr); $arr = $this->getMethodInfoAsArray($method); diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index ba91d0c..a71f3e5 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -2,6 +2,7 @@ group("Mocking a PSR-7 Stream", function(TestCase $case) { + // Create a mock of a PSR-7 StreamInterface + $stream = $case->mock(StreamInterface::class); + + // Inject the mock into a Response object + $response = new Response($stream); + + $case->validate($response->getBody(), function(Expect $expect) { + $expect->isInstanceOf(StreamInterface::class); + }); +}); $unit->group("Advanced Mailer Test", function (TestCase $case) use($unit) { $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { From 823c9d63b6b9fb3f1f50dfd40f30258dfea10e0d Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 25 May 2025 22:10:11 +0200 Subject: [PATCH 35/53] feat: Add argument check to mocker --- src/Mocker/MockBuilder.php | 13 +++++-- src/Mocker/MockController.php | 9 ++++- src/Mocker/MockedMethod.php | 64 +++++++++++++++++++++++++++++++++++ src/TestCase.php | 4 ++- tests/unitary-unitary.php | 21 +++++++----- 5 files changed, 99 insertions(+), 12 deletions(-) diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index e6cfef2..350b31f 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -77,7 +77,16 @@ protected function addMockMetadata(array $data, string $mockClassName, mixed $re $data['keepOriginal'] = ($methodItem && $methodItem->keepOriginal) ? $methodItem->keepOriginal : false; return $data; } - + + /** + * Get reflection of the expected class + * @return ReflectionClass + */ + public function getReflectionClass(): ReflectionClass + { + return $this->reflection; + } + /** * Gets the fully qualified name of the class being mocked. * @@ -276,7 +285,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string $overrides .= " $modifiers function $methodName($paramList){$returnType} { - \$obj = \\MaplePHP\\Unitary\\Mocker\\MockController::getInstance()->buildMethodData('$safeJson', true); + \$obj = \\MaplePHP\\Unitary\\Mocker\\MockController::getInstance()->buildMethodData('$safeJson', func_get_args(), true); \$data = \\MaplePHP\\Unitary\\Mocker\\MockController::getDataItem(\$obj->mocker, \$obj->name); {$returnValue} } diff --git a/src/Mocker/MockController.php b/src/Mocker/MockController.php index d486ff6..eb917ef 100644 --- a/src/Mocker/MockController.php +++ b/src/Mocker/MockController.php @@ -76,19 +76,26 @@ 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, bool $isBase64Encoded = false): object + public function buildMethodData(string $method, array $args = [], bool $isBase64Encoded = false): object { $method = $isBase64Encoded ? base64_decode($method) : $method; $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])) { + // This is outside the mocked method + // You can prepare values here with defaults $data->called = 0; + $data->arguments = []; self::$data[$mocker][$name] = $data; // Mocked method has trigger "once"! } else { + // This is the mocked method + // You can overwrite the default with the expected mocked values here if (isset(self::$data[$mocker][$name])) { + self::$data[$mocker][$name]->arguments[] = $args; self::$data[$mocker][$name]->called = (int)self::$data[$mocker][$name]->called + 1; // Mocked method has trigger "More Than" once! } diff --git a/src/Mocker/MockedMethod.php b/src/Mocker/MockedMethod.php index 90bf0dd..e20a965 100644 --- a/src/Mocker/MockedMethod.php +++ b/src/Mocker/MockedMethod.php @@ -3,6 +3,7 @@ namespace MaplePHP\Unitary\Mocker; use BadMethodCallException; +use InvalidArgumentException; use Closure; use MaplePHP\Unitary\TestUtils\ExecutionWrapper; @@ -17,6 +18,7 @@ final class MockedMethod public ?string $class = null; public ?string $name = null; + public array $arguments = []; public ?bool $isStatic = null; public ?bool $isPublic = null; public ?bool $isPrivate = null; @@ -56,6 +58,10 @@ public function wrap(Closure $call): self throw new BadMethodCallException('Mocker is not set. Use the method "mock" to set the mocker.'); } + if($this->mocker->getReflectionClass()->isInterface()) { + throw new BadMethodCallException('You only use "wrap()" on regular classes and not "interfaces".'); + } + $inst = $this; $wrap = new class ($this->mocker->getClassName(), $this->mocker->getClassArgs()) extends ExecutionWrapper { public function __construct(string $class, array $args = []) @@ -113,6 +119,64 @@ public function called(int $times): self return $inst; } + /** + * Validates arguments for the first called method + * + * @example method('addEmail')->withArguments('john.doe@gmail.com', 'John Doe') + * @param mixed ...$args + * @return $this + */ + public function withArguments(mixed ...$args): self + { + $inst = $this; + foreach ($args as $key => $value) { + $inst = $inst->withArgumentAt($key, $value); + } + return $inst; + } + + /** + * Validates arguments for multiple method calls with different argument sets + * + * @example method('addEmail')->withArguments( + * ['john.doe@gmail.com', 'John Doe'], ['jane.doe@gmail.com', 'Jane Doe'] + * ) + * @param mixed ...$args + * @return $this + */ + public function withArgumentsForCalls(mixed ...$args): self + { + $inst = $this; + foreach ($args as $called => $data) { + if(!is_array($data)) { + throw new InvalidArgumentException( + 'The argument must be a array that contains the expected method arguments.' + ); + } + foreach ($data as $key => $value) { + $inst = $inst->withArgumentAt($key, $value, $called); + } + } + return $inst; + } + + /** + * This will validate an argument at position + * + * @param int $called + * @param int $position + * @param mixed $value + * @return $this + */ + public function withArgumentAt(int $position, mixed $value, int $called = 0): self + { + $inst = $this; + $inst->arguments[] = [ + "validateInData" => ["$called.$position", "equal", [$value]], + ]; + return $inst; + } + /** * Check if a method has been called x times * diff --git a/src/TestCase.php b/src/TestCase.php index 5bbbdad..9cdcec7 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -399,6 +399,7 @@ private function validateRow(object $row, MethodRegistry $pool): array continue; } + if(!property_exists($row, $property)) { throw new ErrorException( "The mock method meta data property name '$property' is undefined in mock object. " . @@ -407,11 +408,12 @@ private function validateRow(object $row, MethodRegistry $pool): array ); } $currentValue = $row->{$property}; + if (is_array($value)) { $validPool = $this->validateArrayValue($value, $currentValue); $valid = $validPool->isValid(); - if (is_array($currentValue)) { + $this->compareFromValidCollection($validPool, $value, $currentValue); } } else { diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index a71f3e5..b5a3306 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -111,6 +111,19 @@ public function registerUser(string $email): bool { } $unit = new Unit(); +$unit->group("Advanced Mailer Test", function (TestCase $case) use($unit) { + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { + $method->method("addFromEmail") + ->withArguments("john.doe@gmail.com", "John Doe") + ->withArgumentsForCalls(["john.doe@gmail.com", "John Doe"], ["jane.doe@gmail.com", "Jane Doe"]) + ->called(2); + }); + + $mail->addFromEmail("john.doe@gmail.com", "John Doe"); + $mail->addFromEmail("jane.doe@gmail.com", "Jane Doe"); +}); + +/* $unit->group("Mocking a PSR-7 Stream", function(TestCase $case) { // Create a mock of a PSR-7 StreamInterface @@ -124,13 +137,7 @@ public function registerUser(string $email): bool { }); }); -$unit->group("Advanced Mailer Test", function (TestCase $case) use($unit) { - $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { - $method->method("send")->keepOriginal(); - }); - echo $mail->send(); -}); $unit->group("Example API Request", function(TestCase $case) { @@ -187,8 +194,6 @@ public function registerUser(string $email): bool { }); -/* - $unit->group("Advanced Mailer Test", function (TestCase $case) use($unit) { $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { From 289f38ce3710bf9ec34daf0bec4241f98f4c44f3 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Tue, 27 May 2025 22:03:19 +0200 Subject: [PATCH 36/53] Add will throw method --- src/Mocker/MockBuilder.php | 33 +++++++++++++++++++++++++++++++++ src/Mocker/MockController.php | 1 + src/Mocker/MockedMethod.php | 35 ++++++++++++++++++++++++++++++++--- src/TestCase.php | 1 + tests/unitary-unitary.php | 9 ++++++++- 5 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index 350b31f..7c460fd 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -75,6 +75,7 @@ protected function addMockMetadata(array $data, string $mockClassName, mixed $re $data['mocker'] = $mockClassName; $data['return'] = ($methodItem && $methodItem->hasReturn()) ? $methodItem->return : eval($returnValue); $data['keepOriginal'] = ($methodItem && $methodItem->keepOriginal) ? $methodItem->keepOriginal : false; + $data['throwOnce'] = ($methodItem && $methodItem->throwOnce) ? $methodItem->throwOnce : false; return $data; } @@ -281,12 +282,18 @@ protected function generateMockMethodOverrides(string $mockClassName): string } } + $exception = ($methodItem && $methodItem->getThrowable()) ? $this->handleTrownExceptions($methodItem->getThrowable()) : ""; + $safeJson = base64_encode($info); $overrides .= " $modifiers function $methodName($paramList){$returnType} { \$obj = \\MaplePHP\\Unitary\\Mocker\\MockController::getInstance()->buildMethodData('$safeJson', func_get_args(), true); \$data = \\MaplePHP\\Unitary\\Mocker\\MockController::getDataItem(\$obj->mocker, \$obj->name); + + if(\$data->throwOnce === false || \$data->called <= 1) { + {$exception} + } {$returnValue} } "; @@ -294,6 +301,32 @@ protected function generateMockMethodOverrides(string $mockClassName): string return $overrides; } + protected function handleTrownExceptions(\Throwable $exception) { + $class = get_class($exception); + $reflection = new \ReflectionClass($exception); + $constructor = $reflection->getConstructor(); + $args = []; + if ($constructor) { + foreach ($constructor->getParameters() as $param) { + $name = $param->getName(); + $value = $exception->{$name} ?? null; + switch ($name) { + case 'message': + $value = $exception->getMessage(); + break; + case 'code': + $value = $exception->getCode(); + break; + case 'previous': + $value = null; + break; + } + $args[] = var_export($value, true); + } + } + + return "throw new \\{$class}(" . implode(', ', $args) . ");"; + } /** * Will build the wrapper return diff --git a/src/Mocker/MockController.php b/src/Mocker/MockController.php index eb917ef..cb3f8d8 100644 --- a/src/Mocker/MockController.php +++ b/src/Mocker/MockController.php @@ -89,6 +89,7 @@ public function buildMethodData(string $method, array $args = [], bool $isBase64 // You can prepare values here with defaults $data->called = 0; $data->arguments = []; + $data->throw = null; self::$data[$mocker][$name] = $data; // Mocked method has trigger "once"! } else { diff --git a/src/Mocker/MockedMethod.php b/src/Mocker/MockedMethod.php index e20a965..d313da2 100644 --- a/src/Mocker/MockedMethod.php +++ b/src/Mocker/MockedMethod.php @@ -6,6 +6,7 @@ use InvalidArgumentException; use Closure; use MaplePHP\Unitary\TestUtils\ExecutionWrapper; +use Throwable; /** * @psalm-suppress PossiblyUnusedProperty @@ -13,7 +14,10 @@ final class MockedMethod { private ?MockBuilder $mocker; + private ?Throwable $throwable = null; + public mixed $return = null; + public array $throw = []; public int|array|null $called = null; public ?string $class = null; @@ -36,9 +40,11 @@ final class MockedMethod public ?int $endLine = null; public ?string $fileName = null; public bool $keepOriginal = false; + public bool $throwOnce = false; protected bool $hasReturn = false; protected ?Closure $wrapper = null; + public function __construct(?MockBuilder $mocker = null) { $this->mocker = $mocker; @@ -83,6 +89,16 @@ public function getWrap(): ?Closure return $this->wrapper; } + /** + * Get the throwable if added as Throwable + * + * @return Throwable|null + */ + public function getThrowable(): ?Throwable + { + return $this->throwable; + } + /** * Check if a return value has been added * @@ -128,11 +144,10 @@ public function called(int $times): self */ public function withArguments(mixed ...$args): self { - $inst = $this; foreach ($args as $key => $value) { - $inst = $inst->withArgumentAt($key, $value); + $this->withArgumentAt($key, $value); } - return $inst; + return $this; } /** @@ -235,6 +250,20 @@ public function willReturn(mixed $value): self return $inst; } + public function willThrow(Throwable $throwable) + { + $this->throwable = $throwable; + $this->throw = []; + return $this; + } + + public function willThrowOnce(Throwable $throwable) + { + $this->throwOnce = true; + $this->willThrow($throwable); + return $this; + } + /** * Set the class name. * diff --git a/src/TestCase.php b/src/TestCase.php index 9cdcec7..72d3c1e 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -180,6 +180,7 @@ protected function expectAndValidate( $test->setTestValue($this->value); if ($validation instanceof Closure) { $listArr = $this->buildClosureTest($validation, $description); + foreach ($listArr as $list) { if(is_bool($list)) { $test->setUnit($list, "Validation"); diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index b5a3306..0d325c6 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -116,11 +116,18 @@ public function registerUser(string $email): bool { $method->method("addFromEmail") ->withArguments("john.doe@gmail.com", "John Doe") ->withArgumentsForCalls(["john.doe@gmail.com", "John Doe"], ["jane.doe@gmail.com", "Jane Doe"]) + ->willThrowOnce(new Exception("Lorem ipsum")) ->called(2); }); - $mail->addFromEmail("john.doe@gmail.com", "John Doe"); + try { + $mail->addFromEmail("john.doe@gmail.com", "John Doe"); + } catch (Exception $e) { + + } + $mail->addFromEmail("jane.doe@gmail.com", "Jane Doe"); + }); /* From 2a67a489ccf4574091501e675937d9af3cba14c8 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Thu, 29 May 2025 20:46:09 +0200 Subject: [PATCH 37/53] Add throwable validations to Expect Add test for library --- src/Expect.php | 169 +++++++++++++++++++++++++++++++++++++ src/Mocker/MockBuilder.php | 4 +- src/TestCase.php | 11 ++- src/TestUnit.php | 3 + src/Utils/Helpers.php | 25 ++++++ tests/unitary-unitary.php | 78 +++++++++++++++-- 6 files changed, 277 insertions(+), 13 deletions(-) create mode 100644 src/Utils/Helpers.php diff --git a/src/Expect.php b/src/Expect.php index ce4433a..e6a7016 100644 --- a/src/Expect.php +++ b/src/Expect.php @@ -2,9 +2,178 @@ namespace MaplePHP\Unitary; +use Exception; +use Throwable; use MaplePHP\Validate\ValidationChain; class Expect extends ValidationChain { + protected mixed $initValue = null; + protected Throwable|false|null $except = null; + + /** + * Validate exception instance + * + * @param string|object|callable $compare + * @return $this + * @throws Exception + */ + public function isThrowable(string|object|callable $compare): self + { + if($except = $this->getException()) { + $this->setValue($except); + } + $this->validateExcept(__METHOD__, $compare, fn() => $this->isClass($compare)); + return $this; + } + + /** + * Validate exception message + * + * @param string|callable $compare + * @return $this + * @throws Exception + */ + public function hasThrowableMessage(string|callable $compare): self + { + if($except = $this->getException()) { + $this->setValue($except->getMessage()); + } + $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); + return $this; + } + + /** + * Validate exception code + * + * @param int|callable $compare + * @return $this + * @throws Exception + */ + public function hasThrowableCode(int|callable $compare): self + { + if($except = $this->getException()) { + $this->setValue($except->getCode()); + } + $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); + return $this; + } + + /** + * Validate exception Severity + * + * @param int|callable $compare + * @return $this + * @throws Exception + */ + public function hasThrowableSeverity(int|callable $compare): self + { + if($except = $this->getException()) { + $value = method_exists($except, 'getSeverity') ? $except->getSeverity() : 0; + $this->setValue($value); + } + $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); + return $this; + } + + /** + * Validate exception file + * + * @param string|callable $compare + * @return $this + * @throws Exception + */ + public function hasThrowableFile(string|callable $compare): self + { + if($except = $this->getException()) { + $this->setValue($except->getFile()); + } + $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); + return $this; + } + + /** + * Validate exception line + * + * @param int|callable $compare + * @return $this + * @throws Exception + */ + public function hasThrowableLine(int|callable $compare): self + { + if($except = $this->getException()) { + $this->setValue($except->getLine()); + } + $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); + return $this; + } + + /** + * Helper to validate the exception instance against the provided callable. + * + * @param string $name + * @param string|object|callable $compare + * @param callable $fall + * @return self + */ + protected function validateExcept(string $name, string|object|callable $compare, callable $fall): self + { + $pos = strrpos($name, '::'); + $name = ($pos !== false) ? substr($name, $pos + 2) : $name; + $this->mapErrorValidationName($name); + if(is_callable($compare)) { + $compare($this); + } else { + $fall($this); + } + + if(is_null($this->initValue)) { + $this->initValue = $this->getValue(); + } + + if($this->except === false) { + $this->setValue(null); + } + return $this; + } + + /** + * Used to get the first value before any validation is performed and + * any changes to the value are made. + * + * @return mixed + */ + public function getInitValue(): mixed + { + return $this->initValue; + } + + /** + * Retrieves the exception instance if one has been caught, + * otherwise attempts to invoke a callable value to detect any exception. + * + * @return Throwable|false Returns the caught exception if available, or false if no exception occurs. + * @throws Exception Throws an exception if the provided value is not callable. + */ + protected function getException(): Throwable|false + { + if (!is_null($this->except)) { + return $this->except; + } + + if(!is_callable($this->getValue())) { + throw new Exception("Except method only accepts callable"); + } + try { + $expect = $this->getValue(); + $expect(); + $this->except = false; + } catch (Throwable $exception) { + $this->except = $exception; + return $this->except; + } + return false; + + } } \ No newline at end of file diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index 7c460fd..46112c6 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -1,4 +1,5 @@ setTestValue($this->value); if ($validation instanceof Closure) { - $listArr = $this->buildClosureTest($validation, $description); + $validPool = new Expect($this->value); + $listArr = $this->buildClosureTest($validation, $validPool, $description); foreach ($listArr as $list) { if(is_bool($list)) { @@ -190,6 +191,11 @@ protected function expectAndValidate( } } } + // In some rare cases the validation value might change along the rode + // tell the test to use the new value + $initValue = $validPool->getInitValue(); + $initValue = ($initValue !== null) ? $initValue : $this->getValue(); + $test->setTestValue($initValue); } else { foreach ($validation as $method => $args) { if (!($args instanceof Closure) && !is_array($args)) { @@ -636,10 +642,9 @@ public function getTest(): array * @param string|null $message * @return array */ - protected function buildClosureTest(Closure $validation, ?string $message = null): array + protected function buildClosureTest(Closure $validation, Expect $validPool, ?string $message = null): array { //$bool = false; - $validPool = new Expect($this->value); $validation = $validation->bindTo($validPool); $error = []; if ($validation !== null) { diff --git a/src/TestUnit.php b/src/TestUnit.php index fb11bba..abaf585 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -216,6 +216,9 @@ public function getReadValue(mixed $value = null, bool $minify = false): string| if (is_array($value)) { return '"' . $this->excerpt(json_encode($value)) . '"' . ($minify ? "" : " (type: array)"); } + if (is_callable($value)) { + return '"' . $this->excerpt(get_class((object)$value)) . '"' . ($minify ? "" : " (type: callable)"); + } if (is_object($value)) { return '"' . $this->excerpt(get_class($value)) . '"' . ($minify ? "" : " (type: object)"); } diff --git a/src/Utils/Helpers.php b/src/Utils/Helpers.php new file mode 100644 index 0000000..d43524d --- /dev/null +++ b/src/Utils/Helpers.php @@ -0,0 +1,25 @@ +group("Advanced Mailer Test", function (TestCase $case) use($unit) { + +$unit->group("Test mocker", function (TestCase $case) use($unit) { + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("addFromEmail") ->withArguments("john.doe@gmail.com", "John Doe") ->withArgumentsForCalls(["john.doe@gmail.com", "John Doe"], ["jane.doe@gmail.com", "Jane Doe"]) - ->willThrowOnce(new Exception("Lorem ipsum")) + ->willThrowOnce(new InvalidArgumentException("Lorem ipsum")) ->called(2); - }); - try { - $mail->addFromEmail("john.doe@gmail.com", "John Doe"); - } catch (Exception $e) { + $method->method("addBCC") + ->isPublic() + ->hasDocComment() + ->hasParams() + ->paramHasType(0) + ->paramIsType(0, "string") + ->paramHasDefault(1, "Daniel") + ->paramIsOptional(1) + ->paramIsReference(2) + ->called(0); + }); - } + $case->validate(fn() => $mail->addFromEmail("john.doe@gmail.com", "John Doe"), function(Expect $inst) { + $inst->isThrowable(InvalidArgumentException::class); + }); $mail->addFromEmail("jane.doe@gmail.com", "Jane Doe"); + $case->error("Test all exception validations") + ->validate(fn() => throw new ErrorException("Lorem ipsum", 1, 1, "example.php", 22), function(Expect $inst, Traverse $obj) { + $inst->isThrowable(ErrorException::class); + $inst->hasThrowableMessage("Lorem ipsum"); + $inst->hasThrowableSeverity(1); + $inst->hasThrowableCode(1); + $inst->hasThrowableFile("example.php"); + $inst->hasThrowableLine(22); + }); + + $case->validate(fn() => throw new TypeError("Lorem ipsum"), function(Expect $inst, Traverse $obj) { + $inst->isThrowable(TypeError::class); + }); }); -/* +$config = TestConfig::make("Mocking response")->withName("unitary"); + +$unit->group($config, function (TestCase $case) use($unit) { + + $stream = $case->mock(Stream::class, function (MethodRegistry $method) { + $method->method("getContents") + ->willReturn('HelloWorld') + ->calledAtLeast(1); + }); + $response = new Response($stream); + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->hasResponse(); + $inst->isEqualTo('HelloWorld'); + $inst->notIsEqualTo('HelloWorldNot'); + }); +}); + +$unit->group($config->withSubject("Assert validations"), function ($case) { + $case->validate("HelloWorld", function(Expect $inst) { + assert($inst->isEqualTo("HelloWorld")->isValid(), "Assert has failed"); + }); + assert(1 === 1, "Assert has failed"); +}); + +$unit->case($config->withSubject("Old validation syntax"), function ($case) { + $case->add("HelloWorld", [ + "isString" => [], + "User validation" => function($value) { + return $value === "HelloWorld"; + } + ], "Is not a valid port number"); + + $this->add("HelloWorld", [ + "isEqualTo" => ["HelloWorld"], + ], "Failed to validate"); +}); + + +/* $unit->group("Mocking a PSR-7 Stream", function(TestCase $case) { // Create a mock of a PSR-7 StreamInterface $stream = $case->mock(StreamInterface::class); From 636d3b6987eaf25b13a29da3da0c7f02228ed9fb Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Thu, 29 May 2025 20:58:47 +0200 Subject: [PATCH 38/53] bugfix: mocker method argument inheritance --- src/Mocker/MockBuilder.php | 6 ++- tests/unitary-unitary.php | 84 +++++--------------------------------- 2 files changed, 14 insertions(+), 76 deletions(-) diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index 46112c6..df94bdb 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -165,7 +165,9 @@ public static function __set_state(array \$an_array): self } "; - //Helpers::createFile() + //print_r($code); + //die; + //Helpers::createFile($this->mockClassName, $code); eval($code); /** @@ -276,7 +278,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string } if($methodItem && $methodItem->keepOriginal) { - $returnValue = "parent::$methodName($paramList);"; + $returnValue = "parent::$methodName(...func_get_args());"; if (!in_array('void', $types)) { $returnValue = "return $returnValue"; } diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 8bcfc29..622429e 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -112,6 +112,8 @@ public function registerUser(string $email): bool { $unit = new Unit(); + + $unit->group("Test mocker", function (TestCase $case) use($unit) { $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { @@ -192,86 +194,20 @@ public function registerUser(string $email): bool { ], "Failed to validate"); }); - -/* -$unit->group("Mocking a PSR-7 Stream", function(TestCase $case) { - // Create a mock of a PSR-7 StreamInterface - $stream = $case->mock(StreamInterface::class); - - // Inject the mock into a Response object - $response = new Response($stream); - - $case->validate($response->getBody(), function(Expect $expect) { - $expect->isInstanceOf(StreamInterface::class); - }); -}); - - - -$unit->group("Example API Request", function(TestCase $case) { - - $request = new Request("GET", "https://example.com/?page=1&slug=hello-world"); - - $case->validate($request->getMethod(), function(Expect $expect) { - $expect->isRequestMethod(); - }); - - $case->validate($request->getUri()->getQuery(), function(Expect $expect) { - $expect->hasQueryParam("page", 1); - $expect->hasQueryParam("slug", "hello-world"); - }); -}); - -$config = TestConfig::make("Testing mocking library")->withName("unitary")->withSkip(); - -$unit->group($config, function (TestCase $case) use($unit) { - - $stream = $case->mock(Stream::class, function (MethodRegistry $method) { - $method->method("getContents") - ->willReturn('') - ->calledAtLeast(1); - }); - $response = new Response($stream); - - $case->validate($response->getBody()->getContents(), function(Expect $inst) { - $inst->hasResponse(); - }); -}); - -$unit->group($config->withSubject("Testing custom validations"), function ($case) { - - $case->validate("HelloWorld", function(Expect $inst) { - assert($inst->isEqualTo("HelloWorld")->isValid(), "Assert has failed"); - }); - - assert(1 === 1, "Assert has failed"); - -}); - -$unit->case($config->withSubject("Validate old Unitary case syntax"), function ($case) { - - $case->add("HelloWorld", [ - "isString" => [], - "User validation" => function($value) { - return $value === "HelloWorld"; - } - ], "Is not a valid port number"); - - $this->add("HelloWorld", [ - "isEqualTo" => ["HelloWorld"], - ], "Failed to validate");; -}); - - - -$unit->group("Advanced Mailer Test", function (TestCase $case) use($unit) { +$unit->group("Validate partial mock", function (TestCase $case) use($unit) { $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("send")->keepOriginal(); + $method->method("isValidEmail")->keepOriginal(); $method->method("sendEmail")->keepOriginal(); }); - $mail->send(); + + $case->validate(fn() => $mail->send(), function(Expect $inst) { + $inst->hasThrowableMessage("Invalid email"); + }); }); + +/* $unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { From e8d5a94ba010513648108916c3b560e363f4f120 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Fri, 30 May 2025 16:57:01 +0200 Subject: [PATCH 39/53] Data type and code quality improvements --- src/Expect.php | 19 ++++-- src/Mocker/MethodRegistry.php | 17 ++++- src/Mocker/MockBuilder.php | 41 ++++++++---- src/Mocker/MockController.php | 1 + src/Mocker/MockedMethod.php | 6 +- src/Setup/assert-polyfill.php | 4 +- src/TestCase.php | 82 +++++++++++++---------- src/TestConfig.php | 2 +- src/TestUnit.php | 37 ++++++----- src/TestUtils/DataTypeMock.php | 15 +++-- src/TestUtils/ExecutionWrapper.php | 13 +++- src/Unit.php | 39 +++++------ src/Utils/FileIterator.php | 14 ++-- src/Utils/Helpers.php | 56 +++++++++++++++- src/Utils/Performance.php | 2 +- tests/unitary-unitary.php | 102 +++++++++++++++++++++++++++-- 16 files changed, 328 insertions(+), 122 deletions(-) diff --git a/src/Expect.php b/src/Expect.php index e6a7016..a3199ff 100644 --- a/src/Expect.php +++ b/src/Expect.php @@ -6,6 +6,9 @@ use Throwable; use MaplePHP\Validate\ValidationChain; +/** + * @api + */ class Expect extends ValidationChain { @@ -24,7 +27,8 @@ public function isThrowable(string|object|callable $compare): self if($except = $this->getException()) { $this->setValue($except); } - $this->validateExcept(__METHOD__, $compare, fn() => $this->isClass($compare)); + /** @psalm-suppress PossiblyInvalidCast */ + $this->validateExcept(__METHOD__, $compare, fn() => $this->isClass((string)$compare)); return $this; } @@ -40,6 +44,7 @@ public function hasThrowableMessage(string|callable $compare): self if($except = $this->getException()) { $this->setValue($except->getMessage()); } + /** @psalm-suppress PossiblyInvalidCast */ $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); return $this; } @@ -56,6 +61,7 @@ public function hasThrowableCode(int|callable $compare): self if($except = $this->getException()) { $this->setValue($except->getCode()); } + /** @psalm-suppress PossiblyInvalidCast */ $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); return $this; } @@ -73,6 +79,7 @@ public function hasThrowableSeverity(int|callable $compare): self $value = method_exists($except, 'getSeverity') ? $except->getSeverity() : 0; $this->setValue($value); } + /** @psalm-suppress PossiblyInvalidCast */ $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); return $this; } @@ -89,6 +96,7 @@ public function hasThrowableFile(string|callable $compare): self if($except = $this->getException()) { $this->setValue($except->getFile()); } + /** @psalm-suppress PossiblyInvalidCast */ $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); return $this; } @@ -105,6 +113,7 @@ public function hasThrowableLine(int|callable $compare): self if($except = $this->getException()) { $this->setValue($except->getLine()); } + /** @psalm-suppress PossiblyInvalidCast */ $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); return $this; } @@ -113,11 +122,11 @@ public function hasThrowableLine(int|callable $compare): self * Helper to validate the exception instance against the provided callable. * * @param string $name - * @param string|object|callable $compare + * @param string|int|object|callable $compare * @param callable $fall * @return self */ - protected function validateExcept(string $name, string|object|callable $compare, callable $fall): self + protected function validateExcept(string $name, int|string|object|callable $compare, callable $fall): self { $pos = strrpos($name, '::'); $name = ($pos !== false) ? substr($name, $pos + 2) : $name; @@ -162,11 +171,11 @@ protected function getException(): Throwable|false return $this->except; } - if(!is_callable($this->getValue())) { + $expect = $this->getValue(); + if(!is_callable($expect)) { throw new Exception("Except method only accepts callable"); } try { - $expect = $this->getValue(); $expect(); $this->except = false; } catch (Throwable $exception) { diff --git a/src/Mocker/MethodRegistry.php b/src/Mocker/MethodRegistry.php index 7080349..7990819 100644 --- a/src/Mocker/MethodRegistry.php +++ b/src/Mocker/MethodRegistry.php @@ -5,7 +5,7 @@ class MethodRegistry { private ?MockBuilder $mocker; - /** @var array */ + /** @var array> */ private static array $methods = []; public function __construct(?MockBuilder $mocker = null) @@ -30,7 +30,11 @@ public static function reset(string $class): void */ public static function getMethod(string $class, string $name): ?MockedMethod { - return self::$methods[$class][$name] ?? null; + $mockedMethod = self::$methods[$class][$name] ?? null; + if($mockedMethod instanceof MockedMethod) { + return $mockedMethod; + } + return null; } /** @@ -42,6 +46,9 @@ public static function getMethod(string $class, string $name): ?MockedMethod */ public function method(string $name): MockedMethod { + if(is_null($this->mocker)) { + throw new \BadMethodCallException("MockBuilder is not set yet."); + } self::$methods[$this->mocker->getMockedClassName()][$name] = new MockedMethod($this->mocker); return self::$methods[$this->mocker->getMockedClassName()][$name]; } @@ -54,6 +61,9 @@ public function method(string $name): MockedMethod */ public function get(string $key): MockedMethod|null { + if(is_null($this->mocker)) { + throw new \BadMethodCallException("MockBuilder is not set yet."); + } return self::$methods[$this->mocker->getMockedClassName()][$key] ?? null; } @@ -75,6 +85,9 @@ public function getAll(): array */ public function has(string $name): bool { + if(is_null($this->mocker)) { + throw new \BadMethodCallException("MockBuilder is not set yet."); + } return isset(self::$methods[$this->mocker->getMockedClassName()][$name]); } diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index df94bdb..bd3ce0a 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -61,6 +61,11 @@ public function __construct(string $className, array $args = []) */ } + protected function getMockClass(?MockedMethod $methodItem, callable $call, mixed $fallback = null): mixed + { + return ($methodItem instanceof MockedMethod) ? $call($methodItem) : $fallback; + } + /** * Adds metadata to the mock method, including the mock class name, return value. * This is possible custom-added data that "has to" validate against the MockedMethod instance @@ -71,12 +76,12 @@ public function __construct(string $className, array $args = []) * @param mixed $methodItem * @return array The data array with added metadata */ - protected function addMockMetadata(array $data, string $mockClassName, mixed $returnValue, mixed $methodItem): array + protected function addMockMetadata(array $data, string $mockClassName, mixed $returnValue, ?MockedMethod $methodItem): array { $data['mocker'] = $mockClassName; - $data['return'] = ($methodItem && $methodItem->hasReturn()) ? $methodItem->return : eval($returnValue); - $data['keepOriginal'] = ($methodItem && $methodItem->keepOriginal) ? $methodItem->keepOriginal : false; - $data['throwOnce'] = ($methodItem && $methodItem->throwOnce) ? $methodItem->throwOnce : false; + $data['return'] = ($methodItem instanceof MockedMethod && $methodItem->hasReturn()) ? $methodItem->return : eval($returnValue); + $data['keepOriginal'] = ($methodItem instanceof MockedMethod && $methodItem->keepOriginal) ? $methodItem->keepOriginal : false; + $data['throwOnce'] = ($methodItem instanceof MockedMethod && $methodItem->throwOnce) ? $methodItem->throwOnce : false; return $data; } @@ -118,7 +123,7 @@ public function getClassArgs(): array */ public function getMockedClassName(): string { - return $this->mockClassName; + return (string)$this->mockClassName; } /** @@ -132,7 +137,7 @@ public function getMockedClassName(): string */ public function mockDataType(string $dataType, mixed $value, ?string $bindToMethod = null): self { - if($bindToMethod) { + if($bindToMethod !== null && $bindToMethod) { $this->dataTypeMock = $this->dataTypeMock->withCustomBoundDefault($bindToMethod, $dataType, $value); } else { $this->dataTypeMock = $this->dataTypeMock->withCustomDefault($dataType, $value); @@ -149,7 +154,7 @@ public function mockDataType(string $dataType, mixed $value, ?string $bindToMeth public function execute(): mixed { $className = $this->reflection->getName(); - $overrides = $this->generateMockMethodOverrides($this->mockClassName); + $overrides = $this->generateMockMethodOverrides((string)$this->mockClassName); $unknownMethod = $this->errorHandleUnknownMethod($className, !$this->reflection->isInterface()); $extends = $this->reflection->isInterface() ? "implements $className" : "extends $className"; @@ -170,6 +175,10 @@ public static function __set_state(array \$an_array): self //Helpers::createFile($this->mockClassName, $code); eval($code); + if(!is_string($this->mockClassName)) { + throw new Exception("Mock class name is not a string"); + } + /** * @psalm-suppress MixedMethodCall * @psalm-suppress InvalidStringClass @@ -215,7 +224,10 @@ protected function getReturnValue(array $types, mixed $method, ?MockedMethod $me { // Will overwrite the auto generated value if ($methodItem && $methodItem->hasReturn()) { - return "return " . var_export($methodItem->return, true) . ";"; + return " + \$returnData = " . var_export($methodItem->return, true) . "; + return \$returnData[\$data->called-1] ?? \$returnData[0]; + "; } if ($types) { return (string)$this->getMockValueForType((string)$types[0], $method); @@ -284,7 +296,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string } } - $exception = ($methodItem && $methodItem->getThrowable()) ? $this->handleTrownExceptions($methodItem->getThrowable()) : ""; + $exception = ($methodItem && $methodItem->getThrowable()) ? $this->handleThrownExceptions($methodItem->getThrowable()) : ""; $safeJson = base64_encode($info); $overrides .= " @@ -303,7 +315,14 @@ protected function generateMockMethodOverrides(string $mockClassName): string return $overrides; } - protected function handleTrownExceptions(\Throwable $exception) { + /** + * Will mocked handle the thrown exception + * + * @param \Throwable $exception + * @return string + */ + protected function handleThrownExceptions(\Throwable $exception): string + { $class = get_class($exception); $reflection = new \ReflectionClass($exception); $constructor = $reflection->getConstructor(); @@ -403,7 +422,7 @@ protected function getReturnType(ReflectionMethod $method): array } elseif ($returnType instanceof ReflectionIntersectionType) { $intersect = array_map( - fn ($type) => $type instanceof ReflectionNamedType ? $type->getName() : (string) $type, + fn (ReflectionNamedType $type) => $type->getName(), $returnType->getTypes() ); $types[] = $intersect; diff --git a/src/Mocker/MockController.php b/src/Mocker/MockController.php index cb3f8d8..d24eb3f 100644 --- a/src/Mocker/MockController.php +++ b/src/Mocker/MockController.php @@ -96,6 +96,7 @@ public function buildMethodData(string $method, array $args = [], bool $isBase64 // This is the mocked method // You can overwrite the default with the expected mocked values here if (isset(self::$data[$mocker][$name])) { + /** @psalm-suppress MixedArrayAssignment */ self::$data[$mocker][$name]->arguments[] = $args; self::$data[$mocker][$name]->called = (int)self::$data[$mocker][$name]->called + 1; // Mocked method has trigger "More Than" once! diff --git a/src/Mocker/MockedMethod.php b/src/Mocker/MockedMethod.php index d313da2..72be003 100644 --- a/src/Mocker/MockedMethod.php +++ b/src/Mocker/MockedMethod.php @@ -242,7 +242,7 @@ public function calledAtMost(int $times): self * @param mixed $value * @return $this */ - public function willReturn(mixed $value): self + public function willReturn(mixed ...$value): self { $inst = $this; $inst->hasReturn = true; @@ -250,14 +250,14 @@ public function willReturn(mixed $value): self return $inst; } - public function willThrow(Throwable $throwable) + public function willThrow(Throwable $throwable): self { $this->throwable = $throwable; $this->throw = []; return $this; } - public function willThrowOnce(Throwable $throwable) + public function willThrowOnce(Throwable $throwable): self { $this->throwOnce = true; $this->willThrow($throwable); diff --git a/src/Setup/assert-polyfill.php b/src/Setup/assert-polyfill.php index 17db104..5753be4 100644 --- a/src/Setup/assert-polyfill.php +++ b/src/Setup/assert-polyfill.php @@ -12,11 +12,11 @@ */ if (PHP_VERSION_ID < 80400) { - if (!ini_get('assert.active')) { + if (ini_get('assert.active') === false) { ini_set('assert.active', 1); } - if (!ini_get('assert.exception')) { + if (ini_get('assert.exception') === false) { ini_set('assert.exception', 1); } } diff --git a/src/TestCase.php b/src/TestCase.php index c1da864..92d76e1 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -28,19 +28,23 @@ */ final class TestCase { + /** + * List of properties to exclude from validation + * (some properties are not valid for comparison) + * + * @var array + */ + private const EXCLUDE_VALIDATE = ["return"]; + private mixed $value; private TestConfig $config; - private ?string $message = null; private array $test = []; private int $count = 0; private ?Closure $bind = null; private ?string $errorMessage = null; private array $deferredValidation = []; - /** @var MockBuilder */ - private MockBuilder $mocker; - /** - * @var true - */ + + private ?MockBuilder $mocker = null; private bool $hasAssertError = false; /** @@ -51,7 +55,7 @@ final class TestCase public function __construct(TestConfig|string|null $config = null) { if (!($config instanceof TestConfig)) { - $this->config = new TestConfig($config); + $this->config = new TestConfig((string)$config); } else { $this->config = $config; } @@ -114,7 +118,7 @@ public function dispatchTest(self &$row): array ); } catch (Throwable $e) { if(str_contains($e->getFile(), "eval()")) { - throw new BlunderErrorException($e->getMessage(), $e->getCode()); + throw new BlunderErrorException($e->getMessage(), (int)$e->getCode()); } throw $e; } @@ -187,6 +191,7 @@ protected function expectAndValidate( $test->setUnit($list, "Validation"); } else { foreach ($list as $method => $valid) { + /** @var array|bool $valid */ $test->setUnit(false, (string)$method, $valid); } } @@ -205,7 +210,7 @@ protected function expectAndValidate( } } if (!$test->isValid()) { - if(!$trace) { + if($trace === null || $trace === []) { $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; } @@ -292,6 +297,9 @@ public function withMock(string $class, array $args = []): self */ public function buildMock(?Closure $validate = null): mixed { + if(!($this->mocker instanceof MockBuilder)) { + throw new BadMethodCallException("The mocker is not set yet!"); + } if (is_callable($validate)) { $this->prepareValidation($this->mocker, $validate); } @@ -299,7 +307,7 @@ public function buildMock(?Closure $validate = null): mixed /** @psalm-suppress MixedReturnStatement */ return $this->mocker->execute(); } catch (Throwable $e) { - throw new BlunderErrorException($e->getMessage(), $e->getCode()); + throw new BlunderErrorException($e->getMessage(), (int)$e->getCode()); } } @@ -310,7 +318,6 @@ public function buildMock(?Closure $validate = null): mixed * A validation closure can also be provided to define mock expectations. These * validations are deferred and will be executed later via runDeferredValidations(). * - * @template T of object * @param class-string $class * @param Closure|null $validate * @param array $args @@ -325,6 +332,9 @@ public function mock(string $class, ?Closure $validate = null, array $args = []) public function getMocker(): MockBuilder { + if(!($this->mocker instanceof MockBuilder)) { + throw new BadMethodCallException("The mocker is not set yet!"); + } return $this->mocker; } @@ -373,8 +383,8 @@ private function runValidation(MockBuilder $mocker, MethodRegistry $pool): array throw new ErrorException("Could not get data from mocker!"); } foreach ($data as $row) { - if (is_object($row) && isset($row->name) && $pool->has($row->name)) { - $error[(string)$row->name] = $this->validateRow($row, $pool); + if (is_object($row) && isset($row->name) && is_string($row->name) && $pool->has($row->name)) { + $error[$row->name] = $this->validateRow($row, $pool); } } return $error; @@ -400,13 +410,11 @@ private function validateRow(object $row, MethodRegistry $pool): array } $errors = []; - foreach (get_object_vars($item) as $property => $value) { if ($value === null) { continue; } - if(!property_exists($row, $property)) { throw new ErrorException( "The mock method meta data property name '$property' is undefined in mock object. " . @@ -416,24 +424,24 @@ private function validateRow(object $row, MethodRegistry $pool): array } $currentValue = $row->{$property}; - if (is_array($value)) { - $validPool = $this->validateArrayValue($value, $currentValue); - $valid = $validPool->isValid(); - if (is_array($currentValue)) { - - $this->compareFromValidCollection($validPool, $value, $currentValue); + if(!in_array($property, self::EXCLUDE_VALIDATE)) { + if (is_array($value)) { + $validPool = $this->validateArrayValue($value, $currentValue); + $valid = $validPool->isValid(); + if (is_array($currentValue)) { + $this->compareFromValidCollection($validPool, $value, $currentValue); + } + } else { + /** @psalm-suppress MixedArgument */ + $valid = Validator::value($currentValue)->equal($value); } - } else { - /** @psalm-suppress MixedArgument */ - $valid = Validator::value($currentValue)->equal($value); + $errors[] = [ + "property" => $property, + "currentValue" => $currentValue, + "expectedValue" => $value, + "valid" => $valid + ]; } - - $errors[] = [ - "property" => $property, - "currentValue" => $currentValue, - "expectedValue" => $value, - "valid" => $valid - ]; } return $errors; @@ -529,6 +537,7 @@ public function runDeferredValidations(): array /** @var callable $row['call'] */ $error = $row['call'](); $hasValidated = []; + /** @var string $method */ foreach ($error as $method => $arr) { $test = new TestUnit("Mock method \"$method\" failed"); if (isset($row['trace']) && is_array($row['trace'])) { @@ -536,12 +545,13 @@ public function runDeferredValidations(): array } foreach ($arr as $data) { // We do not want to validate the return here automatically - if($data['property'] !== "return") { - /** @var array{expectedValue: mixed, currentValue: mixed} $data */ + /** @var array{property: string} $data */ + if(!in_array($data['property'], self::EXCLUDE_VALIDATE)) { + /** @var array{valid: bool|null, expectedValue: mixed, currentValue: mixed} $data */ $test->setUnit($data['valid'], $data['property'], [], [ $data['expectedValue'], $data['currentValue'] ]); - if (!isset($hasValidated[$method]) && !$data['valid']) { + if (!isset($hasValidated[$method]) && $data['valid'] === null || $data['valid'] === false) { $hasValidated[$method] = true; $this->count++; } @@ -747,8 +757,8 @@ public function listAllProxyMethods(string $class, ?string $prefixMethods = null } $params = array_map(function ($param) { - $type = $param->hasType() ? $param->getType() . ' ' : ''; - $value = $param->isDefaultValueAvailable() ? ' = ' . Str::value($param->getDefaultValue())->exportReadableValue()->get() : null; + $type = $param->hasType() ? (string)$param->getType() . ' ' : ''; + $value = $param->isDefaultValueAvailable() ? ' = ' . (string)Str::value($param->getDefaultValue())->exportReadableValue()->get() : ""; return $type . '$' . $param->getName() . $value; }, $method->getParameters()); diff --git a/src/TestConfig.php b/src/TestConfig.php index 9f7c702..82cbb63 100644 --- a/src/TestConfig.php +++ b/src/TestConfig.php @@ -2,7 +2,7 @@ namespace MaplePHP\Unitary; -class TestConfig +final class TestConfig { public ?string $message; diff --git a/src/TestUnit.php b/src/TestUnit.php index abaf585..5fb1b2e 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -6,8 +6,9 @@ use ErrorException; use MaplePHP\DTO\Format\Str; +use MaplePHP\Unitary\Utils\Helpers; -class TestUnit +final class TestUnit { private bool $valid; private mixed $value = null; @@ -56,7 +57,7 @@ public function setTestValue(mixed $value): void * * @param bool|null $valid can be null if validation should execute later * @param string|null|\Closure $validation - * @param array $args + * @param array|bool $args * @param array $compare * @return $this * @throws ErrorException @@ -73,8 +74,8 @@ public function setUnit( $this->count++; } - if (!is_callable($validation)) { - $addArgs = is_array($args) ? "(" . implode(", ", $args) . ")" : ""; + if (is_string($validation)) { + $addArgs = is_array($args) ? "(" . Helpers::stringifyArgs($args) . ")" : ""; $valLength = strlen($validation . $addArgs); if ($validation && $this->valLength < $valLength) { $this->valLength = $valLength; @@ -113,20 +114,22 @@ public function getValidationLength(): int public function setCodeLine(array $trace): self { $this->codeLine = []; - $file = $trace['file'] ?? ''; - $line = $trace['line'] ?? 0; - if ($file && $line) { - $lines = file($file); + $file = (string)($trace['file'] ?? ''); + $line = (int)($trace['line'] ?? 0); + $lines = file($file); + $code = ""; + if($lines !== false) { $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; } + + $this->codeLine['line'] = $line; + $this->codeLine['file'] = $file; + $this->codeLine['code'] = $code; + return $this; } @@ -195,10 +198,10 @@ public function getValue(): mixed * * @param mixed|null $value * @param bool $minify - * @return string|bool + * @return string * @throws ErrorException */ - public function getReadValue(mixed $value = null, bool $minify = false): string|bool + public function getReadValue(mixed $value = null, bool $minify = false): string { $value = $value === null ? $this->value : $value; if (is_bool($value)) { @@ -214,7 +217,11 @@ public function getReadValue(mixed $value = null, bool $minify = false): string| return '"' . $this->excerpt($value) . '"' . ($minify ? "" : " (type: string)"); } if (is_array($value)) { - return '"' . $this->excerpt(json_encode($value)) . '"' . ($minify ? "" : " (type: array)"); + $json = json_encode($value); + if($json === false) { + return "(unknown type)"; + } + return '"' . $this->excerpt($json) . '"' . ($minify ? "" : " (type: array)"); } if (is_callable($value)) { return '"' . $this->excerpt(get_class((object)$value)) . '"' . ($minify ? "" : " (type: callable)"); diff --git a/src/TestUtils/DataTypeMock.php b/src/TestUtils/DataTypeMock.php index 6e328b7..891ded6 100644 --- a/src/TestUtils/DataTypeMock.php +++ b/src/TestUtils/DataTypeMock.php @@ -12,7 +12,7 @@ * This class is particularly useful for testing type-specific functionality * and generating test data with specific data types. */ -class DataTypeMock +final class DataTypeMock { /** @@ -26,7 +26,7 @@ class DataTypeMock private ?array $types = null; /** - * @var array|null Stores bound arguments with their associated keys + * @var array>|null */ private ?array $bindArguments = null; @@ -121,6 +121,9 @@ public function withCustomBoundDefault(string $key, string $dataType, mixed $val { $inst = clone $this; $tempInst = $this->withCustomDefault($dataType, $value); + if($inst->bindArguments === null) { + $inst->bindArguments = []; + } $inst->bindArguments[$key][$dataType] = $tempInst->defaultArguments[$dataType]; return $inst; } @@ -141,23 +144,23 @@ public function getDataTypeListToString(): array * 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 + * @return string 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 + public function getDataTypeValue(string $dataType, ?string $bindKey = null): string { if(is_string($bindKey) && isset($this->bindArguments[$bindKey][$dataType])) { return self::exportValue($this->bindArguments[$bindKey][$dataType]); } if($this->types === null) { - $this->types = $this->getDataTypeListToString(); + $this->types = $this->getDataTypeListToString(); } if(!isset($this->types[$dataType])) { throw new InvalidArgumentException("Invalid data type: $dataType"); } - return $this->types[$dataType]; + return (string)$this->types[$dataType]; } diff --git a/src/TestUtils/ExecutionWrapper.php b/src/TestUtils/ExecutionWrapper.php index e1dab81..04c8291 100755 --- a/src/TestUtils/ExecutionWrapper.php +++ b/src/TestUtils/ExecutionWrapper.php @@ -19,6 +19,7 @@ abstract class ExecutionWrapper { protected Reflection $ref; protected object $instance; + /** @var array */ private array $methods = []; /** @@ -45,7 +46,11 @@ public function __construct(string $className, array $args = []) */ public function bind(Closure $call): Closure { - return $call->bindTo($this->instance); + $closure = $call->bindTo($this->instance); + if(!is_callable($closure)) { + throw new Exception("Closure is not callable."); + } + return $closure; } /** @@ -64,6 +69,9 @@ public function override(string $method, Closure $call): self ); } $call = $call->bindTo($this->instance); + if (!is_callable($call)) { + throw new Exception("Closure is not callable."); + } $this->methods[$method] = $call; return $this; } @@ -84,6 +92,9 @@ public function add(string $method, Closure $call): self ); } $call = $call->bindTo($this->instance); + if (!is_callable($call)) { + throw new Exception("Closure is not callable."); + } $this->methods[$method] = $call; return $this; } diff --git a/src/Unit.php b/src/Unit.php index 5dfaea3..535be37 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -12,10 +12,11 @@ use MaplePHP\Prompts\Command; use MaplePHP\Prompts\Themes\Blocks; use MaplePHP\Unitary\Handlers\HandlerInterface; +use MaplePHP\Unitary\Utils\Helpers; use MaplePHP\Unitary\Utils\Performance; use RuntimeException; -class Unit +final class Unit { private ?HandlerInterface $handler = null; private Command $command; @@ -178,11 +179,11 @@ public function performance(Closure $func, ?string $title = null): void $this->command->message($line); $this->command->message( $this->command->getAnsi()->style(["bold"], "Execution time: ") . - (round($start->getExecutionTime(), 3) . " seconds") + ((string)round($start->getExecutionTime(), 3) . " seconds") ); $this->command->message( $this->command->getAnsi()->style(["bold"], "Memory Usage: ") . - (round($start->getMemoryUsage(), 2) . " KB") + ((string)round($start->getMemoryUsage(), 2) . " KB") ); /* $this->command->message( @@ -211,7 +212,7 @@ public function execute(): bool // LOOP through each case ob_start(); - $countCases = count($this->cases); + //$countCases = count($this->cases); foreach ($this->cases as $index => $row) { if (!($row instanceof TestCase)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestCase."); @@ -220,7 +221,7 @@ public function execute(): bool $errArg = self::getArgs("errors-only"); $row->dispatchTest($row); $tests = $row->runDeferredValidations(); - $checksum = (self::$headers['checksum'] ?? "") . $index; + $checksum = (string)(self::$headers['checksum'] ?? "") . $index; $color = ($row->hasFailed() ? "brightRed" : "brightBlue"); $flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); if ($row->hasFailed()) { @@ -251,7 +252,7 @@ public function execute(): bool if($show && !$row->hasFailed()) { $this->command->message(""); $this->command->message( - $this->command->getAnsi()->style(["italic", $color], "Test file: " . self::$headers['file']) + $this->command->getAnsi()->style(["italic", $color], "Test file: " . (string)self::$headers['file']) ); } @@ -276,29 +277,25 @@ public function execute(): bool $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']) { + + /** @var array{validation: string, valid: bool|null, args: array, compare: array} $unit */ + if ($unit['valid'] === false) { $lengthA = $test->getValidationLength(); - $addArgs = is_array($unit['args']) ? "(" . implode(", ", $unit['args']) . ")" : ""; + $addArgs = ($unit['args'] !== []) ? "(" . Helpers::stringifyArgs($unit['args']) . ")" : "()"; $validation = "{$unit['validation']}{$addArgs}"; $title = str_pad($validation, $lengthA); $compare = ""; - if ($unit['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']) ? $color : null), - $failedMsg - ) - ); + $failedMsg = " " .$title . " β†’ failed"; + $this->command->message($this->command->getAnsi()->style($color, $failedMsg)); - if (!$unit['valid'] && $compare) { + if ($compare) { $lengthB = (strlen($compare) + strlen($failedMsg) - 8); $comparePad = str_pad($compare, $lengthB, " ", STR_PAD_LEFT); $this->command->message( @@ -340,7 +337,7 @@ public function execute(): bool $this->command->message($footer); $this->command->message(""); } - $this->output .= ob_get_clean(); + $this->output .= (string)ob_get_clean(); if ($this->output) { $this->buildNotice("Note:", $this->output, 80); @@ -476,7 +473,7 @@ public static function hasUnit(): bool */ public static function getUnit(): ?Unit { - if (self::hasUnit() === null) { + if (self::hasUnit() === false) { throw new Exception("Unit has not been set yet. It needs to be set first."); } return self::$current; @@ -496,7 +493,7 @@ public static function completed(): void self::$current->command->getAnsi()->style( ["italic", "grey"], "Total: " . self::$totalPassedTests . "/" . self::$totalTests . " $dot " . - "Peak memory usage: " . round(memory_get_peak_usage() / 1024, 2) . " KB" + "Peak memory usage: " . (string)round(memory_get_peak_usage() / 1024, 2) . " KB" ) ); self::$current->command->message(""); diff --git a/src/Utils/FileIterator.php b/src/Utils/FileIterator.php index c276264..d979f03 100755 --- a/src/Utils/FileIterator.php +++ b/src/Utils/FileIterator.php @@ -35,8 +35,8 @@ public function __construct(array $args = []) */ public function executeAll(string $path, string|bool $rootDir = false): void { - $rootDir = $rootDir !== false ? realpath($rootDir) : false; - $path = (!$path && $rootDir) ? $rootDir : $path; + $rootDir = is_string($rootDir) ? realpath($rootDir) : false; + $path = (!$path && $rootDir !== false) ? $rootDir : $path; if($rootDir !== false && !str_starts_with($path, "/") && !str_starts_with($path, $rootDir)) { $path = $rootDir . "/" . $path; } @@ -89,7 +89,6 @@ private function findFiles(string $path, string|bool $rootDir = false): array if(is_file($path)) { $path = dirname($path) . "/"; } - if(is_dir($path)) { $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)); /** @var string $pattern */ @@ -104,12 +103,10 @@ private function findFiles(string $path, string|bool $rootDir = false): array } } } - - if($rootDir && count($files) <= 0 && str_starts_with($path, $rootDir) && isset($this->args['smart-search'])) { - $path = realpath($path . "/..") . "/"; + if($rootDir !== false && count($files) <= 0 && str_starts_with($path, $rootDir) && isset($this->args['smart-search'])) { + $path = (string)realpath($path . "/..") . "/"; return $this->findFiles($path, $rootDir); } - return $files; } @@ -146,8 +143,7 @@ public function findExcluded(array $exclArr, string $relativeDir, string $file): { $file = $this->getNaturalPath($file); foreach ($exclArr as $excl) { - /* @var string $excl */ - $relativeExclPath = $this->getNaturalPath($relativeDir . DIRECTORY_SEPARATOR . $excl); + $relativeExclPath = $this->getNaturalPath($relativeDir . DIRECTORY_SEPARATOR . (string)$excl); if (fnmatch($relativeExclPath, $file)) { return true; } diff --git a/src/Utils/Helpers.php b/src/Utils/Helpers.php index d43524d..d0c6ba1 100644 --- a/src/Utils/Helpers.php +++ b/src/Utils/Helpers.php @@ -2,17 +2,69 @@ namespace MaplePHP\Unitary\Utils; -class Helpers +final class Helpers { + /** + * Used to stringify arguments to show in test + * + * @param array $args + * @return string + */ + public static function stringifyArgs(array $args): string + { + $levels = 0; + $str = self::stringify($args, $levels); + if($levels > 1) { + return "[$str]"; + } + return $str; + } + + /** + * Stringify an array and objects + * + * @param mixed $arg + * @param int $levels + * @return string + */ + public static function stringify(mixed $arg, int &$levels = 0): string + { + if (is_array($arg)) { + $items = array_map(function($item) use(&$levels) { + $levels++; + return self::stringify($item, $levels); + }, $arg); + return implode(', ', $items); + } + + if (is_object($arg)) { + return get_class($arg); + } + + return (string)$arg; + } + + /** + * Create a file instead of eval for improved debug + * + * @param string $filename + * @param string $input + * @return void + */ public static function createFile(string $filename, string $input) { - $tempDir = getenv('UNITARY_TEMP_DIR') ?: sys_get_temp_dir(); + $temp = getenv('UNITARY_TEMP_DIR'); + $tempDir = $temp !== false ? $temp : sys_get_temp_dir(); if (!is_dir($tempDir)) { mkdir($tempDir, 0777, true); } $tempFile = rtrim($tempDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $filename; file_put_contents($tempFile, "mailer->getFromEmail(), FILTER_VALIDATE_EMAIL)) { throw new \Exception("Invalid email"); } - //echo $this->mailer->sendEmail($email, $name)."\n"; - //echo $this->mailer->sendEmail($email, $name); return true; } } @@ -114,9 +112,86 @@ public function registerUser(string $email): bool { +$unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { + + + // Quickly mock the Stream class + $stream = $case->mock(Stream::class, function (MethodRegistry $method) { + $method->method("getContents") + ->willReturn('{"test":"test"}') + ->calledAtLeast(1); + + $method->method("fopen")->isPrivate(); + }); + // 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 (MethodRegistry $method) 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. + $method->method("getBody")->willReturn($stream); + }); + + $case->validate($response->getHeader("lorem"), function(Expect $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->getBody()->getContents(), function(Expect $inst) { + $inst->isString(); + $inst->isJson(); + }); + + $case->validate($response->getStatusCode(), function(Expect $inst) { + // Will validate to the default int data type set above + // and bounded to "getStatusCode" method + $inst->isHttpSuccess(); + }); + + $case->validate($response->getProtocolVersion(), function(Expect $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(Expect $inst) { + $inst->isInstanceOf(Stream::class); + }); + + // You need to return a new instance of TestCase for new mocking settings + return $case; +}); + + + $unit->group("Test mocker", function (TestCase $case) use($unit) { - $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("addFromEmail") ->withArguments("john.doe@gmail.com", "John Doe") ->withArgumentsForCalls(["john.doe@gmail.com", "John Doe"], ["jane.doe@gmail.com", "Jane Doe"]) @@ -139,6 +214,7 @@ public function registerUser(string $email): bool { $inst->isThrowable(InvalidArgumentException::class); }); + $mail->addFromEmail("jane.doe@gmail.com", "Jane Doe"); $case->error("Test all exception validations") @@ -154,6 +230,13 @@ public function registerUser(string $email): bool { $case->validate(fn() => throw new TypeError("Lorem ipsum"), function(Expect $inst, Traverse $obj) { $inst->isThrowable(TypeError::class); }); + + + $case->validate(fn() => throw new TypeError("Lorem ipsum"), function(Expect $inst, Traverse $obj) { + $inst->isThrowable(function(Expect $inst) { + $inst->isClass(TypeError::class); + }); + }); }); $config = TestConfig::make("Mocking response")->withName("unitary"); @@ -162,16 +245,20 @@ public function registerUser(string $email): bool { $stream = $case->mock(Stream::class, function (MethodRegistry $method) { $method->method("getContents") - ->willReturn('HelloWorld') + ->willReturn('HelloWorld', 'HelloWorld2') ->calledAtLeast(1); }); $response = new Response($stream); $case->validate($response->getBody()->getContents(), function(Expect $inst) { $inst->hasResponse(); - $inst->isEqualTo('HelloWorld'); + $inst->isEqualTo('Hello1World'); $inst->notIsEqualTo('HelloWorldNot'); }); + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->isEqualTo('Hello2World2'); + }); }); $unit->group($config->withSubject("Assert validations"), function ($case) { @@ -183,7 +270,8 @@ public function registerUser(string $email): bool { $unit->case($config->withSubject("Old validation syntax"), function ($case) { $case->add("HelloWorld", [ - "isString" => [], + "isInt" => [], + "isBool" => [], "User validation" => function($value) { return $value === "HelloWorld"; } @@ -202,7 +290,7 @@ public function registerUser(string $email): bool { }); $case->validate(fn() => $mail->send(), function(Expect $inst) { - $inst->hasThrowableMessage("Invalid email"); + $inst->hasThrowableMessage("Invalid email 2"); }); }); From 97b498320ce229db9c42a2d6e3880017b1a7f09c Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Fri, 30 May 2025 17:15:23 +0200 Subject: [PATCH 40/53] Code quality imporvements --- src/Expect.php | 1 + src/Handlers/FileHandler.php | 1 - src/Handlers/HtmlHandler.php | 1 - src/Mocker/MethodRegistry.php | 1 + src/Mocker/MockBuilder.php | 4 +- src/Mocker/MockController.php | 1 + src/Mocker/MockedMethod.php | 1 + src/TestCase.php | 1 - src/TestConfig.php | 1 + src/TestUnit.php | 1 - src/TestUtils/DataTypeMock.php | 1 + src/TestUtils/ExecutionWrapper.php | 12 +- src/Unit.php | 1 - src/Utils/FileIterator.php | 1 - src/Utils/Helpers.php | 1 + src/Utils/Performance.php | 1 - tests/TestLib/Mailer.php | 86 +++++++++ tests/TestLib/UserService.php | 20 ++ tests/unitary-unitary.php | 295 ++--------------------------- 19 files changed, 145 insertions(+), 286 deletions(-) create mode 100644 tests/TestLib/Mailer.php create mode 100644 tests/TestLib/UserService.php diff --git a/src/Expect.php b/src/Expect.php index a3199ff..628d153 100644 --- a/src/Expect.php +++ b/src/Expect.php @@ -1,4 +1,5 @@ instance, $method)) { - throw new \BadMethodCallException( + throw new BadMethodCallException( "Method '$method' does not exist in the class '" . get_class($this->instance) . "' and therefore cannot be overridden or called." ); @@ -82,11 +87,12 @@ public function override(string $method, Closure $call): self * @param string $method * @param Closure $call * @return $this + * @throws Exception */ public function add(string $method, Closure $call): self { if (method_exists($this->instance, $method)) { - throw new \BadMethodCallException( + throw new BadMethodCallException( "Method '$method' already exists in the class '" . get_class($this->instance) . "'. Use the 'override' method in TestWrapper instead." ); @@ -125,7 +131,7 @@ public function __call(string $name, array $arguments): mixed * @param Reflection $ref * @param array $args * @return mixed|object - * @throws \ReflectionException + * @throws ReflectionException */ final protected function createInstance(Reflection $ref, array $args): mixed { diff --git a/src/Unit.php b/src/Unit.php index 535be37..8441cdf 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -1,5 +1,4 @@ sendEmail($this->getFromEmail()); + + return $this->privateMethod(); + } + + public function sendEmail(string $email, string $name = "daniel"): string + { + if(!$this->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 setFromEmail(string $email): self + { + $this->from = $email; + return $this; + } + + public function getFromEmail(): string + { + return !empty($this->from) ? $this->from : "empty email"; + } + + private function privateMethod(): string + { + return "HEHEHE"; + } + + /** + * Add from email address + * + * @param string $email + * @return void + */ + public function addFromEmail(string $email, string $name = ""): void + { + $this->from = $email; + } + + /** + * Add a BCC (blind carbon copy) email address + * + * @param string $email The email address to be added as BCC + * @param string $name The name associated with the email address, default is "Daniel" + * @param mixed $testRef A reference variable, default is "Daniel" + * @return void + */ + public function addBCC(string $email, string $name = "Daniel", &$testRef = "Daniel"): void + { + $this->bcc = $email; + } + + public function test(...$params): void + { + $this->test2(); + } + + public function test2(): void + { + echo "Hello World\n"; + } + +} \ No newline at end of file diff --git a/tests/TestLib/UserService.php b/tests/TestLib/UserService.php new file mode 100644 index 0000000..4c3fb84 --- /dev/null +++ b/tests/TestLib/UserService.php @@ -0,0 +1,20 @@ +mailer->addFromEmail($email); + $this->mailer->addBCC("jane.doe@hotmail.com", "Jane Doe"); + $this->mailer->addBCC("lane.doe@hotmail.com", "Lane Doe"); + + if(!filter_var($this->mailer->getFromEmail(), FILTER_VALIDATE_EMAIL)) { + throw new \Exception("Invalid email: " . $this->mailer->getFromEmail()); + } + return true; + } +} \ No newline at end of file diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 9cdf6f0..77961f1 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -1,194 +1,19 @@ - sendEmail($this->getFromEmail()); - - return $this->privateMethod(); - } - - public function sendEmail(string $email, string $name = "daniel"): string - { - if(!$this->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 setFromEmail(string $email): self - { - $this->from = $email; - return $this; - } - - public function getFromEmail(): string - { - return !empty($this->from) ? $this->from : "empty email"; - } - - private function privateMethod(): string - { - return "HEHEHE"; - } - - /** - * Add from email address - * - * @param string $email - * @return void - */ - public function addFromEmail(string $email, string $name = ""): void - { - $this->from = $email; - } - - /** - * Add a BCC (blind carbon copy) email address - * - * @param string $email The email address to be added as BCC - * @param string $name The name associated with the email address, default is "Daniel" - * @param mixed $testRef A reference variable, default is "Daniel" - * @return void - */ - public function addBCC(string $email, string $name = "Daniel", &$testRef = "Daniel"): void - { - $this->bcc = $email; - } - - public function test(...$params): void - { - $this->test2(); - } - - public function test2(): void - { - echo "Hello World\n"; - } - -} - -class UserService { - public function __construct(private Mailer $mailer) {} - - public function registerUser(string $email): bool { - // register user logic... - - $this->mailer->addFromEmail($email); - $this->mailer->addBCC("jane.doe@hotmail.com", "Jane Doe"); - $this->mailer->addBCC("lane.doe@hotmail.com", "Lane Doe"); - - if(!filter_var($this->mailer->getFromEmail(), FILTER_VALIDATE_EMAIL)) { - throw new \Exception("Invalid email"); - } - return true; - } -} $unit = new Unit(); - -$unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { - - - // Quickly mock the Stream class - $stream = $case->mock(Stream::class, function (MethodRegistry $method) { - $method->method("getContents") - ->willReturn('{"test":"test"}') - ->calledAtLeast(1); - - $method->method("fopen")->isPrivate(); - }); - // 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 (MethodRegistry $method) 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. - $method->method("getBody")->willReturn($stream); - }); - - $case->validate($response->getHeader("lorem"), function(Expect $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->getBody()->getContents(), function(Expect $inst) { - $inst->isString(); - $inst->isJson(); - }); - - $case->validate($response->getStatusCode(), function(Expect $inst) { - // Will validate to the default int data type set above - // and bounded to "getStatusCode" method - $inst->isHttpSuccess(); - }); - - $case->validate($response->getProtocolVersion(), function(Expect $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(Expect $inst) { - $inst->isInstanceOf(Stream::class); - }); - - // You need to return a new instance of TestCase for new mocking settings - return $case; -}); - - - $unit->group("Test mocker", function (TestCase $case) use($unit) { $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { @@ -252,12 +77,12 @@ public function registerUser(string $email): bool { $case->validate($response->getBody()->getContents(), function(Expect $inst) { $inst->hasResponse(); - $inst->isEqualTo('Hello1World'); + $inst->isEqualTo('HelloWorld'); $inst->notIsEqualTo('HelloWorldNot'); }); $case->validate($response->getBody()->getContents(), function(Expect $inst) { - $inst->isEqualTo('Hello2World2'); + $inst->isEqualTo('HelloWorld2'); }); }); @@ -270,8 +95,7 @@ public function registerUser(string $email): bool { $unit->case($config->withSubject("Old validation syntax"), function ($case) { $case->add("HelloWorld", [ - "isInt" => [], - "isBool" => [], + "isString" => [], "User validation" => function($value) { return $value === "HelloWorld"; } @@ -290,12 +114,12 @@ public function registerUser(string $email): bool { }); $case->validate(fn() => $mail->send(), function(Expect $inst) { - $inst->hasThrowableMessage("Invalid email 2"); + $inst->hasThrowableMessage("Invalid email"); }); }); -/* + $unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { @@ -317,49 +141,32 @@ public function registerUser(string $email): bool { $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 (MethodRegistry $method) 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. $method->method("getBody")->willReturn($stream); }); - $case->validate($response->getBody()->getContents(), function(Expect $inst) { $inst->isString(); $inst->isJson(); }); - $case->validate($response->getHeader("lorem"), function(Expect $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(Expect $inst) { - // Will validate to the default int data type set above - // and bounded to "getStatusCode" method + // Overriding the default making it a 200 integer $inst->isHttpSuccess(); }); + $case->validate($response->getHeader("lorem"), function(Expect $inst) { + // Overriding the default the array would be ['item1', 'item2', 'item3'] + $inst->isInArray("myCustomMockArrayItem"); + }); + $case->validate($response->getProtocolVersion(), function(Expect $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"); }); @@ -372,79 +179,19 @@ public function registerUser(string $email): bool { }); -$unit->group("Mailer test", function (TestCase $inst) use($unit) { - $mock = $inst->mock(Mailer::class, function (MethodRegistry $method) use($inst) { - $method->method("addBCC") - ->paramIsType(0, "string") - ->paramHasDefault(1, "Daniel") - ->paramIsOptional(1) - ->paramIsReference(1) - ->called(1); - }); - $mock->addBCC("World"); - $mock->test(1); -}); - - -$unit->group("Testing User service", function (TestCase $inst) { - - $mailer = $inst->mock(Mailer::class, function (MethodRegistry $method) use($inst) { - $method->method("addFromEmail") - ->called(1); - - $method->method("addBCC") - ->isPublic() - ->hasDocComment() - ->hasParams() - ->paramHasType(0) - ->paramIsType(0, "string") - ->paramHasDefault(1, "Daniel") - ->paramIsOptional(1) - ->paramIsReference(1) - ->called(2); - - $method->method("getFromEmail") - ->willReturn("john.doe@gmail.com"); - - }, [true // <- Mailer class constructor argument, enable debug]); - - $service = new UserService($mailer); - - $case->validate($service->send(), function(Expect $inst) { - $inst->isTrue(); - }); - -}); $unit->group("Testing User service", function (TestCase $case) { $mailer = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("addFromEmail") + ->keepOriginal() ->called(1); - - $method->method("addBCC") - ->isPublic() - ->hasDocComment() - ->hasParams() - ->paramHasType(0) - ->paramIsType(0, "string") - ->paramHasDefault(2, "Daniel") - ->paramIsOptional(2) - ->paramIsReference(2) - ->called(1); - $method->method("getFromEmail") - ->willReturn("john.doe@gmail.com"); - - }, [true]); // <-- true is passed as argument 1 to Mailer constructor + ->keepOriginal() + ->called(1); + }); $service = new UserService($mailer); $case->validate($service->registerUser("john.doe@gmail.com"), function(Expect $inst) { $inst->isTrue(); }); - }); - */ - - - - From 50e89c78b4e535fe1f141498ddf61e59acfd4628 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Fri, 30 May 2025 17:21:06 +0200 Subject: [PATCH 41/53] Add comment block to files --- src/Expect.php | 8 ++++++++ src/Handlers/FileHandler.php | 8 ++++++++ src/Handlers/HandlerInterface.php | 9 ++++++++- src/Handlers/HtmlHandler.php | 8 ++++++++ src/Mocker/MethodRegistry.php | 8 ++++++++ src/Mocker/MockBuilder.php | 9 +++++---- src/Mocker/MockController.php | 8 ++++++++ src/Mocker/MockedMethod.php | 8 ++++++++ src/TestCase.php | 8 ++++++++ src/TestConfig.php | 8 ++++++++ src/TestUnit.php | 8 ++++++++ src/TestUtils/DataTypeMock.php | 8 ++++++++ src/TestUtils/ExecutionWrapper.php | 13 ++++++------- src/Unit.php | 8 ++++++++ src/Utils/FileIterator.php | 8 ++++++++ src/Utils/Helpers.php | 8 ++++++++ src/Utils/Performance.php | 8 ++++++++ 17 files changed, 131 insertions(+), 12 deletions(-) diff --git a/src/Expect.php b/src/Expect.php index 628d153..216d618 100644 --- a/src/Expect.php +++ b/src/Expect.php @@ -1,4 +1,12 @@ Date: Sun, 1 Jun 2025 13:36:30 +0200 Subject: [PATCH 42/53] Creating TestItem class --- src/Mocker/MockBuilder.php | 12 +++- src/TestCase.php | 8 +-- src/TestItem.php | 104 ++++++++++++++++++++++++++++++++++ src/TestUnit.php | 30 ++++++++-- src/Unit.php | 5 +- tests/TestLib/UserService.php | 10 ++++ tests/unitary-unitary.php | 36 +++++++++++- 7 files changed, 190 insertions(+), 15 deletions(-) create mode 100755 src/TestItem.php diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index dd7ffc3..10a7beb 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -251,6 +251,17 @@ protected function generateMockMethodOverrides(string $mockClassName): string if (!($method instanceof ReflectionMethod)) { throw new Exception("Method is not a ReflectionMethod"); } + + /* + if ($method->isFinal() || $method->isPrivate()) { + trigger_error( + "Cannot mock " . ($method->isFinal() ? "final" : "private") . + " method '" . $method->getName() . "' in '{$this->className}' – the real method will be executed.", + E_USER_WARNING + ); + } + */ + if ($method->isFinal()) { continue; } @@ -298,7 +309,6 @@ protected function generateMockMethodOverrides(string $mockClassName): string } $exception = ($methodItem && $methodItem->getThrowable()) ? $this->handleThrownExceptions($methodItem->getThrowable()) : ""; - $safeJson = base64_encode($info); $overrides .= " $modifiers function $methodName($paramList){$returnType} diff --git a/src/TestCase.php b/src/TestCase.php index 0344d4e..ff043ef 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -310,12 +310,8 @@ 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(), (int)$e->getCode()); - } + /** @psalm-suppress MixedReturnStatement */ + return $this->mocker->execute(); } /** diff --git a/src/TestItem.php b/src/TestItem.php new file mode 100755 index 0000000..5827360 --- /dev/null +++ b/src/TestItem.php @@ -0,0 +1,104 @@ +valid = $isValid; + return $inst; + } + + public function setValidation(string $validation): self + { + $inst = clone $this; + $inst->validation = $validation; + return $inst; + } + + public function setValidationArgs(array $args): self + { + $inst = clone $this; + $inst->args = $args; + return $inst; + } + + public function setCompare(mixed $value, mixed ...$compareValue): self + { + $inst = clone $this; + $inst->value = $value; + $inst->compareValues = $compareValue; + return $inst; + } + + public function isValid(): bool + { + return $this->valid; + } + + public function getValidation(): string + { + return $this->validation; + } + + public function getValidationArgs(): array + { + return $this->args; + } + + public function getValue(): mixed + { + return $this->value; + } + + public function hasComparison(): bool + { + return ($this->compareValues !== []); + } + + public function getCompareValues(): mixed + { + return $this->compareValues; + } + + public function getComparison(): string + { + return "Expected: " . $this->getValue() . " | Actual: " . implode(":", $this->getCompareValues()); + } + + public function getStringifyArgs(): string + { + return Helpers::stringifyArgs($this->args); + } + + public function getValidationLength(): int + { + return strlen($this->getValidation()); + } +} diff --git a/src/TestUnit.php b/src/TestUnit.php index c216238..f59a44f 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -70,8 +70,8 @@ public function setTestValue(mixed $value): void * @throws ErrorException */ public function setUnit( - bool|null $valid, - null|string|\Closure $validation = null, + bool $valid, + string $validation = "", array|bool $args = [], array $compare = [] ): self { @@ -89,14 +89,36 @@ public function setUnit( } } + + + $item = new TestItem(); + + $item = $item->setIsValid($valid) + ->setValidation($validation) + //->setValidationArgs($args) + ; if ($compare && count($compare) > 0) { + $compare = array_map(fn ($value) => $this->getReadValue($value, true), $compare); + + // FIX + $newCompare = $compare; + if (!is_array($newCompare[1])) { + $newCompare[1] = [$newCompare[1]]; + } + $item = $item->setCompare($newCompare[0], ...$newCompare[1]); + } + + + + $this->unit[] = [ 'valid' => $valid, 'validation' => $validation, 'args' => $args, - 'compare' => $compare + 'compare' => $compare, + 'item' => $item ]; return $this; } @@ -201,7 +223,7 @@ public function getValue(): mixed } /** - * Used to get a readable value + * Used to get a readable value (Move to utility) * * @param mixed|null $value * @param bool $minify diff --git a/src/Unit.php b/src/Unit.php index 1b39530..de2b1d6 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -287,17 +287,20 @@ public function execute(): bool foreach ($test->getUnits() as $unit) { /** @var array{validation: string, valid: bool|null, args: array, compare: array} $unit */ - if ($unit['valid'] === false) { + if (!$unit['item']->isValid()) { $lengthA = $test->getValidationLength(); $addArgs = ($unit['args'] !== []) ? "(" . Helpers::stringifyArgs($unit['args']) . ")" : "()"; $validation = "{$unit['validation']}{$addArgs}"; $title = str_pad($validation, $lengthA); + $compare = $unit['item']->hasComparison() ? $unit['item']->getComparison() : ""; + /* $compare = ""; if ($unit['compare'] !== []) { $expectedValue = array_shift($unit['compare']); $compare = "Expected: $expectedValue | Actual: " . implode(":", $unit['compare']); } + */ $failedMsg = " " .$title . " β†’ failed"; $this->command->message($this->command->getAnsi()->style($color, $failedMsg)); diff --git a/tests/TestLib/UserService.php b/tests/TestLib/UserService.php index 4c3fb84..ef20cf2 100644 --- a/tests/TestLib/UserService.php +++ b/tests/TestLib/UserService.php @@ -17,4 +17,14 @@ public function registerUser(string $email): bool { } return true; } + + private function getUserRole(): string + { + return "guest"; + } + + final public function getUserType(): string + { + return $this->getUserRole(); + } } \ No newline at end of file diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 77961f1..49c80e5 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -14,13 +14,27 @@ $unit = new Unit(); + +$unit->group("Mock final method in UserService", function(TestCase $case) { + + $user = $case->mock(UserService::class, function(MethodRegistry $method) { + $method->method("getUserRole")->willReturn("admin"); + $method->method("getUserType")->willReturn("admin"); + }); + + $case->validate($user->getUserType(), function(Expect $expect) { + $expect->isEqualTo("admin"); + }); +}); + + $unit->group("Test mocker", function (TestCase $case) use($unit) { $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("addFromEmail") - ->withArguments("john.doe@gmail.com", "John Doe") - ->withArgumentsForCalls(["john.doe@gmail.com", "John Doe"], ["jane.doe@gmail.com", "Jane Doe"]) - ->willThrowOnce(new InvalidArgumentException("Lorem ipsum")) + ->withArguments("john.doe@gwmail.com", "John Doe") + ->withArgumentsForCalls(["john.doe@wgmail.com", "John Doe"], ["jane.doe@gmail.com", "Jane Doe"]) + ->willThrowOnce(new InvalidArgumentException("Lowrem ipsum")) ->called(2); $method->method("addBCC") @@ -118,6 +132,22 @@ }); }); +$unit->group("Should faile", function (TestCase $case) use($unit) { + + $case->error("Is integer 1")->validate(1, function(Expect $inst) { + $inst->isEmail(); + $inst->isString(); + }); + + $case->error("Will return false")->validate(true, function(Expect $inst) { + return false; + }); + + $case->error("Will return false")->validate(true, function(Expect $inst) { + assert(1 == 2); + }); +}); + $unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { From f058d7dd445654c59c673de15a72a9d1ef7d3faf Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 1 Jun 2025 15:46:37 +0200 Subject: [PATCH 43/53] Structure improvements --- src/TestCase.php | 48 ++++++++---- src/TestItem.php | 152 ++++++++++++++++++++++++++++++++++-- src/TestUnit.php | 140 +++------------------------------ src/Unit.php | 22 ++---- src/Utils/Helpers.php | 103 ++++++++++++++++++++++-- tests/unitary-unitary.php | 18 ----- tests/unitary-will-fail.php | 72 +++++++++++++++++ 7 files changed, 366 insertions(+), 189 deletions(-) create mode 100755 tests/unitary-will-fail.php diff --git a/src/TestCase.php b/src/TestCase.php index ff043ef..582f502 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -194,12 +194,22 @@ protected function expectAndValidate( $listArr = $this->buildClosureTest($validation, $validPool, $description); foreach ($listArr as $list) { + if(is_bool($list)) { - $test->setUnit($list, "Validation"); + $item = new TestItem(); + $item = $item->setIsValid($list)->setValidation("Validation"); + $test->setTestItem($item); } else { foreach ($list as $method => $valid) { - /** @var array|bool $valid */ - $test->setUnit(false, (string)$method, $valid); + $item = new TestItem(); + /** @var array|bool $valid */ + $item = $item->setIsValid(false)->setValidation((string)$method); + if(is_array($valid)) { + $item = $item->setValidationArgs($valid); + } else { + $item = $item->setHasArgs(false); + } + $test->setTestItem($item); } } } @@ -213,7 +223,11 @@ protected function expectAndValidate( if (!($args instanceof Closure) && !is_array($args)) { $args = [$args]; } - $test->setUnit($this->buildArrayTest($method, $args), $method, (is_array($args) ? $args : [])); + $item = new TestItem(); + $item = $item->setIsValid($this->buildArrayTest($method, $args)) + ->setValidation($method) + ->setValidationArgs((is_array($args) ? $args : [])); + $test->setTestItem($item); } } if (!$test->isValid()) { @@ -438,12 +452,13 @@ private function validateRow(object $row, MethodRegistry $pool): array /** @psalm-suppress MixedArgument */ $valid = Validator::value($currentValue)->equal($value); } - $errors[] = [ - "property" => $property, - "currentValue" => $currentValue, - "expectedValue" => $value, - "valid" => $valid - ]; + + $item = new TestItem(); + $item = $item->setIsValid($valid) + ->setValidation($property) + ->setValue($value) + ->setCompareToValue($currentValue); + $errors[] = $item; } } @@ -548,18 +563,16 @@ public function runDeferredValidations(): array } foreach ($arr as $data) { // We do not want to validate the return here automatically - /** @var array{property: string} $data */ - if(!in_array($data['property'], self::EXCLUDE_VALIDATE)) { - /** @var array{valid: bool|null, expectedValue: mixed, currentValue: mixed} $data */ - $test->setUnit($data['valid'], $data['property'], [], [ - $data['expectedValue'], $data['currentValue'] - ]); - if (!isset($hasValidated[$method]) && $data['valid'] === null || $data['valid'] === false) { + /** @var TestItem $data */ + if(!in_array($data->getValidation(), self::EXCLUDE_VALIDATE)) { + $test->setTestItem($data); + if (!isset($hasValidated[$method]) && !$data->isValid()) { $hasValidated[$method] = true; $this->count++; } } } + $this->test[] = $test; } } @@ -652,6 +665,7 @@ public function getTest(): array * This will build the closure test * * @param Closure $validation + * @param Expect $validPool * @param string|null $message * @return array */ diff --git a/src/TestItem.php b/src/TestItem.php index 5827360..efe247b 100755 --- a/src/TestItem.php +++ b/src/TestItem.php @@ -11,7 +11,7 @@ namespace MaplePHP\Unitary; -use Closure; +use ErrorException; use MaplePHP\Unitary\Utils\Helpers; final class TestItem @@ -21,6 +21,7 @@ final class TestItem protected string $validation = ""; protected array $args = []; protected mixed $value = null; + protected bool $hasArgs = true; protected array $compareValues = []; @@ -28,6 +29,11 @@ public function __construct() { } + /** + * Set if the test item is valid + * @param bool $isValid + * @return $this + */ public function setIsValid(bool $isValid): self { $inst = clone $this; @@ -35,6 +41,12 @@ public function setIsValid(bool $isValid): self return $inst; } + /** + * Sets the validation type that has been used. + * + * @param string $validation + * @return $this + */ public function setValidation(string $validation): self { $inst = clone $this; @@ -42,6 +54,12 @@ public function setValidation(string $validation): self return $inst; } + /** + * Sets the validation arguments. + * + * @param array $args + * @return $this + */ public function setValidationArgs(array $args): self { $inst = clone $this; @@ -49,56 +67,180 @@ public function setValidationArgs(array $args): self return $inst; } - public function setCompare(mixed $value, mixed ...$compareValue): self + /** + * Sets if the validation has arguments. If not, it will not be enclosed in parentheses. + * + * @param bool $enable + * @return $this + */ + public function setHasArgs(bool $enable): self + { + $inst = clone $this; + $inst->hasArgs = $enable; + return $inst; + } + + /** + * Sets the value that has been used in validation. + * + * @param mixed $value + * @return $this + */ + public function setValue(mixed $value): self { $inst = clone $this; $inst->value = $value; + return $inst; + } + + /** + * Sets a compare value for the current value. + * + * @param mixed ...$compareValue + * @return $this + */ + public function setCompareToValue(mixed ...$compareValue): self + { + $inst = clone $this; $inst->compareValues = $compareValue; return $inst; } + /** + * Converts the value to its string representation using a helper function. + * + * @return string The stringify representation of the value. + * @throws ErrorException + */ + public function getStringifyValue(): string + { + return Helpers::stringifyDataTypes($this->value, true); + } + + /** + * Converts the comparison values to their string representations using a helper function. + * + * @return array The array of stringify comparison values. + * @throws ErrorException + */ + public function getCompareToValue(): array + { + $compare = array_map(fn ($value) => Helpers::stringifyDataTypes($value, true), $this->compareValues); + return $compare; + } + + /** + * Checks if the current state is valid. + * + * @return bool True if the state is valid, false otherwise. + */ public function isValid(): bool { return $this->valid; } + /** + * Retrieves the validation string associated with the object. + * + * @return string The validation string. + */ public function getValidation(): string { return $this->validation; } + /** + * Retrieves the validation arguments. + * + * @return array The validation arguments. + */ public function getValidationArgs(): array { return $this->args; } + /** + * Retrieves the stored raw value. + * + * @return mixed + */ public function getValue(): mixed { return $this->value; } + /** + * Determines if there are any comparison values present. + * + * @return bool + */ public function hasComparison(): bool { return ($this->compareValues !== []); } - public function getCompareValues(): mixed + /** + * Returns the RAW comparison collection. + * + * @return array + */ + public function getCompareValues(): array { return $this->compareValues; } + /** + * Return a string representation of the comparison between expected and actual values. + * + * @return string + * @throws ErrorException + */ public function getComparison(): string { - return "Expected: " . $this->getValue() . " | Actual: " . implode(":", $this->getCompareValues()); + return "Expected: " . $this->getStringifyValue() . " | Actual: " . implode(":", $this->getCompareToValue()); } + /** + * Retrieves the string representation of the arguments, enclosed in parentheses if present. + * + * @return string + */ public function getStringifyArgs(): string { - return Helpers::stringifyArgs($this->args); + if($this->hasArgs) { + $args = array_map(fn ($value) => Helpers::stringifyArgs($value), $this->args); + return "(" . implode(", ", $args) . ")"; + } + return ""; } + /** + * Retrieves the validation title by combining validation data and arguments. + * + * @return string + */ + public function getValidationTitle(): string + { + return $this->getValidation() . $this->getStringifyArgs(); + } + + /** + * Retrieves the length of the validation string. + * + * @return int + */ public function getValidationLength(): int { return strlen($this->getValidation()); } + + /** + * Retrieves the length of the validation title. + * + * @return int + */ + public function getValidationLengthWithArgs(): int + { + return strlen($this->getValidationTitle()); + } } diff --git a/src/TestUnit.php b/src/TestUnit.php index f59a44f..21a284c 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -12,7 +12,6 @@ namespace MaplePHP\Unitary; use ErrorException; -use MaplePHP\DTO\Format\Str; use MaplePHP\Unitary\Utils\Helpers; final class TestUnit @@ -59,67 +58,26 @@ public function setTestValue(mixed $value): void $this->hasValue = true; } + /** - * Set the test unit + * Create a test item * - * @param bool|null $valid can be null if validation should execute later - * @param string|null|\Closure $validation - * @param array|bool $args - * @param array $compare + * @param TestItem $item * @return $this - * @throws ErrorException */ - public function setUnit( - bool $valid, - string $validation = "", - array|bool $args = [], - array $compare = [] - ): self { - - if (!$valid) { + public function setTestItem(TestItem $item): self + { + if (!$item->isValid()) { $this->valid = false; $this->count++; } - if (is_string($validation)) { - $addArgs = is_array($args) ? "(" . Helpers::stringifyArgs($args) . ")" : ""; - $valLength = strlen($validation . $addArgs); - if ($validation && $this->valLength < $valLength) { - $this->valLength = $valLength; - } + $valLength = $item->getValidationLengthWithArgs(); + if ($this->valLength < $valLength) { + $this->valLength = $valLength; } - - - $item = new TestItem(); - - $item = $item->setIsValid($valid) - ->setValidation($validation) - //->setValidationArgs($args) - ; - if ($compare && count($compare) > 0) { - - $compare = array_map(fn ($value) => $this->getReadValue($value, true), $compare); - - // FIX - $newCompare = $compare; - if (!is_array($newCompare[1])) { - $newCompare[1] = [$newCompare[1]]; - } - $item = $item->setCompare($newCompare[0], ...$newCompare[1]); - - } - - - - - $this->unit[] = [ - 'valid' => $valid, - 'validation' => $validation, - 'args' => $args, - 'compare' => $compare, - 'item' => $item - ]; + $this->unit[] = $item; return $this; } @@ -142,23 +100,7 @@ public function getValidationLength(): int */ public function setCodeLine(array $trace): self { - $this->codeLine = []; - $file = (string)($trace['file'] ?? ''); - $line = (int)($trace['line'] ?? 0); - $lines = file($file); - $code = ""; - if($lines !== false) { - $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; - + $this->codeLine = Helpers::getTrace($trace); return $this; } @@ -221,64 +163,4 @@ public function getValue(): mixed { return $this->value; } - - /** - * Used to get a readable value (Move to utility) - * - * @param mixed|null $value - * @param bool $minify - * @return string - * @throws ErrorException - */ - public function getReadValue(mixed $value = null, bool $minify = false): string - { - $value = $value === null ? $this->value : $value; - if (is_bool($value)) { - return '"' . ($value ? "true" : "false") . '"' . ($minify ? "" : " (type: bool)"); - } - if (is_int($value)) { - return '"' . $this->excerpt((string)$value) . '"' . ($minify ? "" : " (type: int)"); - } - if (is_float($value)) { - return '"' . $this->excerpt((string)$value) . '"' . ($minify ? "" : " (type: float)"); - } - if (is_string($value)) { - return '"' . $this->excerpt($value) . '"' . ($minify ? "" : " (type: string)"); - } - if (is_array($value)) { - $json = json_encode($value); - if($json === false) { - return "(unknown type)"; - } - return '"' . $this->excerpt($json) . '"' . ($minify ? "" : " (type: array)"); - } - if (is_callable($value)) { - return '"' . $this->excerpt(get_class((object)$value)) . '"' . ($minify ? "" : " (type: callable)"); - } - if (is_object($value)) { - return '"' . $this->excerpt(get_class($value)) . '"' . ($minify ? "" : " (type: object)"); - } - if ($value === null) { - return '"null"'. ($minify ? '' : ' (type: null)'); - } - if (is_resource($value)) { - return '"' . $this->excerpt(get_resource_type($value)) . '"' . ($minify ? "" : " (type: resource)"); - } - - return "(unknown type)"; - } - - /** - * Used to get exception to the readable value - * - * @param string $value - * @param int $length - * @return string - * @throws ErrorException - */ - final protected function excerpt(string $value, int $length = 80): string - { - $format = new Str($value); - return (string)$format->excerpt($length)->get(); - } } diff --git a/src/Unit.php b/src/Unit.php index de2b1d6..e23c3a3 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -22,6 +22,7 @@ use MaplePHP\Unitary\Utils\Helpers; use MaplePHP\Unitary\Utils\Performance; use RuntimeException; +use Throwable; final class Unit { @@ -207,7 +208,7 @@ public function performance(Closure $func, ?string $title = null): void * @return bool * @throws ErrorException * @throws BlunderErrorException - * @throws \Throwable + * @throws Throwable */ public function execute(): bool { @@ -286,21 +287,12 @@ public function execute(): bool foreach ($test->getUnits() as $unit) { - /** @var array{validation: string, valid: bool|null, args: array, compare: array} $unit */ - if (!$unit['item']->isValid()) { + /** @var TestItem $unit */ + if (!$unit->isValid()) { $lengthA = $test->getValidationLength(); - $addArgs = ($unit['args'] !== []) ? "(" . Helpers::stringifyArgs($unit['args']) . ")" : "()"; - $validation = "{$unit['validation']}{$addArgs}"; + $validation = $unit->getValidationTitle(); $title = str_pad($validation, $lengthA); - - $compare = $unit['item']->hasComparison() ? $unit['item']->getComparison() : ""; - /* - $compare = ""; - if ($unit['compare'] !== []) { - $expectedValue = array_shift($unit['compare']); - $compare = "Expected: $expectedValue | Actual: " . implode(":", $unit['compare']); - } - */ + $compare = $unit->hasComparison() ? $unit->getComparison() : ""; $failedMsg = " " .$title . " β†’ failed"; $this->command->message($this->command->getAnsi()->style($color, $failedMsg)); @@ -318,7 +310,7 @@ public function execute(): bool $this->command->message(""); $this->command->message( $this->command->getAnsi()->bold("Input value: ") . - $test->getReadValue() + Helpers::stringifyDataTypes($test->getValue()) ); } } diff --git a/src/Utils/Helpers.php b/src/Utils/Helpers.php index 081ce6b..fba5c42 100644 --- a/src/Utils/Helpers.php +++ b/src/Utils/Helpers.php @@ -11,16 +11,20 @@ namespace MaplePHP\Unitary\Utils; +use ErrorException; +use Exception; +use MaplePHP\DTO\Format\Str; + final class Helpers { /** - * Used to stringify arguments to show in test + * Used to stringify arguments to show in a test * - * @param array $args + * @param mixed $args * @return string */ - public static function stringifyArgs(array $args): string + public static function stringifyArgs(mixed $args): string { $levels = 0; $str = self::stringify($args, $levels); @@ -60,8 +64,9 @@ public static function stringify(mixed $arg, int &$levels = 0): string * @param string $filename * @param string $input * @return void + * @throws Exception */ - public static function createFile(string $filename, string $input) + public static function createFile(string $filename, string $input): void { $temp = getenv('UNITARY_TEMP_DIR'); $tempDir = $temp !== false ? $temp : sys_get_temp_dir(); @@ -72,7 +77,7 @@ public static function createFile(string $filename, string $input) file_put_contents($tempFile, "')) { + $code = substr($code, 2); + } + $code = self::excerpt($code); + } + + $codeLine['line'] = $line; + $codeLine['file'] = $file; + $codeLine['code'] = $code; + + return $codeLine; + } + + + /** + * Generates an excerpt from the given string with a specified maximum length. + * + * @param string $value The input string to be excerpted. + * @param int $length The maximum length of the excerpt. Defaults to 80. + * @return string The resulting excerpted string. + * @throws ErrorException + */ + final public static function excerpt(string $value, int $length = 80): string + { + $format = new Str($value); + return (string)$format->excerpt($length)->get(); + } + + /** + * Used to get a readable value (Move to utility) + * + * @param mixed|null $value + * @param bool $minify + * @return string + * @throws ErrorException + */ + public static function stringifyDataTypes(mixed $value = null, bool $minify = false): string + { + if (is_bool($value)) { + return '"' . ($value ? "true" : "false") . '"' . ($minify ? "" : " (type: bool)"); + } + if (is_int($value)) { + return '"' . self::excerpt((string)$value) . '"' . ($minify ? "" : " (type: int)"); + } + if (is_float($value)) { + return '"' . self::excerpt((string)$value) . '"' . ($minify ? "" : " (type: float)"); + } + if (is_string($value)) { + return '"' . self::excerpt($value) . '"' . ($minify ? "" : " (type: string)"); + } + if (is_array($value)) { + $json = json_encode($value); + if($json === false) { + return "(unknown type)"; + } + return '"' . self::excerpt($json) . '"' . ($minify ? "" : " (type: array)"); + } + if (is_callable($value)) { + return '"' . self::excerpt(get_class((object)$value)) . '"' . ($minify ? "" : " (type: callable)"); + } + if (is_object($value)) { + return '"' . self::excerpt(get_class($value)) . '"' . ($minify ? "" : " (type: object)"); + } + if ($value === null) { + return '"null"'. ($minify ? '' : ' (type: null)'); + } + if (is_resource($value)) { + return '"' . self::excerpt(get_resource_type($value)) . '"' . ($minify ? "" : " (type: resource)"); + } + + return "(unknown type)"; + } } \ No newline at end of file diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 49c80e5..b46e70f 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -132,24 +132,6 @@ }); }); -$unit->group("Should faile", function (TestCase $case) use($unit) { - - $case->error("Is integer 1")->validate(1, function(Expect $inst) { - $inst->isEmail(); - $inst->isString(); - }); - - $case->error("Will return false")->validate(true, function(Expect $inst) { - return false; - }); - - $case->error("Will return false")->validate(true, function(Expect $inst) { - assert(1 == 2); - }); -}); - - - $unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { diff --git a/tests/unitary-will-fail.php b/tests/unitary-will-fail.php new file mode 100755 index 0000000..749c997 --- /dev/null +++ b/tests/unitary-will-fail.php @@ -0,0 +1,72 @@ +withName("fail")->withSkip(true); +$unit->group($config, function (TestCase $case) use($unit) { + + $case->error("Default validations")->validate(1, function(Expect $inst) { + $inst->isEmail(); + $inst->length(100, 1); + $inst->isString(); + }); + + $case->error("Return validation")->validate(true, function(Expect $inst) { + return false; + }); + + $case->error("Assert validation")->validate(true, function(Expect $inst) { + assert(1 == 2); + }); + + $case->error("Assert with message validation")->validate(true, function(Expect $inst) { + assert(1 == 2, "Is not equal to 2"); + }); + + $case->error("Assert with all validation")->validate(true, function(Expect $inst) { + assert($inst->isEmail()->isString()->isValid(), "Is not email"); + }); + + + + $case->add("HelloWorld", [ + "isInt" => [], + "User validation" => function($value) { + return $value === 2; + } + ], "Old validation syntax"); + + + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { + $method->method("send")->keepOriginal()->called(0); + $method->method("isValidEmail")->keepOriginal(); + $method->method("sendEmail")->keepOriginal()->called(0); + + $method->method("addBCC") + ->isProtected() + ->hasDocComment() + ->hasParams() + ->paramHasType(0) + ->paramIsType(0, "int") + ->paramHasDefault(1, 1) + ->paramIsOptional(0) + ->paramIsReference(1) + ->called(0); + }); + + $case->error("Mocking validation")->validate(fn() => $mail->send(), function(Expect $inst) { + $inst->hasThrowableMessage("dwdwqdwqwdq email"); + }); + assert(1 == 2, "Assert in group level"); +}); \ No newline at end of file From b44c84d9427eb1824cc1c8ca9ae32016da90715c Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 1 Jun 2025 15:49:56 +0200 Subject: [PATCH 44/53] Add test to test --- tests/unitary-unitary.php | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index b46e70f..8402729 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -13,27 +13,23 @@ $unit = new Unit(); - - -$unit->group("Mock final method in UserService", function(TestCase $case) { - +$unit->group("Can not mock final or private", function(TestCase $case) { $user = $case->mock(UserService::class, function(MethodRegistry $method) { $method->method("getUserRole")->willReturn("admin"); $method->method("getUserType")->willReturn("admin"); }); $case->validate($user->getUserType(), function(Expect $expect) { - $expect->isEqualTo("admin"); + $expect->isEqualTo("guest"); }); }); - $unit->group("Test mocker", function (TestCase $case) use($unit) { $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("addFromEmail") - ->withArguments("john.doe@gwmail.com", "John Doe") - ->withArgumentsForCalls(["john.doe@wgmail.com", "John Doe"], ["jane.doe@gmail.com", "Jane Doe"]) + ->withArguments("john.doe@gmail.com", "John Doe") + ->withArgumentsForCalls(["john.doe@gmail.com", "John Doe"], ["jane.doe@gmail.com", "Jane Doe"]) ->willThrowOnce(new InvalidArgumentException("Lowrem ipsum")) ->called(2); @@ -79,7 +75,6 @@ }); $config = TestConfig::make("Mocking response")->withName("unitary"); - $unit->group($config, function (TestCase $case) use($unit) { $stream = $case->mock(Stream::class, function (MethodRegistry $method) { From cb9e455395c32e3916496aa836e2fb651db563fa Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 1 Jun 2025 16:05:44 +0200 Subject: [PATCH 45/53] Fix skip count Minor code quality improvements --- src/TestConfig.php | 1 - src/TestItem.php | 2 +- src/Unit.php | 11 ++++++----- src/Utils/FileIterator.php | 2 +- tests/unitary-will-fail.php | 12 +----------- 5 files changed, 9 insertions(+), 19 deletions(-) diff --git a/src/TestConfig.php b/src/TestConfig.php index 400fa66..0919d40 100644 --- a/src/TestConfig.php +++ b/src/TestConfig.php @@ -78,5 +78,4 @@ public function withSkip(bool $bool = true): self $inst->skip = $bool; return $inst; } - } \ No newline at end of file diff --git a/src/TestItem.php b/src/TestItem.php index efe247b..e65412f 100755 --- a/src/TestItem.php +++ b/src/TestItem.php @@ -1,6 +1,6 @@ getCount(); + // Important to add test from skip as successfully count to make sure that + // the total passed tests are correct, and it will not exit with code 1 + self::$totalPassedTests += ($row->getConfig()->skip) ? $row->getTotal() : $row->getCount(); self::$totalTests += $row->getTotal(); if ($row->getConfig()->select) { $checksum .= " (" . $row->getConfig()->select . ")"; @@ -366,7 +368,6 @@ public function resetExecute(): bool return false; } - /** * Validate method that must be called within a group method * @@ -508,7 +509,7 @@ public static function completed(): void */ public static function isSuccessful(): bool { - return (self::$totalPassedTests !== self::$totalTests); + return (self::$totalPassedTests === self::$totalTests); } /** diff --git a/src/Utils/FileIterator.php b/src/Utils/FileIterator.php index 4ec1781..5252546 100755 --- a/src/Utils/FileIterator.php +++ b/src/Utils/FileIterator.php @@ -72,7 +72,7 @@ public function executeAll(string $path, string|bool $rootDir = false): void } } Unit::completed(); - exit((int)Unit::isSuccessful()); + exit((int)!Unit::isSuccessful()); } } diff --git a/tests/unitary-will-fail.php b/tests/unitary-will-fail.php index 749c997..4bcc2cc 100755 --- a/tests/unitary-will-fail.php +++ b/tests/unitary-will-fail.php @@ -1,19 +1,12 @@ withName("fail")->withSkip(true); +$config = TestConfig::make("All A should fail")->withName("fail")->withSkip(); $unit->group($config, function (TestCase $case) use($unit) { $case->error("Default validations")->validate(1, function(Expect $inst) { @@ -38,8 +31,6 @@ assert($inst->isEmail()->isString()->isValid(), "Is not email"); }); - - $case->add("HelloWorld", [ "isInt" => [], "User validation" => function($value) { @@ -47,7 +38,6 @@ } ], "Old validation syntax"); - $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("send")->keepOriginal()->called(0); $method->method("isValidEmail")->keepOriginal(); From c448385e21a939d6cb324e1145849008be6428e3 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sat, 7 Jun 2025 01:04:43 +0200 Subject: [PATCH 46/53] Testing mocking idea --- src/Expect.php | 1 + src/Mocker/ClassSourceNormalizer.php | 122 +++++++++++++++++++++++++++ src/Mocker/MockBuilder.php | 61 +++++++++++--- src/TestCase.php | 8 +- src/TestConfig.php | 2 +- src/TestItem.php | 3 +- src/Unit.php | 19 +++-- src/Utils/FileIterator.php | 3 +- tests/TestLib/UserService.php | 3 +- tests/unitary-test-item.php | 75 ++++++++++++++++ tests/unitary-unitary.php | 41 +++++++-- tests/unitary-will-fail.php | 2 +- 12 files changed, 304 insertions(+), 36 deletions(-) create mode 100644 src/Mocker/ClassSourceNormalizer.php create mode 100644 tests/unitary-test-item.php diff --git a/src/Expect.php b/src/Expect.php index 216d618..123d5b1 100644 --- a/src/Expect.php +++ b/src/Expect.php @@ -12,6 +12,7 @@ namespace MaplePHP\Unitary; use Exception; +use MaplePHP\Validate\Validator; use Throwable; use MaplePHP\Validate\ValidationChain; diff --git a/src/Mocker/ClassSourceNormalizer.php b/src/Mocker/ClassSourceNormalizer.php new file mode 100644 index 0000000..b2cd4cf --- /dev/null +++ b/src/Mocker/ClassSourceNormalizer.php @@ -0,0 +1,122 @@ +className = $className; + + $shortClassName = explode("\\", $className); + $this->shortClassName = (string)end($shortClassName); + } + + /** + * Add a namespace to class + * + * @param string $namespace + * @return void + */ + public function addNamespace(string $namespace): void + { + $this->namespace = ltrim($namespace, "\\"); + } + + public function getClassName(): string + { + return $this->namespace . "\\" . $this->shortClassName; + } + + /** + * Retrieves the raw source code of the class. + * + * @return string|null + */ + public function getSource(): ?string + { + try { + + $ref = new ReflectionClass($this->className); + + var_dump($ref->getInterfaceNames()); + die; + $file = $ref->getFileName(); + if (!$file || !is_file($file)) { + // Likely an eval'd or dynamically declared class. + return null; + } + + $stream = new Stream($file, 'r'); + $this->source = $stream->getLines($ref->getStartLine(), $ref->getEndLine()); + var_dump($this->source); + + die("ww"); + return $this->source; + + } catch (ReflectionException) { + return null; + } + } + + /** + * Normalize PHP visibility modifiers in source code. + * - Removing 'final' from class and method declarations + * - Replacing 'private' with 'protected' for visibility declarations (except promoted properties) + * + * @param string $code + * @return string + */ + public function normalizeVisibility(string $code): string + { + $code = preg_replace('/\bfinal\s+(?=class\b)/i', '', $code); + $code = preg_replace('/\bfinal\s+(?=(public|protected|private|static)?\s*function\b)/i', '', $code); + $code = preg_replace_callback('/(?<=^|\s)(private)(\s+(static\s+)?(?:function|\$))/mi', [$this, 'replacePrivateWithProtected'], $code); + $code = preg_replace_callback('/__construct\s*\((.*?)\)/s', [$this, 'convertConstructorVisibility'], $code); + return $code; + } + + /** + * Returns the normalized, mockable version of the class source. + * + * @return string|false + */ + public function getMockableSource(): string|false + { + $source = "namespace {$this->namespace};\n" . $this->getSource(); + return $source !== null ? $this->normalizeVisibility($source) : false; + } + + /** + * Replace `private` with `protected` in method or property declarations. + * + * @param array $matches + * @return string + */ + protected function replacePrivateWithProtected(array $matches): string + { + return 'protected' . $matches[2]; + } + + /** + * Convert `private` to `protected` in constructor-promoted properties. + * + * @param array $matches + * @return string + */ + protected function convertConstructorVisibility(array $matches): string + { + $params = preg_replace('/\bprivate\b/', 'protected', $matches[1]); + return '__construct(' . $params . ')'; + } + +} diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index 10a7beb..41b2426 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -13,6 +13,7 @@ use Closure; use Exception; +use MaplePHP\Http\Stream; use MaplePHP\Unitary\TestUtils\DataTypeMock; use Reflection; use ReflectionClass; @@ -27,11 +28,13 @@ final class MockBuilder protected ReflectionClass $reflection; protected string $className; /** @var class-string|null */ - protected ?string $mockClassName = null; + protected string $mockClassName; + protected string $copyClassName; /** @var array */ protected array $constructorArgs = []; protected array $methods; protected array $methodList = []; + protected array $isFinal = []; private DataTypeMock $dataTypeMock; /** @@ -53,7 +56,8 @@ public function __construct(string $className, array $args = []) * @var class-string $shortClassName * @psalm-suppress PropertyTypeCoercion */ - $this->mockClassName = 'Unitary_' . uniqid() . "_Mock_" . $shortClassName; + $this->mockClassName = "Unitary_" . uniqid() . "_Mock_" . $shortClassName; + $this->copyClassName = "Unitary_Mock_" . $shortClassName; /* // Auto fill the Constructor args! $test = $this->reflection->getConstructor(); @@ -127,6 +131,17 @@ public function getMockedClassName(): string return (string)$this->mockClassName; } + /** + * Gets the list of methods that are mocked. + * + * @param string $methodName + * @return bool + */ + public function isFinal(string $methodName): bool + { + return isset($this->isFinal[$methodName]); + } + /** * 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. @@ -157,7 +172,19 @@ public function execute(): mixed $className = $this->reflection->getName(); $overrides = $this->generateMockMethodOverrides((string)$this->mockClassName); $unknownMethod = $this->errorHandleUnknownMethod($className, !$this->reflection->isInterface()); - $extends = $this->reflection->isInterface() ? "implements $className" : "extends $className"; + + if($this->reflection->isInterface()) { + $extends = "implements $className"; + } else { + + $m = new ClassSourceNormalizer($className); + $m->addNamespace("\MaplePHP\Unitary\Mocker\MockedClass"); + eval($m->getMockableSource()); + $extends = "extends \\" . $m->getClassName(); + //$extends = "extends $className"; + } + + $code = " class $this->mockClassName $extends { @@ -171,9 +198,7 @@ public static function __set_state(array \$an_array): self } "; - //print_r($code); - //die; - //Helpers::createFile($this->mockClassName, $code); + eval($code); if(!is_string($this->mockClassName)) { @@ -187,6 +212,7 @@ public static function __set_state(array \$an_array): self 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. @@ -260,11 +286,11 @@ protected function generateMockMethodOverrides(string $mockClassName): string E_USER_WARNING ); } - */ - if ($method->isFinal()) { + $this->isFinal[$methodName] = true; continue; } + */ $methodName = $method->getName(); $this->methodList[] = $methodName; @@ -285,8 +311,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string } $returnType = ($types) ? ': ' . implode('|', $types) : ''; $modifiersArr = Reflection::getModifierNames($method->getModifiers()); - $modifiersArr = array_filter($modifiersArr, fn($val) => $val !== "abstract"); - $modifiers = implode(" ", $modifiersArr); + $modifiers = $this->handleModifiers($modifiersArr); $arr = $this->getMethodInfoAsArray($method); $arr = $this->addMockMetadata($arr, $mockClassName, $returnValue, $methodItem); @@ -326,6 +351,22 @@ protected function generateMockMethodOverrides(string $mockClassName): string return $overrides; } + /** + * Will handle modifier correctly + * + * @param array $modifiersArr + * @return string + */ + protected function handleModifiers(array $modifiersArr): string + { + $modifiersArr = array_filter($modifiersArr, fn($val) => $val !== "abstract"); + $modifiersArr = array_map(function($val) { + return ($val === "private") ? "protected" : $val; + }, $modifiersArr); + + return implode(" ", $modifiersArr); + } + /** * Will mocked handle the thrown exception * diff --git a/src/TestCase.php b/src/TestCase.php index 582f502..54f26ad 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -42,7 +42,6 @@ final class TestCase * @var array */ private const EXCLUDE_VALIDATE = ["return"]; - private mixed $value; private TestConfig $config; private array $test = []; @@ -124,9 +123,11 @@ public function dispatchTest(self &$row): array true, fn() => false, $msg, $e->getMessage(), $e->getTrace()[0] ); } catch (Throwable $e) { - if(str_contains($e->getFile(), "eval()")) { + /* + if(str_contains($e->getFile(), "eval()")) { throw new BlunderErrorException($e->getMessage(), (int)$e->getCode()); } + */ throw $e; } if ($newInst instanceof self) { @@ -341,7 +342,7 @@ public function buildMock(?Closure $validate = null): mixed * @return T * @throws Exception */ - public function mock(string $class, ?Closure $validate = null, array $args = []) + public function mock(string $class, ?Closure $validate = null, array $args = []): mixed { $this->mocker = new MockBuilder($class, $args); return $this->buildMock($validate); @@ -808,5 +809,4 @@ public function getAllTraitMethods(ReflectionClass $reflection): array } return $traitMethods; } - } diff --git a/src/TestConfig.php b/src/TestConfig.php index 0919d40..1df5011 100644 --- a/src/TestConfig.php +++ b/src/TestConfig.php @@ -29,7 +29,7 @@ public function __construct(string $message) * @param string $message * @return self */ - public static function make(string $message): self + public static function make(string $message = "Validating"): self { return new self($message); } diff --git a/src/TestItem.php b/src/TestItem.php index e65412f..0551136 100755 --- a/src/TestItem.php +++ b/src/TestItem.php @@ -125,8 +125,7 @@ public function getStringifyValue(): string */ public function getCompareToValue(): array { - $compare = array_map(fn ($value) => Helpers::stringifyDataTypes($value, true), $this->compareValues); - return $compare; + return array_map(fn ($value) => Helpers::stringifyDataTypes($value, true), $this->compareValues); } /** diff --git a/src/Unit.php b/src/Unit.php index d460a98..d8ab297 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -31,7 +31,7 @@ final class Unit private string $output = ""; private int $index = 0; private array $cases = []; - private bool $skip = false; + private bool $disableAllTests = false; private bool $executed = false; private static array $headers = []; private static ?Unit $current; @@ -60,16 +60,15 @@ public function __construct(HandlerInterface|StreamInterface|null $handler = nul } /** - * This will skip "ALL" tests in the test file + * This will disable "ALL" tests in the test file * If you want to skip a specific test, use the TestConfig class instead * - * @param bool $skip - * @return $this + * @param bool $disable + * @return void */ - public function skip(bool $skip): self + public function disableAllTest(bool $disable): void { - $this->skip = $skip; - return $this; + $this->disableAllTests = $disable; } /** @@ -214,7 +213,7 @@ public function execute(): bool { $this->template(); $this->help(); - if ($this->executed || $this->skip) { + if ($this->executed || $this->disableAllTests) { return false; } @@ -476,9 +475,11 @@ public static function hasUnit(): bool */ public static function getUnit(): ?Unit { - if (self::hasUnit() === false) { + /* + if (self::hasUnit() === false) { throw new Exception("Unit has not been set yet. It needs to be set first."); } + */ return self::$current; } diff --git a/src/Utils/FileIterator.php b/src/Utils/FileIterator.php index 5252546..c95fb19 100755 --- a/src/Utils/FileIterator.php +++ b/src/Utils/FileIterator.php @@ -212,7 +212,8 @@ protected function getUnit(): Unit { $unit = Unit::getUnit(); if ($unit === null) { - throw new RuntimeException("The Unit instance has not been initiated."); + $unit = new Unit(); + //throw new RuntimeException("The Unit instance has not been initiated."); } return $unit; diff --git a/tests/TestLib/UserService.php b/tests/TestLib/UserService.php index ef20cf2..ae61e42 100644 --- a/tests/TestLib/UserService.php +++ b/tests/TestLib/UserService.php @@ -2,7 +2,8 @@ namespace TestLib; -class UserService { +final class UserService { + private $test = 1; public function __construct(private Mailer $mailer) {} public function registerUser(string $email): bool { diff --git a/tests/unitary-test-item.php b/tests/unitary-test-item.php new file mode 100644 index 0000000..f70cbe5 --- /dev/null +++ b/tests/unitary-test-item.php @@ -0,0 +1,75 @@ +group(TestConfig::make("Test item class") + ->withName("unitary"), function (TestCase $case) { + + $item = new TestItem(); + + $item = $item + ->setValidation("validation") + ->setValidationArgs(["arg1", "arg2"]) + ->setIsValid(true) + ->setValue("value") + ->setCompareToValue("compare") + ->setHasArgs(true); + + $case->validate($item->isValid(), function(Expect $valid) { + $valid->isTrue(); + }); + + $case->validate($item->getValidation(), function(Expect $valid) { + $valid->isEqualTo("validation"); + }); + + $case->validate($item->getValidationArgs(), function(Expect $valid) { + $valid->isInArray("arg1"); + $valid->isInArray("arg2"); + $valid->isCountEqualTo(2); + }); + + $case->validate($item->getValue(), function(Expect $valid) { + $valid->isEqualTo("value"); + }); + + $case->validate($item->hasComparison(), function(Expect $valid) { + $valid->isTrue(); + }); + + $case->validate($item->getCompareValues(), function(Expect $valid) { + $valid->isInArray("compare"); + }); + + $case->validate($item->getComparison(), function(Expect $valid) { + $valid->isEqualTo('Expected: "value" | Actual: "compare"'); + }); + + $case->validate($item->getStringifyArgs(), function(Expect $valid) { + $valid->isEqualTo('(arg1, arg2)'); + }); + + $case->validate($item->getValidationTitle(), function(Expect $valid) { + $valid->isEqualTo('validation(arg1, arg2)'); + }); + + $case->validate($item->getValidationLength(), function(Expect $valid) { + $valid->isEqualTo(10); + }); + + $case->validate($item->getValidationLengthWithArgs(), function(Expect $valid) { + $valid->isEqualTo(22); + }); + + $case->validate($item->getStringifyValue(), function(Expect $valid) { + $valid->isEqualTo('"value"'); + }); + + $case->validate($item->getCompareToValue(), function(Expect $valid) { + $valid->isInArray( '"compare"'); + }); + +}); diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 8402729..99e5496 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -13,7 +13,33 @@ $unit = new Unit(); -$unit->group("Can not mock final or private", function(TestCase $case) { +//$unit->disableAllTest(false); + +$config = TestConfig::make()->withName("unitary"); +$unit->group($config->withSubject("Mocking response"), function (TestCase $case) use($unit) { + + + $stream = $case->mock(Stream::class, function (MethodRegistry $method) { + $method->method("getContents") + ->willReturn('HelloWorld', 'HelloWorld2') + ->calledAtLeast(1); + }); + $response = new Response($stream); + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->hasResponse(); + $inst->isEqualTo('HelloWorld'); + $inst->notIsEqualTo('HelloWorldNot'); + }); + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->isEqualTo('HelloWorld2'); + }); +}); + +/* + +$unit->group($config->withSubject("Can not mock final or private"), function(TestCase $case) { $user = $case->mock(UserService::class, function(MethodRegistry $method) { $method->method("getUserRole")->willReturn("admin"); $method->method("getUserType")->willReturn("admin"); @@ -24,7 +50,7 @@ }); }); -$unit->group("Test mocker", function (TestCase $case) use($unit) { +$unit->group($config->withSubject("Test mocker"), function (TestCase $case) use($unit) { $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("addFromEmail") @@ -74,8 +100,7 @@ }); }); -$config = TestConfig::make("Mocking response")->withName("unitary"); -$unit->group($config, function (TestCase $case) use($unit) { +$unit->group($config->withSubject("Mocking response"), function (TestCase $case) use($unit) { $stream = $case->mock(Stream::class, function (MethodRegistry $method) { $method->method("getContents") @@ -115,7 +140,7 @@ ], "Failed to validate"); }); -$unit->group("Validate partial mock", function (TestCase $case) use($unit) { +$unit->group($config->withSubject("Validate partial mock"), function (TestCase $case) use($unit) { $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("send")->keepOriginal(); $method->method("isValidEmail")->keepOriginal(); @@ -127,7 +152,7 @@ }); }); -$unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { +$unit->group($config->withSubject("Advanced App Response Test"), function (TestCase $case) use($unit) { // Quickly mock the Stream class @@ -186,7 +211,7 @@ }); -$unit->group("Testing User service", function (TestCase $case) { +$unit->group($config->withSubject("Testing User service"), function (TestCase $case) { $mailer = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("addFromEmail") @@ -202,3 +227,5 @@ $inst->isTrue(); }); }); + + */ \ No newline at end of file diff --git a/tests/unitary-will-fail.php b/tests/unitary-will-fail.php index 4bcc2cc..92d8620 100755 --- a/tests/unitary-will-fail.php +++ b/tests/unitary-will-fail.php @@ -6,7 +6,7 @@ use TestLib\Mailer; $unit = new Unit(); -$config = TestConfig::make("All A should fail")->withName("fail")->withSkip(); +$config = TestConfig::make("All A should fail")->withName("unitary-fail")->withSkip(); $unit->group($config, function (TestCase $case) use($unit) { $case->error("Default validations")->validate(1, function(Expect $inst) { From 465df4ae7172f0642916a39e7fe56b1261026810 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 8 Jun 2025 12:05:43 +0200 Subject: [PATCH 47/53] Minor refactoring --- src/Mocker/ClassSourceNormalizer.php | 122 --------------------------- src/Mocker/MockBuilder.php | 27 ++---- src/TestCase.php | 4 +- src/Unit.php | 12 ++- 4 files changed, 17 insertions(+), 148 deletions(-) delete mode 100644 src/Mocker/ClassSourceNormalizer.php diff --git a/src/Mocker/ClassSourceNormalizer.php b/src/Mocker/ClassSourceNormalizer.php deleted file mode 100644 index b2cd4cf..0000000 --- a/src/Mocker/ClassSourceNormalizer.php +++ /dev/null @@ -1,122 +0,0 @@ -className = $className; - - $shortClassName = explode("\\", $className); - $this->shortClassName = (string)end($shortClassName); - } - - /** - * Add a namespace to class - * - * @param string $namespace - * @return void - */ - public function addNamespace(string $namespace): void - { - $this->namespace = ltrim($namespace, "\\"); - } - - public function getClassName(): string - { - return $this->namespace . "\\" . $this->shortClassName; - } - - /** - * Retrieves the raw source code of the class. - * - * @return string|null - */ - public function getSource(): ?string - { - try { - - $ref = new ReflectionClass($this->className); - - var_dump($ref->getInterfaceNames()); - die; - $file = $ref->getFileName(); - if (!$file || !is_file($file)) { - // Likely an eval'd or dynamically declared class. - return null; - } - - $stream = new Stream($file, 'r'); - $this->source = $stream->getLines($ref->getStartLine(), $ref->getEndLine()); - var_dump($this->source); - - die("ww"); - return $this->source; - - } catch (ReflectionException) { - return null; - } - } - - /** - * Normalize PHP visibility modifiers in source code. - * - Removing 'final' from class and method declarations - * - Replacing 'private' with 'protected' for visibility declarations (except promoted properties) - * - * @param string $code - * @return string - */ - public function normalizeVisibility(string $code): string - { - $code = preg_replace('/\bfinal\s+(?=class\b)/i', '', $code); - $code = preg_replace('/\bfinal\s+(?=(public|protected|private|static)?\s*function\b)/i', '', $code); - $code = preg_replace_callback('/(?<=^|\s)(private)(\s+(static\s+)?(?:function|\$))/mi', [$this, 'replacePrivateWithProtected'], $code); - $code = preg_replace_callback('/__construct\s*\((.*?)\)/s', [$this, 'convertConstructorVisibility'], $code); - return $code; - } - - /** - * Returns the normalized, mockable version of the class source. - * - * @return string|false - */ - public function getMockableSource(): string|false - { - $source = "namespace {$this->namespace};\n" . $this->getSource(); - return $source !== null ? $this->normalizeVisibility($source) : false; - } - - /** - * Replace `private` with `protected` in method or property declarations. - * - * @param array $matches - * @return string - */ - protected function replacePrivateWithProtected(array $matches): string - { - return 'protected' . $matches[2]; - } - - /** - * Convert `private` to `protected` in constructor-promoted properties. - * - * @param array $matches - * @return string - */ - protected function convertConstructorVisibility(array $matches): string - { - $params = preg_replace('/\bprivate\b/', 'protected', $matches[1]); - return '__construct(' . $params . ')'; - } - -} diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index 41b2426..dac59a9 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -172,19 +172,7 @@ public function execute(): mixed $className = $this->reflection->getName(); $overrides = $this->generateMockMethodOverrides((string)$this->mockClassName); $unknownMethod = $this->errorHandleUnknownMethod($className, !$this->reflection->isInterface()); - - if($this->reflection->isInterface()) { - $extends = "implements $className"; - } else { - - $m = new ClassSourceNormalizer($className); - $m->addNamespace("\MaplePHP\Unitary\Mocker\MockedClass"); - eval($m->getMockableSource()); - $extends = "extends \\" . $m->getClassName(); - //$extends = "extends $className"; - } - - + $extends = $this->reflection->isInterface() ? "implements $className" : "extends $className"; $code = " class $this->mockClassName $extends { @@ -198,7 +186,6 @@ public static function __set_state(array \$an_array): self } "; - eval($code); if(!is_string($this->mockClassName)) { @@ -286,13 +273,13 @@ protected function generateMockMethodOverrides(string $mockClassName): string E_USER_WARNING ); } + */ + + $methodName = $method->getName(); if ($method->isFinal()) { $this->isFinal[$methodName] = true; continue; } - */ - - $methodName = $method->getName(); $this->methodList[] = $methodName; // The MethodItem contains all items that are validatable @@ -360,15 +347,11 @@ protected function generateMockMethodOverrides(string $mockClassName): string protected function handleModifiers(array $modifiersArr): string { $modifiersArr = array_filter($modifiersArr, fn($val) => $val !== "abstract"); - $modifiersArr = array_map(function($val) { - return ($val === "private") ? "protected" : $val; - }, $modifiersArr); - return implode(" ", $modifiersArr); } /** - * Will mocked handle the thrown exception + * Will mocked a handle the thrown exception * * @param \Throwable $exception * @return string diff --git a/src/TestCase.php b/src/TestCase.php index 54f26ad..5a90ebc 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -123,11 +123,9 @@ public function dispatchTest(self &$row): array true, fn() => false, $msg, $e->getMessage(), $e->getTrace()[0] ); } catch (Throwable $e) { - /* - if(str_contains($e->getFile(), "eval()")) { + if(str_contains($e->getFile(), "eval()")) { throw new BlunderErrorException($e->getMessage(), (int)$e->getCode()); } - */ throw $e; } if ($newInst instanceof self) { diff --git a/src/Unit.php b/src/Unit.php index d8ab297..4d06ffa 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -71,6 +71,13 @@ public function disableAllTest(bool $disable): void $this->disableAllTests = $disable; } + // Deprecated: Almost same as `disableAllTest`, for older versions + public function skip(bool $disable): self + { + $this->disableAllTests = $disable; + return $this; + } + /** * DEPRECATED: Use TestConfig::setSelect instead * See documentation for more information @@ -476,7 +483,10 @@ public static function hasUnit(): bool public static function getUnit(): ?Unit { /* - if (self::hasUnit() === false) { + // Testing to comment out Exception in Unit instance is missing + // because this will trigger as soon as it finds a file name with unitary-* + // and can become tedious that this makes the test script stop. + if (self::hasUnit() === false) { throw new Exception("Unit has not been set yet. It needs to be set first."); } */ From 66a20033ef6cbc0a810254798cb6512193ac2725 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 8 Jun 2025 12:47:39 +0200 Subject: [PATCH 48/53] Add warning if trying to mock final methods Refactor code --- src/Expect.php | 1 - src/Mocker/MethodRegistry.php | 9 +++++ src/Mocker/MockBuilder.php | 28 +++++++------- src/TestCase.php | 70 +++++++++++++++++++++++++---------- src/Unit.php | 7 ++++ tests/TestLib/UserService.php | 2 +- tests/unitary-unitary.php | 42 ++++++++++----------- 7 files changed, 100 insertions(+), 59 deletions(-) diff --git a/src/Expect.php b/src/Expect.php index 123d5b1..216d618 100644 --- a/src/Expect.php +++ b/src/Expect.php @@ -12,7 +12,6 @@ namespace MaplePHP\Unitary; use Exception; -use MaplePHP\Validate\Validator; use Throwable; use MaplePHP\Validate\ValidationChain; diff --git a/src/Mocker/MethodRegistry.php b/src/Mocker/MethodRegistry.php index 1c8ed3e..2f7be72 100644 --- a/src/Mocker/MethodRegistry.php +++ b/src/Mocker/MethodRegistry.php @@ -100,4 +100,13 @@ public function has(string $name): bool return isset(self::$methods[$this->mocker->getMockedClassName()][$name]); } + public function getSelected(array $names): array + { + if(is_null($this->mocker)) { + throw new \BadMethodCallException("MockBuilder is not set yet."); + } + + return array_filter($names, fn($name) => $this->has($name)); + } + } diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index dac59a9..aeca100 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -131,15 +131,24 @@ public function getMockedClassName(): string return (string)$this->mockClassName; } + /** + * Return all final methods + * + * @return array + */ + public function getFinalMethods(): array + { + return $this->isFinal; + } + /** * Gets the list of methods that are mocked. * - * @param string $methodName * @return bool */ - public function isFinal(string $methodName): bool + public function hasFinal(): bool { - return isset($this->isFinal[$methodName]); + return $this->isFinal !== []; } /** @@ -199,7 +208,6 @@ public static function __set_state(array \$an_array): self 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. @@ -265,19 +273,9 @@ protected function generateMockMethodOverrides(string $mockClassName): string throw new Exception("Method is not a ReflectionMethod"); } - /* - if ($method->isFinal() || $method->isPrivate()) { - trigger_error( - "Cannot mock " . ($method->isFinal() ? "final" : "private") . - " method '" . $method->getName() . "' in '{$this->className}' – the real method will be executed.", - E_USER_WARNING - ); - } - */ - $methodName = $method->getName(); if ($method->isFinal()) { - $this->isFinal[$methodName] = true; + $this->isFinal[] = $methodName; continue; } $this->methodList[] = $methodName; diff --git a/src/TestCase.php b/src/TestCase.php index 5a90ebc..4ab241f 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -47,7 +47,8 @@ final class TestCase private array $test = []; private int $count = 0; private ?Closure $bind = null; - private ?string $errorMessage = null; + private ?string $error = null; + private ?string $warning = null; private array $deferredValidation = []; private ?MockBuilder $mocker = null; @@ -100,6 +101,40 @@ function getHasAssertError(): bool return $this->hasAssertError; } + /** + * Get a possible warning message if exists + * + * @return string|null + */ + public function getWarning(): ?string + { + return $this->warning; + } + + /** + * Set a possible warning in the test group + * + * @param string $message + * @return $this + */ + public function warning(string $message): self + { + $this->warning = $message; + return $this; + } + + /** + * Add custom error message if validation fails + * + * @param string $message + * @return $this + */ + public function error(string $message): self + { + $this->error = $message; + return $this; + } + /** * Will dispatch the case tests and return them as an array * @@ -135,18 +170,6 @@ public function dispatchTest(self &$row): array return $this->test; } - /** - * 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 * @@ -159,7 +182,7 @@ public function validate(mixed $expect, Closure $validation): self { $this->expectAndValidate($expect, function (mixed $value, Expect $inst) use ($validation) { return $validation($inst, new Traverse($value)); - }, $this->errorMessage); + }, $this->error); return $this; } @@ -238,7 +261,7 @@ protected function expectAndValidate( $this->count++; } $this->test[] = $test; - $this->errorMessage = null; + $this->error = null; return $this; } @@ -321,10 +344,17 @@ public function buildMock(?Closure $validate = null): mixed throw new BadMethodCallException("The mocker is not set yet!"); } if (is_callable($validate)) { - $this->prepareValidation($this->mocker, $validate); + $pool = $this->prepareValidation($this->mocker, $validate); } /** @psalm-suppress MixedReturnStatement */ - return $this->mocker->execute(); + $class = $this->mocker->execute(); + if($this->mocker->hasFinal()) { + $finalMethods = $pool->getSelected($this->mocker->getFinalMethods()); + if($finalMethods !== []) { + $this->warning = "Warning: It is not possible to mock final methods: " . implode(", ", $finalMethods); + } + } + return $class; } /** @@ -363,10 +393,10 @@ public function getMocker(): MockBuilder * * @param MockBuilder $mocker The mocker instance containing the mock object * @param Closure $validate The closure containing validation rules - * @return void + * @return MethodRegistry * @throws ErrorException */ - private function prepareValidation(MockBuilder $mocker, Closure $validate): void + private function prepareValidation(MockBuilder $mocker, Closure $validate): MethodRegistry { $pool = new MethodRegistry($mocker); $fn = $validate->bindTo($pool); @@ -374,8 +404,8 @@ private function prepareValidation(MockBuilder $mocker, Closure $validate): void throw new ErrorException("A callable Closure could not be bound to the method pool!"); } $fn($pool); - $this->deferValidation(fn () => $this->runValidation($mocker, $pool)); + return $pool; } /** diff --git a/src/Unit.php b/src/Unit.php index 4d06ffa..8abe871 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -271,6 +271,13 @@ public function execute(): bool } if (($show || !$row->getConfig()->skip)) { + // Show possible warnings + if($row->getWarning()) { + $this->command->message(""); + $this->command->message( + $this->command->getAnsi()->style(["italic", "yellow"], $row->getWarning()) + ); + } foreach ($tests as $test) { if (!($test instanceof TestUnit)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); diff --git a/tests/TestLib/UserService.php b/tests/TestLib/UserService.php index ae61e42..78f8aef 100644 --- a/tests/TestLib/UserService.php +++ b/tests/TestLib/UserService.php @@ -2,7 +2,7 @@ namespace TestLib; -final class UserService { +class UserService { private $test = 1; public function __construct(private Mailer $mailer) {} diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 99e5496..0d30e13 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -16,28 +16,7 @@ //$unit->disableAllTest(false); $config = TestConfig::make()->withName("unitary"); -$unit->group($config->withSubject("Mocking response"), function (TestCase $case) use($unit) { - - - $stream = $case->mock(Stream::class, function (MethodRegistry $method) { - $method->method("getContents") - ->willReturn('HelloWorld', 'HelloWorld2') - ->calledAtLeast(1); - }); - $response = new Response($stream); - - $case->validate($response->getBody()->getContents(), function(Expect $inst) { - $inst->hasResponse(); - $inst->isEqualTo('HelloWorld'); - $inst->notIsEqualTo('HelloWorldNot'); - }); - - $case->validate($response->getBody()->getContents(), function(Expect $inst) { - $inst->isEqualTo('HelloWorld2'); - }); -}); -/* $unit->group($config->withSubject("Can not mock final or private"), function(TestCase $case) { $user = $case->mock(UserService::class, function(MethodRegistry $method) { @@ -228,4 +207,23 @@ }); }); - */ \ No newline at end of file +$unit->group($config->withSubject("Mocking response"), function (TestCase $case) use($unit) { + + + $stream = $case->mock(Stream::class, function (MethodRegistry $method) { + $method->method("getContents") + ->willReturn('HelloWorld', 'HelloWorld2') + ->calledAtLeast(1); + }); + $response = new Response($stream); + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->hasResponse(); + $inst->isEqualTo('HelloWorld'); + $inst->notIsEqualTo('HelloWorldNot'); + }); + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->isEqualTo('HelloWorld2'); + }); +}); \ No newline at end of file From 364bc20e4c896f16e987d00dcee7e5bde839d4be Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 8 Jun 2025 12:52:25 +0200 Subject: [PATCH 49/53] Adjust test --- tests/TestLib/UserService.php | 9 +++++++-- tests/unitary-unitary.php | 7 ++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/TestLib/UserService.php b/tests/TestLib/UserService.php index 78f8aef..506057b 100644 --- a/tests/TestLib/UserService.php +++ b/tests/TestLib/UserService.php @@ -19,13 +19,18 @@ public function registerUser(string $email): bool { return true; } - private function getUserRole(): string + private function test(): string + { + return "guest"; + } + + public function getUserRole(): string { return "guest"; } final public function getUserType(): string { - return $this->getUserRole(); + return "guest"; } } \ No newline at end of file diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 0d30e13..83ad723 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -17,16 +17,21 @@ $config = TestConfig::make()->withName("unitary"); - $unit->group($config->withSubject("Can not mock final or private"), function(TestCase $case) { $user = $case->mock(UserService::class, function(MethodRegistry $method) { $method->method("getUserRole")->willReturn("admin"); $method->method("getUserType")->willReturn("admin"); }); + // You cannot mock final with data (should return a warning) $case->validate($user->getUserType(), function(Expect $expect) { $expect->isEqualTo("guest"); }); + + // You can of course mock regular methods with data + $case->validate($user->getUserRole(), function(Expect $expect) { + $expect->isEqualTo("admin"); + }); }); $unit->group($config->withSubject("Test mocker"), function (TestCase $case) use($unit) { From 391e7788789145aa9c805152746485cbd7652f0e Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 15 Jun 2025 20:56:23 +0200 Subject: [PATCH 50/53] Add more descriptive code commments --- src/Mocker/MockedMethod.php | 152 ++++++++++++++++++---------------- src/TestCase.php | 2 +- tests/TestLib/UserService.php | 13 +-- tests/unitary-unitary.php | 28 +++++++ 4 files changed, 119 insertions(+), 76 deletions(-) diff --git a/src/Mocker/MockedMethod.php b/src/Mocker/MockedMethod.php index 3f038e5..af7bbae 100644 --- a/src/Mocker/MockedMethod.php +++ b/src/Mocker/MockedMethod.php @@ -109,38 +109,59 @@ public function getThrowable(): ?Throwable } /** - * Check if a return value has been added - * - * @return bool + * Check if a method has been called x times + * + * @param int $times + * @return $this */ - public function hasReturn(): bool + public function called(int $times): self { - return $this->hasReturn; + $inst = $this; + $inst->called = $times; + return $inst; } /** - * Preserve the original method functionality instead of mocking it. - * When this is set, the method will execute its original implementation instead of any mock behavior. + * Check if a method has been called x times + * + * @return $this + */ + public function hasBeenCalled(): self + { + $inst = $this; + $inst->called = [ + "isAtLeast" => [1], + ]; + return $inst; + } + + /** + * Check if a method has been called x times * - * @return $this Method chain + * @param int $times + * @return $this */ - public function keepOriginal(): self + public function calledAtLeast(int $times): self { $inst = $this; - $inst->keepOriginal = true; + $inst->called = [ + "isAtLeast" => [$times], + ]; return $inst; } /** * Check if a method has been called x times - * + * * @param int $times * @return $this */ - public function called(int $times): self + public function calledAtMost(int $times): self { $inst = $this; - $inst->called = $times; + $inst->called = [ + "isAtMost" => [$times], + ]; return $inst; } @@ -202,47 +223,26 @@ public function withArgumentAt(int $position, mixed $value, int $called = 0): se } /** - * Check if a method has been called x times - * - * @return $this - */ - public function hasBeenCalled(): self - { - $inst = $this; - $inst->called = [ - "isAtLeast" => [1], - ]; - return $inst; - } - - /** - * Check if a method has been called x times + * Preserve the original method functionality instead of mocking it. + * When this is set, the method will execute its original implementation instead of any mock behavior. * - * @param int $times - * @return $this + * @return $this Method chain */ - public function calledAtLeast(int $times): self + public function keepOriginal(): self { $inst = $this; - $inst->called = [ - "isAtLeast" => [$times], - ]; + $inst->keepOriginal = true; return $inst; } /** - * Check if a method has been called x times + * Check if a return value has been added * - * @param int $times - * @return $this + * @return bool */ - public function calledAtMost(int $times): self + public function hasReturn(): bool { - $inst = $this; - $inst->called = [ - "isAtMost" => [$times], - ]; - return $inst; + return $this->hasReturn; } /** @@ -259,6 +259,12 @@ public function willReturn(mixed ...$value): self return $inst; } + /** + * Configures the method to throw an exception every time it's called + * + * @param Throwable $throwable + * @return $this + */ public function willThrow(Throwable $throwable): self { $this->throwable = $throwable; @@ -266,6 +272,12 @@ public function willThrow(Throwable $throwable): self return $this; } + /** + * Configures the method to throw an exception only once + * + * @param Throwable $throwable + * @return $this + */ public function willThrowOnce(Throwable $throwable): self { $this->throwOnce = true; @@ -274,7 +286,7 @@ public function willThrowOnce(Throwable $throwable): self } /** - * Set the class name. + * Compare if method has expected class name. * * @param string $class * @return self @@ -287,7 +299,7 @@ public function hasClass(string $class): self } /** - * Set the method name. + * Compare if method has expected method name. * * @param string $name * @return self @@ -300,7 +312,7 @@ public function hasName(string $name): self } /** - * Mark the method as static. + * Check if the method is expected to be static * * @return self */ @@ -312,7 +324,7 @@ public function isStatic(): self } /** - * Mark the method as public. + * Check if the method is expected to be public * * @return self */ @@ -324,7 +336,7 @@ public function isPublic(): self } /** - * Mark the method as private. + * Check if the method is expected to be private * * @return self */ @@ -336,7 +348,7 @@ public function isPrivate(): self } /** - * Mark the method as protected. + * Check if the method is expected to be protected. * * @return self */ @@ -348,7 +360,7 @@ public function isProtected(): self } /** - * Mark the method as abstract. + * Check if the method is expected to be abstract. * * @return self */ @@ -360,7 +372,7 @@ public function isAbstract(): self } /** - * Mark the method as final. + * Check if the method is expected to be final. * * @return self */ @@ -372,7 +384,7 @@ public function isFinal(): self } /** - * Mark the method as returning by reference. + * Check if the method is expected to return a reference * * @return self */ @@ -384,7 +396,7 @@ public function returnsReference(): self } /** - * Mark the method as having a return type. + * Check if the method has a return type. * * @return self */ @@ -396,7 +408,7 @@ public function hasReturnType(): self } /** - * Set the return type of the method. + * Check if the method return type has expected type * * @param string $type * @return self @@ -409,7 +421,7 @@ public function isReturnType(string $type): self } /** - * Mark the method as a constructor. + * Check if the method is the constructor. * * @return self */ @@ -421,7 +433,7 @@ public function isConstructor(): self } /** - * Mark the method as a destructor. + * Check if the method is the destructor. * * @return self */ @@ -433,7 +445,7 @@ public function isDestructor(): self } /** - * Check if parameter exists + * Check if the method parameters exists * * @return $this */ @@ -447,7 +459,7 @@ public function hasParams(): self } /** - * Check if all parameters have a data type + * Check if the method has parameter types * * @return $this */ @@ -461,7 +473,7 @@ public function hasParamsTypes(): self } /** - * Check if parameter does not exist + * Check if the method is missing parameters * * @return $this */ @@ -475,7 +487,7 @@ public function hasNotParams(): self } /** - * Check a parameter type for method + * Check if the method has equal number of parameters as expected * * @param int $length * @return $this @@ -490,7 +502,7 @@ public function paramsHasCount(int $length): self } /** - * Check a parameter type for method + * Check if the method parameter at given index location has expected data type * * @param int $paramPosition * @param string $dataType @@ -506,7 +518,7 @@ public function paramIsType(int $paramPosition, string $dataType): self } /** - * Check parameter default value for method + * Check if the method parameter at given index location has a default value * * @param int $paramPosition * @param string $defaultArgValue @@ -522,7 +534,7 @@ public function paramHasDefault(int $paramPosition, string $defaultArgValue): se } /** - * Check a parameter type for method + * Check if the method parameter at given index location has a data type * * @param int $paramPosition * @return $this @@ -537,7 +549,7 @@ public function paramHasType(int $paramPosition): self } /** - * Check a parameter type for method + * Check if the method parameter at given index location is optional * * @param int $paramPosition * @return $this @@ -552,7 +564,7 @@ public function paramIsOptional(int $paramPosition): self } /** - * Check parameter is Reference for method + * Check if the method parameter at given index location is a reference * * @param int $paramPosition * @return $this @@ -567,7 +579,7 @@ public function paramIsReference(int $paramPosition): self } /** - * Check the parameter is variadic (spread) for a method + * Check if the method parameter at given index location is a variadic (spread) * * @param int $paramPosition * @return $this @@ -588,7 +600,7 @@ public function paramIsSpread(int $paramPosition): self } /** - * Set the doc comment for the method. + * Check if the method has comment block * * @return self */ @@ -603,7 +615,7 @@ public function hasDocComment(): self } /** - * Set the file name where the method is declared. + * Check if the method exist in file with name * * @param string $file * @return self @@ -616,7 +628,7 @@ public function hasFileName(string $file): self } /** - * Set the starting line number of the method. + * Check if the method starts at line number * * @param int $line * @return self @@ -629,7 +641,7 @@ public function startLine(int $line): self } /** - * Set the ending line number of the method. + * Check if the method return ends at line number * * @param int $line * @return self diff --git a/src/TestCase.php b/src/TestCase.php index 4ab241f..c9d8996 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -365,7 +365,7 @@ public function buildMock(?Closure $validate = null): mixed * validations are deferred and will be executed later via runDeferredValidations(). * * @param class-string $class - * @param Closure|null $validate + * @param (Closure(MethodRegistry): void)|null $callback * @param array $args * @return T * @throws Exception diff --git a/tests/TestLib/UserService.php b/tests/TestLib/UserService.php index 506057b..c6a0524 100644 --- a/tests/TestLib/UserService.php +++ b/tests/TestLib/UserService.php @@ -19,11 +19,6 @@ public function registerUser(string $email): bool { return true; } - private function test(): string - { - return "guest"; - } - public function getUserRole(): string { return "guest"; @@ -33,4 +28,12 @@ final public function getUserType(): string { return "guest"; } + + public function issueToken(): string { + return $this->generateToken(); // private + } + + private function generateToken(): string { + return bin2hex(random_bytes(16)); + } } \ No newline at end of file diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 83ad723..e8c20cf 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -17,6 +17,23 @@ $config = TestConfig::make()->withName("unitary"); + +$unit->group($config->withSubject("Test mocker"), function (TestCase $case) use($unit) { + + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { + $method->method("addFromEmail") + ->withArguments("john.doe@gmail.com", "John Doe") + ->called(2); + }); + + + $mail->addFromEmail("john.doe@gmail.com", "John Doe"); +}); + +$unit->group("Example of assert in group", function(TestCase $case) { + assert(1 === 2, "This is a error message"); +}); + $unit->group($config->withSubject("Can not mock final or private"), function(TestCase $case) { $user = $case->mock(UserService::class, function(MethodRegistry $method) { $method->method("getUserRole")->willReturn("admin"); @@ -32,6 +49,7 @@ $case->validate($user->getUserRole(), function(Expect $expect) { $expect->isEqualTo("admin"); }); + }); $unit->group($config->withSubject("Test mocker"), function (TestCase $case) use($unit) { @@ -231,4 +249,14 @@ $case->validate($response->getBody()->getContents(), function(Expect $inst) { $inst->isEqualTo('HelloWorld2'); }); +}); + +$unit->group("Example API Response", function(TestCase $case) { + + $case->validate('{"response":{"status":200,"message":"ok"}}', function(Expect $expect) { + + $expect->isJson()->hasJsonValueAt("response.status", 404); + assert($expect->isValid(), "Expected JSON structure did not match."); + }); + }); \ No newline at end of file From bd0b6b15e980dfca018d50a916f24852b0a3251f Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sat, 21 Jun 2025 18:41:02 +0200 Subject: [PATCH 51/53] feat: Start building code coverage functionality Improve README.md file --- README.md | 314 +++++++++++---------------------- src/TestUtils/CodeCoverage.php | 171 ++++++++++++++++++ tests/unitary-unitary.php | 1 + 3 files changed, 276 insertions(+), 210 deletions(-) create mode 100644 src/TestUtils/CodeCoverage.php diff --git a/README.md b/README.md index 08e6bdb..b9a8715 100644 --- a/README.md +++ b/README.md @@ -1,280 +1,174 @@ -# MaplePHP - Unitary +# MaplePHP Unitary β€” Fast Testing, Full Control, Zero Friction -PHP Unitary is a **user-friendly** and robust unit testing framework 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)_ +Unitary is a modern PHP testing framework built for developers who want speed, precision, and complete freedom. No config. No noise. Just a clean, purpose-built system that runs 100,000+ tests in a second and scales effortlesslyβ€”from quick checks to full-suite validation. +Mocking, validation, and assertions are all built inβ€”ready to use without setup or plugins. The CLI is intuitive, the experience is consistent, and getting started takes seconds. Whether you’re testing one function or an entire system, Unitary helps you move fast and test with confidence. -### Syntax You Will Love -```php -$unit->case("MaplePHP Request URI path test", function() { - $response = new Response(200); +![Prompt demo](http://wazabii.se/github-assets/unitary/unitary-cli-states.png) - $this->add($response->getStatusCode(), function() { - return $this->equal(200); - }, "Did not return HTTP status code 200"); -}); -``` +_Do you like the CLI theme? [Download it here](https://github.com/MaplePHP/DarkBark)_ -## Documentation -The documentation is divided into several sections: -- [Installation](#installation) -- [Guide](#guide) - - [1. Create a Test File](#1-create-a-test-file) - - [2. Create a Test Case](#2-create-a-test-case) - - [3. Run the Tests](#3-run-the-tests) -- [Configurations](#configurations) -- [Validation List](#validation-list) - - [Data Type Checks](#data-type-checks) - - [Equality and Length Checks](#equality-and-length-checks) - - [Numeric Range Checks](#numeric-range-checks) - - [String and Pattern Checks](#string-and-pattern-checks) - - [Required and Boolean-Like Checks](#required-and-boolean-like-checks) - - [Date and Time Checks](#date-and-time-checks) - - [Version Checks](#version-checks) - - [Logical Checks](#logical-checks) - - -## Installation - -To install MaplePHP Unitary, run the following command: -```bash -composer require --dev maplephp/unitary -``` +### Familiar Syntax. Fast Feedback. -## Guide +Unitary is designed to feel natural for developers. With clear syntax, built-in validation, and zero setup required, writing tests becomes a smooth part of your daily flowβ€”not a separate chore. -### 1. Create a Test File +```php +$unit->group("Has a about page", function(TestCase $case) { -Unitary will, by default, find all files prefixed with "unitary-" recursively from your project's root directory (where your "composer.json" file exists). The vendor directory will be excluded by default. + $response = $this->get("/about"); + $statusCode = $response->getStatusCode(); + + $case->validate($statusCode, function(Expect $valid) { + $valid->isHttpSuccess(); + }); +}); +``` -Start by creating a test file with a name that starts with "unitary-", e.g., "unitary-request.php". You can place the file inside your library directory, for example like this: `tests/unitary-request.php`. +--- -**Note: All of your library classes will automatically be autoloaded through Composer's autoloader inside your test file!** +## Next-Gen PHP Testing Framework -### 2. Create a Test Case +**Unitary** is a blazing-fast, developer-first testing framework for PHP, built from scratch with zero dependencies on legacy tools like many others. It’s simple to get started, lightning-fast to run, and powerful enough to test everything from units to mocks. -Now that we have created a test file, e.g., `tests/unitary-request.php`, we will need to add our test cases and tests. I will create a test for one of my other libraries below, which is MaplePHP/HTTP, specifically the Request library that has full PSR-7 support. +> πŸš€ *Test 100,000+ cases in \~1 second. No config. No bloat. Just results.* -I will show you three different ways to test your application below. +--- -```php -case("MaplePHP Request URI path test", function() use($request) { +## ⚑ Blazing Fast Performance - // Then add tests to your case: - // Test 1: Access the validation instance inside the add closure - $this->add($request->getMethod(), function($value) { - return $this->equal("GET"); +Unitary runs large test suites in a fraction of the time β€” even **100,000+** tests in just **1 second**. - }, "HTTP Request method type does not equal GET"); - // Adding an error message is not required, but it is highly recommended. +πŸš€ That’s up to 46Γ— faster than the most widely used testing frameworks. - // Test 2: Built-in validation shortcuts - $this->add($request->getUri()->getPort(), [ - "isInt" => [], // Has no arguments = empty array - "min" => [1], // The strict way is to pass each argument as an array item - "max" => 65535, // If it's only one argument, then this is acceptable too - "length" => [1, 5] - ], "Is not a valid port number"); +> Benchmarks based on real-world test cases. +> πŸ‘‰ [See full benchmark comparison β†’](https://your-docs-link.com/benchmarks) - // Test 3: It is also possible to combine them all in one. - $this->add($request->getUri()->getUserInfo(), [ - "isString" => [], - "User validation" => function($value) { - $arr = explode(":", $value); - return ($this->withValue($arr[0])->equal("admin") && $this->withValue($arr[1])->equal("mypass")); - } +--- - ], "Did not get the expected user info credentials"); -}); -``` -The example above uses both built-in validation and custom validation (see below for all built-in validation options). +## Getting Started (Under 1 Minute) -### 3. Run the Tests +Set up your first test in three easy steps: -Now you are ready to execute the tests. Open your command line of choice, navigate (cd) to your project's root directory (where your `composer.json` file exists), and execute the following command: +### 1. Install ```bash -php vendor/bin/unitary +composer require --dev maplephp/unitary ``` -#### The Output: -![Prompt demo](http://wazabii.se/github-assets/maplephp-unitary-result.png) -*And that is it! Your tests have been successfully executed!* +_You can run unitary globally if preferred with `composer global require maplephp/unitary`._ -With that, you are ready to create your own tests! +--- +### 2. Create a Test File -## Mocking -Unitary comes with a built-in mocker that makes it super simple for you to mock classes. +Create a file like `tests/unitary-request.php`. Unitary automatically scans all files prefixed with `unitary-` (excluding `vendor/`). - -### 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. +Paste this test boilerplate to get started: ```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_ - -### 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\ValidationChain; -use \MaplePHP\Unitary\Mocker\MethodRegistry; - -$unit->group("Testing user service", function (TestCase $inst) { - $mock = $inst->mock(Mailer::class, function (MethodRegistry $pool) use($inst) { - // Quick way to tell Unitary that this method should return 'john.doe' - $pool->method("getFromEmail")->willReturn('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(ValidationChain $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); -}); -``` - -### 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. +use MaplePHP\Unitary\{Unit, TestCase, TestConfig, Expect}; -```php -use \MaplePHP\Unitary\Mocker\MethodRegistry; - -$unit->group("Unitary test", function (TestCase $inst) { - $mock = $inst->mock(Mailer::class, function (MethodRegistry $pool) use($inst) { - $pool->method("addFromEmail") - ->isPublic() - ->hasDocComment() - ->hasReturnType() - ->isTimes(1); - - $pool->method("addBCC") - ->isPublic() - ->isTimes(3); +$unit = new Unit(); +$unit->group("Your test subject", function (TestCase $case) { + $case->validate("Your test value", function(Expect $valid) { + $valid->isString(); }); - $service = new UserService($mock); }); ``` +> πŸ’‘ Tip: Run `php vendor/bin/unitary --template` to auto-generate this boilerplate code above. -### Integration tests: Test Wrapper -Test wrapper is great to make integration test easier. +--- -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. +### 3. Run Tests -```php -$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); - 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; -}); +```bash +php vendor/bin/unitary ``` +Need help? -## Configurations - -### Show help ```bash php vendor/bin/unitary --help ``` -### Show only errors -```bash -php vendor/bin/unitary --errors-only -``` +#### The Output: +![Prompt demo](http://wazabii.se/github-assets/maplephp-unitary-result.png) +*And that is it! Your tests have been successfully executed!* -### Select a Test File to Run +With that, you are ready to create your own tests! -After each test, a hash key is shown, allowing you to run specific tests instead of all. +--- -```bash -php vendor/bin/unitary --show=b0620ca8ef6ea7598eaed56a530b1983 -``` +## πŸ“… Latest Release -### Run Test Case Manually +**v1.3.0 – 2025-06-20** +This release marks Unitary’s transition from a testing utility to a full framework. With the core in place, expect rapid improvements in upcoming versions. -You can also mark a test case to run manually, excluding it from the main test batch. +--- -```php -$unit->hide('maplePHPRequest')->case("MaplePHP Request URI path test", function() { - ... -}); -``` +## 🧱 Built From the Ground Up -And this will only run the manual test: -```bash -php vendor/bin/unitary --show=maplePHPRequest -``` +Unitary stands on a solid foundation of years of groundwork. Before Unitary was possible, these independent components were developed: -### Change Test Path +* [`maplephp/http`](https://github.com/maplephp/http) – PSR-7 HTTP messaging +* [`maplephp/stream`](https://github.com/maplephp/stream) – PHP stream handling +* [`maplephp/cli`](https://github.com/maplephp/prompts) – Interactive prompt/command engine +* [`maplephp/blunder`](https://github.com/maplephp/blunder) – A pretty error handling framework +* [`maplephp/validate`](https://github.com/maplephp/validate) – Type-safe input validation +* [`maplephp/dto`](https://github.com/maplephp/dto) – Strong data transport +* [`maplephp/container`](https://github.com/maplephp/container) – PSR-11 Container, container and DI system -The path argument takes both absolute and relative paths. The command below will find all tests recursively from the "tests" directory. +This full control means everything works together, no patching, no adapters and no guesswork. -```bash -php vendor/bin/unitary --path="/tests/" -``` +--- -**Note: The `vendor` directory will be excluded from tests by default. However, if you change the `--path`, you will need to manually exclude the `vendor` directory.** +## Philosophy -### Exclude Files or Directories +> **Test everything. All the time. Without friction.** -The exclude argument will always be a relative path from the `--path` argument's path. +TDD becomes natural when your test suite runs in under a second, even with 100,000 cases. No more cherry-picking. No more skipping. -```bash -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. -[Download it here](https://github.com/MaplePHP/DarkBark) \ No newline at end of file +[Download it here](https://github.com/MaplePHP/DarkBark) + + +--- + +## 🀝 Contribute + +Unitary is still young β€” your bug reports, feedback, and suggestions are hugely appreciated. + +If you like what you see, consider: + +* Reporting issues +* Sharing feedback +* Submitting PRs +* Starring the repo ⭐ + +--- + +## πŸ“¬ Stay in Touch + +Follow the full suite of MaplePHP tools: + +* [https://github.com/maplephp](https://github.com/maplephp) diff --git a/src/TestUtils/CodeCoverage.php b/src/TestUtils/CodeCoverage.php new file mode 100644 index 0000000..1b087dd --- /dev/null +++ b/src/TestUtils/CodeCoverage.php @@ -0,0 +1,171 @@ + */ + const ERROR = [ + "No error", + "Xdebug is not available", + "Xdebug is enabled, but coverage mode is missing" + ]; + + private ?array $data = null; + private int $errorCode = 0; + + private array $allowedDirs = []; + private array $excludeDirs = [ + "vendor", + "tests", + "test", + "unit-tests", + "spec", + "bin", + "public", + "storage", + "bootstrap", + "resources", + "database", + "config", + "node_modules", + "coverage-report", + // Exclude below to protect against edge cases + // (like someone accidentally putting a .php file in .github/scripts/ and including it) + ".idea", + ".vscode", + ".git", + ".github" + ]; + + + /** + * Check if Xdebug is enabled + * + * @return bool + */ + public function hasXdebug(): bool + { + if($this->errorCode > 0) { + return false; + } + if (!function_exists('xdebug_info')) { + $this->errorCode = 1; + return false; + } + return true; + } + + /** + * Check if Xdebug has coverage mode enabled. + * + * @return bool + */ + public function hasXdebugCoverage(): bool + { + if(!$this->hasXdebug()) { + return false; + } + $mode = ini_get('xdebug.mode'); + if ($mode === false || !str_contains($mode, 'coverage')) { + $this->errorCode = 1; + return false; + } + return true; + } + + + public function exclude(string|array $path): void + { + + } + + public function whitelist(string|array $path): void + { + + } + + /** + * Start coverage listening + * + * @psalm-suppress UndefinedFunction + * @psalm-suppress UndefinedConstant + * @noinspection PhpUndefinedFunctionInspection + * @noinspection PhpUndefinedConstantInspection + * + * @return void + */ + public function start(): void + { + $this->data = []; + if($this->hasXdebugCoverage()) { + xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE); + } + } + + /** + * End coverage listening + * + * @psalm-suppress UndefinedFunction + * @noinspection PhpUndefinedFunctionInspection + * + * @return void + */ + public function end(): void + { + if($this->data === []) { + throw new BadMethodCallException("You must start code coverage before you can end it"); + } + if($this->hasXdebugCoverage()) { + + $this->data = xdebug_get_code_coverage(); + xdebug_stop_code_coverage(); + } + } + + /** + * Get a Coverage result, will return false if there is an error + * + * @return array|false + */ + public function getResponse(): array|false + { + if($this->errorCode > 0) { + return false; + } + return $this->data; + } + + /** + * Get an error message + * + * @return string + */ + public function getError(): string + { + return self::ERROR[$this->errorCode]; + } + + /** + * Get an error code + * + * @return int + */ + public function getCode(): int + { + return $this->errorCode; + } + + /** + * Check if error exists + * + * @return bool + */ + public function hasError(): bool + { + return ($this->errorCode > 0); + } +} \ No newline at end of file diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index e8c20cf..e2fd38e 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -30,6 +30,7 @@ $mail->addFromEmail("john.doe@gmail.com", "John Doe"); }); + $unit->group("Example of assert in group", function(TestCase $case) { assert(1 === 2, "This is a error message"); }); From 9a871f5543595bee45baf88b4f3d2668d4cb0213 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 22 Jun 2025 16:00:41 +0200 Subject: [PATCH 52/53] feat: Introduce initial MVC setup for CLI --- bin/unitary | 45 +++++----- src/Kernel/Controllers/DefaultController.php | 21 +++++ src/Kernel/Controllers/RunTestController.php | 94 ++++++++++++++++++++ src/Kernel/Kernel.php | 60 +++++++++++++ src/Kernel/routes.php | 6 ++ src/TestUtils/CodeCoverage.php | 10 +++ src/Unit.php | 53 +---------- src/Utils/Dispatcher.php | 28 ++++++ src/Utils/Router.php | 62 +++++++++++++ 9 files changed, 302 insertions(+), 77 deletions(-) create mode 100644 src/Kernel/Controllers/DefaultController.php create mode 100644 src/Kernel/Controllers/RunTestController.php create mode 100644 src/Kernel/Kernel.php create mode 100644 src/Kernel/routes.php create mode 100644 src/Utils/Dispatcher.php create mode 100644 src/Utils/Router.php diff --git a/bin/unitary b/bin/unitary index 0ca8671..af261f9 100755 --- a/bin/unitary +++ b/bin/unitary @@ -5,36 +5,31 @@ * @example php unitary --path=fullDirPath --exclude="dir1/dir2/ */ -require $GLOBALS['_composer_autoload_path']; - +use MaplePHP\Container\Container; use MaplePHP\Http\Environment; use MaplePHP\Http\ServerRequest; use MaplePHP\Http\Uri; -use MaplePHP\Prompts\Command; -use MaplePHP\Unitary\Utils\FileIterator; +use MaplePHP\Unitary\Kernel\Kernel; + +$autoload = __DIR__ . '/../vendor/autoload.php'; +if (!file_exists($autoload)) { + if (!empty($GLOBALS['_composer_autoload_path'])) { + $autoload = $GLOBALS['_composer_autoload_path']; + } else { + fwrite(STDERR, "Autoloader not found. Run `composer install`.\n"); + exit(1); + } +} + +require $autoload; -$command = new Command(); +$container = new Container(); $env = new Environment(); +// Pass argv and expected start directory path where to run tests $request = new ServerRequest(new Uri($env->getUriParts([ - "argv" => $argv + "argv" => $argv, + "dir" => (defined("UNITARY_PATH") ? UNITARY_PATH : "./") ])), $env); -$data = $request->getCliArgs(); -$defaultPath = (defined("UNITARY_PATH") ? UNITARY_PATH : "./"); - -try { - $path = ($data['path'] ?? $defaultPath); - if(!isset($path)) { - throw new Exception("Path not specified: --path=path/to/dir"); - } - - $testDir = realpath($path); - if(!is_dir($testDir)) { - throw new Exception("Test directory '$testDir' does not exist"); - } - $unit = new FileIterator($data); - $unit->executeAll($testDir, $defaultPath); - -} catch (Exception $e) { - $command->error($e->getMessage()); -} +$kernel = new Kernel($request, $container); +$kernel->dispatch(); diff --git a/src/Kernel/Controllers/DefaultController.php b/src/Kernel/Controllers/DefaultController.php new file mode 100644 index 0000000..999552e --- /dev/null +++ b/src/Kernel/Controllers/DefaultController.php @@ -0,0 +1,21 @@ +request = $request; + $this->container = $container; + } +} \ No newline at end of file diff --git a/src/Kernel/Controllers/RunTestController.php b/src/Kernel/Controllers/RunTestController.php new file mode 100644 index 0000000..87501ef --- /dev/null +++ b/src/Kernel/Controllers/RunTestController.php @@ -0,0 +1,94 @@ +container->get("request")->getUri()->getDir(); + try { + $path = ($args['path'] ?? $defaultPath); + if(!isset($path)) { + throw new RuntimeException("Path not specified: --path=path/to/dir"); + } + $testDir = realpath($path); + if(!is_dir($testDir)) { + throw new RuntimeException("Test directory '$testDir' does not exist"); + } + $unit = new FileIterator($args); + $unit->executeAll($testDir, $defaultPath); + + } catch (Exception $e) { + $command->error($e->getMessage()); + } + } + + /** + * Main help page + * + * @param array $args + * @param Command $command + * @return void + */ + public function help(array $args, Command $command): void + { + $blocks = new Blocks($command); + $blocks->addHeadline("\n--- Unitary Help ---"); + $blocks->addSection("Usage", "php vendor/bin/unitary [options]"); + + $blocks->addSection("Options", function(Blocks $inst) { + return $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("template", "Will give you a boilerplate test code") + ->addOption("path=", "Specify test path (absolute or relative)") + ->addOption("exclude=", "Exclude files or directories (comma-separated, relative to --path)"); + }); + + $blocks->addSection("Examples", function(Blocks $inst) { + return $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=YourNameHere", + "Run a manually named test case" + )->addExamples( + "php vendor/bin/unitary --template", + "Run a and will give you template code for a new test" + )->addExamples( + 'php vendor/bin/unitary --path="tests/" --exclude="tests/legacy/*,*/extras/*"', + 'Run all tests under "tests/" excluding specified directories' + ); + }); + // Make sure nothing else is executed when help is triggered + exit(0); + } + +} \ No newline at end of file diff --git a/src/Kernel/Kernel.php b/src/Kernel/Kernel.php new file mode 100644 index 0000000..317126d --- /dev/null +++ b/src/Kernel/Kernel.php @@ -0,0 +1,60 @@ +request = $request; + $this->container = $container; + $this->router = new Router($this->request->getCliKeyword(), $this->request->getCliArgs()); + } + + /** + * Dispatch routes and call controller + * + * @return void + */ + function dispatch() + { + $router = $this->router; + require_once __DIR__ . "/routes.php"; + + $this->container->set("request", $this->request); + + $router->dispatch(function($controller, $args) { + $command = new Command(); + [$class, $method] = $controller; + if(method_exists($class, $method)) { + $inst = new $class($this->request, $this->container); + $inst->{$method}($args, $command); + + } else { + $command->error("The controller {$class}::{$method}() not found"); + } + }); + } +} \ No newline at end of file diff --git a/src/Kernel/routes.php b/src/Kernel/routes.php new file mode 100644 index 0000000..b204ce1 --- /dev/null +++ b/src/Kernel/routes.php @@ -0,0 +1,6 @@ +map(["", "test", "run"], [RunTestController::class, "run"]); +$router->map(["__404", "help"], [RunTestController::class, "help"]); \ No newline at end of file diff --git a/src/TestUtils/CodeCoverage.php b/src/TestUtils/CodeCoverage.php index 1b087dd..681d436 100644 --- a/src/TestUtils/CodeCoverage.php +++ b/src/TestUtils/CodeCoverage.php @@ -1,4 +1,14 @@ template(); - $this->help(); if ($this->executed || $this->disableAllTests) { return false; } @@ -561,57 +561,6 @@ private function template(): void } } - /** - * 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("\n--- Unitary Help ---"); - $blocks->addSection("Usage", "php vendor/bin/unitary [options]"); - - $blocks->addSection("Options", function(Blocks $inst) { - return $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("template", "Will give you a boilerplate test code") - ->addOption("path=", "Specify test path (absolute or relative)") - ->addOption("exclude=", "Exclude files or directories (comma-separated, relative to --path)"); - }); - - $blocks->addSection("Examples", function(Blocks $inst) { - return $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=YourNameHere", - "Run a manually named test case" - )->addExamples( - "php vendor/bin/unitary --template", - "Run a and will give you template code for a new test" - )->addExamples( - 'php vendor/bin/unitary --path="tests/" --exclude="tests/legacy/*,*/extras/*"', - 'Run all tests under "tests/" excluding specified directories' - ); - }); - exit(0); - } - } - /** * Adds a test case to the collection. * diff --git a/src/Utils/Dispatcher.php b/src/Utils/Dispatcher.php new file mode 100644 index 0000000..ebf1da0 --- /dev/null +++ b/src/Utils/Dispatcher.php @@ -0,0 +1,28 @@ +$method($args); + } + +} \ No newline at end of file diff --git a/src/Utils/Router.php b/src/Utils/Router.php new file mode 100644 index 0000000..22fe1f6 --- /dev/null +++ b/src/Utils/Router.php @@ -0,0 +1,62 @@ +args = $argv; + $this->needle = $needle; + } + + /** + * Map one or more needles to controller + + * @param string|array $needles + * @param array $controller + * @return $this + */ + public function map(string|array $needles, array $controller): self + { + if(is_string($needles)) { + $needles = [$needles]; + } + foreach ($needles as $key) { + $this->controllers[$key] = $controller; + } + return $this; + } + + /** + * Dispatch matched router + * + * @param callable $call + * @return bool + */ + function dispatch(callable $call): bool + { + if(isset($this->controllers[$this->needle])) { + $call($this->controllers[$this->needle], $this->args, $this->needle); + return true; + } + if (isset($this->controllers["__404"])) { + $call($this->controllers["__404"], $this->args, $this->needle); + } + return false; + } +} \ No newline at end of file From c89fc39c984dd73e89e36769e729345be1b0fa6c Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Thu, 26 Jun 2025 21:04:55 +0200 Subject: [PATCH 53/53] Add coverage Start building stream handlers --- src/Contracts/AbstractHandler.php | 119 +++++++++++ src/Contracts/HandlerInterface.php | 83 ++++++++ src/Handlers/CliHandler.php | 191 ++++++++++++++++++ src/Kernel/Controllers/CoverageController.php | 101 +++++++++ src/Kernel/Controllers/RunTestController.php | 15 +- src/Kernel/Controllers/TemplateController.php | 98 +++++++++ src/Kernel/routes.php | 5 + src/TestUtils/CodeCoverage.php | 59 +++++- src/TestUtils/Configs.php | 35 ++++ src/Unit.php | 177 ++-------------- src/Utils/Dispatcher.php | 28 --- src/Utils/FileIterator.php | 41 ++-- 12 files changed, 747 insertions(+), 205 deletions(-) create mode 100644 src/Contracts/AbstractHandler.php create mode 100644 src/Contracts/HandlerInterface.php create mode 100644 src/Handlers/CliHandler.php create mode 100644 src/Kernel/Controllers/CoverageController.php create mode 100644 src/Kernel/Controllers/TemplateController.php create mode 100644 src/TestUtils/Configs.php delete mode 100644 src/Utils/Dispatcher.php diff --git a/src/Contracts/AbstractHandler.php b/src/Contracts/AbstractHandler.php new file mode 100644 index 0000000..b94cddb --- /dev/null +++ b/src/Contracts/AbstractHandler.php @@ -0,0 +1,119 @@ +case = $testCase; + } + + /** + * {@inheritDoc} + */ + public function setSuitName(string $title): void + { + $this->suitName = $title; + } + + /** + * {@inheritDoc} + */ + public function setChecksum(string $checksum): void + { + $this->checksum = $checksum; + } + + /** + * {@inheritDoc} + */ + public function setTests(array $tests): void + { + $this->tests = $tests; + } + + /** + * {@inheritDoc} + */ + public function setShow(bool $show): void + { + $this->show = $show; + } + + /** + * {@inheritDoc} + */ + public function outputBuffer(string $outputBuffer): void + { + $this->outputBuffer = $outputBuffer; + } + + /** + * {@inheritDoc} + */ + public function buildBody(): void + { + throw new \RuntimeException('Your handler is missing the execution method.'); + } + + /** + * {@inheritDoc} + */ + public function buildNotes(): void + { + + throw new \RuntimeException('Your handler is missing the execution method.'); + } + + /** + * {@inheritDoc} + */ + public function returnStream(): StreamInterface + { + return new Stream(); + } + + + /** + * Make a file path into a title + * @param string $file + * @param int $length + * @param bool $removeSuffix + * @return string + */ + protected function formatFileTitle(string $file, int $length = 3, bool $removeSuffix = true): string + { + $file = explode("/", $file); + if ($removeSuffix) { + $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); + return ".." . $file; + } + +} \ No newline at end of file diff --git a/src/Contracts/HandlerInterface.php b/src/Contracts/HandlerInterface.php new file mode 100644 index 0000000..651e16f --- /dev/null +++ b/src/Contracts/HandlerInterface.php @@ -0,0 +1,83 @@ +command = $command; + } + + /** + * {@inheritDoc} + */ + public function buildBody(): void + { + $this->initDefault(); + + $this->command->message(""); + $this->command->message( + $this->flag . " " . + $this->command->getAnsi()->style(["bold"], $this->formatFileTitle($this->suitName)) . + " - " . + $this->command->getAnsi()->style(["bold", $this->color], (string)$this->case->getMessage()) + ); + + if($this->show && !$this->case->hasFailed()) { + $this->command->message(""); + $this->command->message( + $this->command->getAnsi()->style(["italic", $this->color], "Test file: " . $this->suitName) + ); + } + + if (($this->show || !$this->case->getConfig()->skip)) { + // Show possible warnings + if($this->case->getWarning()) { + $this->command->message(""); + $this->command->message( + $this->command->getAnsi()->style(["italic", "yellow"], $this->case->getWarning()) + ); + } + + $this->showFailedTests(); + } + + $this->showFooter(); + } + + /** + * {@inheritDoc} + */ + public function buildNotes(): void + { + if($this->outputBuffer) { + $lineLength = 80; + $output = wordwrap($this->outputBuffer, $lineLength); + $line = $this->command->getAnsi()->line($lineLength); + + $this->command->message(""); + $this->command->message($this->command->getAnsi()->style(["bold"], "Note:")); + $this->command->message($line); + $this->command->message($output); + $this->command->message($line); + } + } + + /** + * {@inheritDoc} + */ + public function returnStream(): StreamInterface + { + return $this->command->getStream(); + } + + protected function showFooter(): void + { + $select = $this->checksum; + if ($this->case->getConfig()->select) { + $select .= " (" . $this->case->getConfig()->select . ")"; + } + $this->command->message(""); + + $passed = $this->command->getAnsi()->bold("Passed: "); + if ($this->case->getHasAssertError()) { + $passed .= $this->command->getAnsi()->style(["grey"], "N/A"); + } else { + $passed .= $this->command->getAnsi()->style([$this->color], $this->case->getCount() . "/" . $this->case->getTotal()); + } + + $footer = $passed . + $this->command->getAnsi()->style(["italic", "grey"], " - ". $select); + if (!$this->show && $this->case->getConfig()->skip) { + $footer = $this->command->getAnsi()->style(["italic", "grey"], $select); + } + $this->command->message($footer); + $this->command->message(""); + + } + + protected function showFailedTests(): void + { + if (($this->show || !$this->case->getConfig()->skip)) { + // Show possible warnings + if($this->case->getWarning()) { + $this->command->message(""); + $this->command->message( + $this->command->getAnsi()->style(["italic", "yellow"], $this->case->getWarning()) + ); + } + foreach ($this->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", $this->color], "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 {$trace['file']}:{$trace['line']}")); + $this->command->message($this->command->getAnsi()->style(["grey"], " β†’ {$trace['code']}")); + } + + foreach ($test->getUnits() as $unit) { + + /** @var TestItem $unit */ + if (!$unit->isValid()) { + $lengthA = $test->getValidationLength(); + $validation = $unit->getValidationTitle(); + $title = str_pad($validation, $lengthA); + $compare = $unit->hasComparison() ? $unit->getComparison() : ""; + + $failedMsg = " " .$title . " β†’ failed"; + $this->command->message($this->command->getAnsi()->style($this->color, $failedMsg)); + + if ($compare) { + $lengthB = (strlen($compare) + strlen($failedMsg) - 8); + $comparePad = str_pad($compare, $lengthB, " ", STR_PAD_LEFT); + $this->command->message( + $this->command->getAnsi()->style($this->color, $comparePad) + ); + } + } + } + if ($test->hasValue()) { + $this->command->message(""); + $this->command->message( + $this->command->getAnsi()->bold("Input value: ") . + Helpers::stringifyDataTypes($test->getValue()) + ); + } + } + } + } + } + + protected function initDefault(): void + { + $this->color = ($this->case->hasFailed() ? "brightRed" : "brightBlue"); + if ($this->case->hasFailed()) { + $this->flag = $this->command->getAnsi()->style(['redBg', 'brightWhite'], " FAIL "); + } + if ($this->case->getConfig()->skip) { + $this->color = "yellow"; + $this->flag = $this->command->getAnsi()->style(['yellowBg', 'black'], " SKIP "); + } + $this->flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); + } +} \ No newline at end of file diff --git a/src/Kernel/Controllers/CoverageController.php b/src/Kernel/Controllers/CoverageController.php new file mode 100644 index 0000000..9b40183 --- /dev/null +++ b/src/Kernel/Controllers/CoverageController.php @@ -0,0 +1,101 @@ +enableExitScript(false); + + $coverage->start(); + $this->iterateTest($commandInMem, $iterator, $args); + $coverage->end(); + + $result = $coverage->getResponse(); + + $block = new Blocks($command); + + $block->addSection("Code coverage", function(Blocks $block) use ($result) { + return $block->addList("Total lines:", $result['totalLines']) + ->addList("Executed lines:", $result['executedLines']) + ->addList("Code coverage percent:", $result['percent']); + }); + + $command->message(""); + $iterator->exitScript(); + } + + /** + * Main help page + * + * @param array $args + * @param Command $command + * @return void + */ + public function help(array $args, Command $command): void + { + $blocks = new Blocks($command); + $blocks->addHeadline("\n--- Unitary Help ---"); + $blocks->addSection("Usage", "php vendor/bin/unitary [options]"); + + $blocks->addSection("Options", function(Blocks $inst) { + return $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("template", "Will give you a boilerplate test code") + ->addOption("path=", "Specify test path (absolute or relative)") + ->addOption("exclude=", "Exclude files or directories (comma-separated, relative to --path)"); + }); + + $blocks->addSection("Examples", function(Blocks $inst) { + return $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=YourNameHere", + "Run a manually named test case" + )->addExamples( + "php vendor/bin/unitary --template", + "Run a and will give you template code for a new test" + )->addExamples( + 'php vendor/bin/unitary --path="tests/" --exclude="tests/legacy/*,*/extras/*"', + 'Run all tests under "tests/" excluding specified directories' + ); + }); + // Make sure nothing else is executed when help is triggered + exit(0); + } + +} \ No newline at end of file diff --git a/src/Kernel/Controllers/RunTestController.php b/src/Kernel/Controllers/RunTestController.php index 87501ef..dd8b5a0 100644 --- a/src/Kernel/Controllers/RunTestController.php +++ b/src/Kernel/Controllers/RunTestController.php @@ -7,6 +7,7 @@ use MaplePHP\Container\Interfaces\NotFoundExceptionInterface; use MaplePHP\Prompts\Command; use MaplePHP\Prompts\Themes\Blocks; +use MaplePHP\Unitary\TestUtils\Configs; use MaplePHP\Unitary\Utils\FileIterator; use RuntimeException; @@ -24,6 +25,14 @@ class RunTestController extends DefaultController */ public function run(array $args, Command $command): void { + $iterator = new FileIterator($args); + $this->iterateTest($command, $iterator, $args); + } + + protected function iterateTest(Command $command, FileIterator $iterator, array $args): void + { + Configs::getInstance()->setCommand($command); + $defaultPath = $this->container->get("request")->getUri()->getDir(); try { $path = ($args['path'] ?? $defaultPath); @@ -31,11 +40,11 @@ public function run(array $args, Command $command): void throw new RuntimeException("Path not specified: --path=path/to/dir"); } $testDir = realpath($path); - if(!is_dir($testDir)) { + if(!file_exists($testDir)) { throw new RuntimeException("Test directory '$testDir' does not exist"); } - $unit = new FileIterator($args); - $unit->executeAll($testDir, $defaultPath); + + $iterator->executeAll($testDir, $defaultPath); } catch (Exception $e) { $command->error($e->getMessage()); diff --git a/src/Kernel/Controllers/TemplateController.php b/src/Kernel/Controllers/TemplateController.php new file mode 100644 index 0000000..c73cf66 --- /dev/null +++ b/src/Kernel/Controllers/TemplateController.php @@ -0,0 +1,98 @@ +addHeadline("\n--- Unitary template ---"); + $blocks->addCode( + <<<'PHP' + use MaplePHP\Unitary\{Unit, TestCase, TestConfig, Expect}; + + $unit = new Unit(); + $unit->group("Your test subject", function (TestCase $case) { + + $case->validate("Your test value", function(Expect $valid) { + $valid->isString(); + }); + + }); + PHP + ); + exit(0); + } + + /** + * Main help page + * + * @param array $args + * @param Command $command + * @return void + */ + public function help(array $args, Command $command): void + { + $blocks = new Blocks($command); + $blocks->addHeadline("\n--- Unitary Help ---"); + $blocks->addSection("Usage", "php vendor/bin/unitary [options]"); + + $blocks->addSection("Options", function(Blocks $inst) { + return $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("template", "Will give you a boilerplate test code") + ->addOption("path=", "Specify test path (absolute or relative)") + ->addOption("exclude=", "Exclude files or directories (comma-separated, relative to --path)"); + }); + + $blocks->addSection("Examples", function(Blocks $inst) { + return $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=YourNameHere", + "Run a manually named test case" + )->addExamples( + "php vendor/bin/unitary --template", + "Run a and will give you template code for a new test" + )->addExamples( + 'php vendor/bin/unitary --path="tests/" --exclude="tests/legacy/*,*/extras/*"', + 'Run all tests under "tests/" excluding specified directories' + ); + }); + // Make sure nothing else is executed when help is triggered + exit(0); + } + +} \ No newline at end of file diff --git a/src/Kernel/routes.php b/src/Kernel/routes.php index b204ce1..1aa5fe0 100644 --- a/src/Kernel/routes.php +++ b/src/Kernel/routes.php @@ -1,6 +1,11 @@ map("coverage", [CoverageController::class, "run"]); +$router->map("template", [TemplateController::class, "run"]); $router->map(["", "test", "run"], [RunTestController::class, "run"]); $router->map(["__404", "help"], [RunTestController::class, "help"]); \ No newline at end of file diff --git a/src/TestUtils/CodeCoverage.php b/src/TestUtils/CodeCoverage.php index 681d436..3d05c41 100644 --- a/src/TestUtils/CodeCoverage.php +++ b/src/TestUtils/CodeCoverage.php @@ -28,10 +28,11 @@ class CodeCoverage private int $errorCode = 0; private array $allowedDirs = []; - private array $excludeDirs = [ + private array $exclude = [ "vendor", "tests", "test", + "unitary-*", "unit-tests", "spec", "bin", @@ -51,7 +52,6 @@ class CodeCoverage ".github" ]; - /** * Check if Xdebug is enabled * @@ -88,9 +88,9 @@ public function hasXdebugCoverage(): bool } - public function exclude(string|array $path): void + public function exclude(array $exclude): void { - + $this->exclude = $exclude; } public function whitelist(string|array $path): void @@ -126,7 +126,7 @@ public function start(): void */ public function end(): void { - if($this->data === []) { + if($this->data === null) { throw new BadMethodCallException("You must start code coverage before you can end it"); } if($this->hasXdebugCoverage()) { @@ -136,6 +136,26 @@ public function end(): void } } + protected function excludePattern(string $file): bool + { + $filename = basename($file); + + foreach ($this->exclude as $pattern) { + if (preg_match('#/' . preg_quote($pattern, '#') . '(/|$)#', $file)) { + return true; + } + if (str_ends_with($pattern, '*')) { + $prefix = substr($pattern, 0, -1); + if (str_starts_with($filename, $prefix)) { + return true; + } + } + } + return false; + } + + + /** * Get a Coverage result, will return false if there is an error * @@ -146,7 +166,34 @@ public function getResponse(): array|false if($this->errorCode > 0) { return false; } - return $this->data; + + $totalLines = 0; + $executedLines = 0; + foreach ($this->data as $file => $lines) { + if ($this->excludePattern($file)) { + continue; + } + + foreach ($lines as $line => $status) { + if ($status === -2) continue; + $totalLines++; + if ($status === 1) { + $executedLines++; + } + } + } + + $percent = $totalLines > 0 ? round(($executedLines / $totalLines) * 100, 2) : 0; + return [ + 'totalLines' => $totalLines, + 'executedLines' => $executedLines, + 'percent' => $percent + ]; + } + + public function getRawData(): array + { + return $this->data ?? []; } /** diff --git a/src/TestUtils/Configs.php b/src/TestUtils/Configs.php new file mode 100644 index 0000000..58bd579 --- /dev/null +++ b/src/TestUtils/Configs.php @@ -0,0 +1,35 @@ +command = $command; + } + + public function getCommand(): Command + { + return self::getInstance()->command; + } + +} diff --git a/src/Unit.php b/src/Unit.php index aca6c61..168a1d0 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -15,6 +15,8 @@ use Closure; use ErrorException; use Exception; +use MaplePHP\Unitary\Handlers\CliHandler; +use MaplePHP\Unitary\TestUtils\Configs; use RuntimeException; use Throwable; use MaplePHP\Blunder\BlunderErrorException; @@ -39,8 +41,6 @@ final class Unit public static int $totalPassedTests = 0; public static int $totalTests = 0; - - /** * Initialize Unit test instance with optional handler * @@ -55,7 +55,7 @@ public function __construct(HandlerInterface|StreamInterface|null $handler = nul $this->handler = $handler; $this->command = $this->handler->getCommand(); } else { - $this->command = new Command($handler); + $this->command = ($handler === null) ? Configs::getInstance()->getCommand() : new Command($handler); } self::$current = $this; } @@ -149,7 +149,6 @@ public function confirm(string $message = "Do you wish to continue?"): bool */ public function add(string $message, Closure $callback): void { - //trigger_error('Method ' . __METHOD__ . ' is deprecated', E_USER_DEPRECATED); $this->case($message, $callback); } @@ -219,7 +218,6 @@ public function performance(Closure $func, ?string $title = null): void */ public function execute(): bool { - $this->template(); if ($this->executed || $this->disableAllTests) { return false; } @@ -227,6 +225,10 @@ public function execute(): bool // LOOP through each case ob_start(); //$countCases = count($this->cases); + + $handler = new CliHandler(); + $handler->setCommand($this->command); + foreach ($this->cases as $index => $row) { if (!($row instanceof TestCase)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestCase."); @@ -236,15 +238,6 @@ public function execute(): bool $row->dispatchTest($row); $tests = $row->runDeferredValidations(); $checksum = (string)(self::$headers['checksum'] ?? "") . $index; - $color = ($row->hasFailed() ? "brightRed" : "brightBlue"); - $flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); - if ($row->hasFailed()) { - $flag = $this->command->getAnsi()->style(['redBg', 'brightWhite'], " FAIL "); - } - if ($row->getConfig()->skip) { - $color = "yellow"; - $flag = $this->command->getAnsi()->style(['yellowBg', 'black'], " SKIP "); - } $show = ($row->getConfig()->select === self::getArgs('show') || self::getArgs('show') === $checksum); if((self::getArgs('show') !== false) && !$show) { @@ -256,110 +249,29 @@ public function execute(): bool continue; } - $this->command->message(""); - $this->command->message( - $flag . " " . - $this->command->getAnsi()->style(["bold"], $this->formatFileTitle((string)(self::$headers['file'] ?? ""))) . - " - " . - $this->command->getAnsi()->style(["bold", $color], (string)$row->getMessage()) - ); - if($show && !$row->hasFailed()) { - $this->command->message(""); - $this->command->message( - $this->command->getAnsi()->style(["italic", $color], "Test file: " . (string)self::$headers['file']) - ); - } - - if (($show || !$row->getConfig()->skip)) { - // Show possible warnings - if($row->getWarning()) { - $this->command->message(""); - $this->command->message( - $this->command->getAnsi()->style(["italic", "yellow"], $row->getWarning()) - ); - } - 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", $color], "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 {$trace['file']}:{$trace['line']}")); - $this->command->message($this->command->getAnsi()->style(["grey"], " β†’ {$trace['code']}")); - } - - foreach ($test->getUnits() as $unit) { - - /** @var TestItem $unit */ - if (!$unit->isValid()) { - $lengthA = $test->getValidationLength(); - $validation = $unit->getValidationTitle(); - $title = str_pad($validation, $lengthA); - $compare = $unit->hasComparison() ? $unit->getComparison() : ""; - - $failedMsg = " " .$title . " β†’ failed"; - $this->command->message($this->command->getAnsi()->style($color, $failedMsg)); - - if ($compare) { - $lengthB = (strlen($compare) + strlen($failedMsg) - 8); - $comparePad = str_pad($compare, $lengthB, " ", STR_PAD_LEFT); - $this->command->message( - $this->command->getAnsi()->style($color, $comparePad) - ); - } - } - } - if ($test->hasValue()) { - $this->command->message(""); - $this->command->message( - $this->command->getAnsi()->bold("Input value: ") . - Helpers::stringifyDataTypes($test->getValue()) - ); - } - } - } - } + $handler->setCase($row); + $handler->setSuitName(self::$headers['file'] ?? ""); + $handler->setChecksum($checksum); + $handler->setTests($tests); + $handler->setShow($show); + $handler->buildBody(); // Important to add test from skip as successfully count to make sure that // the total passed tests are correct, and it will not exit with code 1 self::$totalPassedTests += ($row->getConfig()->skip) ? $row->getTotal() : $row->getCount(); self::$totalTests += $row->getTotal(); - if ($row->getConfig()->select) { - $checksum .= " (" . $row->getConfig()->select . ")"; - } - $this->command->message(""); - - $passed = $this->command->getAnsi()->bold("Passed: "); - if ($row->getHasAssertError()) { - $passed .= $this->command->getAnsi()->style(["grey"], "N/A"); - } else { - $passed .= $this->command->getAnsi()->style([$color], $row->getCount() . "/" . $row->getTotal()); - } - - $footer = $passed . - $this->command->getAnsi()->style(["italic", "grey"], " - ". $checksum); - if (!$show && $row->getConfig()->skip) { - $footer = $this->command->getAnsi()->style(["italic", "grey"], $checksum); - } - $this->command->message($footer); - $this->command->message(""); } $this->output .= (string)ob_get_clean(); - + $handler->outputBuffer($this->output); if ($this->output) { - $this->buildNotice("Note:", $this->output, 80); + $handler->buildNotes(); + } + $stream = $handler->returnStream(); + if ($stream->isSeekable()) { + $this->getStream()->rewind(); + echo $this->getStream()->getContents(); } - $this->handler?->execute(); + $this->executed = true; return true; } @@ -393,24 +305,7 @@ public function validate(): self "Move this validate() call inside your group() callback function."); } - /** - * Build the notification stream - * @param string $title - * @param string $output - * @param int $lineLength - * @return void - */ - public function buildNotice(string $title, string $output, int $lineLength): void - { - $this->output = wordwrap($output, $lineLength); - $line = $this->command->getAnsi()->line($lineLength); - $this->command->message(""); - $this->command->message($this->command->getAnsi()->style(["bold"], $title)); - $this->command->message($line); - $this->command->message($this->output); - $this->command->message($line); - } /** * Make a file path into a title @@ -530,36 +425,6 @@ public static function isSuccessful(): bool return (self::$totalPassedTests === self::$totalTests); } - /** - * Display a template for the Unitary testing tool - * Shows a basic template for the Unitary testing tool - * Only displays if --template argument is provided - * - * @return void - */ - private function template(): void - { - if (self::getArgs("template") !== false) { - - $blocks = new Blocks($this->command); - $blocks->addHeadline("\n--- Unitary template ---"); - $blocks->addCode( - <<<'PHP' - use MaplePHP\Unitary\{Unit, TestCase, TestConfig, Expect}; - - $unit = new Unit(); - $unit->group("Your test subject", function (TestCase $case) { - - $case->validate("Your test value", function(Expect $valid) { - $valid->isString(); - }); - - }); - PHP - ); - exit(0); - } - } /** * Adds a test case to the collection. diff --git a/src/Utils/Dispatcher.php b/src/Utils/Dispatcher.php deleted file mode 100644 index ebf1da0..0000000 --- a/src/Utils/Dispatcher.php +++ /dev/null @@ -1,28 +0,0 @@ -$method($args); - } - -} \ No newline at end of file diff --git a/src/Utils/FileIterator.php b/src/Utils/FileIterator.php index c95fb19..31e1805 100755 --- a/src/Utils/FileIterator.php +++ b/src/Utils/FileIterator.php @@ -16,6 +16,7 @@ use MaplePHP\Blunder\Exceptions\BlunderSoftException; use MaplePHP\Blunder\Handlers\CliHandler; use MaplePHP\Blunder\Run; +use MaplePHP\Prompts\Command; use MaplePHP\Unitary\Unit; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; @@ -27,6 +28,8 @@ final class FileIterator public const PATTERN = 'unitary-*.php'; private array $args; + private bool $exitScript = true; + private ?Command $command = null; public function __construct(array $args = []) { @@ -72,10 +75,35 @@ public function executeAll(string $path, string|bool $rootDir = false): void } } Unit::completed(); - exit((int)!Unit::isSuccessful()); + if ($this->exitScript) { + $this->exitScript(); + } + } } + + /** + * You can change the default exist script from enabled to disabled + * + * @param $exitScript + * @return void + */ + public function enableExitScript($exitScript): void + { + $this->exitScript = $exitScript; + } + + /** + * Exist the script with right expected number + * + * @return void + */ + public function exitScript(): void + { + exit((int)!Unit::isSuccessful()); + } + /** * Will Scan and find all unitary test files * @param string $path @@ -178,28 +206,17 @@ private function requireUnitFile(string $file): ?Closure $clone = clone $this; $call = function () use ($file, $clone): void { $cli = new CliHandler(); - if (Unit::getArgs('trace') !== false) { $cli->enableTraceLines(true); } $run = new Run($cli); $run->setExitCode(1); $run->load(); - - //ob_start(); if (!is_file($file)) { throw new RuntimeException("File \"$file\" do not exists."); } require_once($file); - $clone->getUnit()->execute(); - - /* - $outputBuffer = ob_get_clean(); - if (strlen($outputBuffer) && Unit::hasUnit()) { - $clone->getUnit()->buildNotice("Note:", $outputBuffer, 80); - } - */ }; return $call->bindTo(null); }