Тестирование API Laravel с использованием PHPUnit

TL;DR: из этого туториала вы узнаете, как построить и протестировать различные конечные точки RESTful API, созданного с помощью Laravel. Наш подход к достижению этой цели будет включать::

  • Настройка среды тестирования
  • Написание тестов для конечных точек нашего приложения с ожиданием, что они не будут работать
  • А также структурирование и построение нашего API для прохождения тестов

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

Как инженер-программист, который рассматривает возможность сделать небольшой шаг вперед в разработке (и вы должны это сделать), тестовая разработка, часто называемая TDD, является одним из лучших методов, которые вы можете добавить в свой арсенал. Для выразительного тестирования ваших приложений Laravel поставляется вместе с PHPUnit, тестовой платформой для PHP. Он содержит удобные вспомогательные методы, полезные для создания и запуска тестовых сценариев, специально написанных для различных блоков в вашем приложении.

Предпосылки

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

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

Вы можете настроить новые проекты Laravel либо с помощью Composer, либо с помощью установщика Laravel.

В этом уроке мы будем использовать PHPUnit и Xdebug для тестирования и анализа покрытия. Пожалуйста, убедитесь, что у вас установлен Xdebug в вашей системе.

Важность тестирования

Важность тестирования сводится к двум вещам-гарантии и ремонтопригодности.

Интернет изобилует историями об ошибках программного обеспечения, которые обходятся организациям в миллионы долларов ущерба. Среди всех этих историй есть одна общая тема-неадекватное тестирование. Либо никаких тестов не проводилось, либо тесты недостаточно охватывали все потенциальные сценарии, с которыми столкнется приложение. Как разработчики, мы склонны моделировать наши приложения вокруг идеальных сценариев, но производственная среда-это что угодно, только не идеал. Крайне важно, чтобы наши тесты охватывали все возможные сценарии и чтобы наше приложение вело себя ожидаемым образом во всех из них.

Вопрос ремонтопригодности основывается на гарантии. Вы когда-нибудь смотрели на фрагмент кода и думали о его рефакторинге, но сдерживались, потому что боялись сломать приложение? Вместо этого вы дублируете его в другой функции, а затем используете его вместо этого. Вот так код начинает гноиться (по словам дяди Боба) и появляются ошибки. Критическое изменение реализуется в одном классе, но не в дублировании в другом классе. Приложение, по сути, становится бомбой замедленного действия, ожидающей взрыва. Кроме того, правильное тестирование делает такие процессы, как CI/CD, более надежными — что позволяет быстро выпускать обновления. Излишне говорить, что невозможно переоценить важность тестирования.

Разработка На Основе Тестов

Test Driven Development (TDD) – это стиль программирования, основанный на трех китах: тестировании, кодировании и проектировании (рефакторинге). TDD несколько аномален для обычного опыта разработчика в том смысле, что мы склонны писать код сначала перед нашими тестовыми случаями. Однако это портит качество наших тестовых случаев, поскольку наши тесты будут руководствоваться нашим кодом (который обрабатывает только идеальные условия).

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

TDD по сути представляет собой цикл из 3 событий:

  1. Написание тестового случая. Поскольку кода для запуска нет, этот тест завершится неудачей и также будет называться Красной фазой TDD.
  2. Напишите код для прохождения теста (не больше и не меньше). Это известно как зеленая фаза TDD
  3. Рефакторинг кода. Это может быть сделано в форме устранения дубликатов или реструктуризации в соответствии с лучшими практиками (DRYKISSSOLID, чтобы назвать несколько). Это также называется синей фазой TDD.

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

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

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

Настройка демонстрационного проекта

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

Идите вперед и клонируйте приложение

git clone https://github.com/yemiwebby/laravel-phpunit-starter
cd laravel-phpunit-starter

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

  1. User
  2. Wallet
  3. Strategy
  4. Investment

За исключением Walletресурса, маршрут API доступен для создания, обновления, удаления и поиска одного ресурса. Конечная точка также предоставляется для получения всех ресурсов. Вы можете просмотреть маршруты, предоставляемые APIroutes/api.php-интерфейсом .

Затем установите зависимости приложения с помощью следующей команды:

composer install
cp .env.example .env

После завершения процесса перейдите к созданию базы данных для вашего приложения и .env соответствующим образом измените файл

