Предисловие от переводчика

Несмотря на то, что статья была написана почти 3 года назад, она абсолютно не потеряла актуальности. SQLite по прежнему не поддерживает часть базовых функций старших СУБД (MySQL, PostgeSQL).

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

Также не стоит воспринимать статью как критику SQLite и попытку доказать что MySQL/PostgeSQL лучше. У каждой из этих СУБД есть своя сфера применения. Суть статьи - исполнение тестов в том же окружении, что и production

TLDR; Использование Sqlite в Laravel (или любых других PHP приложениях) для Unit-тестирования может привести к false positive результатам тестов. Тот код который пройдет тесты, не заработает после переезда в production и использования других БД, например, MySQL. Вместо этого разверните тестовую БД с использованием той же технологии и движка, которые будут использоваться вашим приложением в production.

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

Один из механизмов, которые Laravel предлагает для Unit-тестов, основан на использовании базы данных SQLite. Для ускорения выполнения тестов, база данных запускается непосредственно в оперативной памяти. Такое решение работает в 95% случаев. Но, дьявол кроется в деталях, в этих 5%.

Поговорим о причинах, почему это не лучший выбор. Для этой настройки я использую совершенно новое приложение Laravel 6 (v6.4.1) и SQLite для MacOS (v3.29.0). Я настроил PHPUnit, добавив следующую строку в ключ <php> файла phpunit.xml:

<server name="DB_CONNECTION" value="testing"/>

В файле config/database.php используется следующая конфигурация:

'testing' => [
  'driver' => 'sqlite',
  'database' => ':memory:',
  'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
],

Проблема 1: типизация

Безопасность типов или точность типов очень важны. Разработчики PHP (и других языков, таких как Javascript, где есть приведение типов) могут почувствовать ложное спокойствие, потому что 6, почти то же самое что и "6"? Однако это становится проблемой, если учесть, что 4,5 не должно равняться 4.

Одно дело - равенство, но совсем другое - целостность данных. В примере ниже я хочу хранить информацию об автомобилях. Я хочу знать, удобно ли попасть в выбранный автомобиль. Очевидно, что в 2-дверные машины сесть труднее, чем в 4-дверные. Давайте посмотрим на модель:

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;

class Car
{
  public function isEasyToGetInto(): bool
  {
    return $this->doors > 2;
  }
}

А также на миграцию этой модели:

<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateCarsTable extends Migration
{
  public function up()
  {
    Schema::create('cars', function (Blueprint $table) {
      $table->bigIncrements('id');
      $table->tinyInteger('doors');
      $table->timestamps();
    });
  }
}

В конце, наш тест, который выполнится успешно.

<?php
namespace Tests\Unit;

use App\Models\Car;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class MyTest extends TestCase
{
  use RefreshDatabase;

  public function testEasyToGetInto(): void
  {
    $userInput = 4.5;

    $car = Car::create([
      'doors' => $userInput
    ]);

    $this->assertTrue($car->isEasyToGetInto());
}

Ура - тест пройден! Но у нас есть одна проблема. Если вы обратите внимание на переменную $userInput, вы заметите, что тип переменной - float. Поле doors в БД имеет тип tiny integer - таким образом мы получаем ошибку типов. SQLite позволяет нам вставлять данные, даже если они имеют неподходящий тип.

Для проверки, вот описание нашей SQLite таблицы:

select sql from sqlite_master where name='cars';
CREATE TABLE "cars" (
  "id" integer not null primary key autoincrement, 
  "doors" integer not null, 
  "created_at" datetime null, 
  "updated_at" datetime null
)

Если мы посмотрим на dump недавно созданной модели, мы увидим следующее:

#original: array:4 [
  "doors" => 4.5
  "updated_at" => "2019-11-01 20:34:09"
  "created_at" => "2019-11-01 20:34:09"
  "id" => 1
]

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

Далее, вы скорее всего будете использовать MySQL в production. В таком случае, на production, всё произойдет не так, как в вашем тесте. Когда вы вытащите данные обратно из БД или создадите новую модель, вы получите значение с типом int (то что и было сохранено).

 #original: array:4 [
  "id" => 1
  "doors" => 4
  "created_at" => "2019-11-01 20:38:37"
  "updated_at" => "2019-11-01 20:38:37"
]

Нарушение целостности данных может вызвать множество проблем (как минимум, $userInput более не равен $car->doors)

Проблема 2: Длина строк

Длина строки, указанная при создании поля, игнорируется SQLite. Позвольте мне показать пример того, что может произойти с вашим приложением.

Для начала, наша миграция создает поле trim с длиной 3 символа:

<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateCarsTable extends Migration
{
  public function up()
  {
    Schema::create('cars', function (Blueprint $table) {
      $table->bigIncrements('id');
      $table->string('trim', 3);
      $table->timestamps();
    });
  }
}

