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');
    }
}

Listing 9-1: A test module and function

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;

Listing 9-2: The output from running a test

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');
    }

}

Listing 9-3: Adding a second test that will 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

Listing 9-4: Test results when one test passes and one test fails

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
    }
}
}

Listing 9-5: Using the Rectangle struct and its can_hold method from Chapter 5

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');
    }
}


}

Listing 9-6: A test for can_hold that checks whether a larger rectangle can indeed hold a smaller rectangle

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);
    }
}
}

Listing 9-8: Testing that a condition will cause a panic

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);
    }
}


}

Listing 9-9: Testing for a panic with a panic message containing the error message string

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');
    }
}
}

Listing 9-10: Two tests with two different names

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.

Last change: 2023-09-22, commit: 724b90c