Pruebas de Simulación (Rust)

14 min read
To Share and +4 nLEARNs

Antes de qué empecemos

Este artículo no muestra las posibilidades de lo qué un marco de prueba de simulación puede hacer; Este artículo analiza cómo crear un marco de prueba de simulación MVP (Producto Mínimo Viable) para su proyecto.  Para cambios más avanzados, haga su propia investigación (DYOR) y consulte algunos enlaces incluidos en este artículo.

NOTA: Las pruebas de simulación quedarán obsoletas cuando aparezca Sandbox.  Consulte workspaces-rs y workspaces-js.

Breve Introducción

La diferencia entre las pruebas unitarias y las pruebas de simulación es qué las primeras están restringidas a las pruebas dentro de la biblioteca; mientras que las últimas simulan una llamada de afuera hacia adentro. Las pruebas unitarias en su mayoría prueban los marcos internos para la lógica correcta; mientras que las pruebas de simulación intentan simular las acciones del usuario y verificar si las cosas salen como espera el desarrollador. Trata cada función como una simulación.

Si estás pensando qué con una prueba de afirmación es suficiente, por favor no lo hagas. Incluso para las pruebas unitarias, yo no estoy de acuerdo en qué con una afirmación por marco de función de prueba basta; lo que significa que las pruebas de simulación requieren más de una afirmación dentro de una sola función/simulación.

Como de costumbre, el ejemplo más simple es la aplicación de Greeting (saludo); así que usaremos eso y escribiremos una prueba de simulación para ello.

Prueba de simulación de Greeting (saludo)

No necesitas escribir tu propia aplicación de saludo: simplemente ejecuta este comando:

npx create-near-app --contract=rust <your_project_name>

¡Listo! Ahora tienes un ejemplo de aplicación de saludo con una interfaz simple. Sigue las instrucciones para implementar el contrato y prueba con él hasta que estés familiarizado antes de continuar. Revisa las últimas líneas de la salida.

We suggest that you begin by typing: 
cd <your_project_name>
yarn dev

Copia el comando y prueba cómo funciona la aplicación hasta que estés familiarizado con ella. Después de la prueba, detén la implementación temporalmente: es bastante molesto volver a desplegar cada vez qué se realiza un pequeño cambio si no necesitamos la interfaz, desde mi punto de vista.

Crear carpeta de prueba de simulación

Sin considerar a NEAR, las pruebas de simulación también se denominan pruebas de integración en Rust (puro). Si deseas obtener más información, consulta The Rust Book (El libro de Rust) o Rust By Example (Ejemplos de Rust) para ver cómo realizar pruebas de integración.

En resumen, necesitas una carpeta completamente fuera de src llamada tests. Así que haremos eso ahora. Suponiendo que ya hiciste cd en el directorio de tu proyecto, ejecuta esto:

cd contract
mkdir tests 
mkdir tests/sim
touch tests/sim/main.rs

Navega y abre: <tu_nombre_de_proyecto>/contract/tests/sim/main.rs y ábrelo en el editor de tu elección.

Ahora, puedes volcar todo en un archivo o dividirlo. Generalmente, tienes un archivo utils.rs para hacer funciones que no son de prueba que se reutilizan. Puedes tener otro archivo .rs para múltiples pruebas cortas. Si tienes pruebas más largas, como las pruebas de simulación del contrato lockup (De bloqueo), se sugiere un archivo por prueba; y nombrar tu archivo en consecuencia. No querrás que tus lectores vean un archivo con más de 1000 líneas de código.

Ahora, aunque el contrato Greeting (Saludo) no tendría largas pruebas de simulación, yo te mostraré lo qué quiero decir con una prueba por archivo separándolos deliberadamente. Sin embargo, no tienes que hacerlo.

Incluye las Dependencias

