Testing With Fixtures in Rust

Testing With Fixtures in Rust

In this article

  • ๐Ÿ”ฌ We'll learn the limitations of #[test] in Rust.
  • ๐Ÿช„ We'll explore an alternative to write cleaner unit tests with fixtures.
  • ๐Ÿ— We'll refactor the unit tests in one of my open source projects!

Let's go.


This article is also available on

Feel free to read it on your favorite platformโœจ


The Fixture Problem

Test fixtures are very effective in producing repeatable tests. A test fixture can be a constant or function that encapsulates a test's dependency.

I wanted to create a few test fixtures for my unit tests in one of my open source projects called Voy. It's a WebAssembly semantic engine written in Rust. What it does is to extract features using machine learning models, build an index, and provide a query function that enables users to search the index based on meaning and semantics. Here's a quick demo:

Voy demo

You can find the Voy's repository on GitHub! Feel free to try it out. The repository includes examples that you can see how to use Voy in different frameworks.

While I was still working on the feature extraction, I needed pre-generated embeddings to test the index and query part of the engine. The fixture for embeddings looks like this:

pub static EMBEDDING: [[f32; 768]; 6] = [
    [
        0.01960003957247733,
        -0.03651725347725505,
        0.03361894761373059,
        // ...
    ]

I created a static variable for 6 embeddings in an array. Each item is a 768 dimensional vector that represents a sentence.

To use the fixture, I simply imported it in my test module. My unit test looks like this:

use super::engine_fixtures::{EMBEDDING, CONTENT, QUESTION};
use crate::engine::{add, index, remove, search, Query};
use crate::{EmbeddedResource, Resource};

fn get_resource(k: usize) -> Resource {
     let embeddings = EMBEDDING
         .iter()
         .take(k)
         .enumerate()
         .map(|(i, x)| EmbeddedResource {
             id: i.to_string(),
             title: CONTENT.get(i).unwrap().to_string(),
             url: "".to_owned(),
             embeddings: x.to_vec(),
         })
         .collect();
     Resource { embeddings }
 }

#[test]
 fn it_returns_vector_search_result() {
     let resource: Resource = get_resource(6);
     let index = index(resource).unwrap();

     let query = Query::Embeddings(QUESTION.to_vec());
     let result = search(&index, &query, 1).unwrap();

     assert_eq!(result.get(0).unwrap().title, CONTENT[0]);
     assert_eq!(result.get(1).unwrap().title, CONTENT[1]);
     assert_eq!(result.get(2).unwrap().title, CONTENT[2]);
     assert_eq!(result.get(3).unwrap().title, CONTENT[4]);
     assert_eq!(result.get(4).unwrap().title, CONTENT[5]);
     assert_eq!(result.get(5).unwrap().title, CONTENT[3]);
 }

I created another 2 fixtures for the sentences the generated the embeddings and a question embedding to perform the query.

The test case worked. The fixture produced repeatable results. However, it seems a little off. It's not very clean:

  • I needed a helper function to "get_resource" in the test case to initiate the fixture.
  • The fixtures were not encapsulated in the unit test and the usages were scattered.

So I started looking for a better way to write the test.

Introducing The rstest Crate

rstest makes it very easy to write unit tests with fixtures. All you need to do is:

Defining fixtures

#[fixture]
pub fn fixture() -> u32 { 42 }

Replacing #[test] with #[rstest] macro.

#[rstest]
fn should_success(fixture: u32) {
    assert_eq!(fixture, 42);
}

You can check out rstest's repository to find more ways to write test fixtures.

Now let's use rstest to refactor the fixtures above. I'll simply create a function to return the static variable. Like this:

src/engine/tests/fixtures.rs
use rstest::fixture;

#[fixture]
pub fn embedding_fixture() -> [[f32; 768]; 6] {
    EMBEDDING
}

We can refactor the "get_resource" helper function as a fixture too:

src/engine/tests/fixtures.rs
#[fixture]
pub fn resource_fixture() -> Resource {
    let content = content_fixture();
    let embeddings = embedding_fixture()
        .iter()
        .enumerate()
        .map(|(i, x)| EmbeddedResource {
            id: i.to_string(),
            title: content.get(i).unwrap().to_string(),
            url: "".to_owned(),
            embeddings: x.to_vec(),
        })
        .collect();
    Resource { embeddings }
}

Since each fixture functions are just functions, we can use them inside another fixture function like we see in the "resource_fixture" function above.

To use the fixtures in a test case, we'll need to import the fixtures and inject them as parameters:

src/engine/tests/mod.rs
use fixtures::*;
use rstest::*;

#[rstest]
fn it_returns_vector_search_result(
    resource_fixture: Resource,
    question_fixture: [f32; 768],
    content_fixture: [&'static str; 6],
) {
    let index = index(resource_fixture).unwrap();
    let query = Query::Embeddings(question_fixture.to_vec());
    let result = search(&index, &query, 6).unwrap();

    assert_eq!(result.get(0).unwrap().title, content_fixture[0]);
    assert_eq!(result.get(1).unwrap().title, content_fixture[1]);
    assert_eq!(result.get(2).unwrap().title, content_fixture[2]);
    assert_eq!(result.get(3).unwrap().title, content_fixture[4]);
    assert_eq!(result.get(4).unwrap().title, content_fixture[5]);
    assert_eq!(result.get(5).unwrap().title, content_fixture[3]);
}

Make sure the parameters have the same names as the fixtures. rstest uses the names to inject and initiate the fixtures in the test functions.

Let's run "cargo test" to see if it works.

Unit result with fixtures

It does!

Final Thoughts

rstest has more convenient features. For example, you can write multiple test cases with #[case]:

use rstest::rstest;

#[rstest]
#[case(0, 0)]
#[case(1, 1)]
#[case(2, 1)]
#[case(3, 2)]
#[case(4, 3)]
fn fibonacci_test(#[case] input: u32, #[case] expected: u32) {
    assert_eq!(expected, fibonacci(input))
}

You can also test Future!

use rstest::*;
#[fixture]
async fn base() -> u32 { 42 }

#[rstest]
#[case(21, async { 2 })]
#[case(6, async { 7 })]
async fn my_async_test(#[future] base: u32, #[case] expected: u32, #[future] #[case] div: u32) {
    assert_eq!(expected, base.await / div.await);
}

These are just a few examples I took from rstest. It has more fixture-based features that help us to structure cleaner unit tests with test fixtures. I really enjoyed it.

Voy is an open source semantic search engine in WebAssembly. I created it to empower more projects to build semantic features and create better user experiences for people around the world. Voy follows several design principles:

  • Tiny: Reduce overhead for limited devices, such as mobile browsers with slow network or IoT.
  • ๐Ÿš€ Fast: Create the best search experience for the users.
  • ๐ŸŒณ Tree Shakable: Optimize bundle size and enable asynchronous capabilities for modern Web API, such as Web Workers.
  • ๐Ÿ”‹ Resumable: Generate portable embeddings index anywhere, anytime.
  • โ˜๏ธ Worldwide: Run semantic search on CDN edge servers.

It's available on npm. You can simply install it with your favorite package manager and you're ready to go.

# with npm
npm i voy-search

# with Yarn
yarn add voy-search

# with pnpm
pnpm add voy-search

Give it a try and I'm happy to hear from you!

References


๐Ÿ’ฌ Comments on Reddit.


Here you have it! Thanks for reading through ๐Ÿ™Œ If you find this article useful, please share it to help more people in their engineering journey.

๐Ÿฆ Feel free to connect with me on twitter!

Ready for the next article? ๐Ÿ‘‰ Share Rust Types With TypeScript for WebAssembly in 30 Seconds

Happy coding!

Daw-Chih Liou
Daw-Chih Liou

Daw-Chih is a software engineer, UX advocate, and creator who is dedicated to Web engineering. His background in Human Centered Computing has led him to work with startups and public companies across North America, Asia, and Europe. He is passionate about meeting business trajectory with user journey and utilizing engineering architecture and performance monitoring to provide optimal user experience.