Добрый день, я хотел бы поднять крайне недооценённую среди PHP-разработчиков тему e2e-тестов, затронуть бизнес-важность таких тестов, техническую часть и почему их никто не пишет. Я неоднократно пытался привить культуру e2e в разных коллективах и коллектив всегда шел на это неохотно.

Разберём идею E2E-теста

Это автоматизированный мануал-тестировщик, как правило, с использованием Selenium-сервера. При запуске теста он открывает браузер и эмулирует действия пользователя - вводит данные в формы, нажимает кнопки, скроллит страницу.

С записью действий человека

Существует два концептуально разных вариантов фреймворков для e2e, мануал-тестировщики обычно используют те, что привязываются к координатам курсора на экране и таймингу нажатия клавиш. Тестировщик один раз записывает сессию последовательностей действий, а потом проигрывает её во время тестирования.

Без записи действий человека

Это как раз наш случай. В коде теста мы прописываем url тестируемой страницы, селекторы элементов, которые мы будем использовать или анализировать их наличие и состояние, сценарий поведения. Selenium делает всё остальное сам.

Зачем нужны такие тесты когда есть Unit, Pest, интеграционные?

Давайте взглянем на код примитивного не e2e-теста.

    public function test_user_can_update_own_post()
    {
        // Регистрируем политику для теста
        Gate::policy(Post::class, PostPolicy::class);

        $user = new User(['id' => 1]);
        $post = new Post(['user_id' => 1]);
        // Проверяем, может ли пользователь обновить свой пост
        $this->assertTrue($user->can('update', $post));
    }

Из кода и комментариев вроде бы всё понятно и ничего сложного. Вот только в комментариях программист нагло врёт, в первую очередь сам себе. В этом тесте assert не проверяет, может ли пользователь изменить свой пост, данный тест проверяет исключительно то, что метод can возвращает true, не более.

Чтобы пользователь смог изменить свой пост, должен выполняться целый ряд условий - аутентификация работает без багов, css не блокирует область редактирования текста, endpoint API или метод контроллера имеют валидный Request, в js нет ошибок, которые ломают vue/react, nginx правильно сконфигурирован и так далее. Приведённый выше тест ничего из этого не проверяет и не гарантирует пользователю заявленную возможность.

Как обычно работают unit-тесты
Как обычно работают unit-тесты

Кроме того, обычно тесты что-то проверяют либо в максимально стерильных условиях, либо с устаревшей структурой БД для сидирования. Я приведу немного искусственный пример стерильности тестов, но вы поймёте, что я имею в виду.

    /**
     * Тест 1: Пользователь с отрицательным счетом ДОЛЖЕН быть 
     * перенаправлен на страницу с предупреждением.
     */
    public function testNegativeBalanceRedirectsToWarningPage(): void
    {
        // Пользователь пытается зайти в личный кабинет (/profile/*) с балансом -100
        $nextUrl = $this->middleware->handle(
            currentUrl: '/profile/', 
            userBalance: -100
        );

        // Тест успешно подтверждает редирект на страницу предупреждения. 
        // Сначала пусть юзер пополнит счет.
        $this->assertEquals('/warning', $nextUrl);
    }

    /**
     * Тест 2: На счет пользователя успешно засчитывается 200.
     */
    public function testUserCanSuccessfullyDepositMoney(): void
    {
        // Исходный баланс -100. Кладем 200 рублей.
        $this->billing->deposit(200);

        // должно остаться 100
        $this->assertEquals(100, $this->billing->getBalance());
    }

Вот только при попытке пополнить свой баланс на странице /profile/deposit реального пользователя будет ждать разочарование, его перекинет на страницу с предупреждением. Все тесты зелёные, все довольны кроме пользователей и бизнеса. В данном случае оба теста находятся в изолированных друг от друга условиях. С e2e тестами тоже можно попасть в подобную ситуацию, но это намного сложнее.

А теперь про e2e

Будем рассматривать selenium-тесты на простом пет-проекте, который состоит из:

  • пользователь может регистрироваться, авторизироваться, удалять аккаунт

  • пользователь может создавать проект

  • в проекте пользователь может создавать текст в проекте, каждый следующий текст имеет более высокую версию

простенький сайт с несколькими CRUD
простенький сайт с несколькими CRUD

Если вы знаете как настраивать dusk в Laravel, можете немного промотать страницу вниз. Если же не знаете, то я рекомендую сначала ознакомиться с официальной документацией по Laravel Dusk (ссылка на перевод документации) перед тем как читать дальше, иначе будет не очень понятно. Пошаговой инструкции со всеми возможными проблемами я приводить не буду, статья рассчитана на программистов, которые уже применяют в работе unit или pest тесты.

