Пишем тесты в транзакциях вместе с MySQL
- среда, 9 апреля 2025 г. в 00:00:10
Хочу поведать о своей библиотеке для написания тестов в транзакциях при работе с 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);
});
Список поддерживаемых библиотек:
Примеры тестов с разными 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, и рад, если данное решение было полезно для вас 😊