DB_CONNECTION=YOUR_DB_CONNECTOR
DB_PORT=3306 # 3306 for MySQL
DB_DATABASE=YOUR_DATABASE_NAME
DB_USERNAME=YOUR_DATABASE_USERNAME
DB_PASSWORD=YOUR_DATABASE_PASSWORD

Замените YOUR_DATABASE_NAME,YOUR_DATABASE_USERNAME, и YOUR_DATABASE_PASSWORD с соответствующими значениями для вашей базы данных.

Существует существующая схема базы данных и классы сеялок, созданные для демонстрационного приложения и расположенные в папках database/migrations и database/seeders соответственно. Выполните следующую команду, чтобы обновить созданную базу данных схемой и затем ввести в нее некоторые тестовые данные:

php artisan migrate --seed

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

php artisan serve

Тестирование

Для тестирования вы будете использовать PHPUnit. Все ваши тесты будут расположены в tests каталоге приложения. Структура папок, которую мы возьмем, будет несколько соответствовать структуре приложения. Код для тестирования контроллеров будет находиться в tests/Controller. Имена, присвоенные вашим тестовым файлам, также будут совпадать с именем тестируемого класса. Например, имя файла, содержащего тестовые случаи для нашего InvestmentController, таково InvestmentControllerTests.

Наконец, все ваши тестовые функции будут иметь префикс test. Например, если вы хотите написать тестовый случай, чтобы убедиться, что ваш InvestmentController правильно создает инвестиции, вы можете назвать эту функцию testInvestmentIsCreatedSuccessfully. Имена функций в ваших тестовых случаях должны быть описательными, чтобы мы могли понять, к чему стремится тест. Это также помогает нам управлять нашим кодом (разбитым на различные тесты, Если имя функции становится слишком длинным).

Команда для запуска всех наших тестов показана ниже

vendor/bin/phpunit

Чтобы сделать наши тесты быстрее, мы будем использовать SQLite для нашей тестовой базы данных. Это уже было настроено в phpunit.xml файле.

<php>
    <server name="APP_ENV" value="testing"/>
    <server name="BCRYPT_ROUNDS" value="4"/>
    <server name="CACHE_DRIVER" value="array"/>
    <server name="DB_CONNECTION" value="sqlite"/>
    <server name="DB_DATABASE" value=":memory:"/>
    <server name="MAIL_MAILER" value="array"/>
    <server name="QUEUE_CONNECTION" value="sync"/>
    <server name="SESSION_DRIVER" value="array"/>
    <server name="TELESCOPE_ENABLED" value="false"/>
</php>

tests/TestCase.php был изменен для заполнения базы данных при настройке тестов. Для генерации случайных данных мы будем использовать пакет Faker. Кроме того, был добавлен волшебный геттер, так что вы можете легко получить доступ к Faker, когда вам нужно генерировать случайные данные. Ваш tests/TestCase.php должен выглядеть так:

<?php
    
namespace Tests;
    
use Exception;
use Faker\Factory;
use Faker\Generator;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\Artisan;
    
abstract class TestCase extends BaseTestCase {
    
    use CreatesApplication, DatabaseMigrations;
    
    private Generator $faker;
    
    public function setUp()
    : void {
    
        parent::setUp();
        $this->faker = Factory::create();
        Artisan::call('migrate:refresh');
    }
    
    public function __get($key) {
    
        if ($key === 'faker')
            return $this->faker;
        throw new Exception('Unknown Key Requested');
    }
}

Покрытие кода

Помимо выполнения тестов, мы также хотим отслеживать покрытие кода. Это позволяет узнать, какая часть написанного кода покрывается тестовыми случаями. PHPUnit также может помочь в этом. Чтобы просмотреть отчет о покрытии после выполнения теста, добавьте --coverage-* аргумент. Например, чтобы просмотреть отчет о покрытии кода сразу после завершения тестов, используйте следующую команду:

vendor/bin/phpunit --coverage-text

Для этого проекта вы будете генерировать свои отчеты в формате HTML, используя --coverage-html аргумент. Этот аргумент требует наличия каталога для записи отчета. Для нашего проекта это будет tests/coverage каталог.

vendor/bin/phpunit --coverage-html tests/coverage

Для упрощения процесса тестирования в файл composer.json был добавлен скрипт с именем test. Чтобы запустить тесты и создать отчет HTML, введите следующее:

composer test

