PHPUnit для начинающих. Часть 1: Начните использование. Перевод Серия
Это первая часть серии «PHPUnit для начинающих». В этом руководстве мы объясним для чего покрывать код unit-тестами и всю мощь инструмента PHPUnit. В конце мы напишем простой тест с использованием PHPUnit.
- PHPUnit для начинающих. Часть 1: Начните использование.
- PHPUnit для начинающих. Часть 2: Data Provider.
- PHPUnit для начинающих. Часть 3: Тестовые «двойники»
Типы тестов
Прежде чем мы погрузимся в PHPUnit давайте разберём различные типы тестов. В зависимости от того, как вы хотите категоризировать их, в PHPUnit применяются любые типы тестов для разработки ПО.
Давайте разделим тесты на категории по уровню их специфичности. По данным Википедии. В целом существует 4 признанных уровня тестов:
- Unit-тестирование (модульное): этот уровень тестирует наименьшую единицу функциональности. С точки зрения разработчика его задачей является убедиться, что тестируемая функция выполняет именно то, для чего она реализована. Таким образом, она должна быть минимально зависима или совершенно независима от другой функции или класса. Она должна быть написана таким образом, чтобы она полностью выполнялась в памяти, т.е. она не должна коннектиться к БД, не должна обращаться к сети или использовать ФС и т.д. Unit-тестирование должно быть как можно более простым.
- Интеграционное тестирование: этот уровень «соединяет» разные единицы кода и тестирует правильно ли работают их комбинации. Он надстраивается сверху над unit-тестированием и способен отловить баги, которые нельзя выявить с помощью unit-тестирования, т.к. интеграционное тестирование проверяет, работает ли класс А с классом Б.
- Системное тестирование: оно создано для воспроизведения работы сценариев в условиях, приближенных к боевым. Оно, в свою очередь, надстраивается сверху над интеграционным тестированием. В то время как интеграционное тестирование обеспечивает слаженную работу различных частей системы. Системное тестирование отвечает за то, что система работает так, как предполагает пользователь, прежде чем отправить её следующий уровень.
- Приёмочное тестирование: в то время как выше приведённые тесты предназначены для разработчиков на стадии разработки, приёмочное тестирование фактически выполняется пользователями ПО. Пользователей не интересуют внутренние особенности ПО, их интересует только как работает это ПО.
Если мы поместим типы тестов в пирамиду, выглядеть это будет так:
Что такое PHPUnit
Глядя на пирамиду вверху, мы можем сказать, что юнит-тестирование — это строительный материал для всех остальных видов тестирования. Когда мы закладываем сильную базу, мы способны построить устойчивое приложение. Однако написание тестов вручную, а также запуск тестов каждый раз когда вы вносите изменения — это процесс трудоёмкий. Если бы существовал инструмент для автоматизации этого процесса, то написание тестов стало бы гораздо более приятным занятием.
Вот где выходит на сцену PHPUnit. В настоящее время PHPUnit наиболее популярный фреймворк для юнит-тестирования в PHP. Кроме наличия таких возможностей, как моки (подделки) объектов, он также может анализировать покрытие кода, логирование и предоставляет тысячи других возможностей.
Давайте установим PHPUnit в нашей системе:
- Загрузите его: PHPUnit распространяется в PHAR(PHp ARhive) файле. Скачать можно здесь.
- Добавьте путь к нему в системную переменную $PATH : после скачивания PHAR файла, убедитесь, что он является запускаемым ( executable ) и путь, где он находится, прописан в системной переменной $PATH . Т.о. вы сможете запускать его из любого места.
Если вы работаете на Unix-подобной системе, то это вы можете сделать следующими командами:
$ wget https://phar.phpunit.de/phpunit.phar
$ chmod +x phpunit.phar
$ sudo mv phpunit.phar /usr/local/bin/phpunit
Если вы сделали всё верно, то вы сможете увидеть версию установленного PHPUnit, набрав в вашем терминале команду:
$ phpunit --version
Ваш первый unit-тест
Пришло время написать ваш первый unit-тест! Для начала нам нужен какой-нибудь класс, который мы будем тестировать. Давайте напишем простенький класс под названием Calculator . И напишем для него тест.
Создайте файл «Calculator.php» и скопируйте в него нижеприведённый код. Этот класс Calculator имеет только один метод add .
class Calculator < public function add($a, $b) < return $a + $b; >>
Теперь создайте файл для тестов «CalculatorTest.php» и скопируйте в него следующий код. Мы остановимся на каждом методе более детально.
require 'Calculator.php'; class CalculatorTests extends PHPUnit_Framework_TestCase < private $calculator; protected function setUp() < $this->calculator = new Calculator(); > protected function tearDown() < $this->calculator = NULL; > public function testAdd() < $result = $this->calculator->add(1, 2); $this->assertEquals(3, $result); > >
- Line 2: подключаем файл тестируемого класса Calculator.php . Так как в этом файле мы собираемся тестировать именно этот класс, убедитесь, что он подключен.
- Line 8: setUp() это метод, который вызывается перед каждым тестом. Запомните он вызывается перед Каждым тестом, что означает, что если вы добавите ещё один метод теста в этот класс, то он будет вызываться и перед ним тоже.
- Line 13: аналогично методу setUp() , tearDown() вызывается после каждого теста.
- Line 18: testAdd() — это метод-тест для метода add() . PHPUnit будет распознавать каждый метод, начинающийся с test, как метод-тест и автоматически запускать его. В действительности этот метод очень прост: сначала мы вызываем метод Calculator::add() чтобы вычислить значение 1 плюс 2, а затем мы проверяем, что этот метод вернул правильное значение, используя assertEquals() из PHPUnit.
Заключительной частью проделанной работы является запуск PHPUnit и проверка, что все тесты проходят (выполняются без ошибок). В вашем терминале зайдите в директорию где вы создали файл с тестами и запустите следующую команду:
$ phpunit CalculatorTest.php
Если вы всё сделали правильно, то вы должны увидеть что-то вроде этого:
PHPUnit 3.7.32 by Sebastian Bergmann. . Time: 31ms, Memory: 2.25Mb OK (1 test, 1 assertion)
Заключение
Мы завершили первое руководство из серии «PHPUnit для начинающих». В следующей статье мы собираемся показать вам как использовать Data Provider (поставщик данных) в ваших тестах.
Надеемся это простое руководство поможет вам в вашей разработке и поможет начать использовать unit-тестирование.
Если вам понравился перевод на эту тему читайте нас в Twitter, подписывайтесь на наши группы в Facebook, ВКонтакте, Google+. Мы будем рады продолжить серию.
php unit test PHPUnit
2015-05-29 alek13 Поделиться: оригинал
Последние посты
- 2023-02-03Новая мажорная версия Flysystem
- 2020-12-31Конечные автоматы для Eloquent
- 2020-12-30Создаём REST API с помощью Laravel Orion
- 2020-12-29При разработке локально используйте queue:listen вместо queue:work
- 2020-12-28Laravel Desktop Notifier
2. Написание тестов на PHPUnit
Пример 2.1 показывает, как мы можем писать тесты, используя PHPUnit, которые выполняют операции с массивом PHP. В этом примере представлены основные соглашения и шаги для написания тестов с помощью PHPUnit:
- Тесты для класса Class содержатся в классе ClassTest .
- ClassTest наследуется (чаще всего) от PHPUnit\Framework\TestCase .
- Тесты — общедоступные методы с именами test* .
Кроме того, вы можете использовать аннотацию @test в докблоке метода, чтобы пометить его как метод тестирования.
Пример 2.1 Тестирование операций с массивами с использованием PHPUnit
php use PHPUnit\Framework\TestCase; class StackTest extends TestCase public function testPushAndPop() $stack = []; $this->assertSame(0, count($stack)); array_push($stack, 'foo'); $this->assertSame('foo', $stack[count($stack)-1]); $this->assertSame(1, count($stack)); $this->assertSame('foo', array_pop($stack)); $this->assertSame(0, count($stack)); > >
Мартин Фаулер (Martin Fowler):
Всякий раз, когда возникает соблазн что-то распечатать, используя print , или написать отладочное выражение, напишите тест вместо этого.
Зависимости тестов
Адриан Кун (Adrian Kuhn) и другие:
Модульные тесты главным образом пишутся в качестве хорошей практики, помогающей разработчикам выявлять и исправлять баги, проводить рефакторинг кода и служить в качестве документации для тестируемого программного модуля (программы). Для достижения этих преимуществ модульные тесты в идеале должны охватывать все возможные пути исполнения программе. Один модульный тест обычно покрывает один конкретный путь в одной функции или метода. Однако тестовые методы необязательно должны быть инкапсулированными и независимыми. Часто существуют неявные зависимости между тестовыми методами, скрытые в сценарии реализации теста.
PHPUnit поддерживает объявление явных зависимостей между тестовыми методами. Эти зависимости не определяют порядок, в котором должны выполняться тестовые методы, но они позволяют возвращать экземпляр (данные) фикстуры теста, созданные поставщиком (producer) для передачи его зависимым потребителям (consumers).
- Поставщик — тестовый метод, который предоставляет свой тестируемый модуль в качестве возвращаемого значения.
- Потребитель — тестовый метод, который зависит от одного или более поставщиков и их возвращаемых значений.
Пример 2.2 показывает, как использовать аннотацию @depends для представления зависимостей между тестовыми методами.
Пример 2.2 Использование аннотации @depends для описания зависимостей
php use PHPUnit\Framework\TestCase; class StackTest extends TestCase public function testEmpty() $stack = []; $this->assertEmpty($stack); return $stack; > /** * @depends testEmpty */ public function testPush(array $stack) array_push($stack, 'foo'); $this->assertSame('foo', $stack[count($stack)-1]); $this->assertNotEmpty($stack); return $stack; > /** * @depends testPush */ public function testPop(array $stack) $this->assertSame('foo', array_pop($stack)); $this->assertEmpty($stack); > >
В вышеприведённом примере первый тест, testEmpty() , создаёт новый массив и утверждает, что он пуст. Затем тест возвращает фикстуру в качестве результата. Второй тест, testPush() , зависит от testEmpty() и ему передаётся результат этого зависимого теста в качестве аргумента. Наконец, testPop() зависит от testPush() .
Возвращаемое значение, предоставленное поставщиком, по умолчанию передаётся потребителям «как есть». Это означает, что когда поставщик возвращает объект, ссылка на этот объект передаётся потребителям. Вместо ссылки возможна, либо (а) (глубокая) копия через @depends clone или (б) (поверхностная) копия (на основе ключевого слова PHP clone ) через @depends shallowClone .
Чтобы быстро находить дефекты, нам нужно сосредоточить внимание на соответствующих неудачных тестах. Вот почему PHPUnit пропускает выполнение теста, когда зависимый тест (тест с зависимостью) провалился (не прошёл). Это помогает локализовать дефекты за счёт использования зависимостей между тестами, как это показано в Пример 2.3 .
Пример 2.3 Использование зависимостей между тестами
php use PHPUnit\Framework\TestCase; class DependencyFailureTest extends TestCase public function testOne() $this->assertTrue(false); > /** * @depends testOne */ public function testTwo() > >
$ phpunit --verbose DependencyFailureTest PHPUnit |version|.0 by Sebastian Bergmann and contributors. FS Time: 0 seconds, Memory: 5.00Mb There was 1 failure: 1) DependencyFailureTest::testOne Failed asserting that false is true. /home/sb/DependencyFailureTest.php:6 There was 1 skipped test: 1) DependencyFailureTest::testTwo This test depends on "DependencyFailureTest::testOne" to pass. FAILURES! Tests: 1, Assertions: 1, Failures: 1, Skipped: 1.
У теста может быть несколько аннотаций @depends . PHPUnit не изменяет порядок выполнения тестов, поэтому вы должны убедиться, что все зависимости действительно могут быть выполнены до запуска теста.
Тест, содержащий более одной аннотации @depends , получит фикстуру от первого поставщика в качестве первого аргумента, фикстуру от второго поставщика вторым аргументом и т.д. См. Пример 2.4
Пример 2.4 Тест с несколькими зависимостями
php use PHPUnit\Framework\TestCase; class MultipleDependenciesTest extends TestCase public function testProducerFirst() $this->assertTrue(true); return 'first'; > public function testProducerSecond() $this->assertTrue(true); return 'second'; > /** * @depends testProducerFirst * @depends testProducerSecond */ public function testConsumer($a, $b) $this->assertSame('first', $a); $this->assertSame('second', $b); > >
$ phpunit --verbose MultipleDependenciesTest PHPUnit |version|.0 by Sebastian Bergmann and contributors. . Time: 0 seconds, Memory: 3.25Mb OK (3 tests, 3 assertions)
Провайдеры данных
Тестовый метод может принимать произвольное количество аргументов. Эти аргументы могут быть предоставлены одним или несколькими методами провайдеров данных (data provider) (см. additionProvider() в Пример 2.5 ). Метод, который будет использован в качестве провайдера данных, обозначается с помощью аннотации @dataProvider .
Метод провайдера данных должен быть объявлен как public и возвращать либо массив массивов, либо объект, реализующий интерфейс Iterator и возвращать массив при каждой итерации. Для каждого массива, являющегося частью коллекции, будет вызываться тестовый метод с элементами массива в качестве его аргументов.
Пример 2.5 Использование провайдера данных, который возвращает массив массивов
php use PHPUnit\Framework\TestCase; class DataTest extends TestCase /** * @dataProvider additionProvider */ public function testAdd($a, $b, $expected) $this->assertSame($expected, $a + $b); > public function additionProvider() return [ [0, 0, 0], [0, 1, 1], [1, 0, 1], [1, 1, 3] ]; > >
$ phpunit DataTest PHPUnit |version|.0 by Sebastian Bergmann and contributors. . F Time: 0 seconds, Memory: 5.75Mb There was 1 failure: 1) DataTest::testAdd with data set #3 (1, 1, 3) Failed asserting that 2 is identical to 3. /home/sb/DataTest.php:9 FAILURES! Tests: 4, Assertions: 4, Failures: 1.
При использовании большого количества наборов данных полезно указывать для каждого из них строковый ключ, вместо использования числового ключа по умолчанию. Вывод станет более подробным, так как он будет содержать имя набора данных, не прошедший тест.
Пример 2.6 Использование провайдера данных с наборами данных
php use PHPUnit\Framework\TestCase; class DataTest extends TestCase /** * @dataProvider additionProvider */ public function testAdd($a, $b, $expected) $this->assertSame($expected, $a + $b); > public function additionProvider() return [ 'adding zeros' => [0, 0, 0], 'zero plus one' => [0, 1, 1], 'one plus zero' => [1, 0, 1], 'one plus one' => [1, 1, 3] ]; > >
$ phpunit DataTest PHPUnit |version|.0 by Sebastian Bergmann and contributors. . F Time: 0 seconds, Memory: 5.75Mb There was 1 failure: 1) DataTest::testAdd with data set "one plus one" (1, 1, 3) Failed asserting that 2 is identical to 3. /home/sb/DataTest.php:9 FAILURES! Tests: 4, Assertions: 4, Failures: 1.
Пример 2.7 Использование провайдера данных, который возвращает объект Iterator
php use PHPUnit\Framework\TestCase; require 'CsvFileIterator.php'; class DataTest extends TestCase /** * @dataProvider additionProvider */ public function testAdd($a, $b, $expected) $this->assertSame($expected, $a + $b); > public function additionProvider() return new CsvFileIterator('data.csv'); > >
$ phpunit DataTest PHPUnit |version|.0 by Sebastian Bergmann and contributors. . F Time: 0 seconds, Memory: 5.75Mb There was 1 failure: 1) DataTest::testAdd with data set #3 ('1', '1', '3') Failed asserting that 2 is identical to 3. /home/sb/DataTest.php:11 FAILURES! Tests: 4, Assertions: 4, Failures: 1.
Пример 2.8 Класс CsvFileIterator
php use PHPUnit\Framework\TestCase; class CsvFileIterator implements Iterator protected $file; protected $key = 0; protected $current; public function __construct($file) $this->file = fopen($file, 'r'); > public function __destruct() fclose($this->file); > public function rewind() rewind($this->file); $this->current = fgetcsv($this->file); $this->key = 0; > public function valid() return !feof($this->file); > public function key() return $this->key; > public function current() return $this->current; > public function next() $this->current = fgetcsv($this->file); $this->key++; > >
Когда тест получает входные данные как из метода с @dataProvider , так и от одного или более методов с аннотацией @depends , первыми будут приходить аргументы от провайдера данных, а после от зависимых тестов. Аргументы от зависимых тестов будут одинаковыми для каждого набора данных. См. Пример 2.9
Пример 2.9 Комбинация @depends и @dataProvider в одном тесте
php use PHPUnit\Framework\TestCase; class DependencyAndDataProviderComboTest extends TestCase public function provider() return [['provider1'], ['provider2']]; > public function testProducerFirst() $this->assertTrue(true); return 'first'; > public function testProducerSecond() $this->assertTrue(true); return 'second'; > /** * @depends testProducerFirst * @depends testProducerSecond * @dataProvider provider */ public function testConsumer() $this->assertSame( ['provider1', 'first', 'second'], func_get_args() ); > >
$ phpunit --verbose DependencyAndDataProviderComboTest PHPUnit |version|.0 by Sebastian Bergmann and contributors. . F Time: 0 seconds, Memory: 3.50Mb There was 1 failure: 1) DependencyAndDataProviderComboTest::testConsumer with data set #1 ('provider2') Failed asserting that two arrays are identical. --- Expected +++ Actual @@ @@ Array &0 ( - 0 => 'provider1' + 0 => 'provider2' 1 => 'first' 2 => 'second' ) /home/sb/DependencyAndDataProviderComboTest.php:32 FAILURES! Tests: 4, Assertions: 4, Failures: 1.
Пример 2.10 Использование нескольких провайдеров данных для одного теста :name: writing-tests-for-phpunit.data-providers.examples.DataTest.php
php use PHPUnit\Framework\TestCase; class DataTest extends TestCase /** * @dataProvider additionWithNonNegativeNumbersProvider * @dataProvider additionWithNegativeNumbersProvider */ public function testAdd($a, $b, $expected) $this->assertSame($expected, $a + $b); > public function additionWithNonNegativeNumbersProvider() return [ [0, 1, 1], [1, 0, 1], [1, 1, 3] ]; > public function additionWithNegativeNumbersProvider() return [ [-1, 1, 0], [-1, -1, -2], [1, -1, 0] ]; > >
Когда тест зависит от теста, который использует провайдеры данных, зависимый тест начнёт выполняться, когда тест, от которого от зависит, успешно выполнится хотя бы для одного набора данных. Результат теста, который использует провайдеры данных, не может быть внедрён в зависимый тест.
Все провайдеры данных выполняются как перед вызовом статического метода setUpBeforeClass() , так и перед первым вызовом метода setUp() . Из-за этого вы не сможете получить доступ к переменным, определённым внутри провайдера данных. Это требуется для того, чтобы PHPUnit смог вычислить общее количество тестов.
Тестирование исключений
Пример 2.11 показывает, как использовать метод expectException() для проверки того, было ли выброшено исключение в тестируемом коде.
Пример 2.11 Использование метода expectException()
php use PHPUnit\Framework\TestCase; class ExceptionTest extends TestCase public function testException() $this->expectException(InvalidArgumentException::class); > >
$ phpunit ExceptionTest PHPUnit |version|.0 by Sebastian Bergmann and contributors. F Time: 0 seconds, Memory: 4.75Mb There was 1 failure: 1) ExceptionTest::testException Failed asserting that exception of type "InvalidArgumentException" is thrown. FAILURES! Tests: 1, Assertions: 1, Failures: 1.
В дополнение к методу expectException() существуют методы expectExceptionCode() , expectExceptionMessage() и expectExceptionMessageRegExp() , чтобы установить ожидания для исключений, вызванных тестируемым кодом.
Обратите внимание, что метод expectExceptionMessage, утверждает, что фактическое сообщение в $actual содержит ожидаемое сообщение $expected без выполнения точного сравнения строк.
Кроме того, вы можете использовать аннотации @expectedException , @expectedExceptionCode , @expectedExceptionMessage и @expectedExceptionMessageRegExp , чтобы установить ожидания для исключений, вызванных тестируемым кодом. Пример 2.12 демонстрирует пример использования.
Пример 2.12 Использование аннотации @expectedException
php use PHPUnit\Framework\TestCase; class ExceptionTest extends TestCase /** * @expectedException InvalidArgumentException */ public function testException() > >
$ phpunit ExceptionTest PHPUnit |version|.0 by Sebastian Bergmann and contributors. F Time: 0 seconds, Memory: 4.75Mb There was 1 failure: 1) ExceptionTest::testException Failed asserting that exception of type "InvalidArgumentException" is thrown. FAILURES! Tests: 1, Assertions: 1, Failures: 1.
Тестирование ошибок PHP
По умолчанию PHPUnit преобразует ошибки, предупреждения и уведомления, вызываемые PHP во время выполнения теста, в исключения. Используя эти исключения, вы можете, например, ожидать, что тест вызовет ошибку PHP, как показано в Пример 2.13 .
Конфигурация времени выполнения PHP error_reporting может ограничивать, какие ошибки PHPUnit будет конвертировать в исключения. Если у вас возникли проблемы с этой настройкой, убедитесь, что PHP не настроен на подавление типов ошибок, которые вы тестируете.
Пример 2.13 Ожидание ошибки PHP в тесте, используя @expectedException
php use PHPUnit\Framework\TestCase; class ExpectedErrorTest extends TestCase /** * @expectedException PHPUnit\Framework\Error\Error */ public function testFailingInclude() include 'not_existing_file.php'; > >
$ phpunit -d error_reporting=2 ExpectedErrorTest PHPUnit |version|.0 by Sebastian Bergmann and contributors. . Time: 0 seconds, Memory: 5.25Mb OK (1 test, 1 assertion)
Классы PHPUnit\Framework\Error\Notice PHPUnit\Framework\Error\Warning представляют уведомления и предупреждения PHP соответственно.
Вы должны быть как можно более конкретно указывать исключения при тестировании. Тестирование слишком общих классов исключений может привести к нежелательным побочным эффектам. Поэтому проверка исключения на соответствие классу Exception с помощью @expectedException или expectException() больше не разрешена.
При тестировании кода, использующего функции PHP, которые вызывают ошибки, например, fopen , иногда бывает полезно использовать подавление ошибок во время тестирования. Таким образом, это позволит вам проверять возвращаемые значения, подавляя уведомления, которые преобразуются в объекты PHPUnit PHPUnit\Framework\Error\Notice .
Пример 2.14 Тестирование возвращаемых значений в коде, в котором возникают ошибки PHP
php use PHPUnit\Framework\TestCase; class ErrorSuppressionTest extends TestCase public function testFileWriting() $writer = new FileWriter; $this->assertFalse(@$writer->write('/is-not-writeable/file', 'stuff')); > > class FileWriter public function write($file, $content) $file = fopen($file, 'w'); if ($file == false) return false; > // . > >
$ phpunit ErrorSuppressionTest PHPUnit |version|.0 by Sebastian Bergmann and contributors. . Time: 1 seconds, Memory: 5.25Mb OK (1 test, 1 assertion)
Без подавления ошибки тест завершится неудачей с сообщением fopen(/is-not-writeable/file): failed to open stream: No such file or directory .
Тестирования вывода
Иногда вам нужно проверить, что выполнение метода, например, генерирует ожидаемый вывод (к примеру, через echo или print ). Класс PHPUnit\Framework\TestCase использует возможности буферизации вывода PHP для предоставления такой функциональности.
Пример 2.15 показывает, как использовать метод expectOutputString() для установки ожидаемого вывода. Если этот ожидаемый вывод не будет сгенерирован, тест будет считаться проваленным.
Пример 2.15 Тестирование вывода функции или метода
php use PHPUnit\Framework\TestCase; class OutputTest extends TestCase public function testExpectFooActualFoo() $this->expectOutputString('foo'); print 'foo'; > public function testExpectBarActualBaz() $this->expectOutputString('bar'); print 'baz'; > >
$ phpunit OutputTest PHPUnit |version|.0 by Sebastian Bergmann and contributors. .F Time: 0 seconds, Memory: 5.75Mb There was 1 failure: 1) OutputTest::testExpectBarActualBaz Failed asserting that two strings are equal. --- Expected +++ Actual @@ @@ -'bar' +'baz' FAILURES! Tests: 2, Assertions: 2, Failures: 1.
Таблица 2.1 показывает доступные методы для тестирования вывода
| Метод | Описание |
|---|---|
| void expectOutputRegex(string $regularExpression) | Проверить, что вывод соответствует регулярному выражению $regularExpression . |
| void expectOutputString(string $expectedString) | Проверить, что вывод соответствует строке $expectedString . |
| bool setOutputCallback(callable $callback) | Устанавливает функцию обратного вызова, используемую, например, для нормализации фактического вывода. |
| string getActualOutput() | Получить фактический вывод. |
Тест, который генерирует вывод, не будет работать в строгом режиме.
Вывод ошибки
Всякий раз, когда тест терпит неудачу, PHPUnit изо всех сил пытается предоставить вам максимально возможный контекст, который может помочь выявить проблему.
Пример 2.16 Вывод ошибки, сгенерированный при неудачном сравнении массива
php use PHPUnit\Framework\TestCase; class ArrayDiffTest extends TestCase public function testEquality() $this->assertSame( [1, 2, 3, 4, 5, 6], [1, 2, 33, 4, 5, 6] ); > >
$ phpunit ArrayDiffTest PHPUnit |version|.0 by Sebastian Bergmann and contributors. F Time: 0 seconds, Memory: 5.25Mb There was 1 failure: 1) ArrayDiffTest::testEquality Failed asserting that two arrays are identical. --- Expected +++ Actual @@ @@ Array ( 0 => 1 1 => 2 - 2 => 3 + 2 => 33 3 => 4 4 => 5 5 => 6 ) /home/sb/ArrayDiffTest.php:7 FAILURES! Tests: 1, Assertions: 1, Failures: 1.
В этом примере только одно из значений массива отличается, а остальные значения показаны, для обеспечения контекста, где произошла ошибка.
Когда сгенерированный вывод будет длинным для чтения, PHPUnit разделит его и отобразит несколько строк контекста вокруг каждого несоответствия (разницы).
Пример 2.17 Вывод ошибки при неудачном сравнении длинного массива
php use PHPUnit\Framework\TestCase; class LongArrayDiffTest extends TestCase public function testEquality() $this->assertSame( [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 33, 4, 5, 6] ); > >
$ phpunit LongArrayDiffTest PHPUnit |version|.0 by Sebastian Bergmann and contributors. F Time: 0 seconds, Memory: 5.25Mb There was 1 failure: 1) LongArrayDiffTest::testEquality Failed asserting that two arrays are identical. --- Expected +++ Actual @@ @@ 11 => 0 12 => 1 13 => 2 - 14 => 3 + 14 => 33 15 => 4 16 => 5 17 => 6 ) /home/sb/LongArrayDiffTest.php:7 FAILURES! Tests: 1, Assertions: 1, Failures: 1.
Крайние случаи
Когда сравнение терпит неудачу, PHPUnit создаёт текстовые представления входных значений и сравнивает их. Благодаря этой реализации результат сравнения изменений (формат diff) может показать больше проблем, чем существуют на самом деле.
Это происходит только при использовании assertEquals() или „слабых“ („weak“) функций сравнения массивов или объектов.
Пример 2.18 Крайний случай в генерации сравнения при использовании слабого сравнения
php use PHPUnit\Framework\TestCase; class ArrayWeakComparisonTest extends TestCase public function testEquality() $this->assertEquals( [1, 2, 3, 4, 5, 6], ['1', 2, 33, 4, 5, 6] ); > >
$ phpunit ArrayWeakComparisonTest PHPUnit |version|.0 by Sebastian Bergmann and contributors. F Time: 0 seconds, Memory: 5.25Mb There was 1 failure: 1) ArrayWeakComparisonTest::testEquality Failed asserting that two arrays are equal. --- Expected +++ Actual @@ @@ Array ( - 0 => 1 + 0 => '1' 1 => 2 - 2 => 3 + 2 => 33 3 => 4 4 => 5 5 => 6 ) /home/sb/ArrayWeakComparisonTest.php:7 FAILURES! Tests: 1, Assertions: 1, Failures: 1.
В этом примере сообщается о различии в первом индексе между 1 и ‘1’ , хотя метод assertEquals() считает, что эти значения совпадают.
© Copyright 2018, Sebastian Bergmann. Revision 65b89c43 .
Read the Docs v: latest
Versions latest Downloads pdf htmlzip epub On Read the Docs Project Home Builds Free document hosting provided by Read the Docs.
Юнит-тестирование на PHP в примерах

