Размышления о моках


На главную

Что такое мок?

Наверх

Мок (Mock) — это фиктивный объект, имитирующий поведение реального класса и выполняющий аудит процесса собственного использования. Мок-объекты появились в результате развития сообщества XP (Extreme Programming), одним из основных приемов которого была разработка через тестирование (TDD, Test Driven Development).

Моки появились как следствие решения конкретной проблемы. Большинство классов взаимодействуют с другими (соседними) классами через интерфейсы. Но что если под рукой нет реализации нужного интерфейса? В этом случае, на замену реализации приходит мок-объект. Такие объекты позволяют программировать последовательность вызовов и результатов работы методов того класса, замещение которого необходимо выполнить.

Для создания мока, разработчику необходимо определить последовательность ожиданий (Expectation), удовлетворение которых ожидается от тестируемого класса. После настройки, мок внедряется в тестируемый объект. Затем инициируется выполнение тестируемого кода. Если порядок использования мок-объекта не соответствует заявленным ожиданиям, значит тестируемый код работает неверно.



Простой пример использования мока

Наверх

Вполне логичным представляется использование мок-объекта в том случае, если ни одна из реализаций интересующего интерфейса недоступна. Рассмотрим пример.

Имеется интерфейс IFactory

interface IFactory
{
    public function produce($blueprint);
}
Требуется реализовать кеширующую фабрику для тех типов объектов, которые нужны только в одном экземпляре. Такая схема может быть востребована для однотипных системных объектов, например, пул подключений к источникам данных или набор датамапперов (data mappers).
class CachingFactoryTest extends PHPUnit_Framework_TestCase
{
    function testProduce()
    {
        $factory = $this->getMock('IFactory');
        $factory->expects($this->once())
            ->method('produce')
            ->with($this->equalTo('foobar'))
            ->will($this->returnValue($this));
        
        $cachingFactory = new CachingFactory($factory);
        $this->assertSame($this, $cachingFactory->produce('foobar'));
        $this->assertSame($this, $cachingFactory->produce('foobar'));
    }
    
    function testCache()
    {
        $factory = $this->getMock('IFactory');
        $factory->expects($this->never())
            ->method('produce');
            
        $cachingFactory = new CachingFactory($factory);
        $cachingFactory->cache('foobar', $this);
        $this->assertSame($this, $cachingFactory->produce('foobar'));
    }
    
}
В первом тесте создается и настраивается производный от интерфейса IFactory мок-объект. Для него задается следующая установка: метод produce должен быть вызван один. При этом, в качестве аргумента ему должно быть передано строковое 'foobar', а в качестве результата должен быть возвращен экземпляр теста.

В данном случае неважно, что будет передано в метод produce декорированной фабрики и что она вернет в результате его работы. Строковое 'foobar' указано для того, что бы убедиться в том, что тестируемый класс не забывает передавать полученные им самим аргументы и передает их в неизменном виде. Впоследствии эта строка используется в качестве аргумента метода produce тестируемого класса. Так же не имеет значения и то, что конкретно возвращает декорируемая фабрика. Здесь важно иметь значение для сравнения.

Второй метод использует мок-объект для того, что бы убедиться в том, что тестируемый класс не обращается к фабрике, если запрашиваемый объект находится в кеше.



Проверка поведения и проверка состояния

Наверх

Рассматривая практику применения мок-объектов, нельзя не коснуться такой важной темы, как стили TDD, сформулированные одним из отцов-основателей экстремального программирования Мартином Фаулером.

В своей статье Mocks Aren't Stubs Мартин акцентирует внимание на двух возможных подходах к разработке тестов: основанный на проверке поведения (behavior verification) и основанный на проверке состояния (state verification). Первый из этих подходов характерен для мок-ориентированного (mockist), а второй для классического (classic) стилей.

Мартин признается, что не видит неоспоримых преимуществ у какого-то одного из этих стилей. В конечном итоге, он рекомендует придерживаться золотой середины.

Так как с простыми примерами все понятно (см. ссылки), здесь они рассматриваться не будут. Основная задача данной статьи - рассмотреть несколько комплексных тестов и попытаться выяснить, в каких случаях нужно использовать моки, а когда их следует применять с осторожностью.



Критерий цены

Наверх