Это создаст HTML-файл в tests/coverage каталоге. Откройте tests/coverage/index.html файл в браузере, чтобы просмотреть отчет о покрытии кода.

Примечание: убедитесь, что вы добавили xdebug.mode=coverage в свой php.ini файл.

Детали покрытия теста Laravel

Вы также можете перейти по ссылкам, чтобы увидеть более подробный отчет. Например, отчет о покрытии для вашей папки Controller:

Ссылки тестового покрытия Laravel

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

Написание первых тестовых случаев

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

В tests каталоге создайте новую папку с именем Controllers. Создайте файл с именем UserControllerTests.php и добавьте следующее:

<?php
    
namespace Tests\Controllers;
    
use Illuminate\Http\Response;
use Tests\TestCase;
    
class UserControllerTests extends TestCase {
    
    public function testIndexReturnsDataInValidFormat() {
    
    $this->json('get', 'api/user')
         ->assertStatus(Response::HTTP_OK)
         ->assertJsonStructure(
             [
                 'data' => [
                     '*' => [
                         'id',
                         'first_name',
                         'last_name',
                         'email',
                         'created_at',
                         'wallet' => [
                             'id',
                             'balance'
                         ]
                     ]
                 ]
             ]
         );
  }
    
}

Эта функция будет делать GET HTTP-запрос к index функции UserController. Сначала он проверяет, соответствует ли состояние ответа функции response HTTP_OK (200). Он также проверяет, соответствует ли структура ответа API той, что указана в функции. Вы используете '*' => […]его, чтобы указать, что ожидаете, что этот узел будет повторен или пуст.

Затем добавьте тестовый случай, чтобы убедиться, что store функция работает правильно. Добавьте в тестовый случай следующую функцию:

public function testUserIsCreatedSuccessfully() {
    
    $payload = [
        'first_name' => $this->faker->firstName,
        'last_name'  => $this->faker->lastName,
        'email'      => $this->faker->email
    ];
    $this->json('post', 'api/user', $payload)
         ->assertStatus(Response::HTTP_CREATED)
         ->assertJsonStructure(
             [
                 'data' => [
                     'id',
                     'first_name',
                     'last_name',
                     'email',
                     'created_at',
                     'wallet' => [
                         'id',
                         'balance'
                     ]
                 ]
             ]
         );
    $this->assertDatabaseHas('users', $payload);
}

Аналогично первому тесту, вы создали поддельные данные с помощью пакета Faker bundle и сделали запрос к store функции UserController. Вы гарантировали, что код ответа HTTP_CREATED (201) будет возвращен и проверен, чтобы убедиться, что он соответствует ожидаемой структуре ответа. Последнее утверждение должно гарантировать, что пользователь существует в базе данных.

В том же духе вы можете проверить свою функцию show следующим образом:

// ...
// add your import statements to the top
use App\Models\User;
use App\Models\Wallet;

// ...

public function testUserIsShownCorrectly() {
    $user = User::create(
        [
            'first_name' => $this->faker->firstName,
            'last_name'  => $this->faker->lastName,
            'email'      => $this->faker->email
        ]
    );
    Wallet::create(
        [
            'balance' => 0,
            'user_id' => $user->id
        ]
    );
        
    $this->json('get', "api/user/$user->id")
        ->assertStatus(Response::HTTP_OK)
        ->assertExactJson(
            [
                'data' => [
                    'id'         => $user->id,
                    'first_name' => $user->first_name,
                    'last_name'  => $user->last_name,
                    'email'      => $user->email,
                    'created_at' => (string)$user->created_at,
                    'wallet'     => [
                        'id'      => $user->wallet->id,
                        'balance' => $user->wallet->balance
                    ]
                ]
            ]
        );
}

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

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

Давайте добавим еще один тест для нашей destroy функции:

public function testUserIsDestroyed() {
    
    $userData =
        [
            'first_name' => $this->faker->firstName,
            'last_name'  => $this->faker->lastName,
            'email'      => $this->faker->email
        ];
    $user = User::create(
        $userData
    );
    
    $this->json('delete', "api/user/$user->id")
         ->assertNoContent();
    $this->assertDatabaseMissing('users', $userData);
}

Эта функция проверяет, что API не возвращает никаких данных в ответе. Он также проверяет, что код ответа HTTP_NO_CONTENT является кодом ответа (204). У нас также есть утверждение, чтобы гарантировать, что пользователь был удален из базы данных.

