Skip to content

Commit

Permalink
Add helper classes for shortcode testing (#23)
Browse files Browse the repository at this point in the history
This PR addresses the shortcomings outlined in #12 and #13, and probably replaces #10 and #18 as well.

1. Deprecate the `\Webfactory\ShortcodeBundle\Tests\Functional\ShortcodeTest` class. 

* It was not available through the regular autoload mechanism (required extra trickery), 
* depended on undeclared dependencies (BrowserKit and DomCrawler), 
* came without tests, 
* suggested users to do/prefer full-scale Application Testing (using `WebTestCase`) and 
* worked only through the means of (ab-)using the Shortcode Guide.

2. Provide alternate means of testing shortcode processing

* Encourage users to do plain/direct unit testing (or integration testing, when necessary) of their shortcode handlers and/or shortcode handling controllers through new sections in the README. This gives best (most direct) control of input parameters.
* Add the public `\Webfactory\ShortcodeBundle\Test\ShortcodeDefinitionTestHelper` service that can be used in Kernel-based (functional) tests to verify a given shortcode name is known, the controller can be resolved etc.
* Add a `\Webfactory\ShortcodeBundle\Test\EndToEndTestHelper` class that simplifies full end-to-end (from content to processed content) functional testing, including round-trips through Symfony's FragmentHandler for controller-based shortcodes.

Fixes #12, fixes #13, closes #10, closes #18, closes #24.

Co-authored-by: Malte Wunsch <mw@webfactory.de>
  • Loading branch information
mpdude and MalteWunsch authored May 31, 2022
1 parent 6a0f46d commit ee4e741
Show file tree
Hide file tree
Showing 14 changed files with 393 additions and 79 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/lock-symfony-version.sh
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
#!/bin/bash

cat <<< $(jq --arg version $VERSION '.require |= with_entries(if ((.key|test("^symfony/service-contracts")|not) and (.key|test("^symfony/"))) then .value=$version else . end)' < composer.json) > composer.json
cat <<< $(jq --arg version $VERSION '.require |= with_entries(if ((.key|test("^symfony/deprecation-contracts")|not) and (.key|test("^symfony/"))) then .value=$version else . end)' < composer.json) > composer.json
139 changes: 74 additions & 65 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,90 +142,99 @@ webfactory_shortcode:
max_iterations: 2 # default: null
```

### Automated Tests for your Shortcodes
## Testing your Shortcodes

With the shortcode guide enabled (remember: you may enable it just in your test environment), you can easily write
functional tests for your shortcodes using the rendered detail pages. This way, you can test even shortcodes with
complex dependencies. But as functional tests are slow, you may want to keep your shortcode tests in a seperate slow
test suite.
This section provides a few hints and starting pointers on testing your shortcode handlers and bundle configuration.

To speed things up, the bundle provides the abstract ```\Webfactory\ShortcodeBundle\Tests\Functional\ShortcodeTest```
class for you to extend. Using it, your test class may look like this (we recommend one test class for each shortcode):
### Direct Unit Tests

In general, try to start with unit testing your shortcode handlers directly.

No matter whether your handler is a simple class implementing the `__invoke` magic method or a Symfony Controller with one or several methods: Direct unit tests are the easiest way to have full control over the handler's (or controller's) input, and to get immediate access to its return value. This allows you to test also a broader range of input parameters and verify the outcomes. In this case, you will typically use Mock Objects to substitute some or all other classes and services your handler depends upon.

If your shortcode handler produces HTML output, the [Symfony DomCrawler](https://symfony.com/doc/current/components/dom_crawler.html) might be helpful to perform assertions on the HTML structure and content.

### Functional Tests for shortcode-handling Controllers

When using a controller to handle a shortcode, and the controller uses Twig for rendering, you might want to do a full functional (integration) test instead of mocking the Twig engine.

The Symfony documentation describes how [Application Tests](https://symfony.com/doc/current/testing.html#write-your-first-application-test) can be performed. This approach, however, is probably not suited for your shortcode controllers since these typically _are not_ reachable through routes and so you cannot perform direct HTTP requests against them.

Instead, write an [integration test](https://symfony.com/doc/current/testing.html#integration-tests) where you retrieve the controller as a service from the Dependency Injection Container and invoke the appropriate method on it directly. Then, just like described in the section before, perform assertions on the Response returned by the controller.

Here is an example of what a test might look like.

```php
<?php
# src/AppBundle/Tests/Shortcodes/ImageTest.php
namespace AppBundle\Tests\Shortcodes;
use Webfactory\ShortcodeBundle\Tests\Functional\ShortcodeTest;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class ImageTest extends ShortcodeTest
class MyShortcodeControllerTest extends KernelTestCase
{
protected function getShortcodeToTest(): string
public function test_renderImageAction_returns_img(): void
{
return 'image';
}
// Assume the controller is used to turn `[img id=42]` into some HTML markup

/** @test */
public function teaser_gets_rendered(): void
{
// without $customParameters, getRenderedExampleHtml() will get a rendering of the example configured in the
// shortcode tag, in this case "image url=https://upload.wikimedia.org/wikipedia/en/f/f7/RickRoll.png"
$this->assertStringContainsString(
'<img src="https://upload.wikimedia.org/wikipedia/en/f/f7/RickRoll.png" />',
$this->getRenderedExampleHtml()
);
}
// create fixture/setup image with ID 42 in the database or similar
// ...

/** @test */
public function teaser_with_custom_parameters(): void
{
// Pass custom parameters as an array
$this->assertStringContainsString(
'<img src="custom-image-url" />',
$this->getRenderedExampleHtml([
'url' => 'custom-image-url',
])
);
// Exercise controller method
$container = static::getContainer();
$controller = $container->get(MyShortcodeController::class);
$response = $controller->renderImageAction(42);

// Verify outcome
self::assertStringContainsString('<img src="..." />', (string) $response->getContent());
}
}
```

### Testing Configuration

After you have written some tests that verify your handlers work as expected for different input parameters or other circumstances (e. g. database content), you also want to make sure a given handler is registered correctly and connected with the right shortcode name. Since we are now concerned with how this bundle, your configuration and your handlers all play together, we're in the realm of integration testing. These tests will be slower, since we need to boot a Symfony Kernel, fetch services from the Dependency Injection Container and test how various parts play together.

This bundle contains the `\Webfactory\ShortcodeBundle\Test\ShortcodeDefinitionTestHelper` class and a public service of the same name. Depending on the degree of test specifity you prefer, you can use this service to verify that...

* A given shortcode name is known, i. e. a handler has been set up for it
* Retrieve the handler for a given shortcode name, so you can for example perform assertions on the class being used
* When using controllers as shortcode handlers, test if the controller reference for a given shortcode can be resolved (the controller actually exists)
* Retrieve an instance of the controller to perform assertions on it.

For all these tests, you probably need to use `KernelTestCase` as your test base class ([documentation](https://symfony.com/doc/current/testing.html#integration-tests)). Basically, you will need to boot the kernel, then get the `ShortcodeDefinitionTestHelper` from the container and use its methods to check your shortcode configuration.

Maybe you want to have a look at [the tests for `ShortcodeDefinitionTestHelper` itself](tests/Functional/ShortcodeDefinitionTestHelperTest.php) to see a few examples of how this class can be used.

Remember – this type of test should not test all the possible inputs and outputs for your handlers; you've already covered that with more specific, direct tests. In this test layer, we're only concerned with making sure all the single parts are connected correctly.

### Full End-to-End Tests

If, for some reason, you would like to do a full end-to-end test for shortcode processing, from a given string containing shortcode markup to the processed result, have a look at the `\Webfactory\ShortcodeBundle\Test\EndToEndTestHelper` class.

This helper class can be used in integration test cases and will do the full shortcode processing on a given input, including dispatching sub-requests to controllers used as shortcode handlers.

A test might look like this:

```php
<?php

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Webfactory\ShortcodeBundle\Test\EndToEndTestHelper;

class MyFullScaleTest extends KernelTestCase
{
/** @test */
public function teaser_to_nonexisting_page_gives_error(): void
public function replace_text_color(): void
{
// both crawlRenderedExample() and assertHttpStatusCodeWhenCrawlingRenderedExample() accept a $customParameters
// argument that will replace the parameters provided in the configuration of the shortcode tag.
// This can be used to cover more test cases, e.g. an unhappy path
$this->assertHttpStatusCodeWhenCrawlingRenderedExample(500, 'url=');
self::bootKernel();

$result = EndToEndTestHelper::createFromContainer(static::$container)->processShortcode('[text color="red"]This is red text.[/text]');

self::assertSame('<span style="color: red;">This is red text.</span>', $result);
}
}
```

## Logging

When something goes wrong with the resolving of a shortcode, maybe you not only want to know which shortcode with
which parameters caused the issue (which you can log in your resolving controller), but also which url was called
that embedded the shortcode.

This is tricky is you embed your shortcode controllers via ESI, as the ESI subrequest is in Symfony terms a master
request, preventing you from getting your answer from RequestStack::getMasterRequest(). Hence, the
`EmbedShortcodeHandler` logs this information in the `shortcode` channel.

```xml
<!-- src/AppBundle/Resources/config/shortcodes.xml -->
<?xml version="1.0" ?>
<container xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://symfony.com/schema/dic/services" xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="webfactory.shortcode.your-shortcode-name" parent="Webfactory\ShortcodeBundle\Handler\EmbeddedShortcodeHandler.esi" class="Webfactory\ShortcodeBundle\Handler\EmbeddedShortcodeHandler">
<argument index="1">AppBundle\Controller\EmbeddedImageController:showAction</argument>
<tag name="webfactory.shortcode" ... />
...
<argument index="3" type="service" id="monolog.logger.your_channel" />
</service>
</services>
</container>
```
Assuming that your application configuration registers a handler for the `text` shortcode, which might also be a controller, this test will perform a full-stack test.

## Credits, Copyright and License

Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"psr/log": "^1.0.2",
"symfony/config": "^4.4|^5.0",
"symfony/dependency-injection": "^4.4|^5.0",
"symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/http-foundation": "^4.4|^5.0",
"symfony/http-kernel": "^4.4|^5.0",
"thunderer/shortcode": "^0.6.5|^0.7",
Expand Down
5 changes: 5 additions & 0 deletions src/Handler/EmbeddedShortcodeHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,9 @@ public function __invoke(ShortcodeInterface $shortcode)
return "<code>&#91;$text&#93;</code>";
}
}

public function getControllerName(): string
{
return $this->controllerName;
}
}
2 changes: 1 addition & 1 deletion src/Resources/config/guide.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<service id="Webfactory\ShortcodeBundle\Controller\GuideController">
<argument><!-- shortcode definitions placeholder argument --></argument>
<argument type="service" id="twig" />
<tag name="controller.service_arguments"/>
<tag name="controller.service_arguments" />
</service>

</services>
Expand Down
30 changes: 18 additions & 12 deletions src/Resources/config/shortcodes.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
<defaults public="false" />

<!-- Definition of a Shortcode HandlerContainer instance. -->
<service id="Thunder\Shortcode\HandlerContainer\HandlerContainer"/>
<service id="Thunder\Shortcode\HandlerContainer\HandlerContainer" />

<service id="webfactory_shortcode.regular_parser" class="Thunder\Shortcode\Parser\RegularParser"/>
<service id="webfactory_shortcode.regex_parser" class="Thunder\Shortcode\Parser\RegexParser"/>
<service id="webfactory_shortcode.regular_parser" class="Thunder\Shortcode\Parser\RegularParser" />
<service id="webfactory_shortcode.regex_parser" class="Thunder\Shortcode\Parser\RegexParser" />

<service id="Webfactory\ShortcodeBundle\Factory\ProcessorFactory">
<argument type="service" id="webfactory_shortcode.parser" />
Expand All @@ -20,8 +20,8 @@
<argument>%webfactory_shortcode.max_iterations%</argument>
</service>

<!-- Definition of a Shortcode Processor instance. -->
<service id="Thunder\Shortcode\Processor\Processor">
<!-- Definition of a Shortcode Processor instance. Public to simplify testing – do not rely on it! -->
<service id="Thunder\Shortcode\Processor\Processor" public="true">
<factory service="Webfactory\ShortcodeBundle\Factory\ProcessorFactory" method="create" />
</service>

Expand All @@ -31,14 +31,14 @@
<call method="addListener">
<argument type="constant">\Thunder\Shortcode\Events::REPLACE_SHORTCODES</argument>
<argument type="service">
<service class="Webfactory\ShortcodeBundle\Handler\RemoveWrappingParagraphElementsEventHandler"/>
<service class="Webfactory\ShortcodeBundle\Handler\RemoveWrappingParagraphElementsEventHandler" />
</argument>
</call>
</service>

<!-- Base definition for the EmbedForShortcodeHandler with esi renderer. -->
<service abstract="true" id="Webfactory\ShortcodeBundle\Handler\EmbeddedShortcodeHandler.esi" class="Webfactory\ShortcodeBundle\Handler\EmbeddedShortcodeHandler" lazy="true">
<argument type="service" id="fragment.handler"/>
<argument type="service" id="fragment.handler" />
<argument><!-- Controller name placeholder argument --></argument>
<argument>esi</argument>
<argument type="service" id="request_stack" />
Expand All @@ -47,11 +47,11 @@
</service>

<!-- alias for BC -->
<service id="webfactory.shortcode.embed_esi_for_shortcode_handler" alias="Webfactory\ShortcodeBundle\Handler\EmbeddedShortcodeHandler.esi"/>
<service id="webfactory.shortcode.embed_esi_for_shortcode_handler" alias="Webfactory\ShortcodeBundle\Handler\EmbeddedShortcodeHandler.esi" />

<!-- Base definition for the EmbedForShortcodeHandler with inline renderer. -->
<service abstract="true" id="Webfactory\ShortcodeBundle\Handler\EmbeddedShortcodeHandler.inline" class="Webfactory\ShortcodeBundle\Handler\EmbeddedShortcodeHandler" lazy="true">
<argument type="service" id="fragment.handler"/>
<argument type="service" id="fragment.handler" />
<argument><!-- Controller name placeholder argument --></argument>
<argument>inline</argument>
<argument type="service" id="request_stack" />
Expand All @@ -60,12 +60,18 @@
</service>

<!-- alias for BC -->
<service id="webfactory.shortcode.embed_inline_for_shortcode_handler" alias="Webfactory\ShortcodeBundle\Handler\EmbeddedShortcodeHandler.inline"/>
<service id="webfactory.shortcode.embed_inline_for_shortcode_handler" alias="Webfactory\ShortcodeBundle\Handler\EmbeddedShortcodeHandler.inline" />

<!-- Twig extension providing the |shortcodes filter. The content will be passed to the Shortcode Processor. -->
<service id="Webfactory\ShortcodeBundle\Twig\ShortcodeExtension" class="Webfactory\ShortcodeBundle\Twig\ShortcodeExtension">
<argument type="service" id="Thunder\Shortcode\Processor\Processor"/>
<tag name="twig.extension"/>
<argument type="service" id="Thunder\Shortcode\Processor\Processor" />
<tag name="twig.extension" />
</service>

<!-- Helper service for functional tests -->
<service id="Webfactory\ShortcodeBundle\Test\ShortcodeDefinitionTestHelper" public="true">
<argument type="service" id="controller_resolver" />
<argument type="service" id="Thunder\Shortcode\HandlerContainer\HandlerContainer" />
</service>

</services>
Expand Down
46 changes: 46 additions & 0 deletions src/Test/EndToEndTestHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

namespace Webfactory\ShortcodeBundle\Test;

use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Thunder\Shortcode\Processor\Processor;

/**
* Helper class that you can use in functional (end-to-end) tests to verify that a given
* content with shortcodes is processed as expected.
*/
class EndToEndTestHelper
{
/**
* @var Processor
*/
private $processor;

/**
* @var RequestStack
*/
private $requestStack;

public static function createFromContainer(ContainerInterface $container): self
{
return new self($container->get(Processor::class), $container->get(RequestStack::class));
}

public function __construct(Processor $processor, RequestStack $requestStack)
{
$this->processor = $processor;
$this->requestStack = $requestStack;
}

public function processShortcode(string $shortcode): string
{
// The fragment handler used by EmbeddedShortcodeHandler requires a request to be active, so let's make sure that is the case
if (null === $this->requestStack->getCurrentRequest()) {
$this->requestStack->push(new Request());
}

return $this->processor->process($shortcode);
}
}
56 changes: 56 additions & 0 deletions src/Test/ShortcodeDefinitionTestHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

namespace Webfactory\ShortcodeBundle\Test;

use RuntimeException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface;
use Thunder\Shortcode\HandlerContainer\HandlerContainerInterface;
use Webfactory\ShortcodeBundle\Handler\EmbeddedShortcodeHandler;

/**
* Helper class that you can use in functional (end-to-end) tests to verify that a given
* content with shortcodes is processed as expected.
*/
class ShortcodeDefinitionTestHelper
{
/**
* @var ControllerResolverInterface
*/
private $controllerResolver;

/**
* @var HandlerContainerInterface
*/
private $handlerContainer;

public function __construct(ControllerResolverInterface $controllerResolver, HandlerContainerInterface $handlerContainer)
{
$this->handlerContainer = $handlerContainer;
$this->controllerResolver = $controllerResolver;
}

public function hasShortcode(string $shortcode): bool
{
return null !== $this->handlerContainer->get($shortcode);
}

public function getHandler(string $shortcode): callable
{
return $this->handlerContainer->get($shortcode);
}

/**
* @return callable-array
*/
public function resolveShortcodeController(string $shortcode): array
{
$handler = $this->handlerContainer->get($shortcode);

if (!$handler instanceof EmbeddedShortcodeHandler) {
throw new RuntimeException('In order to test resolution of shortcodes to Controllers, the handler must be an instance of EmbeddedShortcodeHandler');
}

return $this->controllerResolver->getController(new Request([], [], ['_controller' => $handler->getControllerName()]));
}
}
1 change: 1 addition & 0 deletions tests/Fixtures/config/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ webfactory_shortcode:
controller: 'Webfactory\ShortcodeBundle\Tests\Fixtures\Controller\ShortcodeTestController::test'
description: "Description for the 'test-shortcode-guide' shortcode"
example: "test-shortcode-guide test=true"
test-config-invalid-controller: 'Foo\Bar::baz'
4 changes: 4 additions & 0 deletions tests/Fixtures/config/test_shortcodes.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,9 @@
<tag name="webfactory.shortcode" shortcode="test-service-inline" />
</service>

<service id="Thunder\Shortcode\Handler\PlaceholderHandler">
<tag name="webfactory.shortcode" shortcode="placeholder" />
</service>

</services>
</container>
Loading

0 comments on commit ee4e741

Please sign in to comment.