¿Por qué no se llaman a mis métodos simulados al ejecutar una prueba unitaria?

Resuelto knittl asked hace 2 años • 1 respuestas
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 Randomtiene 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 @Mockno 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 thenReturnno respetado / Mockito thenAnswerno respetado
  • @InjectMocksno funciona
  • @Mockno funciona
  • Mockito.mockno 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
knittl avatar Oct 11 '22 18:10 knittl
Aceptado

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 newobjetos 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.mocka 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 Listo Map(que no deberían burlarse en primer lugar, pero esa es una historia diferente).

Lo mismo se aplica a la @Mockanotació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: newen 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:

  1. 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.
  2. 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.
  3. 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).FunctionBiFunction@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 @InjectMocksy verifiqué con el depurador que tengo simulacros dentro de mi clase bajo prueba. Sin embargo, ¡los métodos simulados que configuré Mockito.mocky Mockito.whennunca 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 @InjectMocksinyecta el valor original en la clase bajo prueba ( obj). La instancia creada con Mockito.mocksolo existe en la prueba, no en las clases bajo prueba.

El orden de operaciones aquí es:

  1. A todos @Mocklos campos anotados se les asigna un nuevo objeto simulado.
  2. El @InjectMockscampo anotado obtiene referencias inyectadas a los objetos simulados del paso 1.
  3. 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.
  4. 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:

  1. MyTestAnnotatedEl marco de prueba crea una nueva instancia de (p. ej new MyTestAnnotated().).
  2. 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, el randomcampo todavía tiene su valor predeterminado null→ el objcampo está asignado new MyClass(null).
  3. A todos @Mocklos campos anotados se les asigna un nuevo objeto simulado. Esto no actualiza el valor en MyService obj, porque se pasó nullinicialmente, no es una referencia al simulacro.

Dependiendo de su MyServiceimplementación, es posible que esto ya falle al crear una instancia de la clase de prueba ( MyServicepodrí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
knittl avatar Oct 11 '2022 11:10 knittl