MyTetra Share
Делитесь знаниями!
О, смотри-ка какое хорошее место. Дайте два!
Юнит-тестирование в PHP
19.04.2017
17:42
Автор: Вадим Массалов (WanderingStar)
Текстовые метки: PHP, PHPUnit, тестирование, юнит-тестирование
Раздел: Компьютер - Программирование - Язык PHP - PHPUnit

Язык PHP очень легок для изучения. Это, а так же обилие литературы «Освой _что_угодно_ за 24 часа» породило большое количество, мягко говоря, некачественного кода. Как следствие, рано или поздно любой программист, который выходит за рамки создания гостевой книги или сайта-визитки сталкивается с вопросом: «а если я здесь немножко добавлю, все остальное не ляжет?» Дать ответ на этот вопрос и на многие другие может юнит-тестирование.

В самом начале хочется оговориться — здесь речь не будет идти о TDD и методологиях разработки ПО. В данной статье я попробую показать начинающему PHP-разработчику основы использования модульного тестирования на базе фреймворка PHPUnit

Вместо предисловия


Вначале возникает вполне резонный вопрос: А зачем, если все и так работает?
Здесь все просто. После очередного изменения что-то перестанет работать так как надо — это я вам обещаю. И тогда поиск ошибки может отнять очень много времени. Модульное тестирование может свести этот процесс к считанным минутам. Не будем так же исключать переезд на другую платформу и связанные с ним «подводные камни». Чего только стоит разность в точности вычислений на 64- и 32-разрядных системах. А когда-то вы в первый раз столкнетесь с нюансами вроде

<?php
print (int)((0.1 + 0.7) * 10);
// думаете 8? проверьте ...
// или лучше так: "Вы еще не используете BC Math? Тогда мы идем к вам!"



Если вопрос необходимости отпал, то можно приступить к выбору инструмента. Здесь ассортимент в общем-то невелик — PHPUnit (http://www.phpunit.de/) и SimpleTest (http://www.simpletest.org/). Поскольку PHPUnit уже стал стандартом де-факто в тестировании PHP приложений, то на нем и остановимся.

Первое знакомство


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

Допустим, есть у нас некий класс «MyClass», одним из методов реализующий возведение числа в степень. (Здесь вынужден извиниться, но все примеры, в общем-то, высосаны из пальца)

MyClass.php

<?php
class MyClass {

   
public function power($x, $y)
   
{
       
return pow($x, $y);
   
}
}



Хочется проверить, правильно ли он работает. О всем классе речь пока не идет — только об одном методе. Напишем для проверки такой тест.

MyClassTest.php

<?php
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-методов для выяснения соответствует ли результат обработки ожидаемому (подробнее чуть ниже);


Теперь глядя на тест мы можем понять, что тестируя метод возведения в степень мы создаем экземпляр класса, вызываем нужный нам метод с заранее определенными значениями и проверяем правильно ли были проведены вычисления. Для этой проверки был использован метод assertEquals(), который первым обязательным параметром принимает ожидаемое значение, вторым актуальное и проверяет их соответствие. Размяв мозги и освежив в памяти знания таблицы умножения, мы предположили, что 23=8. На этих данных мы и проверим, как работает наш метод.
Запускаем тест:

$ phpunit MyClassTest
.
Time: 0 seconds
OK (1 test, 1 assertion)



Результат выполнения теста «ОК». Казалось бы можно остановиться на этом, но иногда было бы неплохо для проверки скормить методу не один набор данных. PHPUnit предоставляет нам такую возможность — это провайдеры данных. Провайдер данных тоже является паблик-методом (название не существенно), который возвращает массив наборов данных для каждой итеррации. Для использования провайдера необходимо указать его в теге @dataProvider к тесту.

Изменим наш тест следующим образом:

MyClassTest.php

<?php
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 <integer:8> matches expected value <integer:9>.
/home/user/unit/MyClassTest.php:14
FAILURES!
Tests: 3, Assertions: 3, Failures: 1.



Опишу подробнее. Точка, которую в первом выводе теста многие могли принять за опечатку на самом деле таковой не является — это успешно пройденный тест. F(ailure) — соответственно тест не пройденный. Значит в данном случае, было проведено 3 теста, один из который завершился неудачно. В расширенном описании нам было сказано, какой именно, с каким набором исходных данных, с каким реальным и каким ожидаемым результатом. Если бы 23 действительно равнялось 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()

Исключения


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

MyClass.php

<?php
class MathException extends Exception {};

class MyClass {

   
// ...

   
public function divide($x, $y)
   
{
       
if (!(boolean)$y)
       
{
            throw
new MathException('Division by zero');
       
}
       
return $x / $y;
   
}
}



Теперь надо создать тест, который будет завершаться успешно в том случае, если при определенном наборе данных будет вызвано это исключение. Задать требуемое исключение можно как минимум двумя способами — добавив к тесту @expectedException либо вызвав в тесте метод setExpectedException().

MyClassTest.php

<?php
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 (), к примеру, так:

MyClassTest.php

<?php
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(), в котором мы можем провести «сборку мусора». Таким образом, исправленный тест может выглядеть так:

MyClassTest.php

<?php
require_once 'PHPUnit/Framework.php';
require_once 'MyClass.php';
   
class MyClassTest extends PHPUnit_Framework_TestCase {

    protected
$fixture;

    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() возможно добавление другого набора.

SpecificTests.php

<?php
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;
   
}
}



AllTests.php

<?php
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. Базы данных мы оставим на потом, а пока попробуем создать собственный набор тестов для уже имеющегося класса.

MyClassTest.php

<?php
require_once 'PHPUnit/Framework.php';
require_once 'MyClass.php';

class MyClassTest extends PHPUnit_Framework_TestCase {
   
    protected
$fixture;

    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)
       
);
   
}

   
// …

}



MySuite.php

<?php
require_once 'MyClassTest.php';

class MySuite extends PHPUnit_Framework_TestSuite {

    protected
$sharedFixture;

   
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 соответственно. Добавим в наш тестируемый класс еще один метод, и проверим правильно ли он работает.

MyClass.php

<?php
class MyClass {

   
// ...

   
public function square($x)
   
{
       
sleep(2);
       
print $x * $x;
   
}

}



MyClassTest.php

<?php
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
$fixture;

    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
$fixture;

    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() планируемое время отработки указывается в секундах. Для того, что бы эти тесты запускались вместе с уже написанными их всего лишь надо добавить к нашему набору:

MySuite.php

<?php
require_once 'MyClassTest.php';

class MySuite extends PHPUnit_Framework_TestSuite {

    protected
$sharedFixture;

   
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 и подтолкнуть к использованию юнит-тестов, как одному из средств для достижения большей эффективности — была достигнута.
Удачи вам, и стабильных приложений.


 
MyTetra Share v.0.52
Яндекс индекс цитирования