Давайте напишем тест для нашей update функции:

public function testUpdateUserReturnsCorrectData() {
    $user = User::create(
        [
            'first_name' => $this->faker->firstName,
            'last_name'  => $this->faker->lastName,
            'email'      => $this->faker->email
        ]
    );
    Wallet::create(
        [
            'balance' => 0,
            'user_id' => $user->id
        ]
    );
        
    $payload = [
        'first_name' => $this->faker->firstName,
        'last_name'  => $this->faker->lastName,
        'email'      => $this->faker->email
    ];
        
    $this->json('put', "api/user/$user->id", $payload)
        ->assertStatus(Response::HTTP_OK)
        ->assertExactJson(
            [
                'data' => [
                    'id'         => $user->id,
                    'first_name' => $payload['first_name'],
                    'last_name'  => $payload['last_name'],
                    'email'      => $payload['email'],
                    'created_at' => (string)$user->created_at,
                    'wallet'     => [
                        'id'      => $user->wallet->id,
                        'balance' => $user->wallet->balance
                    ]
                ]
            ]
        );
}

В этом тесте мы создаем  User и Wallet, а затем пытаемся обновить имя, фамилию и адрес электронной почты пользователя. Аналогично нашим предыдущим тестовым случаям, мы проверяем, что ответ HTTP_OK (200) возвращается и что содержание ответа является правильным.

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

// ...
// Include import statements at the top
use App\Models\Investment;
use App\Models\Strategy;
// ...

public function testGetInvestmentsForUser() {
    
    $user = User::create([
            'first_name' => $this->faker->firstName,
            'last_name'  => $this->faker->lastName,
            'email'      => $this->faker->email
        ]
    );
    
    $strategy = Strategy::create(
        Strategy::factory()->create()->getAttributes()
    );
    $isSuccessful = $this->faker->boolean;
    $investmentAmount = $this->faker->randomNumber(6);
    $investmentReturns = $isSuccessful ?
        $investmentAmount * $strategy->yield :
        $investmentAmount * $strategy->relief;
    $investment = Investment::create(
        [
            'user_id'     => $user->id,
            'strategy_id' => $strategy->id,
            'successful'  => $isSuccessful,
            'amount'      => $investmentAmount,
            'returns'     => $investmentReturns
        ]
    );
    
    $this->json('get', "api/user/$user->id/investments")
         ->assertStatus(Response::HTTP_OK)
         ->assertJson(
             [
                'data' => [
                    [
                        'id'          => $investment->id,
                        'user_id'     => $investment->user->id,
                        'strategy_id' => $investment->strategy->id,
                        'successful'  => (bool)$investment->successful,
                        'amount'      => $investment->amount,
                        'returns'     => $investment->returns,
                        'created_at'  => (string)$investment->created_at,
                    ]
                ]
             ]
         );
}

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

В этой функции мы создаем новый User, Strategy, и Investment. Затем мы проверяем, что он возвращается (в правильной структуре, с правильными данными), когда мы делаем запрос к investments функции UserController.

Запустите все свои тесты:

composer test

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

Результат теста Laravel проходит

Давайте взглянем на наш отчет. Откройте tests/coverage/index.html в своем браузере и перейдите к Controllers.

Детали покрытия теста Laravel

UserController теперь имеет полный охват. Однако означает ли это, что наш код не содержит ошибок?

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

Давайте добавим тестовые случаи для тех случаев , когда мы пытаемся вызвать методж showupdate или delete для отсутствующего пользователя. В каждом случае мы хотим вернуть HTTP_NOT_FOUND код ошибки (404) и убедиться, что наш ответ содержит error запись.

public function testShowForMissingUser() {
    
    $this->json('get', "api/user/0")
         ->assertStatus(Response::HTTP_NOT_FOUND)
         ->assertJsonStructure(['error']);
    
}
    
public function testUpdateForMissingUser() {
    
    $payload = [
        'first_name' => $this->faker->firstName,
        'last_name'  => $this->faker->lastName,
        'email'      => $this->faker->email
    ];
    
    $this->json('put', 'api/user/0', $payload)
         ->assertStatus(Response::HTTP_NOT_FOUND)
         ->assertJsonStructure(['error']);
}
    
public function testDestroyForMissingUser() {
    
    $this->json('delete', 'api/user/0')
         ->assertStatus(Response::HTTP_NOT_FOUND)
         ->assertJsonStructure(['error']);
}