Necesitamos una biblioteca llamada near-sdk-sim.  Este ejemplo usa near-sdk v3.1.0, por lo que usaremos el near-sdk-sim v3.2.0 correspondiente (aunque no sé por qué no coinciden).  Sin embargo, debes tener en cuenta que si la versión no coincide, las pruebas de simulación no se ejecutarán, porque dice Import near_sdk::some_fn is not equal to near_sdk::some_fn, lo cual es confuso (Porque en realidad near_sdk::some_fn es de hecho diferente de near_sdk::some_fn.

A mi realmente me gusta escribir con near-sdk v4.0.0-pre.4, pero eso requiere algunos cambios en el contrato para que funcione. Los cambios no son complicados, principalmente sobre AccountId que ya no es String y algunas otras cosas más pequeñas; pero ese no es el punto de este artículo, así que nos quedaremos con 3.1.0 y tal vez actualicemos este artículo en el futuro. (Además, hay un error con la simulación v4 del que hablaremos más adelante).

Vayamos a Cargo.toml: ahora debería verse así: (ver nueva sección dev-dependencies). ¡Es importante asegurarse de que rlib también esté presente!

[package]
name = "greeter"
version = "0.1.0"
authors = ["Near Inc <[email protected]>"]
edition = "2018"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
near-sdk = "3.1.0"

[dev-dependencies]
near-sdk-sim = "3.2.0"

[profile.release]
codegen-units = 1
# Tell `rustc` to optimize for small code size.
opt-level = "z"
lto = true
debug = false
panic = "abort"
# Opt into extra safety checks on arithmetic operations https://stackoverflow.com/a/64136471/249801
overflow-checks = true

[workspace]
members = []

Anteriormente, escribí una guía sobre cómo reducir el tamaño del contrato; incluida la eliminación de «rlib». Sin embargo, para usar pruebas de simulación, se requiere «rlib», por lo que es una compensación entre el tamaño del contrato y la realización de pruebas de simulación.

Si has detenido yarn dev, ahora compila el contrato una vez para descargar la biblioteca. En contract, ejecuta:

cargo build

¡Asegúrate de tener mucho espacio en disco (se recomiendan 20 GB de espacio libre) ya qué ocupa bastante espacio!

Para una experiencia fluida durante la compilación por primera vez, intenta compilar en una máquina que tenga 16 vCPU y 8 GB de RAM. Especialmente librocksdb-sys tomará mucho tiempo para compilar. No está claro si la compilación es transferible (ciertamente no entre sistemas operativos, pero no estoy seguro dentro del mismo sistema operativo). Dado que alquilé una máquina virtual en Azure, fácilmente podría cambiar el tamaño temporalmente y volver a cambiarlo a un tamaño más pequeño (y más barato) después de la compilación, por lo tanto, no hay conflicto.

Volvamos a escribir las pruebas de simulación en main.rs.

Prepara el archivo wasm

Ten en cuenta: cada vez que realizas cambios en tu contrato, debes reconstruir el archivo wasm. Hicimos un script aquí para construir tu archivo wasm y moverlo a la carpeta res del directorio superior.

Así que ejecuta esto: (desde el directorio contract)

mkdir res 
touch build.sh

Luego, incluye el contenido a continuación dentro de contract/build.sh para que puedas ejecutar bash build.sh (dentro de la carpeta contract) en lugar de escribir el comando cada vez.

#!/bin/bash 
set -e 

export WASM=greeter.wasm 

RUSTFLAGS='-C link-arg=-s' cargo build --target wasm32-unknown-unknown --release 
cp target/wasm32-unknown-unknown/release/$WASM res/

Imports

Esto es fácil: tratamos a la biblioteca como una biblioteca. Recuerde, los que están pub(crate) originalmente en la biblioteca no se pueden usar en pruebas de simulación (ya que esto está afuera). Es como si lo compilaras y alguien estuviera usando tu código. Asegúrese de importar las funciones que necesita.

Si revisamos src.lib.rs, vemos que (solo) hay una estructura de Welcome (Bienvenida). Cuando lo importas, agregas la palabra clave Contract detrás. Por ejemplo:

use greeter::{
    WelcomeContract 
};

Para el contrato de bloqueo, su estructura principal se llama LockupContract, por lo que cuando lo importan, es LockupContractContract. Yo no sé por qué lo hicieron así, tal vez para evitar algún conflicto; solo agrégalo.

Incluir archivos de contrato

Lo siguiente antes de probar que funciona es incluir el archivo wasm. Esto es un requisito. Además, si tienes un utils.rs, NO debes colocarlo allí; de lo contrario, debes pensar mucho en cómo hacerlo reconocible desde otros archivos. Para no pensarlo mucho, lo ponemos en main.rs:

// Directories are relative to top-level Cargo directory. 
near_sdk_sim::lazy_static_include_bytes! {
      GREETER_WASM_BYTES => "res/greeter.wasm" 
      // other wasm bytes.  
}

Lo que significa «directorio de Cargo de nivel superior» es el directorio de contract. Ciertamente, puedes descubrir cosas fuera de él con ../../algun_archivo si alguna vez lo necesitas. Por ejemplo, si no usas res sino yarn devout/main.wasm está fuera del directorio de contratos. Para importar eso, hacemos:

near_sdk_sim::lazy_static_include_bytes! {
     GREETER_WASM_BYTES => "../out/main.wasm" 
}

Dado que se trata de una macro, asegúrate de no poner accidentalmente una «coma» («,») después del último elemento; de lo contrario, puede recibir mensajes de error extraños y Rust se puede negar a compilar.

Desafortunadamente, no podemos probar esta función mientras, hasta que creemos la función auxiliar (un MVP completo) y una prueba MVP.

Función de inicialización

Para no repetir la función de configuración, las incluimos dentro de basic_setup(). Verifica basic_setup() del contrato lockup para ver otro ejemplo (que incluye la implementación de otros contratos además de su contrato de prueba principal). Aquí, también haremos lo mismo, pero no tenemos otro contrato para configurar, así que lo omitiremos y sólo incluiremos las funciones necesarias en basic_setup().

Crea un archivo utils.rs:

touch tests/sim/utils.rs

Dentro de utils.rs, insertamos el contenido:

use crate::*; 

/// 300 TGas 
pub const MAX_GAS: u64 = 300_000_000_000_000; 

/// 1 NEAR (just a random number) 
pub const MIN_BALANCE_FOR_STORAGE: u128 = 1_000_000_000_000_000_000_000_000; 

pub const GREETER_ACCOUNT_ID: &str = "greeter"; 

pub(crate) fn basic_setup() -> (UserAccount, UserAccount) { 
  let mut genesis_config = GenesisConfig::default(); 
  genesis_config.block_prod_time = 0; 
  let root = init_simulator(Some(genesis_config)); 

  let alice = root.create_user( 
    "alice".to_string(), to_yocto("200") 
  ); 

  (root, alice) 
}

Hay algunas cosas importantes a tener en cuenta aquí. El primero es este bloque de código:

let mut genesis_config = GenesisConfig::default(); 
genesis_config.block_prod_time = 0; 
let root = init_simulator(Some(genesis_config));

El Génesis es el primer bloque de la blockchain (cadena de bloques). Sin embargo, el tiempo de génesis y génesis no es realmente lo mismo. Por ejemplo, root representa la propia cadena de bloques. No es la cuenta de nivel superior near o testnet: es la cadena de bloques. Sin embargo, si revisamos el explorador de testnet, vemos que se creó durante el Génesis. Entonces, root viene primero, luego se empaqueta con algunas de las cuentas durante  Genesis time. Creamos un simulador del Génesis llamado root.

Aquí, lo que queremos decir con Génesis no es el tiempo de génesis, sino la «mismísima raíz». Es la «cuenta principal» de todas las cuentas de nivel superior.

Por lo general, no necesitamos modificar GenesisConfig; y si lo necesitas, este es un ejemplo.

Si alguna vez necesitas realizar cambios en el génesis, consulta los documentos para conocer los valores que puedes cambiar. Luego, puedes modificar la línea 2 en el bloque de código anterior asignando un valor a cada campo. Finalmente, debes inicializar el simulador con init_simulator.

Si no necesitas modificaciones, puedes inicializar un simulador sin configuración (que usará el valor predeterminado) de esta manera:

let root = init_simulator(None);

A continuación, hacemos que el root cree una cuenta llamada «alicia» para nosotros. El primer argumento es el nombre de la cuenta, el segundo es cuántos NEAR dar a la cuenta.

Debido a que root es el Génesis, sólo puede crear cuentas de nivel superior como near, testnet, alice. No puede crear subcuentas como alice.near: solo la cuenta principal near puede crear alice.near, no Genesis.

Una cosa que no tenemos aquí es la implementación con root. Para nuestro contrato, usamos el macro deploy! que haremos en la función de prueba en lugar de aquí. Pero si tienes otro archivo wasm, como el contrato de bloqueo, ¡No puedes usar el despliegue! macro, entonces así es como lo hicieron.

Por ejemplo, en el contrato de la lista blanca; se implementa en la raíz de esta manera:

let _whitelist = root.deploy_and_init( 
  &WHITELIST_WASM_BYTES, 
  STAKING_POOL_WHITELIST_ACCOUNT_ID.to_string(), 
  "new", &json!({
    "foundation_account_id": foundation.valid_account_id(), 
  }).to_string().into_bytes(), 
  to_yocto("30"), 
  MAX_GAS, 
);

Debido a que el requisito de qué una función de inicio se llame una vez durante la implementación es tan común, existe una función deploy_and_init. Si el contrato no tiene una función de implementación (asumiendo que la lista blanca no tiene una aquí), podemos hacer esto.

let _whitelist = root.deploy(
 &WHITELIST_WASM_BYTES,
 STAKING_POOL_WHITELIST_ACCOUNT_ID.parse().unwrap(),
 to_yocto("30") 
);

y en realidad no existe la función deploy_and_init, por lo qué la llamamos manualmente. Para hacer esto, necesitamos una cuenta para llamarlo, que tenga la capacidad de hacerlo. para lockup, dicha cuenta es la fundación.

let foundation = root.create_user("foundation".to_string(), to_yocto("10000"));
 foundation.call(
   STAKING_POOL_WHITELIST_ACCOUNT_ID.to_string(),
   "new", 
   &json!({
      "foundation_account_id": foundation.valid_account_id()
   }).to_string().into_bytes(),
   MAX_GAS,
   NO_DEPOSIT, 
).assert_success();

Notamos que la realidad y la simulación tienen algunas diferencias.

Por último, no olvides importarlo a main.rs e importar las funciones requeridas:

use near_sdk::Balance;
 use near_sdk_sim::runtime::GenesisConfig;
 use near_sdk_sim::{init_simulator, to_yocto, UserAccount};
 pub(crate) mod utils;
 use utils::*;

Construyendo la primera función de prueba

Estamos listos para construir la primera función de prueba. Primero, importa las funciones requeridas en main.rs:

use near_sdk_sim::{deploy};

Esta es la macro de implementación.

Cree un archivo para la prueba:

touch tests/sim/test_set_and_get_greeting.rs

AComo yo provengo de trabajar con Python, a mi me gusta nombrar funciones que comienzan con test. No tienes que hacerlo. Aquí, yo adopté el llamar el nombre del archivo comenzando con test_; mientras que la función a probar qué está dentro sin test_. Ejemplo, tendremos una función set_and_get_greeting() dentro de test_set_and_get_greeting.rs (archivo).

Importa el archivo en main.rs antes de qué lo olvidemos:

mod test_set_and_get_greeting;

No necesitamos pub(crate) como lo hace utils, ya que no necesitas compartir nada con otros archivos.

Lo primero que necesitamos en la función set_and_get_greeting es implementar el contrato.

 let greeter_amount = to_yocto("1000");
 let (root, alice) = basic_setup();

 let greeter = deploy!(
   contract: WelcomeContract, 
   contract_id: GREETER_ACCOUNT_ID.to_string(),
   bytes: &GREETER_WASM_BYTES,
   signer_account: root,
   deposit: MIN_BALANCE_FOR_STORAGE + greeter_amount 
);

Si tenemos un método #[init] personalizado, incluimos esto después de los argumentos de depósito:

gas: MAX_GAS,
 init_method: <method_name>(method_args)

Sin embargo, si no los tenemos, eliminamos dichos argumentos. Para ver un montón de rasgos que coinciden con la macro, consulta los documentos. Debes hacer coincidir al menos uno de ellos; de lo contrario, Rust se negará a compilar.

(Pregunta: ¿Importa el orden? ¿O solo el grupo de kwargs (argumentos) debe coincidir con uno de los traits?)

Ten en cuenta qué, a diferencia de la realidad, la implementación se realiza nuevamente por root (puedes verlo por signer_account). En realidad, lo hace alguna cuenta responsable de ello.

A continuación, configuremos un saludo y obtengamos el saludo y afirmemos que son como se esperaba.

Parece que a la gente le gusta asignar una res de llamada variable que se reutiliza una y otra vez. No es la forma más clara; pero seguramente podemos hacer eso para no llenarnos la cabeza y pensar en nombres de variables. res simplemente significa «resultados» devueltos por una llamada de función en particular.

Es una buena práctica asignar su res con un tipo (independientemente de si Rust puede inferir el tipo o no), para que sepa qué tipo se devuelve.

Recuerda que tenemos view_method y change_method en el contrato inteligente. Por el contrato desplegado con deploy! (que es el contrato inteligente que puede importar y el que está probando principalmente), podemos usar view_method_call y function_call respectivamente. Hablaremos en un momento como llamar un archivo externo wasm, si lo tuvieramos.

Nuestro set_greeting es un change_method, por lo que usaremos una function_call. Una function_call toma un PendingContractTx, Gas y Deposit.

PendingContractTx es solo la función, y otros argumentos son fáciles de interpretar. Veamos nuestro set_greeting:

let greeting: String = "Hello, NEAR!".to_owned();
alice.function_call(
  greeter.contract.set_greeting(greeting),
  MAX_GAS, 0 
).assert_success();

Asegúrate de pasar los argumentos respectivos en la función.  También llamamos a assert_success() al final para asegurarnos de que se cumpla la Promesa.  Lo anterior está imitando el near-cli:

near call $GREETER_CONTRACT set_greeting '{
  "message": "Hello, NEAR!"
 }' --gas=$MAX_GAS --amount=0 --accountId=$ALICE_ACCOUNT_ID