Одним из критериев при выборе стратегии тестирования (состояния или поведения) является стоимость разработки теста. Очевидно, что если для настройки всех необходимых мок-объектов требуется сто строк кода, а для настройки реальных объектов - пятьдесят, использовать моки невыгодно. И наоборот, если можно настроить один несложный мок, вместо пятидесяти строк фикстуры, использование мок-объекта может быть вполне оправдано.

Рассмотрим следующий пример

    function testCacheResultSet()
    {
        $rs = $this->getMock('ResultSet');
        $rs->expects($this->at(0))
            ->method('next')
            ->will($this->returnValue(true));
        $rs->expects($this->at(1))
            ->method('get')
            ->with($this->equalTo('id'))
            ->will($this->returnValue(123));
        $rs->expects($this->at(2))
            ->method('getRow')
            ->will($this->returnValue(Array('id' => 123,'name' => 'foo')));
        $rs->expects($this->at(3))
            ->method('next')
            ->will($this->returnValue(true));
        $rs->expects($this->at(4))
            ->method('get')
            ->with($this->equalTo('id'))
            ->will($this->returnValue(345));
        $rs->expects($this->at(5))
            ->method('getRow')
            ->will($this->returnValue(Array('id' => 345,'name'=>'bar')));
        $rs->expects($this->at(6))
            ->method('next')
            ->will($this->returnValue(false));
        
        $cache = new CacheLinkedObjects();
        $cache->cacheResultSet($rs, 'id');
        
        $this->assertEquals(Array('id'=>123,'name'=>'foo'),$cache->get(123));
        $this->assertEquals(Array('id'=>345,'name'=>'bar'),$cache->get(345));
    }
Это тест метода кеширования данных о связанных объектах, получаемых на основании набора записей ResultSet. Выглядит довольно объемно, особенно если учитывать тот факт, что в проекте уже имеется реализация интерфейса ResultSet и для теста она подходит куда лучше, чем мок-объект. Это класс ArrayResultSet. Вот как выглядит тест с использованием этого класса
    function testCacheResultSet()
    {
        $rs = new ArrayResultSet(array(
            array('id' => 123,'name' => 'foo'),
            array('id' => 345,'name'=>'bar')
        ));
        
        $cache = new CacheLinkedObjects();
        $cache->cacheResultSet($rs, 'id');
        
        $this->assertEquals(Array('id'=>123,'name'=>'foo'),$cache->get(123));
        $this->assertEquals(Array('id'=>345,'name'=>'bar'),$cache->get(345));
    }
Этот пример демонстрирует ситуацию, когда настройка мок-объекта стоит слишком дорого. Если для мока настраивается последовательность вызовов одного и того же метода, стоит подумать о применении реального объекта.

Рассмотрим другой тест

class PaymentProcessingTest extends PHPUnit_Framework_TestCase
{
    protected $dbh,$operations;