Модель выглядит как-то так:

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;

class Car extends Model
{  
  public const TRIM_XLT = 'XLT';
  public const TRIM_SE = 'SE';

  public const TRIMS = [
    self::TRIM_SE,
    self::TRIM_XLT,
  ];
}

В константе Car::TRIMS хранится список возможных комплектаций автомобиля. Далее, мы хотим расширить нашу линейку автомобилей и добавить Sport в качестве премиум комплектации. Также нам необходимо протестировать, имеет ли автомобиль премиальную комплектацию. Давайте изменим модель:

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;

class Car extends Model
{  
  public const TRIM_XLT = 'XLT';
  public const TRIM_SE = 'SE';
  public const TRIM_SPORT = 'Sport';

  public const TRIMS = [
    self::TRIM_SE,
    self::TRIM_XLT,
    self::TRIM_SPORT,
  ];
    
  public function isPremiumTrim(): bool
  {
    return $this->trim === self::TRIM_SPORT;
  }
}

Напишем небольшой тест

<?php
namespace Tests\Unit;

use App\Models\Car;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class MyTest extends TestCase
{
  use RefreshDatabase;

  /**
   * @dataProvider premiumProvider
   */
  public function testIsPremium($trim, $expected): void
  {
    $car = Car::create([
      'trim' => $trim
    ]);
    $this->assertSame($expected, $car->isPremiumTrim());
  }

  public function premiumProvider(): array
  {
    return [
      [Car::TRIM_SE, false],
      [Car::TRIM_XLT, false],
      [Car::TRIM_SPORT, true],
    ];
  }
}

Запуска этого теста с SQLite не покажет ничего, кроме зеленой галочки. Каждый тест пройдет успешно. В таком случае, можем ли мы спокойно добавлять новую комплектацию в production? Нет, не можем!

Что произойдет, когда мы вставим значение Sport в поле trim в нашей БД, при использовании MySQL? Так как поле имеет длину только 3 символа, мы получим ошибку: String data, right truncated: 1406 Data too long for column 'trim' at row 1.

Проблема 3: Внешние ключи

Ранее SQLite совсем не поддерживал внешние ключи (foreign keys). Старички помнят это и из-за этого сразу отказываются от SQLite. На самом деле, этот недостаток уже устранен!

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

Но давайте рассмотрим сценарий, который приведет к сбою. Представьте, что ваш SQLite настроен так, чтобы не использовать внешние ключи (и давайте будем честными, сколько раз вы проверяли, настроен ли он таким образом? … да, я тоже...)

Наша миграция:

<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class BlogTestSetup extends Migration
{
  public function up()
  {
    Schema::create('makes', function (Blueprint $table) {
      $table->bigIncrements('id');
      $table->string('name', 128);
      $table->timestamps();
    });

    Schema::create('cars', function (Blueprint $table) {
      $table->bigIncrements('id');
      $table->unsignedBigInteger('make_id');
      $table->foreign('make_id')->references('id')->on('makes');
      $table->string('model_name', 128);
      $table->timestamps();
    });
  }
}

И наш тест:

<?php
namespace Tests\Unit;

use App\Models\Car;
use App\Models\Make;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class MyTest extends TestCase
{
  use RefreshDatabase;

  public function testSomethingContrived(): void
  {
    $make = Make::create([
      'name' => 'Ford'
    ]);

    //$makeId = $make->id;
    $makeId = 44; // here is our mistake

    $this->assertNotNull(Car::create([
      'make_id' => $makeId,
      'model_name' => 'Pinto',
    ]));
  }
}

Если вы запустите этот тест с отключенными внешними ключами - он пройдет успешно. Совершенно очевидно, что так быть не должно.

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

PRAGMA foreign_keys=on.

Есть и хорошие новости: отключение проверок внешних ключей доступно с помощью Schema::disableForeignKeyConstraints().

Проблема 4: специфические запросы

Eloquent - отличная ORM, но есть некоторые вещи которые он просто не поддерживает. Это могут быть выражения, уникальные только для одной СУБД или они слишком редки или сложны для имплементации в ORM коде. В иных случаях это могут быть запросы, которые вы можете выполнить с помощью Eloquent или Query Builder, но они будут не так эффективны. В таких ситуациях лучше написать "сырое" SQL-выражение.

Если вы видите в своем коде DB::raw() - это знак того, что у вас, скорее всего, будут проблемы с БД, отличающейся от используемой в production.

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

Это совсем другое окружение

ORM, такие как Eloquent, позволяют нам заменять базы данных, но это требуется не так часто. Мы редко перемещаем приложение на другой движок БД, без каких-либо изменений структуры или существенного переписывания кода. Вы можете сменить фреймворк, но, скорее всего, вы останетесь на той же БД (например, MySQL).