Давайте также добавим тестовый случай, чтобы проверить, что когда запрос, отправленный в store функцию, не содержит некоторых необходимых данных, HTTP_BAD_REQUEST пользователю возвращается код ответа (400).

public function testStoreWithMissingData() {
    
    $payload = [
        'first_name' => $this->faker->firstName,
        'last_name'  => $this->faker->lastName
        //email address is missing
    ];
    $this->json('post', 'api/user', $payload)
         ->assertStatus(Response::HTTP_BAD_REQUEST)
         ->assertJsonStructure(['error']);
}

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

public function testStoredUserHasEmptyWallet() {
    
    $payload = [
        'first_name' => $this->faker->firstName,
        'last_name'  => $this->faker->lastName,
        'email'      => $this->faker->email
    ];
    
    $apiResponse = $this
        ->json('post', 'api/user', $payload)
        ->getContent();
    
    $userData = json_decode($apiResponse, true)['data'];
    $walletDetails = $userData['wallet'];
    
    $this->assertEquals(0, $walletDetails['balance']);
}

Запустите свои тесты еще раз и проверьте результаты:

Результат теста Laravel отказ от терминала

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

Обновление приложения для прохождения тестов

Прежде чем обновлять код, давайте посмотрим, что происходит. Запустите свой сервер и либо с помощью браузера, либо с помощью postman перейдите по http://localhost:8000/api/user/0 ссылке . Вы увидите, что приложение возвращает HTML-ответ, когда пользователь не найден. Вам нужно переопределить ответ по умолчанию для тех случаев, когда модель с указанным идентификатором не может быть найдена.

Для этого откройте app/Exceptions/Handler.php файл и обновите render функцию, чтобы она соответствовала следующему:

use Illuminate\Http\Response;

// ...

public function render($request, Throwable $exception) {
    
    if ($exception instanceof ModelNotFoundException) {
        return response()->json(
            [
                'error' => 'Resource not found'
            ],
            Response::HTTP_NOT_FOUND
        );
    }
    
    return parent::render($request, $exception);
}

Это означает, что каждый раз, когда возникает это исключение, ответ JSON будет возвращен с записью, описывающей ошибку.

Проведите свои тесты еще раз. Вы увидите, что обработка ModelNotFoundException разрешила 3 из наших предыдущих 5 неудач.

Далее, давайте попробуем исправить наш код так, чтобы он прошел testStoredUserHasEmptyWallet тесты.  Откройте app/Http/Controllers/UserController.php файл и перейдите к store функции. Вы увидите, что кошелек пользователя создан с балансом 100 вместо 0. Подобные ошибки часто встречаются в промышленности, и именно поэтому мы проводим тесты, чтобы убедиться, что мы поймаем их, прежде чем они пойдут в производство. Измените его на 0 и снова запустите тест.

Наконец, давайте исправим наш код, чтобы пройти testStoreWithMissingData тест. Откройте app/Http/Controllers/UserController.php файл и перейдите к store функции. Мы проверим, являются ли first_name last_name email null и если да, то вернем ответ на ошибку. Обновите свою store функцию, чтобы она соответствовала следующим требованиям:

use Illuminate\Http\Response;

// ...

public function store(Request $request) {
    
    $firstName = $request->input('first_name');
    $lastName = $request->input('last_name');
    $email = $request->input('email');
    
    if (is_null($firstName) || is_null($lastName) || is_null($email)) {
        return $this->errorResponse(
            'First Name, Last Name and Email are required',
            Response::HTTP_BAD_REQUEST
        );
    }
    
    $user = User::create(
        [
            'first_name' => $firstName,
            'last_name'  => $lastName,
            'email'      => $email
        ]
    );
    
    Wallet::create(
        [
            'balance' => 0,
            'user_id' => $user->id
        ]
    );
    
    return $this->successResponse(
        new UserResource($user),
        true
    );
}

Проведите свои тесты еще раз. На этот раз наш код проходит все тесты, и у нас также есть 100% покрытие кода для нашей UserController и User модели.

Тестирование других конечных точек

Завершив тесты для UserController, давайте продолжим делать то же самое для других наших контроллеров. В tests/Controllers каталоге создайте новый файл с именем InvestmentControllerTests.php. В вашем случае tests/Controllers/InvestmentControllerTests.php добавьте следующее:

