¿Por qué no se llaman a mis métodos simulados al ejecutar una prueba unitaria?
Prefacio:
Esta pregunta y respuesta pretenden ser una respuesta canónica a la mayoría de las preguntas que surgen debido al mal uso de Mockito o a una mala comprensión de cómo funciona Mockito e interactúa con las pruebas unitarias escritas en el lenguaje Java.
He implementado una clase que debería probarse unitariamente. Tenga en cuenta que el código que se muestra aquí es sólo una implementación ficticia y Random
tiene fines ilustrativos. El código real utilizaría una dependencia real, como otro servicio o repositorio.
public class MyClass {
public String doWork() {
final Random random = new Random(); // the `Random` class will be mocked in the test
return Integer.toString(random.nextInt());
}
}
Quiero usar Mockito para burlarme de otras clases y he escrito una prueba JUnit realmente simple. Sin embargo, mi clase no utiliza el simulacro en la prueba:
public class MyTest {
@Test
public void test() {
Mockito.mock(Random.class);
final MyClass obj = new MyClass();
Assertions.assertEquals("0", obj.doWork()); // JUnit 5
// Assert.assertEquals("0", obj.doWork()); // JUnit 4
// this fails, because the `Random` mock is not used :(
}
}
Incluso ejecutar la prueba con MockitoJUnitRunner
(JUnit 4) o extenderla con MockitoExtension
(JUnit 5) y anotar con @Mock
no ayuda; la implementación real todavía se usa:
@ExtendWith(MockitoExtension.class) // JUnit 5
// @RunWith(MockitoJUnitRunner.class) // JUnit 4
public class MyTest {
@Mock
private Random random;
@Test
public void test() {
final MyClass obj = new MyClass();
Assertions.assertEquals("0", obj.doWork()); // JUnit 5
// Assert.assertEquals("0", obj.doWork()); // JUnit 4
// `Random` mock is still not used :((
}
}
¿Por qué no se utiliza la clase simulada, a pesar de que los métodos de Mockito se llaman antes de que se pruebe mi clase o se ejecute la prueba con la extensión/ejecutador de Mockito?
Otras variantes de esta pregunta incluyen, entre otras:
- Mis simulacros devuelven nulo / Mis talones devuelven nulo
- NullPointerException al usar Mockito
- Mis simulacros son nulos en la prueba.
- Mis simulacros no devuelven el valor esperado/Mis talones no devuelven el valor esperado
- Mockito
thenReturn
no respetado / MockitothenAnswer
no respetado @InjectMocks
no funciona@Mock
no funcionaMockito.mock
no funciona- Mi clase no usa simulacros / Mi clase no usa stubs
- Mis pruebas todavía llaman o ejecutan la implementación real de una clase simulada/stubbed
TLDR: existen dos o más instancias distintas de su simulacro. Su prueba usa una instancia y su clase bajo prueba usa otra instancia. O no estás usando ningún simulacro en tu clase porque creas new
objetos dentro de la clase.
Descripción general del problema (clases frente a instancias)
Los simulacros son instancias (por eso también se les llama "objetos simulados"). Llamar Mockito.mock
a una clase devolverá un objeto simulado para esta clase. Debe asignarse a una variable que luego puede pasarse a los métodos relevantes o inyectarse como dependencia en otras clases. ¡No modifica la clase en sí! Piénselo: si eso fuera cierto, entonces todas las instancias de la clase se convertirían de alguna manera, mágicamente, en simulacros. Eso haría imposible burlarse de clases de las cuales se usan múltiples instancias o de clases del JDK, como List
o Map
(que no deberían burlarse en primer lugar, pero esa es una historia diferente).
Lo mismo se aplica a la @Mock
anotación con la extensión/corredor Mockito: se crea una nueva instancia de un objeto simulado, que luego se asigna al campo (o parámetro) anotado con @Mock
. Este objeto simulado aún debe pasarse a los métodos correctos o inyectarse como dependencia.
Otra forma de evitar esta confusión: new
en Java siempre asignará memoria para un objeto e inicializará esta nueva instancia de la clase real. Es imposible anular el comportamiento de new
. Ni siquiera los frameworks más inteligentes como Mockito pueden hacerlo.
Solución
»¿Pero cómo puedo burlarme de mi clase?«, preguntarás. ¡Cambia el diseño de tus clases para que sean comprobables! Cada vez que decides utilizar new
, te comprometes con una instancia de este tipo exacto. Existen múltiples opciones, según su caso de uso concreto y sus requisitos, que incluyen, entre otras:
- Si puede cambiar la firma/interfaz del método, pase la instancia (simulada) como parámetro del método. Esto requiere que la instancia esté disponible en todos los sitios de llamadas, lo que puede no siempre ser factible.
- Si no puede cambiar la firma del método, inyecte la dependencia en su constructor y guárdela en un campo para que los métodos la utilicen más tarde.
- A veces, la instancia solo debe crearse cuando se llama al método y no antes. En ese caso, puede introducir otro nivel de indirección y utilizar algo conocido como patrón de fábrica abstracto . El objeto de fábrica luego creará y devolverá la instancia de su dependencia. Pueden existir múltiples implementaciones de la fábrica: una que devuelve la dependencia real y otra que devuelve una prueba doble, como una simulada.
A continuación encontrará implementaciones de muestra para cada una de las opciones (con y sin corredor/extensión Mockito):
Cambiar la firma del método
public class MyClass {
public String doWork(final Random random) {
return Integer.toString(random.nextInt());
}
}
public class MyTest {
@Test
public void test() {
final Random mockedRandom = Mockito.mock(Random.class);
final MyClass obj = new MyClass();
Assertions.assertEquals("0", obj.doWork(mockedRandom)); // JUnit 5
// Assert.assertEquals("0", obj.doWork(mockedRandom)); // JUnit 4
}
}
@ExtendWith(MockitoExtension.class) // JUnit 5
// @RunWith(MockitoJUnitRunner.class) // JUnit 4
public class MyTestAnnotated {
@Mock
private Random random;
@Test
public void test() {
final MyClass obj = new MyClass();
Assertions.assertEquals("0", obj.doWork(random)); // JUnit 5
// Assert.assertEquals("0", obj.doWork(random)); // JUnit 4
}
}
Inyección de dependencia del constructor
public class MyClass {
private final Random random;
public MyClass(final Random random) {
this.random = random;
}
// optional: make it easy to create "production" instances (although I wouldn't recommend this)
public MyClass() {
this(new Random());
}
public String doWork() {
return Integer.toString(random.nextInt());
}
}
public class MyTest {
@Test
public void test() {
final Random mockedRandom = Mockito.mock(Random.class);
final MyClass obj = new MyClass(mockedRandom);
// or just obj = new MyClass(Mockito.mock(Random.class));
Assertions.assertEquals("0", obj.doWork()); // JUnit 5
// Assert.assertEquals("0", obj.doWork()); // JUnit 4
}
}
@ExtendWith(MockitoExtension.class) // JUnit 5
// @RunWith(MockitoJUnitRunner.class) // JUnit 4
public class MyTestAnnotated {
@Mock
private Random random;
@Test
public void test() {
final MyClass obj = new MyClass(random);
Assertions.assertEquals("0", obj.doWork()); // JUnit 5
// Assert.assertEquals("0", obj.doWork()); // JUnit 4
}
}
Construcción retrasada vía fábrica
Dependiendo de la cantidad de argumentos del constructor de su dependencia y la necesidad de código expresivo, se podrían usar interfaces existentes del JDK ( Supplier
,, ) o introducir una interfaz de fábrica personalizada (anotada con si solo tiene un método único).Function
BiFunction
@FunctionInterface
El siguiente código optará por una interfaz personalizada, pero funcionará bien con Supplier<Random>
.
@FunctionalInterface
public interface RandomFactory {
Random newRandom();
}
public class MyClass {
private final RandomFactory randomFactory;
public MyClass(final RandomFactory randomFactory) {
this.randomFactory = randomFactory;
}
// optional: make it easy to create "production" instances (again: I wouldn't recommend this)
public MyClass() {
this(Random::new);
}
public String doWork() {
return Integer.toString(randomFactory.newRandom().nextInt());
}
}
public class MyTest {
@Test
public void test() {
final RandomFactory randomFactory = () -> Mockito.mock(Random.class);
final MyClass obj = new MyClass(randomFactory);
Assertions.assertEquals("0", obj.doWork()); // JUnit 5
// Assert.assertEquals("0", obj.doWork()); // JUnit 4
}
}
@ExtendWith(MockitoExtension.class) // JUnit 5
// @RunWith(MockitoJUnitRunner.class) // JUnit 4
public class MyTestAnnotated {
@Mock
private RandomFactory randomFactory;
@Test
public void test() {
// this is really awkward; it is usually simpler to use a lambda and create the mock manually
Mockito.when(randomFactory.newRandom()).thenAnswer(a -> Mockito.mock(Random.class));
final MyClass obj = new MyClass(randomFactory);
Assertions.assertEquals("0", obj.doWork()); // JUnit 5
// Assert.assertEquals("0", obj.doWork()); // JUnit 4
}
}
Corolario: (Mal)uso@InjectMocks
Pero estoy usando
@InjectMocks
y verifiqué con el depurador que tengo simulacros dentro de mi clase bajo prueba. Sin embargo, ¡los métodos simulados que configuréMockito.mock
yMockito.when
nunca los invoco! (En otras palabras: "obtengo un NPE", "mis colecciones están vacías", "se devuelven los valores predeterminados", etc.)— un desarrollador confundido, ca. 2022
Expresada en código, la cita anterior se vería así:
@ExtendWith(MockitoExtension.class) // JUnit 5
// @RunWith(MockitoJUnitRunner.class) // JUnit 4
public class MyTestAnnotated {
@Mock
private Random random;
@InjectMocks
private MyClass obj;
@Test
public void test() {
random = Mockito.mock(Random.class);
Mockito.when(random.nextInt()).thenReturn(42);
Assertions.assertEquals("42", obj.doWork()); // JUnit 5
// Assert.assertEquals("42", obj.doWork()); // JUnit 4
}
}
El problema con el código anterior es la primera línea del test()
método: crea y asigna una nueva instancia simulada al campo, sobrescribiendo efectivamente el valor existente. Pero @InjectMocks
inyecta el valor original en la clase bajo prueba ( obj
). La instancia creada con Mockito.mock
solo existe en la prueba, no en las clases bajo prueba.
El orden de operaciones aquí es:
- A todos
@Mock
los campos anotados se les asigna un nuevo objeto simulado. - El
@InjectMocks
campo anotado obtiene referencias inyectadas a los objetos simulados del paso 1. - La referencia en la clase de prueba se sobrescribe con una referencia diferente al nuevo objeto simulado (creado mediante
Mockito.mock
). La referencia original se pierde y ya no está disponible en la clase de prueba. - La clase bajo prueba (
obj
) todavía tiene una referencia a la instancia simulada inicial y la usa. La prueba sólo tiene una referencia a la nueva instancia simulada.
Básicamente, esto se reduce a ¿Java es "paso por referencia" o "paso por valor"? .
Puedes verificar esto con un depurador. Establezca un punto de interrupción y luego compare las direcciones/identificadores de objetos de los campos simulados en la clase de prueba y en la clase bajo prueba. Notarás que se trata de dos instancias de objetos diferentes y no relacionadas.
¿La solución? No sobrescriba la referencia, pero configure la instancia simulada creada mediante la anotación. Simplemente deshazte de la reasignación con Mockito.mock
:
@ExtendWith(MockitoExtension.class) // JUnit 5
// @RunWith(MockitoJUnitRunner.class) // JUnit 4
public class MyTestAnnotated {
@Mock
private Random random;
@InjectMocks
private MyClass obj;
@Test
public void test() {
// this.random must not be re-assigned!
Mockito.when(random.nextInt()).thenReturn(42);
Assertions.assertEquals("42", obj.doWork()); // JUnit 5
// Assert.assertEquals("42", obj.doWork()); // JUnit 4
}
}
Corolario: ciclos de vida de objetos y anotaciones del marco mágico
Seguí tu consejo y utilicé la inyección de dependencia para pasar manualmente el simulacro a mi servicio. Todavía no funciona y mi prueba falla con excepciones de puntero nulo, a veces incluso antes de que se ejecute un único método de prueba. ¡Mentiste, hermano!
- otro desarrollador confundido, finales de 2022
El código probablemente se vería así:
@ExtendWith(MockitoExtension.class) // JUnit 5
// @RunWith(MockitoJUnitRunner.class) // JUnit 4
public class MyTestAnnotated {
@Mock
private Random random;
private final MyClass obj = new MyClass(random);
@Test
public void test() {
Mockito.when(random.nextInt()).thenReturn(42);
Assertions.assertEquals("42", obj.doWork()); // JUnit 5
// Assert.assertEquals("42", obj.doWork()); // JUnit 4
}
}
Esto es muy similar al primer corolario y se reduce a los ciclos de vida de los objetos y las referencias frente a los valores. Los pasos que ocurren en el código anterior son los siguientes:
MyTestAnnotated
El marco de prueba crea una nueva instancia de (p. ejnew MyTestAnnotated()
.).- Se ejecutan todos los constructores e inicializadores de campos. Aquí no hay constructores, sino un inicializador de campo:
private MyClass obj = new MyClass(random);
. En este momento, elrandom
campo todavía tiene su valor predeterminadonull
→ elobj
campo está asignadonew MyClass(null)
. - A todos
@Mock
los campos anotados se les asigna un nuevo objeto simulado. Esto no actualiza el valor enMyService obj
, porque se pasónull
inicialmente, no es una referencia al simulacro.
Dependiendo de su MyService
implementación, es posible que esto ya falle al crear una instancia de la clase de prueba ( MyService
podría realizar la validación de parámetros de sus dependencias en el constructor); o puede que solo falle al ejecutar un método de prueba (porque la dependencia es nula).
¿La solución? Familiarícese con los ciclos de vida de los objetos, el orden de los inicializadores de campos y el momento en el que los marcos simulados pueden o inyectarán sus simulacros y actualizarán las referencias (y qué referencias se actualizan). Intente evitar mezclar anotaciones de marco "mágicas" con la configuración manual. Cree todo manualmente (simulacros, servicio) o mueva la inicialización a los métodos anotados con @Before
(JUnit 4) o @BeforeEach
(JUnit 5).
@ExtendWith(MockitoExtension.class) // JUnit 5
// @RunWith(MockitoJUnitRunner.class) // JUnit 4
public class MyTestAnnotated {
@Mock
private Random random;
private MyClass obj;
@BeforeEach // JUnit 5
// @Before // JUnit 4
public void setup() {
obj = new MyClass(random);
}
@Test
public void test() {
Mockito.when(random.nextInt()).thenReturn(42);
Assertions.assertEquals("42", obj.doWork()); // JUnit 5
// Assert.assertEquals("42", obj.doWork()); // JUnit 4
}
}
Alternativamente, configure todo manualmente sin anotaciones que requieran un ejecutor/extensión personalizado:
public class MyTest {
private Random random;
private MyClass obj;
@BeforeEach // JUnit 5
// @Before // JUnit 4
public void setup() {
random = Mockito.mock(random);
obj = new MyClass(random);
}
@Test
public void test() {
Mockito.when(random.nextInt()).thenReturn(42);
Assertions.assertEquals("42", obj.doWork()); // JUnit 5
// Assert.assertEquals("42", obj.doWork()); // JUnit 4
}
}
Referencias
- ¿Java es "paso por referencia" o "paso por valor"?
- Inicializando objetos simulados - Mockito
- Diferencia entre @Mock y @InjectMocks
- Clase de prueba con una llamada new() con Mockito