Статьи

Что такое тестирование снимков и действительно ли это в PHP?

Эта статья была рецензирована Мэттом Траском , Полом М. Джонсом и Язидом Ханифи . Спасибо всем рецензентам SitePoint за то, что сделали контент SitePoint как можно лучше!


Векторное изображение поляроида, приклеенного к прозрачному фону

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

Для меня это то, что тестирование снимков.

Вы, вероятно, пишете много кода PHP, но сегодня я хочу поговорить о том, что я узнал в JavaScript. Мы узнаем о том, что такое тестирование моментальных снимков, а затем посмотрим, как оно может помочь нам написать лучшие PHP-приложения.

Построение интерфейсов

Давайте поговорим о React. Не офигенный асинхронный проект PHP , а офигенный проект JavaScript . Это инструмент генерации интерфейса, в котором мы определяем, как наша разметка интерфейса должна выглядеть как отдельные части:

function Tweet(props) {
  return (
    <div className="tweet">
      <img src={props.user.avatar} />
      <div className="text">
        <div className="handle">{props.user.handle}</div>
        <div className="content">{props.content}</div>
      </div>
    </div>
  )
}

function Tweets(props) {
  return (
    <div className="tweets">
      {props.tweets.map((tweet, i) => {
        return (
          <Tweet {...tweet} key={i} />
        )
      })}
    </div>
  )
}

Это не похоже на ванильный Javascript, а скорее на безобразную смесь HTML и Javascript. Можно создавать компоненты React, используя обычный синтаксис Javascript:

 function Tweet(props) {
  return React.createElement(
    "div",
    { className: "tweet" },
    React.createElement("img", { src: props.user.avatar }),
    React.createElement(
      "div",
      { className: "text" },
      React.createElement(
        "div",
        { className: "handle" },
        props.user.handle
      ),
      React.createElement(
        "div",
        { className: "content" },
        props.content
      )
    )
  );
}

Чтобы сделать этот код, я вставил функцию TweetREPL Babel . Это то, к чему сводится весь код React (за исключением периодической оптимизации) перед выполнением браузером.

Прежде чем говорить о том, почему это круто, я хочу рассмотреть пару вопросов …

«Почему вы смешиваете HTML и Javascript ?!»

Мы потратили много времени на обучение и изучение того, что разметка не должна смешиваться с логикой. Обычно это выражается во фразе «Разделение проблем». Дело в том, что разделение HTML и Javascript, который создает и манипулирует этим HTML, в основном не имеет никакой ценности.

Разделение этой разметки и Javascript — это не столько разделение интересов, сколько разделение технологий. Пит Хант рассказывает об этом более подробно в этом видео .

«Этот синтаксис очень странный»

Это может быть, но это вполне возможно воспроизвести на PHP и работает в Hack:

 class :custom:Tweet extends :x:element {
  attribute User user;
  attribute string content;

  protected function render() {
    return (
      <div class="tweet">
        <img src={$this->:user->avatar} />
        <div class="text">
          <div class="handle">{$this->:user->handle}</div>
          <div class="content">{$this->:content}</div>
        </div>
      </div>
    );
  }
}

Я не хочу подробно об этом диком синтаксисе, кроме как сказать, что этот синтаксис уже возможен. К сожалению, похоже, что официальный модуль XHP поддерживает только HHVM и старые версии PHP…

Тестирование интерфейсов

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

Возможно, вы слышали о таких вещах, как Selenium и Behat ? Я не хочу останавливаться на них слишком много. Давайте просто скажем, что Selenium — это инструмент, который мы можем использовать, чтобы притвориться браузером, а Behat — удобный для бизнеса язык для написания таких претензий.

К сожалению, многие тесты на основе браузера могут быть хрупкими. Это связано с точной структурой разметки и не обязательно связано с логикой, которая генерирует разметку.

Тестирование снимков — это другой подход к тому же. React побуждает думать обо всем интерфейсе с точки зрения мельчайших частей, на которые он может быть разбит. Вместо того, чтобы собирать всю корзину, она поощряет разбивать вещи на отдельные части; нравиться:

  • каждый продукт
  • список продуктов
  • детали доставки
  • индикатор прогресса

При создании каждой из этих частей мы определяем, какой должна быть разметка и стили, учитывая любую исходную информацию. Мы определяем это путем создания метода render

 class Tweets extends React.Component {
  render() {
    return (
      <div className="tweets">
        {props.tweets.map((tweet, i) => {
          return (
            <Tweet {...tweet} key={i} />
          )
        })}
      </div>
    )
  }
}

… или путем определения простой функции, которая будет возвращать строку или React.Component Предыдущие примеры продемонстрировали функциональный подход.

Это интересный способ думать об интерфейсе. Мы пишем render

И именно этот способ мышления приводит к простейшему способу тестирования компонентов React: тестированию моментальных снимков. Подумайте об этом на минуту …

Мы строим компоненты React для визуализации самих себя, учитывая любую исходную информацию. Мы можем проработать все возможные входы в нашей голове. Мы даже можем определить строгую проверку того, какую исходную информацию (или свойства) мы допускаем в наши компоненты.