Как подружить Selenium с докером

Без докера работать с Selenium заметно проще, но поскольку сейчас работа через контейнеры это стандарт, придётся добавить такое в docker-compose.yml

  chrome:
    image: selenium/standalone-chromium:4.47.0
    shm_size: 1g
    container_name: chrome_container
    ports:
      - "4444:4444"
      - "5900:5900"
      - "7900:7900"
    volumes:
      - /dev/shm:/dev/shm
    depends_on:
      - php
      - nginx
    networks:
      - docker-testing-network

Это даст нам Selenium Grid, мы сможем через него подключаться к Selenium во время теста и из браузера смотреть как наш тест прокликивает кнопки, заполняет формы. Само собой, этот контейнер в production не нужен, поэтому для dev и production будут нужны разные конфигурационные файлы докера.

Также нам понадобится отдельный файл .env.dusk.local. Он используется во время тестов вместо .env файла, там же при желании можете отдельную тестовую БД указать. Можете содержимое скопировать из .env файла и перенести в .env.dusk.local, а потом туда дописать/исправить такое:

DUSK_HEADLESS_DISABLED=true
DUSK_SERVER_URL=http://chrome_container:4444/wd/hub
DUSK_DEFAULT_ACTOR=dusk.actor@example.org
DUSK_DEFAULT_USER_PASSWORD=1234512345


APP_ENV=testing
APP_URL=http://app_container/

Первая строчка означает, что тесты буду запускаться не в HEADLESS-режиме чтобы мы могли их наблюдать. Обычно, эта опция установлена в false для скорости работы и экономии ресурсов, меняйте на true только когда хотите понаблюдать за тестами лично. Далее у меня указан url по которому идёт связь selenium и selenium grid, а потом логин и пароль для "мануал-тестировщика" по умолчанию. Этот кусок настроек не является частью документации по Dusk, это мой личный подход к e2e тестированию, можете придумать себе что-то более удобное. Обязательно указываем в этом файле, что окружение тестовое. app_container в моём случае это имя контейнера nginx из docker-compose.yml, для dusk надо указать его как url проекта. Естественно, магическим образом эти значения не применятся нигде в коде, их надо будет использовать с помощью env(...).

Если у вас есть заготовленный сид БД, например, дамп с прода с проблемными ситуациями, то можно в файл .env.dusk.local вынести логин/пароль для каждой роли - менеджер, админ, клиент, бухгалтер, а не создавать их в коде с нуля, а потом накидывать им ещё права и роли. Полезно для точного воссоздания проблемной ситуации, а не попытки её эмулировать.

Примеры кода dusk тестов

Критикуйте мой код на здоровье, я не против. И нет, я писал эти тесты руками без ИИ, а не вайбкодил. И да, у меня привычка писать комментарии на английском.

Класс DuskTestCase

<?php

namespace Tests;

use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Faker\Factory as Faker;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Hash;
use Laravel\Dusk\Browser;
use Laravel\Dusk\TestCase as BaseTestCase;
use PHPUnit\Framework\Attributes\BeforeClass;
use App\Models\User;
use Tests\Browser\Pages\Login;

abstract class DuskTestCase extends BaseTestCase
{

    /**
     * @var string
     */
    public string $basicPassword = '';

    /**
     * @var User
     */
    public User $user;

    /**
     * Prepare for Dusk test execution.
     */
    #[BeforeClass]
    public static function prepare(): void
    {

    }

    protected function setUp(): void
    {
        parent::setUp();
        $this->artisan('migrate');
        foreach (static::$browsers as $browser) {
            $browser->driver->manage()->deleteAllCookies();
        }

        $faker = Faker::create();
        $this->basicPassword = env('DUSK_DEFAULT_USER_PASSWORD') ?: $faker->password(8);

        if(env('DUSK_DEFAULT_ACTOR'))
        {
            $user = User::where('email', '=', env('DUSK_DEFAULT_ACTOR'))->first();
            if(!$user)
            {
                $user = User::create([
                    'name' => $faker->name(),
                    'email' => env('DUSK_DEFAULT_ACTOR'),
                    'password' => Hash::make(env('DUSK_DEFAULT_USER_PASSWORD') ?: $faker->password(10)),
                ]);
            }
        } else
        {
            do {
                $email = $faker->unique()->safeEmail;
                $user = User::where('email', $email)->first();
            } while($user);
            $user = User::create([
                'name' => $faker->name(),
                'email' => $email,
                'password' => Hash::make(env('DUSK_DEFAULT_USER_PASSWORD') ?: $faker->password(10)),
            ]);
        }
        $this->user = $user;
    }


