Knex: Как писать модульные тесты для методов, использующих Knex.

Созданный на 21 мая 2017  ·  28Комментарии  ·  Источник: knex/knex

(Первоначально опубликовано в # 1659, перемещено сюда для дальнейшего обсуждения.)

Я как бы в неведении по этому поводу - документы knex охватывают самые простые транзакции, но когда я гуглил "использую knex.js для модульного тестирования" и "ava.js + knex" и "ava.js + database" , Я не смог найти ничего особенно поучительного, чтобы показать мне хороший способ, поэтому я вернулся к своему опыту Ruby, где обертывание модульного теста в транзакцию является распространенным методом сброса БД после каждого теста.

@odigity Я бы не предлагал этого делать, потому что в ваших тестах будет использоваться только одно соединение вместо пула соединений, и это не будет отражать реальное использование вашего приложения.

Это действительно пришло мне в голову, но я был готов согласиться с этим, если бы это позволило мне использовать простой метод однократной записи для реализации пост-тестовой очистки БД. (Я установил для своего пула min / max значение 1, чтобы быть уверенным.) Если есть способ добиться этого, поддерживая одновременные соединения, я полностью приму это.

Кроме того, его практически невозможно реализовать, если приложение, которое вы тестируете, не выполняет свои запросы в той же транзакции, которая была запущена внутри тестового кода (вам нужно будет запустить транзакцию в тесте, затем запустить приложение и передать созданную транзакцию в приложение, чтобы оно делает все запросы к одной и той же транзакции, и вложенные транзакции могут вести себя ужасно ...).

Я надеялся / ожидал, что это сработает:

1) В каждом модульном тесте я создаю транзакцию, которая производит trx .

2) Затем мне нужен модуль, который я хочу протестировать, и передать объект trx в конструктор модуля, чтобы он использовался модулем, в результате чего все запросы выполняются внутри транзакции.

3) После того, как метод модуля возвращается (или выдает ошибку), я запускаю свои утверждения в результирующем состоянии БД, затем вызываю trx.rollback() чтобы отменить все с самого начала и подготовиться к следующему тесту.

Итак, это то, чего я пытаюсь достичь и как я изначально намеревался достичь этого. Я хочу узнать больше о:

1) Почему я не понимаю, как работает Knex.js и как его следует использовать.

2) Лучшие практики написания атомарных модульных тестов для кода, который касается базы данных.

question

Самый полезный комментарий

Неа - ничего не получил. Резюме в хронологическом порядке источников:

2016-04-21 https://medium.com/@jomaora/knex -bookshelf-mocks-and-unit-tests-cca627565d3

