Выпуск 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
Нас уже 1393. Больше подписчиков — лучше выпуски!

Комментарии к статье