Skip to main content

Testes

Quando usar: sempre. O gate local do módulo é um comando:

export NNC_TEST_MYSQL_DSN='usuário:senha@tcp(127.0.0.1:3306)/' # a conta precisa de CREATE USER e de ALL … WITH GRANT OPTION sobre `nnt\_%`
./mvnw -q verify

Sem NNC_TEST_MYSQL_DSN, os testes de banco são pulados com a razão escrita no relatório. Nunca são trocados por um banco em memória: o que se está provando (collation, chave única, coluna JSON, linhas afetadas) é do MySQL.

Os três níveis​

NívelO que provaCustoExemplo no módulo
UnidadeA regra pura: cálculo, validação, mapeamentomsScanEngineTest, SampleMigrationsTest
HTTP com autorizaçãoO contrato: rota, permissionamento, Error Model, cabeçalhos~2 s, sem bancoHttpAuthorizationTest
MySQL realO que só o banco sabe: unicidade, transação, cursor, ledgersegundosItemStoreMysqlTest, ScanActionMysqlTest

E um quarto, de descritor, que roda em milissegundos e pega o que ninguém pega de outra forma: DescriptorsTest sobe os descritores embutidos (NnsModule.embedded) — como o serviço faz na partida — e confere rotas, permissões, trilhas, dependências e configuração.

Unidade: a regra sem infraestrutura​

class ScanEngineTest {

private static Item item(String name, String description) {
return Item.created("4", "0be1d3c6-6f3e-4a6b-a4f5-2a2f2a9f0d10", name, description,
"user:x", "sample@0.1.0", Instant.EPOCH);
}

@Test
void shortNameIsAnError() {
List<Finding> findings = ScanEngine.checks(item("ab", "Conta principal"));
assertThat(findings).extracting(Finding::check).containsExactly("name.length");
assertThat(findings.get(0).level()).isEqualTo("error");
assertThat(ScanEngine.score(findings)).isEqualTo(75);
}
}

O motor é função pura (recebe o item, devolve as ocorrências): nenhum mock, nenhum contexto, nenhum relógio.

HTTP com autorização: o serviço de verdade numa porta de verdade​

O arcabouço são três peças: as chaves (geradas em tempo de execução), o trust que diz quais emissores valem, e o token cunhado como o gateway cunha — preso ao método, ao alvo e ao corpo.

@BeforeAll
static void startTheService() throws Exception {
gatewayKey = TestKeys.key(GATEWAY);
Path trust = TestKeys.writeTrust(dir.resolve("trust"),
List.of(new TestKeys.Issuer(GATEWAY, "ctx", gatewayKey),
new TestKeys.Issuer(MODULE_MANAGER, "svc", TestKeys.key(MODULE_MANAGER))));
Path seed = TestKeys.writeSeedFile(dir.resolve("identity"), TestKeys.newSeed());
// … o registry do ambiente de teste e os slots de segredo …
NnsConfig config = NnsConfig.fromEnviron(env);
NnsModule module = NnsModule.embedded(SampleApplication.class);
context = NnsApplication.springApplication(SampleApplication.class, NnsCommand.SERVE, config, module).run();
port = ((ServletWebServerApplicationContext) context).getWebServer().getPort();
}

private static String ctxToken(ServiceContext c, String method, String target, byte[] body) {
Signer signer = Signer.of(GATEWAY, new KeyRing(GATEWAY, gatewayKey));
return Minter.of(signer)
.mint(c, Binding.of(method, RequestTarget.parse(method, target.getBytes(StandardCharsets.UTF_8)),
"", body == null ? new byte[0] : body, true));
}

As chaves são derivadas de um rótulo (sha256("nn-test-seed/svcauth/" + rótulo)) ou geradas na hora; nenhuma chave privada literal entra no repositório, e nada é impresso no log do build.

E, como a rota interna é mutação privilegiada, o teste do serviço entrega a proteção contra replay pela costura do SDK (com banco seria o nn_jti_replay):

app.addInitializers(ctx -> ((GenericApplicationContext) ctx)
.registerBean("testReplay", ReplayStore.class,
() -> ReplayStore.memory(10_000, InstantSource.system())));

O que o teste afirma: o status, o código e o reason do erro, os campos do contexto, os cabeçalhos (Cache-Control: no-store, quebra de linha final) — e o negativo, que é o que importa:

@Test
void routeWithoutThePermissionIs403WithTheReason() { … }

@Test
void internalRouteRefusesAContextToken() { … }

@Test
void internalRouteWithoutTheScopeIs403ScopeRequired() { … }

MySQL: o banco criado e apagado pela suíte​

