diff --git a/.gitignore b/.gitignore index 011ca6a2b1dfdc7ab4976d54267efeb7791b020d..6f1c63a9a6daad8eedc3c66effe8392d1e6879e4 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ composer.lock .vscode node_modules .user.ini +.phpunit.result.cache \ No newline at end of file diff --git a/application/command.php b/application/command.php index ab4178a367a3e8cc6cb238a9538d17f85785980f..38976002bb28bec81401f028dff80a9fc3597fcf 100755 --- a/application/command.php +++ b/application/command.php @@ -17,4 +17,5 @@ return [ 'app\admin\command\Min', 'app\admin\command\Addon', 'app\admin\command\Api', + 'app\common\command\Test', ]; diff --git a/application/common/command/Test.php b/application/common/command/Test.php new file mode 100644 index 0000000000000000000000000000000000000000..2d131bae0e1781d21971d151ed4539864f19877e --- /dev/null +++ b/application/common/command/Test.php @@ -0,0 +1,37 @@ + +// +---------------------------------------------------------------------- + +namespace app\common\command; + +use PHPUnit\TextUI\Command; +use think\console\Command as BaseCommand; +use think\console\Input; +use think\console\Output; +use think\Session; + +class Test extends BaseCommand +{ + public function configure() + { + $this->setName('unit')->setDescription('phpunit')->ignoreValidationErrors(); + } + + public function execute(Input $input, Output $output): ?int + { + Session::init(); + $argv = $_SERVER['argv']; + array_shift($argv); + array_shift($argv); + array_unshift($argv, 'phpunit'); + return (new Command())->run($argv, false); + } + +} \ No newline at end of file diff --git a/composer.json b/composer.json index aa6245cb9782af5a3fb2572a6a899cfc64bbc558..463c2107c338feaad6a8ca25cf46291294b297a7 100755 --- a/composer.json +++ b/composer.json @@ -14,6 +14,11 @@ "email": "karson@fastadmin.net" } ], + "autoload-dev": { + "psr-4": { + "tests\\": "tests/" + } + }, "require": { "php": ">=7.2.0", "topthink/framework": "dev-master", @@ -40,5 +45,9 @@ "type": "git", "url": "https://gitee.com/fastadminnet/framework.git" } - ] + ], + "require-dev": { + "symfony/dom-crawler": "^5.4", + "phpunit/phpunit": "^8.5" + } } diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000000000000000000000000000000000000..43f1f2b16df7765af9160f65e667478537fb0dfc --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,24 @@ + + + + + ./tests/Unit + + + ./tests/Feature + + + + + ./application + + + \ No newline at end of file diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000000000000000000000000000000000000..5669d10b5c2ccba954bd5871dcbc446bbf2fc490 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,17 @@ +visit('/')->seeStatusCode(200); + } +} \ No newline at end of file diff --git a/tests/helpers/ApplicationTrait.php b/tests/helpers/ApplicationTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..d291d0a229be1de953a4a1a9920e98e6008fb77b --- /dev/null +++ b/tests/helpers/ApplicationTrait.php @@ -0,0 +1,53 @@ + +// +---------------------------------------------------------------------- +namespace tests\helpers; + +use think\Db; +use think\Session; + +trait ApplicationTrait +{ + public function withSession(array $data): self + { + foreach ($data as $key => $value) { + Session::set($key, $value); + } + return $this; + } + + public function clearSession() + { + Session::clear(); + } + + + protected function seeInDatabase($table, array $data): self + { + $count = Db::name($table)->where($data)->count(); + + $this->assertGreaterThan(0, $count, sprintf( + 'Unable to find row in database table [%s] that matched attributes [%s].', $table, json_encode($data) + )); + + return $this; + } + + protected function notSeeInDatabase($table, array $data): self + { + $count = Db::name($table)->where($data)->count(); + + $this->assertEquals(0, $count, sprintf( + 'Found unexpected records in database table [%s] that matched attributes [%s].', $table, json_encode($data) + )); + + return $this; + } +} \ No newline at end of file diff --git a/tests/helpers/AssertionsTrait.php b/tests/helpers/AssertionsTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..06d15bcb58e786005b324e2ef58cac3392a8372a --- /dev/null +++ b/tests/helpers/AssertionsTrait.php @@ -0,0 +1,100 @@ + +// +---------------------------------------------------------------------- +namespace tests\helpers; + +use think\response\View; +use think\Session; +use think\Url; + +trait AssertionsTrait +{ + public function assertResponseOk() + { + $actual = $this->response->getCode(); + + $this->assertTrue(200 == $actual, "Expected status code 200, got {$actual}."); + } + + public function assertResponseStatus($code) + { + $actual = $this->response->getCode(); + + $this->assertEquals($code, $actual, "Expected status code {$code}, got {$actual}."); + } + + public function assertViewHas($key, $value = null) + { + if (is_array($key)) { + $this->assertViewHasAll($key); + } else { + if (!$this->response instanceof View) { + $this->assertTrue(false, 'The response was not a view.'); + } else { + if (is_null($value)) { + $this->assertArrayHasKey($key, $this->response->getVars()); + } else { + $this->assertEquals($value, $this->response->getVars($key)); + } + } + } + } + + public function assertViewHasAll(array $bindings) + { + foreach ($bindings as $key => $value) { + if (is_int($key)) { + $this->assertViewHas($value); + } else { + $this->assertViewHas($key, $value); + } + } + } + + public function assertViewMissing($key) + { + if (!$this->response instanceof View) { + $this->assertTrue(false, 'The response was not a view.'); + } else { + $this->assertArrayNotHasKey($key, $this->response->getVars()); + } + } + + public function assertRedirectedTo($uri, $params = []) + { + $this->assertInstanceOf('think\response\Redirect', $this->response); + + $this->assertEquals(Url::build($uri, $params), $this->response->getTargetUrl()); + } + + public function assertSessionHas($key, $value = null) + { + if (is_array($key)) { + $this->assertSessionHasAll($key); + } else { + if (is_null($value)) { + $this->assertTrue(Session::has($key), "Session missing key: $key"); + } else { + $this->assertEquals($value, Session::get($key)); + } + } + } + + public function assertSessionHasAll(array $bindings) + { + foreach ($bindings as $key => $value) { + if (is_int($key)) { + $this->assertSessionHas($value); + } else { + $this->assertSessionHas($key, $value); + } + } + } +} \ No newline at end of file diff --git a/tests/helpers/CrawlerTrait.php b/tests/helpers/CrawlerTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..ce84817324f36b57020ad631799ba47b7588e2a0 --- /dev/null +++ b/tests/helpers/CrawlerTrait.php @@ -0,0 +1,245 @@ + +// +---------------------------------------------------------------------- +namespace tests\helpers; + +use think\App; +use think\Cookie; +use think\Error; +use think\Exception; +use think\helper\Arr; +use think\helper\Str; +use think\Request; +use think\Response; +use Throwable; + +trait CrawlerTrait +{ + use InteractsWithPages; + + protected $currentUri; + + protected $serverVariables = []; + + /** @var Response */ + protected $response; + + + public function get($uri, array $headers = []): self + { + $server = $this->transformHeadersToServerVars($headers); + + $this->call('GET', $uri, [], [], [], $server); + + return $this; + } + + public function post($uri, array $data = [], array $headers = []): self + { + $server = $this->transformHeadersToServerVars($headers); + + $this->call('POST', $uri, $data, [], [], $server); + + return $this; + } + + public function put($uri, array $data = [], array $headers = []): self + { + $server = $this->transformHeadersToServerVars($headers); + + $this->call('PUT', $uri, $data, [], [], $server); + + return $this; + } + + public function delete($uri, array $data = [], array $headers = []): self + { + $server = $this->transformHeadersToServerVars($headers); + + $this->call('DELETE', $uri, $data, [], [], $server); + + return $this; + } + + + public function call($method, $uri, $parameters = [], $cookies = [], $files = [], $server = [], $content = null): Response + { + $this->currentUri = $this->prepareUrlForRequest($uri); + + $request = Request::create( + $this->currentUri, $method, $parameters, + $cookies, $files, array_replace($this->serverVariables, $server) + ); + try { + $response = App::run($request); + } catch (Exception|Throwable $e) { + $response = Error::getExceptionHandler()->render($e); + } + + return $this->response = $response; + } + + + public function seeJson($data = null, $negate = false) + { + if (is_null($data)) { + $this->assertJson( + $this->response->getContent(), "JSON was not returned from [{$this->currentUri}]." + ); + + return $this; + } + + return $this->seeJsonContains($data, $negate); + } + + public function seeJsonEquals(array $data): self + { + $actual = json_encode(Arr::sortRecursive( + json_decode($this->response->getContent(), true) + )); + + $this->assertEquals(json_encode(Arr::sortRecursive($data)), $actual); + + return $this; + } + + protected function seeJsonContains(array $data, $negate = false): self + { + $method = $negate ? 'assertFalse' : 'assertTrue'; + + $actual = json_decode($this->response->getContent(), true); + + if (is_null($actual) || $actual === false) { + $this->fail('Invalid JSON was returned from the route. Perhaps an exception was thrown?'); + } + + $actual = json_encode(Arr::sortRecursive( + (array)$actual + )); + + foreach (Arr::sortRecursive($data) as $key => $value) { + $expected = $this->formatToExpectedJson($key, $value); + + $this->{$method}( + Str::contains($actual, $expected), + ($negate ? 'Found unexpected' : 'Unable to find') . " JSON fragment [{$expected}] within [{$actual}]." + ); + } + + return $this; + } + + /** + * Format the given key and value into a JSON string for expectation checks. + * + * @param string $key + * @param mixed $value + * @return string + */ + protected function formatToExpectedJson(string $key, $value): string + { + $expected = json_encode([$key => $value]); + + if (Str::startsWith($expected, '{')) { + $expected = substr($expected, 1); + } + + if (Str::endsWith($expected, '}')) { + $expected = substr($expected, 0, -1); + } + + return $expected; + } + + protected function seeModule($module): self + { + $this->assertEquals($module, request()->module()); + return $this; + } + + protected function seeController($controller): self + { + $this->assertEquals($controller, request()->controller()); + return $this; + } + + protected function seeAction($action): self + { + $this->assertEquals($action, request()->action()); + return $this; + } + + + protected function seeStatusCode($status): self + { + $this->assertEquals($status, $this->response->getCode()); + return $this; + } + + protected function seeHeader($headerName, $value = null): self + { + $headers = $this->response->getHeader(); + + $this->assertTrue(!empty($headers[$headerName]), "Header [{$headerName}] not present on response."); + + if (!is_null($value)) { + $this->assertEquals( + $headers[$headerName], $value, + "Header [{$headerName}] was found, but value [{$headers[$headerName]}] does not match [{$value}]." + ); + } + + return $this; + } + + protected function seeCookie($cookieName, $value = null): self + { + + $exist = Cookie::has($cookieName); + + $this->assertTrue($exist, "Cookie [{$cookieName}] not present on response."); + + if (!is_null($value)) { + $cookie = Cookie::get($cookieName); + $this->assertEquals( + $cookie, $value, + "Cookie [{$cookieName}] was found, but value [{$cookie}] does not match [{$value}]." + ); + } + + return $this; + } + + protected function withServerVariables(array $server): self + { + $this->serverVariables = $server; + + return $this; + } + + protected function transformHeadersToServerVars(array $headers): array + { + $server = []; + $prefix = 'HTTP_'; + + foreach ($headers as $name => $value) { + $name = strtr(strtoupper($name), '-', '_'); + + if (!Str::startsWith($name, $prefix) && $name != 'CONTENT_TYPE') { + $name = $prefix . $name; + } + + $server[$name] = $value; + } + + return $server; + } +} \ No newline at end of file diff --git a/tests/helpers/HttpException.php b/tests/helpers/HttpException.php new file mode 100644 index 0000000000000000000000000000000000000000..456c5bfa8e3fc04fe6baf25435959891c8220466 --- /dev/null +++ b/tests/helpers/HttpException.php @@ -0,0 +1,10 @@ + +// +---------------------------------------------------------------------- +namespace tests\helpers; + +use Exception; +use InvalidArgumentException; +use PHPUnit\Framework\Exception as PHPUnitException; +use Symfony\Component\DomCrawler\Crawler; +use Symfony\Component\DomCrawler\Form; +use think\File; +use think\helper\Str; +use think\Request; +use think\response\Redirect; +use think\Url; + +trait InteractsWithPages +{ + + /** @var \Symfony\Component\DomCrawler\Crawler */ + protected $crawler; + + /** + * All of the stored inputs for the current page. + * + * @var array + */ + protected $inputs = []; + + /** + * All of the stored uploads for the current page. + * + * @var array + */ + protected $uploads = []; + + public function visit($uri) + { + return $this->makeRequest('GET', $uri); + } + + + protected function submitForm($buttonText, $inputs = [], $uploads = []) + { + $this->makeRequestUsingForm($this->fillForm($buttonText, $inputs), $uploads); + + return $this; + } + + + protected function see($text, $negate = false) + { + $method = $negate ? 'assertNotRegExp' : 'assertRegExp'; + + $rawPattern = preg_quote($text, '/'); + + $escapedPattern = preg_quote(htmlentities($text, ENT_QUOTES, 'UTF-8', false), '/'); + + $pattern = $rawPattern == $escapedPattern + ? $rawPattern : "({$rawPattern}|{$escapedPattern})"; + + $this->$method("/$pattern/i", $this->response->getContent()); + + return $this; + } + + protected function notSee($text) + { + return $this->see($text, true); + } + + public function seeInElement($element, $text, $negate = false) + { + if ($negate) { + return $this->notSeeInElement($element, $text); + } + + $this->assertTrue( + $this->hasInElement($element, $text), + "Element [$element] should contain the expected text [{$text}]" + ); + + return $this; + } + + public function notSeeInElement($element, $text) + { + $this->assertFalse( + $this->hasInElement($element, $text), + "Element [$element] should not contain the expected text [{$text}]" + ); + + return $this; + } + + + public function seeLink($text, $url = null) + { + $message = "No links were found with expected text [{$text}]"; + + if ($url) { + $message .= " and URL [{$url}]"; + } + + $this->assertTrue($this->hasLink($text, $url), "{$message}."); + + return $this; + } + + public function notSeeLink($text, $url = null) + { + $message = "A link was found with expected text [{$text}]"; + + if ($url) { + $message .= " and URL [{$url}]"; + } + + $this->assertFalse($this->hasLink($text, $url), "{$message}."); + + return $this; + } + + protected function hasLink($text, $url = null) + { + $links = $this->crawler->selectLink($text); + + if ($links->count() == 0) { + return false; + } + + // If the URL is null, we assume the developer only wants to find a link + // with the given text regardless of the URL. So, if we find the link + // we will return true now. Otherwise, we look for the given URL. + if ($url == null) { + return true; + } + + $url = $this->addRootToRelativeUrl($url); + + /** @var \DOMElement $link */ + foreach ($links as $link) { + if ($link->getAttribute('href') == $url) { + return true; + } + } + + return false; + } + + protected function addRootToRelativeUrl($url) + { + if (!Str::startsWith($url, ['http', 'https'])) { + return Url::build($url); + } + + return $url; + } + + public function seeInField($selector, $expected) + { + $this->assertSame( + $expected, $this->getInputOrTextAreaValue($selector), + "The field [{$selector}] does not contain the expected value [{$expected}]." + ); + + return $this; + } + + public function notSeeInField($selector, $value) + { + $this->assertNotSame( + $this->getInputOrTextAreaValue($selector), $value, + "The input [{$selector}] should not contain the value [{$value}]." + ); + + return $this; + } + + public function seeIsChecked($selector) + { + $this->assertTrue( + $this->isChecked($selector), + "The checkbox [{$selector}] is not checked." + ); + + return $this; + } + + public function notSeeIsChecked($selector) + { + $this->assertFalse( + $this->isChecked($selector), + "The checkbox [{$selector}] is checked." + ); + + return $this; + } + + protected function isChecked($selector) + { + $checkbox = $this->filterByNameOrId($selector, "input[type='checkbox']"); + + if ($checkbox->count() == 0) { + throw new Exception("There are no checkbox elements with the name or ID [$selector]."); + } + + return $checkbox->attr('checked') !== null; + } + + public function seeIsSelected($selector, $expected) + { + $this->assertEquals( + $expected, $this->getSelectedValue($selector), + "The field [{$selector}] does not contain the selected value [{$expected}]." + ); + + return $this; + } + + public function notSeeIsSelected($selector, $value) + { + $this->assertNotEquals( + $value, $this->getSelectedValue($selector), + "The field [{$selector}] contains the selected value [{$value}]." + ); + + return $this; + } + + protected function getSelectedValue($selector) + { + $field = $this->filterByNameOrId($selector); + + if ($field->count() == 0) { + throw new Exception("There are no elements with the name or ID [$selector]."); + } + + $element = $field->nodeName(); + + if ($element == 'select') { + return $this->getSelectedValueFromSelect($field); + } + + if ($element == 'input') { + return $this->getCheckedValueFromRadioGroup($field); + } + + throw new Exception("Given selector [$selector] is not a select or radio group."); + } + + protected function getSelectedValueFromSelect(Crawler $field) + { + if ($field->nodeName() !== 'select') { + throw new Exception('Given element is not a select element.'); + } + + /** @var \DOMElement $option */ + foreach ($field->children() as $option) { + if ($option->hasAttribute('selected')) { + return $option->getAttribute('value'); + } + } + } + + protected function getCheckedValueFromRadioGroup(Crawler $radioGroup) + { + if ($radioGroup->nodeName() !== 'input' || $radioGroup->attr('type') !== 'radio') { + throw new Exception('Given element is not a radio button.'); + } + + /** @var \DOMElement $radio */ + foreach ($radioGroup as $radio) { + if ($radio->hasAttribute('checked')) { + return $radio->getAttribute('value'); + } + } + } + + protected function click($name) + { + $link = $this->crawler->selectLink($name); + + if (!count($link)) { + $link = $this->filterByNameOrId($name, 'a'); + + if (!count($link)) { + throw new InvalidArgumentException( + "Could not find a link with a body, name, or ID attribute of [{$name}]." + ); + } + } + + $this->visit($link->link()->getUri()); + + return $this; + } + + protected function type($text, $element) + { + return $this->storeInput($element, $text); + } + + protected function check($element) + { + return $this->storeInput($element, true); + } + + protected function uncheck($element) + { + return $this->storeInput($element, false); + } + + protected function select($option, $element) + { + return $this->storeInput($element, $option); + } + + protected function attach($absolutePath, $element) + { + $this->uploads[$element] = $absolutePath; + + return $this->storeInput($element, $absolutePath); + } + + protected function press($buttonText) + { + return $this->submitForm($buttonText, $this->inputs, $this->uploads); + } + + protected function getInputOrTextAreaValue($selector) + { + $field = $this->filterByNameOrId($selector, ['input', 'textarea']); + + if ($field->count() == 0) { + throw new Exception("There are no elements with the name or ID [$selector]."); + } + + $element = $field->nodeName(); + + if ($element == 'input') { + return $field->attr('value'); + } + + if ($element == 'textarea') { + return $field->text(); + } + + throw new Exception("Given selector [$selector] is not an input or textarea."); + } + + protected function seePageIs($uri) + { + $this->assertPageLoaded($uri = $this->prepareUrlForRequest($uri)); + + $this->assertEquals( + $uri, $this->currentUri, "Did not land on expected page [{$uri}].\n" + ); + + return $this; + } + + protected function hasInElement($element, $text) + { + $elements = $this->crawler->filter($element); + + $rawPattern = preg_quote($text, '/'); + + $escapedPattern = preg_quote(htmlentities($text, ENT_QUOTES, 'UTF-8', false), '/'); + + $pattern = $rawPattern == $escapedPattern + ? $rawPattern : "({$rawPattern}|{$escapedPattern})"; + + foreach ($elements as $element) { + $element = new Crawler($element); + + if (preg_match("/$pattern/i", $element->html())) { + return true; + } + } + + return false; + } + + protected function storeInput($element, $text) + { + $this->assertFilterProducesResults($element); + + $element = str_replace('#', '', $element); + + $this->inputs[$element] = $text; + + return $this; + } + + protected function assertFilterProducesResults($filter) + { + $crawler = $this->filterByNameOrId($filter); + + if (!count($crawler)) { + throw new InvalidArgumentException( + "Nothing matched the filter [{$filter}] CSS query provided for [{$this->currentUri}]." + ); + } + } + + protected function fillForm($buttonText, $inputs = []) + { + if (!is_string($buttonText)) { + $inputs = $buttonText; + + $buttonText = null; + } + + return $this->getForm($buttonText)->setValues($inputs); + } + + protected function getForm($buttonText = null) + { + try { + if ($buttonText) { + return $this->crawler->selectButton($buttonText)->form(); + } + + return $this->crawler->filter('form')->form(); + } catch (InvalidArgumentException $e) { + throw new InvalidArgumentException( + "Could not find a form that has submit button [{$buttonText}]." + ); + } + } + + protected function filterByNameOrId($name, $elements = '*') + { + $name = str_replace('#', '', $name); + + $id = str_replace(['[', ']'], ['\\[', '\\]'], $name); + + $elements = is_array($elements) ? $elements : [$elements]; + + array_walk($elements, function (&$element) use ($name, $id) { + $element = "{$element}#{$id}, {$element}[name='{$name}']"; + }); + + return $this->crawler->filter(implode(', ', $elements)); + } + + protected function makeRequest($method, $uri, $parameters = [], $cookies = [], $files = []) + { + $uri = $this->prepareUrlForRequest($uri); + + $this->call($method, $uri, $parameters, $cookies, $files); + + $this->clearInputs()->followRedirects()->assertPageLoaded($uri); + + $this->currentUri = Request::instance()->url(true); + + $this->crawler = new Crawler($this->response->getContent(), $this->currentUri); + + return $this; + } + + protected function makeRequestUsingForm(Form $form, array $uploads = []) + { + $files = $this->convertUploadsForTesting($form, $uploads); + + return $this->makeRequest( + $form->getMethod(), $form->getUri(), $this->extractParametersFromForm($form), [], $files + ); + } + + + protected function assertPageLoaded($uri, $message = null) + { + $status = $this->response->getCode(); + + try { + $this->assertEquals(200, $status); + } catch (PHPUnitException $e) { + $message = $message ?: "A request to [{$uri}] failed. Received status code [{$status}]."; + + throw new HttpException($message); + } + } + + + protected function convertUploadsForTesting(Form $form, array $uploads) + { + $files = $form->getFiles(); + + $names = array_keys($files); + + $files = array_map(function (array $file, $name) use ($uploads) { + return isset($uploads[$name]) + ? $this->getUploadedFileForTesting($file, $uploads, $name) + : $file; + }, $files, $names); + + return array_combine($names, $files); + } + + protected function getUploadedFileForTesting($file, $uploads, $name) + { + $file['name'] = basename($uploads[$name]); + + return new File( + $file['tmp_name'], $file, true + ); + } + + protected function extractParametersFromForm(Form $form) + { + parse_str(http_build_query($form->getValues()), $parameters); + + return $parameters; + } + + protected function clearInputs() + { + $this->inputs = []; + + $this->uploads = []; + + return $this; + } + + protected function followRedirects() + { + while ($this->response instanceof Redirect) { + $this->makeRequest('GET', $this->response->getTargetUrl()); + } + + return $this; + } + + protected function prepareUrlForRequest($uri) + { + if (Str::startsWith($uri, '/')) { + $uri = substr($uri, 1); + } + + if (!Str::startsWith($uri, 'http')) { + $uri = $this->baseUrl . '/' . $uri; + } + + return trim($uri, '/'); + } +} \ No newline at end of file diff --git a/tests/unit/ExampleTest.php b/tests/unit/ExampleTest.php new file mode 100644 index 0000000000000000000000000000000000000000..cf3a98eb2dfa6999a3a86ee3a116682a7ec5673e --- /dev/null +++ b/tests/unit/ExampleTest.php @@ -0,0 +1,18 @@ +assertTrue(true); + } +} \ No newline at end of file