Faça amigos CI, testes de unidade e banco de dados

imagem

O artigo é sobre como testar a interação com o banco de dados no IC. Vi várias soluções usando docker e testcontainers, mas tenho o meu e quero compartilhá-lo.

Meu projeto java passado estava intimamente ligado a um banco de dados. Processamento longo com tentativas, multithreading e bloqueios de bloqueio. Para a tarefa, foi necessário corrigir algumas consultas SQL complicadas. De alguma forma, eu estava acostumado a cobrir meu código com testes, mas antes disso todo o SQL era reduzido a consultas primitivas e podia ser executado em uma base H2 na memória. E aqui no hardcore.

Eu poderia testar o SQL simples com minhas mãos e covardemente martelar nos autotestes, justificando-me: "Eu não sou algum tipo de modelo, vou cometer erros no código simples". Na verdade, os erros aparecem com menos frequência devido à disponibilidade de testes. Era possível atribuir responsabilidade aos testadores - se eu cometesse algum erro, eles o encontrariam.

imagem

De acordo com a ideologia do teste de unidade, os testes são necessários apenas para testar módulos individuais e, se o módulo usar algo externo, isso deverá ser substituído por um esboço. Na prática, quando se torna muito difícil implementar um stub, o módulo é simplesmente ignorado. Velocidade e modularidade são importantes, mas é mais importante não deixar o código não testado, mesmo que não apareça nas métricas de cobertura. Portanto, o módulo não é mais considerado uma classe separada, mas um grupo conectado, juntamente com a configuração. O principal com essas declarações não é chegar ao conceito de unidade que se estende ao nível do cluster.

Para testes locais, cada desenvolvedor tem seu próprio esquema, mas os testes são executados no Jenkins e ainda não há conexão com o banco de dados. O CI precisa de um circuito separado, parece óbvio. Mas em um diagrama vazio, não é muito correto executar testes; a criação de uma estrutura de banco de dados em cada teste consome tempo e está repleta de discrepâncias entre a estrutura no teste e na batalha. Executar em uma base pré-preparada - eu tenho um monte de problemas com ramificações. Você pode preparar o banco de dados antes de executar todos os testes usando o liquibase, primeiro limpando tudo para zero e depois atualizando para a versão mais recente.

A reversão costuma ser esquecida para finalizar e você precisa limpar as bases com as mãos nos ambientes de teste. Teste e ele! O algoritmo é o seguinte:

  1. remova tudo sob a raiz (para a pureza do experimento)
  2. atualizar para a versão mais recente
  3. executar reversão 1-2 versões de volta
  4. atualizar para a versão mais recente (os testes precisam ser conduzidos na nova estrutura do banco de dados, além de verificar se a reversão não esqueceu de excluir nada, o que impedirá a atualização de rolar novamente)

Outros desenvolvedores não desejam executar testes de reversão ao iniciar cada teste. Nós fazemos a troca.

project.ext.doRollbackTest = { Boolean.parseBoolean(localConfig['test.rollback.enabled'] as String) } 

Enquanto houver treinamento em gatos, está tudo bem. Mas um projeto em desenvolvimento dinâmico faz seus próprios ajustes - 2 pull-quests, montagem simultânea. Uma instância está testando alguma coisa, e a segunda está derrubando a base sob seus pés. É resolvido com uma simples proibição de montagens paralelas.

imagem

E novamente uma queda - eu decidi executar testes usando a conta Jenkins, porque tudo está bem no pessoal, e o conjunto de solicitações cai por motivos pouco claros. Recordamos a conversa fervorosa de que o DevOps é uma cultura e o uso de contas técnicas para fins pessoais é inaceitável.

imagem

Acumulado 10 pool de solicitações. Todos coletados, redistribuídos, você pode mesclar. O primeiro foi, o ramo principal mudou - o resto fica na fila para a reconstrução. Você pode mesclar à medida que avança, mas há prioridades. Pedidos de solicitação mais urgentes, menos urgentes, também estão sendo desligados devido a erros no código. Em geral, paralelize de volta.

