Модульное тестирование

Как? А главное, зачем?

Модульное тестирование

Как? А главное, зачем?

Александр Паньшин

Кто я такой

Меня зовут Александр

Я работаю в Вачанге тестировщиком. Это мой первый опыт в тестировании.

О чем поговорим?

Я хочу рассказать про следующие вещи:

Что такое тестирование

Тестирование программного обеспечения — процесс исследования, испытания программного продукта, имеющий две различные цели:

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

Тут должна быть картинка про летчика, который ничего не понял

Но я ее не нашел приличного размера:)

И да, вот за это я не люблю Википедию.

Определение два

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

Уже лучше

Цель:
Убедиться, что ПО соответствует требованиям
Как:
Создать набор тестов, выполнить их и сравнить полученные результаты с ожидаемыми.

Что такое тест

Тест — это совокупность трех действий:

Тестирование. Классификация по цели:

Тестирование. Классификация по объему тестируемого кода:

Модульное тестирование

Системное тестирование

WAT?!

Зачем тестировать одно и тоже?

Разница в скорости выполнения тестов и «глубине» тестирования.

Модульное тестирование

Системное тестирование

Инструменты (в мире PHP)

Попрактикуемся?

Требования

У нас есть простой класс EventSchedule.

Требования

сlass EventSchedule {
    /** @return \DateTime */
    public function getNearestSession() {
        // Tons of code here
    }
}

Требования

Проблемка

Реализация этого метода заняла 60 строк весьма себе запутанного кода. В процессе было обнаружено несколько ошибок, которые проявлялись... иногда:

Решено! Пишем тесты

На помощь нам идет PHPUnit:

composer require phpunit/phpunit 4.5.* --dev
class EventScheduleTest extends \PHPUnit_Framework_TestCase {
    /**
     * @test
     * If nearest possible session is later today, event schedule should not skip this date
     */
    public function shouldReturnRightNearestSessionIfItIsLaterToday()
    {
        $schedule = new EventSchedule();

        $starts = new \DateTime('20 hours ago');
        $schedule->setStartsAt($starts);
        $schedule->setPeriodic(true);
        $schedule->setWeekdays([1,2,3,4,5,6,7]);

        $this->assertEquals(date('Y-m-d'), $schedule->getNearestSession()->format('Y-m-d'));
    }
    

Arrange

$schedule = new EventSchedule();
$starts = new \DateTime('20 hours ago');
$schedule->setStartsAt($starts);
$schedule->setPeriodic(true);
$schedule->setWeekdays([1,2,3,4,5,6,7]);

$this->assertEquals(date('Y-m-d'),
    $schedule->getNearestSession()->format('Y-m-d'));

Act

$schedule = new EventSchedule();
$starts = new \DateTime('20 hours ago');
$schedule->setStartsAt($starts);
$schedule->setPeriodic(true);
$schedule->setWeekdays([1,2,3,4,5,6,7]);

$this->assertEquals(date('Y-m-d'),
    $schedule->getNearestSession()->format('Y-m-d'));

Assert

$schedule = new EventSchedule();
$starts = new \DateTime('20 hours ago');
$schedule->setStartsAt($starts);
$schedule->setPeriodic(true);
$schedule->setWeekdays([1,2,3,4,5,6,7]);

$this->assertEquals(date('Y-m-d'),
    $schedule->getNearestSession()->format('Y-m-d'));

Запускаем!

$ phpunit -c app/phpunit.xml.dist src/Belaveja/AppBundle/Tests/Entity/EventScheduleTest.php
    PHPUnit 4.5.0 by Sebastian Bergmann and contributors.
    Configuration read from /home/enlightened/projects/php/belaveja/app/phpunit.xml.dist
    .
    Time: 32 ms, Memory: 4.50Mb
    OK (1 test, 1 assertion)

Ой, мы забыли кое-что!

Сказал мне заказчик. Оказывается, бывают события, которые идут весь день. Ну, вроде как масленица - всем гостям по блину в любое время:)

Меняем требования

У нас есть простой класс EventSchedule.

Требования

/** @test */
public function roundTheClockEventStartsNow()
{
    $schedule = new EventSchedule();
    $starts = new \DateTime('20 hours ago');

    $schedule->setPeriodic(true);
    $schedule->setWeekdays([1,2,3,4,5,6,7]);
    $schedule->setRoundTheClock(true);

    $schedule->setStartsAt($starts);

    $this->assertEquals(new \DateTime(), $schedule->getNearestSession());

    $starts = new \DateTime('+51 hour');
    $schedule->setStartsAt($starts);
    $this->assertEquals($starts, $schedule->getNearestSession());
}
    

    There was 1 failure:

    1) Belaveja\AppBundle\Tests\Entity\EventScheduleTest::roundTheClockEventStartsNow
    Failed asserting that two DateTime objects are equal.
    --- Expected
    +++ Actual
    @@ @@
    -2015-04-05T14:11:33+0000
    +2015-04-05T18:11:33+0000

    /home/enlightened/projects/php/belaveja/src/Belaveja/AppBundle/Tests/Entity/EventScheduleTest.php:52

    FAILURES!
    Tests: 2, Assertions: 2, Failures: 1.

