<!--
.. title: A Pattern for Cross-Language Testing in Rust
.. slug: a-pattern-for-cross-language-testing-in-rust
.. date: 2025-01-08 21:07:25 UTC
.. tags: hypercore, rust
.. category: 
.. link: 
.. description: 
.. type: text
-->

This is the second part of a [series](https://cowlicks.website/categories/hypercore.html) of posts about rewriting [Hypercore](https://github.com/holepunchto/hypercore) in [Rust](https://github.com/datrs/hypercore)

<hr>

There is little guidance on how to test Rust against code from other languages.
After trying with several patterns, I found that the approach described here to be the most elegant.

I've been working on rewriting [`Hyperbee`](https://github.com/cowlicks/hyperbee) in Rust.
Hyperbee is a BTree built on Hypercore, a peer-to-peer append-only log, originally written in JavaScript.
I needed to be able to test my Rust code against the equivalent JavaScript.
I'm also creating a Python Hyperbee library, that wraps the Rust, and so I need to test Python against the equivalent Rust.
This is all part of the [Rust Hypercore](https://github.com/datrs) project which is working to rewrite all of Hypercore in Rust.
Another goal rewrite, is to be able to use Rust's excellent [Foreign Function Interface (FFI)](https://doc.rust-lang.org/nomicon/ffi.html) to create libraries for other languages, starting with Python.
So having a good pattern for cross language testing would prove useful throughout this project.

## Key Benefits

### No extra steps, just `cargo test`

Running your entire test suite through `cargo test` streamlines the testing process.
As long as you have the foreign language runtime and `make` installed, there's no additional setup needed.
This aligns with Rust developers' expectations - they're used to `cargo test` handling everything.
No need to to remember to run a separate command like`pytest` or `npm test`.
By avoiding these extra steps, we reduce friction and potential confusion in the development workflow.

### Collocated Test Code

Writing foreign language test code directly within your Rust tests offers several advantages:

* Test code lives alongside the Rust code it's testing
* Clear documentation of test purpose through proximity
* No need to manage hard coded values
* Easy to extend tests as both implementations evolve
* Reduced overhead from switching between files

This organization makes the tests more maintainable and self-documenting. When you need to understand how a piece of Rust code interacts with Python or JavaScript, everything you need is in one place.

## How it works

Lets start with an [example](https://github.com/cowlicks/hyperbee/tree/master/tests) directory structure taken from Hyperbeee.
```
# crate root
tests/                  # Integration tests directory
  py_tests.rs           # Rust -> Python tests
  js_tests.rs           # Rust -> JavaScript tests
  common/
    Makefile            # Makefile for non-language specific stuff
    vars.mk             # Variables shared across Makefiles
    mod.rs              # Rust module
    ...
    python/             # Python specifics
      Makefile          # Run `make` to set up python dependencies
      mod.rs            # python specific rust code
      ...               # misc python files like requirements.txt
    javascript/         # JavaScript specifics
      Makefile          # Run `make` to set up js dependencies
      mod.rs            # javascript specific rust code
      ...               # misc js files: package.json, node_module, etc
```

Here `*_tests.rs` are Rust tests. Each Rust test file is compiled as a crate, from those crates `common/` is seen as a Rust module.
We will explain the innards of `common` later, but first we will start by looking at a test.

## A Rust Test using Python

Let's dive into `py_test.rs` to see how this works
```rust
mod common;
use common::{
    python::{run_python},
    Result,
};
use hyperbee::Hyperbee;

#[tokio::test]
async fn hello_world() -> Result<()> {
    let storage_dir = tempfile::tempdir()?;
    {
        let hb = Hyperbee::from_storage_dir(&storage_dir).await?;
        hb.put(b"hello", Some(b"world")).await?;
        // drop `hb` so it does not lock `storage_dir`
    }
    let result = run_python(&format!(
        "
async def main():
    hb = await hyperbee_from_storage_dir('{}')
    res = await hb.get(b'hello')
    assert(res.value == b'world')
",
        storage_dir.path().display()
    ))?;

    assert_eq!(result.status.code(), Some(0));
    Ok(())
}
```

The actual test part is simple
The Rust creates a Hyperbee and inserts the key `b"hello"` with value `b"world"`, which is saved to `storage_dir`.
Then Python opens a Hyperbee with the same `storage_dir`, and checks for the key value.

The way the test works is more interesting. 
At the top we declare `mod common` to make the `common` module available. We see this in the directory alongside `py_test.rs`. Within common we can have code exclusively for tests. We import `common::python::{require_python, run_python}`. These come from `tests/common/python/mod.rs`. 
Rust code in the `commod` module is general purpose test code. Code within the `python` and `javascript` modules is language specific Rust code.

### Building test dependencies

To understand what's going on we step into `run_python`:
```rust
/// Run the provided python `code` within a context where the Python Hyperbee library is imported.
/// Code must be written within a `async main(): ...` function.
pub fn run_python(code: &str) -> Result<Output, Box<dyn std::error::Error>> {
    require_python()?;
    run_code(
        &build_whole_script(PRE_SCRIPT, code, POST_SCRIPT),
        "script.py",
        build_command,
        vec![path_to_python_target()?, path_to_c_lib()?],
    )
}
```
The first thing do is call `require_python`.
This ensures that all the dependencies for running the python code are ready.
It looks like:
```rust
pub fn require_python() -> Result<Output, Box<dyn std::error::Error>> {
    run_make_from_dir_with_arg(REL_PATH_TO_HERE, "")
}
```
It just calls `run_make_from_dir_with_arg`
```rust
pub fn run_make_from_dir_with_arg(dir: &str, arg: &str) -> Result<Output> {
    let path = join_paths!(git_root()?, dir);
    let cmd = format!("cd {path} && flock make.lock make {arg} && rm -f make.lock ");
    let out = check_cmd_output(Command::new("sh").arg("-c").arg(cmd).output()?)?;
    Ok(out)
}
```
This handles calling `make` from a certain location, with some arguments.
Notice it makes use of [`flock`](https://www.man7.org/linux/man-pages/man2/flock.2.html).
This creates a lockfile while `make` is running, or, if the file exists, it blocks until it is deleted.
This lets us serialize all calls on the system to `make`.
Which is important because the tests are run in parallel, and many of them may be trying to run `make`.

So `require_python` ultimately calls `make`  within `test/common/python/` which uses the `Makefile` in the `python/` directory.
We need to look at `tests/common/python/Makefile` to see what it does:
```make
include ../vars.mk

# Build the Python Hyperbee library
$(TARGET_DEBUG_DIR)/hyperbee.py: $(C_LIB_PATH)
	cargo run -F ffi --bin uniffi-bindgen generate --library $(C_LIB_PATH) --language python --out-dir $(TARGET_DEBUG_DIR)

# Pull in the $(C_LIB_PATH) rule
include ../Makefile
```

First this gets some make variables defined in `tests/common/vars.mk`.
We then use some of these in the [Makefile "rule"](https://www.gnu.org/software/make/manual/html_node/Rule-Introduction.html) called `$(TARGET_DEBUG_DIR)/hyperbee.py`.
The rule should create the Python Hyperbee library at `target/debug/hyperbee.py`.
The "recipe" part shows how this file is made, by running `uniffi-bingen`.
Sidenote: we are using [UniFFI](https://github.com/mozilla/uniffi-rs) for generating foreign language bindings.
It's cool! I'll discuss this more in a future post.

Notice that the rule has `$(C_LIB_PATH)` (`target/debug/libhyperbee.so`) as a dependency.
This rule is loaded in the `include ../Makefile` line, which is `tests/common/Makefile`.
That Makefile handles cross language dependencies. 
We need to see haw it works:

```make
# This file is loaded from other Makefile's so we can't use simple "include ../foo" because 
# that is resolved relative to the original Makefile.
#
# $(dir $(lastword $(MAKEFILE_LIST))) gets the dir of the current Makefile
include $(dir $(lastword $(MAKEFILE_LIST)))vars.mk

# Build Rust FFI library
$(C_LIB_PATH): $(RS_SOURCES) $(ROOT)/Cargo.toml
	cd $(ROOT) && cargo build -F ffi
```

This rule creates `libhyperbee.so` by running `cargo`.
When `libhyperbee.so8`and `hyperbee.py` are ready, we have all the dependencies we need.
Now that the dependencies are ready, lets look at how we run the Python code.

### Running the foreign code

We saw in the `run_python` function, that it is a wrapper around the `run_code` function.
The arguments `run_code` could use some explaining:
```rust
run_code(
    /// Create a string containing all the python code
    &build_whole_script(PRE_SCRIPT, code, POST_SCRIPT),
    /// The code goes in a file called "script.py"
    "script.py",
    /// A function creates a string, the string is the shell command that runs the code
    /// i.e. "python script.py"
    build_command,
    /// A vec of files copied next to "script.py"
    vec![path_to_python_target()?, path_to_c_lib()?],
)
```

Let's see what `run_code` does:
```rust
/// Put the provided `code` into a file named `script_file_name` inside a temporary directory where
/// `copy_dirs` are copied into. `build_command` should output a shell command as a string that
/// runs the script.
pub fn run_code(
    code: &str,
    script_file_name: &str,
    build_command: impl FnOnce(&str, &str) -> String,
    copy_dirs: Vec<PathBuf>,
) -> Result<Output> {
    let working_dir = tempfile::tempdir()?;
    let working_dir_path = working_dir.path().display().to_string();
    let script_path = working_dir.path().join(script_file_name);
    write!(&File::create(&script_path)?, "{code}")?;
    // copy copy_dirs into working dir
    for dir in copy_dirs {
        let dir_cp_cmd = Command::new("cp")
            .arg("-r")
            .arg(&dir)
            .arg(&working_dir_path)
            .output()?;
        if dir_cp_cmd.status.code() != Some(0) {
          // handle error
        }
    }
    let cmd = build_command(&working_dir_path, &script_path.to_string_lossy());
    Command::new("sh").arg("-c").arg(cmd).output()
}
```
Essentially this is just a way to run a shell command (from `build_command()`), in a temporary directory, with `code` inside of a file named `script_file_name`, with some context (`copy_dirs`).

Putting this all together, when we call:
```rust
run_python("
async main():
  run_python("print('hello world'))
")?;
```

We get a python script in a temporary directory (`/tmp/.tmpDBK1j5/script.py`) that looks like:
```python
import asyncio
from hyperbee import *

async main():
  run_python("print('hello world'))

if __name__ == '__main__':
    asyncio.run(main())
```
`hyperbee.py` and `libhyperbee.so` are copied alongside `script.py`.
Finally `python scrip.py` is run.
The output of the process is returned by `run_python` as the `Output` type. This has the status code, `stdout`, and `stderr`.
You can check values using `print` in python, then in Rust checking `Output.stdout` for the result.

## Generalizing to other Languages

You may  see how this would work for another language.
In Hyperbee we apply the same pattern for JavaScript.
The specifics are in `tests/common/js/*`.
There I have a `Makefile` which handles setting up the JavaScript dependencies, so I can create a `require_javascript` function (like `require_python`). 
Then in `js/mod.rs` I implement `run_javascript` around the `run_code` function.
For example there I would add a `node_modules` to the `copy_dirs`.
With these I'm able to write tests the same way I did with Python.

## Why Makefiles for Dependencies

`make` may seem confusing at first, but It's at it's core are some very simple principles.
It is actually really powerful.
Sure there are some hacks in here, but:

* `make` is widely available and probably already installed on a developers machine.
* It is language agnostic and provides a common interface for any language. Just run `make`.
* It's declarative dependency model is self documenting.
* It gives you incremental builds and caching. So we only rebuild what is needed.

I love `make`! But this is just a [**pattern**](https://en.wikipedia.org/wiki/Software_design_pattern).
You could use something else for handling dependencies.
But me, I love `make`!

## Why use this module layout

The way module's work within `tests/` was not well documented, or at least I could not find the documentation.
So I arrived at this after trying several things.
I knew that I wanted non-rust stuff like `Makefile`s, `package.json`, etc to live alongside their respective rust code.
So this would imply I should have a directory for each language. 
You could imagine a more flat layout with `js/`, `common/`, and `python/` all living under `tests/`.
But this has the problem that
then those modules could not see each other because `tests/` is not actually a module.
The individual files within it are compiled as crates.
This has the effect that the layout of the modules tree depends on the test file itself, and so it could differ between test files.
Recall that `mod python` has a dependency on `mod common`.
So a test file that declares both `mod python` and `mod common` could work.
But a test file could just declare `mod python`, which would not work because `common` is not in the modules.

Putting all non-test /utility modules under a tree fixes this by making a single, correct, entry point.

## Downsides and pitfalls

For a small project a better approach might be just hard coding your expected values into the tests.
This pattern requires correctly implementing a way to run foreign code, and automatically pull foreign dependencies.
This isn't super hard, but it should be done with care. Ideally these things could be extracted into library.

To that end, I've been working on building a library for running foreign code in Rust with [`rusty_nodejs_repl`](https://crates.io/crates/rusty_nodejs_repl).
That library lets you write your states within a stateful [Read-Eval-Print loop (REPL)](https://en.wikipedia.org/wiki/Read%E2%80%93eval%E2%80%93print_loop).
This lets you mutate and check state from Node.js throughout the test like this:
```rust
let mut repl = Config::build()?.start()?;
let result = repl.run("
a = 42;
console.log(a)").await?;
assert_eq!(result, b"42\n");
// udate the state in the JavaScript
let result = repl.run("
a = a*2
console.log(a)").await?;
assert_eq!(result, b"84\n");
```
For examples of using this library see the  Hypercore [replicator](https://github.com/cowlicks/replicator) library [here](https://github.com/cowlicks/replicator/blob/master/replicator/tests/js_integration.rs).

<hr>

If you have any questions or feedback, feel free to <a href="mailto:hypercore@cowlicks.website">email me.</a>.
There is also a [Discord Channel](https://discord.com/channels/709519409932140575/776019828900364289).
If you'd like to contribute code, a good place to start is the [Hypercore Rust](https://github.com/datrs) project page.
If you have more money than time, and want to support this work financially, consider contributing on my [GitHub sponsors page](https://github.com/sponsors/cowlicks).
