Перестаньте использовать SQLite в Unit-тестах
Предисловие от переводчика
Несмотря на то, что статья была написана почти 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