    /**
     * Create the RemoteWebDriver instance.
     */
    protected function driver(): RemoteWebDriver
    {
        $options = (new ChromeOptions)->addArguments(collect([
            $this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080',
            '--disable-search-engine-choice-screen',
        ])->unless($this->hasHeadlessDisabled(), function (Collection $items) {
            return $items->merge([
                '--disable-gpu',
                '--headless=new',
                '--whitelisted-ips=""',
                '--disable-dev-shm-usage',
            ]);
        })->all());


        return RemoteWebDriver::create(
            env('DUSK_SERVER_URL'),
            DesiredCapabilities::chrome()
                ->setCapability(ChromeOptions::CAPABILITY, $options)
        );
    }

    /**
     * @param Browser $browser
     * @return Browser
     */
    protected function loginUser(Browser $browser): Browser
    {
        $browser->visit(new Login)
            ->fillInLoginForm($this->user->email, $this->basicPassword)
            ->click('@submit');

        return $browser;
    }
}

Что тут происходит:

  • подготовка драйвера хромиума

  • прогон миграций (у меня отдельная БД для авто-тестов)

  • подготовка "актёра" - того самого автоматического мануал-тестировщика, который будет прокликивать наш интерфейс

  • каждый тест кроме регистрации у меня начинается с залогинивания, так что для тестов я вынес логин в родительский класс

Класс Page

В Dusk удобно часто используемый функционал объединять в страницы, например, группировать по Login, Register, Profile и так далее. Это код базового класса для всех страниц.

<?php

namespace Tests\Browser\Pages;

use Laravel\Dusk\Page as BasePage;

abstract class Page extends BasePage
{
    /**
     * Get the global element shortcuts for the site.
     *
     * @return array<string, string>
     */
    public static function siteElements(): array
    {
        return [
            '@element' => '#selector',
        ];
    }
}

Класс Login

<?php

namespace Tests\Browser\Pages;

use Laravel\Dusk\Browser;

class Login extends Page
{
    /**
     * Get the URL for the page.
     */
    public function url(): string
    {
        return route('login', [], false);
    }

    /**
     * Assert that the browser is on the page.
     */
    public function assert(Browser $browser): void
    {
        $browser->assertPathIs($this->url());
    }

    /**
     * Get the element shortcuts for the page.
     *
     * @return array<string, string>
     */
    public function elements(): array
    {
        return [
            '@element' => '#selector',
        ];
    }

    public function fillInLoginForm(Browser $browser, string $email, string $password): void
    {
        $browser->visit($this->url())
            ->type('@email', $email)
            ->type('@password', $password);
    }
}

Тут должно быть всё понятно - идёт заполнение формы логина нужным email и password, но не совершается клик по кнопке submit, этот клик вызывается уже в самих тестах.

Тест логина

<?php

namespace Tests\Browser;

use Laravel\Dusk\Browser;
use Tests\Browser\Pages\Login;
use Tests\DuskTestCase;

class LoginTest extends DuskTestCase
{

    /**
     * @return void
     * @throws \Throwable
     */
    public function testLogin(): void
    {
        $this->browse(function (Browser $browser) {
            $email = $this->user->email;
            $password = $this->basicPassword;
            $browser->visit(new Login)
                ->fillInLoginForm($email, $password)
                ->click('@submit')
                ->assertRouteIs('dashboard');
        });
    }
}

И вот тут уже видно, что мы вызываем через класс страницы заполнение формы логина валидными email и password, а потом уже нажимаем на кнопку и проверяем, что после отправки формы пользователя перенаправило на страницу dashboard. В реальном проекте неплохо бы проверять значение Auth::user(), правда, в зависимости от версии Laravel ваш Dusk может ругаться о том, что не знает что такое Auth.

Селекторы

Когда-то давно до появления Laravel в его текущем виде я покрывал Selenium-тестами чужой код на голом php и мучался с селекторами элементов выискивая восьмой p в двадцатом div с классом section. Потом я перешел на добавление уникальных селекторов вроде css-классов selenium_[нужный_мне_id] добавляя их в исходный код проекта чтобы в тестах было проще. В Dusk это уже предусмотрено. Вы по проекту просто раздаёте элементам дополнительные атрибуты, например, dusk="submit" или dusk="text_".$text->id, а потом в тестах их используете как '@submit' и '@text_'.$this->text->id.

