Квест → Как хакнуть форму
Прошли: 77
С самой первой версии Symfony 2, фреймворк предоставлял набор удобных инструментов для создания функциональных тестов. Они используют компоненты BrowserKit и DomCrawler для имитации веб-браузера, которые имеют дружественный API для разработчиков.
Давайте освежим это в памяти, создав крошечный новостной сайт и соответствующую цепочку функциональных тестов:
composer create-project symfony/skeleton news-website cd news-website/ composer require twig annotations composer require --dev maker tests php -S 127.0.0.1:8000 -t public
Теперь мы готовы кодить. Начнем с добавления класса для хранения и возвращения новостей:
// src/Repository/NewsRepository.php namespace App\Repository; class NewsRepository { private const NEWS = [ 'week-601' => [ 'slug' => 'week-601', 'title' => 'A week of symfony #601 (2-8 July 2018)', 'body' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.', ], 'symfony-live-usa-2018' => [ 'slug' => 'symfony-live-usa-2018', 'title' => 'Join us at SymfonyLive USA 2018!', 'body' => 'Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur.' ], ]; public function findAll(): iterable { return array_values(self::NEWS); } public function findOneBySlug(string $slug): ?array { return self::NEWS[$slug] ?? null; } }
Данная реализация не очень динамичная, но свою задачу выполняет. Затем, нам нужен контроллер и соответствующий шаблон Twig для отображения последних новостей сообщества. Мы будем использовать Maker Bundle для их генерации:
./bin/console make:controller News
Редактируем сгенерированный код, чтобы соответствовать нашим требованиям:
// src/Controller/NewsController.php namespace App\Controller; use App\Repository\NewsRepository; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; use Symfony\Bundle\FrameworkBundle\Controller\Controller; class NewsController extends Controller { private $newsRepository; public function __construct(NewsRepository $newsRepository) { $this->newsRepository = $newsRepository; } /** * @Route("/", name="news_index") */ public function index(): Response { return $this->render('news/index.html.twig', [ 'collection' => $this->newsRepository->findAll(), ]); } /** * @Route("/news/{slug}", name="news_item") */ public function item(string $slug): Response { if (null === $news = $this->newsRepository->findOneBySlug($slug)) { throw $this->createNotFoundException(); } return $this->render('news/item.html.twig', ['item' => $news]); } }
{# templates/news/index.html.twig #} {% extends 'base.html.twig' %} {% block title %}News{% endblock %} {% block body %} {% for item in collection %} <article id="{{ item.slug }}"> <h1><a href="{{ path('news_item', {slug: item.slug}) }}">{{ item.title }}</a></h1> {{ item.body }} </article> {% endfor %} {% endblock %}
{% extends 'base.html.twig' %} {% block title %}{{ item.title }}{% endblock %} {% block body %} <h1>{{ item.title }}</h1> {{ item.body }} {% endblock %}
Благодаря помощнику WebTestCase, добавить функциональные тесты на этот сайт просто. Во-первых, создаем скелет функционального теста:
./bin/console make:functional-test NewsControllerTest
И добавляем утверждения, чтобы проверить, что контроллер работает правильно:
// tests/NewsControllerTest.php namespace App\Tests; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class NewsControllerTest extends WebTestCase { public function testNews() { $client = static::createClient(); $crawler = $client->request('GET', '/'); $this->assertCount(2, $crawler->filter('h1')); $this->assertSame(['week-601', 'symfony-live-usa-2018'], $crawler->filter('article')->extract('id')); $link = $crawler->selectLink('Join us at SymfonyLive USA 2018!')->link(); $crawler = $client->click($link); $this->assertSame('Join us at SymfonyLive USA 2018!', $crawler->filter('h1')->text()); } }
А теперь выполняем тесты:
./bin/phpunit
Все тесты "зеленые"! Symfony предоставляет очень удобный API для навигации по сайту, проверки работоспособности ссылок и для того, чтобы убедиться, что весь ожидаемый контент отображается. Его (API) легко настроить, и он очень быстрый!
При этом, WebTestCase
не использует настоящий веб-браузер. Он имитирует его, используя простые компоненты PHP. Он даже не использует HTTP-протокол: WebTestCase
создаёт экземпляры объектов Request
из HttpFoundation, передаёт их в ядро Symfony и позволяет проверить содержимое экземпляра HttpFoundation Response
, возвращаемого приложением. Однако, что, если проблема, препядствующая загрузке веб-страницы, происходит в браузере? Подобные проблемы могут быть самыми разнообразными. Например, ссылка, скрытая неисправным правилом CSS, или "дефолтное" поведение форм, которому препятствует JavaScript-файл с багами, или, что еще хуже, обнаружение браузером уязвимости безопасности в вашем коде.
Что ж, Panther позволяет выполнить этот же сценарий в реальных браузерах! Он также добавляет API BrowserKit и DomCrawler, но под капотом он использует библиотеку Facebook PHP WebDriver. Это означает, что вы можете выбрать: выполнить такой же браузерный сценарий в молниеносно-быстрой чистой PHP-реализации (WebTestCase
) или в любом из современных браузеров, с помощью протокола автоматизации браузера WebDriver, которая стала официальной рекомендацией W3C в июне.
Что еще лучше: для того, чтобы использовать Panther, нужна только локальная установка Chrome. Больше ничего не требуется установить: ни Selenium (хоть и Panther также поддерживает его), никаких смутных драйверов или расширений для браузеров... На самом деле, т.к. Panther - это зависимость метапакета symfony/test-pack
, вы, не зная об этом, уже установили Panther, когда ранее выполнили composer require --dev tests
. Вы также можете установить Panther напрямую в любой PHP-проект с помощью composer require symfony/panther
.
Давайте изменим несколько строк в нашем существующем тест-кейсе:
// tests/NewsControllerTest.php namespace App\Tests; -use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\Panther\PantherTestCase; -class NewsControllerTest extends WebTestCase +class NewsControllerTest extends PantherTestCase { public function testNews() { - $client = static::createClient(); // Так всё ещё работает, если нужно + $client = static::createPantherClient();
Еще раз запустим тесты:
./bin/phpunit
Все снова зеленые! Но, на этот раз мы уверены, что наш сайт исправно работает в Google Chrome.
Под капотом Panther:
localhost:9000
Если вы верите только тому, что видите, попробуйте запустить следующее:
PANTHER_NO_HEADLESS=1 ./bin/phpunit
Как вы могли заметить в записи, я добавил несколько вызовов к sleep()
, чтобы показать, как это работает. Иметь доступ к окну браузера (и к Dev Tools) также очень полезно для исправления проблемного сценария.
Поскольку оба эти инструмента реализуют один и тот же API, Panther может также выполнять веб-парсинговые сценарии, написанные для популярной библиотеки Goutte. В тестовых случаях, Panther позволяет выбрать, должен ли сценарий исполняться с использованием ядра Symfony (по возможности, static::createClient()
), используя Goutte (отправить настоящие HTTP-запросы без поддержки Javascript и CSS, static::createGoutteClient()
) или используя реальный веб-браузер (static::createPantherClient()
).
Даже если Chrome - это выбор по умолчанию, Panther может управлять любым браузером, поддерживающим протокол WebDriver. Он также поддерживает удаленные сервисы тестирования браузера, такие как Selenium Grid (с открытым кодом), SauceLabs и Browserstack.
Существует также экспериментальная ветка, которая использует Geckodriver для автоматического запуска локальной установки Firefox вместо Chrome.
Наш новостной сайт выглядит хорошо, и мы только что доказали, что он работает в Chrome. Но теперь мы хотим получить обратную связь от сообщества по поводу наших частых публикаций. Добавим систему комментариев на сайт.
Для этого мы будем использовать возможности Symfony и современной веб-платформы: мы будем управлять комментариями через веб-API, и будем обрабатывать их с помощью Web Components и Vue.js. Использование JavaScript для этой фичи позволяет улучшить общую производительность и пользовательский опыт: каждый раз, когда мы публикуем новый комментарий, он будет отображаться на существующей странице без полной перезагрузки.
Symfony предоставляет официальную интеграцию с API Platform, вероятно, самый простой путь для создания современных веб API (гипермедиа и/или GraphQL). Установим его:
composer require api
Затем, вновь используем Maker Bundle, чтобы создать класс сущности Comment и сгенерировать endpoint'ы для чтения и записи:
./bin/console make:entity --api-resource Comment
Эта команда является интерактивной, и позволяет указать поля для создания. Нам нужны только два: news
(slug новости) и body
(содержимое комментария). Поле news
имеет тип string
(максимальная длина 255
символов), в то время как body имеет тип text
. Оба не могут быть null
.
Обновим .env
файл, чтобы задать адрес вашей РСУБД в DATABASE_URL
и выполним следующую команду, чтобы создать таблицу, соответствующую нашей сущности:
./bin/console doctrine:schema:create
Если вы откроете http://localhost:8000/api
, то увидите, что API уже работает и документируется.
Мы внесем некоторые незначительные изменения в создаваемом классе Comment. На данный момент API позволяет использовать GET
, POST
, PUT
и DELETE
операции. Это слишком открыто. Так как у нас пока отсутствует механизм аутентификации, мы хотим, чтобы наши пользователи могли только создавать и читать комментарии:
/** - * @ApiResource() + * @ApiResource( + * collectionOperations={"post", "get"}, + * itemOperations={"get"} + * )
Затем, мы хотим иметь возможность получить комментарии, размещенные в конкретной новостной статье. Мы будем использовать фильтр для этого:
+ use ApiPlatform\Core\Annotation\ApiFilter; use ApiPlatform\Core\Annotation\ApiResource; + use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter; /** * @ORM\Column(type="string", length=255) + * @ApiFilter(SearchFilter::class) */ private $news;
Наконец, добавим некоторые ограничения валидации, чтобы убедиться, что принятые комментарии подходят:
/** * @ORM\Column(type="string", length=255) + * @Assert\Choice(choices={"week-601", "symfony-live-usa-2018"}) * @ApiFilter(SearchFilter::class) */ private $news; /** * @ORM\Column(type="text") + * @Assert\NotBlank() */ private $body;
Перезагрузите http://localhost:8000/api
- изменения автоматически применятся.
Создание "кастомного" ограничения валидации вместо того, чтобы хардкодить список доступных slug
-ов при подтверждении выбора остается в качестве упражнения для читателя.
Это всё по части PHP! Легко, не так ли? Давайте соединим API с Vue.js! Для этого будем использовать интеграцию с Vue.js при помощи Symfony Webpack Encore.
Установим Encore и его интеграцию с Vue.js:
composer require encore yarn install yarn add --dev vue vue-loader@^14 vue-template-compiler
Обновим config Encore, чтобы включить загрузчик Vue:
// webpack.config.js Encore // ... + .addEntry('js/comments', './assets/comments/index.js') + .enableVueLoader()
Мы готовы создать классный интерфейс! Давайте начнем с Vue-компонента, отображающего список комментариев и формочку для создания нового комментария:
<!-- assets/comments/CommentSystem.vue --> <template> <div> <ol reversed v-if="comments.length"> <li v-for="comment in comments" :key="comment['@id']">{{ comment.body }}</li> </ol> <p v-else>No comments yet :(</p> <form id="post-comment" @submit.prevent="onSubmit"> <textarea name="new-comment" v-model="newComment" placeholder="Your opinion matters! Send us your comment."></textarea> <input type="submit" :disabled="!newComment"> </form> </div> </template> <script> export default { props: { news: {type: String, required: true} }, methods: { fetchComments() { fetch(`/api/comments?news=${encodeURIComponent(this.news)}`) .then((response) => response.json()) .then((data) => this.comments = data['hydra:member']) ; }, onSubmit() { fetch('/api/comments', { method: 'POST', headers: { 'Accept': 'application/ld+json', 'Content-Type': 'application/ld+json' }, body: JSON.stringify({news: this.news, body: this.newComment}) }) .then(({ok, statusText}) => { if (!ok) { alert(statusText); return; } this.newComment = ''; this.fetchComments(); }) ; } }, data() { return { comments: [], newComment: '', }; }, created() { this.fetchComments(); } } </script>
Это было не так сложно, правда?
Затем, создадим endpoint'ы для нашего приложения комментариев:
// assets/comments/index.js import Vue from 'vue'; import CommentSystem from './CommentSystem'; new Vue({ el: '#comments', components: {CommentSystem} });
Наконец, добавим референс на файл JavaScript и инициализируем веб-компонент <сomment-system>
с текущим slug-ом в шаблоне item.html.twig
:
{% block body %} <h1>{{ item.title }}</h1> {{ item.body }} + <div id="comments"> + <comment-system news="{{ item.slug }}"></comment-system> + </div> {% endblock %} + {% block javascripts %} + <script src="{{ asset('build/js/comments.js') }}"></script> + {% endblock %}
Сгенерим транспилированный и минифицированный JS файл (при желании вы можете использовать Hot Module Reloading в dev-среде):
yarn encore production
Вау! Благодаря Symfony, мы создали веб-API и классное Vue.js веб-приложение с помощью всего нескольких строк кода. Окей, давайте добавим несколько тестов для нашей системы комментариев!
Стоп... комментарии извлекаются с помощью AJAX, и обрабатываются на клиентской стороне, в JavaScript. И новые комментарии добавляются асинхронно с помощью JS. К сожалению, ни WebTestCase
, ни Goutte невозможно использовать для тестирования нашей новой фичи: они написаны на PHP, и не поддерживают JavaScript или AJAX.
Не волнуйтесь, Panther способна тестировать такие приложения. Помните: под капотом он использует реальный веб-браузер!
Давайте протестируем систему комментариев:
namespace App\Tests; use Symfony\Component\Panther\PantherTestCase; class CommentsTest extends PantherTestCase { public function testComments() { $client = static::createPantherClient(); $crawler = $client->request('GET', '/news/symfony-live-usa-2018'); $client->waitFor('#post-comment'); // Wait for the form to appear, it may take some time because it's done in JS $form = $crawler->filter('#post-comment')->form(['new-comment' => 'Symfony is so cool!']); $client->submit($form); $client->waitFor('#comments ol'); // Wait for the comments to appear $this->assertSame(self::$baseUri.'/news/symfony-live-usa-2018', $client->getCurrentURL()); // Assert we're still on the same page $this->assertSame('Symfony is so cool!', $crawler->filter('#comments ol li:first-child')->text()); } }
Будьте осторожны: в тестовом режиме, переменные среды должны быть определены с phpunit.xml.dist. Обязательно обновите DATABASE_URL
, чтобы ссылаться на чистую базу данных, заполненную нужными таблицами. Когда база данных готова, запустим тесты:
./bin/phpunit
Благодаря Panther, вы можете воспользоваться как уже имеющимеся навыками в Symfony, так и замечательным BrowserKit API для тестирования современных JavaScript-приложений.
Но это еще не все. Т.к. Panther работает с реальными веб-браузерами, он может предоставить те фичи, которые не поддерживаются компонентом BrowserKit: он способен делать скриншоты, чтобы дождаться появления элементов и выполнить кастомный JavaScript в исполняемом контексте страницы. Кроме того, в дополнение к BrowserKit API, Panther реализует интерфейс Facebook\WebDriver\WebDriver
, дающий доступ ко всем фишкам PHP WebDriver.
Давайте попробуем. Обновите предыдущий сценарий теста и сделайте скриншот обработанной страницы:
$this->assertSame('Symfony is so cool!', $crawler->filter('#comments ol li:first-child')->text()); + $client->takeScreenshot('screen.png');
Panther также предназначен для работы в системах непрерывной интеграции: он поддерживает Travis и AppVeyor из коробки, и совместим с Docker!
Прочитать полную документацию, или поставить звезду проекту можно в репозитории GitHub.
Panther построен поверх нескольких FOSS библиотек, и был вдохновлен Nightwatch.js - инструменте тестирования для Javascript, основанном на WebDriver, который я использую на протяжении нескольких лет.
Для создания этой библиотеки, я использовал несколько зависимостей, и исправил некоторые проблемы в них:
Open Source - это добродетельная экосистема: вы получаете выгоду от существующих мощных библиотек для создания инструментов высокого уровня, и в то же время вы можете "отдать", улучшая их.
Я также выкладываю низкоуровневые части, которые были созданы в ходе разработки Panther напрямую в PHP WebDriver, а именно:
Эти части затем напрямую помогут всему сообществу, в том числе альтернативам Panther, которые также построены поверх PHP WebDriver (Laravel Dusk, Codeception ...).
Я хочу поблагодарить всех участников этих проектов, и особенно Ondrej Machulda, поддерживающего PHP WebDriver в настоящее время, который уделяет время, чтобы просмотреть и объединить мои патчи. Также отдельное спасибо George S. Baugh, кто добавил поддержку протокола W3C WebDriver в его реализацию Perl WebDriver. Когда я узнал о ней, она мне очень помогла понять чем этот протокол отличается от наследуемого протокола Selenium.
Panther все еще находится в ранней стадии. Чтобы сообщить (или исправить) баг, добавить новую фичу или... поставить ему звезду, переходите по https://github.com/symfony/panther.