A montagem deve ser simples, composta por etapas simples e compreensível até para os alunos de ontem. Em 99%, não há problema em que a montagem do conjunto de solicitações e liberações é seqüencial e não paralela. Se a revisão não acumular mais do que 1-2 PR, a proibição de montagens simultâneas é suficiente.

E para o lançamento paralelo, precisamos de bases ou esquemas aos quais cada teste lançado terá acesso exclusivo.

A primeira opção é alocar dinamicamente. Criar um esquema no banco de dados é rápido. Tendo uma nuvem com uma API, você pode alocar um banco de dados lá.

Se você não excluir a exclusão de bancos de dados antigos, poderá terminar rapidamente o espaço em disco quando os testes caírem e "esquecer" para liberar recursos. É "quando", não "se".

Opção dois - um pool de banco de dados / esquema com um serviço de gerenciamento separado. Fora da API, forneça a Base for the Time, recupere a Base Free Before the Term. O que retornará: um servidor dedicado com um banco de dados ou apenas um pequeno esquema, não importa. O principal é que o recurso não será perdido para sempre.

Opção três - um conjunto de bases / esquemas de auto-regulação. É necessário um recurso para a troca de informações sobre bloqueios e o próprio pool de banco de dados.

Optei pela última opção, pois é mais fácil fixá-la e não é particularmente necessário apoiá-la. A lógica é a seguinte - vários circuitos (10 por exemplo) são criados e todas as informações necessárias sobre a conexão a eles são adicionadas ao recurso compartilhado, cada instância de teste faz uma marca de início antes de iniciá-lo e, após o final, o exclui. Se o teste falhar antes de finalizar, o circuito será considerado livre no final do tempo limite.

Configurações de leitura:

  project.ext.localConfig = new Properties() localConfig.load(file("${rootDir}/local.properties").newReader()) 

Trabalhar com sql a partir de scripts gradle requer o carregamento do driver:
  configurations { driver } dependencies { driver group: "oracle", name: "ojdbc6", version: "11.+" } task initDriver { doLast { ClassLoader loader = GroovyObject.class.classLoader configurations.driver.each { File file -> loader.addURL(file.toURL()) } } } 

Conexão:

  import groovy.sql.Sql project.ext.createSqlInstance = { return Sql.newInstance( url: localConfig["pool.db.url"], user: localConfig["pool.db.username"], password: localConfig["pool.db.password"], driver: localConfig["pool.db.driverClass"]) } 

A troca de informações pode ser realizada através da tabela do banco de dados. Inicialização da tabela de referência (ela deve funcionar uma vez, e a tabela permanecerá até o final dos tempos):

  task initDbPool { dependsOn initDriver doLast { Integer poolSize = 10 Sql sql = createSqlInstance() as Sql String tableName = localConfig["pool.db.referenceTable"] String baseName = localConfig["pool.db.baseName"] String basePass = localConfig["pool.db.basePass"] String token = "{id}" List tableExists = sql.rows("select table_name from all_tables where table_name=?", [tableName]) assert tableExists.isEmpty() sql.execute(""" CREATE TABLE ${tableName} ( ID NUMBER(2) NOT NULL PRIMARY KEY, METADATA VARCHAR2(200) NOT NULL, PROCESSED TIMESTAMP NULL, GUID VARCHAR2(36) NULL) """, []) for (Integer i = 0 ; i < poolSize ; i++) { String username = baseName.replace(token, i.toString()) String password = basePass.replace(token, i.toString()) sql.execute(""" CREATE USER ${username} IDENTIFIED BY "${password}" DEFAULT TABLESPACE USERS TEMPORARY TABLESPACE TEMP PROFILE DEFAULT QUOTA UNLIMITED ON USERS """, []) sql.execute("grant connect to ${username}", []) sql.execute("grant create sequence to ${username}", []) sql.execute("grant create session to ${username}", []) sql.execute("grant create table to ${username}", []) String metadata = JsonOutput.toJson([ "app.db.driverClass": localConfig["pool.db.driverClass"], "app.db.url": localConfig["pool.db.url"], "app.db.username": username, "app.db.password": password ]) sql.execute(""" INSERT INTO ${tableName} (id, metadata) values (?, ?) """, [i, metadata]) } } } 

