Knex: Cómo escribir pruebas unitarias para métodos que usan Knex.

Creado en 21 may. 2017  ·  28Comentarios  ·  Fuente: knex/knex

(Publicado originalmente en # 1659, movido aquí para mayor discusión).

Estoy un poco a oscuras en este caso: los documentos de knex cubren los aspectos básicos de las transacciones, pero cuando busco en Google "usando knex.js para pruebas unitarias" y "ava.js + knex" y "ava.js + database" , No pude encontrar nada particularmente instructivo que me mostrara un buen camino, así que recurrí a mi experiencia con Ruby, en la que envolver una prueba unitaria en una transacción es una técnica común para restablecer la base de datos después de cada prueba.

@odigity No sugeriría hacer eso porque sus pruebas terminarán usando solo una conexión en lugar del grupo de conexiones y no representará el uso real de su aplicación.

Eso sí se me ocurrió, pero estaba dispuesto a aceptarlo si me permitía usar un método simple de una sola escritura para implementar la limpieza de base de datos posterior a la prueba. (Definí mi grupo mínimo / máximo en 1 para estar seguro). Si hay una manera de lograr esto mientras se admiten conexiones simultáneas, lo aceptaré por completo.

También es prácticamente imposible de implementar a menos que la aplicación que está probando esté ejecutando sus consultas en la misma transacción que se inició dentro del código de prueba (necesitaría iniciar la transacción en la prueba, luego iniciar su aplicación y pasar la transacción creada a la aplicación para que realiza todas las consultas a la misma transacción y las transacciones anidadas pueden comportarse de manera excepcional ...).

La forma en que esperaba / esperaba que funcionara es:

1) En cada prueba unitaria, creo una transacción que produce trx .

2) Luego necesito el módulo que quiero probar y paso el objeto trx al constructor del módulo para que sea utilizado por el módulo, lo que da como resultado que todas las consultas ocurran dentro de la transacción.

3) Después de que el método del módulo regresa (o arroja un error), ejecuto mis afirmaciones en el estado resultante de la base de datos, luego llamo a trx.rollback() para deshacer todo desde el principio y prepararme para la siguiente prueba.

Entonces, eso es lo que estoy tratando de lograr y cómo originalmente pretendía lograrlo. Estoy ansioso por aprender más sobre:

1) ¿Por qué no entiendo bien cómo funciona y debe usarse Knex.js?

2) Mejores prácticas para escribir pruebas de unidades atómicas para el código que toca la base de datos.

question

Comentario más útil

No, no tengo nada. Resumen en orden cronológico de fuentes:

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

