Data-Driven тестирование в PHPUnit

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

Небольшой пример для начала:

Допустим, вы пишите библиотеку которая трансформирует Markdown в HTML. У вас в тестах скорее всего будут прописаны подобные случаи:

  • Трансформация для h1 тегов
  • Трансформация для блоков кода
  • Трансформация для ссылок
  • ...

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

class ExampleTest extends TestCase
{
  /** @test */
  public function transform_title()
  {
      //
  }

  /** @test */
  public function transform_code()
  {
      //
  }

  /** @test */
  public function transform_links()
  {
      //
  }
}

В чем же проблема?

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

Теперь представим что у нас не 3, а 50 трансформеров. Файл с тестами превратится в огромное нечто, совершенно нечитаемое и неподдерживаемое.

Окей гугл: как мне поможет Data-driven тестирование и что это вообще такое?

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

Пример набора данных для теста:

$dataSet = [
  ['some A', TransformerA::class, 'expected output A'],
  ['some B', TransformerA::class, 'expected output B'],
  ['some C', TransformerA::class, 'expected output C'],
]

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

class ExampleTest extends TestCase
{
  /** @test */
  public function transform_content()
  {
    $dataSet = $this->dataSet();

    foreach($dataSet as $data) {
      $value = $data[0]
      $class = new $data[1];
      $expected = $data[2]
      $this->assertEquals($expected, $class->process($value));
    }
  }

  public function dataSet()
  {
    return [
      ['# Title #', TitleTransformer::class, '<h1>Title</h1>'],
      ['`$var`', CodeTransformer::class, '<code>$var</code>'],
      ['[Link](http://laravel-news.com)', TitleTransformer::class, '<a href="http://laravel-news.com">Link</a>']
    ]
  }
}

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

Data-driven тестирование и PHPUnit

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

PHPUnit подерживает аннотации, которые облегчают чтение и поддержку кода. Например, вместо:

public function test_some_method_name()
{

}

Мы можем использовать аннотацию @test и написать так:

/** @test **/
public function some_method_name()
{

}

Аннотация @test позволяет избавиться от префикса test_ в имени теста.

Но нас интересует другая аннотация: @dataProvider. Используется она так:

class ExampleTest extends TestCase
{
  /*
   * @test
   * @dataprovider dataSet
   */
  public function transform_content($input, $class, $expected)
  {
    $class = new $class;
    $this->assertEquals($expected, $class->process($value));
  }

  public function dataSet()
  {
    return [
      ['# Title #', TitleTransformer::class, '<h1>Title</h1>'],
      ['`$var`', CodeTransformer::class, '<code>$var</code>'],
      ['[Link](http://laravel-news.com)', TitleTransformer::class, '<a href="http://laravel-news.com">Link</a>']
    ]
  }
}

Неправда ли красиво? PHPUnit сам проходится циклом по массиву который возвращает метод dataSet() и вызывает тестовый метод с нужными параметрами! Нам остается сосредоточиться на добавлении новых данных в метод dataSet(), а не плодить десятки методов с одни и тем же функционалом.

В случае ошибки вы увидите примерно такой вывод:

There was 1 failure:

1) TestsUnitExampleTest::transform_content with data set #1 ('# Title #', TitleTransformer::class, '<h1>Title</h1>')
...

Довольно таки подброный вывод, не так ли? Однако мы можем пойти еще дальше, и добавить ключи для наших данных:

public function dataSet()
{
  return [
    'Transform titles' => ['# Title #', TitleTransformer::class, '<h1>Title</h1>'],
    'Transform code text' => ['`$var`', CodeTransformer::class, '<code>$var</code>'],
    'Transform links' => ['[Link](http://laravel-news.com)', TitleTransformer::class, '<a href="https://laravel-news.com">Link</a>']
  ]
}

И тогда ошибка будет выглядеть так:

There was 1 failure:

1) TestsUnitExampleTest::transform_content with data set "Transform titles" ('# Title #', TitleTransformer::class, '<h1>Title</h1>')
...

Небольшой хинт

Вы даже можете написать для ваших данных свой собственный итератор. Класс который имплементирует интерфейс Iterator:

use PHPUnitFrameworkTestCase;

class CustomIterator implements Iterator {
  protected $key = 0;
  protected $current;

  public function __construct()
  {
    //
  }

  public function __destruct()
  {
    //
  }

  public function rewind()
  {
    //
  }

  public function valid() {
    //
  }

  public function key()
  {
    //
  }

  public function current()
  {
    //
  }

  public function next()
  {
    //
  }
}

Узнать больше, о том как использовать итератор вы можете по ссылке.

Спасибо что прочитали. Это был перевод статьи Data-driven testing with PHPUnit.

Опубликовано:

Категории: Статьи