A feature era para estar ligada. O bean tem @ConditionalOnProperty(name = "app.feature.enabled", havingValue = "true") e ninguém colocou app.feature.enabled no YAML do ambiente. A aplicação sobe sem nenhum erro no log e o health fica verde. Mesmo assim o FeatureService não existe no contexto, e o problema só aparece depois, longe da causa, na primeira injeção ou chamada que esperava esse bean.
Uma aplicação que sobe verde sem o bean principal da feature é o tipo de sucesso que ninguém comemora duas vezes.
A ideia aqui é transformar esse comportamento em teste: um ApplicationContextRunner que mostra quando o bean existe, quando não existe e quais são as duas formas estáveis de garantir que ele apareça. Não tem a ver com os casos vizinhos. Na validação aninhada de ConfigurationProperties, o startup pode falhar. No Duration com unidade errada no YAML, o contexto sobe com o valor errado. Aqui o contexto sobe normalmente e o bean simplesmente não está lá.
Ambiente e o que dá para reproduzir
- Spring Boot 4.1.1, a versão estável atual da linha 4.1.
- Temurin 25.0.4 e Maven 3.9.12. O Boot 4.1.1 exige no mínimo Java 17 e é compatível até o Java 26, conforme os requisitos de sistema.
- Dependências: só
spring-boot-starterespring-boot-starter-test. Não tem web starter nem servidor HTTP. O AssertJ vem do starter de teste, então não declareassertj-corede novo.
No projeto de evidência, mvn -q test terminou com 9 testes, 0 falhas, 0 erros e 0 ignorados: 7 em FeatureConfigTests e 2 em FeatureYamlSpringApplicationTests.
O bean condicional
A configuração tem um único bean, ligado por opt-in:
@Configuration(proxyBeanMethods = false)
public class FeatureConfig {
@Bean
@ConditionalOnProperty(name = "app.feature.enabled", havingValue = "true")
FeatureService featureService() {
return new FeatureService();
}
}
Vale prestar atenção no atributo que não aparece aí. Segundo o javadoc de ConditionalOnProperty do 4.1.1, matchIfMissing tem default false: "By default missing attributes do not match." Se a chave não existe, a condição falha e o bean não é registrado. O contexto não considera isso um erro, porque o @Conditional foi feito justamente para pular beans.
A matriz com ApplicationContextRunner
O ApplicationContextRunner sobe um contexto não web só com a configuração que você passar. Num mesmo runner dá para variar as propriedades e verificar o resultado:
class FeatureConfigTests {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withUserConfiguration(FeatureConfig.class);
@Test
void absentKeyDoesNotRegisterBeanAndContextStillStarts() {
this.runner.run((context) -> {
assertThat(context).hasNotFailed();
assertThat(context).doesNotHaveBean(FeatureService.class);
});
}
@Test
void havingValueTrueRegistersSingleBean() {
this.runner.withPropertyValues("app.feature.enabled=true").run((context) -> {
assertThat(context).hasNotFailed();
assertThat(context).hasSingleBean(FeatureService.class);
});
}
}
O assertThat é o org.assertj.core.api.Assertions.assertThat(context). Os métodos hasSingleBean, doesNotHaveBean e hasNotFailed são do ApplicationContextAssert. Não use o context.assertThat(), que está depreciado. O par hasNotFailed + doesNotHaveBean é o que importa aqui: ele separa "o contexto quebrou" de "o contexto subiu e o bean não foi registrado", e esse segundo caso é exatamente o sintoma em produção.
Os seis cenários executados no runner deram este resultado:
| Configuração | Bean FeatureService | Contexto |
|---|---|---|
chave ausente, matchIfMissing default (false), havingValue="true" | ausente (doesNotHaveBean) | subiu (hasNotFailed) |
app.feature.enabled=true | hasSingleBean | subiu |
app.feature.enabled=false | ausente | subiu |
matchIfMissing=true, chave ausente | hasSingleBean | subiu |
app.feature.enabled= (string vazia) | ausente | subiu |
app.feature.enabled=TRUE | hasSingleBean | subiu |
Nenhuma linha derrubou o contexto. Nas quatro em que o bean não aparece, a aplicação subiria normalmente.
Por que a chave ausente não casa
O código do OnPropertyCondition no 4.1.1 é curto. A chave completa é prefix + name. Se o Environment contém a chave, o valor é comparado com havingValue. Se não contém, o resultado depende só de matchIfMissing: com false, não casa, e com true, casa.
A comparação é que pega desprevenido:
- Se
havingValuenão está vazio, a regra éhavingValue.equalsIgnoreCase(valor). - Se
havingValueestá vazio, a regra é "o valor não éfalse".
Por isso TRUE casa com "true" e a string vazia não casa. O havingValue parece booleano, mas é uma comparação de strings que ignora maiúsculas e minúsculas. TRUE passa, e quem configurar yes vai procurar o bean por um bom tempo.
Com YAML de verdade, sem servidor
O runner usa withPropertyValues, que coloca a mesma chave no Environment que o YAML colocaria. Para conferir com um arquivo de verdade, o projeto sobe um SpringApplication sem web usando src/test/resources/feature-on.yml:
app:
feature:
enabled: true
@Test
void yamlTrueRegistersBeanWithoutWebServer() {
AssertableApplicationContext context = AssertableApplicationContext.get(() -> {
SpringApplication application = new SpringApplication(FeatureConfig.class);
application.setWebApplicationType(WebApplicationType.NONE);
return application.run("--spring.config.location=classpath:feature-on.yml");
});
try {
assertThat(context).hasNotFailed();
assertThat(context).hasSingleBean(FeatureService.class);
}
finally {
context.close();
}
}
Com o YAML, hasSingleBean. O teste irmão aponta para optional:classpath:does-not-exist.yml, o mesmo cenário de um ambiente em que ninguém configurou a chave, e o resultado é contexto de pé e doesNotHaveBean. É a primeira linha da tabela, agora vinda de um arquivo de configuração.
As duas saídas estáveis
Para garantir o bean, só existem dois caminhos.
1. Ligado por padrão: matchIfMissing = true.
@ConditionalOnProperty(name = "app.feature.enabled", havingValue = "true", matchIfMissing = true)
Sem a chave, o bean é registrado. Quem quiser desligar escreve false.
2. Opt-in explícito: a chave presente no YAML. app.feature.enabled: true no arquivo do ambiente. A anotação continua opt-in, e a responsabilidade passa para a configuração de cada ambiente.
A recomendação da DevDojo depende do comportamento esperado do produto. Se a feature deve estar ligada quando ninguém configurou nada, use matchIfMissing = true e deixe o default no código, perto do bean. Se ela é opt-in de verdade, mantenha a anotação como está e escreva o teste que espera o bean com a configuração real do ambiente. Esse teste falha quando a chave sumir, e é melhor descobrir isso no build do que em produção. O que não funciona é tratar a feature como ligada por padrão e deixar a anotação como opt-in.
Armadilhas
havingValueé string."true"e"TRUE"casam (testados). Pela regraequalsIgnoreCase, valores comoyes,1ouonnão casam com"true". Esses três não estão na matriz executada, mas a regra é a mesma.- O default de
havingValueé"", não"true". SemhavingValue, a condição passa a ser "chave presente e diferente defalse", e um valor qualquer comofoocasa. Se o que você quer é semântica booleana, existe o ConditionalOnBooleanProperty desde o 3.5, comhavingValuebooleano e defaulttrue. OmatchIfMissingdele também éfalse. matchIfMissing = truenão vence umfalseexplícito. Se a chave existe, o valor é comparado normalmente.app.feature.enabled=falsedesliga o bean mesmo commatchIfMissing = true.prefix+name.prefix = "app.feature"comname = "enabled"viraapp.feature.enabled, e a implementação acrescenta o ponto. Não coloque ponto nos dois lados.- Vários
names são AND. Todas as chaves precisam passar. Basta uma ausente (commatchIfMissing = false) para o bean não ser registrado.
Próximo passo
Escolha uma das saídas para cada flag: matchIfMissing = true se o padrão do produto é ligado, ou a chave obrigatória no YAML se a feature é opt-in. Depois coloque no módulo dono da flag os dois asserts no mesmo runner: hasSingleBean com a configuração que liga a feature e doesNotHaveBean com a chave ausente, os dois acompanhados de hasNotFailed. São poucas linhas e cobrem o caso em que a aplicação sobe verde sem o bean.