Хочу поведать о своей библиотеке для написания тестов в транзакциях при работе с MySQL.
Я люблю писать тесты для своего кода, но при этом не люблю писать моки и всю необходимую для них обвязку. Особенно это касается базы данных, ибо если замокать вызовы внешних сервисов и очереди сообщений еще не так сложно, то с БД все гораздо сложнее. Взаимодействие с базой данных обычно довольно «богатое», это ведет к тому, что приходится писать много хрупких и утомительных моков/стабов, и при этом сами запросы к БД не покрываются тестами (а там зачастую могут таиться ошибки, связанные с некорректными запросами или ошибками миграции схемы).
Выход здесь — это использовать для тестов реальную БД c данными, которые мы помещаем в нее перед запуском всех или одного конкретного теста. Но тут возникает вопрос, как восстанавливать состояние в базе данных после прохождение конкретного теста?
Подходов для решения несколько:
-
перезапускать контейнер с БД перед каждым тестом для восстановления состояния
-
использовать
TRUNCATE -
писать код для загрузки и очищения/восстановления данных после прохождения каждого теста
Первый подход слишком тяжеловесный для прогона тестов в параллель бесконфликтно, ведь придется запускать множество контейнеров в параллель. Второй подход быстрее, чем предыдущий, но TRUNCATE очищает вообще все данные в таблице, что создает проблемы при запуске тестов в параллель, которые работают с одними и теми же таблицами, что может привести к конфликтам. Кроме того, если используются FOREIGN KEY их придется включать/отключать перед вызовом TRUNCATE. И про ручное восстановление данных в БД перед запуском тестов тоже надо не забывать. Последний подход требует написания много бойлерплейт кода, причем в случае падения теста мы можем получить «грязные» данные в БД.
Но есть другой подход, это оборачивать тесты в транзакции и после выполнения каждого теста откатывать транзакцию. Этот подход не то чтобы новый, он используется в других языках и платформах, таких как Ruby on Rails, Laravel и Spring, но при этом в Node js почти не распространен. В проектах, где я работаю, часто используется MySQL и разные ORM/ query builder для него, но писать имплементацию transactional tests под каждую библиотеку не хотелось, а где-то это и невозможно из-за особенности их api. Выход был найден в виде того, что большинство ORM и query builder используют один из двух пакетов, mysql или mysql2 в качестве клиентов, а следовательно, достаточно будет пропатчить их для того, чтобы сделать поддержку транзакционных тестов для всех библиотек, использующих эти драйвера.
Реализация
Устанавливаем библиотеку и необходимый драйвер для ORM/Query builder:
npm i mysql-transactional-tests -S; # устанавливаем один из двух драйверов npm i mysql -S; npm i mysql2 -S;
Затем напишем простой тест, который проверяет добавление нового сотрудника в таблицу employee :
/** Необходимо импортировать раньше, модуля, который создает подключение к БД (mysql driver) **/ import { startTransaction, unPatch } from 'mysql-transactional-tests/mysql'; /** Необходимо импортировать раньше, модуля, который создает подключение к БД (mysql2 driver) **/ // import { startTransaction, unPatch } from 'mysql-transactional-tests/mysql2'; import MySQLClient from '../client/mysql_client'; const mysqlConfig = require('../mysql.config.json'); const dbName = mysqlConfig.database; const mysqlClient = new MySQLClient(mysqlConfig); describe('[mysql]: transactional test', () => { let rollback: () => Promise<void>; beforeEach(async () => { // Создаем отдельную транзакцию для каждого теста ({ rollback } = await startTransaction()); }); afterEach(async () => { // Откатываем транзакцию после прохождения каждого теста await rollback(); }); afterAll(async () => { // Закрываем соединение и возвращаем драйвер к исходному состоянию mysqlClient.close(); unPatch(); }); it('create employee', async () => { await mysqlClient.query( `INSERT INTO ${dbName}.employee SET first_name='John', last_name='Brown', age=35, sex='man', income=23405`, ); const result = await mysqlClient.query(`SELECT * FROM ${dbName}.employee`); expect(result).toHaveLength(4); }); });
В зависимости от типа используемого драйвера, необходимо импортировать соответствующий модуль mysql-transactional-tests/mysql или mysql-transactional-tests/mysql2, и при этом сделать это надо раньше импорта модуля, который создает коннект к БД, для того чтобы была возможность у библиотеки пропатчить драйвер до создания подключения.
Использование транзакций в тестируемом коде поддерживается:
it('create employee with transaction', async () => { const trx = await mysqlClient.beginTransaction(); await trx.query( `INSERT INTO ${dbName}.employee SET first_name='John', last_name='Brown', age=35, sex='man', income=23405`, ); await trx.commit(); const result = await mysqlClient.query(`SELECT * FROM ${dbName}.employee`); expect(result).toHaveLength(4); });
Список поддерживаемых библиотек:
-
и другие клиенты, которые используют
mysqlилиmysql2драйвер
Примеры тестов с разными ORM/Query builder.
Как это работает
Для того чтобы такой подход работал, тест оборачивается в транзакцию. На выходе получится следующий SQL код для теста на добавления сотрудника в таблицу employee:
BEGIN; INSERT INTO employee SET first_name='John', last_name='Brown', age=35, sex='man', income=23405; SELECT * FROM employee; ROLLBACK;
Если тестируемый код содержит транзакции, они будут сконвертированы в savepoints. Операторы BEGIN/START TRANSACTION будут заменены на SAVEPOINT,COMMIT на RELEASE SAVEPOINT,ROLLBACK наROLLBACK TO SAVEPOINT. Таким образом, происходит эмуляция вложенных транзакций.
BEGIN; SAVEPOINT sp_1; INSERT INTO employee SET first_name='John', last_name='Brown', age=35, sex='man', income=23405; RELEASE SAVEPOINT sp_1; SELECT * FROM employee; ROLLBACK;
Ограничения
Относительно редкий кейс, но об этом стоит знать. Вы можете получить ошибку, когда тестируете код, который запускает несколько транзакций, порядок завершения которых не детерминирован:
it('insert: two parallel transcations: one commit, one rollback', async () => { const [ trx1, trx2 ] = await Promise.all([ mysqlClient.beginTransaction(), mysqlClient.beginTransaction(), ]); await trx1.query( `INSERT INTO ${dbName}.employee SET first_name='John', last_name='Brown', age=35, sex='man', income=23405`, ); await trx2.query( `INSERT INTO ${dbName}.employee SET first_name='Matthew', last_name='Black', age=45, sex='woman', income=11000`, ); // ❌ Может произойти ошибка, если commit пройзодет раньше rollback await Promise.all([ trx1.commit, trx2.rollback, ]); });
Происходит это из-за того, что транзакции конвертируются в savepoints, а все sql запросы идут в рамках одной глобальной транзакции.
BEGIN; SAVEPOINT sp_1; INSERT INTO employee SET first_name='John', last_name='Brown', age=35, sex='man', income=23405; SAVEPOINT sp_2; INSERT INTO employee SET first_name='Matthew', last_name='Black', age=45, sex='woman', income=11000; -- ✅ RELEASE SAVEPOINT sp_2; RELEASE SAVEPOINT sp_1; -- ❌ RELEASE SAVEPOINT sp_1; -- Not found savepoint sp_2 RELEASE SAVEPOINT sp_2; ROLLBACK;
Исходя из вышесказанного, для того чтобы избежать проблем, транзакции внутри тестируемого кода должны открываться/закрываться по принципу LIFO (последним пришел, первым вышел):
it('insert: two parallel transcations, one commit, one rollback', async () => { // start two parallel transaction const trx1 = await mysqlClient.beginTransaction(); const trx2 = await mysqlClient.beginTransaction(); await trx1.query( `INSERT INTO ${dbName}.employee SET first_name='John', last_name='Brown', age=35, sex='man', income=23405`, ); await trx2.query( `INSERT INTO ${dbName}.employee SET first_name='Matthew', last_name='Black', age=45, sex='woman', income=11000`, ); // ✅ await trx2.rollback(); await trx1.commit(); // ❌ await trx1.commit(); await trx2.rollback(); const result = await mysqlClient.query(`SELECT * FROM ${dbName}.employee`); expect(result).toHaveLength(4); const notFound = await mysqlClient.query(`SELECT * FROM ${dbName}.employee WHERE first_name='Matthew' LIMIT 1`); expect(notFound).toHaveLength(0); });
И еще очевидный момент, что данный модуль не будет работать с Prisma или другим ORM/Query builder, который не использует пакетmysql или mysql2 в качестве драйвера для работы с MySQL.
Итого
C помощью transactional tests мы получаем следующие возможности:
-
можем использовать реальную БД (MySQL) в тестах и не писать моки для кода, который работает с базой данных
-
не требуется думать о восстановлении данных в БД к исходному состоянию после прохождения теста, так как в конце теста будет вызван rollback для транзакции и данные вернутся к исходному состоянию
-
тесты, работающие с одними и теми же таблицами в БД, могут запускаться параллельно
Будут благодарен, если поставите звездочку на GitHub, и рад, если данное решение было полезно для вас 😊
ссылка на оригинал статьи https://habr.com/ru/articles/822073/
Добавить комментарий