En lugar de "Dedicado a ..."
La tarea que se describe a continuación no fue innovadora ni muy útil, la empresa en la que trabajo no recibirá ganancias por ello, pero seré una bonificación.
Pero esta tarea era, y por lo tanto tenía que ser resuelta.
Introducción
En el artículo, a menudo encontrarás la palabra Lombok, les pido a los que odian que no se apresuren a sacar conclusiones.
No voy a "ahogarme" por Lombok o su ausencia, yo, como Geralt Sapkovsky, trato de ser neutral, y puedo leer el código con o sin Lombok con calma y sin temblar en el siglo.
Pero en el proyecto actual, la biblioteca mencionada está presente, y algo me dice que nuestro proyecto no es el único.
Entonces aquí.
La última vez en Java ciertamente hay una tendencia hacia annotashki. Para la gloria del concepto de falla rápida, los parámetros de los métodos a menudo se anotan con la anotación @NonNull (de modo que si algo sale mal, entonces desaparece).
Hay muchas opciones de importación para esto (o una anotación similar en ideología), pero, como ya ha quedado claro, nos centraremos en la versión
import lombok.NonNull;
Si usa esta anotación (o similar), entonces tiene algún contrato que debe verificar con una prueba y cualquier analizador de código estático le dirá amablemente (Sonar le dice exactamente).
Probar esta anotación con una prueba unitaria es bastante simple, el problema es que tales pruebas se multiplicarán en su proyecto a la velocidad de los conejos en la primavera, y los conejos, como saben, violan el principio DRY.
En el artículo, escribiremos un pequeño marco de prueba para probar el contrato de las anotaciones @NonNull (y para que Sonar no brille en tus ojos con una desagradable luz roja).
PD: El nombre de la canción se inspiró en la canción de la banda PowerWolf, que tocó (por golly) cuando escribí el nombre (en el original, el nombre suena más positivo)
Cuerpo principal
Inicialmente, probamos la anotación algo así:
@Test void methodNameWithNullArgumentThrowException() { try { instance.getAnyType(null); fail("Exception not thrown"); } catch (final NullPointerException e) { assertNotNull(e); } }
Llamaron al método y pasaron nulo como un parámetro anotado con la anotación @NonNull.
Obtuvieron NPE y quedaron satisfechos (Sonar también estaba feliz).
Luego comenzaron a hacer lo mismo, pero con una afirmación más moderna que funciona a través del proveedor (nos encantan las lambdas):
@TestUnitRepeatOnce void methodNameWithNullArgumentThrowException() { assertThrows(NullPointerException.class, () -> instance.getAnyType(null)); }
Con estilo De moda Juventud
Parece posible terminar, las anotaciones se prueban, ¿y qué más?
El problema (no es el problema, pero aún así) de este método de prueba "surgió" cuando un día escribí una prueba para un método, funcionó con éxito, y luego noté que no hay una anotación @NonNull en el parámetro.
Es comprensible: llama al método de prueba, sin describir el comportamiento de las clases de moque, a través de when () / then (). El hilo de ejecución ingresa de manera segura al método, en algún lugar dentro de él atrapa NPE, en un objeto desbloqueado (o bloqueado, pero sin cuándo () / luego ()) y se bloquea, sin embargo, con NPE, como advirtió, lo que significa que la prueba es verde
Resulta que estamos probando en este caso, no la anotación, pero no está claro qué. Con la prueba funcionando correctamente, ni siquiera deberíamos tener que profundizar en el método (caer en el umbral).
Las anotaciones @NonNull de Lombok tienen una característica: si pasamos de NPE a anotaciones, el nombre del parámetro se escribe en el error.
Nos involucraremos en esto, después de que caigamos de NPE, verificaremos adicionalmente el texto de stacktrace, así:
exception.getCause().getMessage().equals(parameter.getName())
Y si de repente ...En caso de que Lombok se actualice repentinamente y deje de escribir el nombre del parámetro que recibió un valor nulo en stacktrace, revisaremos la conferencia de Andrei Pangin sobre la
TI JVM y escribiremos un complemento para la JVM, en el que pasamos el nombre del parámetro.
Todo parece ser nada, ahora realmente comprobamos lo que se necesita, pero el problema de los "conejos" no está resuelto.
Me gustaría tener una herramienta que podría decirse, por ejemplo, así:
@TestUnitRepeatOnce @SneakyThrows void nonNullAnnotationTest() { assertNonNullAnnotation(YourPerfectClass.class); }
y él mismo iría a escanear todos los métodos públicos de la clase especificada y comprobaría todos sus parámetros @NonNull con una prueba.
Dirá, obtenga una reflexión y verifique si el método @NonNull está activado y si hay una viñeta nula.
Todo no sería nada, pero RetentionPolicy no es el indicado.
Todas las anotaciones tienen un parámetro RetentionPolicy, que puede ser de 3 tipos: SOURCE, CLASS y RUNTIME, por lo que Lombok tiene RetentionPolicy.SOURCE de forma predeterminada, lo que significa que esta anotación no es visible en Runtime y no la encontrará a través de la reflexión.
En nuestro proyecto, todos los parámetros de los métodos públicos se anotan (sin contar las primitivas), si se entiende que el parámetro no puede ser nulo, si se supone lo contrario, entonces spring @Nullable anotará el parámetro. Puede participar en esto, buscaremos todos los métodos públicos y todos los parámetros que no estén marcados con @Nullable y que no sean primitivos.
Queremos decir que para todos los demás casos, la anotación @NonNull debe estar en los parámetros.
Por conveniencia, siempre que sea posible, difundiremos la lógica por métodos privados, para empezar obtendremos todos los métodos públicos:
private List<Method> getPublicMethods(final Class clazz) { return Arrays.stream(clazz.getDeclaredMethods()) .filter(METHOD_FILTER) .collect(toList()); }
donde METHOD_FILTER es un predicado regular en el que decimos que:
- El método debe ser público.
- No debe ser sintético (y esto sucede cuando tiene un método con un parámetro sin formato)
- No debe ser abstracto (sobre clases abstractas por separado y a continuación)
- El nombre del método no debe ser igual (en caso de que algún tipo de persona malvada decida poblar una clase con iguales anulados () en la entrada de nuestro marco POJO)
Después de obtener todos los métodos que necesitamos, comenzamos a clasificarlos en un bucle,
Si el método no tiene ningún parámetro, este no es nuestro candidato:
if (method.getParameterCount() == 0) { continue; }
Si hay parámetros, debemos entender si están @NonNull anotados (en caso de que sean más precisos, de acuerdo con
lógica- método público
- no @Nullable
- no primitivo
Para hacer esto, haga un mapa y coloque nuestros parámetros en él de acuerdo con la secuencia en el método, y frente a ellos ponemos una bandera que dice si la anotación @NonNull debe estar arriba o no:
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; }
este mapa es útil para que podamos llamar al método y pasarlo como nulo a todos los parámetros con la anotación @NonNull a su vez, y no solo el primero.
El parámetro nonNullAnnotationCount cuenta cuántos parámetros en el método se deben anotar @NonNull, determinará el número de interacciones de integración de llamadas para cada método.
Por cierto, si no hay anotaciones @NonNull (hay parámetros, pero todos son primitivos o @Nullable), entonces no hay nada de qué hablar:
if (nonNullAnnotationCount == 0) { continue; }
Tenemos a mano un mapa de parámetros. Sabemos cuántas veces llamar a un método y en qué posiciones anular, el asunto es pequeño (como pensé ingenuamente sin comprender), necesitamos crear una instancia de la clase y llamar métodos sobre ellos.
Los problemas comienzan cuando te das cuenta de cuán diferente es una instancia: puede ser una clase privada, puede ser una clase con un constructor predeterminado, con un constructor con parámetros, con tal y tal constructor, una clase abstracta, una interfaz (con sus métodos predeterminados, que también son públicos y que también necesitan ser probados).
Y cuando construimos la instancia por las buenas o por las malas, necesitamos pasar los parámetros al método de invocación y aquí también expandirnos: ¿cómo crear una instancia de la clase final? y Enum? y primitivo? y un conjunto de primitivas (que también es un objeto y también puede ser anotado).
Bueno, hagámoslo en orden.
El primer caso es una clase con un constructor privado:
if (ONLY_ONE_PRIVATE_CONSTRUCTOR_FILTER.test(clazz)) { notNullAnnotationParameterMap.put(currentNullableIndex, false); method.invoke(clazz, invokeMethodParameterArray); makeErrorMessage(method); }
luego simplemente invocamos nuestro método de invocación, pasamos el clazz que vino del exterior a la prueba y una matriz de parámetros en los que nulo ya está cargado en la primera posición con la bandera para la anotación @NonNull (recuerde, arriba creamos el mapa @ NonNulls) comenzamos ejecutar en un bucle y crear una matriz de parámetros, cambiando alternativamente la posición del parámetro nulo y poniendo a cero la bandera antes de llamar al método, de modo que en la próxima integración el otro parámetro se vuelva nulo.
En código, se ve así:
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); } }
La primera opción de instanciación fue resuelta.
En otras interfaces, no puede tomar y crear una instancia de una interfaz (ni siquiera tiene un constructor).
Por lo tanto, con la interfaz será así:
if (INTERFACE_FILTER.test(clazz)) { notNullAnnotationParameterMap.put(currentNullableIndex, false); method.invoke(createInstanceByDynamicProxy(clazz, invokeMethodParameterArray), invokeMethodParameterArray); makeErrorMessage(method); }
createInstanceByDynamicProxy nos permite crear una instancia en una clase si implementa al menos una interfaz, o si es una interfaz en sí misma
Matiztenga en cuenta que aquí es fundamentalmente qué interfaces implementa la clase, la interfaz de tipo (y no algunas Comparable) es importante, en el que hay métodos que implementa en la clase de destino, de lo contrario la instancia lo sorprenderá con su tipo
pero por dentro es así:
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; } ); }
RastrilloPor cierto, también hubo algunos rastrillos aquí, ya no recuerdo cuáles, había muchos, pero debes crear un proxy a través de Lookup.class
La siguiente instancia (mi favorita) es una clase abstracta. Y aquí, el proxy dinámico ya no nos ayudará, ya que si una clase abstracta implementa algún tipo de interfaz, claramente este no es el tipo que nos gustaría. Y así, no podemos tomar y crear newInstance () a partir de una clase abstracta. Aquí CGLIB vendrá en nuestra ayuda, una biblioteca de primavera que crea proxies basados en la herencia, pero el problema es que la clase objetivo debe tener un constructor predeterminado (sin parámetros)
ChismesAunque a juzgar por los chismes en Internet desde la primavera 4, CGLIB puede funcionar sin él, y entonces: ¡ Entonces no funciona!
La opción para crear instancias de una clase abstracta sería esta:
if (isAbstract(clazz.getModifiers())) { createInstanceByCGLIB(clazz, method, invokeMethodParameterArray); makeErrorMessage(); }
makeErrorMessage (), que ya se vio en los ejemplos de código, descarta la prueba, si llamamos al método con el parámetro anotado @NonNull pasando nulo y no cayó, entonces la prueba no funcionó, debe descartar.
Para el mapeo de parámetros, tenemos un método común que puede mapear y bloquear los parámetros del constructor y del método, se ve así:
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()); } }
Presta atención a la creación de Enum (guinda del pastel), en general, no puedes simplemente tomar y crear Enum.
Aquí para los parámetros finales, su propia asignación, para los no finales, y luego simplemente en el texto (código).
Bueno, después de crear los parámetros para el constructor y para el método, formamos nuestra instancia:
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);
Ya sabemos con certeza que, dado que hemos llegado a esta etapa del código, significa que tenemos al menos un constructor, podemos tomar cualquiera para crear una instancia, por lo que tomamos el primero que vemos, vemos si tiene parámetros en el constructor y, si no, llama así:
method.invoke(spy(clazz.getConstructors()[0].newInstance()), invokeMethodParameterArray);
Bueno, si hay algo como esto:
method.invoke(spy(clazz.getConstructors()[0].newInstance()), invokeMethodParameterArray);
Esta es la lógica que ocurre en el método createAndInvoke () que viste un poco más arriba.
La versión completa de la clase de prueba bajo el spoiler, no la subí a git, como escribí en un proyecto de trabajo, pero de hecho es solo una clase que se puede heredar en tus pruebas y usar.
Código fuente 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'; } } }
Conclusión
Este código funciona y prueba las anotaciones en un proyecto real, por el momento solo hay una opción posible, cuando todo lo dicho puede colapsarse.
Declare un setter de Lombock en la clase (si hay un especialista que no establece el setter en la clase Pojo, aunque eso simplemente no sucede) y el campo en el que se declarará el setter no será definitivo.
Luego, el marco dirá amablemente que hay un método público, y tiene un parámetro en el que no hay una anotación @NonNull, la solución es simple: declarar explícitamente el establecedor y anotar su parámetro en función del contexto de la lógica @ NonNull / @ Nullable.
Tenga en cuenta que si desea que esté vinculado al nombre del parámetro del método en sus pruebas (o algo más), en Runtime los nombres de las variables en los métodos no están disponibles de forma predeterminada, encontrará arg [0] y arg [1], etc. .
Para habilitar la visualización de nombres de métodos en Runtime, use el complemento 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>
y en particular esta clave:
<compilerArgs><arg>-parameters</arg></compilerArgs>
Espero que te haya interesado.