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

Дата обновления перевода 2023-09-01

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

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

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

1
$ composer require --dev phpunit/phpunit

Затем, создайте файл конфигурации PHPUnit в example.com/phpunit.xml.dist:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
    backupGlobals="false"
    colors="true"
    bootstrap="vendor/autoload.php"
>
    <coverage processUncoveredFiles="true">
        <include>
            <directory suffix=".php">./src</directory>
        </include>
    </coverage>

    <testsuites>
        <testsuite name="Test Suite">
            <directory>./tests</directory>
        </testsuite>
    </testsuites>
</phpunit>

Эта конфигурация определяет разумные значения по умолчанию для большинства настроек PHPUnit; более того, автозагрузчик используется для начальной загрузки тестов, и тесты будут храниться в каталоге example.com/tests/.

Теперь, давайте напишем тест для "не найденных" источников. Чтобы избежать создания всех зависимостей при написании тестов, и для того, чтобы действительно модульно протестировать то, что мы хотим, мы будем использовать тестовые дубли. Тестовые дубли леге создать, когда мы полагаемся на интерфейсы, а не на конкретные классы. К счастью, Symfony предоставляет такие интерфейсы для базовых объектов вроде соспоставителя URL и разрешителя контроллеров. Измените фреймворк так, чтобы исользовать их:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// example.com/src/Simplex/Framework.php
namespace Simplex;

// ...

use Calendar\Controller\LeapYearController;
use Symfony\Component\HttpKernel\Controller\ArgumentResolverInterface;
use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface;
use Symfony\Component\Routing\Matcher\UrlMatcherInterface;

class Framework
{
    public function __construct(
        private UrlMatcherInterface $matcher,
        private ControllerResolverInterface $resolver,
        private ArgumentResolverInterface $argumentResolver,
    ) {
    }

    // ...
}

Теперь мы готовы написать наш первый тест:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// example.com/tests/Simplex/Tests/FrameworkTest.php
namespace Simplex\Tests;

use PHPUnit\Framework\TestCase;
use Simplex\Framework;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ArgumentResolverInterface;
use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface;
use Symfony\Component\Routing;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;

class FrameworkTest extends TestCase
{
    public function testNotFoundHandling(): void
    {
        $framework = $this->getFrameworkForException(new ResourceNotFoundException());

        $response = $framework->handle(new Request());

        $this->assertEquals(404, $response->getStatusCode());
    }

    private function getFrameworkForException($exception): Framework
    {
        $matcher = $this->createMock(Routing\Matcher\UrlMatcherInterface::class);

        $matcher
            ->expects($this->once())
            ->method('match')
            ->will($this->throwException($exception))
        ;
        $matcher
            ->expects($this->once())
            ->method('getContext')
            ->will($this->returnValue($this->createMock(Routing\RequestContext::class)))
        ;
        $controllerResolver = $this->createMock(ControllerResolverInterface::class);
        $argumentResolver = $this->createMock(ArgumentResolverInterface::class);

        return new Framework($matcher, $controllerResolver, $argumentResolver);
    }
}

Этот тест симулирует запрос, не совпадающий ни с каким маршрутом. Таким образом, метод match() возвращает исключение ResourceNotFoundException и мы тестируем, конвертирует ли наш фреймворк это исключение в ответ 404.

Выполнение этого теста заключается в простом запуске phpunit из каталога example.com:

1
$ ./vendor/bin/phpunit

Note

Если вы не понимаете, что вообще происходит в коде, прочтите эту документацию PHPUnit про тестовые дубли.

После выполнения теста, вы должны увидеть зелёную строку. Если же нет, то у вас есть баг либо в тесте, либо в коде фреймворка!

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

1
2
3
4
5
6
7
8
public function testErrorHandling(): void
{
    $framework = $this->getFrameworkForException(new \RuntimeException());

    $response = $framework->handle(new Request());

    $this->assertEquals(500, $response->getStatusCode());
}

Последнее, но не менее важное, давайте напишем тест для случаев, когда у нас есть настоящий Ответ:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Controller\ArgumentResolver;
use Symfony\Component\HttpKernel\Controller\ControllerResolver;
// ...

public function testControllerResponse(): void
{
    $matcher = $this->createMock(Routing\Matcher\UrlMatcherInterface::class);

    $matcher
        ->expects($this->once())
        ->method('match')
        ->will($this->returnValue([
            '_route' => 'is_leap_year/{year}',
            'year' => '2000',
            '_controller' => [new LeapYearController(), 'index'],
        ]))
    ;
    $matcher
        ->expects($this->once())
        ->method('getContext')
        ->will($this->returnValue($this->createMock(Routing\RequestContext::class)))
    ;
    $controllerResolver = new ControllerResolver();
    $argumentResolver = new ArgumentResolver();

    $framework = new Framework($matcher, $controllerResolver, $argumentResolver);

    $response = $framework->handle(new Request());

    $this->assertEquals(200, $response->getStatusCode());
    $this->assertStringContainsString('Yep, this is a leap year!', $response->getContent());
}

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

Чтобы убедиться, что мы охватили все возможные примеры использования, запустите функцию охвата PHPUnit тестов (вначале вам нужно включить XDebug):

1
$ ./vendor/bin/phpunit --coverage-html=cov/

Откройте example.com/cov/src/Simplex/Framework.php.html в браузере и проверьте, чтобы все строки для класса Фреймворка были зелёными (это значит, что они были посещены при выполнении тестов).

Как вариант, вы можете вывести результат напрямую в консоль:

1
$ ./vendor/bin/phpunit --coverage-text

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

Теперь, когда мы уверены (опять) в написанном нами коде, мы можем спокойно подумать о следующей партии функций, которые мы хотим добаивть в наш фреймворк.