Rust: testing async WebAssembly code executed by spawn_local

2023-10-19

In this post I am going to show a simple (and maybe naive) way to test a specific scenario that involves async rust, the WebAssembly runtime and a synchronous function that is running the async code using spawn_local.

While I was working on my yew component library, I ended up in a situation like this. This may sound like an edge-case, but given the UI framework’s synchronous nature it’s not that rare.

Another factor was that currently there isn’t a good way test how a yew component works, so I decided to split the state and operations on the state from the component. This way some functionality can be unit tested and the rest can be covered by e2e tests with Cypress for example.

The state had to get some data asynchronously, then notify the component about the result. The difficulty is that every state operation needs to be synchronous because they are called from the component.

For the sake of this blog post, let’s imagine this simplified example:

// This function simulates a HTTP request, in the real world we'd use some async library like reqwest, etc...
async fn http_get() -> String {
    "Hello world!".to_string()
}

// The state operation
pub fn get_something<F: Fn(String) + 'static>(callback: F) {
    spawn_local(async move {
        let result = http_get().await;
        
        // do something with the result, e.g. save it in the state

        // notify the component
        callback(result);
    });
}

In this example the get_something function takes a callback, then executes an async computation using spawn_local. This calls an external HTTP resource, then runs the callback with the result.

Let’s see how we could test this function:

  #[wasm_bindgen_test]
  fn test_do_something_calls_callback() {
      let result = Rc::new(RefCell::new(String::new()));

      let moved_result = Rc::clone(&result);
      get_something(move |result| {
          *moved_result.borrow_mut() = result;
      });

      assert_eq!(*(*result).borrow(), String::from("Hello world!"));
  }

As we would expect this test will fail. But why? Let’s have a closer look at spawn_local’s documentation:

Runs a Rust Future on the current thread.

The future must be ‘static because it will be scheduled to run in the background and cannot contain Any stack references.

The future will always be run on the next microtask tick even if it immediately returns Poll::Ready.

We’re getting closer: “… the future will always be run on the next microtask tick”. We just need to make sure the assertion runs after the next microtask tick. But how can we achieve this?

We need a function, that allows us to wait for the next tick:

  async next_tick() {
    todo!()
  }

Once we have this function, we can refactor the test to make it asynchronous, wait for the tick, and run the assertion:

  #[wasm_bindgen_test]
  async fn test_do_something_calls_callback() {
      let result = Rc::new(RefCell::new(String::new()));

      let moved_result = Rc::clone(&result);
      get_something(move |result| {
          *moved_result.borrow_mut() = result;
      });

      next_tick().await;

      assert_eq!(*(*result).borrow(), String::from("Hello world!"));
  }

We just need to implement next_tick. After some search I landed on the page that describes how to write async tests using wasm-bindgen-test. The example test code was nicely commented and had all the key information I needed: “ready on the next tick of the micro task queue” and “make the test wait on it.

So the idea is that once we deferred the tested code using spawn local, we create a JS promise, we convert it to a Rust future, and then we wait for it to resolve:

  async fn next_tick() {
      let promise = js_sys::Promise::resolve(&JsValue::from(0));

      JsFuture::from(promise).await.unwrap();
  }

I am not absolutely sure if this is the most idiomatic solution, but it works: using this trick I was able to test the interaction I wanted to.

A full code example is available at https://git.vdx.hu/voidcontext/blog-examples/src/branch/main/rust-wasm-async-test-tick