Entonces, podemos usar la función view. Si revisas, la función get_greeting toma un ID de cuenta de tipo String y devuelve un String.

let res: String = alice.view_method_call( 
  greeter.contract.get_greeting(alice.account_id().to_string())
 ).unwrap_json();

Yo creo que no necesitas .account_id().to_string(), con solo .account_id() es suficiente.  Aquí, solo lo hacemos explícito porque admite un String.  Si tomara AccountId, podríamos simplemente llamar a .account_id() sin ninguna confusión.  (Especialmente cuando AccountId ya no es igual a String que comienza con near-sdk-rs v4).

Como el resultado devuelto es un JSON, lo desenvolvemos con unwrap_json().

 Entonces, podríamos hacer afirmaciones sobre el resultado.

assert_eq!(res, greeting);

El saludo (greeting) qué recibiremos es una variable que asignamos anteriormente, que es “¡Hola, NEAR!”.

Ejecutando la prueba de integración

Si solo deseas ejecutar la prueba de integración, ejecuta:

cargo test –tests sim (Porque está en la carpeta: tests/sim ). Si deseas ejecutar todas las pruebas, incluidas las pruebas unitarias, ejecuta cargo test.

Ten en cuenta que, por alguna razón, demora como 30 segundos o más (independientemente de cuántos núcleos de CPU tengas);  hay que esperar antes de que comience la prueba.

