Prueba con el ejemplo
Publicado: 2022-02-15Seguimos con nuestra serie de blogs sobre todo lo relacionado con testing. En este blog, nos estamos enfocando en ejemplos reales.
Si bien los ejemplos en esta publicación están escritos usando JUnit 5 y AssertJ, las lecciones son aplicables a cualquier otro marco de pruebas unitarias.
JUnit es el marco de prueba más popular para Java. AssertJ es una biblioteca de Java que ayuda a los desarrolladores a escribir pruebas más expresivas.
Estructura básica de la prueba
El primer ejemplo de una prueba que veremos es una calculadora simple para sumar 2 números.
calculadora de clase debe { @Prueba // 1 suma nula() { Calculadora calculadora = new Calculadora(); // 2 int resultado = calculadora.sum(1, 2); // 3 afirmar que (resultado). es igual a (3); // 4 } }
Prefiero usar la convención de nomenclatura ClassShould
cuando escribo pruebas para evitar repetir should
o test
en cada nombre de método. Puedes leer más sobre esto aquí.
¿Qué hace la prueba anterior?
Vamos a dividir la prueba línea por línea:
- La anotación
@Test
permite a JUnit framework saber qué métodos deben ejecutarse como pruebas. Es perfectamente normal tener métodosprivate
en la clase de prueba que no son pruebas. - Esta es la fase de organización de nuestra prueba, donde preparamos el entorno de prueba. Todo lo que necesitamos para esta prueba es tener una instancia de
Calculator
. - Esta es la fase de acto donde activamos el comportamiento que queremos probar.
- Esta es la fase de afirmación en la que inspeccionamos lo que sucedió y si todo se resolvió como se esperaba. El método
assertThat(result)
es parte de la biblioteca AssertJ y tiene múltiples sobrecargas.
Cada sobrecarga devuelve un objeto Assert
especializado. El objeto devuelto tiene métodos que tienen sentido para el objeto que pasamos al método assertThat
. En nuestro caso, ese objeto es AbstractIntegerAssert
con métodos para probar Integers. isEqualTo(3)
comprobará si result == 3
. Si es así, la prueba pasará y fallará de lo contrario.
No nos centraremos en ninguna implementación en esta publicación de blog.
Otra forma de pensar acerca de Organizar , Actuar , Afirmar es Dado , Cuándo , Entonces .
Después de escribir nuestra implementación de sum
, podemos hacernos algunas preguntas:
- ¿Cómo puedo mejorar en esta prueba?
- ¿Hay más casos de prueba que debería cubrir?
- ¿Qué pasa si sumo un número positivo y uno negativo? ¿Dos números negativos? ¿Uno positivo y otro negativo?
- ¿Qué sucede si desborde el valor entero?
Agreguemos estos casos y mejoremos un poco el nombre de prueba existente.
No permitiremos desbordamientos en nuestra implementación. Si la sum
se desborda, lanzaremos una ArithmeticException
en su lugar.
calculadora de clase debe { Calculadora privada calculadora = nueva Calculadora(); @Prueba void sumaNúmerosPositivos() { suma int = calculadora. suma (1, 2); afirmar que (suma). es igual a (3); } @Prueba void sumaNúmerosNegativos() { int suma = calculadora.suma(-1, -1); afirmar que (suma). es igual a (-2); } @Prueba void sumPositiveAndNegativeNumbers() { suma int = calculadora. suma (1, -2); afirmar que (suma). es igual a (-1); } @Prueba void failWithArithmeticExceptionWhenOverflyn() { afirmarQueLanzadoPor(() -> calculadora.sum(Integer.MAX_VALUE, 1)) .isInstanceOf(ArithmeticException.clase); } }
JUnit creará una nueva instancia de CalculatorShould
antes de ejecutar cada método @Test
. Eso significa que cada CalculatorShould
tener una calculator
diferente para que no tengamos que instanciarla en cada prueba.
La prueba shouldFailWithArithmeticExceptionWhenOverflown
usa un tipo diferente de assert
. Comprueba que un fragmento de código falló. El método assertThatThrownBy
ejecutará la lambda que proporcionamos y se asegurará de que haya fallado. Como ya sabemos, todos los métodos assertThat
devuelven un Assert
especializado que nos permite verificar qué tipo de excepción ocurrió.
Este es un ejemplo de cómo podemos probar que nuestro código falla cuando esperamos que lo haga. Si en algún momento refactorizamos Calculator
y no arroja ArithmeticException
en un desbordamiento, nuestra prueba fallará.
Patrón de diseño ObjectMother
El siguiente ejemplo es una clase de validación para garantizar que una instancia de Person sea válida.
clase PersonaValidadorDebería { validador de PersonValidator privado = new PersonValidator(); @Prueba void failWhenNameIsNull() { Persona persona = nueva Persona(null, 20, nueva Dirección(...), ...); afirmarQueLanzadoPor(() -> validador.validar(persona)) .isInstanceOf(InvalidPersonException.class); } @Prueba void failWhenAgeIsNegative() { Persona persona = nueva Persona("Juan", -5, nueva Dirección(...), ...); afirmarQueLanzadoPor(() -> validador.validar(persona)) .isInstanceOf(InvalidPersonException.class); } }
El patrón de diseño ObjectMother se usa a menudo en pruebas que crean objetos complejos para ocultar los detalles de creación de instancias de la prueba. Múltiples pruebas pueden incluso crear el mismo objeto pero probar diferentes cosas en él.
La prueba #1 es muy similar a la prueba #2. Podemos refactorizar PersonValidatorShould
extrayendo la validación como un método privado y luego pasar instancias ilegales de Person
esperando que todas fallen de la misma manera.
clase PersonaValidadorDebería { validador de PersonValidator privado = new PersonValidator(); @Prueba void failWhenNameIsNull() { shouldFailValidation(PersonObjectMother.createPersonWithoutName()); } @Prueba void failWhenAgeIsNegative() { shouldFailValidation(PersonObjectMother.createPersonWithNegativeAge()); } void privado shouldFailValidation(Persona invalidPerson) { afirmarQueLanzadoPor(() -> validador.validar(persona no válida)) .isInstanceOf(InvalidPersonException.class); } }
Prueba de aleatoriedad
¿Cómo se supone que debemos probar la aleatoriedad en nuestro código?
Supongamos que tenemos un PersonGenerator
que tiene generateRandom
para generar instancias de Person
aleatorias.
Empezamos escribiendo lo siguiente:
clase PersonaGeneradorDebería { privado PersonGenerator generador = new PersonGenerator(); @Prueba void generarPersonaVálida() { Persona persona = generador.generateRandom(); afirmar que (persona). } }
Y entonces deberíamos preguntarnos:
- ¿Qué estoy tratando de probar aquí? ¿Qué tiene que hacer esta funcionalidad?
- ¿Debo simplemente verificar que la persona generada es una instancia no nula?
- ¿Necesito probar que es aleatorio?
- ¿La instancia generada tiene que seguir algunas reglas comerciales?
Podemos simplificar nuestra prueba usando Inyección de Dependencia.
interfaz pública RandomGenerator { Cadena generarRandomString(); int generarEnteroAleatorio(); }
PersonGenerator
ahora tiene otro constructor que también acepta una instancia de esa interfaz. Por defecto, usa la implementación JavaRandomGenerator
que genera valores aleatorios usando java.Random
.
Sin embargo, en la prueba, podemos escribir otra implementación más predecible.
@Prueba void generarPersonaVálida() { RandomGenerator randomGenerator = new PredictableGenerator("John Doe", 20); PersonGenerator generador = new PersonGenerator(randomGenerator); Persona persona = generador.generateRandom(); afirmar que (persona). es igual a (nueva persona ("John Doe", 20)); }
Esta prueba demuestra que PersonGenerator
genera instancias aleatorias según lo especificado por RandomGenerator
sin entrar en ningún detalle de RandomGenerator
.
Probar JavaRandomGenerator
realmente no agrega ningún valor, ya que es un contenedor simple alrededor java.Random
. Al probarlo, esencialmente estaría probando java.Random
de la biblioteca estándar de Java. Escribir pruebas obvias solo conducirá a un mantenimiento adicional con pocos o ningún beneficio.
Para evitar escribir implementaciones con fines de prueba, como PredictableGenerator
, debe usar una biblioteca de simulación como Mockito.
Cuando escribimos PredictableGenerator
, en realidad eliminamos la clase RandomGenerator
manualmente. También podrías haberlo eliminado usando Mockito:
@Prueba void generarPersonaVálida() { RandomGenerator randomGenerator = simulacro(RandomGenerator.class); when(generador aleatorio.generateRandomString()).thenReturn("John Doe"); when(generador aleatorio.generateRandomInteger()).thenReturn(20); PersonGenerator generador = new PersonGenerator(randomGenerator); Persona persona = generador.generateRandom(); afirmar que (persona). es igual a (nueva persona ("John Doe", 20)); }
Esta forma de escribir pruebas es más expresiva y conduce a menos implementaciones para pruebas específicas.
Mockito es una biblioteca de Java para escribir simulacros y stubs. Es muy útil cuando se prueba código que depende de bibliotecas externas que no se pueden instanciar fácilmente. Le permite escribir el comportamiento de estas clases sin implementarlas directamente.
Mockito también permite otra sintaxis para crear e inyectar simulacros para reducir el modelo cuando tenemos más de una prueba similar a la que estamos acostumbrados:
@ExtendWith(MockitoExtension.class) // 1 clase PersonaGeneradorDebería { @Mock // 2 generador aleatorio generador aleatorio; @InyectarMocks // 3 generador privado PersonGenerator; @Prueba void generarPersonaVálida() { when(generador aleatorio.generateRandomString()).thenReturn("John Doe"); when(generador aleatorio.generateRandomInteger()).thenReturn(20); Persona persona = generador.generateRandom(); afirmar que (persona). es igual a (nueva persona ("John Doe", 20)); } }
1. JUnit 5 puede usar "extensiones" para ampliar sus capacidades. Esta anotación le permite reconocer simulacros a través de anotaciones e inyectarlas correctamente.
2. La anotación @Mock
crea una instancia simulada del campo. Esto es lo mismo que escribir mock(RandomGenerator.class)
en el cuerpo de nuestro método de prueba.
3. La anotación @InjectMocks
creará una nueva instancia de PersonGenerator
e inyectará simulacros en la instancia del generator
.
Para obtener más detalles sobre las extensiones de JUnit 5, consulte aquí.
Para obtener más detalles sobre la inyección de Mockito, consulte aquí.
Hay una trampa al usar @InjectMocks
. Puede eliminar la necesidad de declarar una instancia del objeto manualmente, pero perdemos la seguridad en tiempo de compilación del constructor. Si en algún momento alguien agrega otra dependencia al constructor, no obtendríamos el error de tiempo de compilación aquí. Esto podría conducir a pruebas fallidas que no son fáciles de detectar. Prefiero usar @BeforeEach
para configurar la instancia manualmente:
@ExtendWith(MockitoExtension.class) clase PersonaGeneradorDebería { @Imitar generador aleatorio generador aleatorio; generador privado PersonGenerator; @AntesDeCada configuración vacía () { generador = new PersonGenerator(randomGenerator); } @Prueba void generarPersonaVálida() { when(generador aleatorio.generateRandomString()).thenReturn("John Doe"); when(generador aleatorio.generateRandomInteger()).thenReturn(20); Persona persona = generador.generateRandom(); afirmar que (persona). es igual a (nueva persona ("John Doe", 20)); } }
Probar procesos sensibles al tiempo
Un fragmento de código a menudo depende de las marcas de tiempo y tendemos a usar métodos como System.currentTimeMillis()
para obtener la marca de tiempo de la época actual.
Si bien esto se ve bien, es difícil probar y probar si nuestro código funciona correctamente cuando la clase toma decisiones por nosotros internamente. Un ejemplo de tal decisión sería determinar cuál es el día actual.
indexador de clase debe { indexador privado indexador = nuevo indexador (); @Prueba void generarNombreÍndiceParaMañana() { String indexName = indexer.tomorrow("my-index"); // esta prueba funcionaría hoy, pero ¿y mañana? afirmar eso (nombre del índice) .isEqualTo("mi-índice.2022-02-02"); } }
Deberíamos usar Inyección de Dependencia nuevamente para poder 'controlar' cuál es el día al generar el nombre del índice.
Java tiene una clase Clock
para manejar casos de uso como este. Podemos pasar una instancia de un Clock
a nuestro Indexer
para controlar el tiempo. El constructor predeterminado podría usar Clock.systemUTC()
para compatibilidad con versiones anteriores. Ahora podemos reemplazar las llamadas System.currentTimeMillis()
con clock.millis()
.
Al inyectar un Clock
, podemos imponer un tiempo predecible en nuestras clases y escribir mejores pruebas.
Prueba de métodos de producción de archivos
- ¿Cómo deberíamos probar las clases que escriben su salida en archivos?
- ¿Dónde debemos almacenar estos archivos para que funcionen en cualquier sistema operativo?
- ¿Cómo podemos asegurarnos de que el archivo no existe ya?
Cuando se trata de archivos, puede ser difícil escribir pruebas si tratamos de abordar estas preocupaciones nosotros mismos, como veremos en el siguiente ejemplo. La prueba que sigue es una prueba antigua de dudosa calidad. Debería probar si un DogToCsvWriter
serializa y escribe perros en un archivo CSV:
clase DogToCsvWriterDebe { escritor privado de DogToCsvWriter = new DogToCsvWriter("/tmp/dogs.csv"); @Prueba void convertToCsv() { escritor.appendAsCsv(nuevo Perro(Breed.CORGI, Color.BROWN, "Monty")); escritor.appendAsCsv(nuevo Perro(Raza.MALTESE, Color.BLANCO, "Zoe")); String csv = Archivos.readString("/tmp/dogs.csv"); afirmar que(csv).isEqualTo("Monty,corgi,marrón\nZoe,maltés,blanco"); } }
El proceso de serialización debe desvincularse del proceso de escritura, pero concentrémonos en arreglar la prueba.
El primer problema con la prueba anterior es que no funcionará en Windows ya que los usuarios de Windows no podrán resolver la ruta /tmp/dogs.csv
. Otro problema es que no funcionará si el archivo ya existe, ya que no se elimina cuando se ejecuta la prueba anterior. Podría funcionar bien en una canalización de CI/CD, pero no localmente si se ejecuta varias veces.
JUnit 5 tiene una anotación que puede usar para obtener una referencia a un directorio temporal que el marco crea y elimina por usted. Si bien el mecanismo de creación y eliminación de archivos temporales varía de un marco a otro, las ideas siguen siendo las mismas.
clase DogToCsvWriterDebe { @Prueba void convertToCsv(@TempDir Path tempDir) { Ruta dogsCsv = tempDir.resolve("dogs.csv"); DogToCsvWriter escritor = new DogToCsvWriter(dogsCsv); escritor.appendAsCsv(nuevo Perro(Breed.CORGI, Color.BROWN, "Monty")); escritor.appendAsCsv(nuevo Perro(Raza.MALTESE, Color.BLANCO, "Zoe")); String csv = Archivos.readString(dogsCsv); afirmar que(csv).isEqualTo("Monty,corgi,marrón\nZoe,maltés,blanco"); } }
Con este pequeño cambio, ahora estamos seguros de que la prueba anterior funcionará en Windows, macOS y Linux sin tener que preocuparnos por las rutas absolutas. También eliminará los archivos creados después de la prueba, por lo que ahora podemos ejecutarlo varias veces y obtener resultados predecibles cada vez.
Comando vs prueba de consulta
¿Cuál es la diferencia entre un comando y una consulta?
- Comando : instruimos a un objeto para que realice una acción que produzca un efecto sin devolver un valor (métodos void)
- Consulta : le pedimos a un objeto que realice una acción y devuelva un resultado o una excepción
Hasta ahora, hemos probado principalmente consultas en las que llamamos a un método que devolvió un valor o lanzó una excepción en la fase de acción. ¿Cómo podemos probar métodos void
y ver si interactúan correctamente con otras clases? Los marcos proporcionan un conjunto diferente de métodos para escribir este tipo de pruebas.
Las aserciones que escribimos hasta ahora para las consultas comenzaban con assertThat
. Cuando escribimos pruebas de comando, usamos un conjunto diferente de métodos porque ya no estamos inspeccionando los resultados directos de los métodos como lo hicimos con las consultas. Queremos 'verificar' las interacciones que tuvo nuestro método con otras partes de nuestro sistema.
@ExtendWith(MockitoExtension.class) clase FeedMentionServiceDebería { @Imitar repositorio FeedRepository privado; @Imitar emisor privado de FeedMentionEventEmitter; servicio privado FeedMentionService; @AntesDeCada configuración vacía () { servicio = nuevo FeedMentionService (repositorio, emisor); } @Prueba void insertMentionToFeed() { largo feedId = 1L; Mencionar mencionar = ...; when(repository.upsertMention(feedId, mención)) .thenReturn(UpsertResult.success(feedId, mención)); Evento FeedInsertionEvent = new FeedInsertionEvent(feedId, mención); mencionarServicio.insertMentionToFeed(evento); verificar (emisor).mentionInsertedToFeed (feedId, mención); verificarNoMásInteracciones(emisor); } }
En esta prueba, primero nos burlamos de nuestro repositorio para responder con un UpsertResult.success
cuando se nos pidió que añadiéramos una mención en nuestro feed. No estamos interesados en probar el repositorio aquí. Los métodos del repositorio deben probarse en FeedRepositoryShould
. Al burlarnos de este comportamiento, en realidad no llamamos al método de repositorio. Simplemente le dijimos cómo responder la próxima vez que lo llamen.
Luego le dijimos a nuestro servicio de mentionService
que insertara esta mención en nuestro feed. Sabemos que debería emitir el resultado solo si insertó con éxito la mención en el feed. Al usar el método de verify
, podemos asegurarnos de que el método mentionInsertedToFeed
se llamó con nuestra mención y fuente y no se volvió a llamar usando verifyNoMoreInteractions
.
Pensamientos finales
Escribir pruebas de calidad proviene de la experiencia, y la mejor manera de aprender es haciendo. Los consejos escritos en este blog provienen de la práctica. Es difícil ver algunas de las trampas si nunca las encontró y, con suerte, estas sugerencias deberían hacer que el diseño de su código sea más sólido. Tener pruebas confiables aumentará su confianza para cambiar las cosas sin sudar cada vez que tenga que implementar su código.
¿Está interesado en unirse al equipo de Mediatoolkit?
¡Vea nuestra posición abierta para Desarrollador Frontend Senior !