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

Дата оновлення перекладу 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.

Тепер, коли ми переконалися (знову) у написаному нами коді, ми можемо спокійно подумати про наступну партію функцій, які ми хочемо додати у наш фреймворк.