Выпуск 3. Май 2013

От редактора | Содержание | Pinto — собственный CPAN из коробки

Три правила тестирования кода, написанного с использованием ORM-фреймворка

Рассмотрены частые проблемы при тестировании ORM-классов, приведены несколько способов их решения.

Тестирование является важной частью разработки ПО, но на практике многие разработчики часто не пишут тестов. Почему так? Казалось бы, сегодня можно найти достаточное количество документации на эту тему, в которой на пальцах объясняется, как написать тест (например, Test::Tutorial). В реальности же код, который нужно тестировать, намного сложнее обучающих примеров, особенно это касается кода, который работает с базой данной. Еще более усугубляется ситуация, когда мы пытаемся написать тесты к коду, написанному с использованием ORM-фреймворка. Не будем пытаться категоризировать тесты и делить их на разные виды, а попытаемся предложить решение лишь некоторых проблем, с которыми вам придется столкнуться при написании тестов. Примеры будут для Rose::DB::Object.

Плохие новости

Код, написанный с использованием ORM-фреймворка, — это код, который зависит от базы данных

Этот факт создает дополнительные трудности во время тестирования. Естественно, зависимость от базы данных логична, поскольку ORM-фреймворк и предназначен как раз для того, чтобы отобразить объектную модель на реляционную базу данных. Особенно это касается шаблона проектирования Active Record (Rose::DB::Object и DBIx::Class используют данный шаблон), который делает нашу модель и хранилище еще более зависимыми и связанными.

ORM-фреймворк — это черный ящик

ORM фреймворк — это черный ящик и мы не знаем, что происходит внутри. Мы используем его API, загружаем объекты, вызываем разные методы и для нас происходит некая магия, фреймворк за нас формирует запросы к базе, загружает данные, создает объекты, отслеживает отношения между ними. С одной стороны это, конечно, положительный момент, но с другой стороны — негативный. Мы одновременно и зависим от базы данных, и еще больше пытаемся от нее абстрагироваться. Это заставляется нас отказаться от Mock-объектов.

А имеются ли хорошие новости? Хорошие новости имеются и их тоже две:

Хорошие новости

Мы работаем с объектами и классами

То есть, мы можем абстрагироваться от базы данных и просто считать, что мы имеем некие персистентные объекты и тестировать их как обычно.

Для тестирования многих объектов необязательна персистентность

Необязательно для тестирования классов сохранять что-то в базу данных. Часто бывает достаточно создать экземпляр объекта в памяти.

Держа в уме все эти новости, в нашей компании были выработаны три правила, которые позволили облегчить процесс тестирования ORM-зависимого кода. Предлагаю рассмотреть их:

Правило №1 — никаких тестов для простых классов

Не пишите тесты для примитивных классов, в которых нет никакой логики, а только схема данных. Вот пример такого класса:

package Language;

use base qw(Rose::DB::Object);

__PACKAGE__->meta->setup(
    table   => languages,
    columns => [
        language_id => { type => varchar, length => 3,  not_null => 1 },
        name        => { type => varchar, length => 64, not_null => 1 },
    ],
    primary_key_columns => [ language_id ],
);

Если мы посмотрим на этот код, то увидим, что он не содержит никакой логики, а только метаданные для отображения класса на реляционную базу данных. Естественно, все зависит от проекта и требований, но мы на своей практике избегаем тестирования такого кода по нескольким причинами:

  1. Тесты — это код, который необходимо написать, он может содержать ошибки, и его нужно постоянно поддерживать.
  2. Заниматься тестированием таких классов — это, по сути, заниматься тестированием самого ORM-фреймворка, у которого и так имеются свои тесты.

Соглашусь, что в таких классах тоже могут быть ошибки, поскольку человеку свойственно ошибаться. Хорошим решением будет уменьшить участие человека в написании такого кода. Достаточно создать генератор кода, написать к нему тесты, и автоматически сгенерировать простые классы. Для Rose::DB::Object существует Rose::DB::Object::Loader, для DBIx::ClassDBIx::Class::Schema::Loader.