Итак, если мы сможем проработать эти сценарии при разработке нашего компонента: тогда мы сможем проработать их в тесте. Давайте создадим новый компонент:

 const Badge = function(props) {
  const styles = {
    "borderColor": props.borderColor
  }

  if (props.type === "text") {
    return (
      <div style={styles}>{props.text}</div>
    )
  }

  return (
    <img style={styles} src={props.src} alt={props.text} />
  )
}

Это наш компонент Badge Это может быть двух типов: textimage Он также может иметь цвет границы. Мы можем проверить и создать значения по умолчанию для этих свойств:

 const requiredIf = function(field, value, error) {

  // custom validators expect this signature
  return function(props, propName, componentName) {

    // if props.type === "image" and props.src is not set
    if (props[field] === value && !props[propName]) {

      return new Error(error)
    }
  }
}

Badge.propTypes = {
  "borderColor": React.PropTypes.string,
  "type": React.PropTypes.oneOf(["text", "image"]),
  "src": requiredIf("type", "image", "src required for image")
}

Badge.defaultProps = {
  "borderColor": "#000",
  "type": "text"
}

Итак, как можно использовать этот компонент? Есть несколько вариантов:

  • Без указания borderColortype
  • Изменение typeimagesrc
  • Изменение borderColor

Это начинает звучать как тест. Что если мы назвали визуализированный компонент с четко определенным начальным набором данных моментальным снимком? Мы могли бы описать эти сценарии с помощью некоторого JavaScript:

 import React from "react"
import Tweets from "./tweets"

import renderer from "react-test-renderer"

test("tweets are rendered correctly", function() {
  const defaultBadge = renderer.create(
    <Tweets>...</Tweets>
  )

  expect(component.toJSON()).toMatchSnapshot()

  const imageBadge = renderer.create(
    <Tweets type="image" src="path/to/image">...</Tweets>
  )

  expect(component.toJSON()).toMatchSnapshot()

  const borderedBadge = renderer.create(
    <Tweets borderColor="red">...</Tweets>
  )

  expect(component.toJSON()).toMatchSnapshot()
})

Нам нужно настроить Jest , прежде чем мы сможем запустить этот код.

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

При этом мы можем видеть, что тестирование моментальных снимков — это средство, с помощью которого мы можем предсказать вывод данной функции (или внутреннее состояние заданного объекта) и сравнить будущие результаты с ней. С четко определенным планом данных, которые мы ожидаем, что наш код даст, мы можем легко определить, когда результат является неожиданным или неправильным.

У этого есть старое имя: Тестирование характеристик . Мы пишем тесты с характеристиками (или снимками), устанавливая правильно сформированный ожидаемый результат. Последующие тесты сравнивают вывод нашего кода с базовой линией, используя те же входные данные.

Теперь давайте подумаем о сценариях, где это может быть полезно для нас …

Тестирование снимков в PHP

Мы рассмотрим несколько вариантов использования для тестирования снимков. Прежде чем мы это сделаем, позвольте мне представить вам плагин для тестирования снимков PHPUnit: https://github.com/spatie/phpunit-snapshot-assertions

Вы можете установить его через:

 composer require --dev spatie/phpunit-snapshot-assertions

Он предоставляет ряд помощников в виде:

 use PHPUnit\Framework\TestCase;
use Spatie\Snapshots\MatchesSnapshots;

class ExampleTest extends TestCase
{
  use MatchesSnapshots;

  public function testExamples()
  {
    $this->assertMatchesSnapshot(...);
    $this->assertMatchesXmlSnapshot(...);
    $this->assertMatchesJsonSnapshot(...);
  }
}

Эти методы создают файлы снимков того же типа, что и Jest. В общем, нам нужно использовать только первый, но второй и третий генерируют более интересные различия для их соответствующих типов контента.

Шаблоны

Первое и довольно очевидное использование для этого — в тестировании шаблонов. Чем меньше наших шаблонов, тем проще будет написать для них тестовые снимки. Это особенно полезно при использовании шаблонизаторов, которые принимают начальные свойства:

 <div class="tweet">
  <img src={{ $user->avatar }} />
  <div class="text">
    <div class="handle">{{ $user->handle }}</div>
    <div class="content">{{ $content }}</div>
  </div>
</div>
 Route::get("/first-tweet", function () {
  return view(
    "tweet", Tweet::first()->toArray()
  );
});

Понятно, что шаблон должен производить, учитывая повторяемую исходную информацию. Мы могли бы даже посмеяться над начальными данными и подтвердить вывод:

 namespace Tests\Unit;

use Spatie\Snapshots\MatchesSnapshots;
use Tests\TestCase;

class ExampleTest extends TestCase
{
  use MatchesSnapshots;

  public function testTweetsRenderCorrectly()
  {
    $user = new User();
    $user->avatar = "path/to/image";
    $user->handle = "assertchris";

    $tweet = new Tweet();
    $tweet->user = $user;
    $tweet->content = "Beep boop!";

    $rendered = view(
      "tweet", $tweet
    );

    $this->assertMatchesSnapshot($rendered);
  }
}

