From 5a0f8f3672688cd8a1b6c87ded3381d95515afe7 Mon Sep 17 00:00:00 2001 From: Sean Fisher Date: Mon, 15 Apr 2024 10:24:08 -0400 Subject: [PATCH] Element assertions and HTML String (#528) * Add macro support to factories * Adding HTML String class * Fixing helper * Linting fixes * CHANGELOG * Add assertContains/assertNotContains methods * Test for ID * Adding assertion messages * CHANGELOG date * Phpstan fixes --- CHANGELOG.md | 7 +- src/mantle/database/factory/class-factory.php | 12 +- src/mantle/testing/autoload.php | 1 + .../testing/class-assertable-html-string.php | 58 +++++++++ src/mantle/testing/class-utils.php | 4 - .../concerns/trait-element-assertions.php | 123 ++++++++++++++++-- .../helpers/helpers-element-assertions.php | 17 +++ tests/Database/Factory/FactoryTest.php | 12 ++ .../Concerns/ElementAssertionsTest.php | 99 +++++++++----- 9 files changed, 284 insertions(+), 49 deletions(-) create mode 100644 src/mantle/testing/class-assertable-html-string.php create mode 100644 src/mantle/testing/helpers/helpers-element-assertions.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d739508f..4d5fdacc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## v1.0.2 - 2024-04-10 +## v1.0.2 - 2024-04-15 + +### Added + +- Added `html_string()` helper to make assertions against a HTML string easier in testing. +- Added new assertion methods to test against elements. ### Fixed diff --git a/src/mantle/database/factory/class-factory.php b/src/mantle/database/factory/class-factory.php index a2bb6b25..7814fd8b 100644 --- a/src/mantle/database/factory/class-factory.php +++ b/src/mantle/database/factory/class-factory.php @@ -31,9 +31,11 @@ * @method \Mantle\Database\Factory\Fluent_Factory count(int $count) */ abstract class Factory { - use Concerns\Resolves_Factories, - Conditionable, - Macroable; + use Concerns\Resolves_Factories; + use Conditionable; + use Macroable { + __call as macro_call; + } /** * Flag to return the factory as a model. @@ -276,6 +278,10 @@ protected function create_fluent_factory(): Fluent_Factory { * @param array $args The arguments. */ public function __call( string $method, array $args ): mixed { + if ( static::has_macro( $method ) ) { + return $this->macro_call( $method, $args ); + } + return $this->create_fluent_factory()->$method( ...$args ); } } diff --git a/src/mantle/testing/autoload.php b/src/mantle/testing/autoload.php index cdbf4a61..9562b691 100644 --- a/src/mantle/testing/autoload.php +++ b/src/mantle/testing/autoload.php @@ -12,6 +12,7 @@ use function Mantle\Support\Helpers\tap; require_once __DIR__ . '/preload.php'; +require_once __DIR__ . '/helpers/helpers-element-assertions.php'; require_once __DIR__ . '/helpers/helpers-http-response.php'; require_once __DIR__ . '/mail/helpers.php'; diff --git a/src/mantle/testing/class-assertable-html-string.php b/src/mantle/testing/class-assertable-html-string.php new file mode 100644 index 00000000..c8929d98 --- /dev/null +++ b/src/mantle/testing/class-assertable-html-string.php @@ -0,0 +1,58 @@ +content; + } + + /** + * Assert that the content contains the expected string. + * + * @param string $needle The $needle to assert against. + */ + public function assertContains( string $needle ): static { + Assert::assertStringContainsString( $needle, $this->content, 'The content does not contain the expected string.' ); + + return $this; + } + + /** + * Assert that the content does not contain the expected string. + * + * @param string $needle The $needle to assert against. + */ + public function assertNotContains( string $needle ): static { + Assert::assertStringNotContainsString( $needle, $this->content, 'The content contains the unexpected string.' ); + + return $this; + } +} diff --git a/src/mantle/testing/class-utils.php b/src/mantle/testing/class-utils.php index 78901d3b..341526d1 100644 --- a/src/mantle/testing/class-utils.php +++ b/src/mantle/testing/class-utils.php @@ -464,10 +464,6 @@ public static function command( $command, &$exit_code = null ) { * Ensure that Composer is loaded for the current environment. */ public static function ensure_composer_loaded(): void { - if ( class_exists( \Composer\Autoload\ClassLoader::class ) ) { - return; - } - $paths = [ preg_replace( '#/vendor/.*$#', '/vendor/autoload.php', __DIR__ ), __DIR__ . '/../../../vendor/autoload.php', diff --git a/src/mantle/testing/concerns/trait-element-assertions.php b/src/mantle/testing/concerns/trait-element-assertions.php index 690bdaa7..0b46d069 100644 --- a/src/mantle/testing/concerns/trait-element-assertions.php +++ b/src/mantle/testing/concerns/trait-element-assertions.php @@ -2,6 +2,8 @@ /** * Element_Assertions trait file * + * phpcs:disable Squiz.Commenting.FunctionComment.ParamNameNoMatch, Squiz.Commenting.FunctionComment.MissingParamTag + * * @package Mantle */ @@ -9,6 +11,7 @@ use PHPUnit\Framework\Assert as PHPUnit; use DOMDocument; +use DOMNode; use DOMXPath; use Symfony\Component\CssSelector\CssSelectorConverter; @@ -52,11 +55,15 @@ protected function convert_query_selector( string $selector ): string { * Assert that an element exists in the response. * * @param string $expression The XPath expression to execute. + * @param string $message Optional message to display on failure. */ - public function assertElementExists( string $expression ): static { + public function assertElementExists( string $expression, string $message = null ): static { $nodes = ( new DOMXPath( $this->get_dom_document() ) )->query( $expression ); - PHPUnit::assertTrue( ! $nodes ? false : $nodes->length > 0 ); + PHPUnit::assertTrue( + ! $nodes ? false : $nodes->length > 0, + $message ?? 'Element not found for expression: ' . $expression, + ); return $this; } @@ -71,7 +78,7 @@ public function assertElementExistsById( string $id ): static { $id = substr( $id, 1 ); } - return $this->assertElementExists( sprintf( '//*[@id="%s"]', $id ) ); + return $this->assertElementExists( sprintf( '//*[@id="%s"]', $id ), "Element not found for ID: $id" ); } /** @@ -84,18 +91,25 @@ public function assertElementExistsByClassName( string $classname ): static { $classname = substr( $classname, 1 ); } - return $this->assertElementExists( sprintf( '//*[contains(concat(" ", normalize-space(@class), " "), " %s ")]', $classname ) ); + return $this->assertElementExists( + sprintf( '//*[contains(concat(" ", normalize-space(@class), " "), " %s ")]', $classname ), + "Element not found for class: $classname" + ); } /** * Assert that an element is missing in the response. * * @param string $expression The XPath expression to execute. + * @param string $message The message to display if the assertion fails. */ - public function assertElementMissing( string $expression ): static { + public function assertElementMissing( string $expression, string $message = null ): static { $nodes = ( new DOMXPath( $this->get_dom_document() ) )->query( $expression ); - PHPUnit::assertTrue( false === $nodes || 0 === $nodes->length ); + PHPUnit::assertTrue( + false === $nodes || 0 === $nodes->length, + $message ?? "Element found for expression: $expression" + ); return $this; } @@ -110,7 +124,7 @@ public function assertElementMissingById( string $id ): static { $id = substr( $id, 1 ); } - return $this->assertElementMissing( sprintf( '//*[@id="%s"]', $id ) ); + return $this->assertElementMissing( sprintf( '//*[@id="%s"]', $id ), "Element found for ID: $id" ); } /** @@ -132,7 +146,7 @@ public function assertElementMissingByClassName( string $classname ): static { * @param string $type The type of element to check. */ public function assertElementExistsByTagName( string $type ): static { - return $this->assertElementExists( sprintf( '//*[local-name()="%s"]', $type ) ); + return $this->assertElementExists( sprintf( '//*[local-name()="%s"]', $type ), "Element not found for tag: $type" ); } /** @@ -141,7 +155,7 @@ public function assertElementExistsByTagName( string $type ): static { * @param string $type The type of element to check. */ public function assertElementMissingByTagName( string $type ): static { - return $this->assertElementMissing( sprintf( '//*[local-name()="%s"]', $type ) ); + return $this->assertElementMissing( sprintf( '//*[local-name()="%s"]', $type ), "Element found for tag: $type" ); } /** @@ -150,7 +164,7 @@ public function assertElementMissingByTagName( string $type ): static { * @param string $selector The selector to use. */ public function assertElementExistsByQuerySelector( string $selector ): static { - return $this->assertElementExists( $this->convert_query_selector( $selector ) ); + return $this->assertElementExists( $this->convert_query_selector( $selector ), "Element not found for selector: $selector" ); } /** @@ -168,7 +182,7 @@ public function assertQuerySelectorExists( string $selector ): static { * @param string $selector The selector to use. */ public function assertElementMissingByQuerySelector( string $selector ): static { - return $this->assertElementMissing( $this->convert_query_selector( $selector ) ); + return $this->assertElementMissing( $this->convert_query_selector( $selector ), "Element found for selector: $selector" ); } /** @@ -179,4 +193,91 @@ public function assertElementMissingByQuerySelector( string $selector ): static public function assertQuerySelectorMissing( string $selector ): static { return $this->assertElementMissingByQuerySelector( $selector ); } + + /** + * Assert against the expected number of elements for an expression. + * + * @param string $expression The XPath expression to execute. + * @param int $expected The expected number of elements. + */ + public function assertElementCount( string $expression, int $expected ): static { + $nodes = ( new DOMXPath( $this->get_dom_document() ) )->query( $expression ); + + PHPUnit::assertEquals( $expected, $nodes->length, 'Unexpected number of elements found.' ); + + return $this; + } + + /** + * Assert against the expected number of elements for a query selector. + * + * @param string $selector The selector to use. + * @param int $expected The expected number of elements. + */ + public function assertQuerySelectorCount( string $selector, int $expected ): static { + return $this->assertElementCount( $this->convert_query_selector( $selector ), $expected ); + } + + /** + * Assert an element exists by test ID. + * + * @param string $test_id The test ID to check. + */ + public function assertElementExistsByTestId( string $test_id ): static { + return $this->assertQuerySelectorExists( "[data-testid=\"$test_id\"]" ); + } + + /** + * Assert an element is missing by test ID. + * + * @param string $test_id The test ID to check. + */ + public function assertElementMissingByTestId( string $test_id ): static { + return $this->assertQuerySelectorMissing( "[data-testid=\"$test_id\"]" ); + } + + /** + * Assert that an element passes a custom assertion. + * + * The assertion will be called for each node found by the expression. + * + * @param string $expression The XPath expression to execute. + * @param callable(DOMNode $node): bool $assertion The assertion to run. + * @param bool $pass_any Pass if any of the nodes pass the assertion. Otherwise, all must pass. + */ + public function assertElement( string $expression, callable $assertion, bool $pass_any = false ): static { + $nodes = ( new DOMXPath( $this->get_dom_document() ) )->query( $expression ); + + if ( ! $nodes ) { + PHPUnit::fail( 'No nodes found for expression: ' . $expression ); + } + + foreach ( $nodes as $node ) { + if ( $assertion( $node ) ) { + // If we're passing on any, we can return early. + if ( $pass_any ) { + return $this; + } + } elseif ( ! $pass_any ) { + PHPUnit::fail( 'Assertion failed for node: ' . $node->nodeName ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + } + } + + PHPUnit::assertTrue( true, 'All nodes passed assertion.' ); + + return $this; + } + + /** + * Assert that an element passes a custom assertion by query selector. + * + * The assertion will be called for each node found by the selector. + * + * @param string $selector The selector to use. + * @param callable(DOMNode $node): bool $assertion The assertion to run. + * @param bool $pass_any Pass if any of the nodes pass the assertion. Otherwise, all must pass. + */ + public function assertQuerySelector( string $selector, callable $assertion, bool $pass_any = false ): static { + return $this->assertElement( $this->convert_query_selector( $selector ), $assertion, $pass_any ); + } } diff --git a/src/mantle/testing/helpers/helpers-element-assertions.php b/src/mantle/testing/helpers/helpers-element-assertions.php new file mode 100644 index 00000000..a80e6b2d --- /dev/null +++ b/src/mantle/testing/helpers/helpers-element-assertions.php @@ -0,0 +1,17 @@ + $this->with_meta( [ 'custom_meta_key' => 'custom_meta_value' ] ), + ); + + $post_id = Testable_Post::factory()->with_custom_meta()->create(); + + $this->assertEquals( 'custom_meta_value', get_post_meta( $post_id, 'custom_meta_key', true ) ); + } + public function test_create_custom_post_type_model() { register_post_type( 'custom_post_type', diff --git a/tests/Testing/Concerns/ElementAssertionsTest.php b/tests/Testing/Concerns/ElementAssertionsTest.php index d5a720a0..187584d2 100644 --- a/tests/Testing/Concerns/ElementAssertionsTest.php +++ b/tests/Testing/Concerns/ElementAssertionsTest.php @@ -1,9 +1,12 @@ Example Section
Example Div By Class
Example Div By ID
+ '; - public function test_element_exists_by_xpath() { - $response = new Test_Response( $this->test_content ); + protected function response(): Test_Response { + return new Test_Response( $this->test_content ); + } - $response->assertElementExists( '//div' ); - $response->assertElementExists( '//section' ); - $response->assertElementMissing( '//article' ); + public function test_element_exists_by_xpath() { + $this->response() + ->assertElementExists( '//div' ) + ->assertElementExists( '//section' ) + ->assertElementMissing( '//article' ); } public function test_element_exists_by_id() { - $response = new Test_Response( $this->test_content ); - - $response->assertElementExistsById( 'test-id' ); - $response->assertElementExistsById( '#test-id' ); - $response->assertElementMissingById( 'missing-id' ); - $response->assertElementMissingById( '.missing-id' ); + $this->response() + ->assertElementExistsById( 'test-id' ) + ->assertElementExistsById( '#test-id' ) + ->assertElementMissingById( 'missing-id' ) + ->assertElementMissingById( '#missing-id' ) + ->assertElementMissingById( '.test-id' ); // A class selector should not match an ID. } public function test_element_exists_by_class() { - $response = new Test_Response( $this->test_content ); - - $response->assertElementExistsByClassName( 'test-class' ); - $response->assertElementExistsByClassName( '.test-class' ); - $response->assertElementMissingByClassName( 'missing-class' ); - $response->assertElementMissingByClassName( '.missing-class' ); + $this->response() + ->assertElementExistsByClassName( 'test-class' ) + ->assertElementExistsByClassName( '.test-class' ) + ->assertElementMissingByClassName( 'missing-class' ) + ->assertElementMissingByClassName( '.missing-class' ); } public function test_element_exists_by_tag() { - $response = new Test_Response( $this->test_content ); - - $response->assertElementExistsByTagName( 'div' ); - $response->assertElementExistsByTagName( 'section' ); - $response->assertElementMissingByTagName( 'article' ); + $this->response() + ->assertElementExistsByTagName( 'div' ) + ->assertElementExistsByTagName( 'section' ) + ->assertElementMissingByTagName( 'article' ); } public function test_element_exists_by_query_selector() { - $response = new Test_Response( $this->test_content ); - - $response->assertElementExistsByQuerySelector( 'div' ); - $response->assertElementExistsByQuerySelector( 'section' ); - $response->assertElementExistsByQuerySelector( '.test-class' ); - $response->assertElementExistsByQuerySelector( '#test-id' ); - $response->assertElementMissingByQuerySelector( 'article' ); - $response->assertElementMissingByQuerySelector( 'aside' ); + $this->response() + ->assertElementExistsByQuerySelector( 'div' ) + ->assertElementExistsByQuerySelector( 'section' ) + ->assertElementExistsByQuerySelector( '.test-class' ) + ->assertElementExistsByQuerySelector( '#test-id' ) + ->assertElementMissingByQuerySelector( 'article' ) + ->assertElementMissingByQuerySelector( 'aside' ); + } + + public function test_element_count() { + $this->response() + ->assertElementCount( '//li', 3 ) + ->assertElementCount( '//div', 3 ) + ->assertQuerySelectorCount( 'li', 3 ) + ->assertQuerySelectorCount( 'div li', 3 ) + ->assertQuerySelectorCount( 'div', 3 ); + } + + public function test_by_test_id() { + $this->response() + ->assertElementExistsByTestId( 'test-item' ) + ->assertElementMissingByTestId( 'missing-item' ); + } + + public function test_element_callback() { + $this->response() + ->assertElement( '//section', fn ( DOMNode $node ) => $node->textContent === 'Example Section' ) + ->assertQuerySelector( 'section', fn ( DOMNode $node ) => $node->textContent === 'Example Section' ) + ->assertElement( '//li', fn ( DOMNode $node ) => $node->textContent === 'Item 2', pass_any: true ) + ->assertQuerySelector( 'li', fn ( DOMNode $node ) => $node->textContent === 'Item 2', pass_any: true ); + } + + public function test_html_string() { + html_string( $this->test_content ) + ->assertContains( 'Example Section' ) + ->assertElementExists( '//div' ) + ->assertElementExists( '//section' ) + ->assertElementMissing( '//article' ); } }