Правило №2 — передавайте зависимости снаружи

Если вы знакомы с термином внедрение зависимостей (Dependency Injection), то для вас это будет очевидным. Давайте рассмотрим несколько примеров. Примеры реальные, но значительно упрощенные.

Пример №1 — «передача данных снаружи»

Часто возможно избежать работы с базой данной за счет передачи всех необходимых для тестирования данных снаружи. Допустим, у нас есть класс для расчета налогов, налоги и их ставки хранятся в базе данных. Схема класса выглядит так:

package TAX;

use base qw(Rose::DB::Object);

__PACKAGE__->meta->setup(
    table   => taxes,
    columns => [
        tax_id => { type => varchar, length   => 16, not_null => 1 },
        rate   => { type => integer, not_null => 1 }
    ],
    primary_key_columns => [ tax_id ],
);

sub calculate_tax_amount  { ... }

Есть два атрибута — код налога и ставка налога. И есть метод для расчета суммы налога — calculate_tax_amount.

В реальной жизни мы пишем так:

my $tax        = Tax->new( tax_id => 'VAT_20' )->load();
my $tax_amount = $tax->calculate_tax_amount( 102.51 );

То есть:

  1. Создаем объект.
  2. Загружаем его из базы данных по его идентификатору.
  3. Вызываем метод calculate_tax_amount и рассчитываем сумму налога.

В тестах же мы можем передавать ставку налога снаружи в конструктор и затем вызывать пересчет.

my $tax_obj = Tax->new( rate => 20 );
is( $tax_obj->calculate_tax_amount( 123.01 ), 20.50 );
is( $tax_obj->calculate_tax_amount( 456.00 ), 76.00 );

Этот пример наглядно показывает как протестировать простой объект, который не зависит от других объектов.

Пример №2 — внедрение зависимостей

Это более сложный пример и он требует от нас того, чтобы мы во время написания кода не забывали, что нам придется его тестировать. Создаем некий финансовый документ с двумя записями и хотим посчитать налог на весь документ.

В реальной жизни пишем так:

my $doc = FinancialDoc->new(
    lines => [
        {amount => 102.22},
        {amount => 103.27}
    ],
    date => "2010-10-10"
);

$doc->build();
my $amount = $doc->tax_amount();

$doc->build — загружает налог, который был актуален в 2010 году, подсчитывает сумму налога и сохраняет его в атрибуте документа tax_amount. Пример немного искусственный и предназначен исключительно для демонстрации подхода.

В тестах же нам необходимо избежать обращения к базе данных, мы можем это сделать за счет внедрения зависимостей в объект FinancialDoc через конструктор. Добавим дополнительный неперсистентный атрибут tax_object в наш класс (многие ORM позволяют такое сделать) и передадим в конструктор объект налога.

my $doc = FinancialDoc->new(
    lines => [
        {amount => 102.22},
        {amount => 103.27}
    ],
    date => "2010-10-10",
    tax_object => Tax->new( rate => 20 )
);
$doc->build();

is( $doc->tax_amount(), 41.01 );

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

Усложняют ситуацию два дополнительных факта:

  1. Мы не можем использовать DBI Mock, поскольку мы не должны зависеть от реализации фреймворка.
  2. Мы имеем одну базу данных для всей модели.

Что делать в такой ситуации? Первое решение, которое приходит на ум — это использовать заданный набор тестовых данных, который хранится в тестовой базе данных. Например, это могут быть страны, пользователи, компании, материалы, клиенты, контрагенты, склады, курсы валют, запасы… и множество других.

Общий набор тестовых данных будет работать, но это будет ровно до тех пор, пока мы не начнем тестировать объекты, которые агрегируют данные или работают со списками. Например, отчеты, которые будут зависеть не просто он наличия необходимых ему данных, а от наличия всех данных. К примеру, отчет по количеству материала на складе. Для добавления нового теста для такого отчета потребуется расширять набор данных, но при расширении у нас сломаются тесты для существующих отчетов.