<?php
    
namespace Tests\Controllers;
    
use App\Models\Investment;
use App\Models\Strategy;
use App\Models\User;
use Illuminate\Http\Response;
use Tests\TestCase;
    
class InvestmentControllerTests extends TestCase {
    
    public function testIndexReturnsDataInValidFormat() {
        $this->json('get', 'api/investment')
            ->assertStatus(Response::HTTP_OK)
            ->assertJsonStructure(
            [
                'data' => [
                    '*' => [
                        'id',
                        'user_id',
                        'strategy_id',
                        'successful',
                        'amount',
                        'returns',
                        'created_at',
                    ]
                ]
            ]
            );
    }
    
    public function testInvestmentIsCreatedSuccessfully() {
        $user = User::create(User::factory()->make()->getAttributes());
        $strategy = Strategy::create(Strategy::factory()->make()->getAttributes());
        $payload = [
            'user_id'     => $user->id,
            'strategy_id' => $strategy->id,
            'amount'      => $this->faker->randomNumber(4)
        ];
        $this->json('post', 'api/investment', $payload)
             ->assertStatus(Response::HTTP_CREATED)
             ->assertJsonStructure(
                 [
                     'data' => [
                         'id',
                         'user_id',
                         'strategy_id',
                         'successful',
                         'amount',
                         'returns',
                         'created_at',
                     ]
                 ]
             );
        $this->assertDatabaseHas('investments', $payload);
    }
    
    public function testStoreWithMissingData() {
        $payload = [
            'amount' => $this->faker->randomNumber(4)
        ];
        $this->json('post', 'api/investment', $payload)
            ->assertStatus(Response::HTTP_BAD_REQUEST)
            ->assertJsonStructure(['error']);
    }
    
    public function testStoreWithMissingUserAndStrategy() {
    
        $payload = [
            'user_id'     => 0,
            'strategy_id' => 0,
            'amount'      => $this->faker->randomNumber(4)
        ];
        $this->json('post', 'api/investment', $payload)
            ->assertStatus(Response::HTTP_NOT_FOUND)
            ->assertJsonStructure(['error']);
    }
    
    public function testInvestmentIsShownCorrectly() {

        $user = User::create(User::factory()->make()->getAttributes());
        $strategy = Strategy::create(Strategy::factory()->make()->getAttributes());
        $isSuccessful = $this->faker->boolean;
        $investmentAmount = $this->faker->randomNumber(6);
        $investmentReturns = $isSuccessful ?
            $investmentAmount * $strategy->yield :
            $investmentAmount * $strategy->relief;
        $investment = Investment::create(
            [
                'user_id'     => $user->id,
                'strategy_id' => $strategy->id,
                'successful'  => $isSuccessful,
                'amount'      => $investmentAmount,
                'returns'     => $investmentReturns
            ]
        );
    
        $this->json('get', "api/investment/$investment->id")
             ->assertStatus(Response::HTTP_OK)
             ->assertExactJson(
                [
                    'data' => [
                        'id'          => $investment->id,
                        'user_id'     => $investment->user->id,
                        'strategy_id' => $investment->strategy->id,
                        'successful'  => $isSuccessful,
                        'amount'      => round($investment->amount, 2, PHP_ROUND_HALF_UP),
                        'returns'     => round($investment->returns, 2, PHP_ROUND_HALF_UP),
                        'created_at'  => (string)$investment->created_at,
                    ]
                ]
             );
    }
    
    public function testShowMissingInvestment() {

        $this->json('get', "api/investment/0")
            ->assertStatus(Response::HTTP_NOT_FOUND)
            ->assertJsonStructure(['error']);
    }
    
    public function testDestroyInvestment() {

        $user = User::create(User::factory()->make()->getAttributes());
        $strategy = Strategy::create(Strategy::factory()->make()->getAttributes());
        $isSuccessful = $this->faker->boolean;
        $investmentAmount = $this->faker->randomNumber(6);
        $investmentReturns = $isSuccessful ?
            $investmentAmount * $strategy->yield :
            $investmentAmount * $strategy->relief;
        $investment = Investment::create(
            [
                'user_id'     => $user->id,
                'strategy_id' => $strategy->id,
                'successful'  => $isSuccessful,
                'amount'      => $investmentAmount,
                'returns'     => $investmentReturns
            ]
        );
        $this->json('delete', "api/investment/$investment->id")
            ->assertStatus(Response::HTTP_UNAUTHORIZED);
    }
    