    function setUp()
    {
        if ( is_null($this->dbh) )
        {
            $this->dbh = Creole::getConnection(TEST_CONNECTION);
        }
        $datamappers = new CachingFactory
            (new Factory('DataMapper/', 'DataMapper_', $this->dbh));
        $this->operations = new CachingFactory
            (new Factory('Operation/', 'Operation_', $datamappers));
        
        $sth = $this->dbh->prepareStatement('INSERT INTO terminal
            (id,name,sysfee,syshold,url) VALUES (?,?,?,?,?)');
        $sth->executeUpdate
            (array(1, 'term 1', 0.01, 0.10, 'http://localhost/test.php'));
        $sth->executeUpdate
            (array(2, 'term 2', 0.02, 0.20, 'http://localhost/test2.php'));
        
        $this->dbh->executeUpdate('INSERT INTO merchant
            (id,login,type1_terminal,type2_terminal) VALUES (1,"foobar",1, 2)');
    }
    
    function tearDown()
    {
        if ( $this->dbh )
        {
            $this->dbh->executeUpdate('DELETE FROM merchant WHERE id=1');
            $this->dbh->executeUpdate('DELETE FROM terminal WHERE id IN (1,2)');
        }
    }
    
    function testProcessing()
    {
        $request = new ProcessingRequest();
        $request->parse(file_get_contents(dirname(__FILE__).'/request.xml'));
        $response = new ProcessingResponse();
        
        $ctrl = new PaymentProcessing($this->operations);
        $ctrl->run($request, $response);
        
        $this->assertTrue($response->getResult(0)->isSuccess());
        $this->assertEquals(123, $response->getResult(0)->getTransactionId());
    }

}
Это значительно упрощенный контроллер обработки платежей. Из этого теста практически непонятно, что конкретно он тестирует.

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

В таких случаях более целесообразным видится применение мок-объекта. Моком следует заменить интерфейс, за которым следует большой объем настроек тестовой среды. Тот же тест с применением мок-объекта

class PaymentProcessing2Test extends PHPUnit_Framework_TestCase
{
    
    function testProcessing()
    {
        $request = new ProcessingRequest();
        $request->parse(file_get_contents(dirname(__FILE__).'/request.xml'));
        $response = new ProcessingResponse();
        
        $operation = $this->getMock('IOperation');
        $operation->expects($this->once())
            ->method('execute')
            ->with($this->equalTo($request->getPayment(0)),
                   $this->isInstanceOf('IPaymentResult'));
        $operations = new CachingFactory($this->getMock('IFactory'));
        $operations->cache('Authorize', $operation);
        
        $ctrl = new PaymentProcessing($operations);
        $ctrl->run($request, $response);
    }

}

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



Cложные диалекты в аргументах

Наверх

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

    function testRunController()
    {
        $request = new Request();
        $request->setVar('userId', 12345);
        $request->setVar('RecipientName', 'Vasya Pupkin');
        $request->setVar('BankAccount', '00000111112222233333');
        $response = new Response();
        $ctrl = new SphericalControllerInVacuum(new Validator());
        
        $ctrl->run($request, $response);
        
        $this->assertTrue($response->getVar('Success'));
    }
Логично предположить, что количество вариантов валидных данных сопоставимо с количеством вариантов невалидных данных.

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

Но если для тестирования прямого назначения контроллера (безусловной, успешной отработкой) можно опуститься до условностей, то для работы с невалидными данными такой номер не пройдет. А связано это с различной критичностью этих двух тестовых случаев.

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

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

В большинстве подобных случаев, количество тестов можно сократить до количества входных элементов. Это достигается путем использования невалидных данных для одного из элементов. Возвращаясь к примеру контроллера, можно вообразить

    function testUserIdIsNotNull(){ ... }
    function testUserIdIsNotFloat(){ ... }
    function testUserIdIsNotString(){ ... }
    ...
    function testRecipientNameIsNotNull(){ ... }
    function testRecipientNameIsNotTooShort{ ... }
    function testRecipientExists(){ ... }
    ...
    function testAccountNumberContainsAlpha(){ ... }
и так далее и тому подобное. Согласиться на такой объем работ достаточно трудно и тут возникает резонный вопрос - как тестировать?

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

    function testUserIdInvalidation(){ ... }
    function testRecipientInvalidation{ ... }
    function testAccountNumberInvalidation(){ ... }
Это происходит потому, что начинает действовать правило Tell, Don't Ask и постановка множества вопрошаний о том, чем же проверяемые данные не должны являться, заменяются на единичные требования того, чем они должны быть. Таким образом, для тестирования одной строчки
    function validateAccountNumber($value)
    {
        return $this->validator->isBankAccount($value);
    }
не понадобится выполнять тест всех возможных вариантов невалидных данных, какие только можно вообразить в отношении номеров банковских счетов. Достаточно создать мок-объект валидатора и использовать его при тестировании ситуаций, где проверяется реакция контроллера на невалидный номер банковского счета
    function testAccountNumberInvalidation()
    {
        $validator = $this->getMock('IValidator');
        $validator->expects($this->once())
            ->method('isBankAccount')
            ->with($this->equalTo('foobar'))
            ->will($this->returnValue(false));
        $request = new Request();
        $request->setVar('userId', 12345);
        $request->setVar('RecipientName', 'Vasya Pupkin');
        $request->setVar('BankAccount', 'foobar');
        $response = new Response();
        $ctrl = new SphericalControllerInVacuum($validator);
        
        $ctrl->run($request, $response);
        
        $this->assertFalse($response->getVar('Success'));
    }

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

Например, при тестировании класса, формирующего SQL-запрос к базе данных, использование мок-объекта на подключение к БД даст слишком чувствительный к изменениям, либо сложный для понимания тест. Это связано с тем, что строка SQL-запроса представляет собой сложный для анализа диалект.

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



Критерий отзывчивости

Наверх

Насколько правильно тесты будут реагировать на изменения, достаточно сильно зависит от реализации библиотеки моков. Так как в большинстве случаев, работа библиотеки моков опирается на специфические особенности языка программирования, чуткость мок-объектов зависит от строгости конкретного языка.

Особенно это заметно в слаботипизированных языках, таких как PHP, Perl, JavaScript. Так как в скриптовых языках практикуются преимущественно соглашения о типах значений, библиотеки моков для таких языков просто не имеют возможности проконтролировать момент перемены типа.

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

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

Фаулер вскользь намекает на проблему


The second different thing in the second test case is that I've relaxed the constraints on the expectation by using withAnyArguments. The reason for this is that the first test checks that the number is passed to the warehouse, so the second test need not repeat that element of the test. If the logic of the order needs to be changed later, then only one test will fail, easing the effort of migrating the tests.
Но Java язык достаточно строгий и для него это не так актуально, как для скриптовых языков. Ниже проблема демонстируется на примере PHP
    function testView()
    {
    	$rs = new ObjectResultSet(new ArrayResultSet
            (array(array('id' => 1, 'login' => 'foka', 'enabled' => true))));
    	$loader = $this->getMock('UserLoader', array('fetch'));
    	$loader->expects($this->once())
            ->method('fetch')
            ->will($this->returnValue($rs));

        $ctrl = new UserList($loader);
        $json = $ctrl->view();
        $this->assertResponse(true, '', array(array(
            'id' => 1, 'login' => 'foka', 'enabled' => true,
        )), $json);
    }
В этом примере, действие view контроллера UserList использует результат работы мока на UserLoader. Подразумевается, что метод fetch загрузчика возвращает набор объектов, каждый из которых представляет отдельного пользователя. Контроллер использует его примерно так
    function view()
    {
        ...
        $rs = $loader->fetch();
        foreach ( $rs as $user )
        {
            $this->response->data[] = array(
                'id'      => $user->getId(),
                'login'   => $user->getLogin(),
                'enabled' => $user->isEnabled()
            );
        }
        ...
    }
После того, как на основе вышеобозначенного теста был разработан метод view, требования к модели изменились. Теперь, метод fetch загрузчика должен возвращать набор строк объектов, где в каждой строке, помимо объекта-пользователя, содержится объект-группа, к которой этот пользователь относится. На каждой итерации теперь следует использовать вспомогательный набор с интерфейсом
interface UserResultSet
{
   function getUser();
   function getGroup();
}
Но PHPUnit не может угадать, что тип возвращаемого методом fetch значения был изменен, потому что PHP не требует указания типа возвращаемого значения в прототипе метода. Прежний тест контроллера UserList будет завершаться зеленой линией, но в рабочем варианте ошибка возникнет сразу, как только программа дойдет до $user->getId(), потому что такого метода в интерфейсе набора нет.

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



Критерий скорости

В разработке

Критерий надежности

В разработке

Частичные моки (Partial mock)

В разработке



Заключение

Наверх

В общем случае, следует избегать использования мок-объектов. Если не придерживаться этого правила, можно столкнуться с мнимой зеленой линией для тестов объектов-пользователей.

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

Чем больше деталей скрывается за используемым интерфейсом, тем оправданее применение мок-объекта на этот интерфейс. Это позволит разгрузить тест от второстепенных деталей. И наоборот, при тестировании простых объектов следует избегать использования моков, так как, будучи суррогатами, моки не всегда реагируют на изменения так же как и реальные классы.

При работе с аргументами, значения которых представляются сложными для анализа, следует применять мок-объекты на интерфейсы, принимающие эти аргументы по ходу работы тестируемого кода. Это позволит избежать разбухания тестов от повторного тестирования соседних классов. Но делать это следует только в том случае, если эти аргументы не изменяются в процессе работы тестируемого объекта.

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



Ссылки

Наверх


Правила использования | На главную Whirlwind © 2002 - 2012

ИЯндекс цитирования