Knex: 如何为使用Knex的方法编写单元测试。

创建于 2017-05-21  ·  28评论  ·  资料来源: knex/knex

(最初发布于#1659,移至此处进行进一步讨论。)

我有点不了解这个-knex文档涵盖了交易的准系统,但是当我在Google上搜索“使用knex.js进行单元测试”以及“ ava.js + knex”和“ ava.js +数据库”时, ,我找不到特别有启发性的方法来向我展示一种好方法,因此我回想起Ruby的经验,在该经验中,将单元测试包装在事务中是在每次测试后重置数据库的常用技术。

@odigity我不建议这样做,因为您的测试最终只会使用一个连接而不是连接池,并且不会代表您的应用程序的实际使用情况。

那确实发生在我身上,但是我愿意接受,如果它允许我使用一种简单的一次写入方法来执行测试后数据库清除。 (可以肯定的是,我确实将池的最小/最大设置为1。)如果有一种方法可以在支持并发连接的同时实现这一点,那么我绝对会接受。

除非要测试的应用程序在与测试代码内相同的事务中运行查询,否则几乎不可能实现它(您需要先在测试中启动事务,然后启动您的应用程序并将创建的事务传递给应用程序,以便它使所有查询都移至同一个事务,而嵌套事务的行为可能会明显不同...)。

我希望/期望它起作用的方式是:

1)在每个单元测试中,我创建一个产生trx的事务。

2)然后,我需要我要测试的模块并将trx对象传递给模块构造函数,以便模块可以使用它,从而导致所有查询在事务内部发生。

3)模块方法返回(或引发错误)后,我在数据库的结果状态上运行我的断言,然后调用trx.rollback()从一开始就撤消所有操作,以准备进行下一个测试。

因此,这就是我要实现的目标以及我最初打算实现的目标。 我渴望了解更多有关:

1)为什么我误解了Knex.js的工作方式和应使用的方式。

2)为涉及数据库的代码编写原子单元测试的最佳实践。

question

最有用的评论

不-什么也没有。 来源按时间顺序的摘要:

2016年4月21日https://medium.com/@jomaora/knex -bookshelf-嘲笑和-单元测试-cca627565d3

