Выпуск 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;

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

Углубленное тестирование

Рассмотрим, какие случаи нужно протестировать:

  1. Модуль должен бросать исключение в случае неуспешного ответа от сервера.
  2. Модуль должен бросать исключение в случае неправильного JSON.
  3. Модуль должен преобразовывать ответ из JSON в Perl-структуру.
  4. Модуль должен правильно вызывать метод 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 — система тестирования ПО полного цикла
Нас уже 1393. Больше подписчиков — лучше выпуски!

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