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

<?php
include 'vendor/autoload.php';
assert_options(ASSERT_ACTIVE, 1);
assert_options(ASSERT_BAIL, true);

$instance = new ExampleClass();
$instance->setValue(42);

assert($instance->getValueSquared() == 1764);

try {
    $instance->failingOperation();
} catch (Exception $e) {
    assert($e instanceof SpecificException);
}

echo "OK\n";

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

Установка

В наш век Composer и управляемых зависимостей установка чего угодно упрощена до предела. При условии, конечно, что требуемая библиотека есть в Packagist.

composer require --dev phpunit/phpunit

Проверим корректность установки PHPUnit, запросив версию:

php vendor/bin/phpunit --version

Инструкции приводятся для версий 7.4, 8.5 и 9.0.

Настройка

Понятно, что вы не хотите каждый раз вручную указывать всевозможные аргументы типа --bootstrap при запуске phpunit, а потому лучше всего будет сразу же, до начала работы, указать все необходимые параметры для системного запуска тестов. Настроил — и забыл!

Настройки PHPUnit хранятся в XML-файле в корне вашего проекта. Лучше сразу использовать phpunit.xml.dist, а не phpunit.xml, чтобы у ваших коллег была возможность переопределить свои настройки для тестов или свою подборку тестов.

Что мы делаем

Пример минимального файла phpunit.xml.dist, который подразумевает, что автозагрузчик и всё необходимо для работы наших классов инициализируется в vendor/autoload.php, и что все тесты лежат в каталоге tests в корне проекта, и в подкаталогах этого каталога. Также мы просим PHPUnit проводить тесты в случайном порядке для исключения возможных скрытых зависимостей между тестами.

<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php"
         colors="true" 
         executionOrder="random"
         resolveDependencies="true">
  <testsuites>
    <testsuite name="Main">
      <directory>tests/</directory>
    </testsuite>
  </testsuites>

  <filter>
    <whitelist>
      <directory suffix=".php">src/</directory>
    </whitelist>
  </filter>
</phpunit>

Если нужна какая-то ещё инициализация кроме автозагрузчика, то вместо vendor/autoload.php, который генерирует Composer, можно и нужно использовать app/common.php или подобный.

Вас никто не обязывает держать тесты в каком-то одном месте, хоть это и фактический стандарт для PHP библиотек. Вы вполне можете включить вообще все исходные файлы вашего проекта в область поиска тестов, например, добавив ещё один <testsuite>, или задав дополнительные каталоги для поиска тестов добавив ещё один <directory> в существующий <testsuite>.

Подход с включением всего подряд в область поиска тестов ведет к некоторым неудобствами в том, что касается оценки покрытия кода тестами, но не будем пока на этом останавливаться — нам важно начать хоть что-то.

Проверить корректность конфигурации можно следующей командой:

xmllint --noout --schema vendor/phpunit/phpunit/phpunit.xsd phpunit.xml.dist

Должно вывести что-то вроде phpunit.xml.dist validates.

Что мы получили

С указанным выше минимальным phpunit.xml.dist будут просмотрены все файлы, заканчивающиеся на *Test.php, и использованы все найденные в этих файлах классы, названия которых заканчиваются на *Test.

Примерно оценить список классов, которые будут задействованы, можно так:

find tests/ -type f -name *Test.php -print0 |
  xargs -0 grep --color 'class .*Test '

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

php vendor/bin/phpunit

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

Применение

Самый простой вид теста представляет собой...

  • Метод класса, начинающийся на test,
  • в классе, заканчивающемся на Test,
  • и наследующем от \PHPUnit\Framework\TestCase.

Понимаю, сложно и запутанно. Но посмотрим на пример такого класса:

<?php
use PHPUnit\Framework\TestCase;

class ExampleTest extends TestCase
{
    public function testExample()
    {
        $this->assertTrue(true);
    }
}

Как видите, ничего особенно сложного.

Достаточно будет определить этот класс в tests/ExampleTest.php и мы сможем испытать PHPUnit:

php vendor/bin/phpunit tests/ExampleTest.php

Эта команда сообщит нам об успешном прохождении всех тестов:


PHPUnit 9.x.x by Sebastian Bergmann.

Configuration read from phpunit.xml

.

Time: 63 ms, Memory: 6.50 MB

OK (1 test, 1 assertion)

Практический пример

Чтобы не ходить далеко, перепишем скрипт из начала этой заметки.

<?php
use PHPUnit\Framework\TestCase;

class ExampleClassTest extends TestCase
{
    public function testValueSquared()
    {
        $instance = new ExampleClass();
        $instance->setValue(42);
        $this->assertEquals(1764, $instance->getValueSquared());

        return $instance;
    }

    /**
     * @depends testValueSquared
     */
    public function testInvalidResult($instance)
    {
        $this->expectException(SpecificException::class);

        $instance->failingOperation();
    }
}

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

Испытаем:

$ php vendor/bin/phpunit tests/ExampleClassTest.php 
PHPUnit 9.x.x by Sebastian Bergmann and contributors.

..                                      2 / 2 (100%)

Time: 75 ms, Memory: 8.50 MB

OK (2 tests, 2 assertions)

Вот так могло бы выглядеть определение для тестируемого класса ExampleClass и исключения SpecificException:

<?php
final class ExampleClass
{
    private $value;

    public function setValue($value)
    {
        $this->value = $value;
    }

    public function getValueSquared()
    {
        return pow($this->value, 2);
    }

    public function failingOperation()
    {
        throw new SpecificException();
    }
}

final class SpecificException extends Exception
{
}

Оценка тестов

Предположим, написали вы десяток-другой тестов, запускаете их одной командой, всё прекрасно. Так ли прекрасно?.. Чем ещё может помочь нам PHPUnit?

PHPUnit может помочь нам с метриками, которые могут использоваться для первичной оценки качества тестов в части покрытия кода тестами. Покрытие рассчитывается как отношение числа строк, файлов, классов, методов, которые были задействованы в тестах, к число строк и т.д., которые никак не задействованы в тестах. Оценка покрытием не даёт гарантий качества тестов (с чем может помочь мутационное тестирование), но, по крайней, вы будете знать что для какого-то класса и какой-то функции у вас есть тест, и что для чего-то теста ещё нет.

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

Получить простой текстовый отчёт можно с ключем --coverage-text:

$ php vendor/bin/phpunit --coverage-text
PHPUnit 9.n.n by Sebastian Bergmann and contributors.

...                                                                 3 / 3 (100%)

Time: 91 ms, Memory: 6.00 MB

OK (3 tests, 3 assertions)


Code Coverage Report:   
  2020-02-01 10:00:00   

 Summary:               
  Classes: 100.00% (1/1)
  Methods: 100.00% (3/3)
  Lines:   100.00% (4/4)

ExampleClass
  Methods: 100.00% ( 3/ 3)   Lines: 100.00% (  4/  4)

В случае выше видно что у нас 100% покрытие всего кода тестами.

Понятно что из одной метрики покрытия меньшей 100%, например, 85%, сложно сделать вывод о том, какие именно части кода не покрыты тестами. В этом нам поможет отчёт о покрытии в формате HTML, получить и изучить который можно в две команды:

php vendor/bin/phpunit --coverage-html=coverage
php -S localhost:8000 -t coverage/

Открываем http://localhost:8000 и видим код проекта, размеченный согласно покрытия тестами.

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

Что дальше?

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

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

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