How To Write Tests
The Anatomy of a Test Function
Las pruebas son funciones en Cairo que verifican que el código no relacionado con las pruebas está funcionando de la manera esperada. Los cuerpos de las funciones de prueba típicamente realizan estas tres acciones:
- Set up any needed data or state.
- Run the code you want to test.
- Assert the results are what you expect.
Veamos las características específicas que Cairo proporciona para escribir pruebas que realizan estas acciones, que incluyen el atributo test
, la función assert
y el atributo should_panic
.
The Anatomy of a Test Function
At its simplest, a test in Cairo is a function that’s annotated with the test
attribute. Attributes are metadata about pieces of Cairo code; one example is the derive attribute we used with structs in Chapter 5. To change a function into a test function, add #[test]
on the line before fn
. When you run your tests with the scarb cairo-test
command, Scarb runs Cairo's test runner binary that runs the annotated functions and reports on whether each test function passes or fails.
Creemos un nuevo proyecto llamado adder
que sumará dos números usando Scarb con el comando scarb new adder
:
adder
├── Scarb.toml
└── src
└── lib.cairo
In lib.cairo, let's remove the existing content and add a first test, as shown in Listing 9-1.
Filename: src/lib.cairo
#![allow(unused)] fn main() { #[test] fn it_works() { let result = 2 + 2; assert(result == 4, 'result is not 4'); } }
Por ahora, ignoraremos las dos primeras líneas y nos centraremos en la función. Observa la anotación #[test]
: este atributo indica que esta es una función de prueba, por lo que el runner de pruebas sabe que debe tratar esta función como una prueba. También podríamos tener funciones que no son de prueba en el módulo de pruebas para ayudar a configurar escenarios comunes o realizar operaciones comunes, por lo que siempre debemos indicar qué funciones son pruebas.
El cuerpo de la función de ejemplo utiliza la función assert
, que comprueba que el resultado de sumar 2 y 2 es igual a 4. Esta afirmación sirve como ejemplo del formato de una prueba típica. Ejecutémoslo para ver que esta prueba pasa.
The scarb cairo-test
command runs all tests founds in our project, as shown in Listing 9-2.
$ scarb cairo-test
testing adder...
running 1 tests
test adder::lib::tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 filtered out;
scarb cairo-test
compiled and ran the test. We see the line running 1 tests
. The next line shows the name of the test function, called it_works
, and that the result of running that test is ok
. The overall summary test result: ok.
means that all the tests passed, and the portion that reads 1 passed; 0 failed
totals the number of tests that passed or failed.
It’s possible to mark a test as ignored so it doesn’t run in a particular instance; we’ll cover that in the Ignoring Some Tests Unless Specifically Requested section later in this chapter. Because we haven’t done that here, the summary shows 0 ignored
. We can also pass an argument to the scarb cairo-test
command to run only a test whose name matches a string; this is called filtering and we’ll cover that in the Running Single Tests section. We also haven’t filtered the tests being run, so the end of the summary shows 0 filtered out
.
Comencemos a personalizar la prueba según nuestras necesidades. Primero, cambie el nombre de la función it_works
a un nombre diferente, como exploration
, así:
Filename: src/lib.cairo
#![allow(unused)] fn main() { #[test] fn exploration() { let result = 2 + 2; assert(result == 4, 'result is not 4'); } }
Then run scarb cairo-test
again. The output now shows exploration
instead of it_works
:
$ scarb cairo-test
running 1 tests
test adder::lib::tests::exploration ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 filtered out;
Now we’ll add another test, but this time we’ll make a test that fails! Tests fail when something in the test function panics. Each test is run in a new thread, and when the main thread sees that a test thread has died, the test is marked as failed. Enter the new test as a function named another
, so your src/lib.cairo file looks like Listing 9-3.
#![allow(unused)] fn main() { #[test] fn another() { let result = 2 + 2; assert(result == 6, 'Make this test fail'); } }
$ scarb cairo-test
running 2 tests
test adder::lib::tests::exploration ... ok
test adder::lib::tests::another ... fail
failures:
adder::lib::tests::another - panicked with [1725643816656041371866211894343434536761780588 ('Make this test fail'), ].
Error: test result: FAILED. 1 passed; 1 failed; 0 ignored
En lugar de ok
, la línea adder::lib::tests::another
muestra fail
. Aparece una nueva sección entre los resultados individuales y el resumen. Muestra la razón detallada de cada falla de prueba. En este caso, obtenemos los detalles de que another
falló porque falló con un pánico con [1725643816656041371866211894343434536761780588 ('Make this test fail'), ]
en el archivo src/lib.cairo.
La línea de resumen se muestra al final: en general, nuestro resultado de prueba es FAILED
. Tuvimos una prueba que pasó y otra que falló.
Ahora que ha visto cómo son los resultados de las pruebas en diferentes escenarios, veamos algunas funciones que son útiles en las pruebas.
Checking Results with the assert function
La función assert
, proporcionada por Cairo, es útil cuando desea asegurarse de que alguna condición en una prueba se evalúe como verdadera. Le damos a la función assert
un primer argumento que se evalúa como un valor booleano. Si el valor es true
, no sucede nada y la prueba pasa. Si el valor es false
, la función assert
llama a panic()
para hacer que la prueba falle con un mensaje que definimos como segundo argumento de la función assert
. Usar la función assert
nos ayuda a verificar que nuestro código funciona de la manera que pretendemos.
In Chapter 5, Listing 5-15, we used a Rectangle
struct and a can_hold
method, which are repeated here in Listing 9-5. Let’s put this code in the src/lib.cairo file, then write some tests for it using the assert
function.
Filename: src/lib.cairo
#![allow(unused)] fn main() { trait RectangleTrait { fn area(self: @Rectangle) -> u64; fn can_hold(self: @Rectangle, other: @Rectangle) -> bool; } impl RectangleImpl of RectangleTrait { fn area(self: @Rectangle) -> u64 { *self.width * *self.height } fn can_hold(self: @Rectangle, other: @Rectangle) -> bool { *self.width > *other.width && *self.height > *other.height } } }
The can_hold
method returns a bool
, which means it’s a perfect use case for the assert function. In Listing 9-6, we write a test that exercises the can_hold
method by creating a Rectangle
instance that has a width of 8
and a height of 7
and asserting that it can hold another Rectangle
instance that has a width of 5
and a height of 1
.
Filename: src/lib.cairo
#![allow(unused)] fn main() { use debug::PrintTrait; #[derive(Copy, Drop)] struct Rectangle { width: u64, height: u64, } trait RectangleTrait { fn area(self: @Rectangle) -> u64; fn can_hold(self: @Rectangle, other: @Rectangle) -> bool; } impl RectangleImpl of RectangleTrait { fn area(self: @Rectangle) -> u64 { *self.width * *self.height } fn can_hold(self: @Rectangle, other: @Rectangle) -> bool { *self.width > *other.width && *self.height > *other.height } } #[cfg(test)] mod tests { use super::Rectangle; use super::RectangleTrait; #[test] fn larger_can_hold_smaller() { let larger = Rectangle { height: 7, width: 8, }; let smaller = Rectangle { height: 1, width: 5, }; assert(larger.can_hold(@smaller), 'rectangle cannot hold'); } #[test] fn smaller_cannot_hold_larger() { let larger = Rectangle { height: 7, width: 8, }; let smaller = Rectangle { height: 1, width: 5, }; assert(!smaller.can_hold(@larger), 'rectangle cannot hold'); } } }
Note que hemos agregado dos nuevas líneas dentro del módulo de pruebas: use super::Rectangle;
y use super::RectangleTrait;
. El módulo de pruebas es un módulo regular que sigue las reglas normales de visibilidad. Debido a que el módulo de pruebas es un módulo interno, necesitamos traer el código bajo prueba en el módulo externo al ámbito del módulo interno.
Hemos nombrado nuestro test larger_can_hold_smaller
, y hemos creado los dos instancias de Rectangle
que necesitamos. Luego llamamos a la función assert y le pasamos el resultado de llamar a larger.can_hold(@smaller)
. Esta expresión se supone que devuelve true
, por lo que nuestra prueba debería pasar. ¡Descubramoslo!
$ scarb cairo-test
running 1 tests
test adder::lib::tests::larger_can_hold_smaller ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 filtered out;
¡Pasó la prueba! Ahora agreguemos otra prueba, esta vez afirmamos que un rectángulo más pequeño no puede contener un rectángulo más grande:
Filename: src/lib.cairo
#![allow(unused)] fn main() { use debug::PrintTrait; #[derive(Copy, Drop)] struct Rectangle { width: u64, height: u64, } trait RectangleTrait { fn area(self: @Rectangle) -> u64; fn can_hold(self: @Rectangle, other: @Rectangle) -> bool; } impl RectangleImpl of RectangleTrait { fn area(self: @Rectangle) -> u64 { *self.width * *self.height } fn can_hold(self: @Rectangle, other: @Rectangle) -> bool { *self.width > *other.width && *self.height > *other.height } } #[cfg(test)] mod tests { use super::Rectangle; use super::RectangleTrait; #[test] fn larger_can_hold_smaller() { let larger = Rectangle { height: 7, width: 8, }; let smaller = Rectangle { height: 1, width: 5, }; assert(larger.can_hold(@smaller), 'rectangle cannot hold'); } #[test] fn smaller_cannot_hold_larger() { let larger = Rectangle { height: 7, width: 8, }; let smaller = Rectangle { height: 1, width: 5, }; assert(!smaller.can_hold(@larger), 'rectangle cannot hold'); } } }
Como el resultado correcto de la función can_hold
en este caso es false
, debemos negar ese resultado antes de pasarlo a la función assert
. Como resultado, nuestro test pasará si can_hold
devuelve false:
$ scarb cairo-test
running 2 tests
test adder::lib::tests::smaller_cannot_hold_larger ... ok
test adder::lib::tests::larger_can_hold_smaller ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 filtered out;
¡Dos pruebas que pasan! Ahora veamos qué sucede con los resultados de nuestras pruebas cuando introducimos un error en nuestro código. Cambiaremos la implementación del método can_hold
reemplazando el signo mayor que (>
) por un signo menor que (<
) cuando compara los anchos:
#![allow(unused)] fn main() { impl RectangleImpl of RectangleTrait { fn area(self: @Rectangle) -> u64 { *self.width * *self.height } fn can_hold(self: @Rectangle, other: @Rectangle) -> bool { *self.width < *other.width && *self.height > *other.height } } }
Ejecutando los test ahora produce lo siguiente:
$ scarb cairo-test
running 2 tests
test adder::lib::tests::smaller_cannot_hold_larger ... ok
test adder::lib::tests::larger_can_hold_smaller ... fail
failures:
adder::lib::tests::larger_can_hold_smaller - panicked with [167190012635530104759003347567405866263038433127524 ('rectangle cannot hold'), ].
Error: test result: FAILED. 1 passed; 1 failed; 0 ignored
Nuestras pruebas han detectado el error. Como mayor.anchura
es 8
y menor.anchura
es 5
, la comparación de las anchuras en can_hold
ahora devuelve false
: 8
no es menor que 5
.
Checking for panics with should_panic
In addition to checking return values, it’s important to check that our code handles error conditions as we expect. For example, consider the Guess type in Listing 9-8. Other code that uses Guess
depends on the guarantee that Guess
instances will contain only values between 1
and 100
. We can write a test that ensures that attempting to create a Guess
instance with a value outside that range panics.
Lo hacemos agregando el atributo should_panic
a nuestra función de prueba. La prueba pasa si el código dentro de la función entra en pánico; la prueba falla si el código dentro de la función no entra en pánico.
Listing 9-8 shows a test that checks that the error conditions of GuessTrait::new
happen when we expect them to.
Filename: src/lib.cairo
#![allow(unused)] fn main() { #[derive(Copy, Drop)] struct Guess { value: u64, } trait GuessTrait { fn new(value: u64) -> Guess; } impl GuessImpl of GuessTrait { fn new(value: u64) -> Guess { if value < 1 || value > 100 { let mut data = ArrayTrait::new(); data.append('Guess must be >= 1 and <= 100'); panic(data); } Guess { value } } } #[cfg(test)] mod tests { use super::Guess; use super::GuessTrait; #[test] #[should_panic] fn greater_than_100() { GuessTrait::new(200); } } }
Colocamos el atributo #[should_panic]
después del atributo #[test]
y antes de la función de prueba a la que se aplica. Veamos el resultado cuando esta prueba pasa:
$ scarb cairo-test
running 1 tests
test adder::lib::tests::greater_than_100 ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 filtered out;
¡Tiene buena pinta! Ahora vamos a introducir un error en nuestro código eliminando la condición de que la nueva función entre en pánico si el valor es mayor que 100
:
#![allow(unused)] fn main() { #[derive(Copy, Drop)] struct Guess { value: u64, } trait GuessTrait { fn new(value: u64) -> Guess; } impl GuessImpl of GuessTrait { fn new(value: u64) -> Guess { if value < 1 { let mut data = ArrayTrait::new(); data.append('Guess must be >= 1 and <= 100'); panic(data); } Guess { value, } } } }
When we run the test in Listing 9-8, it will fail:
$ scarb cairo-test
running 1 tests
test adder::lib::tests::greater_than_100 ... fail
failures:
adder::lib::tests::greater_than_100 - expected panic but finished successfully.
Error: test result: FAILED. 0 passed; 1 failed; 0 ignored
En este caso, no obtenemos un mensaje muy útil, pero cuando miramos la función de prueba, vemos que está anotada con #[should_panic]
. La falla que obtuvimos significa que el código en la función de prueba no causó un pánico.
Tests that use should_panic
can be imprecise. A should_panic
test would pass even if the test panics for a different reason from the one we were expecting. To make should_panic
tests more precise, we can add an optional expected parameter to the should_panic
attribute. The test harness will make sure that the failure message contains the provided text. For example, consider the modified code for Guess
in Listing 9-9 where the new function panics with different messages depending on whether the value is too small or too large.
Filename: src/lib.cairo
#![allow(unused)] fn main() { #[derive(Copy, Drop)] struct Guess { value: u64, } trait GuessTrait { fn new(value: u64) -> Guess; } impl GuessImpl of GuessTrait { fn new(value: u64) -> Guess { if value < 1 { panic_with_felt252('Guess must be >= 1'); } else if value > 100 { panic_with_felt252('Guess must be <= 100'); } Guess { value, } } } #[cfg(test)] mod tests { use super::Guess; use super::GuessTrait; #[test] #[should_panic(expected: ('Guess must be <= 100',))] fn greater_than_100() { GuessTrait::new(200); } } }
Esta prueba pasará porque el valor que ponemos en el parámetro esperado del atributo should_panic
es la matriz de cadenas del mensaje con el que la función Guess::new
genera la excepción. Necesitamos especificar el mensaje completo de la excepción que esperamos.
Para ver qué ocurre cuando falla una prueba should_panic
con un mensaje esperado, introduzcamos de nuevo un error en nuestro código intercambiando los cuerpos de los bloques if value < 1
y else if value > 100
:
#![allow(unused)] fn main() { impl GuessImpl of GuessTrait { fn new(value: u64) -> Guess { if value < 1 { let mut data = ArrayTrait::new(); data.append('Guess must be >= 1'); panic(data); } else if value > 100 { let mut data = ArrayTrait::new(); data.append('Guess must be <= 100'); panic(data); } Guess { value, } } } #[cfg(test)] mod tests { use super::Guess; use super::GuessTrait; #[test] #[should_panic(expected: ('Guess must be <= 100',))] fn greater_than_100() { GuessTrait::new(200); } } }
Esta vez, cuando ejecutamos la prueba should_panic
, fallará:
$ scarb cairo-test
running 1 tests
test adder::lib::tests::greater_than_100 ... fail
failures:
adder::lib::tests::greater_than_100 - panicked with [6224920189561486601619856539731839409791025 ('Guess must be >= 1'), ].
Error: test result: FAILED. 0 passed; 1 failed; 0 ignored
El mensaje de fallo indica que este test realmente causó un pánico como esperábamos, pero el mensaje de pánico no incluyó la cadena esperada. El mensaje de pánico que obtuvimos en este caso fue Guess must be >= 1
. ¡Ahora podemos comenzar a descubrir dónde está nuestro error!
Running Single Tests
Sometimes, running a full test suite can take a long time. If you’re working on code in a particular area, you might want to run only the tests pertaining to that code. You can choose which tests to run by passing scarb cairo-test
an option -f
(for "filter"), followed by the name of the test you want to run as an argument.
To demonstrate how to run a single test, we’ll first create two tests functions, as shown in Listing 9-10, and choose which ones to run.
Filename: src/lib.cairo
#![allow(unused)] fn main() { #[cfg(test)] mod tests { #[test] fn add_two_and_two() { let result = 2 + 2; assert(result == 4, 'result is not 4'); } #[test] fn add_three_and_two() { let result = 3 + 2; assert(result == 5, 'result is not 5'); } } }
Podemos pasar el nombre de cualquier función de prueba a cairo-test
para ejecutar solo esa prueba usando la bandera -f
:
$ scarb cairo-test -f add_two_and_two
running 1 tests
test adder::lib::tests::add_two_and_two ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 1 filtered out;
Solo se ejecutó la prueba con el nombre add_two_and_two
; la otra prueba no coincidía con ese nombre. La salida de la prueba nos indica que tuvimos una prueba más que no se ejecutó al mostrar "1 filtrado" al final.
También podemos especificar parte del nombre de una prueba y se ejecutarán todas las pruebas cuyo nombre contenga ese valor.
Ignoring Some Tests Unless Specifically Requested
Sometimes a few specific tests can be very time-consuming to execute, so you might want to exclude them during most runs of scarb cairo-test
. Rather than listing as arguments all tests you do want to run, you can instead annotate the time-consuming tests using the ignore
attribute to exclude them, as shown here:
Filename: src/lib.cairo
#![allow(unused)] fn main() { #[cfg(test)] mod tests { #[test] fn it_works() { let result = 2 + 2; assert(result == 4, 'result is not 4'); } #[test] #[ignore] fn expensive_test() { // code that takes an hour to run } } }
Después de #[test]
agregamos la línea #[ignore]
al test que queremos excluir. Ahora, cuando ejecutamos nuestros tests, it_works
se ejecuta pero expensive_test
no lo hace:
$ scarb cairo-test
running 2 tests
test adder::lib::tests::expensive_test ... ignored
test adder::lib::tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 1 ignored; 0 filtered out;
La función expensive_test
está listada como ignorada.
When you’re at a point where it makes sense to check the results of the ignored tests and you have time to wait for the results, you can run scarb cairo-test --include-ignored
to run all tests whether they’re ignored or not.
Testing recursive functions or loops
When testing recursive functions or loops, you must provide the test with a maximum amount of gas that it can consume. This prevents running infinite loops or consuming too much gas, and can help you benchmark the efficiency of your implementations. To do so, you must add the #[available_gas(<Number>)]
attribute on the test function. The following example shows how to use it:
Filename: src/lib.cairo
#![allow(unused)] fn main() { fn sum_n(n: usize) -> usize { let mut i = 0; let mut sum = 0; loop { if i == n { sum += i; break; }; sum += i; i += 1; }; sum } #[cfg(test)] mod test { use super::sum_n; #[test] #[available_gas(2000000)] fn test_sum_n() { let result = sum_n(10); assert(result == 55, 'result is not 55'); } } }
Benchmarking the gas usage of a specific operation
When you want to benchmark the gas usage of a specific operation, you can use the following pattern in your test function.
#![allow(unused)] fn main() { let initial = testing::get_available_gas(); gas::withdraw_gas().unwrap(); /// code we want to bench. (testing::get_available_gas() - x).print(); }
The following example shows how to use it to test the gas function of the sum_n
function above.
#![allow(unused)] fn main() { fn sum_n(n: usize) -> usize { let mut i = 0; let mut sum = 0; loop { if i == n { sum += i; break; }; sum += i; i += 1; }; sum } #[cfg(test)] mod test { use super::sum_n; use debug::PrintTrait; #[test] #[available_gas(2000000)] fn benchmark_sum_n_gas() { let initial = testing::get_available_gas(); gas::withdraw_gas().unwrap(); /// code we want to bench. let result = sum_n(10); (initial - testing::get_available_gas()).print(); } } }
The value printed when running scarb cairo-test
is the amount of gas that was consumed by the operation benchmarked.
$ scarb cairo-test
testing no_listing_09_benchmark_gas ...
running 1 tests
[DEBUG] (raw: 0x179f8
test no_listing_09_benchmark_gas::benchmark_sum_n_gas ... ok (gas usage est.: 98030)
test result: ok. 1 passed; 0 failed; 0 ignored; 0 filtered out;
Here, the gas usage of the sum_n
function is 96760 (decimal representation of the hex number). The total amount consumed by the test is slightly higher at 98030, due to some extra steps required to run the entire test function.