В предыдущих статьях я показал вам, как писать базовые тесты для базовых методов. Теперь вы можете пользоваться аннотацией @dataProvider
, создавать отчеты о покрытии, и знаете, как пользоваться отдельными утверждениями.
До этого мы писали тесты для простых методов. Вызов внутреннего метода в рамках одного и того же класса, вставка блока if, но ничего особо сложного.
Это все хорошо, но на практике вы редко будете встречать что-то такое же легкое. Обычно вы будете сталкиваться с методами, которые инстанцируют объекты других классов, вызывают методы в рамках одного и того же класса, используют статику или содержат зависимости от внешних объектов, внедренные через параметры.
Класс Payment
Сегодня я расскажу о более сложных понятиях из области тестирования на примере кода, который всем нам известен и который, возможно, мы уже когда-то использовали: API-обработчик платежей. Специально для http://www.authorize.net/, но это может быть любой API-обработчик.
Загрузите файлы authorize.net
Для начала обновите файл ./composer.json следующим образом:
{
"require": {
"ajbdev/authorizenet-php-api": "dev-master"
},
"require-dev": {
"phpunit/phpunit": "3.7.14"
},
"autoload": {
"psr-0": {
"phpUnitTutorial": ""
}
}
}
Мы просто добавили неофициальный репозиторий Authorize.net, чтобы загрузить все файлы.
Для установки выполните composer update
.
Класс Payment
Теперь создайте пустой файл по адресу ./phpUnitTutorial/Payment.php
и вставьте следующий код:
<?php
namespace phpUnitTutorial;
class Payment
{
const API_ID = 123456;
const TRANS_KEY = 'TRANSACTION KEY';
public function processPayment(array $paymentDetails)
{
$transaction = new \AuthorizeNetAIM(self::API_ID, self::TRANS_KEY);
$transaction->amount = $paymentDetails['amount'];
$transaction->card_num = $paymentDetails['card_num'];
$transaction->exp_date = $paymentDetails['exp_date'];
$response = $transaction->authorizeAndCapture();
if ($response->approved) {
return $this->savePayment($response->transaction_id);
} else {
throw new \Exception($response->error_message);
}
}
public function savePayment($transactionId)
{
// здесь указывается логика для сохранения идентификатора транзакции к базе данных или чему-либо еще
return true;
}
}
Этот код мог бы использоваться во множестве проектов во всем мире, выполняющих функции электронной торговли. Он простой, четкий и непригодный для тестирования. Скоро вы узнаете, почему.
Каркас теста
Создайте новый файл по адресу ./phpUnitTutorial/Test/PaymentTest.php
и создайте необходимый минимум:
<?php
namespace phpUnitTutorial\Test;
use phpUnitTutorial\Payment;
class PaymentTest extends \PHPUnit_Framework_TestCase
{
//
}
В результате выполнения комплекта тестов получаем одну ошибку:
1) Warning
No tests found in class "phpUnitTutorial\Test\PaymentTest".
Отлично, можем продолжать!
Первый тест
Перед написанием первого теста подумайте о том, что именно из представленного кода вы хотите протестировать.
Два наиболее очевидных результата:
$response->approved
равняетсяtrue
, что активирует вызов к::savePayment()
который возвращаетtrue
, и$response->approved
равняетсяfalse
, которое затем выдает\Exception()
.
Создайте первый пустой метод теста:
public function testProcessPaymentReturnsTrueOnSuccessfulPayment()
{
//
}
Мы знаем, что ::processPayment()
принимает массив, и по коду мы видим, что он использует ключиamount
, card_num
и exp_date
. Мы задаем эти ключи:
$paymentDetails = array(
'amount' => 123.99,
'card_num' => '4111-1111-1111-1111',
'exp_date' => '03/2013',
);
Итак, мы фактически воссоздали то, как выглядел бы обычный платеж.
Теперь, когда у нас есть необходимый параметр и его заданные ключи, инстанцируйте объект, вставьте массив и задайте ожидаемый результат – возврат значения true
:
<?php
namespace phpUnitTutorial\Test;
use phpUnitTutorial\Payment;
class PaymentTest extends \PHPUnit_Framework_TestCase
{
public function testProcessPaymentReturnsTrueOnSuccessfulPayment()
{
$paymentDetails = array(
'amount' => 123.99,
'card_num' => '4111-1111-1111-1111',
'exp_date' => '03/2013',
);
$payment = new Payment();
$result = $payment->processPayment($paymentDetails);
$this->assertTrue($result);
}
}
Взрыв!
Наш комплект тестов взлетел на воздух. Что же случилось?
Вот как Authorize.net отреагировал на наш тест: «Логин или пароль платежного агента недействителен или аккаунт неактивен». Упс!
Может, нам нужно получить действующие логин и пароль для Authorize.net и включить их в наш тест? Это наверняка решит проблему, но возникает еще одна:
Если вы зайдете в класс \AuthorizeNetAIM
, то увидите, что сложность стремительно возрастает – методы вызывают другие методы, которые, в свою очередь, вызывают еще больше методов. Там даже попадается вызов cURL, при помощи которого обычно происходит взаимодействие с серверами Authorize.net.
Что произойдет, если во время написания/выполнения тестов серверы Authorize.net будут недоступны?
Можем ли мы допустить, чтобы наши тесты окончились неудачей и выдали красную строку, если Authorize.net будет недоступен? Или если у нас будут проблемы с доступом в Интернет?
Почему мы обеспокоены тем, что произойдет в этом внешнем классе? Мы не хотим зависеть от внешнего источника, которым мы не можем управлять. Должен же быть другой выход…
Введение в имитацию объектов (Mock)
PHPUnit имеет очень эффективную функцию, которая помогает работать с внешними зависимостями. Эта функция позволяет заменять реальный объект на «пустышку», имитацию – то есть объект, которым мы можем полностью управлять, убрав все зависимости от внешних систем или код, который нам не нужно тестировать.
В классе \AuthorizeNetAIM
мы знаем, что метод ::authorizeAndCapture()
связан с серьезными проблемами для нашего тестируемого кода, то есть он делает запросы на внешний сервер, которым мы не можем управлять, да и управлять которым нам не нужно.
Но здесь есть небольшой вопрос: как нам вставить имитирующий объект в тестируемый нами код? Код, который инстанцирует объект Authorize.net, довольно конкретный и не предоставляет никакой свободы для различных интерпретаций, так ведь?
$transaction = new \AuthorizeNetAIM(self::API_ID, self::TRANS_KEY);
Зависимость от внедрения зависимости
Есть такое понятие, как внедрение зависимости. Довольно устрашающее название для такого простого понятия.
Вместо того, чтобы использовать в своих методах ключевое слово new
, задайте объект в параметрах.
Таким образом, это:
public function processPayment(array $paymentDetails)
{
$transaction = new \AuthorizeNetAIM(self::API_ID, self::TRANS_KEY);
$transaction->amount = $paymentDetails['amount'];
$transaction->card_num = $paymentDetails['card_num'];
$transaction->exp_date = $paymentDetails['exp_date'];
$response = $transaction->authorizeAndCapture();
if ($response->approved) {
return $this->savePayment($response->transaction_id);
}
throw new \Exception($response->error_message);
}
превращается в это:
public function processPayment(\AuthorizeNetAIM $transaction, array $paymentDetails)
{
$transaction->amount = $paymentDetails['amount'];
$transaction->card_num = $paymentDetails['card_num'];
$transaction->exp_date = $paymentDetails['exp_date'];
$response = $transaction->authorizeAndCapture();
if ($response->approved) {
return $this->savePayment($response->transaction_id);
}
throw new \Exception($response->error_message);
}
Вы перемещаете ответственность за создание объекта из класса Payment
в класс, который вызывает его. Если вы хотите почитать о внедрении зависимости, по этой ссылке находится статья, в которой все очень подробно объясняется.
Несмотря на то, что прием простой, он имеет множество преимуществ.
Но почему внедрение зависимости?
Мы хотим заменить зависимость в своем коде на имитирующий объект. Но как именно это сделать, если в вашем коде создаваемый объект определен четко и однозначно?
$transaction = new \AuthorizeNetAIM(self::API_ID, self::TRANS_KEY);
Короткий ответ: никак.
Более подробный ответ: это можно сделать, но такое «решение» будет ужасным и его следует всеми силами избегать: runkit.
Runkit позволяет заменить код во время выполнения, что на первый взгляд кажется именно тем, что нам нужно, так? Заменить реальный объект на объект-пустышку?
Сам процесс называется обезьяний патч, и здесь подробно рассказано о том, почему его следует избегать.
Итак, мы опять наткнулись на ответ «никак».
Еще один способ заменить зависимость – создать метод с предварительно инстанцированным объектом в параметрах.
Есть еще и третий способ: контейнер служб. В этой статье я не буду рассказывать о контейнерах, но коснусь того, как они улучшают качество кода и затем — тестирования. О том, что из себя представляет контейнер служб, подробно рассказано здесь!
Вместо продемонстированного выше инстанцирования объекта, который невозможно заменить, задание зависимости с public function processPayment(\AuthorizeNetAIM $transaction, array $paymentDetails)
означает, что теперь вы можете задать объект, который пройдет проверку is_a()
. Какие именно требования предъявляет is_a()
?
is_a
— проверяет, принадлежит ли объект к данному классу или является ли данный класс для него родительским.
Любой класс, который расширяет \AuthorizeNetAIM
, пройдет проверку is_a()
. Пока все довольно просто. Итак, как бы мы задали объект, который проходит эту проверку? Он должен соответствовать следующим требованиям:
- Иметь все методы, ожидаемые вашим кодом, и
- Любые методы, которые являются проблематичными для вашего кода (например,
authorizeAndCapture()
), должны быть изменены таким образом, чтобы они стали безопасными для ваших тестов.
Итак, требуется лишь расширить класс \AuthorizeNetAIM
, так? Просто создать новый класс – например, \AuthorizeNetAIMFake
, который перепишет все методы и вернет какое-то ожидаемое значение, исключив любые сюрпризы.
Это неплохая идея, и для небольших кодовых баз она бы отлично подошла… но если вам нужно заменить 5 классов? 10? 50? Может получиться так, что вам нужно будет заменить и несколько сотен классов. Вы готовы создать и поддерживать несколько сотен файлов, которые лишь расширяющие другой класс и переопределяют все его методы? Должен же быть более просто способ!
Помощник для работы с имитирующими объектами от PHPUnit
С учетом изменений, внесенных в наш код, наш тест должен выглядеть примерно так:
<?php
namespace phpUnitTutorial\Test;
use phpUnitTutorial\Payment;
class PaymentTest extends \PHPUnit_Framework_TestCase
{
public function testProcessPaymentReturnsTrueOnSuccessfulPayment()
{
$paymentDetails = array(
'amount' => 123.99,
'card_num' => '4111-1111-1111-1111',
'exp_date' => '03/2013',
);
$payment = new Payment();
$authorizeNet = new \AuthorizeNetAIM($payment::API_ID, $payment::TRANS_KEY);
$result = $payment->processPayment($authorizeNet, $paymentDetails);
$this->assertTrue($result);
}
}
Проблема с этим кодом заключается в том, что мы по-прежнему зависим от класса \AuthorizeNetAIM и всего кода в рамках его методов. По приведенным выше причинам мы не будем создавать пустой файл класса. Что же делать?
На помощь приходит PHPUnit!
Одним из наиболее эффективных и доступных вам инструментов является метод getMock()
. Он позволяет создавать новый класс, который отвечает двум вышеназванным требованиям. Вам не нужно создавать отдельные файлы для каждого класса, не нужно беспокоиться о поддержании стабильно расширяющейся файловой структуры.
Чтобы воспользоваться им, нужно просто вызвать его и задать несколько параметров, большинство из которых – опциональные.
$authorizeNet = $this->getMock('\AuthorizeNetAIM', array(), array($payment::API_ID, $payment::TRANS_KEY));
Стоп. Что это за второй параметр?
Взглянув на этот код, видно, что первый параметр это имя класса, а третий – массив, содержащий параметры конструктора. Но что такое array()
?
Оказывается, getMock()
отличается громоздкостью:
public function getMock($originalClassName, $methods = array(), array $arguments = array(), $mockClassName = '', $callOriginalConstructor = TRUE, $callOriginalClone = TRUE, $callAutoload = TRUE, $cloneArguments = TRUE)
Выглядит ужасно. 8 параметров, большинство из которых — опциональные, для единственного метода. Вы действительно хотите, чтобы всегда, когда вы пишите тесты, было открыто окно? Конечно нет. Вы просто бросите писать тесты, потому что это никуда не годится.
getMockBuilder()
Пару версий назад PHPUnit представил очень удобного помощника — getMockBuilder()
. Он представляет из себя немного больше, чем обертку (оболочку) вокруг метода getMock()
. Он предлагает более удобочитаемый формат цепочечных методов, что упрощает создание имитирующих объектов.
Вот наш $authorizeNet с getMockBuilder()
:
$authorizeNet = $this->getMockBuilder('\AuthorizeNetAIM')
->setConstructorArgs(array($payment::API_ID, $payment::TRANS_KEY))
->getMock();
Благодаря именам методов вы сразу видите, для чего они предназначены и можете полностью пропустить опциональные методы.
По сути, единственные требования это getMockBuilder()
и getMock()
.
Изучение имитирующего объекта
getMockBuilder()
возвращает имитирующий объект, который является объектом, чьё поведение похоже на поведение оригинального объекта.
Если дампить имитирующий объект, то можно увидеть, что он очень похож на оригинал:
$authorizeNet = $this->getMockBuilder('\AuthorizeNetAIM')
->setConstructorArgs(array($payment::API_ID, $payment::TRANS_KEY))
->getMock();
var_dump($authorizeNet);
что выводится следующим образом:
class Mock_AuthorizeNetAIM_084f7b20#17 (12) {
private $__phpunit_invocationMocker => NULL
protected $_x_post_fields => array(5) {
'version' => string(3) "3.1"
'delim_char' => string(1) ","
'delim_data' => string(4) "TRUE"
'relay_response' => string(5) "FALSE"
'encap_char' => string(1) "|"
}
private $_additional_line_items => array(0) {}
protected $_custom_fields => array(0) {}
public $verify_x_fields => bool(true)
private $_all_aim_fields => array(61) {
[0] => string(7) "address"
[1] => string(18) "allow_partial_auth"
[2] => string(6) "amount"
[3] => string(9) "auth_code"
[4] => string(24) "authentication_indicator"
[5] => string(13) "bank_aba_code"
[6] => string(14) "bank_acct_name"
[7] => string(13) "bank_acct_num"
[8] => string(14) "bank_acct_type"
[9] => string(17) "bank_check_number"
[10] => string(9) "bank_name"
[11] => string(9) "card_code"
[12] => string(8) "card_num"
[13] => string(31) "cardholder_authentication_value"
[14] => string(4) "city"
[15] => string(7) "company"
[16] => string(7) "country"
[17] => string(7) "cust_id"
[18] => string(11) "customer_ip"
[19] => string(10) "delim_char"
[20] => string(10) "delim_data"
[21] => string(11) "description"
[22] => string(16) "duplicate_window"
[23] => string(4) "duty"
[24] => string(11) "echeck_type"
[25] => string(5) "email"
[26] => string(14) "email_customer"
[27] => string(10) "encap_char"
[28] => string(8) "exp_date"
[29] => string(3) "fax"
[30] => string(10) "first_name"
[31] => string(20) "footer_email_receipt"
[32] => string(7) "freight"
[33] => string(20) "header_email_receipt"
[34] => string(11) "invoice_num"
[35] => string(9) "last_name"
[36] => string(9) "line_item"
[37] => string(5) "login"
[38] => string(6) "method"
[39] => string(5) "phone"
[40] => string(6) "po_num"
[41] => string(17) "recurring_billing"
[42] => string(14) "relay_response"
[43] => string(15) "ship_to_address"
[44] => string(12) "ship_to_city"
[45] => string(15) "ship_to_company"
[46] => string(15) "ship_to_country"
[47] => string(18) "ship_to_first_name"
[48] => string(17) "ship_to_last_name"
[49] => string(13) "ship_to_state"
[50] => string(11) "ship_to_zip"
[51] => string(15) "split_tender_id"
[52] => string(5) "state"
[53] => string(3) "tax"
[54] => string(10) "tax_exempt"
[55] => string(12) "test_request"
[56] => string(8) "tran_key"
[57] => string(8) "trans_id"
[58] => string(4) "type"
[59] => string(7) "version"
[60] => string(3) "zip"
}
protected $_api_login => int(123456)
protected $_transaction_key => string(15) "TRANSACTION KEY"
protected $_post_string => NULL
public $VERIFY_PEER => bool(true)
protected $_sandbox => bool(true)
protected $_log_file => bool(false)
}
Он также соответствует методам оригинального объекта,print_r(get_class_methods($authorizeNet));
, что выводится следующим образом:
[0] => __clone
[1] => authorizeAndCapture
[2] => priorAuthCapture
[3] => authorizeOnly
[4] => void
[5] => captureOnly
[6] => credit
[7] => __set
[8] => setFields
[9] => setCustomFields
[10] => addLineItem
[11] => setECheck
[12] => setField
[13] => setCustomField
[14] => unsetField
[15] => setSandbox
[16] => setLogFile
[17] => getPostString
[18] => expects
[19] => staticExpects
[20] => __phpunit_getInvocationMocker
[21] => __phpunit_getStaticInvocationMocker
[22] => __phpunit_hasMatchers
[23] => __phpunit_verify
[24] => __phpunit_cleanup
[25] => __construct
Имитирующий объект, созданный при помощи getMockBuilder()
, это метод, который действительно работает… но с одним исключением!
Попробуйте дампировать результат любого вызова метода:
var_dump($authorizeNet->authorizeAndCapture());
Результат, который вы получите, будет NULL.
С другими методами результат также всегда будет NULL
. Все методы вашего имитирующего объекта возвращают NULL
.
Такие методы называются заглушками!
Методы-заглушки
Метод-заглушка это метод, который симулирует оригинальный метод двумя способами – принимает такое же имя и такие же параметры. Но метод-заглушка имеет одну особенность – весь код внутри этого метода удален.
Вот оригинальный метод из класса \AuthorizeNetAIM
:
public function authorizeAndCapture($amount = false, $card_num = false, $exp_date = false)
{
($amount ? $this->amount = $amount : null);
($card_num ? $this->card_num = $card_num : null);
($exp_date ? $this->exp_date = $exp_date : null);
$this->type = "AUTH_CAPTURE";
return $this->_sendRequest();
}
Метод-заглушка пока выглядит так:
public function authorizeAndCapture($amount = false, $card_num = false, $exp_date = false)
{
return null;
}
Все остальные методы в вашем имитирующем объекте также являются заглушками и возвращают значение NULL
.
Преимущество состоит в том, что метод authorizeAndCapture()
больше не отправляет запрос к серверам Authorize.net. Вместо этого он возвращает известное значение (NULL
) каждый раз, когда он вызывается.
Но вот что самое интересное: Теперь вы можете переопределять значение, возвращаемое методом-заглушкой из вашего теста.
Это значит, что вы можете задать значение, возвращаемое методом-заглушкой в вашем тесте, и при выполнении теста ваш код будет думать, что возвращаемое значение — нормальное, и будет действовать в соответствии с вашими желаниями.
Возвращаемое значение может быть любым — null
, строкой, массивом, целыми числами, другими объектами и даже другими имитирующими объектами.
В следующей главе мы рассмотрим этот вопрос более подробно.
Теперь взгляните на код вашего теста:
<?php
namespace phpUnitTutorial\Test;
use phpUnitTutorial\Payment;
class PaymentTest extends \PHPUnit_Framework_TestCase
{
public function testProcessPaymentReturnsTrueOnSuccessfulPayment()
{
$paymentDetails = array(
'amount' => 123.99,
'card_num' => '4111-1111-1111-1111',
'exp_date' => '03/2013',
);
$payment = new Payment();
$authorizeNet = $this->getMockBuilder('\AuthorizeNetAIM')
->setConstructorArgs(array($payment::API_ID, $payment::TRANS_KEY))
->getMock();
$result = $payment->processPayment($authorizeNet, $paymentDetails);
$this->assertTrue($result);
}
}
Если вы выполните тест сейчас, вы получите:
There was 1 error:
1) phpUnitTutorial\Test\PaymentTest::testProcessPaymentReturnsTrueOnSuccessfulPayment
Trying to get property of non-object
/webroot/phpUnitTutorial/phpUnitTutorial/Payment.php:18
/webroot/phpUnitTutorial/phpUnitTutorial/Test/PaymentTest.php:23
FAILURES!
Tests: 11, Assertions: 10, Errors: 1.
Payment.php:18
соответствует if ($response->approved) {
. $response
был инстанцирован при помощи $response = $transaction->authorizeAndCapture();
. Теперь, прочитав эту статью, вы знаете, что это произошло потому, что все методы-заглушки возвращают значение NULL
, если его не переопределить.
Дело в том, что $response
равняется NULL
, но потом мы попытались вызвать approved
из объекта, который не существует. Отсюда и ошибка..
Мы знаем, что нам нужно переопределить возвращаемое значение authorizeAndCapture()
, и, к счастью, это довольно просто.
Переопределение возвращаемых значений методов-заглушек
Чтобы переопределить возвращаемое значение метода-заглушки, вам нужно познакомиться с пятью новыми методами PHPUnit:
$authorizeNet->expects($this->once())
->method('authorizeAndCapture')
->will($this->returnValue('RETURN VALUE HERE!'));
Следите за моими рассуждениями.
Мы утверждаем, что объект $authorizeNet
вызовет метод authorizeAndCapture()
один раз и вернет значение RETURN VALUE HERE!
.
Сначала вы вызываете expects()
, который принимает единственный параметр: число раз, которое, как мы ожидаем, метод будет вызван в нашем коде. Для метода чисел существует множество вариантов, включая once()
, any()
, never()
и некоторые другие. Их имена говорят сами за себя.
Если мы будем утверждать, что метод будет вызван один раз, а в результате – он не будет вызван ни одного раза или вызван более одного раза, наш тест окончится неудачей.
Если мы будем утверждать, что он не будет вызван ни разу, но в результате он все же будет вызван, наш тест также завершится неудачей.
any()
это обманка, которая означает: «Мне не важно, будет ли он вызван, а если и будет, то вот ожидаемое значение».method()
принимает имя метода, который необходимо переопределить. В нашем случае он будет соответствовать вызову $response = $transaction->authorizeAndCapture(); в нашем коде.will()
охватывает (оборачивает) важноеreturnValue()
, и вы определяете, какое значение будет возращено. В этом случае этоRETURN VALUE HERE
!.
Теперь в результате выполнения теста снова будет неудача, потому что authorizeAndCapture()
возвращает строку, когда наш код ожидает объект с ключом approved
и transaction_id
. Для таких типов объектов проще использовать \stdClass()
:
$response = new \stdClass();
$response->approved = true;
$response->transaction_id = 123;
Теперь вы можете задать это в методе returnValue()
. Вот наш завершенный тест:
<?php
namespace phpUnitTutorial\Test;
use phpUnitTutorial\Payment;
class PaymentTest extends \PHPUnit_Framework_TestCase
{
public function testProcessPaymentReturnsTrueOnSuccessfulPayment()
{
$paymentDetails = array(
'amount' => 123.99,
'card_num' => '4111-1111-1111-1111',
'exp_date' => '03/2013',
);
$payment = new Payment();
$response = new \stdClass();
$response->approved = true;
$response->transaction_id = 123;
$authorizeNet = $this->getMockBuilder('\AuthorizeNetAIM')
->setConstructorArgs(array($payment::API_ID, $payment::TRANS_KEY))
->getMock();
$authorizeNet->expects($this->once())
->method('authorizeAndCapture')
->will($this->returnValue($response));
$result = $payment->processPayment($authorizeNet, $paymentDetails);
$this->assertTrue($result);
}
}
Ура, в результате получаем: OK (11 tests, 12 assertions)
.
Заключение
Впереди еще много работы. Смотря на наш код, мы знаем, что нам нужно рассмотреть сценарий, когда $response->approved
равняется false
, и затем разобраться в том,что делать с строкой throw new \Exception($response->error_message);
.
Но пока вы познакомились с такими понятиями, как имитирующие объекты, методы-заглушки, а также узнали, почему внедрение зависимости считается очень полезным инструментом для тестирования. Затем вы познакомитесь с имитирующими методами (которые похожи на имитирующие объекты и методы-заглушки, но слегка отличаются от них), перехватом исключений и написанием тестов для более сложного кода.