Knex: Como escrever testes de unidade para métodos que usam Knex.

Criado em 21 mai. 2017  ·  28Comentários  ·  Fonte: knex/knex

(Postado originalmente em # 1659, movido aqui para uma discussão mais aprofundada).

Estou meio que no escuro quanto a isso - os documentos do knex cobrem o básico das transações, mas quando procuro no google "usando knex.js para testes de unidade" e "ava.js + knex" e "ava.js + banco de dados" , Não consegui encontrar nada particularmente instrutivo para me mostrar um bom caminho, então recorri à minha experiência em Ruby, em que envolver um teste de unidade em uma transação é uma técnica comum para redefinir o banco de dados após cada teste.

@odigity Eu não sugeriria fazer isso porque seus testes acabarão usando apenas uma conexão em vez do pool de conexão e não representará o uso do seu aplicativo no mundo real.

Isso me ocorreu, mas eu estava disposto a aceitar isso, se me permitisse usar um método simples de escrever uma vez para implementar a limpeza de banco de dados pós-teste. (Eu defini meu pool mín. / Máx. Para 1 para ter certeza.) Se houver uma maneira de fazer isso e ao mesmo tempo oferecer suporte a conexões simultâneas, vou aceitar isso.

Além disso, é quase impossível de implementar, a menos que o aplicativo que você está testando esteja executando suas consultas na mesma transação que foi iniciada dentro do código de teste (você precisaria iniciar a transação em teste, em seguida, iniciar seu aplicativo e passar a transação criada para o aplicativo para que faz todas as consultas para a mesma transação e as transações aninhadas podem se comportar estritamente ...).

A maneira que eu esperava / esperava que funcionasse é:

1) Em cada teste de unidade, crio uma transação que produz trx .

2) Eu então solicito o módulo que desejo testar e passo o objeto trx para o construtor do módulo para que ele seja usado pelo módulo, resultando em todas as consultas ocorrendo dentro da transação.

3) Depois que o método do módulo retorna (ou lança um erro), eu executo minhas asserções no estado resultante do banco de dados e chamo trx.rollback() para desfazer tudo desde o início e me preparar para o próximo teste.

Então, é isso que estou tentando alcançar e como originalmente pretendia alcançá-lo. Estou ansioso para aprender mais sobre:

1) Por que estou entendendo mal como Knex.js funciona e deve ser usado.

2) Melhores práticas para escrever testes de unidade atômica para código que toca o banco de dados.

question

Comentários muito úteis

Não - não tenho nada. Resumo em ordem cronológica das fontes:

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

