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.

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:
- remova tudo sob a raiz (para a pureza do experimento)
- atualizar para a versão mais recente
- executar reversão 1-2 versões de volta
- 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.

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.

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
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 } }
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
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