Сегодня юнит-тесты невероятно полезны. Думаю, они есть в большинстве из недавно созданных проектов. Юнит-тесты являются важнейшими в enterprise-приложениях с обилием бизнес-логики, потому что они быстрые и могут сразу сказать нам, корректна ли наша реализация. Однако я часто сталкиваюсь с проблемами, которые связаны с хорошими тестами, хотя те и крайне полезны. Я дам вам несколько советов с примерами, как писать хорошие юнит-тесты.
Тестовые дубли
Это фальшивые зависимости, используемые в тестах.
Заглушки (Stub)
Имитатор (Dummy)
Имитатор — всего лишь простая реализация, которая ничего не делает.
final class Mailer implements MailerInterface < public function send(Message $message): void < >>
Фальшивка (Fake)
Фальшивка — это упрощённая реализация, эмулирующая нужное поведение.
final class InMemoryCustomerRepository implements CustomerRepositoryInterface < /** * @var Customer[] */ private array $customers; public function __construct() < $this->customers = []; > public function store(Customer $customer): void < $this->customers[(string) $customer->id()->id()] = $customer; > public function get(CustomerId $id): Customer < if (!isset($this->customers[(string) $id->id()])) < throw new CustomerNotFoundException(); >return $this->customers[(string) $id->id()]; > public function findByEmail(Email $email): Customer < foreach ($this->customers as $customer) < if ($customer->getEmail()->isEqual($email)) < return $customer; >> throw new CustomerNotFoundException(); > >
Заглушка (Stub)
Заглушка — это простейшая реализация с прописанным в коде поведением.
final class UniqueEmailSpecificationStub implements UniqueEmailSpecificationInterface < public function isUnique(Email $email): bool < return true; >> $specificationStub = $this->createStub(UniqueEmailSpecificationInterface::class); $specificationStub->method('isUnique')->willReturn(true);
Моки (Mock)
Шпион (Spy)
Шпион — реализация для проверки конкретного поведения.
final class Mailer implements MailerInterface < /** * @var Message[] */ private array $messages; public function __construct() < $this->messages = []; > public function send(Message $message): void < $this->messages[] = $message; > public function getCountOfSentMessages(): int < return count($this->messages); > >
Мок (Mock)
Мок — сконфигурированная имитация для проверки вызовов взаимодействующих объектов.
$message = new Message('test@test.com', 'Test', 'Test test test'); $mailer = $this->createMock(MailerInterface::class); $mailer ->expects($this->once()) ->method('send') ->with($this->equalTo($message));
! Для проверки входящий взаимодействий используйте заглушку, а для проверки исходящих взаимодействий — мок. Подробнее об этом в главе Моки и заглушки.
Наименования
public function test(): void < $subscription = SubscriptionMother::new(); $subscription->activate(); self::assertSame(Status::activated(), $subscription->status()); >
Явно указывайте, что вы тестируете.
public function sut(): void < // sut = System under test $sut = SubscriptionMother::new(); $sut->activate(); self::assertSame(Status::activated(), $sut->status()); >
public function it_throws_invalid_credentials_exception_when_sign_in_with_invalid_credentials(): void < >public function testCreatingWithATooShortPasswordIsNotPossible(): void < >public function testDeactivateASubscription(): void
- Использование нижних подчёркиваний повышает удобочитаемость.
- Наименование должно описывать поведение, а не реализацию.
- Используйте наименования без технических терминов. Они должны быть понятны непрограммистам.
public function sign_in_with_invalid_credentials_is_not_possible(): void < >public function creating_with_a_too_short_password_is_not_possible(): void < >public function deactivating_an_activated_subscription_is_valid(): void < >public function deactivating_an_inactive_subscription_is_invalid(): void
Описание поведения важно при тестировании предметных сценариев. Если ваш код утилитарный, то это уже не так важно.
Почему важно, чтобы непрограммисты могли читать юнит-тесты? Если в проекте сложная предметная логика, то эта логика должна быть очевидна для всех, а для этого тесты должны описывать подробности без технических терминов, чтобы вы могли говорить с представителями бизнеса на том же языке, что используется в тестах. Освободите от терминов и весь код, связанный с предметной областью, иначе непрограммисты не смогут понять эти тесты. Не надо писать в комментариях «возвращает null», «бросает исключение» и т.д. Такая информация не относится к предметной области.
Шаблон AAA
Также известен как «Given, When, Then».
Выделяйте в тестах три этапа:
- Arrange: приведите тестируемую систему к нужному состоянию. Подготовьте зависимости, аргументы, и создайте SUT.
- Act: извлеките тестируемый элемент.
- Assert: проверьте результат, финальное состояние или взаимодействие с другими объектами.
public function aaa_pattern_example_test(): void < //Arrange|Given $sut = SubscriptionMother::new(); //Act|When $sut->activate(); //Assert|Then self::assertSame(Status::activated(), $sut->status()); >
Мать объекта
Этот шаблон помогает создавать конкретные объекты, которые можно использовать в нескольких тестах. Благодаря этому этап «arrange» получается кратким, а весь тест — более удобочитаемым.
final class SubscriptionMother < public static function new(): Subscription < return new Subscription(); >public static function activated(): Subscription < $subscription = new Subscription(); $subscription->activate(); return $subscription; > public static function deactivated(): Subscription < $subscription = self::activated(); $subscription->deactivate(); return $subscription; > > final class ExampleTest < public function example_test_with_activated_subscription(): void < $activatedSubscription = SubscriptionMother::activated(); // do something // check something >public function example_test_with_deactivated_subscription(): void < $deactivatedSubscription = SubscriptionMother::deactivated(); // do something // check something >>
Параметризированный тест
Параметризированный тест — хороший способ тестирования SUT с многочисленными параметрами без повторения кода. Но такие тесты менее удобочитаемые. Чтобы немного улучшить ситуацию, отрицательные и положительные примеры нужно раскидать по разным тестам.
final class ExampleTest extends TestCase < /** * @test * @dataProvider getInvalidEmails */ public function detects_an_invalid_email_address(string $email): void < $sut = new EmailValidator(); $result = $sut->isValid($email); self::assertFalse($result); > /** * @test * @dataProvider getValidEmails */ public function detects_an_valid_email_address(string $email): void < $sut = new EmailValidator(); $result = $sut->isValid($email); self::assertTrue($result); > public function getInvalidEmails(): array < return [ ['test'], ['test@'], ['test@test'], //. ]; >public function getValidEmails(): array < return [ ['test@test.com'], ['test123@test.com'], ['Test123@test.com'], //. ]; >>
Две школы юнит-тестирования
Классическая (Детройтская школа)
- Модуль — это единица поведения, может состоять из нескольких взаимосвязанных классов.
- Все тесты должны быть изолированы друг от друга. Должна быть возможность вызывать их параллельно или в произвольном порядке.
final class TestExample extends TestCase < /** * @test */ public function suspending_an_subscription_with_can_always_suspend_policy_is_always_possible(): void < $canAlwaysSuspendPolicy = new CanAlwaysSuspendPolicy(); $sut = new Subscription(); $result = $sut->suspend($canAlwaysSuspendPolicy); self::assertTrue($result); self::assertSame(Status::suspend(), $sut->status()); > >
Моковая (Лондонская школа)
- Модуль — это один класс.
- Модуль должен быть изолирован от взаимодействующих объектов.
final class TestExample extends TestCase < /** * @test */ public function suspending_an_subscription_with_can_always_suspend_policy_is_always_possible(): void < $canAlwaysSuspendPolicy = $this->createStub(SuspendingPolicyInterface::class); $canAlwaysSuspendPolicy->method('suspend')->willReturn(true); $sut = new Subscription(); $result = $sut->suspend($canAlwaysSuspendPolicy); self::assertTrue($result); self::assertSame(Status::suspend(), $sut->status()); > >
Классический подход лучше позволяет избегать хрупких тестов.
Зависимости
Моки и заглушки
final class NotificationService < public function __construct( private MailerInterface $mailer, private MessageRepositoryInterface $messageRepository ) <>public function send(): void < $messages = $this->messageRepository->getAll(); foreach ($messages as $message) < $this->mailer->send($message); > > >
- Проверочные взаимодействия с заглушками приводят к хрупким тестам.
final class TestExample extends TestCase < /** * @test */ public function sends_all_notifications(): void < $message1 = new Message(); $message2 = new Message(); $messageRepository = $this->createMock(MessageRepositoryInterface::class); $messageRepository->method('getAll')->willReturn([$message1, $message2]); $mailer = $this->createMock(MailerInterface::class); $sut = new NotificationService($mailer, $messageRepository); $messageRepository->expects(self::once())->method('getAll'); $mailer->expects(self::exactly(2))->method('send') ->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]); $sut->send(); > >
final class TestExample extends TestCase < /** * @test */ public function sends_all_notifications(): void < $message1 = new Message(); $message2 = new Message(); $messageRepository = $this->createStub(MessageRepositoryInterface::class); $messageRepository->method('getAll')->willReturn([$message1, $message2]); $mailer = $this->createMock(MailerInterface::class); $sut = new NotificationService($mailer, $messageRepository); // Removed asserting interactions with the stub $mailer->expects(self::exactly(2))->method('send') ->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]); $sut->send(); > >
Три стиля юнит-тестирования
Результат
- Наилучшая сопротивляемость рефакторингу.
- Наилучшая точность.
- Меньше всего усилий по сопровождению.
- Если возможно, применяйте этот вид тестов.
final class ExampleTest extends TestCase < /** * @test * @dataProvider getInvalidEmails */ public function detects_an_invalid_email_address(string $email): void < $sut = new EmailValidator(); $result = $sut->isValid($email); self::assertFalse($result); > /** * @test * @dataProvider getValidEmails */ public function detects_an_valid_email_address(string $email): void < $sut = new EmailValidator(); $result = $sut->isValid($email); self::assertTrue($result); > public function getInvalidEmails(): array < return [ ['test'], ['test@'], ['test@test'], //. ]; >public function getValidEmails(): array < return [ ['test@test.com'], ['test123@test.com'], ['Test123@test.com'], //. ]; >>
Состояние
final class ExampleTest extends TestCase < /** * @test */ public function adding_an_item_to_cart(): void < $item = new CartItem('Product'); $sut = new Cart(); $sut->addItem($item); self::assertSame(1, $sut->getCount()); self::assertSame($item, $sut->getItems()[0]); > >
Взаимодействие
- Худшая сопротивляемость рефакторингу.
- Худшая точность.
- Сложнее всего в сопровождении.
final class ExampleTest extends TestCase < /** * @test */ public function sends_all_notifications(): void < $message1 = new Message(); $message2 = new Message(); $messageRepository = $this->createStub(MessageRepositoryInterface::class); $messageRepository->method('getAll')->willReturn([$message1, $message2]); $mailer = $this->createMock(MailerInterface::class); $sut = new NotificationService($mailer, $messageRepository); $mailer->expects(self::exactly(2))->method('send') ->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]); $sut->send(); > >
Функциональная архитектура и тесты
final class NameService < public function __construct(private CacheStorageInterface $cacheStorage) <>public function loadAll(): void < $namesCsv = array_map('str_getcsv', file(__DIR__.'/../names.csv')); $names = []; foreach ($namesCsv as $nameData) < if (!isset($nameData[0], $nameData[1])) < continue; >$names[] = new Name($nameData[0], new Gender($nameData[1])); > $this->cacheStorage->store('names', $names); > >
Как тестировать подобный код? Это можно сделать только с помощью интеграционных тестов, потому что они напрямую используют инфраструктурный код, относящийся к файловой системе.
Как и в функциональной архитектуре, нам нужно отделить код с побочными эффектами от кода, который содержит только логику.
final class NameParser < /** * @param array $namesData * @return Name[] */ public function parse(array $namesData): array < $names = []; foreach ($namesData as $nameData) < if (!isset($nameData[0], $nameData[1])) < continue; >$names[] = new Name($nameData[0], new Gender($nameData[1])); > return $names; > > final class CsvNamesFileLoader < public function load(): array < return array_map('str_getcsv', file(__DIR__.'/../names.csv')); >> final class ApplicationService < public function __construct( private CsvNamesFileLoader $fileLoader, private NameParser $parser, private CacheStorageInterface $cacheStorage ) <>public function loadNames(): void < $namesData = $this->fileLoader->load(); $names = $this->parser->parse($namesData); $this->cacheStorage->store('names', $names); > > final class ValidUnitExampleTest extends TestCase < /** * @test */ public function parse_all_names(): void < $namesData = [ ['John', 'M'], ['Lennon', 'U'], ['Sarah', 'W'] ]; $sut = new NameParser(); $result = $sut->parse($namesData); self::assertSame( [ new Name('John', new Gender('M')), new Name('Lennon', new Gender('U')), new Name('Sarah', new Gender('W')) ], $result ); > >
Наблюдаемое поведение и подробности реализации
final class ApplicationService < public function __construct(private SubscriptionRepositoryInterface $subscriptionRepository) <>public function renewSubscription(int $subscriptionId): bool < $subscription = $this->subscriptionRepository->findById($subscriptionId); if (!$subscription->getStatus()->isEqual(Status::expired())) < return false; >$subscription->setStatus(Status::active()); $subscription->setModifiedAt(new \DateTimeImmutable()); return true; > > final class Subscription < private Status $status; private \DateTimeImmutable $modifiedAt; public function __construct(Status $status, \DateTimeImmutable $modifiedAt) < $this->status = $status; $this->modifiedAt = $modifiedAt; > public function getStatus(): Status < return $this->status; > public function setStatus(Status $status): void < $this->status = $status; > public function getModifiedAt(): \DateTimeImmutable < return $this->modifiedAt; > public function setModifiedAt(\DateTimeImmutable $modifiedAt): void < $this->modifiedAt = $modifiedAt; > > final class InvalidTestExample extends TestCase < /** * @test */ public function renew_an_expired_subscription_is_possible(): void < $modifiedAt = new \DateTimeImmutable(); $expiredSubscription = new Subscription(Status::expired(), $modifiedAt); $repository = $this->createStub(SubscriptionRepositoryInterface::class); $repository->method('findById')->willReturn($expiredSubscription); $sut = new ApplicationService($repository); $result = $sut->renewSubscription(1); self::assertSame(Status::active(), $expiredSubscription->getStatus()); self::assertGreaterThan($modifiedAt, $expiredSubscription->getModifiedAt()); self::assertTrue($result); > /** * @test */ public function renew_an_active_subscription_is_not_possible(): void < $modifiedAt = new \DateTimeImmutable(); $activeSubscription = new Subscription(Status::active(), $modifiedAt); $repository = $this->createStub(SubscriptionRepositoryInterface::class); $repository->method('findById')->willReturn($activeSubscription); $sut = new ApplicationService($repository); $result = $sut->renewSubscription(1); self::assertSame($modifiedAt, $activeSubscription->getModifiedAt()); self::assertFalse($result); > >
final class ApplicationService < public function __construct(private SubscriptionRepositoryInterface $subscriptionRepository) <>public function renewSubscription(int $subscriptionId): bool < $subscription = $this->subscriptionRepository->findById($subscriptionId); return $subscription->renew(new \DateTimeImmutable()); > > final class Subscription < private Status $status; private \DateTimeImmutable $modifiedAt; public function __construct(\DateTimeImmutable $modifiedAt) < $this->status = Status::new(); $this->modifiedAt = $modifiedAt; > public function renew(\DateTimeImmutable $modifiedAt): bool < if (!$this->status->isEqual(Status::expired())) < return false; >$this->status = Status::active(); $this->modifiedAt = $modifiedAt; return true; > public function active(\DateTimeImmutable $modifiedAt): void < //simplified $this->status = Status::active(); $this->modifiedAt = $modifiedAt; > public function expire(\DateTimeImmutable $modifiedAt): void < //simplified $this->status = Status::expired(); $this->modifiedAt = $modifiedAt; > public function isActive(): bool < return $this->status->isEqual(Status::active()); > > final class ValidTestExample extends TestCase < /** * @test */ public function renew_an_expired_subscription_is_possible(): void < $expiredSubscription = SubscriptionMother::expired(); $repository = $this->createStub(SubscriptionRepositoryInterface::class); $repository->method('findById')->willReturn($expiredSubscription); $sut = new ApplicationService($repository); $result = $sut->renewSubscription(1); // skip checking modifiedAt as it's not a part of observable behavior. To check this value we // would have to add a getter for modifiedAt, probably only for test purposes. self::assertTrue($expiredSubscription->isActive()); self::assertTrue($result); > /** * @test */ public function renew_an_active_subscription_is_not_possible(): void < $activeSubscription = SubscriptionMother::active(); $repository = $this->createStub(SubscriptionRepositoryInterface::class); $repository->method('findById')->willReturn($activeSubscription); $sut = new ApplicationService($repository); $result = $sut->renewSubscription(1); self::assertTrue($activeSubscription->isActive()); self::assertFalse($result); > >
У первой модели подписки плохая архитектура. Для вызова одной бизнес-операции нужно вызывать три метода. Также не рекомендуется использовать методы-получатели (геттеры) для проверки операции. В данном примере пропущена проверка изменения modifiedAt . Возможно, указание конкретного modifiedAt в ходе операции renew можно протестировать с помощью бизнес-операции устаревания. Для modifiedAt метод-получатель не требуется. Конечно, есть ситуации, в которых очень трудно найти способ избежать использования методов-получателей только для тестов, но их нужно избегать всеми силами.
Единица поведения
class CannotSuspendExpiredSubscriptionPolicy implements SuspendingPolicyInterface < public function suspend(Subscription $subscription, \DateTimeImmutable $at): bool < if ($subscription->isExpired()) < return false; >return true; > > class CannotSuspendExpiredSubscriptionPolicyTest extends TestCase < /** * @test */ public function it_returns_true_when_a_subscription_is_expired(): void < $policy = new CannotSuspendExpiredSubscriptionPolicy(); $subscription = $this->createStub(Subscription::class); $subscription->method('isExpired')->willReturn(true); self::assertFalse($policy->suspend($subscription, new \DateTimeImmutable())); > /** * @test */ public function it_returns_false_when_a_subscription_is_not_expired(): void < $policy = new CannotSuspendExpiredSubscriptionPolicy(); $subscription = $this->createStub(Subscription::class); $subscription->method('isExpired')->willReturn(false); self::assertTrue($policy->suspend($subscription, new \DateTimeImmutable())); > > class CannotSuspendNewSubscriptionPolicy implements SuspendingPolicyInterface < public function suspend(Subscription $subscription, \DateTimeImmutable $at): bool < if ($subscription->isNew()) < return false; >return true; > > class CannotSuspendNewSubscriptionPolicyTest extends TestCase < /** * @test */ public function it_returns_false_when_a_subscription_is_new(): void < $policy = new CannotSuspendNewSubscriptionPolicy(); $subscription = $this->createStub(Subscription::class); $subscription->method('isNew')->willReturn(true); self::assertFalse($policy->suspend($subscription, new \DateTimeImmutable())); > /** * @test */ public function it_returns_true_when_a_subscription_is_not_new(): void < $policy = new CannotSuspendNewSubscriptionPolicy(); $subscription = $this->createStub(Subscription::class); $subscription->method('isNew')->willReturn(false); self::assertTrue($policy->suspend($subscription, new \DateTimeImmutable())); > > class CanSuspendAfterOneMonthPolicy implements SuspendingPolicyInterface < public function suspend(Subscription $subscription, \DateTimeImmutable $at): bool < $oneMonthEarlierDate = \DateTime::createFromImmutable($at)->sub(new \DateInterval('P1M')); return $subscription->isOlderThan(\DateTimeImmutable::createFromMutable($oneMonthEarlierDate)); > > class CanSuspendAfterOneMonthPolicyTest extends TestCase < /** * @test */ public function it_returns_true_when_a_subscription_is_older_than_one_month(): void < $date = new \DateTimeImmutable('2021-01-29'); $policy = new CanSuspendAfterOneMonthPolicy(); $subscription = new Subscription(new \DateTimeImmutable('2020-12-28')); self::assertTrue($policy->suspend($subscription, $date)); > /** * @test */ public function it_returns_false_when_a_subscription_is_not_older_than_one_month(): void < $date = new \DateTimeImmutable('2021-01-29'); $policy = new CanSuspendAfterOneMonthPolicy(); $subscription = new Subscription(new \DateTimeImmutable('2020-01-01')); self::assertTrue($policy->suspend($subscription, $date)); > > class Status < private const EXPIRED = 'expired'; private const ACTIVE = 'active'; private const NEW = 'new'; private const SUSPENDED = 'suspended'; private string $status; private function __construct(string $status) < $this->status = $status; > public static function expired(): self < return new self(self::EXPIRED); >public static function active(): self < return new self(self::ACTIVE); >public static function new(): self < return new self(self::NEW); >public static function suspended(): self < return new self(self::SUSPENDED); >public function isEqual(self $status): bool < return $this->status === $status->status; > > class StatusTest extends TestCase < public function testEquals(): void < $status1 = Status::active(); $status2 = Status::active(); self::assertTrue($status1->isEqual($status2)); > public function testNotEquals(): void < $status1 = Status::active(); $status2 = Status::expired(); self::assertFalse($status1->isEqual($status2)); > > class SubscriptionTest extends TestCase < /** * @test */ public function suspending_a_subscription_is_possible_when_a_policy_returns_true(): void < $policy = $this->createMock(SuspendingPolicyInterface::class); $policy->expects($this->once())->method('suspend')->willReturn(true); $sut = new Subscription(new \DateTimeImmutable()); $result = $sut->suspend($policy, new \DateTimeImmutable()); self::assertTrue($result); self::assertTrue($sut->isSuspended()); > /** * @test */ public function suspending_a_subscription_is_not_possible_when_a_policy_returns_false(): void < $policy = $this->createMock(SuspendingPolicyInterface::class); $policy->expects($this->once())->method('suspend')->willReturn(false); $sut = new Subscription(new \DateTimeImmutable()); $result = $sut->suspend($policy, new \DateTimeImmutable()); self::assertFalse($result); self::assertFalse($sut->isSuspended()); > /** * @test */ public function it_returns_true_when_a_subscription_is_older_than_one_month(): void < $date = new \DateTimeImmutable(); $futureDate = $date->add(new \DateInterval('P1M')); $sut = new Subscription($date); self::assertTrue($sut->isOlderThan($futureDate)); > /** * @test */ public function it_returns_false_when_a_subscription_is_not_older_than_one_month(): void < $date = new \DateTimeImmutable(); $futureDate = $date->add(new \DateInterval('P1D')); $sut = new Subscription($date); self::assertTrue($sut->isOlderThan($futureDate)); > >
Не пишите код 1:1: один класс — один тест. Это приводит к хрупким тестам, что затрудняет рефакторинг.
final class CannotSuspendExpiredSubscriptionPolicy implements SuspendingPolicyInterface < public function suspend(Subscription $subscription, \DateTimeImmutable $at): bool < if ($subscription->isExpired()) < return false; >return true; > > final class CannotSuspendNewSubscriptionPolicy implements SuspendingPolicyInterface < public function suspend(Subscription $subscription, \DateTimeImmutable $at): bool < if ($subscription->isNew()) < return false; >return true; > > final class CanSuspendAfterOneMonthPolicy implements SuspendingPolicyInterface < public function suspend(Subscription $subscription, \DateTimeImmutable $at): bool < $oneMonthEarlierDate = \DateTime::createFromImmutable($at)->sub(new \DateInterval('P1M')); return $subscription->isOlderThan(\DateTimeImmutable::createFromMutable($oneMonthEarlierDate)); > > final class Status < private const EXPIRED = 'expired'; private const ACTIVE = 'active'; private const NEW = 'new'; private const SUSPENDED = 'suspended'; private string $status; private function __construct(string $status) < $this->status = $status; > public static function expired(): self < return new self(self::EXPIRED); >public static function active(): self < return new self(self::ACTIVE); >public static function new(): self < return new self(self::NEW); >public static function suspended(): self < return new self(self::SUSPENDED); >public function isEqual(self $status): bool < return $this->status === $status->status; > > final class Subscription < private Status $status; private \DateTimeImmutable $createdAt; public function __construct(\DateTimeImmutable $createdAt) < $this->status = Status::new(); $this->createdAt = $createdAt; > public function suspend(SuspendingPolicyInterface $suspendingPolicy, \DateTimeImmutable $at): bool < $result = $suspendingPolicy->suspend($this, $at); if ($result) < $this->status = Status::suspended(); > return $result; > public function isOlderThan(\DateTimeImmutable $date): bool < return $this->createdAt < $date; >public function activate(): void < $this->status = Status::active(); > public function expire(): void < $this->status = Status::expired(); > public function isExpired(): bool < return $this->status->isEqual(Status::expired()); > public function isActive(): bool < return $this->status->isEqual(Status::active()); > public function isNew(): bool < return $this->status->isEqual(Status::new()); > public function isSuspended(): bool < return $this->status->isEqual(Status::suspended()); > > final class SubscriptionSuspendingTest extends TestCase < /** * @test */ public function suspending_an_expired_subscription_with_cannot_suspend_expired_policy_is_not_possible(): void < $sut = new Subscription(new \DateTimeImmutable()); $sut->activate(); $sut->expire(); $result = $sut->suspend(new CannotSuspendExpiredSubscriptionPolicy(), new \DateTimeImmutable()); self::assertFalse($result); > /** * @test */ public function suspending_a_new_subscription_with_cannot_suspend_new_policy_is_not_possible(): void < $sut = new Subscription(new \DateTimeImmutable()); $result = $sut->suspend(new CannotSuspendNewSubscriptionPolicy(), new \DateTimeImmutable()); self::assertFalse($result); > /** * @test */ public function suspending_an_active_subscription_with_cannot_suspend_new_policy_is_possible(): void < $sut = new Subscription(new \DateTimeImmutable()); $sut->activate(); $result = $sut->suspend(new CannotSuspendNewSubscriptionPolicy(), new \DateTimeImmutable()); self::assertTrue($result); > /** * @test */ public function suspending_an_active_subscription_with_cannot_suspend_expired_policy_is_possible(): void < $sut = new Subscription(new \DateTimeImmutable()); $sut->activate(); $result = $sut->suspend(new CannotSuspendExpiredSubscriptionPolicy(), new \DateTimeImmutable()); self::assertTrue($result); > /** * @test */ public function suspending_an_subscription_before_a_one_month_is_not_possible(): void < $sut = new Subscription(new \DateTimeImmutable('2020-01-01')); $result = $sut->suspend(new CanSuspendAfterOneMonthPolicy(), new \DateTimeImmutable('2020-01-10')); self::assertFalse($result); > /** * @test */ public function suspending_an_subscription_after_a_one_month_is_possible(): void < $sut = new Subscription(new \DateTimeImmutable('2020-01-01')); $result = $sut->suspend(new CanSuspendAfterOneMonthPolicy(), new \DateTimeImmutable('2020-02-02')); self::assertTrue($result); > >
Шаблон humble
Как правильно выполнять юнит-тестирование такого класса?
class ApplicationService < public function __construct( private OrderRepository $orderRepository, private FormRepository $formRepository ) <>public function changeFormStatus(int $orderId): void < $order = $this->orderRepository->getById($orderId); $soapResponse = $this->getSoapClient()->getStatusByOrderId($orderId); $form = $this->formRepository->getByOrderId($orderId); $form->setStatus($soapResponse['status']); $form->setModifiedAt(new \DateTimeImmutable()); if ($soapResponse['status'] === 'accepted') < $order->setStatus('paid'); > $this->formRepository->save($form); $this->orderRepository->save($order); > private function getSoapClient(): \SoapClient < return new \SoapClient('https://legacy_system.pl/Soap/WebService', []); >>
Нужно разбить чрезмерно усложнённый код на отдельные классы.
final class ApplicationService < public function __construct( private OrderRepositoryInterface $orderRepository, private FormRepositoryInterface $formRepository, private FormApiInterface $formApi, private ChangeFormStatusService $changeFormStatusService ) <>public function changeFormStatus(int $orderId): void < $order = $this->orderRepository->getById($orderId); $form = $this->formRepository->getByOrderId($orderId); $status = $this->formApi->getStatusByOrderId($orderId); $this->changeFormStatusService->changeStatus($order, $form, $status); $this->formRepository->save($form); $this->orderRepository->save($order); > > final class ChangeFormStatusService < public function changeStatus(Order $order, Form $form, string $formStatus): void < $status = FormStatus::createFromString($formStatus); $form->changeStatus($status); if ($form->isAccepted()) < $order->changeStatus(OrderStatus::paid()); > > > final class ChangingFormStatusTest extends TestCase < /** * @test */ public function changing_a_form_status_to_accepted_changes_an_order_status_to_paid(): void < $order = new Order(); $form = new Form(); $status = 'accepted'; $sut = new ChangeFormStatusService(); $sut->changeStatus($order, $form, $status); self::assertTrue($form->isAccepted()); self::assertTrue($order->isPaid()); > /** * @test */ public function changing_a_form_status_to_refused_not_changes_an_order_status(): void < $order = new Order(); $form = new Form(); $status = 'new'; $sut = new ChangeFormStatusService(); $sut->changeStatus($order, $form, $status); self::assertFalse($form->isAccepted()); self::assertFalse($order->isPaid()); > >
Однако ApplicationService , вероятно, нужно проверить с помощью интеграционного теста с моком FormApiInterface .
Бесполезный тест
final class Customer < public function __construct(private string $name) <>public function getName(): string < return $this->name; > public function setName(string $name): void < $this->name = $name; > > final class CustomerTest extends TestCase < public function testSetName(): void < $customer = new Customer('Jack'); $customer->setName('John'); self::assertSame('John', $customer->getName()); > > final class EventSubscriber < public static function getSubscribedEvents(): array < return ['event' =>'onEvent']; > public function onEvent(): void < >> final class EventSubscriberTest extends TestCase < public function testGetSubscribedEvents(): void < $result = EventSubscriber::getSubscribedEvents(); self::assertSame(['event' =>'onEvent'], $result); > >
Тестировать код, не содержащий какой-либо сложной логики, не только бессмысленно, но и приводит к хрупким тестам.
Хрупкий тест
final class UserRepository < public function __construct( private Connection $connection ) <>public function getUserNameByEmail(string $email): ?array < return $this ->connection ->createQueryBuilder() ->from('user', 'u') ->where('u.email = :email') ->setParameter('email', $email) ->execute() ->fetch(); > > final class TestUserRepository extends TestCase < public function testGetUserNameByEmail(): void < $email = 'test@test.com'; $connection = $this->createMock(Connection::class); $queryBuilder = $this->createMock(QueryBuilder::class); $result = $this->createMock(ResultStatement::class); $userRepository = new UserRepository($connection); $connection ->expects($this->once()) ->method('createQueryBuilder') ->willReturn($queryBuilder); $queryBuilder ->expects($this->once()) ->method('from') ->with('user', 'u') ->willReturn($queryBuilder); $queryBuilder ->expects($this->once()) ->method('where') ->with('u.email = :email') ->willReturn($queryBuilder); $queryBuilder ->expects($this->once()) ->method('setParameter') ->with('email', $email) ->willReturn($queryBuilder); $queryBuilder ->expects($this->once()) ->method('execute') ->willReturn($result); $result ->expects($this->once()) ->method('fetch') ->willReturn(['email' => $email]); $result = $userRepository->getUserNameByEmail($email); self::assertSame(['email' => $email], $result); > >
Подобное тестирование репозиториев приводит к хрупким тестам и затрудняет рефакторинг. Тестируйте репозитории с помощью интеграционных тестов.
Исправления тестов
final class InvalidTest extends TestCase < private ?Subscription $subscription; public function setUp(): void < $this->subscription = new Subscription(new \DateTimeImmutable()); $this->subscription->activate(); > /** * @test */ public function suspending_an_active_subscription_with_cannot_suspend_new_policy_is_possible(): void < $result = $this->subscription->suspend(new CannotSuspendNewSubscriptionPolicy(), new \DateTimeImmutable()); self::assertTrue($result); > /** * @test */ public function suspending_an_active_subscription_with_cannot_suspend_expired_policy_is_possible(): void < $result = $this->subscription->suspend(new CannotSuspendExpiredSubscriptionPolicy(), new \DateTimeImmutable()); self::assertTrue($result); > /** * @test */ public function suspending_a_new_subscription_with_cannot_suspend_new_policy_is_not_possible(): void < // Here we need to create a new subscription, it is not possible to change $this->subscription to a new subscription > >
final class ValidTest extends TestCase < /** * @test */ public function suspending_an_active_subscription_with_cannot_suspend_new_policy_is_possible(): void < $sut = $this->createAnActiveSubscription(); $result = $sut->suspend(new CannotSuspendNewSubscriptionPolicy(), new \DateTimeImmutable()); self::assertTrue($result); > /** * @test */ public function suspending_an_active_subscription_with_cannot_suspend_expired_policy_is_possible(): void < $sut = $this->createAnActiveSubscription(); $result = $sut->suspend(new CannotSuspendExpiredSubscriptionPolicy(), new \DateTimeImmutable()); self::assertTrue($result); > /** * @test */ public function suspending_a_new_subscription_with_cannot_suspend_new_policy_is_not_possible(): void < $sut = $this->createANewSubscription(); $result = $sut->suspend(new CannotSuspendNewSubscriptionPolicy(), new \DateTimeImmutable()); self::assertFalse($result); > private function createANewSubscription(): Subscription < return new Subscription(new \DateTimeImmutable()); >private function createAnActiveSubscription(): Subscription < $subscription = new Subscription(new \DateTimeImmutable()); $subscription->activate(); return $subscription; > >
- Лучше избегать использования общего для нескольких тестов состояния.
- Чтобы повторно использовать элементы в нескольких тестах применяйте:
Общие антипаттерны тестирования
Раскрытие приватного состояния
final class Customer < private CustomerType $type; private DiscountCalculationPolicyInterface $discountCalculationPolicy; public function __construct() < $this->type = CustomerType::NORMAL(); $this->discountCalculationPolicy = new NormalDiscountPolicy(); > public function makeVip(): void < $this->type = CustomerType::VIP(); $this->discountCalculationPolicy = new VipDiscountPolicy(); > public function getCustomerType(): CustomerType < return $this->type; > public function getPercentageDiscount(): int < return $this->discountCalculationPolicy->getPercentageDiscount(); > > final class InvalidTest extends TestCase < public function testMakeVip(): void < $sut = new Customer(); $sut->makeVip(); self::assertSame(CustomerType::VIP(), $sut->getCustomerType()); > >
final class Customer < private CustomerType $type; private DiscountCalculationPolicyInterface $discountCalculationPolicy; public function __construct() < $this->type = CustomerType::NORMAL(); $this->discountCalculationPolicy = new NormalDiscountPolicy(); > public function makeVip(): void < $this->type = CustomerType::VIP(); $this->discountCalculationPolicy = new VipDiscountPolicy(); > public function getPercentageDiscount(): int < return $this->discountCalculationPolicy->getPercentageDiscount(); > > final class ValidTest extends TestCase < /** * @test */ public function a_vip_customer_has_a_25_percentage_discount(): void < $sut = new Customer(); $sut->makeVip(); self::assertSame(25, $sut->getPercentageDiscount()); > >
Внесение дополнительного production-кода (например, метода-получателя getCustomerType() ) только ради проверки состояния в тестах — плохая практика. Состояние нужно проверять другим важным предметным значением (в этом случае — getPercentageDiscount() ). Конечно, иногда трудно найти другой способ проверки операции, и мы можем оказаться вынуждены внести дополнительный production-код для проверки корректности тестов, но нужно стараться избегать этого.
Утечка подробностей о предметной области
final class DiscountCalculator < public function calculate(int $isVipFromYears): int < Assert::greaterThanEq($isVipFromYears, 0); return min(($isVipFromYears * 10) + 3, 80); >>
final class InvalidTest extends TestCase < /** * @dataProvider discountDataProvider */ public function testCalculate(int $vipDaysFrom, int $expected): void < $sut = new DiscountCalculator(); self::assertSame($expected, $sut->calculate($vipDaysFrom)); > public function discountDataProvider(): array < return [ [0, 0 * 10 + 3], //leaking domain details [1, 1 * 10 + 3], [5, 5 * 10 + 3], [8, 80] ]; >>
final class ValidTest extends TestCase < /** * @dataProvider discountDataProvider */ public function testCalculate(int $vipDaysFrom, int $expected): void < $sut = new DiscountCalculator(); self::assertSame($expected, $sut->calculate($vipDaysFrom)); > public function discountDataProvider(): array < return [ [0, 3], [1, 13], [5, 53], [8, 80] ]; >>
Не дублируйте в тестах production-логику. Проверяйте результаты с помощью прописанных в коде значений.
Мокинг конкретных классов
class DiscountCalculator < public function calculateInternalDiscount(int $isVipFromYears): int < Assert::greaterThanEq($isVipFromYears, 0); return min(($isVipFromYears * 10) + 3, 80); >public function calculateAdditionalDiscountFromExternalSystem(): int < // get data from an external system to calculate a discount return 5; >> class OrderService < public function __construct(private DiscountCalculator $discountCalculator) <>public function getTotalPriceWithDiscount(int $totalPrice, int $vipFromDays): int < $internalDiscount = $this->discountCalculator->calculateInternalDiscount($vipFromDays); $externalDiscount = $this->discountCalculator->calculateAdditionalDiscountFromExternalSystem(); $discountSum = $internalDiscount + $externalDiscount; return $totalPrice - (int) ceil(($totalPrice * $discountSum) / 100); > > final class InvalidTest extends TestCase < /** * @dataProvider orderDataProvider */ public function testGetTotalPriceWithDiscount(int $totalPrice, int $vipDaysFrom, int $expected): void < $discountCalculator = $this->createPartialMock(DiscountCalculator::class, ['calculateAdditionalDiscountFromExternalSystem']); $discountCalculator->method('calculateAdditionalDiscountFromExternalSystem')->willReturn(5); $sut = new OrderService($discountCalculator); self::assertSame($expected, $sut->getTotalPriceWithDiscount($totalPrice, $vipDaysFrom)); > public function orderDataProvider(): array < return [ [1000, 0, 920], [500, 1, 410], [644, 5, 270], ]; >>
interface ExternalDiscountCalculatorInterface < public function calculate(): int; >final class InternalDiscountCalculator < public function calculate(int $isVipFromYears): int < Assert::greaterThanEq($isVipFromYears, 0); return min(($isVipFromYears * 10) + 3, 80); >> final class OrderService < public function __construct( private InternalDiscountCalculator $discountCalculator, private ExternalDiscountCalculatorInterface $externalDiscountCalculator ) <>public function getTotalPriceWithDiscount(int $totalPrice, int $vipFromDays): int < $internalDiscount = $this->discountCalculator->calculate($vipFromDays); $externalDiscount = $this->externalDiscountCalculator->calculate(); $discountSum = $internalDiscount + $externalDiscount; return $totalPrice - (int) ceil(($totalPrice * $discountSum) / 100); > > final class ValidTest extends TestCase < /** * @dataProvider orderDataProvider */ public function testGetTotalPriceWithDiscount(int $totalPrice, int $vipDaysFrom, int $expected): void < $externalDiscountCalculator = $this->createStub(ExternalDiscountCalculatorInterface::class); $externalDiscountCalculator->method('calculate')->willReturn(5); $sut = new OrderService(new InternalDiscountCalculator(), $externalDiscountCalculator); self::assertSame($expected, $sut->getTotalPriceWithDiscount($totalPrice, $vipDaysFrom)); > public function orderDataProvider(): array < return [ [1000, 0, 920], [500, 1, 410], [644, 5, 270], ]; >>
Необходимость мокать конкретный класс для замены части его поведения означает, что этот класс, вероятно, слишком сложен и нарушает принцип единственной ответственности.
Тестирование приватных методов
final class OrderItem < public function __construct(private int $total) <>public function getTotal(): int < return $this->total; > > final class Order < /** * @param OrderItem[] $items * @param int $transportCost */ public function __construct(private array $items, private int $transportCost) <>public function getTotal(): int < return $this->getItemsTotal() + $this->transportCost; > private function getItemsTotal(): int < return array_reduce( array_map(fn (OrderItem $item) =>$item->getTotal(), $this->items), fn (int $sum, int $total) => $sum += $total, 0 ); > >
final class InvalidTest extends TestCase < /** * @test * @dataProvider ordersDataProvider */ public function get_total_returns_a_total_cost_of_a_whole_order(Order $order, int $expectedTotal): void < self::assertSame($expectedTotal, $order->getTotal()); > /** * @test * @dataProvider orderItemsDataProvider */ public function get_items_total_returns_a_total_cost_of_all_items(Order $order, int $expectedTotal): void < self::assertSame($expectedTotal, $this->invokePrivateMethodGetItemsTotal($order)); > public function ordersDataProvider(): array < return [ [new Order([new OrderItem(20), new OrderItem(20), new OrderItem(20)], 15), 75], [new Order([new OrderItem(20), new OrderItem(30), new OrderItem(40)], 0), 90], [new Order([new OrderItem(99), new OrderItem(99), new OrderItem(99)], 9), 306] ]; >public function orderItemsDataProvider(): array < return [ [new Order([new OrderItem(20), new OrderItem(20), new OrderItem(20)], 15), 60], [new Order([new OrderItem(20), new OrderItem(30), new OrderItem(40)], 0), 90], [new Order([new OrderItem(99), new OrderItem(99), new OrderItem(99)], 9), 297] ]; >private function invokePrivateMethodGetItemsTotal(Order &$order): int < $reflection = new \ReflectionClass(get_class($order)); $method = $reflection->getMethod('getItemsTotal'); $method->setAccessible(true); return $method->invokeArgs($order, []); > >
final class ValidTest extends TestCase < /** * @test * @dataProvider ordersDataProvider */ public function get_total_returns_a_total_cost_of_a_whole_order(Order $order, int $expectedTotal): void < self::assertSame($expectedTotal, $order->getTotal()); > public function ordersDataProvider(): array < return [ [new Order([new OrderItem(20), new OrderItem(20), new OrderItem(20)], 15), 75], [new Order([new OrderItem(20), new OrderItem(30), new OrderItem(40)], 0), 90], [new Order([new OrderItem(99), new OrderItem(99), new OrderItem(99)], 9), 306] ]; >>
Тесты должны проверять только публичный API.
Время как непостоянная зависимость
Время является непостоянной зависимостью из-за своего недетерминизма. Каждый вызов даёт другой результат.
final class Clock < public static \DateTime|null $currentDateTime = null; public static function getCurrentDateTime(): \DateTime < if (null === self::$currentDateTime) < self::$currentDateTime = new \DateTime(); >return self::$currentDateTime; > public static function set(\DateTime $dateTime): void < self::$currentDateTime = $dateTime; >public static function reset(): void < self::$currentDateTime = null; >> final class Customer < private \DateTime $createdAt; public function __construct() < $this->createdAt = Clock::getCurrentDateTime(); > public function isVip(): bool < return $this->createdAt->diff(Clock::getCurrentDateTime())->y >= 1; > > final class InvalidTest extends TestCase < /** * @test */ public function a_customer_registered_more_than_a_one_year_ago_is_a_vip(): void < Clock::set(new \DateTime('2019-01-01')); $sut = new Customer(); Clock::reset(); // you have to remember about resetting the shared state self::assertTrue($sut->isVip()); > /** * @test */ public function a_customer_registered_less_than_a_one_year_ago_is_not_a_vip(): void < Clock::set((new \DateTime())->sub(new \DateInterval('P2M'))); $sut = new Customer(); Clock::reset(); // you have to remember about resetting the shared state self::assertFalse($sut->isVip()); > >
interface ClockInterface < public function getCurrentTime(): \DateTimeImmutable; >final class Clock implements ClockInterface < private function __construct() < >public static function create(): self < return new self(); >public function getCurrentTime(): \DateTimeImmutable < return new \DateTimeImmutable(); >> final class FixedClock implements ClockInterface < private function __construct(private \DateTimeImmutable $fixedDate) <>public static function create(\DateTimeImmutable $fixedDate): self < return new self($fixedDate); >public function getCurrentTime(): \DateTimeImmutable < return $this->fixedDate; > > final class Customer < private \DateTimeImmutable $createdAt; public function __construct(\DateTimeImmutable $createdAt) < $this->createdAt = $createdAt; > public function isVip(\DateTimeImmutable $currentDate): bool < return $this->createdAt->diff($currentDate)->y >= 1; > > final class ValidTest extends TestCase < /** * @test */ public function a_customer_registered_more_than_a_one_year_ago_is_a_vip(): void < $sut = new Customer(FixedClock::create(new \DateTimeImmutable('2019-01-01'))->getCurrentTime()); self::assertTrue($sut->isVip(FixedClock::create(new \DateTimeImmutable('2020-01-02'))->getCurrentTime())); > /** * @test */ public function a_customer_registered_less_than_a_one_year_ago_is_not_a_vip(): void < $sut = new Customer(FixedClock::create(new \DateTimeImmutable('2019-01-01'))->getCurrentTime()); self::assertFalse($sut->isVip(FixedClock::create(new \DateTimeImmutable('2019-05-02'))->getCurrentTime())); > >
В коде, относящемся к предметной области, нельзя напрямую генерировать время и случайные числа. Для проверки поведения нужны детерминистские результаты, поэтому нужно внедрять эти значения в объект, относящийся к предметной области, как в примере выше.
Не гонитесь за полным покрытием
Полное покрытие не является целью, или даже не желательно, потому что в противном случае тесты наверняка будут очень хрупкими, а рефакторинг — очень сложным. Мутационное тестирование даёт более полезную обратную связь о качестве тестов. Подробнее.
Рекомендуемые книги
- Test Driven Development: By Example / Kent Beck — классика.
- Unit Testing Principles, Practices, and Patterns / Vladimir Khorikov — лучшая известная мне книга о тестировании.
Юнит-тестирование в PHP
Язык PHP очень легок для изучения. Это, а так же обилие литературы «Освой _что_угодно_ за 24 часа» породило большое количество, мягко говоря, некачественного кода. Как следствие, рано или поздно любой программист, который выходит за рамки создания гостевой книги или сайта-визитки сталкивается с вопросом: «а если я здесь немножко добавлю, все остальное не ляжет?» Дать ответ на этот вопрос и на многие другие может юнит-тестирование.
В самом начале хочется оговориться — здесь речь не будет идти о TDD и методологиях разработки ПО. В данной статье я попробую показать начинающему PHP-разработчику основы использования модульного тестирования на базе фреймворка PHPUnit
Вместо предисловия
Вначале возникает вполне резонный вопрос: А зачем, если все и так работает?
Здесь все просто. После очередного изменения что-то перестанет работать так как надо — это я вам обещаю. И тогда поиск ошибки может отнять очень много времени. Модульное тестирование может свести этот процесс к считанным минутам. Не будем так же исключать переезд на другую платформу и связанные с ним «подводные камни». Чего только стоит разность в точности вычислений на 64- и 32-разрядных системах. А когда-то вы в первый раз столкнетесь с нюансами вроде
print ( int ) ( ( 0.1 + 0.7 ) * 10 ) ;
// думаете 8? проверьте .
// или лучше так: «Вы еще не используете BC Math? Тогда мы идем к вам!»
Если вопрос необходимости отпал, то можно приступить к выбору инструмента. Здесь ассортимент в общем-то невелик — PHPUnit (http://www.phpunit.de/) и SimpleTest (http://www.simpletest.org/). Поскольку PHPUnit уже стал стандартом де-факто в тестировании PHP приложений, то на нем и остановимся.
Первое знакомство
Вопросы установки рассматривать не будем: все достаточно внятно изложено на сайте. Попробуем лучше понять, как это все работает.
Допустим, есть у нас некий класс «MyClass», одним из методов реализующий возведение числа в степень. (Здесь вынужден извиниться, но все примеры, в общем-то, высосаны из пальца)
public function power ( $x , $y )
<
return pow ( $x , $y ) ;
>
>
Хочется проверить, правильно ли он работает. О всем классе речь пока не идет — только об одном методе. Напишем для проверки такой тест.
require_once ‘PHPUnit/Framework.php’ ;
require_once ‘MyClass.php’ ;
class MyClassTest extends PHPUnit_Framework_TestCase <
public function testPower ( )
<
$my = new MyClass ( ) ;
$this -> assertEquals ( 8 , $my -> power ( 2 , 3 ) ) ;
>
>
- Название класса в тесте складывается из названия тестируемого класса плюс «Test»;
- Класс для тестирования в большинстве случаев наследуется от PHPUnit_Framework_TestCase;
- Каждый тест является паблик-методом, название которого начинается с префикса «test»;
- Внутри теста мы используем один из assert-методов для выяснения соответствует ли результат обработки ожидаемому (подробнее чуть ниже);
$ phpunit MyClassTest
.
Time: 0 seconds
OK (1 test, 1 assertion)
Результат выполнения теста «ОК». Казалось бы можно остановиться на этом, но иногда было бы неплохо для проверки скормить методу не один набор данных. PHPUnit предоставляет нам такую возможность — это провайдеры данных. Провайдер данных тоже является паблик-методом (название не существенно), который возвращает массив наборов данных для каждой итеррации. Для использования провайдера необходимо указать его в теге @dataProvider к тесту.
Изменим наш тест следующим образом:
require_once ‘PHPUnit/Framework.php’ ;
require_once ‘MyClass.php’ ;
class MyClassTest extends PHPUnit_Framework_TestCase
/**
* @dataProvider providerPower
*/
public function testPower ( $a , $b , $c )
<
$my = new MyClass ( ) ;
$this -> assertEquals ( $c , $my -> power ( $a , $b ) ) ;
>
public function providerPower ( )
<
return array (
array ( 2 , 2 , 4 ) ,
array ( 2 , 3 , 9 ) ,
array ( 3 , 5 , 243 )
) ;
>
>
После запуска увидим следующую картину:
.F.
Time: 0 seconds
There was 1 failure:
1) testPower(MyClassTest) with data set #1 (2, 3, 9)
Failed asserting that matches expected value .
/home/user/unit/MyClassTest.php:14
FAILURES!
Tests: 3, Assertions: 3, Failures: 1.
Опишу подробнее. Точка, которую в первом выводе теста многие могли принять за опечатку на самом деле таковой не является — это успешно пройденный тест. F(ailure) — соответственно тест не пройденный. Значит в данном случае, было проведено 3 теста, один из который завершился неудачно. В расширенном описании нам было сказано, какой именно, с каким набором исходных данных, с каким реальным и каким ожидаемым результатом. Если бы 2 3 действительно равнялось 9-ти, то мы увидели бы таким образом ошибку в нашем сценарии.
Здесь, как мне кажется, есть смысл отвлечься от несколько абстрактной практики и перейти ко вполне конкретной теории. А именно, описать какими же assert-методами мы располагаем для проверки поведения тестируемых сценариев.
Два самых простых — это assertFalse() и assertTrue(). Проверяют, является ли полученное значение false и true соответственно. Далее идут уже упомянутый assertEquals() и обратный ему assertNotEquals(). В их использовании есть нюансы. Так при сравнении чисел с плавающей точкой есть возможность указать точность сравнения. Так же эти методы используются для сравнения экземпляров класса DOMDocument, массивов и любых объектов (в последнем случае равенство будет установлено, если атрибуты объектов содержат одинаковые значения). Так же следует упомянуть assertNull() и assertNotNull() которые проверяют соответствие параметра типу данных NULL (да-да, не забываем, что в PHP это отдельный тип данных). Этим возможные сравнения не ограничиваются. Нет смысла в рамках этой статьи заниматься перепечаткой документации, потому приведу по возможности структурированный список всех возможных методов. Более детально интересующиеся могут прочитать здесь
Базовые методы сравнения
assertTrue() / assertFalse()
assertEquals() / assertNotEquals()
assertGreaterThan()
assertGreaterThanOrEqual()
assertLessThan()
assertLessThanOrEqual()
assertNull() / assertNotNull()
assertType() / assertNotType()
assertSame() / assertNotSame()
assertRegExp() / assertNotRegExp()
Методы сравнения массивов
assertArrayHasKey() / assertArrayNotHasKey()
assertContains() / assertNotContains()
assertContainsOnly() / assertNotContainsOnly()
ООП специфичные методы
assertClassHasAttribute() / assertClassNotHasAttribute()
assertClassHasStaticAttribute() / assertClassNotHasStaticAttribute()
assertAttributeContains() / assertAttributeNotContains()
assertObjectHasAttribute() / assertObjectNotHasAttribute()
assertAttributeGreaterThan()
assertAttributeGreaterThanOrEqual()
assertAttributeLessThan()
assertAttributeLessThanOrEqual()
Методы сравнения файлов
assertFileEquals() / assertFileNotEquals()
assertFileExists() / assertFileNotExists()
assertStringEqualsFile() / assertStringNotEqualsFile()
Методы сравнения XML
assertEqualXMLStructure()
assertXmlFileEqualsXmlFile() / assertXmlFileNotEqualsXmlFile()
assertXmlStringEqualsXmlFile() / assertXmlStringNotEqualsXmlFile()
assertXmlStringEqualsXmlString() / assertXmlStringNotEqualsXmlString()
Разное
assertTag()
assertThat()
Исключения
Если еще не надоело, то вернемся к практике, а именно к обработке исключений. Для начала модифицируем наш тестируемый класс — введем в него метод, который будет это исключение выбрасывать.
public function divide ( $x , $y )
<
if ( ! ( boolean ) $y )
<
throw new MathException ( ‘Division by zero’ ) ;
>
return $x / $y ;
>
>
Теперь надо создать тест, который будет завершаться успешно в том случае, если при определенном наборе данных будет вызвано это исключение. Задать требуемое исключение можно как минимум двумя способами — добавив к тесту @expectedException либо вызвав в тесте метод setExpectedException().
require_once ‘PHPUnit/Framework.php’ ;
require_once ‘MyClass.php’ ;
class MyClassTest extends PHPUnit_Framework_TestCase
/**
* @expectedException MathException
*/
public function testDivision1 ( )
<
$my = new MyClass ( ) ;
$my -> divide ( 8 , 0 ) ;
>
public function testDivision2 ( )
<
$this -> setExpectedException ( ‘MathException’ ) ;
$my = new MyClass ( ) ;
$my -> divide ( 8 , 0 ) ;
>
>
Тесты, в общем-то, абсолютно идентичны. Выбор способа остается на ваше усмотрение. Помимо механизмов предоставляемых непосредственно PHPUnit, для тестирования исключений можно воспользоваться стандартным try catch (), к примеру, так:
require_once ‘PHPUnit/Framework.php’ ;
require_once ‘MyClass.php’ ;
class MyClassTest extends PHPUnit_Framework_TestCase
public function testDivision3 ( )
<
$my = new MyClass ( ) ;
try <
$my -> divide ( 8 , 2 ) ;
> catch ( MathException $e ) <
return ;
>
$this -> fail ( ‘Not raise an exception’ ) ;
>
>
В этом примере мы так же видим не рассмотренный ранее способ завершения теста с помощью вызова метода fail(). Вывод теста будет следующим:
F
Time: 0 seconds
There was 1 failure:
1) testDivision3(MyClassTest)
Not raise an exception
/home/user/unit/MyClassTest.php:50
Принадлежности
Базовые методы тестирования мы освоили. Можно ли улучшить наш тест? Да. Написанный c начала этой статьи класс проводит несколько тестов, в каждом из которых создается экземпляр тестируемого класса, что абсолютно излишне, потому как PHPUnit предоставляет в наше пользование механизм принадлежностей теста (fixtures). Установить их можно защищенным методом setUp(), который вызывается один раз перед началом каждого теста. После окончания теста вызывается метод tearDown(), в котором мы можем провести «сборку мусора». Таким образом, исправленный тест может выглядеть так:
require_once ‘PHPUnit/Framework.php’ ;
require_once ‘MyClass.php’ ;
class MyClassTest extends PHPUnit_Framework_TestCase
protected function setUp ( )
<
$this -> fixture = new MyClass ( ) ;
>
protected function tearDown ( )
<
$this -> fixture = NULL ;
>
/**
* @dataProvider providerPower
*/
public function testPower ( $a , $b , $c )
<
$this -> assertEquals ( $c , $this -> fixture -> power ( $a , $b ) ) ;
>
public function providerPower ( )
<
return array (
array ( 2 , 2 , 4 ) ,
array ( 2 , 3 , 8 ) ,
array ( 3 , 5 , 243 )
) ;
>
Наборы тестов
После того, как код нескольких классов будет покрыт тестами, становится довольно таки неудобно запускать каждый тест по отдельности. Здесь нам на помощь могут прийти наборы тестов — несколько связанных единой задачей тестов можно объединить в набор и запускать соответственно его. Наборы реализованы классом PHPUnit_Framework_TestSuite. Необходимо создать экземпляр этого класса и добавить в него необходимые тесты с помощью метода addTestSuite(). Так же с помощью метода addTest() возможно добавление другого набора.
require_once ‘PHPUnit/Framework.php’ ;
// подключаем файлы с тестами
require_once ‘MyClassTest.php’ ;
class SpecificTests
<
public static function suite ( )
<
$suite = new PHPUnit_Framework_TestSuite ( ‘MySuite’ ) ;
// добавляем тест в набор
$suite -> addTestSuite ( ‘MyClassTest’ ) ;
return $suite ;
>
>
require_once ‘PHPUnit/Framework.php’ ;
// подключаем файл с набором тестов
require_once ‘SpecificTests.php’ ;
class AllTests
<
public static function suite ( )
<
$suite = new PHPUnit_Framework_TestSuite ( ‘AllMySuite’ ) ;
// добавляем набор тестов
$suite -> addTest ( SpecificTests :: suite ( ) ) ;
return $suite ;
>
>
А теперь представим себе набор тестов для сценария, работающего с БД. Неужели нам в каждом тесте придется подключаться к базе? Нет — не придется. Для этого можно создать свой класс унаследованный от PHPUnit_Framework_TestSuite, определить его методы setUp() и tearDown() для инициализации интерфейса БД и просто передать его в тест атрибутом sharedFixture. Базы данных мы оставим на потом, а пока попробуем создать собственный набор тестов для уже имеющегося класса.
require_once ‘PHPUnit/Framework.php’ ;
require_once ‘MyClass.php’ ;
class MyClassTest extends PHPUnit_Framework_TestCase
protected function setUp ( )
<
$this -> fixture = $this -> sharedFixture ;
>
protected function tearDown ( )
<
$this -> fixture = NULL ;
>
/**
* @dataProvider providerPower
*/
public function testPower ( $a , $b , $c )
<
$this -> assertEquals ( $c , $this -> fixture -> power ( $a , $b ) ) ;
>
public function providerPower ( )
<
return array (
array ( 2 , 2 , 4 ) ,
array ( 2 , 3 , 8 ) ,
array ( 3 , 5 , 243 )
) ;
>
class MySuite extends PHPUnit_Framework_TestSuite
public static function suite ( )
<
$suite = new MySuite ( ‘MyTests’ ) ;
$suite -> addTestSuite ( ‘MyClassTest’ ) ;
return $suite ;
>
protected function setUp ( )
<
$this -> sharedFixture = new MyClass ( ) ;
>
protected function tearDown ( )
<
$this -> sharedFixture = NULL ;
>
Здесь мы в sharedFixture положили экземпляр тестируемого класса, а в тесте просто его использовали — решение не слишком красивое (я бы даже сказал, вообще не красивое), но общее представление о наборах тестов и передаче принадлежностей между тестами оно дает. Если наглядно изобразить очередность вызова методов, то получится нечто вроде такого:
MySuite::setUp()
MyClassTest::setUp()
MyClassTest::testPower()
MyClassTest::tearDown()
MyClassTest::setUp()
MyClassTest::testDivision()
MyClassTest::tearDown()
.
MySuite::tearDown()
Дополнительные возможности
Помимо всего вышеизложенного может возникнуть необходимость проверить не только расчеты и поведение сценария, но так же вывод и скорость отработки. Для этого в наше распоряжение предоставлены расширения PHPUnit_Extensions_OutputTestCase и PHPUnit_Extensions_PerformanceTestCase соответственно. Добавим в наш тестируемый класс еще один метод, и проверим правильно ли он работает.
public function square ( $x )
<
sleep ( 2 ) ;
print $x * $x ;
>
require_once ‘PHPUnit/Framework.php’ ;
require_once ‘PHPUnit/Extensions/OutputTestCase.php’ ;
require_once ‘PHPUnit/Extensions/PerformanceTestCase.php’ ;
require_once ‘MyClass.php’ ;
class MyClassOutputTest extends PHPUnit_Extensions_OutputTestCase
protected function setUp ( )
<
$this -> fixture = $this -> sharedFixture ;
>
protected function tearDown ( )
<
$this -> fixture = NULL ;
>
public function testSquare ( )
<
$this -> expectOutputString ( ‘4’ ) ;
$this -> fixture -> square ( 2 ) ;
>
>
class MyClassPerformanceTest extends PHPUnit_Extensions_PerformanceTestCase
protected function setUp ( )
<
$this -> fixture = $this -> sharedFixture ;
>
protected function tearDown ( )
<
$this -> fixture = NULL ;
>
public function testPerformance ( )
<
$this -> setMaxRunningTime ( 1 ) ;
$this -> fixture -> square ( 4 ) ;
>
>
class MyClassTest extends PHPUnit_Framework_TestCase
Задать ожидаемый вывод сценария можно с помощью методов expectOutputString() или expectOutputRegex(). А для метода setMaxRunningTime() планируемое время отработки указывается в секундах. Для того, что бы эти тесты запускались вместе с уже написанными их всего лишь надо добавить к нашему набору:
class MySuite extends PHPUnit_Framework_TestSuite
public static function suite ( )
<
$suite = new MySuite ( ‘MyTests’ ) ;
$suite -> addTestSuite ( ‘MyClassTest’ ) ;
$suite -> addTestSuite ( ‘MyClassOutputTest’ ) ;
$suite -> addTestSuite ( ‘MyClassPerformanceTest’ ) ;
return $suite ;
>
Пропускаем тесты
И напоследок рассмотрим ситуацию, в которой некоторые тесты необходимо пропускать по каким либо причинам. К примеру в том случае, когда на тестируемой машине отсутствует какое-либо расширение PHP, можно убедившись в его отсутствии пометить тест, как пропущенный добавив к его коду следующее:
if ( ! extension_loaded ( ‘someExtension’ ) ) <
$this -> markTestSkipped ( ‘Extension is not loaded.’ ) ;
>
Либо в том случае, когда тест написан для кода, которого еще нет в сценарии (не редкая для TDD ситуация) его можно пометить как не реализованный с помощью метода markTestIncomplete()
Напоследок
Наверное, на этом пока можно остановиться. Тема модульного тестирования данной статьей далеко не завершена — осталось еще использование mock-объектов, тестирование работы с БД, анализ покрытия кода и многое другое. Но надеюсь, что поставленная цель — ознакомить начинающих с базовыми возможностями PHPUnit и подтолкнуть к использованию юнит-тестов, как одному из средств для достижения большей эффективности — была достигнута.
Удачи вам, и стабильных приложений.