Но когда дело доходит до тестов, которые должны быть механизмом страховки и наиболее точным кодом, должны ли мы забыть о специфичности каждой БД? Конечно нет!

Использование SQLite для тестов, если вы не используете его на production — это совсем другое окружение:

  • Скорость разная. (использование MySQL по сравнению с SQLite не намного медленнее при выполнении ваших Unit-тестов)

  • Стиль подключения отличается (если вы когда-либо сражались с localhost и 127.0.0.1 в MySQL, представьте, что тогда использование совершенно другого движка БД - еще большая сложность).

И, скорее всего, вы уже настроили одну базу данных, похожую на productionдля своей разработки.

Если вы используете что-то вроде Valet, настройка очень проста. Просто откройте phpMyAdmin и создайте еще одну БД. Готово!. А Homestead? Просто — просто добавьте еще одну строку в ключ databases файла Homestead.yaml. Используете Docker Compose? Просто продублируйте свой контейнер MySQL и измените имя контейнера.

Для профессионального разработчика не должно быть никаких оправданий.

Заключение

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

Вы можете проверить список SQL Features That SQLite Does Not Implement или получить больше информации на странице "причуды" SQLite на официальном сайте с документацией SQLite.

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

Дополнение от переводчика

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

  • удаление/изменение foreign key (возможно только при помощи удаления всей таблицы и создания её заново)

  • создание полнотекстовых индексов

  • удаление нескольких колонок в таблице одним запросом

Использование MySQL для тестов

В случае использования окружения Laravel Sail для локальной разработки, для использования MySQL при запуске тестов необходимо:

Создать файл .env.testing:

APP_KEY=base64:Ivsght96azGOdwVzOjwPZMY3BrlFrgzUiKPq4eOlJCM=

CACHE_DRIVER=array
MAIL_MAILER=array
QUEUE_CONNECTION=sync
SESSION_DRIVER=array
TELESCOPE_ENABLED=false

# ENV-переменные используемые Laravel для подключения к БД
DB_HOST=mysql-autotest
DB_DATABASE=db
DB_USERNAME=root
DB_PASSWORD=password

# ENV-переменные используемые при старте MySQL (MariaDB)
# для создания пользователей и БД
MARIADB_ROOT_PASSWORD=${DB_PASSWORD}
MARIADB_DATABASE=${DB_DATABASE}
MARIADB_PASSWORD=${DB_PASSWORD}
MARIADB_USER=db
MARIADB_ROOT_HOST='%'
MYSQL_ALLOW_EMPTY_PASSWORD=yes

В phpunit.xml указать используемое окружение:

...
<php>
  <server name="APP_ENV" value="testing"/>
</php>
...

Добавить Docker-контейнер с тестовой БД:

...
    mysql-autotest:
        image: mariadb:10.8.2
        env_file:
            - .env.testing
        networks:
            - sail
...

Запуск тестов в Gitlab CI с использованием MySQL:

phpunit:
  stage: test
  script:
    - php artisan test
  variables:
    MYSQL_DATABASE: db
    MYSQL_ROOT_PASSWORD: password
  services:
    - name: mariadb:10.8.2
      alias: mysql-autotest

