Выпуск 15. Май 2014
← Простые способы сделать консольную утилиту удобнее | Содержание | Тестирование интерфейса веб-приложений. Применение WWW::WebKit →Модульное тестирование под AnyEvent
Рассмотрены некоторые простые соображения, касающиеся тестирования модулей Perl под управлением AnyEvent. Также рассмотрен вспомогательный модуль AE::AdHoc, берущий на себя часть рутинных задач, возникающих при тестировании.
Общие соображения
Здесь и далее мы будем предполагать наличие модуля My::Module
с методами new
и do_something
без аргументов, а также блока ok_result
, который в действительности может быть как отдельной функцией, так и «портянкой» из ok
, like
и т.д.
Мы также предполагаем для простоты, что конструктор можно вызвать без параметров и он никак не взаимодействует с окружением.
Лирическое отступление 1. Вообще в написании тестов очень помогает возможность порождать объекты с минимальными усилиями, а не путём тщательной фальсификации внешнего мира. Если инициализация реального объекта этого требует, можно сделать отдельный метод startup()
или вроде того.
Вот как выглядит простейший тест (предположим, что use_ok
было в соседнем тесте).
use strict;
use warnings;
use Test::More;
use My::Module;
my $object = My::Module->new;
my $result = $object->do_something;
ok_result( $result );
done_testing;
Теперь добавим немного асинхронности. Вместо метода do_something
у нас будет do_something_async
, который ничего не возвращает, но имеет одним из аргументов (обычно либо последним, либо это элемент хеша с именем callback
, on_done
и т.п.) коллбэк, то есть функцию, в которую будет передан результат.
Я начинал примерно следующим образом:
use strict;
use warnings;
use Test::More;
use AnyEvent;
use My::Module;
my $object = My::Module->new;
my $cv = AnyEvent->condvar; # or AE::condvar if one prefers
$object->do_something_async(
sub {
ok_result(shift);
$cv->send();
}
);
$cv->recv;
done_testing;
Этот код рабочий, но он неправильный (так бывает).
Во-первых, если по какой-то причине коллбэк не вызовется никогда, то скрипт попросту зависнет. За это админы вашей системы деплоймента скажут вам отдельное спасибо. Так что хорошо бы к скрипту добавить таймер.
Во-вторых, ok_result
на самом деле может выбросить исключение. В самом тесте это не страшно — будет проваленный тест и, следовательно, сигнал, что что-то пошло не так. В коллбэке это приведет все к тому же зависанию скрипта.
Лирическое отступление 2. Разумеется, можно написать тест так, чтобы он не умирал никогда. Однако написание неумирающего кода — отдельное и в данном случае лишнее умственное упражнение. Тестовый код по возможности должен быть в разы проще тестируемого, иначе это не упрощение разработки, а просто двойная работа.
Ну и напоследок чисто стилевое замечание. ok_result
, напоминаю, не обязательно состоит из одной строчки. При дальнейшем редактировании теста может получиться лапша, в которой непонятно, где тестирующий код, а где тестируемый.
Да, а еще неплохо добавить use AnyEvent::Strict;
— тоже страховка на случай неудачного редактирования My::Module
.
При учете этих соображений получается примерно следующий код.
use strict;
use warnings;
use Test::More;
use AnyEvent::Strict;
use My::Module;
my $object = My::Module->new;
my $cv = AnyEvent->condvar;
my $timer = AnyEvent->timer(
after => 10,
cb => sub {
$cv->croak("Timeout exceeded");
}
);
$object->do_something_async(
sub {
$cv->send(shift);
}
);
my $result = $cv->recv;
undef $timer;
ok_result( $result );
done_testing;
Тут бы и остановиться, но…
Модуль Ae::AdHoc
…написание однотипной обвязки очень быстро надоедает. Поэтому был создан заменяющий ее модуль. Стало немного короче, но, главное, теперь и ошибиться-то толком негде.
use strict;
use warnings;
use Test::More;
use AE::AdHoc;
use My::Module;
my $object = My::Module->new;
my $result = ae_recv {
$object->do_something_async(ae_send);
} timeout => 10;
ok_result( $result );
done_testing;
Оговорюсь сразу, что название не совсем удачное и, доведись мне писать его сейчас, я бы и переименовал, и внутри тоже по-другому сделал (возможно, через Future или Promise — тогда я про них попросту не знал).
Обратите внимание: ae_send
возвращает коллбэк, останавливающий event loop. Чтобы вернуться сразу, нужно написать ae_send->()
. Аналогично действует ae_croak
— только на этот раз ae_recv
выбросит исключение.
Не буду переводить man-страницу собственного модуля целиком, остановлюсь на интересных, как мне кажется, моментах.
Таймаут
Обязательный аргумент — timeout
или soft_timeout
. Он не может быть равен нулю во избежание разночтений. Если таймаут все-таки не нужен, следует использовать отрицательное значение (“Yes, do as I say”).
soft_timeout
означает, что в случае его достижения будет просто возвращено undef
, без die
.
Изоляция вызовов
Все генераторы коллбэков работают только внутри ae_recv
, иначе умирают. Более того, коллбэк запоминает «свой» событийный цикл и отказывается работать в последующих, генерируя только warning. Умирать под управлением AnyEvent нежелательно, поэтому так.
При желании все сообщения об ошибках такого рода можно посмотреть в переменной @AE::AdHoc::errors
.
Например,
use strict;
use warnings;
use Test::More;
use AE::AdHoc;
use My::Module;
my $object = My::Module->new;
my $result = ae_recv {
$object->do_something_async( ae_send ); # takes 5 sec
} soft_timeout => 3;
is ($result, undef, "Not finished yet");
$result = ae_recv {
# just wait
} soft_timeout => 3;
is ($result, undef, "Not finished either");
is (scalar @AE::AdHoc::errors, 1, "1 runaway callback detected");
note join "\n", @AE::AdHoc::errors;
done_testing;
Здесь результат do_something
не вернется никогда. Хотя do_something
, конечно, доделается.
Множественные задания
Во время обсуждения будущего модуля в рассылке moscow.pm меня попросили добавить примитивы для begin/end
, что и было сделано. Однако на практике с begin/end
слишком легко ошибиться на один.
Поэтому была реализована дополнительная функция ae_goal("identifier")
. Каждый такой вызов создает «задание» и возвращает коллбэк, который в свою очередь помещает в специальный хеш ссылку на массив аргументов (@_
). Когда/если все задания выполнены, производится вызов ae_send
.
Результат можно посмотреть в функции AE::AdHoc->results;
Вот это уж точно следовало бы делать через future/promise. См. также модуль Async::MergePoint.
Вот такой вот скрипт, например, опрашивает параллельно насколько адресов вида host:port
на предмет открытости порта. Обратите внимание на то, что здесь полностью отсутствует упоминание Test::More
и вообще каких-либо тестов.
use warnings;
use strict;
use AE::AdHoc;
use AnyEvent::Socket;
use Data::Dumper;
my ( $timeout, @probe ) = @ARGV;
$timeout or die "Usage: $0 <timeout> <host:port> ...\n";
ae_recv {
foreach (@probe) {
warn "Trying $_...\n";
/(\S+):(\d+)/ or die "Wrong format, must be 'host:port': $_";
tcp_connect $1, $2, ae_goal($_);
};
} soft_timeout => $timeout;
my $ready = AE::AdHoc->results;
print Dumper($ready);
foreach my $host (keys %$ready) {
# skip rejected hosts
ref $ready->{$host}->[0] and print "Port open: $host\n";
};
Заключение
В заключение хотелось бы сказать, что написание самих тестов все-таки менее важно с технической точки зрения, чем написание кода, хорошо поддающегося тестированию. Я перепробовал не так много способов. Например, вообще не пробовал Coro — хотелось бы увидеть комментарий на эту тему.
Наиболее перспективным, однако, мне показался подход, при котором бизнес-логика по максимуму выносится в чистые функции, то есть такие, которые возвращают значение, зависящее от входных параметров, и больше никак себя не проявляют. Вот короткая, но емкая заметка насчет рефакторинга такого рода.
А доля ветвления в асинхронных вызовах и, как следствие, потребность их в тестировании сводится к минимуму.
Но это тема для отдельной статьи.
Благодарности
- Евгению Понизовскому — за приобщение к миру AnyEvent.
- Вадиму Власову — за бета-тест модуля и обратную связь.
- Алексею Шрубу — за то, что модуль был-таки выложен на CPAN, а не остался на личном жестком диске.
- Ивану Петрову — за замечание о begin/end.
- Корректору Наталье Витько.
Приложение: My/Module.pm
Все-таки приятнее работать с кодом, чем с псевдокодом. Поэтому вот модуль для превращения примеров в полноценные скрипты.
use warnings;
use strict;
package My::Module;
sub new { return bless {}, shift };
sub do_something { return 42; };
use AnyEvent;
sub do_something_async {
my $self = shift;
my $code = shift;
my $timer;
$timer = AnyEvent->timer(
after => 5,
cb => sub {
undef $timer;
$code->(42);
}
);
return;
}
use Test::More;
use parent qw( Exporter );
our @EXPORT = qw( ok_result );
sub ok_result { is shift, 42, "Don't panic" };
1;
← Простые способы сделать консольную утилиту удобнее | Содержание | Тестирование интерфейса веб-приложений. Применение WWW::WebKit →