Un recordatorio nuevamente: si realizas cambios en el contrato, debes reconstruirlo;  de lo contrario, te preguntarás por qué no se ejecuta y creeras que se ejecutará, así que no lo olvides.

Código Completo

Puedes encontrar el código completo aquí:  https://github.com/Wabinab/NEAR_greeter_integration_tests

Conclusión

Ahora, puedes repetir otras pruebas (si tienes alguna otra) creando un nuevo archivo, vinculándolo usando mod a main.rs, escribe las pruebas dentro. Es un ejercicio divertido: cuanto más escribes, más entiendes.

Una nota sobre la actualización a v4.0.0-pre.4

La función de contrato de lista blanca deploy_and_init necesita cambios en esto:

AccountId ya no es String

  • Así que reemplaza todo «alice».to_string() con «alice».parse().unwrap(). Si el reemplazo está dentro de una función en la que no puede llamar a parse, debes crear una variable. (Esto es especialmente cierto en la macro deploy!, que no tiene inferencia de tipo).
    let alice_account: AccountId = "alice".parse().unwrap(); 
    
    // pass it to the function. 
    • valid_account_id está en desuso. Usa account_id() en su lugar. Esto ocurre en json!.
    • Cualquier número pasado en json! requiere especificación. Ejemplo: v3.2.0 permite 10, pero v4.0.0-pre.4 no lo permite: tu debes especificar 10u64 o algún otro tipo, dependiendo de lo qué necesites..
    • Siéntete libre de consultar la prueba de simulación del contrato de bloqueo en mi libro (actualmente en pre-Alfa al momento de escribir) para quizás más consejos y trucos que no se enumeran aquí. (Esto es MVP, de todos modos).

Referencias

21
Ir arriba