Выпуск 23. Январь 2015
← Взгляд на 2014 г. | Содержание | Tapper — система тестирования ПО полного цикла →Тестирование с помощью Mock-объектов
Рассмотрены основные задачи Mock-объектов и их разновидности в Perl
Продолжаем цикл статей про тестирование: Тестирование в Perl. Лучшие практики, Тестирование в Perl. Практика.
Mock-объекты обычно применяются в тестировании и представляют собой аналогичные по поведению и интерфейсу имитаторы настоящих объектов, позволяют просто подставлять возвращаемые данные, проверять, какие данные были переданы в методы и т.д. Mock-объекты удобны, когда реализация настоящего объекта крайне сложна, его не так просто инициализировать, он требует особого окружения и т.п. Также они применяются при начальной разработке, когда некоторые подсистемы еще не разработаны, но их интерфейс и поведение в целом понятны.
Рассмотрим пример тестирования модуля, который делает запрос на получение списка товаров через API некоего сервиса. Интерфейс модуля будет выглядеть примерно так:
package API;
use strict;
use warnings;
sub new {
my $class = shift;
my $self = {};
bless $self, $class;
return $self;
}
sub list_orders {
...
}
В качестве модуля HTTP-клиента воспользуемся HTTP::Tiny
, он с некоторых пор идет вместе с ядром Perl. Допустим, что мы ничего не знаем о тестировании и сразу приступили к реализации модуля.
package API;
use strict;
use warnings;
use JSON ();
use HTTP::Tiny;
sub new {
my $class = shift;
my $self = {};
bless $self, $class;
return $self;
}
sub list_orders {
my $self = shift;
my $response = HTTP::Tiny->new->get('http://api.example.com/orders/');
die "Failed!\n" unless $response->{success};
my $content = $reponse->{content};
return JSON::decode_json($content);
}
У этой реализации много проблем. Во-первых, ее невозможно протестировать без доступа к интернету и работающему API, во-вторых, нет проверок на возникновение различного рода ошибок.
Имитация поведения HTTP::Tiny
Если мы приступим к тестированию, то сразу заметим, что нужно как-то подставить результат GET-запроса, чтобы сэмулирвать различные сценарии. Здесь нам и пригодятся Mock-объекты. Имитация объекта HTTP::Tiny
должна как-то попасть внутрь метода list_orders
.
Есть как минимум два способа это сделать:
- инъекция зависимости;
- фабричный метод.
Инъекция зависимости
В этом случае объект HTTP::Tiny
передается в конструктор класса API. Таким образом мы можем передавать любой объект, который реализует метод get
и возвращает соответствующий HASH.
package API;
use strict;
use warnings;
sub new {
my $class = shift;
my (%params) = @_;
my $self = {};
bless $self, $class;
$self->{ua} = $params{ua};
return $self;
}
sub list_orders {
my $self = shift;
my $ua = $self->{ua};
...
}
Тест может выглядеть следующим образом:
use stict;
use warnings;
use Test::More;
subtest 'returns orders' => sub {
my $api = API->new(ua => TestUA->new)
my $orders = $api->list_orders;
is scalar @$orders, 1;
is $orders->[0]->{foo}, 'bar';
};
done_testing;
package TestUA;
sub new {
my $class = shift;
my (%params) = @_;
my $self = {};
bless $self, $class;
return $self;
}
sub get {
return {
success => 1,
content => '[{"foo":"bar"}]'
}
}
В этом тесте создается имитатор HTTP::Tiny
, объект класс TestUA
, который при вызове метода get
возвращает тестовый набор данных.
Вручную создавать Mock-объекты может быть довольно утомительно. На CPAN существует несколько модулей, который позволяют создавать Mock-объекты с различными свойствами. Здесь мы воспользуемся Test::MonkeyMock
. После применения этого модуля тест будет выглядеть следующим образом:
use stict;
use warnings;
use Test::More;
use Test::MonkeyMock;
subtest 'returns orders' => sub {
my $ua = Test::MonkeyMock->new;
$ua->mock(get => sub { {success => 1, content => '[{"foo":"bar"}]'}});
my $api = API->new(ua => $ua);
my $orders = $api->list_orders;
is scalar @$orders, 1;
is $orders->[0]->{foo}, 'bar';
};
done_testing;
С помощью метода mock
класса Test::MonkeyMock
можно эмулировать поведение метода get
оригинального класса HTTP::Tiny
.
Фабричный метод
В этом случае объект HTTP::Tiny
создается внутри API с помощью фабричного метода, который во время тестирования можно заменить своим методом, который будет возвращать Mock-объект.
package API;
use strict;
use warnings;
sub new {
my $class = shift;
my (%params) = @_;
my $self = {};
bless $self, $class;
return $self;
}
sub list_orders {
my $self = shift;
my $ua = $self->build_ua;
...
}
sub _build_ua { HTTP::Tiny->new }
Модуль Test::MonkeyMock
позволяет заменить методы в объектах нужными, тест будет выглядеть следующим образом:
use stict;
use warnings;
use Test::More;
use Test::MonkeyMock;
subtest 'returns orders' => sub {
my $ua = Test::MonkeyMock->new;
$ua->mock(get => sub { {success => 1, content => '[{"foo":"bar"}]'}});
my $api = API->new;
$api = Test::MonkeyMock->new($api);
$api->mock(_build_ua => sub { $ua });
my $orders = $api->list_orders;
is scalar @$orders, 1;
is $orders->[0]->{foo}, 'bar';
};
done_testing;
Какой вариант подстановки использовать, зависит от задачи. В нашем случае удобнее использовать передачу через конструктор. Поэтому дальше будет использоваться первый вариант. Однако, это не значит, что он лучше или хуже. Все зависит от конкретной реализации и возможностей.
Углубленное тестирование
Рассмотрим, какие случаи нужно протестировать:
- Модуль должен бросать исключение в случае неуспешного ответа от сервера.
- Модуль должен бросать исключение в случае неправильного JSON.
- Модуль должен преобразовывать ответ из JSON в Perl-структуру.
- Модуль должен правильно вызывать метод
get
у объектаHTTP::Tiny
.
Проверка поведения при неуспешном ответе сервера
Исключения удобно проверять с помощью Test::Fatal
:
use Test::Fatal;
subtest 'throws exception when answer is not successful' => sub {
my $ua = Test::MonkeyMock->new;
$ua->mock(get => sub { {success => 0}});
my $api = API->new(ua => $ua);
ok exception { $api->list_orders };
};
Просто проверить, что было исключение, обычно не достаточно. Нужно проверить и ошибку. Конечно, лучше всего бросить специальное исключение и проверять имя класса, а не сообщение.
use Test::Fatal;
subtest 'throws exception when answer is not successful' => sub {
my $ua = Test::MonkeyMock->new;
$ua->mock(get => sub { {success => 0}});
my $api = API->new(ua => $ua);
like exception { $api->list_orders }, qr/Failed!/;
};
Если проверяются тексты исключений, то лучше пользоваться like
, так как там обычно добавляется в конце информация об источнике исключения, которая может меняться при рефакторинге и ломать тесты.
Проверка поведения при неправильном JSON
subtest 'throws exception when answer is not valid JSON' => sub {
my $ua = Test::MonkeyMock->new;
$ua->mock(
get => sub {
{
success => 1,
content => 'invalid JSON'
};
}
);
my $api = API->new;
$api = Test::MonkeyMock->new($api);
$api->mock(_build_ua => sub { $ua });
like exception { $api->list_orders }, qr/JSON error!/;
};
В реальной жизни лучше обернуть парсинг JSON в свой собственный eval
и бросать исключение со своей ошибкой. Здесь для наглядности нам достаточно ошибки, которую бросает JSON.
Проверка, что JSON правильно преобразуется в Perl-структуру
Здесь все просто. Воспользуемся фунцией is_deeply
для проверки получаемой структуры.
subtest 'correctly parses JSON response' => sub {
my $ua = Test::MonkeyMock->new;
$ua->mock(
get => sub {
{
success => 1,
content => '[{"foo":"bar"}]'
};
}
);
my $api = API->new(ua => $ua);
my $orders = $api->list_orders;
is_deeply $orders, [{foo => 'bar'}];
};
Проверка, что метод get
вызывается с правильными параметрами
В этом тесте мы проверяем, что get
вызывается с правильным url
. Test::MonkeyMock
позволяет это делать с помощь метода mocked_call_args
. Например:
subtest 'calls get with correct arguments' => sub {
my $ua = Test::MonkeyMock->new;
$ua->mock(
get => sub {
{
success => 1,
content => '[{"foo":"bar"}]'
};
}
);
my $api = API->new(ua => $ua);
$api->list_orders;
my ($url) = $ua->mocked_call_args('get');
is $url, 'http://example.com/orders/';
};
На данном этапе полностью протестировано поведение модуля. Но есть еще что улучшить в самих тестах. Как видно, создание тестируемого объекта везде одинаково, и его можно выделить в отдельный фабричный метод. Например:
subtest 'returns orders' => sub {
my $ua = Test::MonkeyMock->new;
$ua->mock(
get => sub {
{
success => 1,
content => '[{"foo":"bar"}]'
};
}
);
my $api = _build_api(ua => $ua);
my $orders = $api->list_orders;
is scalar @$orders, 1;
is $orders->[0]->{foo}, 'bar';
};
sub _build_api {
my (%params) = @_;
my $ua = delete $params{ua};
return API->new(ua => $ua);
}
Также можно выделить и создание Mock-объекта HTTP::Tiny
, который по умолчанию будет возвращать успешный ответ, но при необходимости можно изменить поведение.
subtest 'returns orders' => sub {
my $ua = _mock_ua();
my $api = _build_api(ua => $ua);
my $orders = $api->list_orders;
is scalar @$orders, 1;
is $orders->[0]->{foo}, 'bar';
};
sub _mock_ua {
my (%params) = @_;
my $ua = Test::MonkeyMock->new;
$ua->mock(
get => sub {
{
success => 1,
content => '[{"foo":"bar"}]',
%params
};
}
);
return $ua;
}
sub _build_api {
my (%params) = @_;
my $ua = delete $params{ua};
return API->new(ua => $ua);
}
В итоге тест приобретает более чистый и понятный вид.
use stict;
use warnings;
use Test::More;
use Test::Fatal;
use Test::MonkeyMock;
subtest 'returns orders' => sub {
my $ua = _mock_ua();
my $api = _build_api(ua => $ua);
my $orders = $api->list_orders;
is scalar @$orders, 1;
is $orders->[0]->{foo}, 'bar';
};
subtest 'throws exception when answer is not successful' => sub {
my $ua = _mock_ua(success => 0);
my $api = _build_api(ua => $ua);
ok exception { $api->list_orders };
};
subtest 'throws exception when answer is not valid JSON' => sub {
my $ua = _mock_ua(content => 'invalid JSON');
my $api = _build_api(ua => $ua);
like exception { $api->list_orders }, qr/JSON error!/;
};
subtest 'correctly parses JSON response' => sub {
my $ua = _mock_ua();
my $api = _build_api(ua => $ua);
my $orders = $api->list_orders;
is_deeply $orders, [{foo => 'bar'}];
};
subtest 'calls get with correct arguments' => sub {
my $ua = _mock_ua();
my $api = _build_api(ua => $ua);
$api->list_orders;
my ($url) = $ua->mocked_call_args('get');
is $url, 'http://example.com/orders/';
};
sub _mock_ua {
my (%params) = @_;
my $ua = Test::MonkeyMock->new;
$ua->mock(
get => sub {
{
success => 1,
content => '[{"foo":"bar"}]',
%params
};
}
);
return $ua;
}
sub _build_api {
my (%params) = @_;
my $ua = delete $params{ua};
return API->new(ua => $ua);
}
Заключение
Таким образом осуществляется тестирование с помощью Mock-объектов. Не стоит однако сильно увлекаться Mock-объектами, когда они совершенно ни к чему. Опасность заключается в том, что при изменении интерфейса или поведения оригинального модуля тесты будут и дальше проходить, и вы будете уверены, что ничего не нужно исправлять. Такие ошибки должны проявиться в интеграционном тестировании, когда тестируется вся система в сборе как черный ящик. Об этом читайте в следующих выпусках.
← Взгляд на 2014 г. | Содержание | Tapper — система тестирования ПО полного цикла →