diff --git a/.docker/app_test/Dockerfile b/.docker/app_test/Dockerfile new file mode 100644 index 0000000..134b5fb --- /dev/null +++ b/.docker/app_test/Dockerfile @@ -0,0 +1,17 @@ +FROM php:7.4-cli + +RUN apt-get update && apt-get install -y git unzip + +ENV COMPOSER_ALLOW_SUPERUSER 1 +ENV COMPOSER_MEMORY_LIMIT -1 + +RUN mkdir /.composer_cache +ENV COMPOSER_CACHE_DIR /.composer_cache + +RUN mkdir /packages +COPY packages /packages/ +WORKDIR /packages/Saga + +RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer +RUN composer -vvv global require hirak/prestissimo +RUN composer install diff --git a/.docker/php7.4-dev/Dockerfile b/.docker/php7.4-dev/Dockerfile new file mode 100644 index 0000000..72c38a7 --- /dev/null +++ b/.docker/php7.4-dev/Dockerfile @@ -0,0 +1,17 @@ +FROM php:7.4-cli + +RUN apt-get update && apt-get install -y git unzip + +ENV COMPOSER_ALLOW_SUPERUSER 1 +ENV COMPOSER_MEMORY_LIMIT -1 + +RUN mkdir /.composer_cache +ENV COMPOSER_CACHE_DIR /.composer_cache + +RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer + +RUN composer -vvv global require hirak/prestissimo + +# php extensions +RUN pecl install xdebug +RUN docker-php-ext-enable xdebug diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f5939b2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,25 @@ +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 + +# 4 space indentation +[*.php] +indent_style = space +indent_size = 4 + +# Tab indentation (no size specified) +[Makefile] +indent_style = tab + +# Matches the exact files either package.json or .travis.yml +[{*.yml, *.yaml}] +indent_style = space +indent_size = 2 + +[composer.json] +indent_size = 4 diff --git a/.env b/.env new file mode 100644 index 0000000..481280d --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +###> common variables ### +GENERATOR_COMPOSE_PROJECT_NAME=unit-test-generator +CI_COMMIT_REF_SLUG=master +###< common variables ### diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..372f8fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +###> symfony/framework-bundle ### +.env.local +.env.local.php +.env.*.local +.env.dist + +var/ +vendor/ +###< symfony/framework-bundle ### + +###> PhpStorm project profile ### +.idea/ +###< PhpStorm project profile ### + +###> phpunit/phpunit ### +phpunit.xml +.phpunit.result.cache +###< phpunit/phpunit ### + +###> friendsofphp/php-cs-fixer ### +.php_cs.cache +###< friendsofphp/php-cs-fixer ### + +###> squizlabs/php_codesniffer ### +.phpcs-cache +phpcs.xml +###< squizlabs/php_codesniffer ### + +###> sensiolabs-de/deptrac ### +.deptrac.cache +###< sensiolabs-de/deptrac ### + +# Build data +build/ + +###> Phpunit ### +bin/.phpunit +###< Phpunit ### diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..94d87df --- /dev/null +++ b/Makefile @@ -0,0 +1,79 @@ +version = $(shell git describe --tags --dirty --always) +build_name = application-$(version) +# use the rest as arguments for "run" +RUN_ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) +# ...and turn them into do-nothing targets +#$(eval $(RUN_ARGS):;@:) + +.PHONY: fix-permission +fix-permission: ## fix permission for docker env + sudo chown -R $(shell whoami):$(shell whoami) * + sudo chown -R $(shell whoami):$(shell whoami) .docker/* + +.PHONY: build +build: ## build environment and initialize composer and project dependencies + docker-compose build + make composer-install + +.PHONY: composer-install +composer-install: ## Install project dependencies + docker-compose run --rm --no-deps php sh -lc 'composer install' + +.PHONY: composer-update +composer-update: ## Update project dependencies + docker-compose run --rm --no-deps php sh -lc 'composer update' + +.PHONY: composer-outdated +composer-outdated: ## Show outdated project dependencies + docker-compose run --rm --no-deps php sh -lc 'composer outdated' + +.PHONY: composer-validate +composer-validate: ## Validate composer config + docker-compose run --rm --no-deps php sh -lc 'composer validate --no-check-publish' + +.PHONY: composer +composer: ## Execute composer command + docker-compose run --rm --no-deps php sh -lc "composer $(RUN_ARGS)" + +.PHONY: phpunit +phpunit: ## execute project unit tests + docker-compose run --rm php sh -lc "./vendor/bin/phpunit $(conf)" + +.PHONY: style +style: ## executes php analizers + docker-compose run --rm --no-deps php sh -lc './vendor/bin/phpstan analyse -l 6 -c phpstan.neon src' + docker-compose run --rm --no-deps php sh -lc './vendor/bin/psalm --config=psalm.xml' + +.PHONY: lint +lint: ## checks syntax of PHP files + docker-compose run --rm --no-deps php sh -lc './vendor/bin/parallel-lint ./ --exclude vendor --exclude bin/.phpunit' + +.PHONY: logs +logs: ## look for service logs + docker-compose logs -f $(RUN_ARGS) + +.PHONY: help +help: ## Display this help message + @cat $(MAKEFILE_LIST) | grep -e "^[a-zA-Z_\-]*: *.*## *" | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +.PHONY: php-shell +php-shell: ## PHP shell + docker-compose run --rm php sh -l + +unit-tests: ## Run unit-tests suite + docker-compose run --rm php sh -lc 'vendor/bin/phpunit --testsuite unit-tests' + +static-analysis: style coding-standards ## Run phpstan, easycoding standarts code static analysis + +coding-standards: ## Run check and validate code standards tests + docker-compose run --rm --no-deps php sh -lc 'vendor/bin/ecs check src' + docker-compose run --rm --no-deps php sh -lc 'vendor/bin/phpmd src/ text phpmd.xml' + +coding-standards-fixer: ## Run code standards fixer + docker-compose run --rm --no-deps php sh -lc 'vendor/bin/ecs check src --fix' + +security-tests: ## The SensioLabs Security Checker + docker-compose run --rm --no-deps php sh -lc 'vendor/bin/security-checker security:check --end-point=http://security.sensiolabs.org/check_lock' + +.PHONY: test lint static-analysis phpunit coding-standards composer-validate +test: build lint static-analysis phpunit coding-standards composer-validate stop ## Run all test suites diff --git a/README.md b/README.md new file mode 100644 index 0000000..0a7fc52 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +Unit test generator +===================== + +Proof-of-concept component providing unit test generator. + +## Documentation +This project can generate or single unit for one class or for all project with all needed mocks. + +## Getting started +* For example if you want generate unit test for one class use following code: + + `$testGenerator = new MicroModule\UnitTestGenerator\Service\TestClass();` + `$testGenerator->generate(FooService::class);` + +* For generate tests and mocks for all project use: + + `$testGenerator = new MicroModule\UnitTestGenerator\Service\TestProject(realpath('src'), ['Migrations', 'Presentation', 'Exception']);` + `$testGenerator->generate();` + + second argument is array of excluded folders + +## License +This project is licensed under the MIT License - see the LICENSE file for details diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..4cb3411 --- /dev/null +++ b/composer.json @@ -0,0 +1,29 @@ +{ + "name": "micro-module/unit-test-generator", + "description": "POC for unit test generator", + "type": "library", + "license": "proprietary", + "require": { + "php": "^7.4", + "ext-json": "*", + "fzaninotto/faker": "^1.9" + }, + "require-dev": { + "jakub-onderka/php-console-highlighter": "^0.4", + "jakub-onderka/php-parallel-lint": "^1.0", + "mockery/mockery": "^1.2", + "phpmd/phpmd": "^2.6", + "phpstan/phpstan": "^0.11", + "phpstan/phpstan-mockery": "^0.11", + "phpstan/phpstan-phpunit": "^0.11", + "phpunit/phpunit": "^8.0", + "roave/security-advisories": "dev-master", + "symplify/easy-coding-standard": "^5.4", + "vimeo/psalm": "^3.0" + }, + "autoload": { + "psr-4": { + "MicroModule\\UnitTestGenerator\\": "src/" + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2330f58 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +version: "3.7" + +services: + php: + container_name: ${GENERATOR_COMPOSE_PROJECT_NAME}_php + user: 1000:1000 + build: + context: .docker/php7.4-dev + volumes: + - ~/.composer/cache/:/.composer_cache/:rw + - ../:/packages:rw + working_dir: /packages/UnitTestGenerator diff --git a/ecs.yml b/ecs.yml new file mode 100644 index 0000000..6b7a996 --- /dev/null +++ b/ecs.yml @@ -0,0 +1,5 @@ +imports: + - { resource: 'vendor/symplify/easy-coding-standard/config/set/clean-code.yaml' } + - { resource: 'vendor/symplify/easy-coding-standard/config/set/symfony.yaml' } + - { resource: 'vendor/symplify/easy-coding-standard/config/set/php71.yaml' } + - { resource: 'vendor/symplify/easy-coding-standard/config/set/psr12.yaml' } diff --git a/phpmd.xml b/phpmd.xml new file mode 100644 index 0000000..700df16 --- /dev/null +++ b/phpmd.xml @@ -0,0 +1,28 @@ + + + Ruleset for PHP Mess Detector that enforces coding standards + + + + + + + + + + + + + + + + + + + + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..2b198c5 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,3 @@ +includes: + - vendor/phpstan/phpstan-mockery/extension.neon + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..1c226eb --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + tests/unit + + + + + + + diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..13b78a0 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Generator/AbstractGenerator.php b/src/Generator/AbstractGenerator.php new file mode 100644 index 0000000..5dc63b2 --- /dev/null +++ b/src/Generator/AbstractGenerator.php @@ -0,0 +1,180 @@ +outClassName['fullyQualifiedClassName']; + } + + /** + * Return source class path. + * + * @return string + */ + public function getOutSourceFile(): string + { + return $this->outSourceFile; + } + + /** + * Generates the code and writes it to a source file. + * + * @param string $file + * + * @return bool + */ + public function write(string $file = ''): bool + { + if ('' === $file) { + $file = $this->outSourceFile; + } + + if (file_exists($file)) { + echo "UnitTest '" . $file . "' already exists." . PHP_EOL; + + return false; + } + $testCode = $this->generate(); + + if (null === $testCode) { + echo "UnitTest was not created for file '" . $file . "'." . PHP_EOL; + + return false; + } + + if (file_put_contents($file, $testCode)) { + echo "UnitTest '" . $file . "' created." . PHP_EOL; + + return true; + } + echo "UnitTest was not created for file '" . $file . "'." . PHP_EOL; + + return false; + } + + /** + * Parse class name and return namespace, full and sort class name, test base class name. + * + * @param string $className + * + * @return string[] + */ + protected function parseFullyQualifiedClassName(string $className): array + { + if (0 !== strpos($className, '\\')) { + $className = '\\' . $className . self::TEST_NAME_PREFIX; + } + $result = [ + 'namespace' => '', + 'testBaseFullClassName' => '', + 'className' => $className, + 'fullyQualifiedClassName' => $className, + ]; + + if (false !== strpos($className, '\\')) { + $tmp = explode('\\', $className); + $result['className'] = $tmp[count($tmp) - 1]; + $result['namespace'] = $this->arrayToName($tmp); + + $testBaseFullClassName = $this->baseNamespace ? $this->baseNamespace . '\\' . $tmp[2] . '\Base' : $tmp[1] . '\Base'; + $result['testBaseFullClassName'] = $testBaseFullClassName; + } + + return $result; + } + + /** + * Build class name from exploded parts. + * + * @param string[] $parts + * + * @return string + */ + protected function arrayToName(array $parts): string + { + $result = ''; + + if (count($parts) > 1) { + array_pop($parts); + $result = implode('\\', $parts); + } + + return $result; + } + + /** + * Generate test code. + * + * @return string|null + */ + abstract public function generate(): ?string; +} diff --git a/src/Generator/BaseTestGenerator.php b/src/Generator/BaseTestGenerator.php new file mode 100644 index 0000000..4da1e74 --- /dev/null +++ b/src/Generator/BaseTestGenerator.php @@ -0,0 +1,66 @@ +baseNamespace = $baseNamespace; + $this->outClassName = $this->parseFullyQualifiedClassName($outClassName); + $this->outSourceFile = str_replace( + $this->outClassName['fullyQualifiedClassName'], + $this->outClassName['className'], + $outSourceFile + ); + } + + /** + * Generate base test class. + * + * @return string|null + * + * @throws Exception + */ + public function generate(): ?string + { + $classTemplate = new Template( + sprintf( + '%s%stemplate%sTestBaseClass.tpl', + __DIR__, + DIRECTORY_SEPARATOR, + DIRECTORY_SEPARATOR + ) + ); + + $classTemplate->setVar( + [ + 'namespace' => trim($this->outClassName['namespace'], '\\'), + 'testClassName' => $this->outClassName['className'], + 'date' => date('Y-m-d'), + 'time' => date('H:i:s'), + ] + ); + + return $classTemplate->render(); + } +} diff --git a/src/Generator/DataProviderGenerator.php b/src/Generator/DataProviderGenerator.php new file mode 100644 index 0000000..1e9d155 --- /dev/null +++ b/src/Generator/DataProviderGenerator.php @@ -0,0 +1,972 @@ + 'boolean', + DataTypeInterface::TYPE_INT => 'randomDigitNotNull', + DataTypeInterface::TYPE_FLOAT => 'randomFloat', + DataTypeInterface::TYPE_STRING => 'word', + DataTypeInterface::TYPE_MIXED => 'word', + DataTypeInterface::TYPE_ARRAY => 'words', + DataTypeInterface::TYPE_ARRAY_MIXED => 'words', + ]; + + private const FAKE_PROVIDERS = [ + 'Lorem' => [ + 'text' + ], + 'Person' => [ + 'titleMale', // 'Mr.' + 'titleFemale', // 'Ms.' + 'name' => ['user', 'member'], // 'Dr. Zane Stroman' + 'firstName', // 'Maynard' + 'firstNameMale', // 'Maynard' + 'firstNameFemale', // 'Rachel' + 'lastName', // 'Zulauf' + ], + 'Address' => [ + 'address', + 'cityPrefix', // 'Lake' + 'secondaryAddress', // 'Suite 961' + 'state', // 'NewMexico' + 'stateAbbr', // 'OH' + 'citySuffix', // 'borough' + 'streetSuffix', // 'Keys' + 'buildingNumber', // '484' + 'city', // 'West Judge' + 'streetName', // 'Keegan Trail' + 'streetAddress', // '439 Karley Loaf Suite 897' + 'postcode', // '17916' + 'address', // '8888 Cummings Vista Apt. 101, Susanbury, NY 95473' + 'country', // 'Falkland Islands (Malvinas)' + 'latitude', // 77.147489 + 'longitude', // 86.211205 + ], + 'Phone' => [ + 'phoneNumber' => ['phone'], // '201-886-0269 x3767' + 'tollFreePhoneNumber', // '(888) 937-7238' + 'e164PhoneNumber', // '+27113456789' + ], + 'Company' => [ + 'catchPhrase', // 'Monitored regional contingency' + 'company', // 'Bogan-Treutel' + 'companySuffix', // 'and Sons' + 'jobTitle', // 'Cashier' + ], + 'Text' => [ + 'realText' => ['content', 'text', 'article', 'description'] + ], + 'DateTime' => [ + 'unixTime', // 58781813 + 'dateTime', // DateTime('2008-04-25 08:37:17', 'UTC') + 'dateTimeAD', // DateTime('1800-04-29 20:38:49', 'Europe/Paris') + 'iso8601', // '1978-12-09T10:10:29+0000' + 'date', // '1979-06-09' + 'time', // '20:49:42' + 'dateTimeBetween', // DateTime('2003-03-15 02:00:49', 'Africa/Lagos') + 'dateTimeInInterval', // DateTime('2003-03-15 02:00:49', 'Antartica/Vostok') + 'dateTimeThisCentury', // DateTime('1915-05-30 19:28:21', 'UTC') + 'dateTimeThisDecade', // DateTime('2007-05-29 22:30:48', 'Europe/Paris') + 'dateTimeThisYear', // DateTime('2011-02-27 20:52:14', 'Africa/Lagos') + 'dateTimeThisMonth', // DateTime('2011-10-23 13:46:23', 'Antarctica/Vostok') + 'dayOfMonth', // '04' + 'dayOfWeek', // 'Friday' + 'month', // '06' + 'monthName', // 'January' + 'year', // '1993' + 'century', // 'VI' + 'timezone', // 'Europe/Paris' + ], + 'Internet' => [ + 'email', // 'tkshlerin@collins.com' + 'safeEmail', // 'king.alford@example.org' + 'freeEmail', // 'bradley72@gmail.com' + 'companyEmail', // 'russel.durward@mcdermott.org' + 'freeEmailDomain', // 'yahoo.com' + 'safeEmailDomain', // 'example.org' + 'userName', // 'wade55' + 'password', // 'k&|X+a45*2[' + 'domainName', // 'wolffdeckow.net' + 'domainWord', // 'feeney' + 'tld', // 'biz' + 'url', // 'http://www.skilesdonnelly.biz/aut-accusantium-ut-architecto-sit-et.html' + 'slug', // 'aut-repellat-commodi-vel-itaque-nihil-id-saepe-nostrum' + 'ipv4', // '109.133.32.252' + 'localIpv4', // '10.242.58.8' + 'ipv6', // '8e65:933d:22ee:a232:f1c1:2741:1f10:117c' + 'macAddress', // '43:85:B7:08:10:CA' + ], + 'UserAgent' => [ + 'userAgent', // 'Mozilla/5.0 (Windows CE) AppleWebKit/5350 (KHTML, like Gecko) Chrome/13.0.888.0 Safari/5350' + 'chrome', // 'Mozilla/5.0 (Macintosh; PPC Mac OS X 10_6_5) AppleWebKit/5312 (KHTML, like Gecko) Chrome/14.0.894.0 Safari/5312' + 'firefox', // 'Mozilla/5.0 (X11; Linuxi686; rv:7.0) Gecko/20101231 Firefox/3.6' + 'safari', // 'Mozilla/5.0 (Macintosh; U; PPC Mac OS X 10_7_1 rv:3.0; en-US) AppleWebKit/534.11.3 (KHTML, like Gecko) Version/4.0 Safari/534.11.3' + 'opera', // 'Opera/8.25 (Windows NT 5.1; en-US) Presto/2.9.188 Version/10.00' + 'internetExplorer', // 'Mozilla/5.0 (compatible; MSIE 7.0; Windows 98; Win 9x 4.90; Trident/3.0)' + ], + 'Payment' => [ + 'creditCardType', // 'MasterCard' + 'creditCardNumber' => ['creditCard', ], // '4485480221084675' + 'creditCardExpirationDate', // 04/13 + 'creditCardExpirationDateString', // '04/13' + 'creditCardDetails', // array('MasterCard', '4485480221084675', 'Aleksander Nowak', '04/13') + 'iban', // 'IT31A8497112740YZ575DJ28BP4' + 'swiftBicNumber', // 'RZTIAT22263' + ], + 'Color' => [ + 'color' + ], +// 'File' => [ +// 'file' +// ], + 'Image' => [ + 'imageUrl', + 'image' => ['img', 'image', 'jpg'], + ], + 'Uuid' => [ + 'uuid' + ], + 'Barcode' => [ + 'barcode' + ], + 'Miscellaneous' => [ + 'boolean', // false + 'md5', // 'de99a620c50f2990e87144735cd357e7' + 'sha1', // 'f08e7f04ca1a413807ebc47551a40a20a0b4de5c' + 'sha256', // '0061e4c60dac5c1d82db0135a42e00c89ae3a333e7c26485321f24348c7e98a5' + 'locale', // en_UK + 'countryCode', // UK + 'languageCode', // en + 'currencyCode', // EUR + 'emoji', // 😁 + ], + 'Html' => [ + 'randomHtml' => ['html'] + ], + ]; + + /** + * Data providers code. + * + * @var mixed[] + */ + protected $dataProviders = []; + + /** + * Path to save dataProvider helper. + * + * @var string + */ + private $dataProviderTestPath; + + /** + * DataProvider namespace. + * + * @var string + */ + private $dataProviderNamespace; + + /** + * Project namespace. + * + * @var string + */ + private $projectNamespace; + + /** + * Fake data generator. + * + * @var Generator + */ + private $faker; + + /** + * Is throw exception if return type was not found. + * + * @var bool + */ + private $returnTypeNotFoundThrowable = false; + + /** + * Count of test data sets. + * + * @var int + */ + private $countDataSets = 1; + + /** + * Deep level to build data for mocked object. + * + * @var int + */ + private $mockDeepLevel = 4; + + /** + * TestGenerator constructor. + * + * @param string $dataProviderTestPath + * @param string $dataProviderNamespace + * @param string $baseNamespace + * @param string $projectNamespace + */ + public function __construct( + string $dataProviderTestPath, + string $dataProviderNamespace, + string $baseNamespace, + string $projectNamespace + ) { + $this->dataProviderTestPath = $dataProviderTestPath; + $this->dataProviderNamespace = $dataProviderNamespace; + $this->baseNamespace = $baseNamespace; + $this->projectNamespace = $projectNamespace; + $this->faker = Factory::create(); + } + + /** + * Build and return dataProvider file path. + * + * @param string $className + * + * @return string[] + */ + private function getDataProviderPathAndNamespace(string $className): array + { + $pos = strpos($className, $this->projectNamespace); + + if (false === $pos) { + $dataProviderClassName = ucfirst(str_replace('\\', '', $className)) . self::DATA_PROVIDER_SUFFIX; + $dataProviderFilePath = $this->dataProviderTestPath . DIRECTORY_SEPARATOR . 'Common' . DIRECTORY_SEPARATOR . $dataProviderClassName . '.php'; + $dataProviderFullClassName = '\\' .$this->dataProviderNamespace . '\\' . 'Common' . '\\' . $dataProviderClassName; + $dataProviderName = ucfirst(str_replace('\\', '', $dataProviderClassName)); + $pos = strrpos($dataProviderFullClassName, '\\'); + + if (false === $pos) { + $pos = strlen($dataProviderFullClassName); + } + $dataProviderNamespace = substr($dataProviderFullClassName, 0, $pos); + + return [$dataProviderFilePath, $dataProviderNamespace, $dataProviderClassName, $dataProviderFullClassName, $dataProviderName]; + } + $tmpNamespace = substr($className, strlen($this->projectNamespace) + 1); + $tmpNamespaces = explode('\\', $tmpNamespace); + $tmpNamespace = implode('\\', array_slice($tmpNamespaces, 0, count($tmpNamespaces) - 1)); + $tmpClassName = implode('\\', array_slice($tmpNamespaces, count($tmpNamespaces) - 1, 1)); + $dataProviderFilePath = $this->dataProviderTestPath . DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, $tmpNamespace) . DIRECTORY_SEPARATOR . $tmpClassName . self::DATA_PROVIDER_SUFFIX . '.php'; + $dataProviderClassName = $tmpClassName . self::DATA_PROVIDER_SUFFIX; + $dataProviderName = ucfirst(str_replace('\\', '', $dataProviderClassName)); + $dataProviderFullClassName = '\\' .$this->dataProviderNamespace . '\\' . $tmpNamespace . '\\' . $dataProviderClassName; + $pos = strrpos($dataProviderFullClassName, '\\'); + + if (false === $pos) { + $pos = strlen($dataProviderFullClassName); + } + $dataProviderNamespace = substr($dataProviderFullClassName, 0, $pos); + + return [$dataProviderFilePath, $dataProviderNamespace, $dataProviderClassName, $dataProviderFullClassName, $dataProviderName]; + } + + /** + * Generate and return DataProvider name + * + * @param string $className + * + * @return string + */ + public function getDataProviderName(string $className): string + { + $pos = strpos($className, $this->projectNamespace); + + if (false === $pos) { + $dataProviderClassName = ucfirst(str_replace('\\', '', $className)) . self::DATA_PROVIDER_SUFFIX; + + return ucfirst(str_replace('\\', '', $dataProviderClassName)); + } + $tmpNamespace = substr($className, strlen($this->projectNamespace) + 1); + $tmpNamespaces = explode('\\', $tmpNamespace); + $tmpClassName = implode('\\', array_slice($tmpNamespaces, count($tmpNamespaces) - 1, 1)); + $dataProviderClassName = $tmpClassName . self::DATA_PROVIDER_SUFFIX; + + return ucfirst(str_replace('\\', '', $dataProviderClassName)); + } + + + /** + * Generate and return DataProvider method name + * + * @param ReflectionMethod $method + * @param string $dataProviderClassName + * + * @return string + */ + public function getDataProviderMethodName(ReflectionMethod $method, string $dataProviderClassName): string + { + if ($method->isConstructor()) { + return self::DATA_PROVIDER_DEFAULT_ARGS; + } + + return 'getDataFor' . ucfirst($method->getName()) . 'Method'; + } + + /** + * Generate and save test dataProviders. + * + * @return string|null + * + * @throws Exception + */ + public function generate(): ?string + { + $dataProviderFiles = []; + $dataProviders = []; + $dataProviderFileInitialized = []; + + foreach ($this->dataProviders as $dataProviderName => $dataProvider) { + $testClassName = $dataProvider['testClassName']; + $dataProviderFilepath = $dataProvider['dataProviderFilepath']; + $dataProviderNamespace = $dataProvider['dataProviderNamespace']; + $dataProviderClassName = $dataProvider['dataProviderClassName']; + $dataProviderFullClassName = $dataProvider['dataProviderFullClassName']; + $dataProviderName = $dataProvider['dataProviderName']; + + if (!isset($dataProviders[$dataProviderFilepath])) { + $dataProviders[$dataProviderFilepath] = [ + 'testClassName' => $testClassName, + 'className' => $dataProviderClassName, + 'fullClassName' => $dataProviderFullClassName, + 'namespace' => $dataProviderNamespace, + 'methods' => [], + ]; + } + $defaultArgs = null; + + if (isset($dataProvider['dataProviderMethods'][self::DATA_PROVIDER_DEFAULT_ARGS])) { + $defaultArgs = $dataProvider['dataProviderMethods'][self::DATA_PROVIDER_DEFAULT_ARGS]; + unset($dataProvider['dataProviderMethods'][self::DATA_PROVIDER_DEFAULT_ARGS]); + } + + foreach ($dataProvider['dataProviderMethods'] as $dataProviderMethodName => $dataProviderArgs) { + if (null !== $defaultArgs) { + if ($dataProviderArgs) { + for ($i = 0; $i < $this->countDataSets; ++$i) { + $dataProviderArgs[$i][0] = array_merge($defaultArgs[$i][0], $dataProviderArgs[$i][0]); + $dataProviderArgs[$i][1] = array_merge($defaultArgs[$i][1], $dataProviderArgs[$i][1]); + } + } else { + $dataProviderArgs = $defaultArgs; + } + } + + if (!isset($dataProviderFileInitialized[$dataProviderFilepath]) && file_exists($dataProviderFilepath)) { + $methods = $this->parseMethodsFromSource($dataProviderFilepath); + + foreach ($methods as $method) { + $dataProviderFiles[$dataProviderFilepath][] = $method; + } + $dataProviderFileInitialized[$dataProviderFilepath] = true; + } + + if (!isset($dataProviderFiles[$dataProviderFilepath])) { + $dataProviderFiles[$dataProviderFilepath] = []; + } + + if (in_array($dataProviderMethodName, $dataProviderFiles[$dataProviderFilepath], true)) { + continue; + } + $dataProviderTemplate = new Template( + sprintf( + '%s%stemplate%sDataProviderMethod.tpl', + __DIR__, + DIRECTORY_SEPARATOR, + DIRECTORY_SEPARATOR + ) + ); + $dataProviderTemplate->setVar([ + 'testClassName' => $testClassName, + 'dataProviderName' => $dataProviderName, + 'dataProviderClassName' => $dataProviderClassName, + 'dataProviderMethodName' => $dataProviderMethodName, + 'dataProviderArgs' => str_replace("\n", "\n\t\t\t", $this->varExport($dataProviderArgs)) + ]); + $dataProviders[$dataProviderFilepath]['methods'][] = $dataProviderTemplate->render(); + } + } + + foreach ($dataProviders as $dataProviderFilepath => $dataProvider) { + $dataProviderCode = implode('', $dataProvider['methods']); + + if (!file_exists($dataProviderFilepath)) { + $dataProviderCode = $this->generateNewDataProvider($dataProvider['namespace'], $dataProvider['className'], $dataProviderCode); + } + $this->saveFile($dataProviderFilepath, $dataProviderCode); + } + + return null; + } + + /** + * Generate new dataProvider. + * + * @param string $namespace + * @param string $className + * @param string $methods + * + * @return string + * + * @throws Exception + */ + private function generateNewDataProvider(string $namespace, string $className, string $methods): string + { + $classTemplate = new Template( + sprintf( + '%s%stemplate%sDataProvider.tpl', + __DIR__, + DIRECTORY_SEPARATOR, + DIRECTORY_SEPARATOR + ) + ); + $classTemplate->setVar([ + 'namespace' => trim($namespace, '\\'), + 'className' => $className, + 'methods' => $methods, + 'date' => date('Y-m-d'), + 'time' => date('H:i:s'), + ]); + + return $classTemplate->render(); + } + + /** + * Save code to file, if file exist append code, if no, check if folder exist and create if needed. + * + * @param string $filepath + * @param string $code + * + * @throws CodeExtractException + */ + private function saveFile(string $filepath, string $code): void + { + $pathInfo = pathinfo($filepath); + + if (!file_exists($pathInfo['dirname'])) { + if (!mkdir($concurrentDirectory = $pathInfo['dirname'], 0755, true) && !is_dir($concurrentDirectory)) { + throw new RuntimeException(sprintf('Directory "%s" was not created', $concurrentDirectory)); + } + } + + if (file_exists($filepath)) { + $existCode = file_get_contents($filepath); + + if (false === $existCode) { + throw new CodeExtractException(sprintf('Code can not be extract from source \'%s\'.', $filepath)); + } + $pos = strrpos($existCode, "\n}"); + + if (false === $pos) { + $pos = strlen($existCode); + } + $existCode = substr($existCode, 0, $pos); + $code = $existCode . $code . "\n}"; + } + + file_put_contents($filepath, $code); + } + + /** + * Add new DataProvider + * + * @param ReflectionClass $reflectionClass + * @param ReflectionMethod $reflectionMethod + * + * @return string + * + * @throws ReflectionException + * @throws ReturnTypeNotFoundException + */ + public function addDataProviderMethod(ReflectionClass $reflectionClass, ReflectionMethod $reflectionMethod): string + { + $className = $reflectionClass->getName(); + [ + $dataProviderFilepath, + $dataProviderNamespace, + $dataProviderClassName, + $dataProviderFullClassName, + $dataProviderName + ] = $this->getDataProviderPathAndNamespace($className); + $dataProviderMethodName = $this->getDataProviderMethodName($reflectionMethod, $dataProviderClassName); + + if (!isset($this->dataProviders[$dataProviderName])) { + $this->dataProviders[$dataProviderName] = [ + 'testClassName' => $className, + 'dataProviderFilepath' => $dataProviderFilepath, + 'dataProviderNamespace' => $dataProviderNamespace, + 'dataProviderClassName' => $dataProviderClassName, + 'dataProviderFullClassName' => $dataProviderFullClassName, + 'dataProviderName' => $dataProviderName, + 'dataProviderMethods' => [], + ]; + } + + if (!isset($this->dataProviders[$dataProviderName]['dataProviderMethods'][$dataProviderMethodName])) { + $this->dataProviders[$dataProviderName]['dataProviderMethods'][$dataProviderMethodName] = []; + + if (!$reflectionMethod->isConstructor()) { + for ($i = 0; $i < $this->countDataSets; ++$i) { + $fakeData = $this->getFakeDataForMethodReturnType($reflectionMethod); + + if (null === $fakeData) { + break; + } + [$methodName, $fakeValue, $fakeTimes] = $fakeData; + $this->dataProviders[$dataProviderName]['dataProviderMethods'][$dataProviderMethodName][$i][0][$methodName] = $fakeValue; + $this->dataProviders[$dataProviderName]['dataProviderMethods'][$dataProviderMethodName][$i][1][$methodName] = $fakeTimes; + } + } + } + + return $dataProviderMethodName; + } + + /** + * Generate and return fake data by method return type. + * + * @param ReflectionMethod $method + * + * @return mixed[]|null + * + * @throws ReflectionException + * @throws ReturnTypeNotFoundException + */ + private function getFakeDataForMethodReturnType(ReflectionMethod $method): ?array + { + $typeIsArray = false; + $type = $this->getReturnFromAnnotation($method, false, true); + + if (null === $type) { + $type = $this->getMethodReturnType($method); + + if ($type === DataTypeInterface::TYPE_VOID) { + return null; + } + + if (null === $type) { + $type = DataTypeInterface::TYPE_MIXED; + } + } + $originType = $type; + + if (false !== strpos($type, '[]')) { + $type = $this->getClassNameFromComplexAnnotationName($type, $method->getDeclaringClass()); + $originType = str_replace('[]', '', $originType); + $typeIsArray = true; + } + $methodName = $method->getName(); + + if (in_array($type, self::METHODS_RETURN_SELF, true)) { + $type = $method->getDeclaringClass()->getName(); + } + + if (class_exists($type) || interface_exists($type)) { + [, $fakeValue, $fakeTimes] = $this->getMockedDataProvider($type, true, 1); + $fakeValue['className'] = $type; + $fakeTimes['className'] = $type; + + if ($typeIsArray) { + $fakeValue = [$fakeValue]; + $fakeTimes = [$fakeTimes]; + } + + return [$methodName, $fakeValue, $fakeTimes]; + } + $fakeValue = $this->getFakeValueByName($methodName); + + if (null === $fakeValue) { + $fakeValue = $this->getFakeValueByType($originType); + } + + if ($typeIsArray) { + $fakeValue = [$fakeValue]; + } + + return [$methodName, $fakeValue, 0]; + } + + /** + * Add to data provider new argument. + * + * @param ReflectionClass $reflectionClass + * @param ReflectionParameter $parameter + * @param string $dataProviderMethodName + * + * @throws ReflectionException + * @throws ReturnTypeNotFoundException + */ + public function addDataProviderArgument( + ReflectionClass $reflectionClass, + ReflectionParameter $parameter, + string $dataProviderMethodName + ): void { + $dataProviderName = $this->getDataProviderName($reflectionClass->getName()); + + for ($i=0; $i < $this->countDataSets; ++$i) { + [$paramName, $fakeValue, $fakeTimes] = $this->getFakeData($parameter); + + if (isset($this->dataProviders[$dataProviderName]['dataProviderMethods'][$dataProviderMethodName][$i][0][$paramName])) { + return; + } + + if (!isset($this->dataProviders[$dataProviderName]['dataProviderMethods'][$dataProviderMethodName][$i])) { + $this->dataProviders[$dataProviderName]['dataProviderMethods'][$dataProviderMethodName][$i] = [ + [], + [] + ]; + } + $this->dataProviders[$dataProviderName]['dataProviderMethods'][$dataProviderMethodName][$i][0][$paramName] = $fakeValue; + + if (null !== $fakeTimes) { + $this->dataProviders[$dataProviderName]['dataProviderMethods'][$dataProviderMethodName][$i][1][$paramName] = $fakeTimes; + } + } + } + + /** + * Return, if exists, dataProvider structure. + * + * @param string $dataProviderName + * + * @return mixed[]|null + */ + public function getDataProvider(string $dataProviderName): ?array + { + return $this->dataProviders[$dataProviderName] ?: null; + } + + /** + * Build and return fake data. + * + * @param ReflectionParameter $parameter + * + * @return mixed[] + * + * @throws ReflectionException + * @throws ReturnTypeNotFoundException + */ + private function getFakeData(ReflectionParameter $parameter): array + { + $type = $parameter->getType(); + + if (null !== $type) { + $type = $type->getName(); + } + + if (null === $type) { + try { + $type = $this->getReturnFromAnnotation($parameter->getDeclaringFunction()); + } catch (ReturnTypeNotFoundException $e) { + if ($this->returnTypeNotFoundThrowable) { + throw $e; + } + $type = DataTypeInterface::TYPE_MIXED; + } + } + + if (class_exists($type) || interface_exists($type)) { + return $this->getMockedDataProvider($type, true, 1); + } + $paramName = $parameter->getName(); + $fakeValue = $this->getFakeValueByName($paramName); + + if (null === $fakeValue) { + $fakeValue = $this->getFakeValueByType($type); + } + + return [$paramName, $fakeValue, null]; + } + + /** + * Analyze and generate fake value for param. + * + * @param string $paramName + * + * @return mixed|null + */ + private function getFakeValueByName(string $paramName) + { + foreach (self::FAKE_PROVIDERS as $type => $fakeProvider) { + foreach ($fakeProvider as $subFormatter => $formatter) { + if (is_array($formatter)) { + $formatters = $formatter; + $formatter = $subFormatter; + array_unshift($formatters, $subFormatter); + } else { + $formatters = [$formatter]; + } + + foreach ($formatters as $pattern) { + if (false !== stripos($paramName, $pattern)) { + $fakeValue = $this->faker->{$formatter}; + + if ($fakeValue instanceof \DateTime) { + $fakeValue = $fakeValue->format(DATE_ATOM); + } + + return $fakeValue; + } + } + } + } + + return null; + } + + /** + * Analyze and generate fake value for param. + * + * @param string $paramType + * + * @return mixed|null + */ + private function getFakeValueByType(string $paramType) + { + if (isset(self::FAKE_BASE_PROVIDERS[$paramType])) { + return $this->faker->{self::FAKE_BASE_PROVIDERS[$paramType]}; + } + + return null; + } + + /** + * Build data provider structure for mock. + * + * @param string $className + * @param bool $mockDeeper + * @param int $level + * + * @return mixed[] + * + * @throws ReflectionException + * @throws ReturnTypeNotFoundException + */ + private function getMockedDataProvider(string $className, bool $mockDeeper = false, int $level = 0): array + { + $class = new ReflectionClass($className); + $mockArguments = []; + $mockTimes = ['times' => 0]; + $arguments = []; + $argumentTimes = []; + + foreach ($class->getMethods() as $method) { + $methodName = $method->getName(); + + if ( + $method->isConstructor() || + $method->isDestructor() || + $method->isProtected() || + $method->isPrivate() || + $method->isStatic() || + in_array($className, self::$excludeMethods, true) || + in_array($methodName, self::$excludeMethods, true) || + (isset(self::$excludeMethods[$className]) && in_array($methodName, self::$excludeMethods[$className], true)) || + ( + isset(self::$excludeMethods[$className]['all_except']) && + !in_array($methodName, self::$excludeMethods[$className]['all_except'], true) + ) + ) { + continue; + } + $fakeParam = $methodName; + + if (isset($arguments[$fakeParam])) { + $mockArguments[$methodName] = $arguments[$fakeParam]; + $mockTimes[$methodName] = $argumentTimes[$fakeParam]; + continue; + } + + if (in_array($fakeParam, self::$selfMethods, true)) { + $fakeParam = $className; + + if (isset($arguments[$fakeParam])) { + $mockArguments[$methodName] = $arguments[$fakeParam]; + $mockTimes[$methodName] = $argumentTimes[$fakeParam]; + continue; + } + } + $fakeValue = null; + $fakeTimes = 0; + $typeIsArray = false; + + if (!$mockDeeper || $level > $this->mockDeepLevel) { + continue; + } + $returnType = $this->getMethodReturnType($method); + + if ( + $returnType !== DataTypeInterface::TYPE_ARRAY_MIXED && + false !== strpos($returnType, '[]') + ) { + $returnType = str_replace('[]', '', $returnType); + $typeIsArray = true; + } + + if (class_exists($returnType) || interface_exists($returnType)) { + if (isset($arguments[$returnType])) { + $mockArguments[$methodName] = $arguments[$returnType]; + $mockTimes[$methodName] = $argumentTimes[$returnType]; + continue; + } + + if ($returnType === $className) { + continue; + } + [, $fakeValue, $fakeTimes] = $this->getMockedDataProvider($returnType, true, $level + 1); + } else { + $returnType = $this->findAndReturnClassNameFromUseStatement($returnType, $method->getDeclaringClass()); + + if (null !== $returnType) { + if (isset($arguments[$returnType])) { + $mockArguments[$methodName] = $arguments[$returnType]; + $mockTimes[$methodName] = $argumentTimes[$returnType]; + continue; + } + [, $fakeValue, $fakeTimes] = $this->getMockedDataProvider($returnType, true, $level + 1); + } + } + + if (null === $fakeValue) { + $fakeValue = $this->getFakeValueByName($fakeParam); + } + + if (null === $fakeValue) { + $fakeParam = $this->getMethodReturnType($method); + + if (isset($arguments[$fakeParam])) { + $mockArguments[$methodName] = $arguments[$fakeParam]; + $mockTimes[$methodName] = $argumentTimes[$fakeParam]; + continue; + } + + if (class_exists($fakeParam) || interface_exists($fakeParam)) { + $fakeValue = $this->getFakeValueByName($fakeParam); + } + + if (null === $fakeValue) { + $fakeValue = $this->getFakeValueByType($fakeParam); + } + } + $arguments[$fakeParam] = $fakeValue; + $argumentTimes[$fakeParam] = $fakeTimes; + + if ($typeIsArray) { + $fakeValue = [$fakeValue]; + + if (isset($fakeTimes['times'])) { + $times = $fakeTimes['times']; + unset($fakeTimes['times']); + $fakeTimes = ['times' => $times, 'mockTimes' => [$fakeTimes]]; + } + } + $mockArguments[$methodName] = $fakeValue; + $mockTimes[$methodName] = $fakeTimes; + } + $pos = strrpos($className, '\\'); + $paramName = $pos ? substr($className, ++$pos) : $className; + + return [$paramName, $mockArguments, $mockTimes]; + } + + /** + * PHP var_export() with short array syntax (square brackets) indented 2 spaces. + * + * @param mixed $expression + * + * @return string + */ + private function varExport($expression): string + { + $export = var_export($expression, TRUE); + $patterns = [ + "/array \(/" => '[', + "/^([ ]*)\)(,?)$/m" => '$1]$2', + "/=>[ ]?\n[ ]+\[/" => '=> [', + "/([ ]*)(\'[^\']+\') => ([\[\'])/" => '$1$2 => $3', + ]; + $export = preg_replace(array_keys($patterns), array_values($patterns), $export); + + return $export; + } + + /** + * Set method names, that should return self value. + * + * @param string[] $selfMethods + */ + public static function setSelfMethods(array $selfMethods): void + { + self::$selfMethods = $selfMethods; + } + + /** + * Set method names, that should be excluded. + * + * @param string[] $excludeMethods + */ + public static function setExcludeMethods(array $excludeMethods): void + { + self::$excludeMethods = $excludeMethods; + } +} diff --git a/src/Generator/DataTypeInterface.php b/src/Generator/DataTypeInterface.php new file mode 100644 index 0000000..25332a2 --- /dev/null +++ b/src/Generator/DataTypeInterface.php @@ -0,0 +1,31 @@ +getDocComment(); + + if ($docComment) { + preg_match_all('/@return (.*)$/Um', $docComment, $return); + } + + if (!isset($return[1][0])) { + if (!$strictMode) { + return null; + } + $reflection = $refMethod->getDeclaringClass(); + + throw new ReturnTypeNotFoundException( + sprintf( + 'In class \'%s\' could not find annotation comment for method \'%s\' in source \'%s\'.', + $reflection->getName(), + $refMethod->getName(), + $reflection->getFileName() + ) + ); + } + $return = trim($return[1][0], ' \\'); + + if (false !== strpos($return, '|')) { + $return = explode('|', $return); + $return = (count($return) > 1 && $return[0] === DataTypeInterface::TYPE_NULL) ? $return[1] : $return[0]; + } + $return = trim($return); + + if (false !== strpos($return, ' ')) { + $return = explode(' ', $return)[0]; + } + + if (false === $returnOrigin && false !== strpos($return, '[]')) { + return DataTypeInterface::TYPE_ARRAY; + } + + return $return; + } + + /** + * Find and return method param types from annotation. + * + * @param ReflectionMethod $refMethod + * @param bool $strictMode + * @param bool $returnOrigin + * + * @return string[]|null + * + * @throws ReturnTypeNotFoundException + */ + protected function getParamsFromAnnotation( + ReflectionMethod $refMethod, + bool $strictMode = true, + bool $returnOrigin = false + ): ?array { + $annotationParams = []; + $docComment = $refMethod->getDocComment(); + + if (false === $docComment) { + if (!$strictMode) { + return null; + } + $reflection = $refMethod->getDeclaringClass(); + + throw new ReturnTypeNotFoundException( + sprintf( + 'In class \'%s\' could not find annotation comment for method \'%s\' in source \'%s\'.', + $reflection->getName(), + $refMethod->getName(), + $reflection->getFileName() + ) + ); + } + + if (false !== strpos($docComment, '@inheritdoc')) { + try { + $docComment = $refMethod->getPrototype()->getDocComment(); + } catch (ReflectionException $e) { + if (!$strictMode) { + return null; + } + $reflection = $refMethod->getDeclaringClass(); + + throw new ReturnTypeNotFoundException( + sprintf( + 'In class \'%s\' find \'@inheritdoc\' annotation comment, but prototype does not exists, for method \'%s\' in source \'%s\'.', + $reflection->getName(), + $refMethod->getName(), + $reflection->getFileName() + ) + ); + } + } + + if ($docComment) { + preg_match_all('/@param (.*?) (.*)$/Um', $docComment, $annotationParams); + } + + if (!isset($annotationParams[1][0])) { + if (!$strictMode) { + return null; + } + $reflection = $refMethod->getDeclaringClass(); + + throw new ReturnTypeNotFoundException( + sprintf( + 'In class \'%s\' could not find annotation comment for method \'%s\' in source \'%s\'.', + $reflection->getName(), + $refMethod->getName(), + $reflection->getFileName() + ) + ); + } + $annotationParams = $annotationParams[1]; + + foreach ($annotationParams as &$annotationParam) { + $annotationParam = trim($annotationParam, ' \\'); + + if (false !== strpos($annotationParam, '|')) { + $annotationParam = explode('|', $annotationParam); + $annotationParam = (count($annotationParam) > 1 && $annotationParam[0] === DataTypeInterface::TYPE_NULL) ? $annotationParam[1] : $annotationParam[0]; + } + $annotationParam = trim($annotationParam); + + if (false !== strpos($annotationParam, ' ')) { + $annotationParam = explode(' ', $annotationParam)[0]; + } + + if (false === $returnOrigin && false !== strpos($annotationParam, '[]')) { + $annotationParam = DataTypeInterface::TYPE_ARRAY; + } + } + + return $annotationParams; + } + + /** + * Parse and find all parent classes and traits. + * + * @param ReflectionClass $reflection + * + * @return mixed[] + */ + public function getParentClassesAndTraits(ReflectionClass $reflection): array + { + $traitsNames = []; + $parentClasses = []; + $recursiveClasses = static function (ReflectionClass $class) use (&$recursiveClasses, &$traitsNames, &$parentClasses): void { + if (false !== $class->getParentClass()) { + $parentClass = $class->getParentClass()->getName(); + + if (in_array($parentClass, $parentClasses, true)) { + return; + } + $parentClasses[] = $parentClass; + $recursiveClasses($class->getParentClass()); + } else { + $reflectionTraits = $class->getTraitNames(); + + if ($reflectionTraits) { + $traitsNames = array_merge($traitsNames, $reflectionTraits); + } + } + }; + $recursiveClasses($reflection); + + return [$parentClasses, $traitsNames]; + } + + /** + * Return all namespaces from parent classes and traits. + * + * @param ReflectionClass $reflection + * + * @return string[] + * + * @throws CodeExtractException + * @throws FileNotExistsException + * @throws ReflectionException + */ + public function getNamespacesFromParentClassesAndTraits(ReflectionClass $reflection): array + { + $namespaces = []; + [$parentClasses, $extendedTraits] = $this->getParentClassesAndTraits($reflection); + unset($parentClasses); + + if (!$extendedTraits) { + return $namespaces; + } + + foreach ($extendedTraits as $trait) { + $reflection = new ReflectionClass($trait); + $filename = $reflection->getFileName(); + + if (false === $filename) { + throw new FileNotExistsException(sprintf('Trait \'%s\' does not exists.', $trait)); + } + $namespaces[] = $this->getNamespacesFromSource($filename); + } + + return array_merge(...$namespaces); + } + + /** + * Return all namespaces source. + * + * @param string $sourceName + * + * @return string[] + * + * @throws CodeExtractException + */ + public function getNamespacesFromSource(string $sourceName): array + { + if (isset($this->namespaces[$sourceName])) { + return $this->namespaces[$sourceName]; + } + $namespaces = []; + $sourceCode = file_get_contents($sourceName); + + if (false === $sourceCode) { + throw new CodeExtractException(sprintf('Code can not be extract from source \'%s\'.', $sourceName)); + } + $tokens = token_get_all($sourceCode); + $iMax = count($tokens); + + for ($i = 0; $i < $iMax; ++$i) { + $token = $tokens[$i]; + + if (is_array($token) && T_USE === $token[0]) { + $y = 0; + ++$i; + $namespace = []; + $alias = false; + $namespaceFinish = false; + + foreach (array_slice($tokens, $i) as $y => $useToken) { + if ( + is_array($useToken) && + ( + T_WHITESPACE === $useToken[0] || + T_NS_SEPARATOR === $useToken[0] + ) + ) { + continue; + } + + if (is_array($useToken) && T_STRING === $useToken[0]) { + $useToken[1] = trim($useToken[1]); + if (!$namespaceFinish) { + $namespace[] = $useToken[1]; + } else { + $alias = $useToken[1]; + } + } elseif (is_string($useToken)) { + if (',' === $useToken) { + if (false === $alias) { + $alias = end($namespace); + } + $namespace = implode('\\', $namespace); + $namespaces[$alias] = $namespace; + $namespace = []; + $alias = false; + $namespaceFinish = false; + } elseif (';' === $useToken) { + break; + } + } elseif (is_array($useToken) && T_AS === $useToken[0]) { + $namespaceFinish = true; + } + } + + if (false === $alias) { + $alias = end($namespace); + } + $namespace = implode('\\', $namespace); + $namespaces[$alias] = $namespace; + + $i += $y; + } + if (is_array($token) && T_CLASS === $token[0]) { + break; + } + } + $this->namespaces[$sourceName] = $namespaces; + + return $this->namespaces[$sourceName]; + } + + /** + * Parse the use statements from read source by + * tokenizing and reading the tokens. Returns + * an array of use statements and aliases. + * + * @param ReflectionClass $reflection + * + * @return mixed[] + * + * @throws CodeExtractException + * @throws FileNotExistsException + */ + private function parseUseStatements(ReflectionClass $reflection): array + { + $filename = $reflection->getFileName(); + + if (false === $filename) { + throw new FileNotExistsException(sprintf('Trait \'%s\' does not exists.', $reflection->getName())); + } + $sourceCode = file_get_contents($filename); + + if (false === $sourceCode) { + throw new CodeExtractException(sprintf('Code can not be extract from source \'%s\'.', $filename)); + } + $tokens = token_get_all($sourceCode); + $builtNamespace = ''; + $buildingNamespace = false; + $matchedNamespace = false; + $useStatements = []; + $record = false; + $currentUse = [ + 'class' => '', + 'as' => '', + ]; + + foreach ($tokens as $token) { + if (T_NAMESPACE === $token[0]) { + $buildingNamespace = true; + if ($matchedNamespace) { + break; + } + } + + if ($buildingNamespace) { + if (';' === $token) { + $buildingNamespace = false; + + continue; + } + switch ($token[0]) { + case T_STRING: + case T_NS_SEPARATOR: + $builtNamespace .= $token[1]; + + break; + } + + continue; + } + + if (';' === $token || !is_array($token)) { + if ($record) { + $useStatements[] = $currentUse; + $record = false; + $currentUse = [ + 'class' => '', + 'as' => '', + ]; + } + + continue; + } + + if (T_CLASS === $token[0]) { + break; + } + + if (0 === strcasecmp($builtNamespace, $reflection->getNamespaceName())) { + $matchedNamespace = true; + } + + if ($matchedNamespace) { + if (T_USE === $token[0]) { + $record = 'class'; + } + + if (T_AS === $token[0]) { + $record = 'as'; + } + + if ($record) { + switch ($token[0]) { + case T_STRING: + case T_NS_SEPARATOR: + $currentUse[$record] .= $token[1]; + + break; + } + } + } + + if ($token[2] >= $reflection->getStartLine()) { + break; + } + } + // Make sure the as key has the name of the class even + // if there is no alias in the use statement. + foreach ($useStatements as &$useStatement) { + if (empty($useStatement['as'])) { + $useStatement['as'] = basename($useStatement['class']); + } + } + + return $useStatements; + } + + /** + * Return all methods from source. + * + * @param string $sourceName + * + * @return string[] + * + * @throws CodeExtractException + */ + public function parseMethodsFromSource(string $sourceName): array + { + $methods = []; + $sourceCode = file_get_contents($sourceName); + + if (false === $sourceCode) { + throw new CodeExtractException(sprintf('Code can not be extract from source \'%s\'.', $sourceName)); + } + $tokens = token_get_all($sourceCode); + $iMax = count($tokens); + + for ($i = 0; $i < $iMax; ++$i) { + $token = $tokens[$i]; + + if (is_array($token) && T_FUNCTION === $token[0]) { + $y = 0; + ++$i; + $method = ''; + + foreach (array_slice($tokens, $i) as $y => $methodToken) { + if ( + is_array($methodToken) && + ( + T_WHITESPACE === $methodToken[0] || + T_NS_SEPARATOR === $methodToken[0] + ) + ) { + continue; + } + + if (is_array($methodToken) && T_STRING === $methodToken[0]) { + $methodToken[1] = trim($methodToken[1]); + $method = $methodToken[1]; + } elseif (is_string($methodToken)) { + if ('(' === $methodToken) { + break; + } + } + } + $methods[] = $method; + $i += $y; + } + } + + return $methods; + } + + /** + * Find and return source method return type. + * + * @param ReflectionMethod $reflectionMethod + * + * @return string + * + * @throws ReturnTypeNotFoundException + */ + protected function getMethodReturnType(ReflectionMethod $reflectionMethod): string + { + $returnType = null; + $className = $reflectionMethod->getDeclaringClass()->getName(); + + if (in_array($reflectionMethod->getName(), self::METHOD_NAMES_RETURN_SELF, true)) { + $returnType = $className; + } else { + $returnType = $reflectionMethod->getReturnType(); + + if (null !== $returnType) { + $returnType = $returnType->getName(); + } + } + + if (!$returnType || $returnType === 'array') { + $returnType = $this->getReturnFromAnnotation($reflectionMethod, false, true); + + if (null === $returnType) { + $returnType = DataTypeInterface::TYPE_MIXED; + } + } + + if (in_array($returnType, self::METHODS_RETURN_SELF, true)) { + $returnType = $className; + } + + return $returnType; + } + + /** + * Process ReflectionClass from method annotation name and return class name. + * + * @param string $annotationName + * @param ReflectionClass $reflectionClass + * + * @return string + * + * @throws CodeExtractException + * @throws FileNotExistsException + */ + protected function getClassNameFromComplexAnnotationName(string $annotationName, ReflectionClass $reflectionClass): string + { + $annotationName = str_replace('[]', '', $annotationName); + $useStatements = $this->parseUseStatements($reflectionClass); + $fullClassName = current(array_filter($useStatements, static function (array $element) use ($annotationName) { + if (!(class_exists($element['class']) || interface_exists($element['class']))) { + return false; + } + $reflection = new ReflectionClass($element['class']); + + return ($element['as'] === $annotationName || $reflection->getShortName() === $annotationName) ?? true; + })); + + if (is_array($fullClassName) && isset($fullClassName['class'])) { + $annotationName = $fullClassName['class']; + } else { + $annotationName = $reflectionClass->getNamespaceName() . '\\' . $annotationName; + } + + return $annotationName; + } + + /** + * Validate and try to find full class name from class use statement. + * + * @param string $className + * @param ReflectionClass $reflectionClass + * + * @return string|null + * + * @throws CodeExtractException + * @throws FileNotExistsException + */ + protected function findAndReturnClassNameFromUseStatement(string $className, ReflectionClass $reflectionClass): ?string + { + if( + in_array($className, [ + DataTypeInterface::TYPE_INT, + DataTypeInterface::TYPE_INTEGER, + DataTypeInterface::TYPE_FLOAT, + DataTypeInterface::TYPE_MIXED, + DataTypeInterface::TYPE_STRING, + DataTypeInterface::TYPE_BOOL, + DataTypeInterface::TYPE_BOOLEAN, + DataTypeInterface::TYPE_BOOL_TRUE, + DataTypeInterface::TYPE_BOOL_FALSE, + DataTypeInterface::TYPE_NULL, + DataTypeInterface::TYPE_VOID, + DataTypeInterface::TYPE_ARRAY_MIXED, + DataTypeInterface::TYPE_CLOSURE, + DataTypeInterface::TYPE_SELF, + ], true) || + !$reflectionClass->getFileName() + ) { + return null; + } + $className = $this->getClassNameFromComplexAnnotationName($className, $reflectionClass); + + if (!class_exists($className) && !interface_exists($className)) { + return null; + } + + return $className; + } + + /** + * @param ReflectionMethod $method + * + * @return Node|null + * + * @throws FileNotExistsException + */ + protected function findAndReturnMethodAstStmtByName(ReflectionMethod $method) + { + $phpCodeGenerator = (new ParserFactory())->create(ParserFactory::PREFER_PHP7); + $reflectionClass = $method->getDeclaringClass(); + $filename = $reflectionClass->getFileName(); + + if (false === $filename) { + throw new FileNotExistsException(sprintf('Trait \'%s\' does not exists.', $filename)); + } + $sourceCode = file_get_contents($reflectionClass->getFileName()); + $stmts = $phpCodeGenerator->parse($sourceCode); + $methodName = $method->getName(); + $nodeFinder = new NodeFinder; + $stmtMethod = $nodeFinder->findFirst($stmts, function(Node $node) use ($methodName) { + return $node instanceof ClassMethod + && $node->name->toString() === $methodName; + }); + + return $stmtMethod; + } + + /** + * @param ClassMethod $methodStmt + * @param string $methodName + * + * @return array + */ + protected function findCallMethodsByNameFromAstStmt(ClassMethod $methodStmt, string $methodName): array + { + $nodeFinder = new NodeFinder; + + return $nodeFinder->find($methodStmt, function(Node $node) use ($methodName) { + return $node instanceof Node\Expr\MethodCall + && $node->name->toString() === $methodName; + }); + } + + /** + * @param array $stmtMethods + * @param string $pattern + * + * @return array + */ + protected function findCallMethodsByPatternFromAstStmts(array $stmtMethods, string $pattern): array + { + $nodeFinder = new NodeFinder; + $stmtCallMethods = []; + + foreach ($stmtMethods as $stmtMethod) { + $stmtCallMethods[] = $nodeFinder->findFirst($stmtMethod, function(Node $node) use ($pattern) { + return $node instanceof Node\Expr\MethodCall + && preg_match($pattern, $node->name->toString()); + }); + } + + return $stmtCallMethods; + } +} diff --git a/src/Generator/Helper/ReturnTypeNotFoundException.php b/src/Generator/Helper/ReturnTypeNotFoundException.php new file mode 100644 index 0000000..68d1a43 --- /dev/null +++ b/src/Generator/Helper/ReturnTypeNotFoundException.php @@ -0,0 +1,14 @@ +isFinal() && !class_exists(BypassFinals::class)) { + throw new MockFinalClassException('Final class \'' . $reflection->getName() . '\' cannot be mocked, use interface instead.'); + } + + [$extendedClasses, $extendedTraits] = $this->getParentClassesAndTraits($reflection); + $methods = $reflection->getMethods(); + $mockMethods = []; + $mockArgs = []; + $mockTimes = []; + + foreach ($methods as $refMethod) { + $refMethodName = $refMethod->getName(); + + if ( + $refMethod->isConstructor() || + $refMethod->isDestructor() || + $refMethod->isProtected() || + $refMethod->isPrivate() || + $refMethod->isStatic() || + in_array($className, self::$excludeMethods, true) || + in_array($refMethodName, self::$excludeMethods, true) || + (isset(self::$excludeMethods[$className]) && in_array($refMethodName, self::$excludeMethods[$className], true)) || + ( + isset(self::$excludeMethods[$className]['all_except']) && + !in_array($refMethodName, self::$excludeMethods[$className]['all_except'], true) + ) + ) { + continue; + } + $refMethodDeclaringClassName = $refMethod->getDeclaringClass()->getName(); + + if ( + in_array(strtolower($refMethodName), AbstractGenerator::EXCLUDE_DECLARING_METHODS, true) || + in_array(explode('\\', trim($refMethodDeclaringClassName, '\\'))[0], AbstractGenerator::EXCLUDE_DECLARING_CLASSES_FOR_METHODS, true) + ) { + continue; + } + $returnType = null; + $mockArgs[] = '\'' . $refMethodName . '\' => \'\''; + $mockTimes[] = '\'' . $refMethodName . '\' => 0'; + + if (in_array($refMethodName, self::METHODS_RETURN_SELF, true)) { + $returnType = $className; + } else { + $returnType = $refMethod->getReturnType(); + + if (null !== $returnType) { + $returnType = $returnType->getName(); + } + } + + if (!$returnType) { + try { + $returnType = $this->getReturnFromAnnotation($refMethod); + } catch (ReturnTypeNotFoundException $e) { + if ($this->returnTypeNotFoundThrowable) { + throw $e; + } + $returnType = DataTypeInterface::TYPE_MIXED; + } + } + + $mockMethod = 'if (array_key_exists(\'' . $refMethodName . '\', $mockTimes)) { + $mockMethod = $mock + ->shouldReceive(\'' . $refMethodName . '\'); + + if (null === $mockTimes[\'' . $refMethodName . '\']) { + $mockMethod->zeroOrMoreTimes(); + } elseif (is_array($mockTimes[\'' . $refMethodName . '\'])) { + $mockMethod->times($mockTimes[\'' . $refMethodName . '\'][\'times\']); + } else { + $mockMethod->times($mockTimes[\'' . $refMethodName . '\']); + }' . "\r\n\t\t\t"; + + switch ($returnType) { + case DataTypeInterface::TYPE_INT: + case DataTypeInterface::TYPE_INTEGER: + case DataTypeInterface::TYPE_FLOAT: + case DataTypeInterface::TYPE_MIXED: + case DataTypeInterface::TYPE_STRING: + case DataTypeInterface::TYPE_BOOL: + case DataTypeInterface::TYPE_BOOLEAN: + case DataTypeInterface::TYPE_BOOL_TRUE: + case DataTypeInterface::TYPE_BOOL_FALSE: + $mockMethod .= '$mockMethod->andReturn($mockArgs[\'' . $refMethodName . '\']);'; + + break; + + case DataTypeInterface::TYPE_NULL: + $mockMethod .= '$mockMethod->andReturnNull();'; + + break; + + case DataTypeInterface::TYPE_VOID: + $mockMethod .= ''; + + break; + + case DataTypeInterface::TYPE_ARRAY: + $returnType = $this->getReturnFromAnnotation($refMethod, false, true); + + if (null === $returnType || $returnType === 'mixed[]') { + $returnType = DataTypeInterface::TYPE_ARRAY; + } else { + $returnType = $this->getClassNameFromComplexAnnotationName($returnType, $refMethod->getDeclaringClass()); + } + + if (class_exists($returnType) || interface_exists($returnType)) { + $filename = $reflection->getFileName(); + + if (false === $filename) { + throw new FileNotExistsException(sprintf('File \'%s\' does not exists.', $reflection->getName())); + } + $refNamespaces = $this->getNamespacesFromSource($filename); + $namespacesFromParentClassesAndTraits = $this->getNamespacesFromParentClassesAndTraits($reflection); + $namespaces = array_merge($parentNamespaces, $refNamespaces, $namespacesFromParentClassesAndTraits); + $refMockName = $mockGenerator->addMock($returnType, $reflection, $refMethod, $namespaces, $level, $parentClass); + $mockStructure = $mockGenerator->getMock($refMockName); + + if (null === $mockStructure) { + throw new MockNotExistsException(sprintf('Mock \'%s\' does not exist.', $refMockName)); + } + $mockMethodName = $mockStructure['mockMethodName']; + $mockShortName = $mockStructure['mockShortName']; + $mockMethod .= '$' . $mockShortName . 's = [];'; + $mockMethod .= "\n\r\n\t\t\t" . 'foreach ($mockArgs[\'' . $refMethodName . '\'] as $i => $' . $mockShortName . ') {'; + $mockMethod .= "\r\n\t\t\t\t$" . $mockShortName . 's[] = $this->' . $mockMethodName . + '($' . $mockShortName . ', $mockTimes[\'' . $refMethodName . '\'][\'mockTimes\'][$i]);' . "\r\n\t\t\t}\r\n\t\t\t" . + '$mockMethod->andReturn($' . $mockShortName . 's);'; + + break; + } + + case DataTypeInterface::TYPE_ARRAY_MIXED: + $mockMethod .= '$mockMethod->andReturn($mockArgs[\'' . $refMethodName . '\']);'; + + break; + + case DataTypeInterface::TYPE_CLOSURE: + case DataTypeInterface::TYPE_CALLABLE: + $mockMethod .= '$mockMethod->andReturn(function () { return true; });'; + + break; + + case DataTypeInterface::TYPE_SELF: + case DataTypeInterface::TYPE_STATIC: + case DataTypeInterface::TYPE_THIS: + case $className: + $mockMethod .= '$mockMethod->andReturnSelf();'; + + break; + + default: + if ( + in_array($returnType, $extendedClasses, true) || + in_array($returnType, $extendedTraits, true) + ) { + $mockMethod .= '$mockMethod->andReturnSelf();'; + + break; + } + $filename = $reflection->getFileName(); + + if (false === $filename) { + throw new FileNotExistsException(sprintf('File \'%s\' does not exists.', $reflection->getName())); + } + $refNamespaces = $this->getNamespacesFromSource($filename); + $namespacesFromParentClassesAndTraits = $this->getNamespacesFromParentClassesAndTraits($reflection); + $namespaces = array_merge($parentNamespaces, $refNamespaces, $namespacesFromParentClassesAndTraits); + $refMockName = $mockGenerator->addMock($returnType, $reflection, $refMethod, $namespaces, $level, $parentClass); + $mockStructure = $mockGenerator->getMock($refMockName); + + if (null === $mockStructure) { + throw new MockNotExistsException(sprintf('Mock \'%s\' does not exist.', $refMockName)); + } + $mockMethodName = $mockStructure['mockMethodName']; + $mockShortName = $mockStructure['mockShortName']; + $mockMethod .= "\r\n\t\t\t$" . $mockShortName . ' = $this->' . $mockMethodName . + '($mockArgs[\'' . $refMethodName . '\'], $mockTimes[\'' . $refMethodName . '\']);' . "\r\n\t\t\t" . + '$mockMethod->andReturn($' . $mockShortName . ');'; + + break; + } + $mockMethod .= "\r\n\t\t}\r\n"; + $mockMethods[] = $mockMethod; + } + $mock = '$mock = \Mockery::namedMock(\'Mock\\' . $className . '\', \\' . $className . '::class);'; + $mock .= "\r\n\r\n\t\t" . implode("\r\n\t\t", $mockMethods) . "\r\n"; + $mockArgs = '[' . implode(', ', $mockArgs) . ']'; + $mockTimes = '[' . implode(', ', $mockTimes) . ']'; + + return [$mock, $mockArgs, $mockTimes]; + } + + /** + * Return mock method template name. + * + * @return string + */ + public function getMockMethodTemplate(): string + { + return self::METHOD_TEMPLATE_MOCKERY; + } + + /** + * Return mock method return interface. + * + * @return string + */ + public function getReturnMockInterface(): string + { + return self::METHOD_RETURN_MOCK_INTERFACE_MOCKERY; + } + + /** + * Set method names, that should be excluded. + * + * @param string[] $excludeMethods + */ + public static function setExcludeMethods(array $excludeMethods): void + { + self::$excludeMethods = $excludeMethods; + } +} diff --git a/src/Generator/Mock/PhpUnit.php b/src/Generator/Mock/PhpUnit.php new file mode 100644 index 0000000..8b65f35 --- /dev/null +++ b/src/Generator/Mock/PhpUnit.php @@ -0,0 +1,282 @@ + 2 || + in_array(explode('\\', trim($className, '\\'))[0], AbstractGenerator::EXCLUDE_DECLARING_CLASSES, true) + ) { + $mock = '$this->getMockBuilder(\'' . $className . '\') + ->disableOriginalConstructor() + ->getMock();'; + + return [$mock, '', '']; + } + + [$extendedClasses, $extendedTraits] = $this->getParentClassesAndTraits($reflection); + $methods = $reflection->getMethods(); + $refMethods = []; + $mockMethods = []; + $mockArgs = []; + $mockTimes = []; + + foreach ($methods as $refMethod) { + $refMethodName = $refMethod->getName(); + + if ( + $refMethod->isConstructor() || + $refMethod->isDestructor() || + $refMethod->isProtected() || + $refMethod->isPrivate() || + $refMethod->isStatic() || + in_array($className, self::$excludeMethods, true) || + in_array($refMethodName, self::$excludeMethods, true) || + (isset(self::$excludeMethods[$className]) && in_array($refMethodName, self::$excludeMethods[$className], true)) || + ( + isset(self::$excludeMethods[$className]['all_except']) && + !in_array($refMethodName, self::$excludeMethods[$className]['all_except'], true) + ) + ) { + continue; + } + $refMethodDeclaringClassName = $refMethod->getDeclaringClass()->getName(); + $mockArgs[] = '\'' . $refMethodName . '\' => \'\''; + $mockTimes[] = '\'' . $refMethodName . '\' => 0'; + + if ( + in_array(explode('\\', trim($refMethodDeclaringClassName, '\\'))[0], AbstractGenerator::EXCLUDE_DECLARING_CLASSES_FOR_METHODS) || + in_array(strtolower($refMethodName), AbstractGenerator::EXCLUDE_DECLARING_METHODS) + ) { + continue; + } + $returnType = null; + + if (in_array($refMethodName, self::METHODS_RETURN_SELF, true)) { + $returnType = $className; + } else { + $returnType = $refMethod->getReturnType(); + + if (null !== $returnType) { + $returnType = $returnType->getName(); + } + } + + if (!$returnType) { + try { + $returnType = $this->getReturnFromAnnotation($refMethod); + } catch (ReturnTypeNotFoundException $e) { + if ($this->returnTypeNotFoundThrowable) { + throw $e; + } + $returnType = DataTypeInterface::TYPE_MIXED; + } + } + + switch ($returnType) { + case DataTypeInterface::TYPE_INT: + case DataTypeInterface::TYPE_INTEGER: + $mockMethod = '$' . $mockName . + '->expects($this->any())->method(\'' . $refMethodName . '\')->will($this->returnValue(1));'; + + break; + + case DataTypeInterface::TYPE_FLOAT: + $mockMethod = '$' . $mockName . + '->expects($this->any())->method(\'' . $refMethodName . '\')->will($this->returnValue(1.5));'; + + break; + + case DataTypeInterface::TYPE_MIXED: + case DataTypeInterface::TYPE_STRING: + $mockMethod = '$' . $mockName . + '->expects($this->any())->method(\'' . $refMethodName . '\')->will($this->returnValue(\'testMock' . $refMethodName . '\'));'; + + break; + + case DataTypeInterface::TYPE_BOOL: + case DataTypeInterface::TYPE_BOOLEAN: + case DataTypeInterface::TYPE_BOOL_TRUE: + $mockMethod = '$' . $mockName . + '->expects($this->any())->method(\'' . $refMethodName . '\')->will($this->returnValue(true));'; + + break; + + case DataTypeInterface::TYPE_BOOL_FALSE: + $mockMethod = '$' . $mockName . + '->expects($this->any())->method(\'' . $refMethodName . '\')->will($this->returnValue(false));'; + + break; + + case DataTypeInterface::TYPE_NULL: + $mockMethod = '$' . $mockName . + '->expects($this->any())->method(\'' . $refMethodName . '\')->will($this->returnValue(null));'; + + break; + + case DataTypeInterface::TYPE_VOID: + $mockMethod = '$' . $mockName . + '->expects($this->any())->method(\'' . $refMethodName . '\')'; + + break; + + case DataTypeInterface::TYPE_ARRAY: + case DataTypeInterface::TYPE_ARRAY_MIXED: + $mockMethod = '$' . $mockName . + '->expects($this->any())->method(\'' . $refMethodName . '\')->will($this->returnValue([\'test1\', \'test2\', \'test3\']));'; + + break; + + case DataTypeInterface::TYPE_CLOSURE: + $mockMethod = '$' . $mockName . + '->expects($this->any())->method(\'' . $refMethodName . '\')->will($this->returnValue(function () { return true; }));'; + + break; + + case DataTypeInterface::TYPE_SELF: + case DataTypeInterface::TYPE_THIS: + case DataTypeInterface::TYPE_STATIC: + case $className: + $mockMethod = '$' . $mockName . + '->expects($this->any())->method(\'' . $refMethodName . '\')->will($this->returnSelf());'; + + break; + + default: + if ( + in_array($returnType, $extendedClasses, true) || + in_array($returnType, $extendedTraits, true) + ) { + $mockMethod = '$' . $mockName . + '->expects($this->any())->method(\'' . $refMethodName . '\')->will($this->returnSelf());'; + + break; + } + $filename = $reflection->getFileName(); + + if (false === $filename) { + throw new FileNotExistsException(sprintf('File \'%s\' does not exists.', $reflection->getName())); + } + $refNamespaces = $this->getNamespacesFromSource($filename); + $namespacesFromParentClassesAndTraits = $this->getNamespacesFromParentClassesAndTraits($reflection); + $namespaces = array_merge($parentNamespaces, $refNamespaces, $namespacesFromParentClassesAndTraits); + $refMockName = $mockGenerator->addMock($returnType, $reflection, $refMethod, $namespaces, $level, $parentClass); + $mockStructure = $mockGenerator->getMock($refMockName); + + if (null === $mockStructure) { + throw new MockNotExistsException(sprintf('Mock \'%s\' does not exist.', $refMockName)); + } + $mockMethodName = $mockStructure['mockMethodName']; + $mockShortName = $mockStructure['mockShortName']; + $mockMethod = "\r\n\t\t$" . $mockShortName . ' = $this->' . $mockMethodName . "();\r\n\t\t$" . $mockName . + '->expects($this->any())->method(\'' . $refMethodName . '\')->will($this->returnValue($' . $mockShortName . '));'; + + break; + } + $mockMethods[] = $mockMethod; + $refMethods[] = '\'' . $refMethodName . '\''; + } + $mock = '$this->getMockBuilder(\'' . $className . '\') + ->setMethods([' . implode(', ', $refMethods) . ']) + ->disableOriginalConstructor() + ->getMock();'; + + $mock .= "\r\n\r\n\t\t" . implode("\r\n\t\t", $mockMethods) . "\r\n"; + $mockArgs = '[' . implode(', ', $mockArgs) . ']'; + $mockTimes = '[' . implode(', ', $mockTimes) . ']'; + + return [$mock, $mockArgs, $mockTimes]; + } + + /** + * Return mock method template name. + * + * @return string + */ + public function getMockMethodTemplate(): string + { + return self::METHOD_TEMPLATE_PHPUNIT; + } + + /** + * Return mock method return interface. + * + * @return string + */ + public function getReturnMockInterface(): string + { + return self::METHOD_RETURN_MOCK_INTERFACE_PHPUNIT; + } + + /** + * Set method names, that should be excluded. + * + * @param string[] $excludeMethods + */ + public static function setExcludeMethods(array $excludeMethods): void + { + self::$excludeMethods = $excludeMethods; + } +} diff --git a/src/Generator/MockGenerator.php b/src/Generator/MockGenerator.php new file mode 100644 index 0000000..ba772ca --- /dev/null +++ b/src/Generator/MockGenerator.php @@ -0,0 +1,427 @@ +mockTestPath = $mockTestPath; + $this->mockNamespace = $mockNamespace; + $this->projectNamespace = $projectNamespace; + $this->mockGenerator = $this->makeMockGeneratorByType($mockType); + } + + /** + * Build and return mock file path. + * + * @param string $className + * + * @return string[] + */ + private function getMockPathAndNamespace(string $className): array + { + $mockFilePath = $this->mockTestPath . DIRECTORY_SEPARATOR; + $mockFullClassName = $this->mockNamespace . '\\'; + + if (false === strpos($className, $this->projectNamespace)) { + $mockFilePath .= self::MOCK_VENDOR_FOLDER . DIRECTORY_SEPARATOR; + $mockFullClassName .= self::MOCK_VENDOR_FOLDER . '\\'; + $tmpNamespace = $className; + } else { + $tmpNamespace = substr($className, strlen($this->projectNamespace) + 1); + } + + if (false === strpos($tmpNamespace, '\\')) { + $tmpNamespace = self::MOCK_NATIVE_NAMESPACE . '\\' . $tmpNamespace; + } + $tmpNamespace = implode('\\', array_slice(explode('\\', $tmpNamespace), 0, 2)); + $tmpClassName = implode('\\', array_slice(explode('\\', $tmpNamespace), 1, 1)); + $mockFilePath .= str_replace('\\', DIRECTORY_SEPARATOR, $tmpNamespace) . self::MOCK_TRAIT_SUFFIX . '.php'; + $mockClassName = $tmpClassName . self::MOCK_TRAIT_SUFFIX; + $mockFullClassName .= $tmpNamespace . self::MOCK_TRAIT_SUFFIX; + $pos = strrpos($mockFullClassName, '\\'); + + if (false === $pos) { + $pos = strlen($mockFullClassName); + } + $mockNamespace = substr($mockFullClassName, 0, $pos); + + return [$mockFilePath, $mockNamespace, $mockClassName, $mockFullClassName]; + } + + /** + * Mock code generator factory. + * + * @param string $type + * + * @return MockInterface + * + * @throws InvalidMockTypeException + */ + private function makeMockGeneratorByType(string $type): MockInterface + { + switch ($type) { + case self::MOCK_TYPE_PHPUNIT: + return new PhpUnit(); + + case self::MOCK_TYPE_MOCKERY: + return new Mockery(); + + default: + throw new InvalidMockTypeException('Invalid mock type.'); + } + } + + /** + * Generate and save test mocks. + * + * @return string|null + * + * @throws Exception + */ + public function generate(): ?string + { + $mocks = []; + $mockFiles = []; + $mockTraits = []; + $mockFileInitialized = []; + + if (!$this->mocks) { + return null; + } + + foreach ($this->mocks as $mockName => $mock) { + [$mockFilepath, $mockNamespace, $mockClassName, $mockFullClassName] = $this->getMockPathAndNamespace($mock['className']); + + if (!isset($mockFileInitialized[$mockFilepath]) && file_exists($mockFilepath)) { + $methods = $this->parseMethodsFromSource($mockFilepath); + + foreach ($methods as $method) { + $mockFiles[$mockFilepath][] = $method; + } + $mockFileInitialized[$mockFilepath] = true; + } + + if (!isset($mockTraits[$mockFullClassName])) { + $mockTraits[$mockFullClassName] = '\\' . $mockFullClassName; + } + + if (!isset($mockFiles[$mockFilepath])) { + $mockFiles[$mockFilepath] = []; + } + $mockMethodName = $mock['mockMethodName']; + + if (in_array($mockMethodName, $mockFiles[$mockFilepath], true)) { + continue; + } + $mockTemplate = new Template( + sprintf( + '%s%stemplate%s%s.tpl', + __DIR__, + DIRECTORY_SEPARATOR, + DIRECTORY_SEPARATOR, + $this->mockGenerator->getMockMethodTemplate() + ) + ); + $mockTemplate->setVar([ + 'mockClassName' => $mock['className'], + 'mockName' => $mockName, + 'mockMethodName' => $mockMethodName, + 'mockInterface' => $this->mockGenerator->getReturnMockInterface(), + 'mock' => $mock['mock'], + 'mockArgs' => $mock['mockArgs'], + 'mockTimes' => $mock['mockTimes'], + ]); + + if (!isset($mocks[$mockFilepath])) { + $mocks[$mockFilepath] = [ + 'className' => $mockClassName, + 'fullClassName' => $mockFullClassName, + 'namespace' => $mockNamespace, + 'methods' => [], + ]; + } + $mocks[$mockFilepath]['methods'][] = $mockTemplate->render(); + } + + foreach ($mocks as $mockFilepath => $mock) { + $mockCode = implode('', $mock['methods']); + + if (!file_exists($mockFilepath)) { + $mockCode = $this->generateNewMockTrait($mock['namespace'], $mock['className'], $mockCode); + } + $this->saveFile($mockFilepath, $mockCode); + } + + return implode(',', $mockTraits); + } + + /** + * Generate new mock trait. + * + * @param string $namespace + * @param string $className + * @param string $methods + * + * @return string + * + * @throws Exception + */ + private function generateNewMockTrait(string $namespace, string $className, string $methods): string + { + $classTemplate = new Template( + sprintf( + '%s%stemplate%sTrait.tpl', + __DIR__, + DIRECTORY_SEPARATOR, + DIRECTORY_SEPARATOR + ) + ); + $classTemplate->setVar([ + 'namespace' => trim($namespace, '\\'), + 'className' => $className, + 'methods' => $methods, + 'date' => date('Y-m-d'), + 'time' => date('H:i:s'), + ]); + + return $classTemplate->render(); + } + + /** + * Save code to file, if file exist append code, if no, check if folder exist and create if needed. + * + * @param string $filepath + * @param string $code + * + * @throws CodeExtractException + */ + private function saveFile(string $filepath, string $code): void + { + $pathInfo = pathinfo($filepath); + + if (!file_exists($pathInfo['dirname'])) { + if (!mkdir($concurrentDirectory = $pathInfo['dirname'], 0755, true) && !is_dir($concurrentDirectory)) { + throw new RuntimeException(sprintf('Directory "%s" was not created', $concurrentDirectory)); + } + } + + if (file_exists($filepath)) { + $existCode = file_get_contents($filepath); + + if (false === $existCode) { + throw new CodeExtractException(sprintf('Code can not be extract from source \'%s\'.', $filepath)); + } + $pos = strrpos($existCode, "\n}"); + + if (false === $pos) { + $pos = strlen($existCode); + } + $existCode = substr($existCode, 0, $pos); + $code = $existCode . $code . "\n}"; + } + + file_put_contents($filepath, $code); + } + + /** + * Generate mock object. + * + * @param string|ReflectionClass $className + * @param ReflectionClass $parentClass + * @param ReflectionMethod $parentMethod + * @param mixed[] $parentNamespaces + * @param int $level + * @param ReflectionClass|null $parentAddMockClass + * + * @return string + * + * @throws CodeExtractException + * @throws FileNotExistsException + */ + public function addMock( + $className, + ReflectionClass $parentClass, + ReflectionMethod $parentMethod, + array $parentNamespaces, + int $level = 0, + ?ReflectionClass $parentAddMockClass = null + ): string { + ++$level; + $reflection = $className; + + if (!$reflection instanceof ReflectionClass) { + $reflection = trim($reflection); + $reflection = $this->makeReflectionClass($reflection, $parentNamespaces, $parentMethod, $parentClass); + } + $className = $reflection->getName(); + $mockName = ucfirst(str_replace('\\', '', $className . self::MOCK_NAME_PREFIX)); + + if (isset($this->mocks[$mockName])) { + return $mockName; + } + $mockShortName = lcfirst(str_replace('\\', '', str_replace($this->projectNamespace, '', $className) . self::MOCK_NAME_PREFIX)); + $mockMethodName = MockInterface::METHOD_NAME_PREFIX . ucfirst($mockShortName); + + if (null === $parentAddMockClass || $parentAddMockClass->getName() !== $reflection->getName()) { + [$mock, $mockArgs, $mockTimes] = $this->mockGenerator->generateMockCode($this, $className, $reflection, $level, $mockName, $parentClass, $parentNamespaces); + $this->mocks[$mockName] = ['className' => $className, 'mockShortName' => $mockShortName, 'mockMethodName' => $mockMethodName, 'mock' => $mock, 'mockArgs' => $mockArgs, 'mockTimes' => $mockTimes]; + } else { + $this->mocks[$mockName] = ['className' => $className, 'mockShortName' => $mockShortName, 'mockMethodName' => $mockMethodName, 'mock' => '$mock', 'mockArgs' => '[]', 'mockTimes' => '[]']; + } + + return $mockName; + } + + /** + * Return, if exists, mock structure. + * + * @param string $mockName + * + * @return mixed[]|null + */ + public function getMock(string $mockName): ?array + { + return $this->mocks[$mockName] ?: null; + } + + /** + * Make and return ReflectionClass. + * + * @param string $className + * @param string[] $parentNamespaces + * @param ReflectionMethod $parentMethod + * @param ReflectionClass $parentClass + * + * @return ReflectionClass + * + * @throws CodeExtractException + * @throws FileNotExistsException + * + * @psalm-suppress ArgumentTypeCoercion + */ + private function makeReflectionClass( + string $className, + array $parentNamespaces, + ReflectionMethod $parentMethod, + ReflectionClass $parentClass + ): ReflectionClass { + try { + $reflection = new ReflectionClass($className); + } catch (Throwable $e) { + $alias = $className; + $fullClassName = $className; + + if (strpos('\\', $className)) { + $alias = explode('\\', $className)[0]; + } + + if (isset($parentNamespaces[$alias])) { + $fullClassName = $parentNamespaces[$alias]; + } + + try { + $reflection = new ReflectionClass($fullClassName); + } catch (Throwable $e) { + $fullClassName = $this->getClassNameFromComplexAnnotationName($fullClassName, $parentMethod->getDeclaringClass()); + + try { + $reflection = new ReflectionClass($fullClassName); + } catch (Throwable $e) { + throw new RuntimeException( + sprintf( + 'Class \'%s\' does not exists, creating mock in parent class \'%s\' for method \'%s\'.', + $fullClassName, + $parentClass->getName(), + $parentMethod->getName() + ) + ); + } + } + } + + return $reflection; + } +} diff --git a/src/Generator/Preprocessor/PreprocessorInterface.php b/src/Generator/Preprocessor/PreprocessorInterface.php new file mode 100644 index 0000000..135e1be --- /dev/null +++ b/src/Generator/Preprocessor/PreprocessorInterface.php @@ -0,0 +1,35 @@ +outClassName = $this->parseFullyQualifiedClassName( + $outClassName + ); + + $this->outSourceFile = str_replace( + $this->outClassName['fullyQualifiedClassName'], + $this->outClassName['className'], + $outSourceFile + ); + + $this->modules = $modules; + $this->sourcePath = $sourcePath; + } + + /** + * Generate base test class. + * + * @return string|null + * + * @throws Exception + */ + public function generate(): ?string + { + $oathinfo = pathinfo($this->outSourceFile); + $classTemplate = new Template( + sprintf( + '%s%stemplate%s' . $oathinfo['filename'] . '.tpl', + __DIR__, + DIRECTORY_SEPARATOR, + DIRECTORY_SEPARATOR + ) + ); + $testSuites = $this->getTestsuites($this->modules); + $modules = $this->getModules($this->modules); + + $classTemplate->setVar( + [ + 'testsuites' => $testSuites, + 'modules' => $modules, + 'moduleDir' => $this->sourcePath, + 'testClassName' => $this->outClassName['className'], + 'date' => date('Y-m-d'), + 'time' => date('H:i:s'), + ] + ); + + return $classTemplate->render(); + } + + /** + * Genereate test suites. + * + * @param mixed[] $modules + * + * @return string + */ + public function getTestsuites(array $modules): string + { + $testsuiteModules = []; + + foreach ($modules as $module => $tests) { + $testsuite = ''; + $testsuitefiles = []; + + foreach ($tests as $localTestPath) { + $testsuitefiles[] = '' . $localTestPath . ''; + } + + $testsuite .= "\r\n\t\t\t" . implode("\r\n\t\t\t", $testsuitefiles); + $testsuite .= "\r\n\t\t" . ''; + + $testsuiteModules[] = $testsuite; + } + + return implode("\r\n\t\t", $testsuiteModules); + } + + /** + * Genereate modules array. + * + * @param string[] $modules + * + * @return string + */ + public function getModules(array $modules): string + { + $testSuiteModules = []; + + foreach (array_keys($modules) as $module) { + $testSuiteModules[] = '\'' . $module . '\''; + } + + return implode(', ', $testSuiteModules); + } +} diff --git a/src/Generator/Template.php b/src/Generator/Template.php new file mode 100644 index 0000000..428092c --- /dev/null +++ b/src/Generator/Template.php @@ -0,0 +1,156 @@ +setFile($file); + $this->openDelimiter = $openDelimiter; + $this->closeDelimiter = $closeDelimiter; + } + + /** + * Sets the template file. + * + * @param string $file + * + * @throws Exception + */ + public function setFile(string $file): void + { + $distFile = $file . '.dist'; + + if (file_exists($file)) { + $template = file_get_contents($file); + + if (false === $template) { + throw new CodeExtractException(sprintf('Code can not be extract from source \'%s\'.', $file)); + } + } elseif (file_exists($distFile)) { + $template = file_get_contents($distFile); + + if (false === $template) { + throw new CodeExtractException(sprintf('Code can not be extract from source \'%s\'.', $distFile)); + } + } else { + throw new RuntimeException( + 'Template file could not be loaded.' + ); + } + $this->template = $template; + } + + /** + * Sets one or more template variables. + * + * @param mixed[] $values + * @param bool $merge + */ + public function setVar(array $values, bool $merge = true): void + { + if (!$merge || empty($this->values)) { + $this->values = $values; + } else { + $this->values = array_merge($this->values, $values); + } + } + + /** + * Renders the template and returns the result. + * + * @return string + */ + public function render(): string + { + $keys = []; + + foreach (array_keys($this->values) as $key) { + $keys[] = $this->openDelimiter . $key . $this->closeDelimiter; + } + + return str_replace($keys, $this->values, $this->template); + } + + /** + * Renders the template and writes the result to a file. + * + * @param string $target + */ + public function renderTo(string $target): void + { + $fp = @fopen($target, 'wt'); + + if ($fp) { + fwrite($fp, $this->render()); + fclose($fp); + } else { + $error = error_get_last(); + + if (null === $error) { + $error = ['message' => sprintf('Error with writing into \'%s\' file.', $target)]; + } + + $pos = strpos($error['message'], ':'); + + if (false !== $pos) { + $error['message'] = substr($error['message'], $pos + 2); + } + + throw new RuntimeException(sprintf('Could not write to %s: %s', $target, $error['message'])); + } + } +} diff --git a/src/Generator/TestGenerator.php b/src/Generator/TestGenerator.php new file mode 100644 index 0000000..696d5ea --- /dev/null +++ b/src/Generator/TestGenerator.php @@ -0,0 +1,850 @@ +getFileName(); + + if (false === $inSourceFile) { + $inSourceFile = ''; + } + unset($reflector); + } else { + $possibleFilenames = [ + $inClassName . '.php', + str_replace( + ['_', '\\'], + DIRECTORY_SEPARATOR, + $inClassName + ) . '.php', + ]; + + if (empty($inSourceFile)) { + foreach ($possibleFilenames as $possibleFilename) { + if (is_file($possibleFilename)) { + $inSourceFile = $possibleFilename; + } + } + } + + if (empty($inSourceFile)) { + throw new RuntimeException( + sprintf( + 'Neither \'%s\' nor \'%s\' could be opened.', + $possibleFilenames[0], + $possibleFilenames[1] + ) + ); + } + + if (!is_file($inSourceFile)) { + throw new RuntimeException( + sprintf( + '\'%s\' could not be opened.', + $inSourceFile + ) + ); + } + $inSourceFile = realpath($inSourceFile); + + if (!class_exists($inClassName)) { + throw new RuntimeException( + sprintf( + 'Could not find class \'%s\' in \'%s\'.', + $inClassName, + $inSourceFile + ) + ); + } + } + + if (false === $inSourceFile) { + throw new FileNotExistsException(sprintf('Filename can not be empty.')); + } + + if (empty($outClassName)) { + $outClassName = $inClassName . 'Test'; + } + + if (empty($outSourceFile)) { + $dirname = dirname($inSourceFile); + $outClassNameTree = explode('\\', $outClassName); + $outSourceFile = $dirname . DIRECTORY_SEPARATOR . end($outClassNameTree) . '.php'; + } + + $this->baseNamespace = $baseNamespace; + $this->baseTestNamespace = $baseTestNamespace; + $this->inClassName = $this->parseFullyQualifiedClassName( + $inClassName + ); + $this->outClassName = $this->parseFullyQualifiedClassName( + $outClassName + ); + $this->inSourceFile = str_replace( + $this->inClassName['fullyQualifiedClassName'], + $this->inClassName['className'], + $inSourceFile + ); + $this->outSourceFile = str_replace( + $this->outClassName['fullyQualifiedClassName'], + $this->outClassName['className'], + $outSourceFile + ); + $this->initDataProviderGenerator($dataProviderTestPath, $dataProviderNamespace, $baseNamespace, $projectNamespace); + $this->initMockGenerator($mockTestPath, $mockNamespace, $baseNamespace, $projectNamespace, $mockType); + } + + /** + * Init DataProviderGenerator. + * + * @param string $dataProviderTestPath + * @param string $dataProviderNamespace + * @param string $baseNamespace + * @param string $projectNamespace + */ + private function initDataProviderGenerator( + string $dataProviderTestPath, + string $dataProviderNamespace, + string $baseNamespace, + string $projectNamespace + ): void { + $this->dataProviderGenerator = new DataProviderGenerator( + $dataProviderTestPath, + $dataProviderNamespace, + $baseNamespace, + $projectNamespace + ); + } + + /** + * Init MockGenerator. + * + * @param string $mockTestPath + * @param string $mockNamespace + * @param string $baseNamespace + * @param string $projectNamespace + * @param string $mockType + * + * @throws InvalidMockTypeException + */ + private function initMockGenerator( + string $mockTestPath, + string $mockNamespace, + string $baseNamespace, + string $projectNamespace, + string $mockType + ): void { + $this->mockGenerator = new MockGenerator($mockTestPath, $mockNamespace, $projectNamespace, $mockType); + } + + /** + * Generate test class code. + * + * @return string|null + * + * @throws Exception + * @throws ReflectionException + * @throws ReturnTypeNotFoundException + * + * @psalm-suppress ArgumentTypeCoercion + */ + public function generate(): ?string + { + $reflectionClass = new ReflectionClass( + $this->inClassName['fullyQualifiedClassName'] + ); + + if ($reflectionClass->isAbstract()) { + sprintf( + 'Class \'%s\' is abstract, test reflectionClass will not be generate', + $reflectionClass->getName() + ); + + return null; + } + $traits = $reflectionClass->getTraitNames(); + + if (null !== $traits) { + foreach ($traits as $trait) { + if (!trait_exists($trait)) { + throw new Exception(sprintf('Trait \'%s\' does not exists!', $trait)); + } + } + } + $methods = ''; + $constructArguments = ''; + $constructArgumentsInitialize = ''; + $reflectionClassMethods = $reflectionClass->getMethods(); + + foreach ($reflectionClassMethods as $reflectionMethod) { + if (!$reflectionMethod->isConstructor()) { + continue; + } + [$constructArguments, $constructArgumentsInitialize, ] = $this->processMethodDocComment($reflectionClass, $reflectionMethod); + } + + foreach ($reflectionClassMethods as $reflectionMethod) { + if ($reflectionMethod->isDestructor() || $reflectionMethod->isConstructor()) { + continue; + } + $methodName = $reflectionMethod->getName(); + $methodDeclaringClassName = $reflectionMethod->getDeclaringClass()->getName(); + + if ( + !$reflectionMethod->isAbstract() + && $reflectionMethod->isPublic() + && !in_array(strtolower($methodName), self::EXCLUDE_DECLARING_METHODS, true) + && !in_array(explode('\\', trim($methodDeclaringClassName, '\\'))[0], self::EXCLUDE_DECLARING_CLASSES_FOR_METHODS, true) + ) { + $testMethodCode = $this->renderMethod($reflectionClass, $reflectionMethod, $constructArguments, $constructArgumentsInitialize); + + if (!$testMethodCode) { + continue; + } + $methods .= $testMethodCode; + } + } + $useStatement = []; + $useStatement[] = "\r\nuse " . ltrim($this->baseTestNamespace, '\\') . '\\' . 'UnitTestCase;'; + $useStatement[] = "\r\nuse " . ltrim($this->inClassName['fullyQualifiedClassName'], '\\') . ';'; + $mockTraits = $this->mockGenerator->generate(); + + if (null !== $mockTraits) { + $mockTraits = explode(',', $mockTraits); + + foreach ($mockTraits as &$mockTrait) { + $reflectionMock = new ReflectionClass($mockTrait); + $useStatement[] = "\r\nuse " . $reflectionMock->getName() . ';'; + $mockTrait = $reflectionMock->getShortName(); + } + } + $useTraits = $mockTraits ? "\r\n\tuse " . implode(', ', $mockTraits) . ';' : ''; + $this->dataProviderGenerator->generate(); + $classTemplate = new Template( + sprintf( + '%s%stemplate%sTestClass.tpl', + __DIR__, + DIRECTORY_SEPARATOR, + DIRECTORY_SEPARATOR + ) + ); + sort($useStatement); + $classTemplate->setVar( + [ + 'namespace' => trim($this->outClassName['namespace'], '\\'), + 'testBaseFullClassName' => trim($this->outClassName['testBaseFullClassName'], '\\'), + 'className' => $this->inClassName['className'], + 'fullClassName' => $this->inClassName['fullyQualifiedClassName'], + 'useStatement' => implode('', $useStatement), + 'useTraits' => $useTraits, + 'constructArguments' => $constructArguments, + 'constructArgumentsInitialize' => $constructArgumentsInitialize, + 'testClassName' => $this->outClassName['className'], + 'methods' => $methods, + 'date' => date('Y-m-d'), + 'time' => date('H:i:s'), + ] + ); + + return $classTemplate->render(); + } + + /** + * Render test method. + * + * @param ReflectionClass $class + * @param ReflectionMethod $method + * @param string $constructArguments + * @param string $constructArgumentsInitialize + * + * @return string + * + * @throws CodeExtractException + * @throws FileNotExistsException + * @throws InvalidClassnameException + * @throws MockNotExistsException + * @throws ReflectionException + * @throws ReturnTypeNotFoundException + */ + protected function renderMethod( + ReflectionClass $class, + ReflectionMethod $method, + string $constructArguments, + string $constructArgumentsInitialize + ): ?string { + $argumentsInitialize = ''; + $additional = ''; + $returnType = $this->getMethodReturnType($method); + $methodName = $method->getName(); + + switch ($returnType) { + case DataTypeInterface::TYPE_INT: + case DataTypeInterface::TYPE_INTEGER: + $assertion = 'Equals'; + $template = self::METHOD_TEMPLATE_TYPE_DEFAULT; + $expected = '$mockArgs[\'' . $methodName . '\']'; + + break; + + case DataTypeInterface::TYPE_STRING: + $assertion = 'Equals'; + $template = self::METHOD_TEMPLATE_TYPE_DEFAULT; + $expected = '$mockArgs[\'' . $methodName . '\']'; + + break; + + case DataTypeInterface::TYPE_BOOL: + case DataTypeInterface::TYPE_BOOLEAN: + $assertion = 'True'; + $template = self::METHOD_TEMPLATE_TYPE_BOOL; + $expected = DataTypeInterface::TYPE_BOOL_TRUE; + + break; + + case DataTypeInterface::TYPE_VOID: + $assertion = false; + $template = self::METHOD_TEMPLATE_TYPE_VOID; + $expected = null; + + break; + + case DataTypeInterface::TYPE_ARRAY: + case DataTypeInterface::TYPE_ARRAY_MIXED: + $assertion = 'Equals'; + $template = self::METHOD_TEMPLATE_TYPE_DEFAULT; + $expected = '$mockArgs[\'' . $methodName . '\']'; + + break; + + default: + $typeIsArray = false; + + if (false !== strpos($returnType, '[]')) { + $returnType = $this->getClassNameFromComplexAnnotationName($returnType, $method->getDeclaringClass()); + $typeIsArray = true; + } + + if (!$typeIsArray) { + $assertion = 'InstanceOf'; + $template = self::METHOD_TEMPLATE_TYPE_DEFAULT; + $expected = '\\' . $returnType . '::class'; + } else { + $filename = $class->getFileName(); + $namespaces = $this->getNamespacesFromSource($filename); + $mockName = $this->mockGenerator->addMock($returnType, $class, $method, $namespaces); + $mockStructure = $this->mockGenerator->getMock($mockName); + $assertion = 'Equals'; + $template = self::METHOD_TEMPLATE_TYPE_DEFAULT; + $mockMethodName = $mockStructure['mockMethodName']; + $mockShortName = $mockStructure['mockShortName']; + $argumentsInitialize .= '$' . $methodName . 's = [];'; + $argumentsInitialize .= "\n\r\n\t\t" . 'foreach ($mockArgs[\'' . $methodName . '\'] as $i => $' . $mockShortName . ') {'; + $argumentsInitialize .= "\r\n\t\t\t$" . $methodName . 's[] = $this->' . $mockMethodName . + '($' . $mockShortName . ', $mockTimes[\'' . $methodName . '\'][$i]);' . "\r\n\t\t}\r\n\t\t"; + $expected = '$' . $methodName . 's'; + } + } + + if ($method->isStatic()) { + $template .= 'Static'; + } + $methodTemplate = new Template( + sprintf( + '%s%stemplate%s%s.tpl', + __DIR__, + DIRECTORY_SEPARATOR, + DIRECTORY_SEPARATOR, + $template + ) + ); + $methodName = $this->generateTestMethodName($method, $returnType); + + if (!isset($this->methodNameCounter[$methodName])) { + $this->methodNameCounter[$methodName] = 0; + } + ++$this->methodNameCounter[$methodName]; + + if ($this->methodNameCounter[$methodName] > 1) { + $methodName .= $this->methodNameCounter[$methodName]; + } + + if ($this->testPreprocessor) { + if (!$this->testPreprocessor->isShouldBeTested($class, $method)) { + return null; + } + + $this->testPreprocessor->process($class, $method, $methodName, $additional); + } + [ + $arguments, + $argumentsInitializeFromMethod, + $methodComment, + $dataProviderClassName, + $dataProviderMethodName + ] = $this->processMethodDocComment($class, $method); + $methodTemplate->setVar( + [ + 'arguments' => $arguments, + 'argumentsInitialize' => $argumentsInitialize . $argumentsInitializeFromMethod, + 'assertion' => $assertion, + 'expected' => $expected, + 'additional' => $additional, + 'origMethodName' => $method->getName(), + 'className' => $this->inClassName['className'], + 'fullClassName' => $this->inClassName['fullyQualifiedClassName'], + 'methodName' => $methodName, + 'methodComment' => $methodComment, + 'constructArguments' => $constructArguments, + 'constructArgumentsInitialize' => $constructArgumentsInitialize, + 'dataProviderClassName' => $dataProviderClassName, + 'dataProviderMethodName' => $dataProviderMethodName, + ] + ); + + return $methodTemplate->render(); + } + + /** + * Build test method name. + * + * @param ReflectionMethod $method + * @param string $returnType + * + * @return string + */ + protected function generateTestMethodName(ReflectionMethod $method, string $returnType): string + { + if ($returnType === DataTypeInterface::TYPE_ARRAY_MIXED) { + $returnType = DataTypeInterface::TYPE_ARRAY; + } + $origMethodName = $method->getName(); + $methodName = lcfirst($origMethodName); + $pos = strrpos($returnType, '\\'); + $returnTypeSuffix = $pos ? substr($returnType, ++$pos) : $returnType; + + if ( + false !== strpos('create', $origMethodName) || + false !== strpos('make', $origMethodName) + ) { + return $methodName . 'ShouldInitializeAndReturn' . $returnTypeSuffix; + } + + if (false !== strpos('Event', $returnType)) { + return $methodName . 'ShouldFire' . $returnTypeSuffix; + } + + if ( + false !== strpos('handle', $origMethodName) + ) { + $args = []; + + foreach ($method->getParameters() as $param) { + $args[] = ucfirst($param->getName()); + } + $methodName .= 'ShouldProcess' . implode('', $args); + + if ($returnType === self::METHOD_TEMPLATE_TYPE_VOID) { + return $methodName; + } + + if (class_exists($returnType) || interface_exists($returnType)) { + return $methodName . 'AndReturn' . $returnTypeSuffix; + } + + return $methodName . 'AndReturn' . ucfirst($returnType); + } + + if (class_exists($returnType) || interface_exists($returnType)) { + return $methodName . 'ShouldReturn' . $returnTypeSuffix; + } + + return $methodName . 'ShouldReturn' . ucfirst($returnType); + } + + /** + * Process method doc comment and parse method description, method arguments and code for inititalize all arguments for testing. + * + * @param ReflectionClass $reflectionClass + * @param ReflectionMethod $reflectionMethod + * + * @return string[] + * + * @throws CodeExtractException + * @throws FileNotExistsException + * @throws InvalidClassnameException + * @throws MockNotExistsException + * @throws ReflectionException + * @throws ReturnTypeNotFoundException + * + * @psalm-suppress PossiblyNullReference + */ + protected function processMethodDocComment(ReflectionClass $reflectionClass, ReflectionMethod $reflectionMethod): array + { + $excludeConstructor = false; + $class = $reflectionMethod->getDeclaringClass(); + $methodDeclaringClass = $class->getName(); + + if ( + $reflectionMethod->isConstructor() && + in_array(explode('\\', $methodDeclaringClass)[0], self::EXCLUDE_DECLARING_CLASSES, true) + ) { + $excludeConstructor = true; + } + $filename = $reflectionClass->getFileName(); + + if (false === $filename) { + throw new FileNotExistsException(sprintf('File \'%s\' does not exists.', $reflectionClass->getName())); + } + $namespaces = $this->getNamespacesFromSource($filename); + $methodDocComment = $reflectionMethod->getDocComment(); + $methodComment = 'Execute \'' . $reflectionMethod->getName() . '\' method'; + + if (false !== $methodDocComment) { + preg_match_all('/\* (.*)$/Um', $methodDocComment, $annotationComment); + + if (isset($annotationComment[1][0])) { + $methodComment = trim($annotationComment[1][0], ". \t\n\r\0\x0B"); + } + } + $annotationParams = $this->getParamsFromAnnotation($reflectionMethod, false, true); + $parameters = $reflectionMethod->getParameters(); + $argumentsInitialize = []; + $arguments = []; + $dataProviderMethodName = $this->dataProviderGenerator->addDataProviderMethod($reflectionClass, $reflectionMethod); + + if (null === $annotationParams) { + $annotationParams = []; + } + + foreach ($parameters as $i => $param) { + $this->dataProviderGenerator->addDataProviderArgument($reflectionClass, $param, $dataProviderMethodName); + $setEndColon = true; + $argumentName = '$' . $param->getName(); + $argumentInitialize = '$' . $param->getName() . ' = '; + + if (null !== $param->getType()) { + $annotationParams[$i] = $param->getType()->getName(); + } elseif (isset($annotationParams[$i])) { + $annotationParam = $this->findAndReturnClassNameFromUseStatement($annotationParams[$i], $reflectionClass); + + if (null !== $annotationParam) { + $annotationParams[$i] = $annotationParam; + } + } + + if ($param->isDefaultValueAvailable()) { + $value = $param->getDefaultValue(); + + if ( + (null === $value || false === $value || true === $value) && + (class_exists($annotationParams[$i]) || interface_exists($annotationParams[$i])) + ) { + $className = $annotationParams[$i]; + $mockName = $this->mockGenerator->addMock($className, $reflectionClass, $reflectionMethod, $namespaces); + $mockStructure = $this->mockGenerator->getMock($mockName); + + if (null === $mockStructure) { + throw new MockNotExistsException(sprintf('Mock \'%s\' does not exist.', $mockName)); + } + $pos = strrpos($className, '\\'); + $dataProviderMockArgName = $pos ? substr($className, ++$pos) : $className; + $mockMethodName = $mockStructure['mockMethodName']; + $mockShortName = $mockStructure['mockShortName']; + $argumentName = '$' . $mockShortName; + $argumentInitialize = '$' . $mockShortName . ' = $this->' . $mockMethodName . '($mockArgs[\'' . $dataProviderMockArgName . '\'], $mockTimes[\'' . $dataProviderMockArgName . '\']);'; + $setEndColon = false; + } elseif (null === $value) { + $argumentInitialize .= 'null'; + } elseif (false === $value) { + $argumentInitialize .= 'false'; + } elseif (true === $value) { + $argumentInitialize .= 'true'; + } elseif (is_numeric($value) || is_float($value)) { + $argumentInitialize .= $value; + } elseif (is_array($value)) { + $tmpValue = []; + + foreach ($value as $key => $val) { + $key = is_numeric($key) ? $key : '\'' . $key . '\''; + $val = is_numeric($val) || is_float($val) ? $val : '\'' . $val . '\''; + $tmpValue[] = $key . ' => ' . $val; + } + $argumentInitialize .= '[' . implode(', ', $tmpValue) . ']'; + } else { + $argumentInitialize .= '\'' . $value . '\''; + } + } else { + if (!isset($annotationParams[$i]) && + in_array(explode('\\', $methodDeclaringClass)[0], self::EXCLUDE_DECLARING_CLASSES_FOR_METHODS, true) + ) { + $annotationParams[$i] = null; + } + + if (!array_key_exists($i, $annotationParams)) { + if ('{@inheritdoc}' === $methodComment) { + if ($reflectionMethod->getDeclaringClass()->isInterface()) { + throw new Exception(sprintf( + 'In interface \'%s\' in method \'%s\' for argument \'%s\' annotation not exists.', + $reflectionClass->getName(), + $reflectionMethod->getName(), + $param->getName() + )); + } + $interfaces = $reflectionMethod->getDeclaringClass()->getInterfaces(); + + foreach ($interfaces as $interface) { + try { + $parentMethod = $interface->getMethod($reflectionMethod->getName()); + + return $this->processMethodDocComment($interface, $parentMethod); + break; + } catch (ReflectionException $e) { + continue; + } catch (RuntimeException $e) { + break; + } + } + $parentClass = $reflectionMethod->getDeclaringClass()->getParentClass(); + + if ($parentClass) { + $parentMethod = $parentClass->getMethod($reflectionMethod->getName()); + + return $this->processMethodDocComment($parentClass, $parentMethod); + } + } + + throw new RuntimeException( + sprintf( + 'In class \'%s\' in method \'%s\' for argument \'%s\' annotation not exists.', + $reflectionClass->getName(), + $reflectionMethod->getName(), + $param->getName() + ) + ); + } + + if (null !== $annotationParams[$i]) { + $annotationParams[$i] = trim($annotationParams[$i]); + + if (false !== strpos($annotationParams[$i], '|')) { + $annotationParams[$i] = explode('|', $annotationParams[$i])[0]; + } + } + + switch ($annotationParams[$i]) { + case DataTypeInterface::TYPE_INT: + case DataTypeInterface::TYPE_INTEGER: + case DataTypeInterface::TYPE_FLOAT: + case DataTypeInterface::TYPE_STRING: + case DataTypeInterface::TYPE_BOOL: + case DataTypeInterface::TYPE_BOOLEAN: + case DataTypeInterface::TYPE_MIXED: + case DataTypeInterface::TYPE_ARRAY: + case DataTypeInterface::TYPE_ARRAY_MIXED: + $argumentInitialize .= '$mockArgs[\'' . $param->getName() . '\']'; + break; + + case DataTypeInterface::TYPE_CLOSURE: + $argumentInitialize .= 'function () { return true; }'; + + break; + + default: + if ( + false === $excludeConstructor && + empty($annotationParams[$i]) + ) { + throw new RuntimeException( + sprintf( + 'Could not find param type for param \'%s\' in method \'%s\' in \'%s\'.', + $param->getName(), + $reflectionMethod->getName(), + $reflectionClass->getName() + ) + ); + } + + if (!$excludeConstructor && $param->getClass()) { + $className = $param->getClass() ?: $annotationParams[$i]; + + if (null === $className) { + throw new InvalidClassnameException(sprintf('Class name could not be null.')); + } + $classNameTmp = ($className instanceof ReflectionClass) ? $className->getName() : $className; + $pos = strrpos($classNameTmp, '\\'); + $dataProviderMockArgName = $pos ? substr($classNameTmp, ++$pos) : $classNameTmp; + $mockName = $this->mockGenerator->addMock($className, $reflectionClass, $reflectionMethod, $namespaces); + $mockStructure = $this->mockGenerator->getMock($mockName); + + if (null === $mockStructure) { + throw new MockNotExistsException(sprintf('Mock \'%s\' does not exist.', $mockName)); + } + $mockMethodName = $mockStructure['mockMethodName']; + $mockShortName = $mockStructure['mockShortName']; + $argumentName = '$' . $mockShortName; + $argumentInitialize = '$' . $mockShortName . ' = $this->' . $mockMethodName . '($mockArgs[\'' . $dataProviderMockArgName . '\'], $mockTimes[\'' . $dataProviderMockArgName . '\']);'; + $setEndColon = false; + } else { + $argumentInitialize .= '$mockArgs[\'' . $param->getName() . '\']'; + } + } + } + + if ($setEndColon) { + $argumentInitialize .= ';'; + } + $arguments[] = $argumentName; + $argumentsInitialize[] = $argumentInitialize; + } + $arguments = implode(', ', $arguments); + $argumentsInitialize = implode("\r\n\t\t", $argumentsInitialize); + $dataProviderName = $this->dataProviderGenerator->getDataProviderName($reflectionClass->getName()); + $dataProviderConfig = $this->dataProviderGenerator->getDataProvider($dataProviderName); + $dataProviderClassName = $dataProviderConfig['dataProviderFullClassName']; + + return [$arguments, $argumentsInitialize, $methodComment, $dataProviderClassName, $dataProviderMethodName]; + } + + /** + * Set preprocessor test generator. + * + * @return PreprocessorInterface + */ + public function getTestPreprocessor(): PreprocessorInterface + { + return $this->testPreprocessor; + } + + /** + * Get preprocessor test generator. + * + * @param PreprocessorInterface $testPreprocessor + */ + public function setTestPreprocessor(PreprocessorInterface $testPreprocessor): void + { + $this->testPreprocessor = $testPreprocessor; + } +} diff --git a/src/Generator/template/Class.tpl.dist b/src/Generator/template/Class.tpl.dist new file mode 100644 index 0000000..ae4a0d0 --- /dev/null +++ b/src/Generator/template/Class.tpl.dist @@ -0,0 +1,14 @@ +markTestIncomplete( + 'This test has not been implemented yet.' + ); + } diff --git a/src/Generator/template/Method.tpl.dist b/src/Generator/template/Method.tpl.dist new file mode 100644 index 0000000..dd3b044 --- /dev/null +++ b/src/Generator/template/Method.tpl.dist @@ -0,0 +1,9 @@ + + /** + * @todo Implement {methodName}(). + */ + public function {methodName}() + { + // Remove the following line when you implement this method. + throw new RuntimeException('Not yet implemented.'); + } diff --git a/src/Generator/template/MockMethodMockery.tpl.dist b/src/Generator/template/MockMethodMockery.tpl.dist new file mode 100644 index 0000000..bd1dbff --- /dev/null +++ b/src/Generator/template/MockMethodMockery.tpl.dist @@ -0,0 +1,16 @@ + + /** + * Create and return mock object for class {mockClassName} + * + * @param mixed[] $mockArgs + * @param mixed[] $mockTimes + * + * @return \{mockInterface}|\{mockClassName} + */ + protected function {mockMethodName}( + array $mockArgs = {mockArgs}, + array $mockTimes = {mockTimes} + ): \{mockInterface} { + {mock} + return $mock; + } diff --git a/src/Generator/template/MockMethodPhpUnit.tpl.dist b/src/Generator/template/MockMethodPhpUnit.tpl.dist new file mode 100644 index 0000000..f673959 --- /dev/null +++ b/src/Generator/template/MockMethodPhpUnit.tpl.dist @@ -0,0 +1,11 @@ + + /** + * Create and return mock object for class {mockClassName} + * + * @return \{mockInterface}|\{mockClassName} + */ + protected function {mockMethodName}() + { + {mock} + return $mock; + } diff --git a/src/Generator/template/TestBaseClass.tpl.dist b/src/Generator/template/TestBaseClass.tpl.dist new file mode 100644 index 0000000..6b3eeda --- /dev/null +++ b/src/Generator/template/TestBaseClass.tpl.dist @@ -0,0 +1,20 @@ +{origMethodName}({arguments}); + self::assert{assertion}({expected}, $result);{additional} + } diff --git a/src/Generator/template/TestMethodBool.tpl.dist b/src/Generator/template/TestMethodBool.tpl.dist new file mode 100644 index 0000000..d037968 --- /dev/null +++ b/src/Generator/template/TestMethodBool.tpl.dist @@ -0,0 +1,23 @@ + + /** + * Test for "{methodComment}". + * + * @test + * + * @group unit + * + * @covers {fullClassName}::{origMethodName} + * + * @dataProvider {dataProviderClassName}::{dataProviderMethodName}() + * + * @param mixed[] $mockArgs + * @param mixed[] $mockTimes + */ + public function {methodName}Test(array $mockArgs, array $mockTimes): void + { + {constructArgumentsInitialize} + $test = new {className}({constructArguments}); + {argumentsInitialize} + $result = $test->{origMethodName}({arguments}); + self::assert{assertion}($result);{additional} + } diff --git a/src/Generator/template/TestMethodBoolStatic.tpl.dist b/src/Generator/template/TestMethodBoolStatic.tpl.dist new file mode 100644 index 0000000..dbf14d5 --- /dev/null +++ b/src/Generator/template/TestMethodBoolStatic.tpl.dist @@ -0,0 +1,22 @@ + + /** + * Test for "{methodComment}". + * + * @test + * + * @group unit + * + * @covers {fullClassName}::{origMethodName} + * + * @dataProvider {dataProviderClassName}::{dataProviderMethodName}() + * + * @param mixed[] $mockArgs + * @param mixed[] $mockTimes + */ + public function {methodName}Test(array $mockArgs, array $mockTimes): void + { + {argumentsInitialize} + self::assert{assertion}( + {className}::{origMethodName}({arguments}) + );{additional} + } diff --git a/src/Generator/template/TestMethodException.tpl.dist b/src/Generator/template/TestMethodException.tpl.dist new file mode 100644 index 0000000..6d1cae4 --- /dev/null +++ b/src/Generator/template/TestMethodException.tpl.dist @@ -0,0 +1,23 @@ + + /** + * Test for "{methodComment}". + * + * @test + * + * @group unit + * + * @covers {fullClassName}::{origMethodName} + * @expectedException {expected} + * + * @dataProvider {dataProviderClassName}::{dataProviderMethodName}() + * + * @param mixed[] $mockArgs + * @param mixed[] $mockTimes + */ + public function {methodName}Test(array $mockArgs, array $mockTimes): void + { + {constructArgumentsInitialize} + $test = new {className}({constructArguments}); + {argumentsInitialize} + $test->{origMethodName}({arguments});{additional} + } diff --git a/src/Generator/template/TestMethodExceptionStatic.tpl.dist b/src/Generator/template/TestMethodExceptionStatic.tpl.dist new file mode 100644 index 0000000..557f911 --- /dev/null +++ b/src/Generator/template/TestMethodExceptionStatic.tpl.dist @@ -0,0 +1,21 @@ + + /** + * Test for "{methodComment}". + * + * @test + * + * @group unit + * + * @covers {fullClassName}::{origMethodName} + * @expectedException {expected} + * + * @dataProvider {dataProviderClassName}::{dataProviderMethodName}() + * + * @param mixed[] $mockArgs + * @param mixed[] $mockTimes + */ + public function {methodName}Test(array $mockArgs, array $mockTimes): void + { + {argumentsInitialize} + {className}::{origMethodName}({arguments});{additional} + } diff --git a/src/Generator/template/TestMethodStatic.tpl.dist b/src/Generator/template/TestMethodStatic.tpl.dist new file mode 100644 index 0000000..4a79468 --- /dev/null +++ b/src/Generator/template/TestMethodStatic.tpl.dist @@ -0,0 +1,21 @@ + + /** + * Test for "{methodComment}". + * + * @test + * + * @group unit + * + * @covers {fullClassName}::{origMethodName} + * + * @dataProvider {dataProviderClassName}::{dataProviderMethodName}() + * + * @param mixed[] $mockArgs + * @param mixed[] $mockTimes + */ + public function {methodName}Test(array $mockArgs, array $mockTimes): void + { + {argumentsInitialize} + $test = {className}::{origMethodName}({arguments}); + self::assert{assertion}({expected}, $test);{additional} + } diff --git a/src/Generator/template/TestMethodVoid.tpl.dist b/src/Generator/template/TestMethodVoid.tpl.dist new file mode 100644 index 0000000..db55b51 --- /dev/null +++ b/src/Generator/template/TestMethodVoid.tpl.dist @@ -0,0 +1,22 @@ + + /** + * Test for "{methodComment}". + * + * @test + * + * @group unit + * + * @covers {fullClassName}::{origMethodName} + * + * @dataProvider {dataProviderClassName}::{dataProviderMethodName}() + * + * @param mixed[] $mockArgs + * @param mixed[] $mockTimes + */ + public function {methodName}Test(array $mockArgs, array $mockTimes): void + { + {constructArgumentsInitialize} + $test = new {className}({constructArguments}); + {argumentsInitialize} + $test->{origMethodName}({arguments});{additional} + } diff --git a/src/Generator/template/TestMethodVoidStatic.tpl.dist b/src/Generator/template/TestMethodVoidStatic.tpl.dist new file mode 100644 index 0000000..6f96078 --- /dev/null +++ b/src/Generator/template/TestMethodVoidStatic.tpl.dist @@ -0,0 +1,20 @@ + + /** + * Test for "{methodComment}". + * + * @test + * + * @group unit + * + * @covers {fullClassName}::{origMethodName} + * + * @dataProvider {dataProviderClassName}::{dataProviderMethodName}() + * + * @param mixed[] $mockArgs + * @param mixed[] $mockTimes + */ + public function {methodName}Test(array $mockArgs, array $mockTimes): void + { + {argumentsInitialize} + {className}::{origMethodName}({arguments});{additional} + } diff --git a/src/Generator/template/Trait.tpl.dist b/src/Generator/template/Trait.tpl.dist new file mode 100644 index 0000000..1fc5e4d --- /dev/null +++ b/src/Generator/template/Trait.tpl.dist @@ -0,0 +1,13 @@ +registerNamespaces($modulesNamespaces); + +$loader->register(); \ No newline at end of file diff --git a/src/Generator/template/phpunit.tpl.dist b/src/Generator/template/phpunit.tpl.dist new file mode 100644 index 0000000..fee1186 --- /dev/null +++ b/src/Generator/template/phpunit.tpl.dist @@ -0,0 +1,32 @@ + + + + + {testsuites} + + + + + slow + + + + + + ../vendor + + ../vendor/autoload.php + + + + \ No newline at end of file diff --git a/src/Service/TestClass.php b/src/Service/TestClass.php new file mode 100644 index 0000000..37beb48 --- /dev/null +++ b/src/Service/TestClass.php @@ -0,0 +1,325 @@ +psrNamespaceType = $psrNamespaceType; + $this->sourceFolderName = $sourceFolderName; + $this->sourceTestFolder = $sourceTestFolder; + $this->unitTestFolder = $unitTestFolder; + } + + /** + * Generate test. + * + * @param string $className + * + * @throws Exception + */ + public function generate(string $className): void + { + if (file_exists($className)) { + $this->sourcePath = $className; + } elseif (class_exists($className)) { + $filename = (new ReflectionClass($className))->getFileName(); + + if (false === $filename) { + throw new FileNotExistsException(sprintf('File \'%s\' does not exists.', $className)); + } + $this->sourcePath = $filename; + } else { + throw new RuntimeException(sprintf("Source '%s' doesn't exists!", $className)); + } + + $item = new SplFileInfo($this->sourcePath); + $this->generateTest($item); + } + + /** + * Generate skeleton for source class. + * + * @param SplFileInfo $item + * + * @throws Exception + */ + protected function generateTest(SplFileInfo $item): void + { + $sourcePath = $item->getPath(); + $sourceFileName = $item->getFilename(); + $sourceClassName = $this->getClassName($sourcePath . DIRECTORY_SEPARATOR . $sourceFileName); + + if (!$sourceClassName) { + return; + } + $testPathCredentials = $this->getTestPathFromSource($sourcePath, $sourceClassName['namespace']); + + if ( + !file_exists($testPathCredentials['testPath']) && + !@mkdir($testPathCredentials['testPath'], 0755, true) && + !is_dir($testPathCredentials['testPath']) + ) { + throw new Exception(sprintf('Directory \'%s\' was not created!', $testPathCredentials['testPath'])); + } + $generator = new TestGenerator( + '\\' . $sourceClassName['namespace'] . '\\' . $sourceClassName['class'], + $sourcePath . DIRECTORY_SEPARATOR . $sourceFileName, + '\\' . $testPathCredentials['testNamespace'] . '\\' . $sourceClassName['class'] . self::DEFAULT_TEST_PREFIX, + $testPathCredentials['testPath'] . DIRECTORY_SEPARATOR . $item->getBasename('.' . $item->getExtension()) . self::DEFAULT_TEST_PREFIX . '.' . $item->getExtension(), + $testPathCredentials['baseNamespace'], + $testPathCredentials['baseTestNamespace'], + MockGenerator::MOCK_TYPE_MOCKERY, + $testPathCredentials['dataProviderTestPath'], + $testPathCredentials['dataProviderNamespace'], + $testPathCredentials['mockTestPath'], + $testPathCredentials['mockNamespace'], + $testPathCredentials['projectNamespace'] + ); + + $testPreprocessor = $this->getTestPreprocessor($sourceClassName['namespace'] . '\\' . $sourceClassName['class']); + + if ($testPreprocessor) { + $generator->setTestPreprocessor($this->getTestPreprocessor($sourceClassName['namespace'] . '\\' . $sourceClassName['class'])); + } + $generator->write(); + $this->localTestsPath[] = $testPathCredentials['localTestPath'] . DIRECTORY_SEPARATOR . $sourceFileName; + } + + /** + * Analyze source path and return namespace and origin class name. + * + * @param string $sourceFilePath + * + * @return mixed[] + * + * @throws Exception + */ + protected function getClassName(string $sourceFilePath): array + { + $namespace = 0; + $sourceCode = file_get_contents($sourceFilePath); + + if (false === $sourceCode) { + throw new CodeExtractException(sprintf('Code can not be extract from source \'%s\'.', $sourceFilePath)); + } + $tokens = token_get_all($sourceCode); + $count = count($tokens); + $dlm = false; + $class = []; + + for ($i = 2; $i < $count; ++$i) { + if ((isset($tokens[$i - 2][1]) && ('phpnamespace' === $tokens[$i - 2][1] || 'namespace' === $tokens[$i - 2][1])) || + ($dlm && T_NS_SEPARATOR === $tokens[$i - 1][0] && T_STRING === $tokens[$i][0]) + ) { + if (!$dlm) { + $namespace = 0; + } + + if (isset($tokens[$i][1])) { + $namespace = $namespace ? $namespace . '\\' . $tokens[$i][1] : $tokens[$i][1]; + $dlm = true; + } + } elseif ($dlm && (T_NS_SEPARATOR !== $tokens[$i][0]) && (T_STRING !== $tokens[$i][0])) { + $dlm = false; + } + + if ((T_CLASS === $tokens[$i - 2][0] || (isset($tokens[$i - 2][1]) && 'phpclass' === $tokens[$i - 2][1])) + && T_WHITESPACE === $tokens[$i - 1][0] && T_STRING === $tokens[$i][0]) { + $class['namespace'] = $namespace; + $class['class'] = $tokens[$i][1]; + } + } + + return $class; + } + + /** + * Generate test folder for new tests. + * + * @param string $sourcePath + * @param string $namespace + * + * @return string[] + */ + private function getTestPathFromSource(string $sourcePath, string $namespace): array + { + $namespace = trim(str_replace('\\', DIRECTORY_SEPARATOR, $namespace), DIRECTORY_SEPARATOR); + $sourcePath = trim($sourcePath, DIRECTORY_SEPARATOR); + $testFolderPosition = 1; + + switch ($this->psrNamespaceType) { + case self::PSR_NAMESPACE_TYPE_1: + $sourcePath = str_replace($namespace, '', $sourcePath); + + break; + + case self::PSR_NAMESPACE_TYPE_4: + $treePath = array_reverse(explode(DIRECTORY_SEPARATOR, $sourcePath)); + $treeNamespace = array_reverse(explode(DIRECTORY_SEPARATOR, $namespace)); + $sourcePath = []; + $testFolderPosition = count($treePath) + 1; + + foreach ($treePath as $i => $part) { + if ($part === $treeNamespace[$i]) { + --$testFolderPosition; + + continue; + } + array_unshift($sourcePath, $part); + } + $sourcePath = implode(DIRECTORY_SEPARATOR, $sourcePath); + + break; + } + + $treePath = explode(DIRECTORY_SEPARATOR, $sourcePath); + $localTestPath = ''; + $step = 1; + $testPath = implode(DIRECTORY_SEPARATOR, array_slice($treePath, 0, count($treePath) - $step)); + $testPath .= DIRECTORY_SEPARATOR . $this->sourceTestFolder . DIRECTORY_SEPARATOR . $this->unitTestFolder; + $dataProviderTestPath = $testPath . DIRECTORY_SEPARATOR . self::DEFAULT_DATA_PROVIDER_NAMESPACE; + $mockTestPath = $testPath . DIRECTORY_SEPARATOR . self::DEFAULT_MOCK_NAMESPACE; + $namespace = explode(DIRECTORY_SEPARATOR, $namespace); + $baseNamespace = implode('\\', array_slice($namespace, $testFolderPosition)); + $basePath = implode(DIRECTORY_SEPARATOR, array_slice($namespace, $testFolderPosition)); + $projectNamespace = implode('\\', array_slice($namespace, 0, $testFolderPosition)); + $dataProviderNamespace = $projectNamespace . '\\' . self::DEFAULT_TEST_NAMESPACE . '\\' . $this->unitTestFolder . '\\' . self::DEFAULT_DATA_PROVIDER_NAMESPACE; + $mockNamespace = $projectNamespace . '\\' . self::DEFAULT_TEST_NAMESPACE . '\\' . $this->unitTestFolder . '\\' . self::DEFAULT_MOCK_NAMESPACE; + $baseTestNamespace = $projectNamespace . '\\' . self::DEFAULT_TEST_NAMESPACE . '\\' . $this->unitTestFolder; + array_splice($namespace, $testFolderPosition, 0, [self::DEFAULT_TEST_NAMESPACE, $this->unitTestFolder]); + $testNamespace = implode('\\', $namespace); + $localTestPath .= self::DEFAULT_TEST_NAMESPACE . DIRECTORY_SEPARATOR . $this->unitTestFolder; + $testPath .= DIRECTORY_SEPARATOR . $basePath; + $localTestPath .= DIRECTORY_SEPARATOR . $basePath; + + return [ + 'testPath' => DIRECTORY_SEPARATOR . $testPath, + 'localTestPath' => $localTestPath, + 'baseTestNamespace' => $baseTestNamespace, + 'testNamespace' => $testNamespace, + 'baseNamespace' => $baseNamespace, + 'dataProviderTestPath' => DIRECTORY_SEPARATOR . $dataProviderTestPath, + 'dataProviderNamespace' => $dataProviderNamespace, + 'mockTestPath' => DIRECTORY_SEPARATOR . $mockTestPath, + 'mockNamespace' => $mockNamespace, + 'projectNamespace' => $projectNamespace, + ]; + } + + /** + * Set preprocessor test generator. + * + * @param string $className + * + * @return PreprocessorInterface + */ + public function getTestPreprocessor(string $className): ?PreprocessorInterface + { + return $this->testPreprocessors[$className] ?? null; + } + + /** + * Get preprocessor test generator. + * + * @param string $className + * @param PreprocessorInterface $testPreprocessor + */ + public function setTestPreprocessor(string $className, PreprocessorInterface $testPreprocessor): void + { + $this->testPreprocessors[$className] = $testPreprocessor; + } +} diff --git a/src/Service/TestProject.php b/src/Service/TestProject.php new file mode 100644 index 0000000..a76171a --- /dev/null +++ b/src/Service/TestProject.php @@ -0,0 +1,135 @@ +sourcePath = $sourcePath; + $this->excludeFolders = $excludeFolders; + } + + /** + * Generate tests. + * + * @throws Exception + */ + public function generate(): void + { + if (!file_exists($this->sourcePath)) { + throw new Exception("Source file '{$this->sourcePath}' doesn't exists!"); + } + $modules = $this->scanProjectSourceFolder($this->sourcePath, true); + + foreach ($modules as $folder) { + foreach ($folder as $file) { + $testClass = new TestClass(); + $testClass->generate($file); + } + } + } + + /** + * Scan all subfolders in the source project directory and generate test for all classes. + * + * @param string $projectSourcePath + * @param bool $deepLevel + * + * @return mixed[] + * + * @throws Exception + */ + protected function scanProjectSourceFolder(string $projectSourcePath, bool $deepLevel = false): array + { + $folders = scandir($projectSourcePath); + + if (false === $folders) { + throw new NotDirectoryException('Project source path is not a directory.'); + } + $srcFolders = []; + + foreach ($folders as $folder) { + if ('.' === $folder || '..' === $folder || in_array($folder, $this->excludeFolders, true)) { + continue; + } + $sourcePath = $projectSourcePath . DIRECTORY_SEPARATOR . $folder; + + if (!is_dir($sourcePath)) { + continue; + } + + if ($deepLevel) { + $subFolders = $this->scanProjectSourceFolder($sourcePath); + $srcFolders[$folder] = array_merge(...array_values($subFolders)); + } else { + $srcFolders[$folder] = $this->scanSources($sourcePath); + } + } + + return $srcFolders; + } + + /** + * Scan all subfolders in the source project directory and generate test for all classes. + * + * @param string $sourcePath + * + * @return mixed[] + * + * @throws Exception + */ + protected function scanSources(string $sourcePath): array + { + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($sourcePath, FilesystemIterator::SKIP_DOTS)); + $files = []; + + foreach ($iterator as $item) { + if (!$item instanceof SplFileInfo) { + continue; + } + + if ('php' !== $item->getExtension()) { + continue; + } + + $files[] = $item->getRealPath(); + } + + return $files; + } +}