Таким образом мы приходим к третьем правилу:

Правило №3 — индивидуальное тестовое окружение для каждого набора тестов

Звучит вполне логично и на практике работает. Остается вопрос, как можно такого добиться? Есть несколько вариантов решения:

  1. Пересоздавать базу данных для каждого набора тестов. Такой подход будет гарантированно работать, но на пересоздание базы данных требуется слишком много времени. Кроме того, скорее всего буду данные, которые никогда не меняются и практически не влияют на тесты.
  2. Использовать встраиваемую базу данных, например, SQLite. Таким образом можно просто копировать файл эталонный базы перед каждым набором тестов. Будет работать достаточно быстро, но тут возникает другая проблема — необходимость поддерживать два описания базы данных. Кроме того, нужно учитывать, что это все таки разные базы данных и результаты в тестах и реальной жизни могу отличаться.
  3. Вручную удалять все данные после каждого теста. Такой подход часто встречается, но приходится писать дополнительные процедуры очистки данных и поддерживать их в актуальном состоянии.

В нашей компании мы пришли к следующему решению — по завершению тестов мы просто откатываем транзакцию:

use Test::More;

my $db = Rose::DB->new_or_cached();
$db->begin_work();

init_test_data();

do_testing();

$db->rollback();

В данном примере происходит следующее:

  1. Мы стартуем новую транзакцию.
  2. Инициализируем индивидуальное тестовое окружение. Наполняем базу необходимыми для данного конкретного тестового набора данными.
  3. Запускаем тесты, которые могу делать любые изменения в базе данных.
  4. Запускаем откат транзакции и база данных возвращается в исходное состояние.

Единственный момент, про который стоит помнить — это, чтобы база данных поддерживала транзакции. В MySQL (InnoDB) такой подход отлично работает. В результате мы получаем для каждого теста свой собственный набор данных, не прилагая особых усилий для очистки базы после каждого теста.

Какой можно сделать вывод из всего вышесказанного? Иногда простые правила, позволяют серьезно облегчить работу программиста. Еще раз три правила:

  1. Не пишите тесты для простых классов.
  2. Используйте внедрение зависимостей.
  3. Используйте индивидуальное тестовое окружение для каждого набора тестов.

В завершение статьи приведу пример кода, который позволит упростить старт и откат транзакции. Пример очень близкий к реальному:

use Test::More;
use Test::Project;

my $t = Test::Project->new();

$t->object_factory->buy_material_from_partner({price => 1200});
$t->object_factory->buy_service_from_partner({price => 1200});

$t->iterate_test_data(
    "report_a",
    sub {
        my ($report_params, $expected_output) = @_;
        my $report = Project::Report::A->new(%$report_params);

        $t->test_report($report, $expected_output);
    }
);

Когда у нас создается объект класса Test::Project, то автоматически стартует транзакция. Когда объект уничтожается, то транзакция откатывается.

И, соответственно, код класса Test::Project:

package Test::Project;

use Moo;

has 'object_factory' => (
    is      => 'lazy',
    default => sub {
        return Test::ProjectObjectFactory->new();
    }
);

has 'db' => (
    is      => 'lazy',
    default => sub {

        # Switch database
        Project::DB->default_domain('test');
        return Project::DB->new_or_cached();
    }
);

sub BUILD {
    my $self = shift;

    die "ALREADY IN TRANSACTION" if $self->db->in_transaction();
    $self->db->begin_work();

    return $self;
}

sub DEMOLISH {
    my $self = shift;
    $self->db->rollback();
}

1;

Виктор Турский, технический директор компании WebbyLab


От редактора | Содержание | Pinto — собственный CPAN из коробки
Нас уже 1393. Больше подписчиков — лучше выпуски!

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