En este artículo voy a centrarme únicamente en las aserciones de los tests, relatar mi experiencia, pasando por una serie de ejemplos en diferentes tecnologías, para ilustrar esas pequeñas cosas que me hubiera gustado saber cuando empecé a programar y que marcan una diferencia radical en el día a día. Buscar una mejor manera de hacer las cosas ha inspirado este artículo, al igual que las vivencias en el equipo de Lean Mind.
Si mirásemos la estructura de un test nos centraríamos en este bloque:
class InvoiceShould {
@Test
void not_allow_negative_discount() {
// arrange or given
// act or when
// assertion or then <-- 👈
}
}
Dentro de los tests, las aserciones son el pilar de la mantenibilidad de los mismos, respondiendo a la pregunta: ¿por qué estás fallando? Dependiendo de su legibilidad, puede ser un camino de rosas o un infierno debugeando para ver qué pasa. Veamos el primer ejemplo, he elegido este en particular porque lo veo mucho más de lo que me gustaría:
class MovementShould(TestCase):
def test_move_aircraft_to_the_north(self):
# some code goes here
assert plane.position == Position(0, 1)
Esto da como resultado:
Traceback (most recent call last):
File "path/to/your/test/file.py", line XX, in test_move_aircraft_to_the_north
assert plane.position == Position(0, 1)
AssertionError
No parece muy legible que digamos, ni tan siquiera nos devuelve la posición que tenía el avión. Como es lógico, el siguiente paso sería usar una librería de aserciones o implementarlo nosotros. En este caso, vamos a ver un poco de TypeScript, el cual mejora enormemente la legibilidad. Miremos este ejemplo:
describe('Memory', () => {
describe('on memory management should', () => {
it('increase the memory slots', () => {
// some code
expect(memory.getMemoryCells()).toHaveLength(3);
});
// more code
Si fallara este test, nos devolvería un resultado mucho más legible porque usamos expect
de vitest
:
FAIL src/tests/memory.test.ts > Memory should > on memory management > increase the memory slots
AssertionError: expected [ MemoryCell{ memoryValue: +0 }, …(2) ] to have a length of 4 but got 3
- Expected
+ Received
- 4
+ 3
❯ src/tests/memory.test.ts:20:36
18| memory.incrementValueTo(pointerPosition);
19|
20| expect(memory.getMemoryCells()).toHaveLength(4);
| ^
21| });
22|
Este es el punto en el que la mayoría de developers estamos, en el cual usamos los componentes de la librería para intentar dar legibilidad a nuestras aserciones. Usamos a veces muchas aserciones para un mismo test. Por mera vagancia o por no hacer o duplicar la lógica en otro test, ponemos más aserciones de las que debemos. Veamos otro ejemplo:
class Invoice:
def test_apply_discount():
# some code goes here
assert_that(invoice.discount).is_equal_to(10)
assert_that(invoice.amount).is_equal_to(90)
assert_that(invoice.title).is_equal_to("Invoice X with reference Y")
En este código se presentan dos problemas. El primero es que se verifican cosas que no tienen relación con el descuento, como puede ser el título, por lo que es muy probable que debiera ir en otro test. Por otro lado, la segunda parte es que si el descuento empieza a afectar a otras zonas de la factura, el código se presta a seguir añadiendo aserciones indefinidamente. No hay un número exacto, pero a la cuarta o quinta aserción podemos optar por usar aserciones personalizadas (custom assertions). Se pueden usar las opciones que ofrecen librerías como Assertpy
o AssertJ
, o hacer una propia como en el siguiente ejemplo, donde incluso se pueden añadir mensajes propios para hacerlo más entendible:
def is_invoice_equal_to(self, amount, discount):
self._assert(self.val.amount == amount, f"Expected amount to be {amount} but was {self.val.amount}")
self._assert(self.val.discount == discount, f"Expected discount to be {discount} but was {self.val.discount}")
return self
Dejando nuestro test de la siguiente manera:
class Invoice:
def test_apply_discount():
# some code goes here
assert_that(invoice).is_invoice_equal_to(amount=90, discount=10)
Su output si fallara sería:
> assert_that(invoice).is_invoice_equal_to(amount=90, discount=10)
E AssertionError: Expected amount to be 90 but was 80
E Expected discount to be 10 but was 5
test_invoice.py:xx: AssertionError
De esta manera, si empezara a fallar todo, todos los cambios producidos en la factura se evaluarían de una sola vez con más legibilidad, en lugar de tener que ir corrigiendo cada aserción de una en una. Me parece imprescindible dedicarle tiempo a estos problemas, ya que afectan enormemente al tiempo que tardas en arreglar algo. La idea es, que se consiga un resultado que pueda ayudar a alguien no tan técnico a entender el problema.
¿Sabías que el término “assertion” en los tests de programación fue popularizado por Tony Hoare, un destacado científico informático británico, conocido por sus contribuciones al desarrollo de lenguajes de programación y su trabajo en la teoría de lenguajes de programación? Hoare introdujo el uso de “assertions” en el contexto de la programación, con su trabajo en la “Hoare logic” o “Hoare triples”, que son una manera formal de especificar y verificar el comportamiento de los programas. Estos conceptos fueron presentados en su influyente artículo de 1969, titulado “An Axiomatic Basis for Computer Programming”.
Volviendo a las aserciones, hay varios trucos que me han ayudado muchísimo a mejorarlas. La idea de ser explícitos en las aserciones es un factor fundamental. Por ejemplo, si tenemos una lista de elementos a la que hemos añadido un valor, es ideal no usar .Contains(x)
, sino .ContainsOnly(x)
, de esta manera no nos dejamos nada fuera.
// antes
Assertions.assertThat(elements).contains("elemento1")
// después
Assertions.assertThat(elements).containsOnly("elemento1");
Otro tip es plantearse si estamos generando getters
o setters
solo para los tests. En lenguajes como TypeScript podemos hacer lo siguiente:
expect(coordinate).toStrictEqual(new CoordinateBuilder().withX(1).build());
De esta manera evitamos añadir getters donde no hacen falta. Pero en lenguajes como Java, a veces hay que sobreescribir el método equals
para poder comparar sus atributos, en vez de la referencia del objeto en memoria.
Debemos ser pragmáticos. En muchas librerías de mocking, la mayoría de las veces no vale la pena intentar mejorar un mensaje cuando lo único que se mira es que fue llamado, como por ejemplo:
it('start recording', () => {
const spyRecorder = vi.spyOn(recorder, 'startRecording');
controller.monitor();
expect(spyRecorder).toHaveBeenCalled();
});
Finalmente, decir que las aserciones dependen en gran medida de nuestro estilo de programación. Por ejemplo, el estilo funcional cambia la perspectiva de cómo veo la programación, al igual que los tests. Si usáramos Either, podrían quedar de la siguiente manera:
public class BagShould
{
private readonly Bag _bag = new(Category.Electronics);
[Fact]
public void not_allow_to_store_items_with_different_category()
{
const Category differentCategory = Category.Clothes;
var laptop = Item.From("Laptop", differentCategory);
_bag.Store(laptop).Match(
error => error.Should().BeOfType<DifferentCategoryItemError>(),
bag => bag.RetrieveAll().Should().NotContain(laptop)
);
}
}
Como cierre, espero que os haya gustado este artículo, el cual se complementa muy bien con el resto de artículos de la web. Os recomiendo echar un vistazo. Hasta la próxima.
¿Quieres más? te invitamos a suscribirte a nuestro boletín para avisarte cada vez que recopilemos contenido de calidad que compartir.
Si disfrutas leyendo nuestro blog, ¿imaginas lo divertido que sería trabajar con nosotros? ¿te gustaría?
Pero espera 🖐 que tenemos un conflicto interno. A nosotros las newsletter nos parecen 💩👎👹 Por eso hemos creado la LEAN LISTA, la primera lista zen, disfrutona y que suena a rock y reggaeton del sector de la programación. Todos hemos recibido newsletters por encima de nuestras posibilidades 😅 por eso este es el compromiso de la Lean Lista