Стратегия: используйте mock-knex . Я не хочу издеваться над БД - я хочу протестировать свои методы на реальной базе данных MySQL, чтобы гарантировать правильное поведение, но я все равно взглянул на mock-knex ... это может быть просто худшая библиотека Я когда-либо сталкивался. :(

2016-04-28 http://mherman.org/blog/2016/04/28/test-driven-development-with-node/

Стратегия: откат / повторная миграция / повторная загрузка БД после каждого теста. Кажется, что для каждого теста много накладных расходов, и он будет выполняться очень медленно. Параллелизма можно добиться, создав UUID для каждого имени тестовой БД, но это кажется ужасным решением по сравнению с элегантностью транзакций ...

2015-09-23 http://stackoverflow.com/a/32749601/210867

Стратегия: использовать sqlite для тестирования, создавать / уничтожать БД для каждого теста. Выше я рассмотрел обе причины, по которым мне не нравится этот подход.

Итак ... все еще ловлю предложения и дополнительные инструкции о том, как на самом деле работают транзакции Knex, а также о том, как правильно применить их к моему варианту использования.

Все 28 Комментарий

Вчера вечером я явно плохо справился с поиском в Google, потому что я просто попробовал еще раз с тем, «как писать атомарные модульные тесты с помощью knex.js», и получил некоторые интересные результаты. Я сейчас их прочту. Отправлю снова, если что-то узнаю.

Неа - ничего не получил. Резюме в хронологическом порядке источников:

2016-04-21 https://medium.com/@jomaora/knex -bookshelf-mocks-and-unit-tests-cca627565d3

Стратегия: используйте mock-knex . Я не хочу издеваться над БД - я хочу протестировать свои методы на реальной базе данных MySQL, чтобы гарантировать правильное поведение, но я все равно взглянул на mock-knex ... это может быть просто худшая библиотека Я когда-либо сталкивался. :(

2016-04-28 http://mherman.org/blog/2016/04/28/test-driven-development-with-node/

Стратегия: откат / повторная миграция / повторная загрузка БД после каждого теста. Кажется, что для каждого теста много накладных расходов, и он будет выполняться очень медленно. Параллелизма можно добиться, создав UUID для каждого имени тестовой БД, но это кажется ужасным решением по сравнению с элегантностью транзакций ...

2015-09-23 http://stackoverflow.com/a/32749601/210867

Стратегия: использовать sqlite для тестирования, создавать / уничтожать БД для каждого теста. Выше я рассмотрел обе причины, по которым мне не нравится этот подход.

Итак ... все еще ловлю предложения и дополнительные инструкции о том, как на самом деле работают транзакции Knex, а также о том, как правильно применить их к моему варианту использования.

@odigity красиво резюмировал плохие практики тестирования 👍

Мы проводим наши «модульные» тесты следующим образом:

  1. Запустите систему, инициализируйте БД и выполните миграции

  2. Перед каждым тестом мы обрезаем все таблицы и последовательности (с помощью пакета knex-db-manager)

  3. Вставьте данные, необходимые для тестового примера (мы используем ORM на основе knex objection.js для того, что позволяет нам вставлять иерархии вложенных объектов с помощью одной команды, он знает, как оптимизировать вставки, чтобы ему не приходилось делать отдельную вставку для каждой строки в таблице, но обычно только одна вставка на таблицу)

  4. запустить 1 тест и перейти к шагу 2

В тестах e2e мы реализовали методы saveState / restoreState (с pg_restore / pg_dump), которые позволяют нам возвращаться к определенному состоянию во время тестового прогона, поэтому нам не нужно перезапускать тестовый прогон каждый раз, когда какой-либо тест завершается неудачно после выполнения 20 протоколы тестов.

Это похоже на то, что я начал делать вчера, потому что это просто и понятно, и мне нужно было добиться прогресса.

Рассматривали ли вы стратегию обертывания тестов в транзакциях и их отката как более быструю альтернативу усечению всех таблиц? Мне это кажется идеальным, если я смогу разобраться в реализации.

Я полагаю, что если вы запускаете db и код приложения в одном процессе, вы можете создать транзакцию в тестовом коде, а затем зарегистрировать эту транзакцию как свой экземпляр knex (экземпляр knex и транзакция в некоторых случаях выглядят немного иначе, но обычно вы можете использовать транзакцию как обычный экземпляр knex).

Затем вы запускаете код приложения, которое выбирает транзакцию вместо обычного экземпляра knex в пуле, и начинаете выполнять запросы через него.

Практически так же, как вы описали в OP. Как вы это описали, звучит жизнеспособно.

Я рассматривал возможность использования транзакций для сброса БД в тестах пару лет назад, но отклонил это, потому что я хочу, чтобы пул соединений работал в тестах почти так же, как он работает в приложении + truncate / init достаточно быстр для нас.

Нет ли способа достичь сродства соединения на время транзакции, чтобы все запросы, выполняемые в одном пакете, использовали одно и то же соединение и, таким образом, могли быть упакованы в одну транзакцию?

Стратегия усечения работает, но это очень грубая сила. В настоящее время у меня есть хук test.after.always () в каждом тестовом файле, который обрезает таблицы, на которые влияют тесты в этом файле (обычно по одной таблице на файл), но я могу вспомнить крайние случаи, которые могут быть испорчены.

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

Наконец, мне до сих пор неясно, как именно транзакции работают в Knex. Если я создам trx с knex.transaction() , все ли запросы, выполняемые с использованием trx автоматически будут частью транзакции? Нужно ли совершать / откатывать вручную? (При условии отсутствия ошибок.)

Нет ли способа достичь сродства соединения на время транзакции, чтобы все запросы, выполняемые в одном пакете, использовали одно и то же соединение и, таким образом, могли быть упакованы в одну транзакцию?

Я не понял этого

Стратегия усечения работает, но это очень грубая сила. В настоящее время у меня есть хук test.after.always () в каждом тестовом файле, который обрезает таблицы, на которые влияют тесты в этом файле (обычно по одной таблице на файл), но я могу вспомнить крайние случаи, которые могут быть испорчены.

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

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

Наконец, мне до сих пор неясно, как именно транзакции работают в Knex. Если я создам trx с помощью knex.transaction (), все ли запросы, запускаемые с использованием trx, автоматически будут частью транзакции? Нужно ли совершать / откатывать вручную? (При условии отсутствия ошибок.)

Это можно найти в документации. Если вы не возвращаете обещание из обратного вызова в knex.transaction(callback) вам необходимо выполнить фиксацию / откат вручную. Если обратный вызов возвращает обещание, автоматически вызывается фиксация / откат. В вашем случае вам, вероятно, придется откатиться вручную.

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

Я произвольно генерирую идентификаторы для каждой вставляемой записи. Коллизии возможны, но маловероятны, и если когда-нибудь это произойдет, это просто тест, а не код, ориентированный на клиента.

Что касается параллелизма, я предполагаю, что все запросы, которые необходимо сгруппировать в одну транзакцию, также должны использовать одно и то же соединение с БД, чего я могу добиться, установив размер пула Knex равным 1. Затем мне нужно будет провести тесты в серийный номер файла с модификатором .serial . Однако у меня по-прежнему будет параллелизм между тестовыми файлами (что является наиболее важным фактором для производительности), потому что Ava запускает каждый тестовый файл в отдельном дочернем процессе.

Думаю, мне стоит просто попробовать.

У меня все заработало!

https://gist.github.com/odigity/7f37077de74964051d45c4ca80ec3250

Я использую Ava для модульного тестирования в своем проекте, но для этого примера я просто создал макет сценария модульного тестирования с хуками до / после, чтобы продемонстрировать концепцию.

Перед крючком

  • Поскольку получение транзакции является асинхронным действием, я создаю новое обещание для возврата из хука before и захватываю его метод resolve чтобы его можно было вызвать в обратном вызове transaction() .
  • Открываю сделку. В обратном вызове я ...

    • сохраните дескриптор tx в месте, которое будет доступно для теста и after hook

    • вызовите сохраненный метод resolve чтобы сообщить "тестовой платформе", что перехват before завершился, и можно начать тест

Тест - я выполняю два запроса на вставку, используя сохраненный дескриптор tx .

После перехвата - я использую сохраненный дескриптор tx для отката транзакции, тем самым отменяя все изменения, сделанные во время теста. Поскольку данные никогда не фиксируются, они даже не могут быть видны другим тестам или мешать им.

О параллелизме

Knex

Knex позволяет вам указать параметры для размера пула соединений. Если вам нужно обеспечить, чтобы все запросы в транзакции выполнялись в одном и том же соединении (я предполагаю, что мы это делаем), вы можете добиться этого, установив размер пула равным 1.

_Но подождите ..._ посмотрите этот комментарий в источнике :

// We need to make a client object which always acquires the same
// connection and does not release back into the pool.
function makeTxClient(trx, client, connection) {

Если я правильно понимаю, это означает, что на Knex можно положиться, чтобы гарантировать, что все запросы в транзакции проходят через одно и то же соединение, даже если ваш пул больше 1! Поэтому нам не нужно жертвовать этой степенью параллелизма для нашего сценария.

BTW - Документы, вероятно, должны отражать этот важный и замечательный факт.

Ава

_ Я знаю, что эта специфичная для Ava, а не для Knex, но это популярная структура, и уроки широко применимы к большинству платформ. _

Ava запускает каждый тестовый файл в отдельном процессе одновременно. В каждом процессе он также запускает все тесты одновременно. Обе формы параллелизма являются disableable с помощью --serial опции CLI (который сериализующий всё) или верхнюю .serial Модификатор на test методом (который сериализующий помеченных испытания перед запуском остальных одновременно ).

Заворачивать

Если сопоставить все эти факты:

  • Я могу обернуть каждый тест в транзакцию, которая гарантирует (а) отсутствие конфликтов тестовых данных (б) автоматическую очистку после тестирования без усечения или повторного переноса. Фактически, используя эту стратегию, в моей тестовой БД буквально никогда не будет сохраняться ни одной записи, кроме, возможно, основных исходных данных.

  • Я могу продолжать пользоваться преимуществами параллелизма между тестовыми файлами Ava, поскольку каждый тестовый файл выполняется в отдельном процессе и, таким образом, будет иметь собственный пул соединений Knex.

  • Я могу продолжать пользоваться преимуществами параллелизма внутри тестового файла Ava, если я гарантирую, что мой пул соединений> = количеству тестов в файле. Это не должно быть проблемой. Я не помещаю огромное количество тестов в один файл, чего все равно стараюсь избегать. Кроме того, пул может быть установлен в диапазоне от 1 до 10, поэтому вы не создаете ненужные подключения, но вам не нужно настраивать константу в каждом тестовом файле каждый раз, когда вы добавляете или удаляете тест.


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

@odigity yup , транзакция в knex - это в значительной степени только дескриптор выделенного соединения, где knex автоматически добавляет BEGIN query при создании экземпляра trx (на самом деле транзакция в SQL обычно просто запросы, идущие к тому же соединению с добавлением BEGIN).

Если knex не будет выделять соединение для транзакции, транзакции просто не будут работать.

Использование ключей uuid тоже неплохо, изменение коллизии действительно маловероятно, если не действительно много строк. («Если вы используете 128-битный UUID,« эффект дня рождения »говорит нам, что коллизия вероятна после того, как вы сгенерировали около 2 ^ 64 ключей, при условии, что у вас есть 128 бит энтропии в каждом ключе.»)

Я полагаю, вы уже догадались, так что закрываю это 👍

Извините за повторное открытие, но теперь я наблюдаю неожиданное поведение. В частности, он работает тогда, когда этого не должно быть, а это значит, что мое понимание требует исправления.

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

Однако, если я установлю размер пула равным 1 и запускаю одновременно четыре теста, каждый из которых:

  • открывает сделку
  • выполняет запросы
  • откат звонков в конце

Все они работают. Они не должны работать - по крайней мере, некоторые из них должны быть заблокированы из-за нехватки подключения - но все они работают нормально каждый раз.

Есть ли у Knex встроенная очередь для запросов, когда в данный момент нет доступных соединений? Если да, то почему мой первый пример терпит неудачу, вместо того, чтобы ждать завершения транзакции перед выполнением запроса, не связанного с TX?

Или Knex каким-то образом мультиплексирует несколько одновременных транзакций в одно соединение?

Или Knex действительно создает суб-транзакции при 2-м, 3-м и 4-м вызовах? Если так, это, вероятно, случайным образом приведет к ожидаемым результатам ...

@odgity В первом случае у вас есть тупик на стороне приложения (ваше приложение застревает в ожидании подключения и триггеры времени ожидания подключения).

Во втором случае тесты фактически запускаются последовательно, так как все, кроме одного, ждут подключения. Если у вас достаточно тестов, выполняющих это параллельно, или ваши тестовые примеры действительно медленные, AcquireConnectionTimeout должен срабатывать и во втором случае.

Мультиплексирование транзакций в одном соединении невозможно. Вложенные транзакции поддерживаются knex с использованием точек сохранения внутри транзакции. Если вы запрашиваете новую транзакцию из пула, этого тоже не произойдет.

Спасибо, это действительно полезно.

Хотя я все еще немного смущен тем, почему выполнение запроса блокируется, когда соединение используется открытой транзакцией, но попытка открыть вторую новую транзакцию терпеливо ждет, а затем завершается.

Запустите свой код с переменной окружения DEBUG = knex: *, и вы увидите, что делает пул. Knex следует подождать и в первом случае, пока не истечет тайм-аут «Я не могу получить соединение». Итак, если ваша транзакция ожидает, пока не будет получено второе соединение, это тупик на уровне приложения, потому что оба соединения ждут друг друга (хотя я не знаю, относится ли это к вашему случаю).

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

https://github.com/bas080/knest

@ bas080 в общем случае такой подход ограничивает то, что вы можете тестировать (позволяя тесту использовать только одну транзакцию). Это предотвратит тестирование кода, который использует несколько подключений / одновременных транзакций. Также нельзя тестировать код, который выполняет неявные коммиты (хотя и не очень распространенные случаи) таким образом.

Я знаю, что использование транзакции для сброса состояния после запуска теста - довольно распространенный шаблон, я хочу подчеркнуть, что использование только этого подхода не позволяет тестировать определенные вещи.

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

привет, извините за повторное открытие этой проблемы, но я начал новый проект, который является кликом для knex для _seed нескольких баз данных с поддельными данными, _ я хотел бы, чтобы вы это увидели и помогли мне с некоторым вкладом

Для модульного тестирования я проверял только правильность формирования запросов, выполняемых Knex из моей оболочки SQL, с помощью toString() в конце цепочки запросов. Для интеграционного тестирования я использовал стратегию, уже перечисленную выше, то есть цикл: откат -> миграция -> начальное значение перед каждым тестом. Понятно, что это может быть слишком медленно, если вы не можете сохранить свои исходные данные небольшими, но может быть подходящим для других.

то есть цикл: откат -> миграция -> семя перед каждым тестом.

Это действительно плохой способ сделать это. Выполнение миграции назад и вперед легко занимает сотни миллисекунд, что слишком медленно. Вы должны выполнить миграцию только один раз для всего набора тестов, а затем перед тестом просто усечь все таблицы и заполнить подходящие тестовые данные. Это можно легко сделать в 100 раз быстрее, чем откат / воссоздание всей схемы.

Вы можете использовать knex-cleaner для простого усечения всех таблиц:

knexCleaner
    .clean(knex, { ignoreTables: ['knex_migrations', 'knex_migrations_lock'] })
    .then(() => knex.seed.run())

Обратите внимание, что нет необходимости использовать часть ignoreTables если вы выполняете миграции в начале каждого запуска набора тестов. Это необходимо только в том случае, если вы запускаете миграции вручную в тестовой базе данных.

@ricardograca Хорошо ли обрабатываются случаи с внешними ключами? (это означает, что очистка не завершится неудачно из-за неправильного порядка удаления)

Если нет, то это легко исправить (просто нужно обрезать все таблицы одним запросом) :)

@elhigu Как ты можешь это сделать?

Зависит от базы данных, но, например, вот так: https://github.com/Vincit/knex-db-manager/blob/master/lib/PostgresDatabaseManager.js#L95

@kibertoad Да, он отлично справляется с ограничениями внешнего ключа и удаляет все.

@odigity а что, если мы что тестируем такую ​​структуру:

// controller.js
const users = require('./usersModel.js');

module.exports.addUser = async ({ token, user }) => {
  // check token, or other logic
  return users.add(user);
};

// usersModel.js
const db = require('./db');

module.exports.add = async user => db('users').insert(user);

// db.js
module.exports = require('knex')({ /* config */});

По-вашему, все функции должны иметь дополнительный аргумент (например, trx ) для передачи транзакции в фактические построители запросов.
https://knexjs.org/#Builder -transacting

Я думаю, что правильный путь должен быть примерно таким:

  1. Создайте trx в beforeEach .
  2. Каким-то образом колоть. В моем примере require('./db') вернуть значение trx.
  3. Сделайте тесты.
  4. Откат trx в afterEach.

Но я не знаю, возможно ли это с knex?
Что, если код использует другие транзакции?

Еще один вариант: может быть, есть какая-то функция, которая запускает выполнение запроса. Таким образом, мы можем переопределить его, чтобы принудительно запустить тестовую транзакцию.

После дня чтения кода knex я пробую такой подход:

test.beforeEach(async t => {
    // if we use new 0.17 knex api knex.transaction().then - we can not handle rollback error
    // so we need to do it in old way
    // and add empty .catch to prevent unhandled rejection
    t.context.trx = await new Promise(resolve => db.transaction(trx => resolve(trx)).catch(() => {}));
    t.context.oldRunner = db.client.runner;
    db.client.runner = function(builder) {
        return t.context.oldRunner.call(t.context.trx.client, builder);
    };
    t.context.oldRaw = db.raw;
    db.raw = function(...args) {
        return t.context.oldRaw.call(this, ...args).transacting(t.context.trx);
    };
});

test.afterEach(async t => {
    db.raw = t.context.oldRaw;
    db.client.runner = t.context.oldRunner;
    await t.context.trx.rollback();
});

И это вроде как работает. Поэтому я переопределяю методы .raw и .client.runner . .client.runner вызывает внутренний вызов, когда вы .then построитель запросов. db в этой функции - это knex client knex({ /* config */}) .

@Niklv, как я уже объяснял здесь; использование транзакции для сброса тестовых данных - это вообще плохая идея, и я бы даже рассмотрел ее как антипаттерн. Так происходит переопределение внутреннего устройства knex. Рекомендуется усечь + повторно заполнить базу данных для каждого теста или для набора тестов. Это не займет много миллисекунд, если тестовые данные имеют разумный размер.

Была ли эта страница полезной?
0 / 5 - 0 рейтинги