Em vez de "Dedicado a ..."
A tarefa descrita abaixo não foi inovadora nem muito útil; a empresa em que trabalho não receberá lucro por isso, mas serei um bônus.
Mas essa tarefa era e, portanto, tinha que ser resolvida.
Introdução
No artigo, você encontrará muitas vezes a palavra Lombok, peço aos inimigos que não cheguem a conclusões precipitadas.
Não vou me "afogar" por Lombok ou por sua ausência. Eu, como Geralt Sapkovsky, tento ser neutro e posso ler código com ou sem Lombok com calma e sem tremores no século.
Mas no projeto atual, a biblioteca mencionada está presente, e algo me diz que nosso projeto não é o único.
Então aqui.
A última vez em java, certamente há uma tendência para o annotashki. Para a glória do conceito de falha rápida, os parâmetros dos métodos costumam ser anotados com a anotação @NonNull (de modo que, se algo der errado, acontece).
Existem muitas opções de importação para isso (ou uma anotação semelhante em ideologia), mas, como já ficou claro, focaremos na versão
import lombok.NonNull;
Se você usar essa anotação (ou semelhante), terá algum contrato que precisará verificar com um teste e qualquer analisador de código estático gentilmente informará (o Sonar informa exatamente).
Testar essa anotação com um teste de unidade é bastante simples, o problema é que esses testes se multiplicam no seu projeto na velocidade dos coelhos na primavera, e os coelhos, como você sabe, violam o princípio DRY.
No artigo, escreveremos uma pequena estrutura de teste para testar o contrato de anotações do @NonNull (e para que o Sonar não brilhe nos seus olhos com uma luz vermelha desagradável).
PS O nome da música foi inspirado na música da banda PowerWolf, que tocou (por golly) quando escrevi o nome (no original, o nome soa mais positivo)
Corpo principal
Inicialmente, testamos a anotação da seguinte forma:
@Test void methodNameWithNullArgumentThrowException() { try { instance.getAnyType(null); fail("Exception not thrown"); } catch (final NullPointerException e) { assertNotNull(e); } }
Eles chamaram o método e passaram nulos como um parâmetro anotado com a anotação @NonNull.
Eles receberam NPE e ficaram satisfeitos (o Sonar também ficou feliz).
Então eles começaram a fazer o mesmo, mas com uma declaração mais elegante que funciona através do Fornecedor (nós amamos lambdas):
@TestUnitRepeatOnce void methodNameWithNullArgumentThrowException() { assertThrows(NullPointerException.class, () -> instance.getAnyType(null)); }
Elegante. Na moda. Juventude
Parece possível terminar, as anotações são testadas, e daí?
O problema (não o problema, mas ainda assim) desse método de teste "surgiu" quando um dia escrevi um teste para um método, funcionou com êxito e, em seguida, notei que não havia anotação @NonNull no parâmetro.
É compreensível: você chama o método de teste, sem descrever o comportamento das classes moque, através de quando () / então (). O encadeamento em execução entra com segurança no método, em algum lugar dentro dele captura o NPE, em um objeto desbloqueado (ou bloqueado, mas sem quando () / then ()) e trava, no entanto, com o NPE, como você alertou, o que significa que o teste é verde
Acontece que estamos testando neste caso, não a anotação, mas não está claro o que. Com o teste funcionando corretamente, não precisamos nem nos aprofundar no método (caindo no limiar).
As anotações @NonNull do Lombok têm um recurso: se cairmos do NPE para anotações, o nome do parâmetro será gravado no erro.
Nós nos envolveremos nisso, depois de cairmos no NPE, verificaremos adicionalmente o texto do stacktrace, assim:
exception.getCause().getMessage().equals(parameter.getName())
E se de repente ...Caso o Lombok atualize repentinamente e pare de escrever o nome do parâmetro que recebeu nulo no stacktrace, revisaremos a palestra de Andrei Pangin sobre a
JVM TI e escreveremos um plug-in para a JVM, na qual transferimos o nome do parâmetro.
Tudo parece não ser nada, agora checamos o que realmente é necessário, mas o problema dos “coelhos” não está resolvido.
Eu gostaria de ter uma ferramenta que pudesse ser dita, por exemplo, assim:
@TestUnitRepeatOnce @SneakyThrows void nonNullAnnotationTest() { assertNonNullAnnotation(YourPerfectClass.class); }
e ele próprio examinaria todos os métodos públicos da classe especificada e verificaria todos os parâmetros @NonNull com um teste.
Você dirá, obtenha uma reflexão e verifique se o método @NonNull está ativado e se há um marcador nulo.
Tudo não seria nada, mas RetentionPolicy não é o único.
Todas as anotações têm um parâmetro RetentionPolicy, que pode ser de três tipos: SOURCE, CLASS e RUNTIME; portanto, o Lombok possui RetentionPolicy.SOURCE por padrão, o que significa que essa anotação não é visível no tempo de execução e você não a encontra através da reflexão.
Em nosso projeto, todos os parâmetros de métodos públicos são anotados (sem contar primitivos), se for entendido que o parâmetro não pode ser nulo, se o contrário for assumido, o parâmetro será anotado por spring @Nullable. Você pode se envolver nisso, procuraremos todos os métodos públicos e todos os parâmetros neles que não estão marcados com @Nullable e não são primitivos.
Queremos dizer que, para todos os outros casos, a anotação @NonNull deve estar nos parâmetros.
Por conveniência, sempre que possível, espalharemos a lógica por métodos privados; para começar, obteremos todos os métodos públicos:
private List<Method> getPublicMethods(final Class clazz) { return Arrays.stream(clazz.getDeclaredMethods()) .filter(METHOD_FILTER) .collect(toList()); }
onde METHOD_FILTER é um predicado regular no qual dizemos que:
- O método deve ser público
- Não deve ser sintético (e isso acontece quando você tem um método com um parâmetro bruto)
- Não deve ser abstrato (sobre classes abstratas separadamente e abaixo)
- O nome do método não deve ser igual (no caso de algum tipo de pessoa má decidir preencher uma classe com substituídos igual a () na entrada de nossa estrutura POJO)
Depois de obter todos os métodos necessários, começamos a classificá-los em um loop,
se o método não possui parâmetros, este não é nosso candidato:
if (method.getParameterCount() == 0) { continue; }
Se houver parâmetros, precisamos entender se eles estão anotados em @NonNull (mais precisamente, devem estar, de acordo com
lógica- método público
- not @Nullable
- não primitivo
Para fazer isso, faça um mapa e coloque nossos parâmetros de acordo com a sequência do método, e ao lado deles, colocamos um sinalizador que diz se a anotação @NonNull deve estar acima ou não:
int nonNullAnnotationCount = 0; int index = 0; val parameterCurrentMethodArray = method.getParameters(); val notNullAnnotationParameterMap = new HashMap<Integer, Boolean>(); for (val parameter : parameterCurrentMethodArray) { if (isNull(parameter.getAnnotation(Nullable.class)) && isFalse(parameter.getType().isPrimitive())) { notNullAnnotationParameterMap.put(index++, true); nonNullAnnotationCount++; } else { notNullAnnotationParameterMap.put(index++, false); } } if (nonNullAnnotationCount == 0) { continue; }
esse mapa é útil para chamarmos o método e passá-lo nulo para todos os parâmetros com a anotação @NonNull por sua vez, e não apenas o primeiro.
O parâmetro nonNullAnnotationCount conta quantos parâmetros no método devem ser anotados em @NonNull; ele determinará o número de interações de integração de chamadas para cada método.
A propósito, se não houver anotações @NonNull (existem parâmetros, mas todos são primitivos ou @Nullable), não há nada para falar:
if (nonNullAnnotationCount == 0) { continue; }
Temos em mãos um mapa de parâmetros. Sabemos quantas vezes chamar um método e em quais posições nulo, o assunto é pequeno (como eu pensava ingenuamente sem entender), precisamos criar uma instância da classe e chamar métodos sobre eles.
Os problemas começam quando você percebe o quão diferente é uma instância: pode ser uma classe privada, pode ser uma classe com um construtor padrão, com um construtor com parâmetros, com esse e tal construtor, uma classe abstrata, uma interface (com seus métodos padrão, que também são públicos e que também precisam ser testados).
E quando construímos a instância por gancho ou por bandido, precisamos passar os parâmetros para o método de chamada e aqui também: como criar uma instância da classe final? e Enum? e primitivo? e uma matriz de primitivas (que também é um objeto e também pode ser anotada).
Bem, vamos fazer isso em ordem.
O primeiro caso é uma classe com um construtor privado:
if (ONLY_ONE_PRIVATE_CONSTRUCTOR_FILTER.test(clazz)) { notNullAnnotationParameterMap.put(currentNullableIndex, false); method.invoke(clazz, invokeMethodParameterArray); makeErrorMessage(method); }
então invocamos nosso método de invocação, passamos o clazz que veio de fora para o teste e uma matriz de parâmetros em que null já está carregado na primeira posição com a flag da anotação @NonNull (lembre-se, acima de termos criado o mapa @ NonNulls) execute um loop e crie uma matriz de parâmetros, alterando alternadamente a posição do parâmetro nulo e zerando o sinalizador antes de chamar o método, para que na próxima integração o outro parâmetro se torne nulo.
No código, fica assim:
val invokeMethodParameterArray = new Object[parameterCurrentMethodArray.length]; boolean hasNullParameter = false; int currentNullableIndex = 0; for (int i = 0; i < invokeMethodParameterArray.length; i++) { if (notNullAnnotationParameterMap.get(i) && isFalse(hasNullParameter)) { currentNullableIndex = i; invokeMethodParameterArray[i] = null; hasNullParameter = true; } else { mappingParameter(parameterCurrentMethodArray[i], invokeMethodParameterArray, i); } }
A primeira opção de instanciação foi resolvida.
Interfaces adicionais, você não pode obter e criar uma instância de uma interface (ela nem sequer tem um construtor).
Portanto, com a interface, será assim:
if (INTERFACE_FILTER.test(clazz)) { notNullAnnotationParameterMap.put(currentNullableIndex, false); method.invoke(createInstanceByDynamicProxy(clazz, invokeMethodParameterArray), invokeMethodParameterArray); makeErrorMessage(method); }
createInstanceByDynamicProxy nos permite criar uma instância em uma classe se ela implementar pelo menos uma interface ou ela própria uma interface
Nuancelembre-se de que aqui é fundamental que interfaces a classe implementa, a interface de tipo (e não algumas comparáveis) é importante, na qual existem métodos que você implementa na classe de destino; caso contrário, a instância o surpreenderá com seu tipo
mas por dentro é assim:
private Object createInstanceByDynamicProxy(final Class clazz, final Object[] invokeMethodParameterArray) { return newProxyInstance( currentThread().getContextClassLoader(), new Class[]{clazz}, (proxy, method1, args) -> { Constructor<Lookup> constructor = Lookup.class .getDeclaredConstructor(Class.class); constructor.setAccessible(true); constructor.newInstance(clazz) .in(clazz) .unreflectSpecial(method1, clazz) .bindTo(proxy) .invokeWithArguments(invokeMethodParameterArray); return null; } ); }
AncinhoA propósito, também havia alguns ancinhos aqui, não me lembro mais quais, havia muitos deles, mas você precisa criar um proxy por meio da Lookup.class
A próxima instância (minha favorita) é uma classe abstrata. E aqui o proxy dinâmico não nos ajudará mais, pois se uma classe abstrata implementa algum tipo de interface, esse claramente não é o tipo que gostaríamos. E assim, não podemos pegar e criar newInstance () a partir de uma classe abstrata. Aqui, o CGLIB virá em nosso auxílio, uma biblioteca de primavera que cria proxies baseados em herança, mas o problema é que a classe de destino deve ter um construtor padrão (sem parâmetros)
FofocaEmbora a julgar pela fofoca na Internet desde a Primavera 4, o CGLIB possa funcionar sem ela, e por isso: Portanto, não funciona!
A opção para instanciar uma classe abstrata seria esta:
if (isAbstract(clazz.getModifiers())) { createInstanceByCGLIB(clazz, method, invokeMethodParameterArray); makeErrorMessage(); }
makeErrorMessage (), que já foi visto nos exemplos de código, descarta o teste, se chamarmos o método com o parâmetro anotado @NonNull passando nulo e ele não cair, o teste não funcionará, você deverá soltar.
Para o mapeamento de parâmetros, temos um método comum que pode mapear e bloquear os parâmetros do construtor e do método:
private void mappingParameter(final Parameter parameter, final Object[] methodParam, final int index) throws InstantiationException, IllegalAccessException { if (isFinal(parameter.getType().getModifiers())) { if (parameter.getType().isEnum()) { methodParam[index] = Enum.valueOf( (Class<Enum>) (parameter.getType()), parameter.getType().getEnumConstants()[0].toString() ); } else if (parameter.getType().isPrimitive()) { mappingPrimitiveName(parameter, methodParam, index); } else if (parameter.getType().getTypeName().equals("byte[]")) { methodParam[index] = new byte[0]; } else { methodParam[index] = parameter.getType().newInstance(); } } else { methodParam[index] = mock(parameter.getType()); } }
Preste atenção na criação de Enum (cereja no bolo), em geral, você não pode simplesmente criar e enum.
Aqui, para os parâmetros finais, seu próprio mapeamento, para o não final, e simplesmente no texto (código).
Bem, depois que criamos os parâmetros para o construtor e para o método, formamos nossa instância:
val firstFindConstructor = clazz.getConstructors()[0]; val constructorParameterArray = new Object[firstFindConstructor.getParameters().length]; for (int i = 0; i < constructorParameterArray.length; i++) { mappingParameter(firstFindConstructor.getParameters()[i], constructorParameterArray, i); } notNullAnnotationParameterMap.put(currentNullableIndex, false); createAndInvoke(clazz, method, invokeMethodParameterArray, firstFindConstructor, constructorParameterArray); makeErrorMessage(method);
Já sabemos ao certo que, desde que atingimos esse estágio do código, significa que temos pelo menos um construtor, podemos usar qualquer um para criar uma instância, portanto, pegamos o primeiro que vemos, veja se ele possui parâmetros no construtor e, se não, chame assim:
method.invoke(spy(clazz.getConstructors()[0].newInstance()), invokeMethodParameterArray);
Bem, se houver algo parecido com isto:
method.invoke(spy(clazz.getConstructors()[0].newInstance()), invokeMethodParameterArray);
Essa é a lógica que ocorre no método createAndInvoke () que você viu um pouco mais alto.
A versão completa da classe de teste no spoiler, eu não carreguei no git, como escrevi em um projeto em funcionamento, mas na verdade é apenas uma classe que pode ser herdada em seus testes e usada.
Código fonte public class TestUtil { private static final Predicate<Method> METHOD_FILTER = method -> isPublic(method.getModifiers()) && isFalse(method.isSynthetic()) && isFalse(isAbstract(method.getModifiers())) && isFalse(method.getName().equals("equals")); private static final Predicate<Class> ONLY_ONE_PRIVATE_CONSTRUCTOR_FILTER = clazz -> clazz.getConstructors().length == 0 && isFalse(clazz.isInterface()); private static final Predicate<Class> INTERFACE_FILTER = clazz -> clazz.getConstructors().length == 0; private static final BiPredicate<Exception, Parameter> LOMBOK_ERROR_FILTER = (exception, parameter) -> isNull(exception.getCause().getMessage()) || isFalse(exception.getCause().getMessage().equals(parameter.getName())); protected void assertNonNullAnnotation(final Class clazz) throws Throwable { for (val method : getPublicMethods(clazz)) { if (method.getParameterCount() == 0) { continue; } int nonNullAnnotationCount = 0; int index = 0; val parameterCurrentMethodArray = method.getParameters(); val notNullAnnotationParameterMap = new HashMap<Integer, Boolean>(); for (val parameter : parameterCurrentMethodArray) { if (isNull(parameter.getAnnotation(Nullable.class)) && isFalse(parameter.getType().isPrimitive())) { notNullAnnotationParameterMap.put(index++, true); nonNullAnnotationCount++; } else { notNullAnnotationParameterMap.put(index++, false); } } if (nonNullAnnotationCount == 0) { continue; } for (int j = 0; j < nonNullAnnotationCount; j++) { val invokeMethodParameterArray = new Object[parameterCurrentMethodArray.length]; boolean hasNullParameter = false; int currentNullableIndex = 0; for (int i = 0; i < invokeMethodParameterArray.length; i++) { if (notNullAnnotationParameterMap.get(i) && isFalse(hasNullParameter)) { currentNullableIndex = i; invokeMethodParameterArray[i] = null; hasNullParameter = true; } else { mappingParameter(parameterCurrentMethodArray[i], invokeMethodParameterArray, i); } } try { if (ONLY_ONE_PRIVATE_CONSTRUCTOR_FILTER.test(clazz)) { notNullAnnotationParameterMap.put(currentNullableIndex, false); method.invoke(clazz, invokeMethodParameterArray); makeErrorMessage(method); } if (INTERFACE_FILTER.test(clazz)) { notNullAnnotationParameterMap.put(currentNullableIndex, false); method.invoke(createInstanceByDynamicProxy(clazz, invokeMethodParameterArray), invokeMethodParameterArray); makeErrorMessage(method); } if (isAbstract(clazz.getModifiers())) { createInstanceByCGLIB(clazz, method, invokeMethodParameterArray); makeErrorMessage(); } val firstFindConstructor = clazz.getConstructors()[0]; val constructorParameterArray = new Object[firstFindConstructor.getParameters().length]; for (int i = 0; i < constructorParameterArray.length; i++) { mappingParameter(firstFindConstructor.getParameters()[i], constructorParameterArray, i); } notNullAnnotationParameterMap.put(currentNullableIndex, false); createAndInvoke(clazz, method, invokeMethodParameterArray, firstFindConstructor, constructorParameterArray); makeErrorMessage(method); } catch (final Exception e) { if (LOMBOK_ERROR_FILTER.test(e, parameterCurrentMethodArray[currentNullableIndex])) { makeErrorMessage(method); } } } } } @SneakyThrows private void createAndInvoke( final Class clazz, final Method method, final Object[] invokeMethodParameterArray, final Constructor firstFindConstructor, final Object[] constructorParameterArray ) { if (firstFindConstructor.getParameters().length == 0) { method.invoke(spy(clazz.getConstructors()[0].newInstance()), invokeMethodParameterArray); } else { method.invoke(spy(clazz.getConstructors()[0].newInstance(constructorParameterArray)), invokeMethodParameterArray); } } @SneakyThrows private void createInstanceByCGLIB(final Class clazz, final Method method, final Object[] invokeMethodParameterArray) { MethodInterceptor handler = (obj, method1, args, proxy) -> proxy.invoke(clazz, args); if (clazz.getConstructors().length > 0) { val firstFindConstructor = clazz.getConstructors()[0]; val constructorParam = new Object[firstFindConstructor.getParameters().length]; for (int i = 0; i < constructorParam.length; i++) { mappingParameter(firstFindConstructor.getParameters()[i], constructorParam, i); } for (val constructor : clazz.getConstructors()) { if (constructor.getParameters().length == 0) { val proxy = Enhancer.create(clazz, handler); method.invoke(proxy.getClass().newInstance(), invokeMethodParameterArray); } } } } private Object createInstanceByDynamicProxy(final Class clazz, final Object[] invokeMethodParameterArray) { return newProxyInstance( currentThread().getContextClassLoader(), new Class[]{clazz}, (proxy, method1, args) -> { Constructor<Lookup> constructor = Lookup.class .getDeclaredConstructor(Class.class); constructor.setAccessible(true); constructor.newInstance(clazz) .in(clazz) .unreflectSpecial(method1, clazz) .bindTo(proxy) .invokeWithArguments(invokeMethodParameterArray); return null; } ); } private void makeErrorMessage() { fail(" @NonNull DefaultConstructor "); } private void makeErrorMessage(final Method method) { fail(" " + method.getName() + " @NonNull"); } private List<Method> getPublicMethods(final Class clazz) { return Arrays.stream(clazz.getDeclaredMethods()) .filter(METHOD_FILTER) .collect(toList()); } private void mappingParameter(final Parameter parameter, final Object[] methodParam, final int index) throws InstantiationException, IllegalAccessException { if (isFinal(parameter.getType().getModifiers())) { if (parameter.getType().isEnum()) { methodParam[index] = Enum.valueOf( (Class<Enum>) (parameter.getType()), parameter.getType().getEnumConstants()[0].toString() ); } else if (parameter.getType().isPrimitive()) { mappingPrimitiveName(parameter, methodParam, index); } else if (parameter.getType().getTypeName().equals("byte[]")) { methodParam[index] = new byte[0]; } else { methodParam[index] = parameter.getType().newInstance(); } } else { methodParam[index] = mock(parameter.getType()); } } private void mappingPrimitiveName(final Parameter parameter, final Object[] methodParam, final int index) { val name = parameter.getType().getName(); if ("long".equals(name)) { methodParam[index] = 0L; } else if ("int".equals(name)) { methodParam[index] = 0; } else if ("byte".equals(name)) { methodParam[index] = (byte) 0; } else if ("short".equals(name)) { methodParam[index] = (short) 0; } else if ("double".equals(name)) { methodParam[index] = 0.0d; } else if ("float".equals(name)) { methodParam[index] = 0.0f; } else if ("boolean".equals(name)) { methodParam[index] = false; } else if ("char".equals(name)) { methodParam[index] = 'A'; } } }
Conclusão
Esse código funciona e testa anotações em um projeto real, no momento há apenas uma opção possível, quando tudo o que é dito pode ser recolhido.
Declare um levantador Lombock na classe (se houver um especialista que não o coloque na classe Pojo, embora isso simplesmente não aconteça) e o campo em que o levantador for declarado não será final.
Então a estrutura dirá gentilmente que existe um método público e que possui um parâmetro no qual não há anotação @NonNull; a solução é simples: declare o setter explicitamente e anote seu parâmetro com base no contexto da lógica @ NonNull / @ Nullable.
Observe que, se você quiser que eu esteja vinculado ao nome do parâmetro do método em seus testes (ou algo mais), no tempo de execução, os nomes das variáveis nos métodos não estarão disponíveis por padrão, você encontrará arg [0] e arg [1], etc. .
Para habilitar a exibição dos nomes dos métodos no Runtime, use o plug-in Maven:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>${maven.compiler.plugin.version}</version> <configuration> <source>${compile.target.source}</source/> <target>${compile.target.source}</target> <encoding>${project.build.sourceEncoding}</encoding> <compilerArgs><arg>-parameters</arg></compilerArgs> </configuration> </plugin>
e em particular esta chave:
<compilerArgs><arg>-parameters</arg></compilerArgs>
Espero que você esteja interessado.