Local Testing

Learn how to locally test Zephyr programs.

Mercury's Zephyr comes equipped with all the tooling you need to test and debug your programs locally. This includes also being able to craft manually custom situations and edge cases asserting the correct funcitoning of all of the code blocks in your program.

In this section of the documentation you'll learn everything you need to make sure your program as a whole works correctly.

Understanding Zephyr Tests

Due to the way Zephyr works and it's target utility, coming up with a great testing experience has not been an easy task. We believe that the current state (which will keep improving) is already quite a great developer experience, that said there are a few concepts that need to be comprehended before starting to test your programs:

  • Database connector. The testutils provide a simple, non production-ready zephyr postgres database connector. This means that in order to run tests that write/read from tables you'll need a working postgresql setup on your machine. In fact, during the test setup you'll have to provide a valid postgres connection string. If you're testing on a non user-facing machine such as your personal computer it can be a common practice to connect to a default postgres string such as postgres://postgres:postgres@localhost:5432 assuming that the user exists with such password.

  • [Currently] No ledger read functionality. Since the purpose of local testing is to test without needing to setup services like stellar-core, ability to read from the ledger is not currently supported in tests. However, we are working on bringing a ledger adapter on tests too where the developer can set the ledger entries they'll access into the testing environment.

  • Building ledger transitions. As one could imagine, the hardest part about providing a consistent (assuming a correctly setup database) testing environment in Zephyr is the data the VM is fed. In Mercury servers, stellar core communicates directly with the ZephyrVMs spawned at each ledegr close providing them with the metadata pertinent to the ledger close (soroban events, transactions, changed entries, etc). In a local testing environment this is not possible, and even if you manually connected a local stellar core instance to Zephyr you'd have non-consistent testing, and difficulty in feeding the VM with the data you want to test (you'd have to perform on chain actions such as contract calls), and testing edge cases is also complex. To counter this and offer a smooth testing experience, we've coupled Zephyr's testutils with a new crate we've just created and are going to further improve which allows to craft custom ledger transition/meta objects.

Cargo.toml

Before starting, make sure that your Cargo.toml is correctly configured to add the testutils feature to the zephyr-sdk in the dev depencencies, plus add the ledger-meta-factory, tokyo and xdr libraries which will be additional crates needed for testing:

[package]
name = "test-program"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
zephyr-sdk = { path = "../../zephyr-sdk", features = [] }

[dev-dependencies]
zephyr-sdk = { path = "../../zephyr-sdk", features = ["testutils"] }
tokio = {version = "1.0", features = ["full"]}
ledger-meta-factory = { path = "../../../zephyr/ledger-meta-factory", features = [] }

[dev-dependencies.stellar-xdr]
version = "=20.1.0"
git = "https://github.com/stellar/rs-stellar-xdr"
rev = "44b7e2d4cdf27a3611663e82828de56c5274cba0"
features=["next", "curr", "serde", "base64"]

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

[profile.release]
opt-level = "z"
overflow-checks = true
debug = 0
strip = "symbols"
debug-assertions = false
panic = "abort"
codegen-units = 1
lto = true

Note that if you add the testutils flag on the dependencies instead of dev dependencies your program will not compile.

The TestHost Object

The TestHost object exported by the zephyr-sdk's testutils will be the entry point for your tests management. It will provide you handles to:

  • set up your database and load the ephemeral tables that your program will use.

  • manage and invoke Zephyr programs enabling also to set the custom transitions if needed.

Below is how a typical Zephyr test function would look like:

#[cfg(test)]
mod test {
    use ledger_meta_factory::{Transition, TransitionPretty};
    use stellar_xdr::next::{Hash, Int128Parts, ScSymbol, ScVal};
    use zephyr_sdk::testutils::TestHost;

    fn build_transition() -> Transition {
        let mut transition = TransitionPretty::new();
        transition.inner.set_sequence(2000);
        transition
            .contract_event(
                "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA",
                vec![
                    ScVal::Symbol(ScSymbol("transfer".try_into().unwrap())),
                    ScVal::Address(stellar_xdr::next::ScAddress::Contract(Hash([8; 32]))),
                    ScVal::Address(stellar_xdr::next::ScAddress::Contract(Hash([1; 32])))
                ],
                ScVal::I128(Int128Parts {
                    hi: 0,
                    lo: 100000000,
                }),
            )
            .unwrap();

            transition
            .contract_event(
                "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA",
                vec![
                    ScVal::Symbol(ScSymbol("other_action".try_into().unwrap())),
                    ScVal::Address(stellar_xdr::next::ScAddress::Contract(Hash([8; 32]))),
                    ScVal::Address(stellar_xdr::next::ScAddress::Contract(Hash([1; 32])))
                ],
                ScVal::I128(Int128Parts {
                    hi: 0,
                    lo: 100000000,
                }),
            )
            .unwrap();

        transition.inner
    }

    #[tokio::test]
    async fn test_storage() {
        let env = TestHost::default();
        // Note: this is a default postgres connection string. If you're on production
        // (or even public-facing dev) make sure not to share this string and use an
        // environment variable.
        let mut db = env.database("postgres://postgres:postgres@localhost:5432");
        let mut program = env.new_program("./target/wasm32-unknown-unknown/release/test_program.wasm");
        let transition = build_transition();
        program.set_transition(transition);

        // Create a new ephemeral table in the local database.
        let created = db
            .load_table(0, "events", vec!["topic1", "remaining", "data"])
            .await;

        // note this is a very strict check since it makes sure that the table
        // didn't previously exist. It's recommended to enable it only when you're
        // sure your program is executing correctly, else you'll have to manually
        // drop the database tables in case the below assertions are failing.
        assert!(created.is_ok());

        // We make sure that there are no pre-existing rows in the table.
        assert_eq!(db.get_rows_number(0, "events").await.unwrap(), 0);

        // We make sure first that there haven't been any issues in the host by asserting
        // that the outer result is ok.
        // Then we assert that there was no error on the guest side (inner result) too.
        let invocation = program.invoke_vm("on_close").await;
        assert!(invocation.is_ok());
        let inner_invocation = invocation.unwrap();
        assert!(inner_invocation.is_ok());

        // A new row has been indexed in the database.
        assert_eq!(db.get_rows_number(0, "events").await.unwrap(), 1);

        // Drop the connection and all the noise created in the local database.
        db.close().await;
    }
}
  1. We create an util function to create a transition that contains certain soroban events which our program relies on.

  2. Setup the database and load the program from the built binary.

  3. Load the transition from point 1 into the program.

  4. Create all the required tables with the respective columns.

  5. Assert that the table is empty.

  6. Invoke the program and assert that both on the host (outer result) and on the guest (inner result) everything ran as expected.

  7. Check again the number of rows in the table and assert that our entry was correctly added.

  8. Close the connection to the database and drop all the ephemeral tables.

If your test panics before db.close().await then the test won't have cleared and dropped your tables. You'll need to manually connect to the database (psql $CONNECTION_STRING and drop the tables DROP TABLE zephyr_$TABLE_NAME).

If you're running multiple tests in the same execution, make sure to run them single-threadedly since the execution of one test could impact the execution of another test if they're connected to the same database: cargo test -- --exact --nocapture --test-threads 1


Resources

Last updated