Если у вас в проекте на фронте есть препроцессор селекторов и атрибутов, и он выкидывает в целях оптимизации неиспользуемое, добавьте в него исключение по маске dusk=(['"])([^'"]*?)\1 или же проводите тесты без этого препроцессора. Первый вариант предпочтительнее, этот препроцессор может сломать проект сам по себе на проде, а мы в e2e пытаемся тестировать пользовательский опыт в максимально приближенных к production условиях.

Если по какой-то причине вам не подходит атрибут dusk, в тестах можно использовать xPath, поиск по id, классам и прочим атрибутам, но это уже неудобно.

Почему e2e никто не использует

Прежде чем мы вернемся к коду, я бы хотел написать почему культура e2e непопулярна, во всяком случае в php.

  • бизнес не хочет тратиться на тесты и выделять на них время

  • e2e это технология которую за 10 минут не освоить, программист должен потратить немало времени

  • selenium-тесты это довольно объемный код, который ничего не делает в production

  • придётся учить всем программистам на проекте, недавно нанятым на проект тоже придётся учить дополнительный мини-фреймворк

  • не получится переложить это на тестировщиков, для них это будет либо слишком сложно, либо они освоят программирование и им накидают задач из беклога вместо тестов

  • не получится тестировать удобство интерфейса, светло-бежевый текст на белом фоне для selenium такой же понятный как и белый текст на чёрном

Другими словами, это дорого как для самого программиста по времени, так и для бизнеса по деньгам. А учитывая, что сейчас даже крупные компании перекладывают поиск багов на своих корпоративных клиентов и всеми способами удешевляют производство кода, дорогие тесты не пользуются популярностью.

Почему e2e тесты это всё таки круто

  • Они дают максимальную гарантию работоспособности проекта из всех вариантов тестирования, разве что уступают ручной проверке. Тестируется не просто какой-то класс одного из множества микросервисов, а опыт пользователя системы как единого целого

  • Можно встроить в пайплайн CI/CD, запускать тесты на любом этапе, в случае фейла быстро обнаружать баги по скринам багов (до скриншотов доберёмся)

  • Можно тестировать не только что-то локально развёрнутое, никто не мешает настроить url для тестов на домен тестового сервера. Или же при наличии проекта на Go/React нам ничто не запрещает написать e2e тесты на Laravel в отдельном репозитории для данного проекта

  • Можно тестировать защиту от спам-ботов кликеров, Selenium по сути таковым и является

  • Можно автоматизировать сбор информации с нужных сайтов и обходить защиту от кравлеров информации, вычислить грамотно написанного бота на selenium не так уж и просто

  • Можно сделать набор драйверов с разными браузерами, наборами cookie, разрешениями экранов, прогонять циклом тесты на каждом из драйверов

  • Можно тестировать внешние сервисы без которых проект не может работать - по крону 1-2 раза в день запускаем нужный тест на предмет показа формы платёжного агрегатора на тестовом сервере

Вернемся к простыням с кодом

Итак, после запуска php artisan dusk мы можем открыть в браузере http://localhost:4444 и там после ввода пароля по умолчанию "secret" мы можем наблюдать как наш автоматический мануал тестировщик проверяет функционал

Selenium-сервер в работе
Selenium-сервер в работе

Если же какой-то из тестов упадёт, то, во-первых, вы увидите сообщение о фейле теста в терминале, во-вторых, скриншот ошибки будет помещён в папку test/Browser/screenshots

Тесты на CRUD простой модели

<?php

namespace Tests\Browser;

use Laravel\Dusk\Browser;
use Tests\Browser\Pages\Project;
use Tests\DuskTestCase;
use Faker\Factory as Faker;

class ProjectTest extends DuskTestCase
{

    /**
     * @return void
     * @throws \Throwable
     */
    public function testCreate(): void
    {
        $namePrefix = 'test_cr ';
        $data = $this->createProjectForFurtherTesting($namePrefix);

        $this->assertDatabaseHas('projects', ['name' => $data['name'], 'description' => $data['description']]);
    }

    /**
     * @return void
     * @throws \Throwable
     */
    public function testUpdate(): void
    {
        $namePrefix = 'test_upd ';
        $data = $this->createProjectForFurtherTesting($namePrefix);
        $project = \App\Models\Project::where('name', '=', $data['name'])->orderBy('id', 'desc')->first();
        $this->browse(function (Browser $browser) use ($project) {
            $browser->click('@edit'.$project->id)
                ->assertRouteIs('projects.show', ['id' => $project->id])
                ->click('@edit'.$project->id)
                ->assertRouteIs('projects.edit', ['id' => $project->id])
                ->append('name', '_upd')
                ->append('description', '_upd')
                ->click('@submit')
                ->assertRouteIs('projects.index');
        });
        $this->assertDatabaseHas('projects', ['id' => $project->id, 'name' => '_upd'.$data['name'], 'description' => $data['description'].'_upd']);
    }

    /**
     * @return void
     * @throws \Throwable
     */
    public function testDelete(): void
    {
        $namePrefix = 'test_del ';
        $data = $this->createProjectForFurtherTesting($namePrefix);
        $project = \App\Models\Project::where('name', '=', $data['name'])->orderBy('id', 'desc')->first();
        $this->browse(function (Browser $browser) use ($project) {
            $browser->click('@edit'.$project->id)
                ->assertRouteIs('projects.show', ['id' => $project->id])
                ->click('@delete')
                ->assertRouteIs('projects.index')
                ->assertNotPresent('@edit'.$project->id);
        });
        $this->assertDatabaseMissing('projects', ['id' => $project->id, 'deleted_at' => null]);
    }

    /**
     * @param string $namePrefix
     * @return array
     * @throws \Throwable
     */
    protected function createProjectForFurtherTesting(string $namePrefix): array
    {
        $faker = Faker::create();
        $name = $namePrefix. $faker->words(2, true);
        $description = $faker->sentence();
        $this->browse(function (Browser $browser) use ($name, $description) {
            $this->loginUser($browser)
                ->visit(new Project)
                ->assertSee('Create new')
                ->click('@create_project')
                ->assertRouteIs('projects.create')
                ->fillInCreateForm($name, $description)
                ->click('@submit')
                ->assertRouteIs('projects.index');
        });

        return ['name' => $name, 'description' => $description];
    }
}

Коротко пройдемся по коду выше. Метод createProjectForFurtherTesting является вспомогательным, он создаёт свежий проект с которым потом будут совершаться различные действия. В методах создания, изменения и удаления тестируется, что проект есть в БД физически (или его нет после удаления), пользователь находится на нужной странице после отправки формы или клика кнопки. После удаления также тестируется отсутствие кнопки Edit для только что удалённого проекта. Это очень примитивные тесты, но их уже неслабо раздуло, времени уходит очень много при ручном написании, но если переложить эту задачу на ИИ с правильными гайдлайнами, то можно генерировать довольно быстро, главное, чтобы вы сами понимали смысл кода тестов.

Выше мы разобрали e2e тесты для простенького CRUD, но это не интересно на практике. Что действительно надо тестировать, так это жизненно важные user stories ради которых был создан проект.

  • Администратор ресурса смог зайти в админку -> смог добавить новый товар -> анонимный пользователь или покупатель после авторизации видит этот товар (всё одним тестом)

  • Посетитель сайта смог зайти на страницу товара -> добавить его в корзину -> перейти в корзину -> там правильная цена (смотрим в ТЗ какая должна быть по бизнес-логике с учётом скидок и НДС) -> пользователь смог оформить заказ (тоже одним большим тестом без сброса БД)

  • После оформления заказа администратор получил уведомление, зашел в админку и увидел информацию по заказу -> выполнил необходимые действия

В Dusk можно накидать в папку файлы и тестировать их прикрепление к формам. Например, загрузка изображений, экселевских таблиц, бухгалтерских документов, а потом проверять адекватность обработки этих файлов системой. Когда вам приходит очередной багрепорт, что какой-то файл ваши бухгалтеры не смогли закинуть в админку, делаем фикс кода, добавляем этот файл в папку с xlsx-таблицами, проверяем в цикле по файлам корректность обработки каждого из них.

В тестах с драйвером Firefox дасковские prepend и append иногда работают противоположным образом, надеюсь, в последних версиях починили.

Клик мыши на элементе !== click(...) через Selenium. Вместо реального клика мышкой вызывается js-событие, крайне редко, но разница в поведении может быть. Для реального помещения курсора над элементом и срабатывания ЛКМ придётся написать чуть больше кода.

Если тестов накопилось много, то можно разбить их на группы аннотациями типа

/** @group critical */

А потом вызывать как php artisan dusk --group=critical , а ещё лучше это поместить в Makefile и вызывать короткими командами.