Я показываю примеры из приложения Laravel (купите наш вводный курс здесь !). Laravel использует PHPUnit под капотом, поэтому примеры будут работать и за пределами Laravel, с достаточной модификацией. Laravel является полезной отправной точкой, потому что он поставляется с механизмом шаблонов , маршрутизацией и ORM.

Событие Sourcing

Архитектура Event Sourcing особенно хорошо подходит для тестирования моментальных снимков. Их много, так что не стесняйтесь читать их !

Основная идея Event Sourcing заключается в том, что база данных доступна только для записи. Ничто не удаляется, но каждое значимое действие ведет к записи события. В отличие от большинства приложений CRUD, которые свободно создают и удаляют последнее состояние записей, приложения Event Source добавляют записи, которые представляют все действия.

Тестирование снимков работает с или без Event Sourcing, но тестирование потока событий без него утомительно. Представьте себе, что вы можете утверждать последнее состояние записи, но также без усилий утверждать каждый шаг, который привел запись в это состояние.

 // continuing from sitepoint.com/event-sourcing-in-a-pinch...

$events = [
  new ProductInvented(...),
  new ProductPriced(...),
  new OutletOpened(...),
  new OutletStocked(...),
];

$this->assertMatchesSnapshot($events);

$projected = project($connection, $events);

$this->assertMatchesSnapshot($projected);

Еще до этих утверждений полезно имитировать активность браузера (или любого другого носителя ввода, который позволяет это приложение), чтобы утверждать, генерирует ли взаимодействие интерфейса моментальный снимок, который соответствует моментальному снимку сгенерированных событий.

Задачи в очереди

Асинхронные системы особенно трудны для тестирования. В худшем случае мы игнорируем эти части нашего приложения. В лучшем случае мы боремся за процесс насмешки над поставщиком очередей за каждое отложенное взаимодействие с системой. Что если бы мы могли протестировать все задачи из очереди сразу для всего нашего набора тестов?

 php artisan queue:table
php artisan migrate

Это создает таблицу базы данных для драйвера очереди базы данных.

 return [
  "default" => env("QUEUE_DRIVER", "database"),
  // ...remaining configuration
];

Это из config/queue.php

 namespace Tests\Unit;

use Spatie\Snapshots\MatchesSnapshots;
use Tests\TestCase;

class ExampleTest extends TestCase
{
  use MatchesSnapshots;

  public function testQueueSomething()
  {
    // ...do something that leads to dispatch(new Job);
  }

  public function testQueueSomethingAgain()
  {
    // ...do something that leads to dispatch(new AnotherJob);
  }

  public function testQueue()
  {
    $table = config("queue.connections.database.table");
    $jobs = app("db")->select("select * from {$table}");

    $this->assertMatchesSnapshot($jobs);
  }
}

Как только задания удалены, они больше не будут возвращаться оператором select. Вы должны убедиться, что слушатель очереди / демон не запущен для той же таблицы базы данных, пока выполняются тесты.

Хрупкие тесты

Вы можете подумать, что тесты моментальных снимков являются хрупкими. Так же хрупко, как плохо написанные тесты интерфейса. Только чуть более хрупкий, чем хорошо написанные тесты интерфейса. И ты был бы прав.

Вопрос, который мы должны задать, заключается в том, почему хрупкие испытания являются проблемой. Приложение с некоторыми полезными тестами находится в лучшем месте, чем приложение без тестов вообще. Но когда тесты становятся хрупкими, любой рефакторинг их нарушает. Даже несущественные или косметические изменения могут сломать тесты. Менять ли класс div с «some-style» на «some-style»? Если вы используете это в селекторе CSS, то это может нарушить тесты.

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

Но на самом деле мы не пишем никаких тестов с Snapshot Testing . На самом деле, нет. Конечно, мы можем написать модульные тесты и интеграционные тесты, чтобы дополнить наши тесты Snapshot. Но когда мы пишем эти тесты, мы просто берем что-то сериализуемое и сравниваем с тем, что было сериализовано, как в первую очередь. И когда они ломаются, после того, как мы уверены, что на самом деле ничего не сломалось, мы можем просто удалить снимок и повторить попытку.

В некотором смысле, тестирование снимков делает тестирование без TDD более интерактивным. Это нормально, что эти тесты хрупкие, потому что они легко восстанавливаются, и все еще полезны во многих отношениях.

Резюме

У меня был замечательный момент, когда я впервые узнал о Snapshot Testing. С тех пор большинство входных тестов, которые я пишу, являются тестами моментальных снимков. Было интересно рассмотреть варианты использования этого в PHP. Я надеюсь, вы так же взволнованы, чтобы копаться в этом, как и я!

Можете ли вы вспомнить больше мест, где это было бы полезно? Расскажите нам о них в комментариях!

Спасибо, Пол Джонс, за то, что указал мне на Тестирование характеристик.