    public function testUpdateInvestment() {

        $user = User::create(User::factory()->make()->getAttributes());
        $strategy = Strategy::create(Strategy::factory()->make()->getAttributes());
    
        $isSuccessful = $this->faker->boolean;
        $investmentAmount = $this->faker->randomNumber(6);
        $investmentReturns = $isSuccessful ?
            $investmentAmount * $strategy->yield :
            $investmentAmount * $strategy->relief;
        $investment = Investment::create(
            [
                'user_id'     => $user->id,
                'strategy_id' => $strategy->id,
                'successful'  => $isSuccessful,
                'amount'      => $investmentAmount,
                'returns'     => $investmentReturns
            ]
        );
        $payload = [
            'id'         => $investment->id,
            'successful' => !$isSuccessful
        ];
    
        $this->json('put', "api/investment/$investment->id", $payload)
             ->assertStatus(Response::HTTP_UNAUTHORIZED);
    }
}

Наш InvestmentController проходит большинство тестовых случаев, кроме testStoreWithMissingUserAndStrategy и testStoreWithMissingData тестов. Чтобы исправить это, давайте обновим store функцию в нашем app/Http/Controllers/InvestmentController.php файле:

use App\Models\User;

// ...

public function store(Request $request) {
    
    $userId = $request->input('user_id');
    $strategyId = $request->input('strategy_id');
    $amount = $request->input('amount');
    
    if (is_null($userId) || is_null($strategyId) || is_null($amount)) {
        return $this->errorResponse(
            'User ID, Strategy ID and Amount are required',
            Response::HTTP_BAD_REQUEST
        );
    }
    
    $strategy = Strategy::findOrFail($strategyId);
    $user = User::findOrFail($userId);
    
    $investment = [
        'user_id'     => $user->id,
        'strategy_id' => $strategy->id,
        'amount'      => $amount
    ];
    
    $successful = (bool)random_int(0, 1);
    $investment['successful'] = $successful;
    
    $multiplier = $successful ?
        $strategy->yield :
        $strategy->relief;
    $investment['returns'] = $amount * $multiplier;
    $investment = Investment::create($investment);
    
    return $this->successResponse(
        new InvestmentResource($investment),
        true
    );
    
}

Как и в предыдущем случае UserController , мы проверяем, чтобы убедиться, что все необходимые поля предоставлены, прежде чем пытаться создать новую Investment модель. Мы также используем findOrFail функцию, чтобы убедиться, что userIdstrategyId предоставленны и действительно соответствуют сохраненному пользователю и стратегии в нашей базе данных. Если таковых не существует, будет создано исключение ModelNotFound. Поскольку мы уже настроили наш обработчик для прослушивания этого исключения, наш API изящно завершит работу и вернет HTTP_NOT_FOUND код ошибки (404) вместе с сообщением об ошибке.

Наконец, у нас есть тесты для наших StrategyController. Создайте еще один файл в tests/Controllers/StrategyControllerTests.php.

<?php
    
namespace Tests\Controllers;
    
use App\Models\Strategy;
use Illuminate\Http\Response;
use Tests\TestCase;
    
class StrategyControllerTests extends TestCase {
    
    public function testIndexReturnsDataInValidFormat() {
    
        $this->json('get', 'api/strategy')
             ->assertStatus(Response::HTTP_OK)
             ->assertJsonStructure(
                [
                    'data' => [
                        '*' => [
                            'id',
                            'type',
                            'tenure',
                            'yield',
                            'relief',
                            'investments' => [
                                '*' => [
                                    'id',
                                    'user_id',
                                    'strategy_id',
                                    'successful',
                                    'amount',
                                    'returns',
                                    'created_at',
                                ]
                            ],
                            'created_at',
                        ]
                    ]
                ]
             );
    }
    
    public function testStrategyIsCreatedSuccessfully() {
    
        $payload = Strategy::factory()->make()->getAttributes();
        $this->json('post', 'api/strategy', $payload)
             ->assertStatus(Response::HTTP_CREATED)->assertJsonStructure(
                [
                    'data' => [
                        'id',
                        'type',
                        'tenure',
                        'yield',
                        'relief',
                        'investments',
                        'created_at',
                    ]
                ]
            );
        $this->assertDatabaseHas('strategies', $payload);
    }
    
