javascript

Пишем тесты в транзакциях вместе с MySQL

  • среда, 9 апреля 2025 г. в 00:00:10
https://habr.com/ru/articles/822073/

Хочу поведать о своей библиотеке для написания тестов в транзакциях при работе с MySQL.

Я люблю писать тесты для своего кода, но при этом не люблю писать моки и всю необходимую для них обвязку. Особенно это касается базы данных, ибо если замокать вызовы внешних сервисов и очереди сообщений еще не так сложно, то с БД все гораздо сложнее. Взаимодействие с базой данных обычно довольно «богатое», это ведет к тому, что приходится писать много хрупких и утомительных моков/стабов, и при этом сами запросы к БД не покрываются тестами (а там зачастую могут таиться ошибки, связанные с некорректными запросами или ошибками миграции схемы).

Выход здесь — это использовать для тестов реальную БД c данными, которые мы помещаем в нее перед запуском всех или одного конкретного теста. Но тут возникает вопрос, как восстанавливать состояние в базе данных после прохождение конкретного теста?

Подходов для решения несколько:

  • перезапускать контейнер с БД перед каждым тестом для восстановления состояния

  • использовать TRUNCATE

  • писать код для загрузки и очищения/восстановления данных после прохождения каждого теста

Первый подход слишком тяжеловесный для прогона тестов в параллель бесконфликтно, ведь придется запускать множество контейнеров в параллель. Второй подход быстрее, чем предыдущий, но TRUNCATE очищает вообще все данные в таблице, что создает проблемы при запуске тестов в параллель, которые работают с одними и теми же таблицами, что может привести к конфликтам. Кроме того, если используются FOREIGN KEY их придется включать/отключать перед вызовом TRUNCATE. И про ручное восстановление данных в БД перед запуском тестов тоже надо не забывать. Последний подход требует написания много бойлерплейт кода, причем в случае падения теста мы можем получить «грязные» данные в БД.

Но есть другой подход, это оборачивать тесты в транзакции и после выполнения каждого теста откатывать транзакцию. Этот подход не то чтобы новый, он используется в других языках и платформах, таких как Ruby on RailsLaravel и 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, и рад, если данное решение было полезно для вас 😊