策略:使用嘲笑knex 。 我不想模拟数据库-我想针对实际的MySQL数据库测试我的方法以确保行为正确-但无论如何我还是看了mock-knex ...这可能是设计最糟糕的库我见过 :(

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

策略:每次测试后回滚/迁移/重新设置DB。 每次测试似乎都需要大量开销,并且运行速度非常慢。 可以通过为每个测试的数据库名称生成一个UUID来实现并发,但是与事务的优雅相比,这似乎是一个糟糕的解决方案...

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

策略:使用sqlite进行测试,为每个测试创建/销毁数据库。 上面我提到了我不喜欢这种方法的两个原因。

因此...仍然在寻求建议,以及有关Knex事务实际工作方式以及如何将其正确应用于我的用例的其他指导。

所有28条评论

显然,昨晚我在谷歌搜索上做得很差,因为我只是再次尝试了“如何使用knex.js编写原子单元测试”,并且得到了一些有趣的结果。 现在要通读它们。 如果我学到一些,会再次发布。

不-什么也没有。 来源按时间顺序的摘要:

2016年4月21日https://medium.com/@jomaora/knex -bookshelf-嘲笑和-单元测试-cca627565d3

策略:使用嘲笑knex 。 我不想模拟数据库-我想针对实际的MySQL数据库测试我的方法以确保行为正确-但无论如何我还是看了mock-knex ...这可能是设计最糟糕的库我见过 :(

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

策略:每次测试后回滚/迁移/重新设置DB。 每次测试似乎都需要大量开销,并且运行速度非常慢。 可以通过为每个测试的数据库名称生成一个UUID来实现并发,但是与事务的优雅相比,这似乎是一个糟糕的解决方案...

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

策略:使用sqlite进行测试,为每个测试创建/销毁数据库。 上面我提到了我不喜欢这种方法的两个原因。

因此...仍然在寻求建议,以及有关Knex事务实际工作方式以及如何将其正确应用于我的用例的其他指导。

@odigity很好地总结了不良的测试做法👍

我们正在像这样进行“单元”测试:

  1. 启动系统,初始化数据库并运行迁移

  2. 在每次测试之前,我们都会截断所有表和序列(使用knex-db-manager软件包)

  3. 插入测试用例所需的数据(我们使用基于knex的objection.js ORM,它允许我们使用单个命令插入嵌套的对象层次结构,它知道如何优化插入操作,因此不必为每一行分别进行插入操作在表格中,但通常每张表格仅插入一个)

  4. 运行1测试并转到步骤2

使用e2e测试,我们实现了saveState / restoreState(使用pg_restore / pg_dump)方法,该方法使我们能够在测试运行期间回滚到某些状态,因此当某些测试在运行20次后失败时,我们不必每次都重新启动测试运行分钟的测试。

这与我昨天开始进行的操作类似,因为它简单明了且需要进步。

您是否考虑过将测试包装在事务中并在之后回滚作为截断所有表的更快选择的策略? 如果我能弄清楚实现的话,那对我来说似乎是理想的选择。

我想如果您在同一过程中运行db和应用程序代码,则可以在测试代码中创建事务,然后将该事务注册为您的knex-instance(knex实例和事务在某些情况下看起来有些不同,但是通常可以使用事务实例,例如普通的knex实例)。

然后,您启动应用程序代码,该代码将获取事务而不是普通的池式knex实例,并开始通过该实例进行查询。

与您在OP中描述的方式几乎相同。 您如何描述它听起来可行。

几年前,我曾考虑过使用事务来重置测试中的数据库,但是由于我希望连接池能够在测试中正常工作,就像我在应用程序+截断/ init中的工作方式一样快,因此拒绝了它。

是否没有办法在事务期间实现连接亲和力,以使在同一批中运行的所有查询将使用同一连接,因此可以包装在单个事务中?

截断策略有点奏效,但这是蛮力的。 我目前在每个测试文件中都有一个test.after.always()钩子,该钩子会截断该文件中受测试影响的表(通常每个文件一个表),但是我想到的是一些极端情况。

例如,如果来自不同测试文件的两个测试在同一张表上同时运行,那么一个文件的截断钩子可能会在第二个文件的测试处于运行中时启动,从而破坏了该测试。

最后,我仍然不清楚事务如何在Knex中工作。 如果我使用knex.transaction()创建trx ,使用trx运行的所有查询是否会自动成为事务的一部分? 我必须手动提交/回滚吗? (假设没有引发错误。)

是否没有办法在事务期间实现连接亲和力,以使在同一批中运行的所有查询将使用同一连接,因此可以包装在单个事务中?

我不明白

截断策略有点奏效,但这是蛮力的。 我目前在每个测试文件中都有一个test.after.always()钩子,该钩子会截断该文件中受测试影响的表(通常每个文件一个表),但是我想到的是一些极端情况。

例如,如果来自不同测试文件的两个测试在同一张表上同时运行,那么一个文件的截断钩子可能会在第二个文件的测试处于运行中时启动,从而破坏了该测试。

即使在使用事务重置测试数据的情况下,一般情况下您也无法并行运行多个测试。 编号顺序会有所不同,交易可能会陷入僵局等。

最后,我仍然不清楚事务如何在Knex中工作。 如果我使用knex.transaction()创建trx,使用trx运行的所有查询会自动成为事务的一部分吗? 我必须手动提交/回滚吗? (假设没有引发错误。)

这可以从文档中找到。 如果您不从knex.transaction(callback)回调返回承诺,则需要手动提交/回滚。 如果回调返回Promise,则将自动调用commit / rollback。 在您的情况下,您可能必须手动回滚。

即使在使用事务重置测试数据的情况下,一般情况下您也无法并行运行多个测试。 编号顺序会有所不同,交易可能会陷入僵局等。

我为插入的每条记录随机生成ID。 冲突是可能的,但可能性很小,如果冲突一天发生一次,那只是测试,而不是面向客户的代码。

至于并发性,我假设所有需要分组为单个事务的查询也必须使用相同的数据库连接,这可以通过将Knex池大小设置为1来实现。然后,我需要在一个数据库中进行测试。带有.serial修饰符的文件序列。 但是,由于Ava在单独的子进程中运行每个测试文件,因此测试文件之间仍然并发(这是提高性能的最重要因素)。

我想我应该去尝试一下。

我知道了!

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

我在项目中使用Ava进行单元测试,但是对于本示例,我只是使用前/后钩子创建了一个模拟单元测试方案以演示该概念。

上钩之前

  • 因为获取事务是异步操作,所以我创建了一个新的Promise来从before挂钩返回并捕获其resolve方法,以便可以在transaction()回调中调用它。
  • 我打开交易。 在回调中,我...

    • tx句柄保存在测试可访问的位置和after挂钩

    • 调用保存的resolve方法来通知“测试框架” before挂钩已完成,并且测试可以开始

测试—我使用保存的tx句柄执行两个插入查询。

Hook之后—我使用保存的tx句柄回滚事务,从而撤消了测试期间所做的所有更改。 由于数据永远不会提交,因此甚至无法被其他测试的活动看到或干扰。

关于并发

纳克斯

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! 因此,我们无需为我们的方案牺牲这种特殊程度的并发性。

顺便说一句—这些文档可能应该反映出这一至关重要的事实。

阿瓦

_我知道此特定于Ava而不是Knex特定,但这是一个流行的框架,并且这些课程广泛适用于大多数框架。

Ava在单独的进程中同时运行每个测试文件。 在每个过程中,它还同时运行所有测试。 可以使用--serial CLI选项(对所有内容进行序列化)或test方法的.serial修饰符(在并发运行其余序列之前对标记的测试进行序列化)来禁用两种并发形式)。

包起来

当我将所有这些事实放在一起时:

  • 我可以将每个测试包装在一个事务中,以确保(a)测试数据不冲突(b)测试后自动清除而不会被截断或迁移。 实际上,使用这种策略,除了可能的种子数据外,我的测试数据库几乎不会保留任何记录。

  • 我可以继续受益于Ava的内部测试文件间并发性,因为每个测试文件都在单独的进程中运行,因此将拥有自己的Knex连接池。

  • 如果我确保连接池> =文件中的测试数量,则可以继续受益于Ava的内部测试文件并发。 我不会在单个文件中进行大量测试,这不应该成为问题,无论如何我都希望避免这种情况。 此外,可以将池设置为1-10的范围,因此您不必创建不需要的连接,但是不必在每次添加或删除测试时都在每个测试文件中调整常量。


急于引起别人的想法,因为我同时涉足大量对我而言是新技术的交汇处...

@odigity是的,knex中的事务几乎只是专用连接的句柄,其中在创建trx实例时knex自动添加BEGIN查询(实际上,SQL中的事务通常只是去往以BEGIN开头的同一连接的查询)。

如果knex不会为事务分配连接,则事务根本无法工作。

使用uuid键也不错,除非真的有很多行,否则更改冲突的可能性很小。 (“如果使用128位UUID,则“生日效应”会告诉我们,如果每个键中都有128位熵,则在生成大约2 ^ 64个键之后可能会发生冲突。”)

我想您已经弄清了答案,所以关闭此👍

抱歉,重新打开,但是我现在观察到了意外的行为。 具体来说,它应该在不应该使用的时候工作,这意味着我的理解需要纠正。

如果我将池大小设置为1,则打开一个事务,然后不使用查询而执行查询,由于无法获得连接而超时-这很有意义,因为该连接已被事务锁定。

但是,如果我将池大小设置为1并同时运行四个测试,则每个测试:

  • 开启交易
  • 运行查询
  • 最后调用回滚

他们都工作。 它们不应该工作-至少其中一些应该由于连接不足而被锁定-但是它们每次都可以正常工作。

当没有连接可用时,Knex是否具有内置的查询队列? 如果是这样,为什么我的第一个示例失败,而不是在执行non-tx查询之前等待事务完成?

还是Knex以某种方式将多个并发事务复用到单个连接上?

还是Knex实际在第二,第三和第四次调用中创建子事务? 如果是这样,那可能会随机产生预期的结果...

@odgity在第一种情况下,您会遇到应用程序侧死锁(您的应用程序处于等待连接和AcquisitionConnection超时触发器的状态)。

在第二种情况下,测试实际上是串行运行的,因为除了一个人以外的所有人都在等待连接。 如果您有足够的并行执行测试,或者您的测试用例确实很慢,那么connectConnectionTimeout也应该在第二种情况下触发。

无法在单个连接中进行多重事务处理。 knex使用事务内部的保存点支持嵌套事务。 如果您要从池中请求新事务,则也不会发生。

谢谢,这真的很有帮助。

尽管我对为什么在打开的事务进行连接时执行查询锁定为什么仍感到困惑,但是尝试耐心等待打开第二个新事务然后等待完成。

使用DEBUG = knex:*环境变量运行代码,您将看到池在做什么。 在第一种情况下,Knex也应等待,直到发生“我无法获得连接”超时。 因此,如果您的事务正在等待直到获得第二个连接,则它是应用程序级死锁,因为两个连接都在互相等待(不过我不知道这是否是您的情况)。

我发现此线程非常有用。 很高兴看到其他人也关心编写不会污染系统状态的测试。 我启动了一个项目,该项目允许人们编写使用knex的代码的测试,该代码将在测试完成后回滚。

https://github.com/bas080/knest

在一般情况下, @ bas080这种方法限制了您可以测试的内容(允许测试仅使用单个事务)。 这将防止测试使用多个连接/并发事务的代码。 同样,无法以这种方式测试执行隐式提交(虽然不是很常见的情况)的代码。

我知道在运行测试后使用事务重置状态是很常见的模式,我想强调的是,仅使用该方法会阻止人们测试某些东西。

我一直喜欢在每次修改数据的测试之后或在相互依赖的某些测试之后截断并重新填充DB(有时,出于性能原因,如果填充时间过长(例如超过50毫秒),我会这样做)。

您好,很抱歉再次提出该问题,但是我开始了一个新项目,这是knex的一个cli _seed带有假数据的多个数据库,_我希望您看到它并为我提供一些帮助

对于单元测试,我只是通过在查询链末尾使用toString()来检查Knex从SQL包装器执行的查询是否正确形成。 对于集成测试,我一直在使用上面已经列出的策略-即循环:回滚->迁移->种子,然后再进行每次测试。 可以理解,如果您不能保持较小的种子数据,那可能会太慢,但是可能适合其他人。

即循环:在每次测试之前回滚->迁移->种子。

那真的是不好的方法。 来回运行迁移很容易花费数百毫秒,这太慢了。 对于整个测试套件,您应该只运行一次迁移,然后在测试之前截断所有表并填充适当的测试数据。迁移可以轻松完成,比回滚/重新创建整个架构快100倍。

您可以使用knex-cleaner轻松截断所有表:

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

请注意,如果在每个测试套件运行开始时都在运行迁移,则无需使用ignoreTables部分。 仅当您在测试数据库上手动运行迁移时才需要。

@ricardograca是否可以很好地处理带有外键的案件? (这意味着清除不会失败,因为删除顺序错误)

如果不是,那很容易解决(只需要用一个查询来截断所有表):)

@elhigu你怎么做到的?

取决于数据库,但是例如这样: https :

@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 */});

以您的方式,所有函数都必须具有附加的arg(例如trx ),才能将事务传递到实际的查询生成器中。
https://knexjs.org/#Builder-交易

我认为正确的方法一定是这样的:

  1. beforeEach创建trx。
  2. 以某种方式注入它。 在我的示例中, require('./db')灵魂返回trx值。
  3. 做测试。
  4. 在“ afterEach”中回滚trx。

但是,我不知道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方法。 当您.then查询构建器时, .client.runner内部调用。 db在此函数中是knex客户端knex({ /* config */})

正如我已经在这里解释的@Niklv ; 使用事务重置测试数据通常是一个坏主意,我什至会认为它是一种反模式。 覆盖knex内部的方法也是如此。 建议在每个测试或一组测试中截断并重新填充数据库。 如果测试数据大小合理,则无需花费几毫秒的时间。

此页面是否有帮助?
0 / 5 - 0 等级

相关问题

zettam picture zettam  ·  3评论

mtom55 picture mtom55  ·  3评论

PaulOlteanu picture PaulOlteanu  ·  3评论

npow picture npow  ·  3评论

legomind picture legomind  ·  3评论