O arcabouço lê a NNC_TEST_MYSQL_DSN (o formato usuário:senha@tcp(host:porta)/), cria um banco nnt_sample_*, migra com o mesmo migrador do Job e apaga no fim:

public static MysqlFixture create(String prefix) throws Exception {
String dsn = System.getenv("NNC_TEST_MYSQL_DSN");
Assumptions.assumeTrue(dsn != null && !dsn.isBlank(), "NNC_TEST_MYSQL_DSN não definida");
Matcher m = Pattern.compile("^([^:@]+)(?::([^@]*))?@tcp\\(([^)]+)\\)/.*$").matcher(dsn);
assertThat(m.matches()).as("DSN no formato usuário:senha@tcp(host:porta)/").isTrue();
// … CREATE DATABASE, JdbcDb sobre uma ConnectionProvider, migrator.apply() …
}

As propriedades da conexão são as do serviço (NnsMysql): tempo em texto, UTC, useAffectedRows. Um teste que rodasse com outras propriedades provaria outro comportamento.

O que os testes de banco cobrem, e que nenhum dublê cobriria:

@Test
void nameIsUniquePerTenantIgnoringCaseAndAccent() { … } // a collation ai_ci

@Test
void paginationWalksEveryItemExactlyOnceEvenAtTheSameInstant() { … } // o cursor

@Test
void cursorFromSomewhereElseIsAValidationErrorNotACrash() { … } // entrada do cliente

@Test
void theScanBelongsToTheActionThatOwnsIt() { … } // linhas MUDADAS

@Test
void migrateIsIdempotent() { … } // o ledger

E o teste com o ActionRunner de verdade fecha a operação assíncrona ponta a ponta:

@Test
void runnerClosesTheActionWithTheResultAndTheItemKeepsIt() {
Action act = acceptScan(newItem("Cartão"));
assertThat(runner.processOnce(CTX)).isEqualTo(1);
assertThat(actions.get(CTX, TENANT, act.id()).status()).isEqualTo(Status.SUCCEEDED);
}

Sempre que um teste cria estado (item, Action, outbox), a suíte limpa antes de cada caso — assim a ordem dos testes não muda o resultado:

@BeforeEach
void clean() {
db.exec(CTX, "DELETE FROM nn_actions");
db.exec(CTX, "DELETE FROM nn_action_replicas");
db.exec(CTX, "DELETE FROM sample_items");
db.exec(CTX, "DELETE FROM nn_audit_outbox");
db.exec(CTX, "DELETE FROM nn_idempotency");
}

Descritor: o serviço como ele se lê na partida​

private static final NnsModule MODULE = NnsModule.embedded(SampleApplication.class);

@Test
void everyRouteOfTheApiYamlIsDeclaredOnce() {
List<String> ids = MODULE.api().routes().stream().map(Route::id).sorted().toList();
assertThat(ids).containsExactly("actions.get", "items.create", "items.delete", "items.get",
"items.list", "items.scan", "reconcile", "whoami");
Route scan = MODULE.api().route("items.scan").orElseThrow();
assertThat(scan.mode()).isEqualTo("async");
assertThat(MODULE.api().statusPathForRoute("items.scan", "0be1d3c6-…"))
.contains("/v1/sample/actions/0be1d3c6-…");
}

Esse teste é o que pega: api.yaml sem status route, permissão de rota que o iam.yaml não declara, trilha de audit sem o evento da Action, dependência que o service.yaml esqueceu, MANIFEST de migration fora de sincronia com o diretório.

Erros comuns​

  • Testar com banco em memória — passa e não prova nada do que o MySQL faz. Se não há banco no ambiente, o teste certo é o que se pula, não o que se falsifica.
  • Fingir autorização — chamar o handler direto e "conferir" a permissão com um if no teste. O negativo (403/401) só existe com o token de verdade.
  • Chave privada literal no repositório — gere em tempo de execução; o repositório guarda o rótulo, não a chave.
  • Teste que depende da ordem — deixe cada caso limpar o próprio estado (ou criar o seu).
  • Asserção no texto da mensagem — o contrato é code e reason; a mensagem muda.
  • Thread.sleep para esperar algo — no módulo, a espera é do SDK e o teste usa o gancho (PeriodicTasks.runOnce, ActionRunner.processOnce) em vez de dormir.
  • Teste que só roda na máquina de quem escreveu — caminho absoluto, porta fixa, localhost com banco pessoal: use diretório temporário (@TempDir), porta efêmera (NN_ADDR=:0) e a DSN.
  • Esquecer o teste do caso triste — o caminho de erro é a metade do contrato que o cliente usa.