Комментарии (13)


  1. v3shin
    06.10.2022 14:36
    +2

    Как я понял, вся статья сводится к принципу "используй в тестах то же окружение, что и на проде".
    Кстати, опечатку нашел:
    > SQLite также содержит несколько других вещей, которые SQLite не поддерживает


    1. KravetsV Автор
      06.10.2022 14:47

      В целом - да, именно это и вынесено в TLDR в самом начале статьи. Но статья раскрывает в деталях - с какими проблемами можно столкнуться - если не следовать этому правилу.

      По поводу замечания - спасибо! Переформулировал предложение в переводе


      1. v3shin
        06.10.2022 15:35

        Упс, и точно: все прочитал, кроме этого предложения. =)


    1. domix32
      06.10.2022 20:58
      +1

      Самое забавное, что статья рассказывает о том что PHP не умеет обеспечивать типобезопасность из коробки, поэтому SQLite виноват.


  1. Tuki_Tip
    06.10.2022 17:45
    +4

    Про одинаковое окружение говорят в тех же 12factor, но, понимать это требование буквально, такое себе занятие ИМХО. Есть важные дядьки, такие как Бек, Мартин, Фаулер. Они в унисон твердят о том, что тетсирование это основа разработки ПО и тесты должны запускаться максимально быстро и просто и проходить должны тоже максимально быстро. Для этого мы(разработчики) выстраиваем пирамиды тестирования, мокаем не важные для конкретного теста зависимости и т.д.

    Если для запусков тестов у вас должны быть запущены контейнеры с MySQL/PostgreSQL, Redis и проими, то это не то, о чем говорят вышеперечисленные дядечьки. На Apple M1, например, докер работает в 3-5 раз медленнее и те же 1000 тестов будут гнаться пол минуты минимум.. а с учетом того, что в обычном режиме тесты запускаются минимум раз в минуту (привет TDD), то скорость разработки будет оставлять желать лучшего.

    Я для себя нашел одно решение и пока оно меня не подводит. При локальной разработке гоняю тесты на SQLite. Но в CI/CD при каждом пуше в remote, прогоняются тесты на той БД, которая в проде. Таким образом тесты гоняются быстро при разработке и требование 12factor удовлетворяется.

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


    1. KravetsV Автор
      06.10.2022 17:52
      -2

      В целом согласен, довольно взвешенная точка зрения.

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

      if (DB::getDriverName() != 'sqlite') {
         ...
      }


      1. Desprit
        06.10.2022 18:18
        +5

        И потом незаметно весь проект превращается в эти леса костылей :D


        1. KravetsV Автор
          06.10.2022 18:31

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

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


      1. Tuki_Tip
        06.10.2022 20:07
        +6

        Стоит отметить, что ни бизнес логика, ни слой приложения, ни инфраструктурный слой и ни какой другой слой вашего приложения, не должны знать о том, в каком окружении они запущены. Это все конфигурируется конфигами перед запуском (переменные окружения, механизм конфигов и т.д.). Об этом также явно говорят все те же 12factor. Поэтому ваш костыль делает архитектуру менее гибкой, плодит "особые" знания для новых разработчиков проекта или, если сказать проще, удорожает поддержку и развитие кодовой базы. На старте проекта это не критично, но "теорию разбитых стекл" никто не отменял и вскоре кодовая база может обрасти такими костылями по самое немогу. В вашем случае, нужно не просто делать одинаково, а вынести эти костыли в одно место и попытаться соблюсти наш любимый DRY.

        Что касается миграций, то это механизмы другого порядка и никакого отношения к приложению не имеют. Приложение ожидает уже готовую к использованию БД со всеми индексами, таблицами и т.д. Это справедливо и для unit-тестов. Механизмы миграции лучше разрабатывать и тестировать отдельно от приложения, если вы не хотите использовать сторонние, готовые, протестированные решения. Миграции накатываются перед запуском приложения. Отмечу, что код таких механизмов может храниться и рядом с приложением, но не быть его частью. Т.е. миграции не должны влиять на работу приложения напрямую.


  1. webdevium
    06.10.2022 18:24
    +4

    В тестах можно указывать теги группировки.
    Тогда можно одной командой прогонять тесты для группы sqlite, а совсем другой запускать тесты для production-like базы.

    Профит в том, что базовая функциональность тестируется молниеносно быстро, а уже на стороне CI/CD прогон идет по всем тестам. Как говорится, для gitlab-runner времени не жалко.

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


    1. mvs
      06.10.2022 18:30
      +1

      Согласен, большинство ошибок, указанных в статье, должны решаться валидацией данных, а не надеждой на ДБ


  1. atoshin
    07.10.2022 11:29
    -1

    Перестаньте использовать библиотеки, не умеющие абстрагировать от вас детали БД. Да и в целом, на PHP писать перестаньте.

    Ну и, на секундочку, SQLite - одна из самых высокопроизводительных БД.


    1. Tsimur_S
      08.10.2022 21:54

      Перестаньте использовать библиотеки, не умеющие абстрагировать от вас детали БД.

      en.wikipedia.org/wiki/Leaky_abstraction

      Да и в целом, на PHP писать перестаньте.

      Мыши и мудрый филин
      В одном лесу жил мудрый Филин, к которому все за советом обращались. И вот как-то приходит к нему Мышь и говорит:
      — Филин, ты такой мудрый! Скажи, как нам, мышам, выжить в этих ужасных лесных условиях, когда мы такие маленькие и все за нами постоянно охотятся?
      Филин подумал и ответил, глядя куда-то вдаль:
      — Вам, мыши, надо стать ёжиками!
      — Гениально! — воскликнула Мышь и убежала.

      Проходит несколько минут, Мышь снова возвращается к Филину:
      — Слушай, Филин. А как нам стать ёжиками?
      — Иди-ка ты отсюда, Мышь, — с той же задумчивостью произнёс Филин. — Я стратег, а не тактик!


      Ну и, на секундочку, SQLite — одна из самых высокопроизводительных БД.

      1) СУБД
      2) «Одна из» это из скольки?
      3) высокопроизводительная это дает больше RPS чем другие СУБД на равном железе или тут какое-то другое понимание производительности?