Os desenvolvedores têm seus próprios esquemas de depuração e montagem, portanto, o uso do pool deve ser desativado:

  project.ext.isCiBuild = { Boolean.parseBoolean(localConfig['pool.db.enabled'] as String) } 

Tomar e base livre:

  task lockDb { dependsOn initDriver onlyIf isCiBuild doLast { project.ext.lockUid = UUID.randomUUID().toString() String tableName = localConfig["pool.db.referenceTable"] Sql sql = createSqlInstance() as Sql sql.executeUpdate("""UPDATE ${tableName} SET GUID = ?, PROCESSED = SYSDATE WHERE ID IN ( SELECT ID FROM ( SELECT ID, ROW_NUMBER() OVER (ORDER BY PROCESSED) AS RN FROM ${tableName} WHERE GUID IS NULL OR PROCESSED < (SYSDATE - NUMTODSINTERVAL(?, 'MINUTE')) ) WHERE RN = 1 ) """, [lockUid, 15]) def meta = sql.firstRow("SELECT METADATA FROM ${tableName} WHERE GUID = ?", [lockUid]) assert meta != null, "No free databases in pool" def slurper = new JsonSlurper() Map metadata = slurper.parseText(meta["METADATA"] as String) as Map localConfig.putAll(metadata) logger.info("Database locked, {}", metadata) } } task unlockDb { dependsOn lockDb // init lockUid onlyIf isCiBuild doLast { try { String tableName = localConfig["pool.db.referenceTable"] Sql sql = createSqlInstance() as Sql sql.executeUpdate("UPDATE ${tableName} SET GUID = NULL WHERE GUID = ?", [lockUid]) logger.info("Database unlocked") } catch (ignored) { logger.error(ignored) } } } 

Se você concluir a montagem duas vezes seguidas, esquemas diferentes poderão ser selecionados e valores diferentes permanecerão ao montar os arquivos de propriedades. Para lançamentos locais, as configurações são estáticas.

  configure([processResources, processTestResources]) { Task t -> if (project.ext.isCiBuild()) { t.outputs.upToDateWhen { false } } t.filesMatching('**/*.properties') { filter(ReplaceTokens, tokens: localConfig, beginToken: '${', endToken: '}') } } 

Tarefas para testar a reversão:

  task restoreAfterRollbackTest(type: LiquibaseTask) { command = 'update' } task rollbackTest(type: LiquibaseTask) { dependsOn lockDb command = 'rollback' requiresValue = true doFirst { project.ext.liquibaseCommandValue = localConfig['test.rollback.tag'] } doLast { project.ext.liquibaseCommandValue = null } } 

E configure a ordem de execução:

  configure([project]) { tasks.withType(LiquibaseTask.class) { LiquibaseTask t -> logger.info("Liquibase task {} must run after {}", t.getName(), configLiquibase.getName()) (t as Task).dependsOn configLiquibase if (isCiBuild()) { logger.info("Liquibase task {} must run after {}", t.getName(), lockDb.getName()) (t as Task).dependsOn lockDb (t as Task).finalizedBy unlockDb } } //   CI: // 1.    0 (dropAll) // 2.     rollback (update) // 3. ,       (rollback tag) // 4.      (update) // 5.     if (doRollbackTest()) { def setTaskOrdering = { List<Task> lst -> for (int i = 0; i < lst.size() - 1; i++) { logger.info("Task {} must run after {}", lst[i + 1].getName(), lst[i].getName()) lst[i + 1].dependsOn lst[i] } } setTaskOrdering([ lockDb, configLiquibase, dropAll, update, rollbackTest, restoreAfterRollbackTest, processTestResources, test, ]) lockDb.finalizedBy unlockDb test.finalizedBy unlockDb } } 

A alocação de banco de dados e o teste de reversão podem ser colocados em testes. Maneiras de executar o código antes e após a conclusão de todos os testes: no Junit5, é BeforeAllCallback, no TestNG BeforeSuite.

Para a pergunta "por que o sql deve ser testado pelo programador java", a resposta é que qualquer código deve ser testado. Há exceções e algum código é irracional para testar em um determinado momento.

Eu gostaria de saber como o problema de testar a interação com o banco de dados é resolvido por outros programadores? Os contêineres chegaram em todas as residências ou os testes de integração foram repassados ​​aos ombros dos testadores?

Listagem completa
 import groovy.json.JsonOutput import groovy.json.JsonSlurper import groovy.sql.Sql import org.apache.tools.ant.filters.ReplaceTokens import org.liquibase.gradle.LiquibaseTask plugins { id 'java' id 'org.liquibase.gradle' version '2.0.1' } configurations { driver } repositories { jcenter() mavenCentral() maven { url = "http://www.datanucleus.org/downloads/maven2/" } } dependencies { implementation 'com.google.guava:guava:27.0.1-jre' implementation 'org.springframework:spring-core:5.1.7.RELEASE' implementation 'org.springframework:spring-context:5.1.7.RELEASE' implementation 'org.springframework:spring-jdbc:5.1.7.RELEASE' testImplementation 'junit:junit:4.12' testImplementation 'org.springframework:spring-test:5.1.7.RELEASE' testRuntime 'oracle:ojdbc6:11.+' liquibaseRuntime 'org.liquibase:liquibase-core:3.6.1' liquibaseRuntime 'oracle:ojdbc6:11.+' liquibaseRuntime 'org.yaml:snakeyaml:1.24' driver group: "oracle", name: "ojdbc6", version: "11.+" } project.ext.localConfig = new Properties() localConfig.load(file("${rootDir}/local.properties").newReader()) project.ext.isCiBuild = { Boolean.parseBoolean(localConfig['pool.db.enabled'] as String) } project.ext.doRollbackTest = { Boolean.parseBoolean(localConfig['test.rollback.enabled'] as String) } task configLiquibase { doLast { liquibase { activities { testdb { changeLogFile 'changelog.yaml' url localConfig['app.db.url'] driver localConfig['app.db.driverClass'] username localConfig['app.db.username'] password localConfig['app.db.password'] logLevel 'debug' classpath "${project.projectDir}/db" contexts 'main' } runList = 'testdb' } } } } task initDriver { doLast { ClassLoader loader = GroovyObject.class.classLoader configurations.driver.each { File file -> loader.addURL(file.toURL()) } } } project.ext.createSqlInstance = { return Sql.newInstance( url: localConfig["pool.db.url"], user: localConfig["pool.db.username"], password: localConfig["pool.db.password"], driver: localConfig["pool.db.driverClass"]) } task initDbPool { dependsOn initDriver doLast { Integer poolSize = 10 Sql sql = createSqlInstance() as Sql String tableName = localConfig["pool.db.referenceTable"] String baseName = localConfig["pool.db.baseName"] String basePass = localConfig["pool.db.basePass"] String token = "{id}" List tableExists = sql.rows("select table_name from all_tables where table_name=?", [tableName]) assert tableExists.isEmpty() sql.execute(""" CREATE TABLE ${tableName} ( ID NUMBER(2) NOT NULL PRIMARY KEY, METADATA VARCHAR2(200) NOT NULL, PROCESSED TIMESTAMP NULL, GUID VARCHAR2(36) NULL) """, []) for (Integer i = 0 ; i < poolSize ; i++) { String username = baseName.replace(token, i.toString()) String password = basePass.replace(token, i.toString()) sql.execute(""" CREATE USER ${username} IDENTIFIED BY "${password}" DEFAULT TABLESPACE USERS TEMPORARY TABLESPACE TEMP PROFILE DEFAULT QUOTA UNLIMITED ON USERS """, []) sql.execute("grant connect to ${username}", []) sql.execute("grant create sequence to ${username}", []) sql.execute("grant create session to ${username}", []) sql.execute("grant create table to ${username}", []) String metadata = JsonOutput.toJson([ "app.db.driverClass": localConfig["pool.db.driverClass"], "app.db.url": localConfig["pool.db.url"], "app.db.username": username, "app.db.password": password ]) sql.execute(""" INSERT INTO ${tableName} (id, metadata) values (?, ?) """, [i, metadata]) } } } task lockDb { dependsOn initDriver onlyIf isCiBuild doLast { project.ext.lockUid = UUID.randomUUID().toString() String tableName = localConfig["pool.db.referenceTable"] Sql sql = createSqlInstance() as Sql sql.executeUpdate("""UPDATE ${tableName} SET GUID = ?, PROCESSED = SYSDATE WHERE ID IN ( SELECT ID FROM ( SELECT ID, ROW_NUMBER() OVER (ORDER BY PROCESSED) AS RN FROM ${tableName} WHERE GUID IS NULL OR PROCESSED < (SYSDATE - NUMTODSINTERVAL(?, 'MINUTE')) ) WHERE RN = 1 ) """, [lockUid, 15]) def meta = sql.firstRow("SELECT METADATA FROM ${tableName} WHERE GUID = ?", [lockUid]) assert meta != null, "No free databases in pool" def slurper = new JsonSlurper() Map metadata = slurper.parseText(meta["METADATA"] as String) as Map localConfig.putAll(metadata) logger.info("Database locked, {}", metadata) } } task unlockDb { dependsOn lockDb // init lockUid onlyIf isCiBuild doLast { try { String tableName = localConfig["pool.db.referenceTable"] Sql sql = createSqlInstance() as Sql sql.executeUpdate("UPDATE ${tableName} SET GUID = NULL WHERE GUID = ?", [lockUid]) logger.info("Database unlocked") } catch (ignored) { logger.error(ignored) } } } configure([processResources, processTestResources]) { Task t -> if (project.ext.isCiBuild()) { t.outputs.upToDateWhen { false } } t.filesMatching('**/*.properties') { filter(ReplaceTokens, tokens: localConfig, beginToken: '${', endToken: '}') } } task restoreAfterRollbackTest(type: LiquibaseTask) { command = 'update' } task rollbackTest(type: LiquibaseTask) { dependsOn lockDb command = 'rollback' requiresValue = true doFirst { project.ext.liquibaseCommandValue = localConfig['test.rollback.tag'] } doLast { project.ext.liquibaseCommandValue = null } } configure([project]) { tasks.withType(LiquibaseTask.class) { LiquibaseTask t -> logger.info("Liquibase task {} must run after {}", t.getName(), configLiquibase.getName()) (t as Task).dependsOn configLiquibase if (isCiBuild()) { logger.info("Liquibase task {} must run after {}", t.getName(), lockDb.getName()) (t as Task).dependsOn lockDb (t as Task).finalizedBy unlockDb } } //   CI: // 1.    0 (dropAll) // 2.     rollback (update) // 3. ,       (rollback tag) // 4.      (update) // 5.     if (doRollbackTest()) { def setTaskOrdering = { List<Task> lst -> for (int i = 0; i < lst.size() - 1; i++) { logger.info("Task {} must run after {}", lst[i + 1].getName(), lst[i].getName()) lst[i + 1].dependsOn lst[i] } } setTaskOrdering([ lockDb, configLiquibase, dropAll, update, rollbackTest, restoreAfterRollbackTest, processTestResources, test, ]) lockDb.finalizedBy unlockDb test.finalizedBy unlockDb } } 

 pool.db.enabled=false test.rollback.enabled=true pool.db.driverClass=oracle.jdbc.driver.OracleDriver pool.db.url=jdbc:oracle:thin:@localhost:1527:ORCLCDB pool.db.username=SYSTEM pool.db.password=Oradoc_db1 pool.db.referenceTable=c##test_user1.REF_TABLE pool.db.baseName=C##CI_SCHEMA_{id} pool.db.basePass=CI_SCHEMA_{id}_PASS app.db.driverClass= app.db.url= app.db.username= app.db.password= test.rollback.tag=version_1 

Source: https://habr.com/ru/post/pt452722/


All Articles