Ну да, мы же еще не писали кода:)

public function getNearestSession() {
    // Tons of code here
    if ($this->isRoundTheClock()) {
        $now = new \DateTime();
        $session->setTime(
            $now->format('H'), $now->format('i'), $now->format('s')
        );
    }
}




    Configuration read from /home/enlightened/projects/php/belaveja/app/phpunit.xml.dist

    ...

    Time: 36 ms, Memory: 4.75Mb

    OK (3 tests, 4 assertions)

Боль и страдания

Три главных источника боли

Решения проблемы с базой данных

У вас много зависимостей, но вы пользуетесь Dependency Injection?

У вас нет проблемы, у вас сложная часть про Arrange.

Создаете скрытые зависимости?

У вас проблема:)

Пример скрытой зависимости

class AppBundle\Content\Provider\Dropbox {
    public function update(Account $account) {
        $delta = $this->getClient($account->getAccessToken())->getDelta();
    }
    private function getClient(AccessToken $token) {
        return new Dropbox\Client((string) $token);
    }
}

Пример Dependency Injection

class AppBundle\Content\Provider\Dropbox {
    public function update(Account $account) {
    $delta = $this->clientFactory
                ->getClient($account->getAccessToken())->getDelta();
    }
    public function __construct(DropboxClientFactory $clientFactory) {
        $this->clientFactory = $clientFactory;
    }
}

Фабрика

class AppBundle\Content\Provider\DropboxClientFactory {
    const USER_AGENT = 'CompanyName Application/1.0';

    public function getClient(AccessToken $token)
    {
        return new Client((string)$token, self::USER_AGENT);
    }
}

Ради чего все это?

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

Ну да, я же говорил, что Arrange - это самое сложное:)



/** @test */
public function shouldIgnoreEdata()
{
    $data = [ /* Тут хитрый заранее заготовленный массив данных */];

    $provider = new Dropbox($this->getClientFactoryMock($data), $this->getFormatsMock());

    $result = $provider->update($this->getAccountMock());

    $this->assertInstanceOf('AppBundle\Content\UpdateResult', $result);
    $this->assertTrue($result->isSuccessful());
    $this->assertCount(0, $result->getNew());
}
private function getClientFactoryMock($response = [])
{
    $response = array_merge($this->getDefaultResponse(), $response);
    $client = $this->getMockBuilder('Dropbox\Client')
                   ->disableOriginalConstructor()
                   ->getMock();

    $client->expects($this->once())
           ->method('getDelta')
           ->will($this->returnValue($response));

    $factory = $this->getMock('AppBundle\Content\Provider\DropboxClientFactory');
    $factory->expects($this->once())
            ->method('getClient')
            ->will($this->returnValue($client));

    return $factory;
}

Пользуетесь Dependency Injection Container?

Не забывайте тестировать сборку объекта. Кстати, вот вам пример интерграционного теста.



class DropboxServiceTest extends WebTestCase{
    /** @test */
    public function shouldLoadDropboxProvider()
    {
        $this->assertInstanceOf(
            'AppBundle\Content\Provider\Dropbox',
            $this->container->get('vendor.content_provider.dropbox')
        );
    }
}

Фейковые данные

Эта проблема, на самом деле, очень тесно связана с проблемой тестовой БД.

Инструменты

Как водится, аналоги этих библиотек есть во всех языках. FactoryMuffin вообще, по старой традиции PHP, содран с factory_girl - библиотеки на ruby.

Мифы и легенды

Миф 1: Тестирование — это долго и дорого

Дорого чинить баги на продакшене. Особенно если продакшен — марсоход:)

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

Мечты менеджера
  1. dev
  2. test
  3. bugfix
  4. test
Суровая реальность
  1. dev
  2. test
  3. bugfix
  4. test
  5. bugfix
  6. test
  7. bugfix
  8. test
Реальность с тестами
  1. dev
  2. test
  3. bugfix
  4. test

Миф 2: Писать тесты скучно!

Используйте правильные инструменты! Faker, Mockery, FactoryMuffin избавят вас от большинства проблем с тестами.

А если вы пишите тесты до кода — это шанс проверить удобство использования ваших интерфейсов.

Миф 2: Писать тесты скучно!

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

Жестко, но правда.

Легенда: Внедрение тестирования позволит писать код без багов

Писать код без багов невозможно. Тестирование всего лишь позволяет раньше находить баги.

Вас даже никто не заставляет их чинить!

Легенда о священном граале: 100% покрытие

А оно вам надо? Будьте прагматиками. Пишите ровно столько тестов, сколько достаточно для того, чтобы выпускать стабильный продукт.

Философия: У меня есть проект на 100500 классов, как я его тестировать буду?

Через год: «Ой, кажется мы покрыли тестами весь проект за год».

Вопросы?