Estratégia: Use mock-knex . Não quero zombar do banco de dados - quero testar meus métodos em um banco de dados MySQL real para garantir o comportamento correto - mas dei uma olhada em mock-knex qualquer maneira ... pode ser apenas a biblioteca com o pior design Eu já encontrei. :(

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

Estratégia: Reverter / migrar novamente / propagar novamente o banco de dados após cada teste. Isso parece uma grande sobrecarga para cada teste e será terrivelmente lento. A simultaneidade pode ser alcançada gerando um UUID para o nome do banco de dados de cada teste, mas isso parece uma solução terrível em comparação com a elegância das transações ...

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

Estratégia: Use sqlite para teste, crie / destrua o banco de dados para cada teste. Abordei os dois motivos pelos quais não gosto dessa abordagem acima.

Então ... ainda buscando sugestões e orientações adicionais sobre como as transações Knex realmente funcionam, bem como como aplicá-las adequadamente ao meu caso de uso.

Todos 28 comentários

Aparentemente, fiz um péssimo trabalho pesquisando no Google ontem à noite, porque acabei de tentar novamente com "como escrever testes de unidade atômica com knex.js" e estou obtendo alguns resultados interessantes. Vou lê-los agora. Postarei novamente se eu aprender algo.

Não - não tenho nada. Resumo em ordem cronológica das fontes:

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

Estratégia: Use mock-knex . Não quero zombar do banco de dados - quero testar meus métodos em um banco de dados MySQL real para garantir o comportamento correto - mas dei uma olhada em mock-knex qualquer maneira ... pode ser apenas a biblioteca com o pior design Eu já encontrei. :(

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

Estratégia: Reverter / migrar novamente / propagar novamente o banco de dados após cada teste. Isso parece uma grande sobrecarga para cada teste e será terrivelmente lento. A simultaneidade pode ser alcançada gerando um UUID para o nome do banco de dados de cada teste, mas isso parece uma solução terrível em comparação com a elegância das transações ...

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

Estratégia: Use sqlite para teste, crie / destrua o banco de dados para cada teste. Abordei os dois motivos pelos quais não gosto dessa abordagem acima.

Então ... ainda buscando sugestões e orientações adicionais sobre como as transações Knex realmente funcionam, bem como como aplicá-las adequadamente ao meu caso de uso.

@odigity resumiu muito bem as más práticas de teste 👍

Estamos fazendo nossos testes de "unidade" como este:

  1. Inicie o sistema, inicialize o banco de dados e execute as migrações

  2. Antes de cada teste, truncamos todas as tabelas e sequências (com o pacote knex-db-manager)

  3. Insira os dados necessários para o caso de teste (usamos knex com base em objection.js ORM para o que nos permite inserir hierarquias de objetos aninhados com um único comando, ele sabe como otimizar inserções de modo que não tenha que fazer inserções separadas para cada linha na tabela, mas geralmente apenas uma inserção por tabela)

  4. execute 1 teste e vá para a etapa 2

Com os testes e2e, implementamos métodos saveState / restoreState (com pg_restore / pg_dump), o que nos permite reverter a determinado estado durante a execução do teste, para que não tenhamos que reiniciar a execução do teste toda vez que algum teste falhar após a execução 20 minutos de testes.

É semelhante ao que comecei a fazer ontem porque é simples e direto e eu precisava fazer progressos.

Você considerou a estratégia de agrupar testes em transações e reverter depois como uma alternativa mais rápida para truncar todas as tabelas? Isso parece o ideal para mim, se eu conseguir descobrir a implementação.

Suponho que se você executar o db e o código do aplicativo no mesmo processo, você pode criar a transação no código de teste e, em seguida, registrar essa transação como sua instância do knex (a instância e a transação do knex parecem um pouco diferentes em alguns casos, mas normalmente você pode usar a transação instância como instância normal do knex).

Em seguida, você inicia o código do aplicativo, que busca a transação em vez da instância knex agrupada normal e começa a fazer consultas por meio dela.

Praticamente da mesma maneira que você descreveu em OP. Como você descreveu, parece viável.

Eu considerei o uso de transações para redefinir o banco de dados em testes alguns anos atrás, mas rejeitei porque quero que o pool de conexão funcione em testes da mesma forma que funciona em app + truncate / init é rápido o suficiente para nós.

Não há como atingir a afinidade de conexão durante a transação de forma que todas as consultas executadas em um lote usem a mesma conexão e, portanto, possam ser agrupadas em uma única transação?

A estratégia de truncamento funciona, mas é muito forte. Atualmente, tenho um gancho test.after.always () em cada arquivo de teste que trunca as tabelas afetadas pelos testes nesse arquivo (geralmente uma tabela por arquivo), mas posso pensar em casos extremos que seriam complicados por isso.

Por exemplo, se dois testes de arquivos de teste diferentes que tocam na mesma tabela forem executados ao mesmo tempo, o gancho de truncamento de um arquivo pode ser iniciado enquanto os testes do segundo arquivo estão no meio da execução, bagunçando esse teste.

Por fim, ainda não estou certo de como as transações funcionam no Knex. Se eu criar um trx com knex.transaction() , todas as consultas executadas usando trx automaticamente farão parte da transação? Tenho que confirmar / reverter manualmente? (Supondo que não haja erros de lançamento.)

Não há como atingir a afinidade de conexão durante a transação de forma que todas as consultas executadas em um lote usem a mesma conexão e, portanto, possam ser agrupadas em uma única transação?

Eu não entendi isso

A estratégia de truncamento funciona, mas é muito forte. Atualmente, tenho um gancho test.after.always () em cada arquivo de teste que trunca as tabelas afetadas pelos testes nesse arquivo (geralmente uma tabela por arquivo), mas posso pensar em casos extremos que seriam complicados por isso.

Por exemplo, se dois testes de arquivos de teste diferentes que tocam na mesma tabela forem executados ao mesmo tempo, o gancho de truncamento de um arquivo pode ser iniciado enquanto os testes do segundo arquivo estão no meio da execução, bagunçando esse teste.

Mesmo se você estiver usando transações para redefinir seus dados de teste, você não poderá executar vários testes em paralelo no caso geral. As sequências de ID serão diferentes e as transações podem travar etc.

Por fim, ainda não estou certo de como as transações funcionam no Knex. Se eu criar um trx com knex.transaction (), todas as consultas executadas usando trx farão parte da transação automaticamente? Tenho que confirmar / reverter manualmente? (Supondo que não haja erros de lançamento.)

Este é encontrado na documentação. Se você não retornar a promessa do retorno de chamada em knex.transaction(callback) você precisa confirmar / reverter manualmente. Se o retorno de chamada retornar a promessa, o commit / rollback será chamado automaticamente. No seu caso, você provavelmente terá que fazer rollback manualmente.

Mesmo se você estiver usando transações para redefinir seus dados de teste, você não poderá executar vários testes em paralelo no caso geral. As sequências de ID serão diferentes e as transações podem travar etc.

Estou gerando IDs aleatoriamente para cada registro que insiro. As colisões são possíveis, mas improváveis, e se acontecerem uma vez por dia, é apenas um teste - não um código voltado para o cliente.

Quanto à simultaneidade, estou assumindo que todas as consultas que precisam ser agrupadas em uma única transação também devem usar a mesma conexão de banco de dados, o que posso conseguir definindo o tamanho do meu pool Knex para 1. Eu então precisaria fazer os testes em um serial do arquivo com o modificador .serial . No entanto, eu ainda teria simultaneidade entre os arquivos de teste (que é o fator mais significativo para o desempenho) porque o Ava executa cada arquivo de teste em um processo filho separado.

Acho que devo tentar.

Eu fiz funcionar!

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

Estou usando o Ava para teste de unidade em meu projeto, mas, para este exemplo, acabei de criar um cenário de teste de unidade simulado com ganchos antes / depois para demonstrar o conceito.

Antes do gancho

  • Como obter uma transação é uma ação assíncrona, crio uma nova promessa de retornar do gancho before e capturar seu método resolve para que ele possa ser chamado no retorno de chamada transaction() .
  • Eu abro uma transação. No callback, eu ...

    • salve o identificador tx em um lugar que seja acessível para o teste e after gancho

    • chame o método resolve salvo para informar ao "framework de teste" que o gancho before foi concluído e o teste pode começar

Teste - eu executo duas consultas de inserção usando o identificador tx salvo.

Após o gancho - eu uso o identificador tx salvo para reverter a transação, desfazendo todas as alterações feitas durante o teste. Como os dados nunca são confirmados, eles não podem nem mesmo ser vistos ou interferir na atividade de outros testes.

Em simultaneidade

Knex

Knex permite que você especifique opções para o tamanho do pool de conexão. Se você precisar garantir que todas as consultas em uma transação sejam executadas na mesma conexão (presumo que precisamos), você pode fazer isso definindo o tamanho do pool para 1.

_Mas espere ..._ verifique este comentário na fonte :

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

Se eu entendi corretamente, significa que a Knex pode ser confiável para garantir que todas as consultas em uma transação passem pela mesma conexão, mesmo se o seu pool for maior que 1! Portanto, não precisamos sacrificar esse grau específico de simultaneidade em nosso cenário.

BTW - Os documentos provavelmente devem refletir esse fato essencial e maravilhoso.

Ava

_Eu conheço isso específico do Ava em vez de específico do Knex, mas é uma estrutura popular e as lições são amplamente aplicáveis ​​à maioria das estruturas._

Ava executa cada arquivo de teste em um processo separado simultaneamente. Dentro de cada processo, ele também executa todos os testes simultaneamente. Ambas as formas de concorrência são disableable usando o --serial opção CLI (que serializa tudo) ou o .serial modificador sobre o test método (que serializa os testes marcados antes de executar o resto concomitantemente )

Embrulhar

Quando eu coloco todos esses fatos juntos:

  • Posso agrupar cada teste em uma transação, o que garante (a) não colisão de dados de teste (b) limpeza automática pós-teste sem truncamento ou remigração. Na verdade, usando essa estratégia, meu banco de dados de teste literalmente nunca terá um único registro persistido nele, exceto, talvez, os dados de semente essenciais.

  • Posso continuar a me beneficiar da simultaneidade entre os arquivos de teste do Ava, já que cada arquivo de teste é executado em um processo separado e, portanto, terá seu próprio pool de conexão Knex.

  • Posso continuar a me beneficiar da simultaneidade intra-testfile do Ava se garantir que meu pool de conexão é> = o número de testes no arquivo. Isso não deve ser um problema, eu não coloco um grande número de testes em um único arquivo, o que tento evitar de qualquer maneira. Além disso, o pool pode ser definido com um intervalo de 1 a 10, portanto, você não está criando conexões desnecessárias, mas não precisa ajustar uma constante em cada arquivo de teste sempre que adicionar ou remover um teste.


Ansioso por obter os pensamentos de outras pessoas, enquanto estou me intrometendo na interseção de um grande número de tecnologias que são novas para mim ao mesmo tempo ...

@odigity sim, a transação no knex é basicamente apenas um identificador para a conexão dedicada onde o knex adiciona automaticamente BEGIN consulta quando a instância trx é criada (na verdade, a transação no SQL geralmente é apenas consultas indo para a mesma conexão prefixada com BEGIN).

Se o knex não alocasse a conexão para a transação, então as transações simplesmente não funcionariam.

Usar chaves uuid também não é ruim, a mudança de colisão é realmente improvável, a menos que haja muitas linhas. ("Se você usar um UUID de 128 bits, o 'efeito de aniversário' nos informa que é provável que haja uma colisão após você ter gerado cerca de 2 ^ 64 chaves, desde que haja 128 bits de entropia em cada chave.")

Suponho que você tenha suas respostas planejadas, então fechando isto

Desculpe por reabrir, mas agora estou observando um comportamento inesperado. Especificamente, está funcionando quando não deveria, o que significa que meu entendimento precisa ser corrigido.

Se eu definir o tamanho do pool como 1, abrir uma transação e, em seguida, executar uma consulta sem usá-la, o tempo limite é atingido porque não é possível obter uma conexão - o que faz sentido, porque essa conexão já foi bloqueada pela transação.

No entanto, se eu definir o tamanho do pool para 1 e executar quatro testes simultaneamente, cada um dos quais:

  • abre uma transação
  • executa consultas
  • chama reversão no final

Todos eles funcionam. Eles não deveriam funcionar - pelo menos alguns deles deveriam ser bloqueados pela escassez de conexão - mas todos eles funcionam bem, sempre.

O Knex possui enfileiramento integrado para consultas quando nenhuma conexão estiver disponível no momento? Em caso afirmativo, por que meu primeiro exemplo falha, em vez de esperar a conclusão da transação antes de executar a consulta não-tx?

Ou Knex de alguma forma multiplexa várias transações simultâneas em uma única conexão?

Ou o Knex está realmente criando subtransações na 2ª, 3ª e 4ª invocação? Em caso afirmativo, isso provavelmente causará resultados esperados aleatoriamente ...

@odgity No primeiro caso, você tem um deadlock do lado do aplicativo (seu aplicativo está preso esperando a conexão e os gatilhos de tempo de espera de activateConnection).

No segundo caso, os testes são realmente executados em série, já que todos, exceto um, estão esperando pela conexão. Se você tiver testes suficientes fazendo isso em paralelo ou se seus casos de teste forem muito lentos, o takeConnectionTimeout deve ser acionado no segundo caso também.

Transações de multiplexação em uma única conexão não são possíveis. As transações de aninhamento são suportadas pelo knex usando pontos de salvamento dentro da transação. Se você estiver solicitando uma nova transação do pool, isso também não acontecerá.

Obrigado, isso é realmente útil.

Embora eu ainda esteja um pouco confuso sobre por que a execução de uma consulta trava quando a conexão é feita por uma transação aberta, mas a tentativa de abrir uma segunda nova transação espera pacientemente e, em seguida, é concluída.

Execute seu código com DEBUG = knex: * variável de ambiente e você verá o que o pool está fazendo. Knex deve esperar também no primeiro caso até que o tempo limite "Não consegui obter a conexão" aconteça. Portanto, se sua transação está esperando até que a segunda conexão seja adquirida, é um deadlock no nível do aplicativo porque ambas as conexões estão esperando uma à outra (não sei se este é o seu caso).

Eu achei este tópico muito útil. É bom ver que outras pessoas também se preocupam em escrever testes que não poluem o estado do sistema. Iniciei um projeto que permite que as pessoas escrevam testes para código que usa knex que irá reverter após a conclusão do teste.

https://github.com/bas080/knest

@ bas080 no caso geral, essa abordagem limita as coisas que você pode testar (permitindo que o teste use apenas uma única transação). Isso impedirá o teste de código que usa várias conexões / transações simultâneas. Também não se pode testar código que faça commits implícitos (embora não sejam casos muito comuns) dessa forma.

Eu sei que usar a transação para redefinir o estado após a execução do teste é um padrão bastante comum, o que quero enfatizar é que usar apenas essa abordagem impede que alguém teste certas coisas.

Sempre estive preferindo truncar e repopular o banco de dados após cada teste que modifica os dados ou após algum conjunto de testes que são dependentes uns dos outros (algumas vezes eu faço isso por motivos de desempenho se o preenchimento demorar muito, como mais de 50ms).

oi, desculpe por reabrir este problema, mas eu comecei um novo projeto que é um cli para knex para _semear vários bancos de dados com dados falsos, _ gostaria que você visse e me ajudasse com alguma contribuição

Para teste de unidade, só verifiquei se as consultas executadas pelo Knex a partir do meu invólucro SQL estão formadas corretamente, usando toString() no final da cadeia de consultas. Para teste de integração, estou empregando a estratégia já listada acima - isto é, fazer um ciclo: rollback -> migrar -> semente, antes de cada teste. Compreensivelmente, isso pode ser muito lento se você não puder manter seus dados iniciais pequenos, mas pode ser adequado para outros.

isto é, fazer um ciclo: rollback -> migrate -> seed, antes de cada teste.

Essa é uma maneira muito ruim de fazer isso. Executar migrações de um lado para outro leva facilmente centenas de milissegundos, o que é muito lento. Você deve executar migrações apenas uma vez para todo o conjunto de testes e, antes do teste, apenas truncar todas as tabelas e preencher os dados de teste adequados. Isso pode ser feito facilmente 100 vezes mais rápido do que reverter / recriar todo o esquema.

Você pode usar o knex-cleaner para truncar facilmente todas as tabelas:

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

Observe que não há necessidade de usar a parte ignoreTables se você estiver executando migrações no início de cada execução de suíte de teste. Isso só é necessário se você executar migrações manualmente em seu banco de dados de teste.

@ricardograca Ele lida bem com casos com chaves estrangeiras? (o que significa que a limpeza não falhará porque a ordem de exclusão está errada)

Caso contrário, isso é fácil de corrigir (basta truncar todas as tabelas com uma única consulta) :)

@elhigu Como você pode fazer isso?

@kibertoad Sim, ele lida muito bem com as restrições de chave estrangeira e apaga tudo.

@odigity e se nós quisermos testar uma estrutura como essa:

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

Do seu jeito, todas as funções devem ter arg adicional (por exemplo trx ) para passar a transação para os construtores de consulta reais.
https://knexjs.org/#Builder -transacting

Acho que o jeito certo deve ser algo assim:

  1. Crie trx em beforeEach .
  2. De alguma forma injete. No meu exemplo require('./db') deve retornar o valor trx.
  3. Faça testes.
  4. Reverter trx em 'afterEach'.

Mas, eu não sei, é possível com o Knex?
E se o código usar outras transações?

Outra opção: talvez haja alguma função que comece a executar a consulta. Portanto, podemos substituí-lo para forçar a execução na transação de teste.

Depois de um dia lendo o código Knex, tento esta abordagem:

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

E meio que funciona. Portanto, substituo os métodos .raw e .client.runner . .client.runner chama internamente quando você .then um construtor de consultas. db nestas funções é o cliente knex knex({ /* config */}) .

@Niklv como já expliquei aqui; usar a transação para redefinir os dados de teste geralmente é uma má ideia e eu até consideraria um antipadrão. O mesmo ocorre com os internos knex. Recomenda-se truncar + repopular db em cada teste ou para um conjunto de testes. Não leva muitos milissegundos para fazer se os dados de teste tiverem um tamanho razoável.

Esta página foi útil?
0 / 5 - 0 avaliações

Questões relacionadas

sandrocsimas picture sandrocsimas  ·  3Comentários

marianomerlo picture marianomerlo  ·  3Comentários

legomind picture legomind  ·  3Comentários

npow picture npow  ·  3Comentários

hyperh picture hyperh  ·  3Comentários