    public function testStrategyIsShownCorrectly() {

        $strategy = Strategy::create(Strategy::factory()->make()->getAttributes());
        $this->json('get', "api/strategy/$strategy->id")
            ->assertStatus(Response::HTTP_OK)
            ->assertExactJson(
            [
                'data' => [
                    'id'          => $strategy->id,
                    'type'        => $strategy->type,
                    'tenure'      => $strategy->tenure,
                    'yield'       => round($strategy->yield, 2, PHP_ROUND_HALF_UP),
                    'relief'      => round($strategy->relief, 2, PHP_ROUND_HALF_UP),
                    'investments' => $strategy->investments,
                    'created_at'  => (string)$strategy->created_at,
                ]
            ]
            );
    }
    
    public function testUpdateMissingStrategy() {
    
        $this->json('put', 'api/strategy/0', Strategy::factory()->make()->getAttributes())
            ->assertStatus(Response::HTTP_NOT_FOUND)
            ->assertJsonStructure(['error']);
    }
    
    public function testDestroyMissingStrategy() {
    
        $this->json('delete', 'api/strategy/0')
            ->assertStatus(Response::HTTP_NOT_FOUND)
            ->assertJsonStructure(['error']);
    }
    
    public function testStrategyIsUpdatedSuccessfully() {
    
        $strategy = Strategy::create(Strategy::factory()->make()->getAttributes());
        $payload = Strategy::factory()->make();
    
        $this->json('put', "api/strategy/$strategy->id", $payload->getAttributes())
             ->assertStatus(Response::HTTP_OK)
             ->assertExactJson(
                [
                    'data' => [
                        'id'          => $strategy->id,
                        'type'        => $payload['type'],
                        'tenure'      => $payload['tenure'],
                        'yield'       => round($payload['yield'], 2, PHP_ROUND_HALF_UP),
                        'relief'      => round($payload['relief'], 2, PHP_ROUND_HALF_UP),
                        'investments' => $strategy->investments,
                        'created_at'  => (string)$strategy->created_at,
                    ]
                ]
             );
    }
    
    public function testStrategyIsDestroyedSuccessfully() {
    
        $strategyAttributes = Strategy::factory()->make()->getAttributes();
        $strategy = Strategy::create($strategyAttributes);
        $this->json('delete', "api/strategy/$strategy->id")
            ->assertStatus(Response::HTTP_NO_CONTENT)
            ->assertNoContent();
        $this->assertDatabaseMissing('strategies', $strategyAttributes);
    }
}

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

Вывод

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

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

  1. Напишите тестовый случай для вашего улучшения (имитированный запрос и ожидаемый ответ).
  2. Запустите свои тесты ( composer test)
  3. Если какой-либо из ваших тестов провалится, напишите ровно столько кода, чтобы тест прошел успешно
  4. Посмотрите, сможете ли вы сделать свой код более понятным
  5. Запустите свои тесты еще раз

Тестирование – это необходимость, от которой мы не можем избавиться, если хотим создавать надежные приложения. Полное принятие TDD требует изменения менталитета. Вместо того чтобы говорить себе: “я перейду этот мост, когда доберусь туда”, спросите себя: “какие мосты я должен пересечь и как я должен их пересечь?”.

Olususi Oluyemi Инженер-Программист 

Рейтинг
( 3 оценки, среднее 5 из 5 )
Maxyc Webber/ автор статьи
Мне 35 лет. Опыт профессиональной разработки 15 лет. Занимаюсь разработкой и поддержкой корпоративных систем автоматизации бизнеса, а также высоконагруженными проектами. Мне нравится решать нестандартные проблемы бизнеса. Имею опыт формирования команд под проект, налаживания процесса разработки, коммуникации программистов и заказчиков. Есть опыт работы с зарубежными заказчиками (ОАЭ, Польша, Германия, Швейцария).
Понравилась статья? Поделиться с друзьями:
Комментарии: 2
  1. Ярослав

    Подскажите из за чего может быть причина того что при запуске composer test выдает следующее: Generating code coverage report in HTML format … Class “App\Models\Model” not found. Пытаюсь приделать к существующему проекту… на примере с данного сайта все работает как надо

    1. Maxyc Webber (автор)

      в тексте ошибки у вас написан ответ. Модель не найдена. проверьте ваш код, верно ли все написано. Запусакается ли проверяемый код руками?

Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!:

Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.