9 min to read
Unit testing Jest & Jasmine
Unit testing - Jasmine & Jest
Buenas! como andan? El post de hoy va dedicado a unit testing general teniendo en cuenta algunos conceptos y experiencias en ambos frameworks: Jasmine y Jest.
mockReturnValue y mockReturnValueOnce
Supongamos un escenario donde queremos probar qué sucede cuando un método de algun servicio, retorna diferentes valores. Un ejemplo clásico que podríamos usar sería el siguiente:
jest.spyOn(userService, "get").mockReturnValue(0);
Ahora, si quisiéramos retornar otros valores para distintas llamadas al método, podemos hacer esto:
jest
.spyOn(userService, "get")
.mockReturnValue(0) // default
.mockReturnValueOnce(2) // first call
.mockReturnValueOnce(4); // second call
it("should mock the return value of consecutive calls differently", () => {
expect(userService.get()).toBe(2);
expect(userService.get()).toBe(4);
expect(userService.get()).toBe(0);
expect(userService.get()).toBe(0);
});
Como se puede ver, con la función mockReturnValue retornaremos por “default” un valor, y si concatenamos con la función mockReturnValueOnce, el once nos da la clave de que sólo retornará una única vez ese valor, y será según el orden que le demos. Como vemos en el ejercicio, la primer llamada retornará 2, luego 4 y luego, todas las veces que lo sigamos llamando, siempre retornará 0 (default).
mockImplementation y mockImplementationOnce
Del mismo modo que hicimos antes, podríamos concatenar distintos comportamientos por cada vez que se llame/invoque al método. Es decir, podríamos hacer exactamente el mismo ejercicio anterior pero usando la función mockImplementation(), o también customizarlo un poco si así lo quisiéramos, por ejemplo:
it("Should mock the return value of consecutive calls differently", () => {
userService.get = jest
.fn()
.mockImplementation(() => 0) // default
.mockImplementationOnce((num) => num + 10) // first call
.mockImplementationOnce((num) => num + 20); // second call
expect(userService.get(10)).toBe(20);
expect(userService.get(10)).toBe(30);
expect(userService.get()).toBe(0);
expect(userService.get()).toBe(0);
});
Como vemos en este ejercicio, es posible realizar una suma diferente en cada llamada o invocación a nuestro método. Lo útil del mockImplementation es que al recibir una función como parámetro, debemos usarlo sabiamente, ya que con esta función es posible re-escribir cualquier lógica.
Jasmine
Este mismo ejercicio de retornar distintos valores en llamadas consecutivas, en Jasmine también es posible, usando las funciones returnValue y returnValues . En el caso de returnValue() devolveremos un sólo valor, mientras que en el caso de returnValues() podremos retornar distintos valores separados por coma y en ese orden. Quedaría de la siguiente manera:
spyOn(userService, "get").and.returnValues(2, 4, 0);
También, si quisiéramos obtener el equivalente de mockImplementation(), en Jasmine se podría hacer con la función callFake(), que también recibe como parámetro una función:
spyOn(obj, "methodName").and.callFake(customImplementation);
Limpieza de Spies
En Jest, si quisiéramos limpiar los spies que vamos mockeando en cada escenario, podemos usar alguna de las 3 funciones que nos provee este framework: mockClear(), mockReset() y mockRestore(). Veamos como funcionan
const spy = jest.spyOn(userService, "get");
spy.mockImplementation(() => "mocked implementation");
userService.get();
expect(spy).toHaveBeenCalledTimes(1); //mocked implementation
spy.mockClear();
userService.get();
expect(spy).toHaveBeenCalledTimes(1); //mocked implementation
spy.mockReset();
userService.get();
expect(spy).toHaveBeenCalledTimes(1); //undefined (reset destruye mockImplementation)
spy.mockRestore();
userService.get();
expect(spy).toHaveBeenCalledTimes(0); //Its me Mario!(implementación (stub) en provider)
El primer caso, mockClear() lo que hará será limpiar toda la información de tracking que nuestro spy almacena, pero mantendrá las mismas características de nuestro spy. En este caso, seguirá retornando lo que mockeamos mediante mockImplementation().
El segundo caso, mockReset(), no sólo eliminará la información de tracking al igual que el clear, sino que también destruirá el mockeo que hayamos implementado, y lo reemplazará por undefined. Lo que significa que para poder continuar usando ese spy, deberemos volver a mockearlo según lo que necesitemos probar.
El tercer caso, mockRestore(), ya dejará de ser un spy, e invocará a nuestro método e implementación real. Es por eso que en este caso, el *toHaveBeenCalledTimes(0)* será 0, ya que el spy no se llama y directamente se ejecutaría la implementación real del método.
También es posible que usemos estas 3 funciones pero generales para todos los spies, en lugar de cada uno, para poder invocarlas por ejemplo, en los before/afterEach yo before/afterAll, de esta manera:
jest.clearAllMocks();
jest.resetAllMocks();
jest.restoreAllMocks();
Spies: Jasmine vs Jest
Un tema fundamental a tener en cuenta es que en Jasmine, y en la mayoría de los frameworks de testing, haciendo lo siguiente:
spyOn(obj, ‘methodName’); //!real function
Vemos que basta con sólo crear el spy de un método, para mockearlo, y que la implementación real no sea accesible por éste. De tal manera que cuando invoquemos nuestro método, éste no accederá a su implementación real, como si estuviera vacío.
Sin embargo, en Jest, es todo lo contrario:
jest.spyOn(obj, ‘methodName’); //real function
Cuando creamos el spy, no es suficiente y si invocamos al método, la implementación real es la que va a ir a buscar. Es por eso que en el caso de Jest, debemos aplicar algunas de las funciones de mockeo que nos provee el framekwork.
Mockeando dependencias
Para mockear nuestras dependencias/servicios, basta con inyectarlo en nuestros tests de alguna de las siguientes maneras:
TestBed.configureTestingModule({
providers: [
{ provide: service, useClass: serviceClassMock }
{ provide: service, useValue: { serviceMock } }
{ provide: service, useFactory: () => {
if (IS_A) {
return new A();
} else {
return new B();
}
}
En el primer caso useClass , usaremos una instancia de una clase. En el segundo caso useValue, usaremos un objeto o valor estático. Y en el tercer caso useFactory usaremos una función que pueda producir el valor a inyectar.
En el caso de una dependencia que no sea inyectada por constructor, sino directamente en un componente, de esta manera:
@Component({
templateUrl: './main.component.html',
styleUrls: ['./main.component.scss'],
providers: [service]
})
Para poder mockear esa dependencia (scoped), vamos a necesitar aplicar la función overrideComponent(), de la siguiente manera:
TestBed.configureTestingModule({
//imports, declarations, providers, schemas
})
.overrideComponent(MainComponent, { set: { providers:
[{ provide: service, useClass: serviceClassMock }]
}})
.compileComponents()});
Implementando esa función, podremos inyectar nuestra dependencia con su respectivo mock.
Conclusión
Viendo algunos conceptos y experiencias para continuar armando y mockeando nuestros tests, también es necesario tener en cuenta algunos puntos cuando escribimos tests:
-
Tests simples, claros y descriptivos en los mensajes: la idea es que seamos tan claros que la próxima persona que lea nuestro test, pueda interpretar fácilmente qué se está probando y no tenga que ir a leer toda la suite de tests para poder hacerlo.
- Tests aislados, sin dependencias ni orden de ejecución: recordemos siempre mockear todas las dependencias y que nuestros tests deben arrojar siempre los mismos resultados, independientemente del orden en el que se ejecuten.
- Regla AAA: Arrange, Act, Assert: para una mejor legibilidad resulta útil poder desglosar y separar las 3 acciones en cada uno de nuestros tests.
- Exponer bugs: desde nuestros unit tests, es posible recrear los escenarios para poder reproducir los bugs que se hayan detectado, revelando los mismos, nos puede ser de mucha ayuda para detectar dónde se encuentra el problema y así poder fixear el código. También nos da mayor seguridad tener el unit test del escenario que estamos fixeando para garantizar que tal bug no regrese a la vida :) Happy coding!