Skip to content

Use assets in tests

You want tests that cover the code which reads assets, without embedding fixtures in the binary or writing files to disk.

Accept an Assets, do not construct one

The single change that makes asset-reading code testable is taking the overlay as a parameter rather than building it inside the function:

// Testable: the caller decides where assets come from.
fn render_template(assets: &Assets, name: &str) -> Result<String, AssetError> {
    assets.open_text(&format!("templates/{name}.md"))
}

Assets is Clone at refcount cost and Send + Sync, so passing it around — into threads, into async tasks, into every command — is cheap. Production wires the real stack once at startup; tests wire whatever they need.

Build the fixture stack in the test

use std::collections::HashMap;
use rtb_assets::Assets;

fn fixtures(files: &[(&str, &str)]) -> HashMap<String, Vec<u8>> {
    files
        .iter()
        .map(|(k, v)| ((*k).to_string(), (*v).as_bytes().to_vec()))
        .collect()
}

#[test]
fn renders_the_readme_template() {
    let assets = Assets::builder()
        .memory("fixtures", fixtures(&[("templates/readme.md", "# {{ name }}")]))
        .build();

    assert_eq!(render_template(&assets, "readme").unwrap(), "# {{ name }}");
}

Keys are matched literally, so write them exactly as production code asks for them — no leading ./, no leading /.

Test precedence with two memory layers

Layer ordering is the behaviour most worth a test, and it needs no disk at all. Register lowest priority first:

#[test]
fn user_layer_wins_over_defaults() {
    let assets = Assets::builder()
        .memory("defaults", fixtures(&[("greeting.txt", "hello")]))
        .memory("user", fixtures(&[("greeting.txt", "hi")]))
        .build();

    assert_eq!(assets.open_text("greeting.txt").unwrap(), "hi");
}

The same trick covers merge behaviour: put the shipped YAML in the lower layer and the override in the upper, and assert on the deserialised result.

Start from Assets::default() when the code should find nothing

Assets::default() is a valid overlay with no layers. Every read returns nothing, no read errors, and no builder call is needed:

#[test]
fn reports_a_missing_template() {
    let err = render_template(&Assets::default(), "nope").unwrap_err();
    assert!(matches!(err, AssetError::NotFound(_)));
}

Use a real directory only when the directory is the thing under test

MemorySource cannot exercise the path-traversal rules, permission handling, or a missing root — those live in DirectorySource. For those, use tempfile:

let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("assets")).unwrap();
std::fs::write(dir.path().join("assets/allowed.txt"), b"ok").unwrap();

let assets = Assets::builder()
    .directory(dir.path().join("assets"), "disk")
    .build();

assert_eq!(assets.open("../secret.txt"), None);

Keep the tempdir alive for the duration of the test — dropping it deletes the directory, and the layer then quietly returns nothing rather than failing loudly.

Do not embed test fixtures into the library

#[derive(RustEmbed)] on a test folder works and this crate's own suite does it to cover EmbeddedSource. For anything else it is the wrong tool: the folder must exist at compile time, changes need a rebuild to be seen (debug-embed is enabled, so there is no read-from-disk fallback in debug builds), and the fixtures end up in the built artefact.

Reach for MemorySource unless the thing you are testing is embedding itself.