Estrategia: Utilice mock-knex . No quiero burlarme de la base de datos, quiero probar mis métodos contra una base de datos MySQL real para asegurar el comportamiento correcto, pero eché un vistazo a mock-knex todos modos ... puede que sea la biblioteca peor diseñada Alguna vez me he encontrado. :(

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

Estrategia: Revertir / volver a migrar / resembrar la base de datos después de cada prueba. Eso parece una gran sobrecarga para cada prueba y funcionará terriblemente lento. La simultaneidad se puede lograr generando un UUID para el nombre de la base de datos de cada prueba, pero eso parece una solución terrible en comparación con la elegancia de las transacciones ...

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

Estrategia: use sqlite para las pruebas, cree / destruya la base de datos para cada prueba. He cubierto las dos razones por las que no me gusta este enfoque anteriormente.

Entonces ... sigo buscando sugerencias y orientación adicional sobre cómo funcionan realmente las transacciones de Knex, así como sobre cómo aplicarlas correctamente a mi caso de uso.

Todos 28 comentarios

Aparentemente hice un mal trabajo buscando en Google anoche, porque lo intenté nuevamente con "cómo escribir pruebas de unidades atómicas con knex.js", y estoy obteniendo algunos resultados interesantes. Voy a leerlos ahora. Publicaré de nuevo si aprendo algo.

No, no tengo nada. Resumen en orden cronológico de fuentes:

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

Estrategia: Utilice mock-knex . No quiero burlarme de la base de datos, quiero probar mis métodos contra una base de datos MySQL real para asegurar el comportamiento correcto, pero eché un vistazo a mock-knex todos modos ... puede que sea la biblioteca peor diseñada Alguna vez me he encontrado. :(

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

Estrategia: Revertir / volver a migrar / resembrar la base de datos después de cada prueba. Eso parece una gran sobrecarga para cada prueba y funcionará terriblemente lento. La simultaneidad se puede lograr generando un UUID para el nombre de la base de datos de cada prueba, pero eso parece una solución terrible en comparación con la elegancia de las transacciones ...

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

Estrategia: use sqlite para las pruebas, cree / destruya la base de datos para cada prueba. He cubierto las dos razones por las que no me gusta este enfoque anteriormente.

Entonces ... sigo buscando sugerencias y orientación adicional sobre cómo funcionan realmente las transacciones de Knex, así como sobre cómo aplicarlas correctamente a mi caso de uso.

@odigity resumió muy bien las malas prácticas de prueba 👍

Estamos haciendo nuestras pruebas de "unidad" como esta:

  1. Inicie el sistema, inicialice la base de datos y ejecute las migraciones

  2. Antes de cada prueba truncamos todas las tablas y secuencias (con el paquete knex-db-manager)

  3. Inserte los datos requeridos para el caso de prueba (usamos objection.js ORM basado en knex para aquello que nos permite insertar jerarquías de objetos anidados con un solo comando, sabe cómo optimizar las inserciones para que no tenga que hacer una inserción separada para cada fila en la tabla, pero generalmente solo una inserción por tabla)

  4. ejecutar 1 prueba y pasar al paso 2

Con las pruebas e2e hemos implementado los métodos saveState / restoreState (con pg_restore / pg_dump), que nos permiten retroceder a cierto estado durante la ejecución de la prueba, por lo que no tenemos que reiniciar la ejecución de la prueba cada vez que falla alguna prueba después de ejecutar 20 minutos de pruebas.

Eso es similar a lo que comencé a hacer ayer porque es simple y directo y necesitaba progresar.

¿Ha considerado la estrategia de envolver las pruebas en las transacciones y retroceder después como una alternativa más rápida a truncar todas las tablas? Eso me parece lo ideal, si puedo averiguar la implementación.

Supongo que si ejecuta la base de datos y el código de la aplicación en el mismo proceso, puede crear una transacción en el código de prueba y luego registrar esa transacción para que sea su instancia de knex (la instancia de knex y la transacción se ven un poco diferentes en algunos casos, pero generalmente puede usar la transacción instancia como una instancia normal de knex).

Luego, inicia el código de su aplicación, que obtiene la transacción en lugar de la instancia de knex agrupada normal y comienza a realizar consultas a través de ella.

Casi de la misma manera que describiste en OP. Cómo lo describiste suena viable.

He considerado usar transacciones para restablecer DB en pruebas hace un par de años, pero lo rechacé porque quiero que la agrupación de conexiones funcione en las pruebas de la misma manera que funciona en app + truncate / init es lo suficientemente rápido para nosotros.

¿No hay forma de lograr la afinidad de conexión durante la transacción de modo que todas las consultas que se ejecuten en un lote utilicen la misma conexión y, por lo tanto, se puedan envolver en una sola transacción?

La estrategia truncada funciona, pero es una fuerza bruta. Actualmente tengo un gancho test.after.always () en cada archivo de prueba que trunca las tablas afectadas por las pruebas en ese archivo (generalmente una tabla por archivo), pero puedo pensar en casos extremos que se estropearían por eso.

Por ejemplo, si dos pruebas de diferentes archivos de prueba que tocan la misma tabla se ejecutan aproximadamente al mismo tiempo, el enlace truncado de un archivo podría iniciarse mientras las pruebas del segundo archivo están en medio de la ejecución, arruinando esa prueba.

Por último, todavía no tengo claro exactamente cómo funcionan las transacciones en Knex. Si creo un trx con knex.transaction() , ¿se ejecutarán todas las consultas usando trx automáticamente como parte de la transacción? ¿Tengo que confirmar / deshacer manualmente? (Suponiendo que no haya errores lanzados).

¿No hay forma de lograr la afinidad de conexión durante la transacción de modo que todas las consultas que se ejecuten en un lote utilicen la misma conexión y, por lo tanto, se puedan envolver en una sola transacción?

No entendí esto

La estrategia truncada funciona, pero es una fuerza bruta. Actualmente tengo un gancho test.after.always () en cada archivo de prueba que trunca las tablas afectadas por las pruebas en ese archivo (generalmente una tabla por archivo), pero puedo pensar en casos extremos que se estropearían por eso.

Por ejemplo, si dos pruebas de diferentes archivos de prueba que tocan la misma tabla se ejecutan aproximadamente al mismo tiempo, el enlace truncado de un archivo podría iniciarse mientras las pruebas del segundo archivo están en medio de la ejecución, arruinando esa prueba.

Incluso si está utilizando transacciones para restablecer sus datos de prueba, no podrá ejecutar varias pruebas en paralelo en el caso general. Las secuencias de identificación serán diferentes y las transacciones pueden bloquearse, etc.

Por último, todavía no tengo claro exactamente cómo funcionan las transacciones en Knex. Si creo un trx con knex.transaction (), ¿todas las consultas se ejecutarán usando trx automáticamente como parte de la transacción? ¿Tengo que confirmar / deshacer manualmente? (Suponiendo que no haya errores lanzados).

Este se encuentra en la documentación. Si no devuelve la promesa de la devolución de llamada en knex.transaction(callback) , debe confirmar / deshacer manualmente. Si la devolución de llamada devuelve una promesa, se llama automáticamente a commit / rollback. En su caso, probablemente tendrá que revertir manualmente.

Incluso si está utilizando transacciones para restablecer sus datos de prueba, no podrá ejecutar varias pruebas en paralelo en el caso general. Las secuencias de identificación serán diferentes y las transacciones pueden bloquearse, etc.

Estoy generando identificaciones al azar para cada registro que inserto. Las colisiones son posibles, pero poco probables, y si ocurre una vez algún día, es solo una prueba, no un código de cara al cliente.

En cuanto a la concurrencia, supongo que todas las consultas que deben agruparse en una sola transacción también tienen que usar la misma conexión de base de datos, lo que puedo lograr estableciendo el tamaño de mi grupo de Knex en 1. Luego, necesitaría hacer las pruebas en un serial del archivo con el modificador .serial . Sin embargo, todavía tendría simultaneidad entre los archivos de prueba (que es el factor más importante para el rendimiento) porque Ava ejecuta cada archivo de prueba en un proceso secundario separado.

Supongo que debería intentarlo.

¡Lo tengo funcionando!

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

Estoy usando Ava para pruebas unitarias en mi proyecto, pero para este ejemplo, acabo de crear un escenario de prueba unitaria simulado con ganchos antes / después para demostrar el concepto.

Antes de Hook

  • Debido a que obtener una transacción es una acción asincrónica, creo una nueva Promesa para regresar desde el gancho before y capturo su método resolve para que se pueda llamar en la devolución transaction() llamada
  • Abro una transacción. En la devolución de llamada, yo ...

    • guarde el identificador tx en un lugar que sea accesible para la prueba y el gancho after

    • llamar al método guardado resolve para informar al "marco de prueba" que el gancho before ha completado y que la prueba puede comenzar

Prueba : ejecuto dos consultas de inserción utilizando el identificador tx guardado.

After Hook : uso el identificador tx guardado para revertir la transacción, deshaciendo así todos los cambios realizados durante la prueba. Dado que los datos nunca se confirman, ni siquiera pueden verse o interferir con la actividad de otras pruebas.

En concurrencia

Knex

Knex le permite especificar opciones para el tamaño del grupo de conexiones. Si necesita asegurarse de que todas las consultas de una transacción se ejecuten en la misma conexión (supongo que sí), puede lograrlo configurando el tamaño del grupo en 1.

_Pero espera ..._ mira este comentario en la fuente :

// 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) {

Si entiendo esto correctamente, significa que se puede confiar en Knex para garantizar que todas las consultas en una transacción pasen por la misma conexión, ¡incluso si su grupo es mayor que 1! Por lo tanto, no necesitamos sacrificar este grado particular de concurrencia por nuestro escenario.

Por cierto, los documentos probablemente deberían reflejar este hecho esencial y maravilloso.

Ava

_Conozco esto específico de Ava en lugar de específico de Knex, pero es un marco popular y las lecciones son ampliamente aplicables a la mayoría de los marcos.

Ava ejecuta cada archivo de prueba en un proceso separado al mismo tiempo. Dentro de cada proceso, también ejecuta todas las pruebas al mismo tiempo. Ambas formas de concurrencia son disableable utilizando el --serial opción CLI (que serializa todo) o el .serial modificador en el test método (que serializa las pruebas corregidas antes de ejecutar el resto concurrentemente ).

Envolver

Cuando pongo todos estos hechos juntos:

  • Puedo envolver cada prueba en una transacción, lo que garantiza (a) la no colisión de los datos de la prueba (b) la limpieza automática posterior a la prueba sin truncamiento o remigración. De hecho, al usar esta estrategia, mi base de datos de prueba literalmente nunca tendrá un solo registro persistido, aparte de los datos iniciales esenciales.

  • Puedo seguir beneficiándome de la simultaneidad entre archivos de prueba de Ava, ya que cada archivo de prueba se ejecuta en un proceso separado y, por lo tanto, tendrá su propio grupo de conexiones Knex.

  • Puedo seguir beneficiándome de la simultaneidad intra-archivo de prueba de Ava si me aseguro de que mi grupo de conexiones sea> = la cantidad de pruebas en el archivo. Esto no debería ser un problema. No pongo una gran cantidad de pruebas en un solo archivo, lo cual trato de evitar de todos modos. Además, el grupo se puede configurar con un rango como 1-10, por lo que no está creando conexiones que no necesita, pero no tiene que ajustar una constante en cada archivo de prueba cada vez que agrega o elimina una prueba.


Ansioso por captar los pensamientos de otras personas, ya que estoy incursionando en la intersección de una gran cantidad de tecnologías que son nuevas para mí al mismo tiempo ...

@odigity sí , la transacción en knex es prácticamente solo un identificador para la conexión dedicada donde knex agrega automáticamente BEGIN consulta cuando se crea la instancia trx (en realidad, la transacción en SQL generalmente es solo consultas que van a la misma conexión precedida por BEGIN).

Si knex no asigna la conexión para la transacción, las transacciones simplemente no funcionarían en absoluto.

El uso de claves uuid tampoco está mal, el cambio de colisión es realmente poco probable a menos que haya muchas filas. ("Si usa un UUID de 128 bits, el 'efecto de cumpleaños' nos dice que es probable que haya una colisión después de haber generado aproximadamente 2 ^ 64 claves, siempre que tenga 128 bits de entropía en cada clave").

Supongo que tienes tus respuestas resueltas, así que cerrando esto 👍

Lamento volver a abrir, pero ahora estoy observando un comportamiento inesperado. Específicamente, está funcionando cuando no debería, lo que significa que mi comprensión necesita ser corregida.

Si establezco el tamaño del grupo en 1, abro una transacción y luego ejecuto una consulta sin usarla, se agota el tiempo de espera porque no puede obtener una conexión, lo cual tiene sentido, porque esa conexión ya ha sido bloqueada por la transacción.

Sin embargo, si configuro el tamaño del grupo en 1 y ejecuto cuatro pruebas al mismo tiempo, cada una de las cuales:

  • abre una transacción
  • ejecuta consultas
  • llamadas rollback al final

Todos funcionan. No deberían funcionar, al menos algunos de ellos deberían quedar bloqueados por la escasez de conexión, pero todos funcionan bien, siempre.

¿Knex tiene cola incorporada para consultas cuando no hay conexiones disponibles en ese momento? Si es así, ¿por qué falla mi primer ejemplo, en lugar de esperar a que finalice la transacción antes de ejecutar la consulta no tx?

¿O Knex de alguna manera multiplexa múltiples transacciones concurrentes en una sola conexión?

¿O Knex está creando subtransacciones en la segunda, tercera y cuarta invocación? Si es así, eso probablemente causará resultados esperados al azar ...

@odgity En el primer caso, tiene un punto muerto en el lado de la aplicación (su aplicación está atascada esperando la conexión y se activa el tiempo de espera de la conexión).

En el segundo caso, las pruebas se ejecutan en serie, ya que todos menos uno están esperando la conexión. Si tiene suficientes pruebas haciendo eso en paralelo o sus casos de prueba son realmente lentos, purchaseConnectionTimeout también debería activarse en el segundo caso.

No es posible multiplexar transacciones en una sola conexión. El anidamiento de transacciones es compatible con knex utilizando puntos de guardado dentro de la transacción. Si está solicitando una nueva transacción del grupo, eso tampoco sucederá.

Gracias, eso es realmente útil.

Aunque todavía estoy un poco confundido acerca de por qué la ejecución de una consulta se bloquea cuando la conexión es tomada por una transacción abierta, pero al intentar abrir una segunda transacción nueva, espera pacientemente y luego se completa.

Ejecute su código con DEBUG = knex: * variable de entorno y verá lo que está haciendo el grupo. Knex debería esperar también en el primer caso hasta que se agote el tiempo de espera "No pude obtener conexión". Entonces, si su transacción está esperando hasta que se adquiera la segunda conexión, es un punto muerto en el nivel de la aplicación porque ambas conexiones se están esperando entre sí (aunque no sé si este es su caso).

Encontré este hilo muy útil. Es bueno ver que a otras personas también les importa escribir pruebas que no contaminen el estado del sistema. Inicié un proyecto que permite a las personas escribir pruebas para el código que usa knex que se revertirá una vez finalizada la prueba.

https://github.com/bas080/knest

@ bas080 en el caso general, ese enfoque limita las cosas que puede probar (lo que permite que la prueba use una sola transacción). Evitará probar el código que usa múltiples conexiones / transacciones concurrentes. Además, no se puede probar el código que realiza confirmaciones implícitas (aunque no son casos muy comunes) de esta manera.

Sé que usar la transacción para restablecer el estado después de ejecutar la prueba es un patrón bastante común, lo que quiero enfatizar es que usar solo ese enfoque evita que uno pruebe ciertas cosas.

Siempre he preferido truncar y repoblar la base de datos después de cada prueba que modifica los datos o después de un conjunto de pruebas que dependen unas de otras (algunas veces hago esto por razones de rendimiento si completarlo tarda demasiado, como más de 50 ms).

hola, lamento volver a abrir este problema, pero comencé un nuevo proyecto que es un cli para knex para _sembrar múltiples bases de datos con datos falsos, _ me gustaría que lo vieras y me ayudaras con alguna contribución

Para las pruebas unitarias, solo he estado verificando que las consultas ejecutadas por Knex desde mi contenedor SQL estén formadas correctamente, usando toString() al final de la cadena de consultas. Para las pruebas de integración, he estado empleando la estrategia ya mencionada anteriormente, es decir, hacer un ciclo: retroceder -> migrar -> semilla, antes de cada prueba. Es comprensible que eso sea demasiado lento si no puede mantener pequeños sus datos iniciales, pero puede ser adecuado para otros.

es decir, realizar un ciclo: retroceder -> migrar -> semilla, antes de cada prueba.

Esa es una forma realmente mala de hacerlo. Ejecutar migraciones de un lado a otro toma fácilmente cientos de milisegundos, lo cual es demasiado lento. Debe ejecutar las migraciones solo una vez para todo el conjunto de pruebas y luego, antes de la prueba, simplemente trunque todas las tablas y complete los datos de prueba adecuados. Se puede hacer fácilmente 100 veces más rápido que revertir / recrear todo el esquema.

Puede usar knex-cleaner para truncar fácilmente todas las tablas:

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

Tenga en cuenta que no es necesario utilizar la parte ignoreTables si está ejecutando migraciones al comienzo de cada ejecución de la suite de pruebas. Esto solo es necesario si ejecuta migraciones manualmente en su base de datos de prueba.

@ricardograca ¿

De lo contrario, es fácil de solucionar (solo es necesario truncar todas las tablas con una sola consulta) :)

@elhigu ¿Cómo puedes hacer eso?

@kibertoad Sí, maneja muy bien las restricciones de clave externa y elimina todo.

@odigity, ¿y si probamos una estructura así?

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

A su manera, todas las funciones deben tener argumentos adicionales (por ejemplo, trx ) para pasar la transacción a los constructores de consultas reales.
https://knexjs.org/#Builder -transacting

Creo que la forma correcta debe ser algo así:

  1. Cree trx en beforeEach .
  2. Inyectarlo de alguna manera. En mi ejemplo, require('./db') devolvería el valor trx.
  3. Haz pruebas.
  4. Revertir trx en 'afterEach'.

Pero, no sé si es posible con knex.
¿Qué pasa si el código usa otras transacciones?

También otra opción: tal vez haya alguna función que comience a ejecutar la consulta. Entonces podemos anularlo para forzar la ejecución en la transacción de prueba.

Después de un día de leer el código knex, pruebo este enfoque:

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();
});

Y funciona un poco. Así que anulo los métodos .raw y .client.runner . .client.runner llama internamente cuando .then un generador de consultas. db en esta función es cliente knex knex({ /* config */}) .

@Niklv como ya expliqué aquí; usar transacciones para restablecer los datos de prueba es generalmente una mala idea e incluso lo consideraría como un anti-patrón. También lo son los aspectos internos de knex. Truncar + repoblar db en cada prueba o para un conjunto de pruebas es una recomendación. No se necesitan muchos milisegundos si los datos de prueba tienen un tamaño razonable.

¿Fue útil esta página
0 / 5 - 0 calificaciones

Temas relacionados

fsebbah picture fsebbah  ·  3Comentarios

tjwebb picture tjwebb  ·  3Comentarios

koskimas picture koskimas  ·  3Comentarios

PaulOlteanu picture PaulOlteanu  ·  3Comentarios

mattgrande picture mattgrande  ·  3Comentarios