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ível | O que prova | Custo | Exemplo no módulo |
|---|---|---|---|
| Unidade | A regra pura: cálculo, validação, mapeamento | ms | ScanEngineTest, SampleMigrationsTest |
| HTTP com autorização | O contrato: rota, permissionamento, Error Model, cabeçalhos | ~2 s, sem banco | HttpAuthorizationTest |
| MySQL real | O que só o banco sabe: unicidade, transação, cursor, ledger | segundos | ItemStoreMysqlTest, 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
ifno 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 é
codeereason; a mensagem muda. Thread.sleeppara 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,
localhostcom 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.
Esta página ajudou?
Reportar um problema nesta páginaNão envie senhas, chaves, tokens ou dados de clientes.