How to use test fixtures

Use string-based test fixtures with `//-` directives and `$0` cursors to simulate multi-file Rust projects for IDE and compiler testing.

When a string becomes a project

You are building a tool that analyzes Rust code. Maybe a linter that catches unused variables, or a formatter that fixes indentation, or an IDE feature that provides completions. You write the logic. Now you need tests. Creating a temporary directory, writing main.rs, writing Cargo.toml, compiling, and checking the output feels like a chore. You do it once. You do it twice. By the third test, you are copying and pasting boilerplate until your fingers hurt.

There is a better way. You can embed the entire project structure inside a single string literal. The test framework reads the string, builds a virtual filesystem in memory, and hands you a database ready for analysis. This is a test fixture. You get the benefits of a real project structure without touching the disk. The test runs faster. The test is portable. The test is readable.

Embed the project in the test. Keep the disk clean.

The fixture syntax

A test fixture is a raw string literal that defines a mini-project environment. The framework parses the string using a specific syntax to declare files, crates, and cursor positions. The syntax relies on comment-like markers. Lines starting with //- act as headers. They define the file path, the crate name, dependencies, and other metadata. The text after the header becomes the file content. The parser continues until it hits the next header or the end of the string.

Think of the fixture as a compressed archive of a project, but written as plain text. Instead of zipping files, you use the //- markers to tell the parser where one file ends and another begins. The parser inflates this text into a virtual workspace. You can query the workspace just like a real compiler database.

The header line supports several keys. crate:name sets the crate name. deps:a,b lists dependencies. edition:2021 sets the Rust edition. You can combine them in any order. The parser splits the header by whitespace. If you omit the crate name, the framework might infer it from the path or use a default. Check your framework's documentation for the default behavior.

The header line is the contract. Follow the syntax exactly.

Minimal example

A minimal fixture defines a single file in a single crate. The raw string holds the project structure. The r# delimiters prevent escaping issues with quotes inside the code. The //- line declares the file path and crate name. The framework parses this and returns a database.

/// Checks that the fixture parser handles a single file correctly.
#[test]
fn test_single_file_fixture() {
    // The raw string holds the entire project structure.
    // r# delimiters allow quotes without escaping.
    let fixture = r#"
//- /main.rs crate:my_crate
fn main() {
    println!("Hello");
}
"#;

    // Parse the fixture into a database.
    // The framework creates a virtual /main.rs.
    // MiniCore provides basic types like String and Vec.
    let (db, files) = RootDatabase::from_ra_fixture(fixture, MiniCore::default());

    // Verify the file exists in the virtual filesystem.
    assert!(files.contains_key("/main.rs"));
}

One string, one project. No temp files required.

How the parser builds the database

When the test runs, the framework scans the raw string. It looks for lines starting with //-. Each marker triggers a new file creation in the virtual database. The text after the marker defines the path and metadata. crate:my_crate tells the database this file belongs to a crate named my_crate. deps:other links it to another crate defined in the same fixture.

The framework assembles these pieces into a crate graph. It resolves dependencies. It populates the syntax trees. Finally, it hands you a RootDatabase object. This object acts like a compiler backend. You can query it for types, definitions, and diagnostics without running rustc. The database is isolated. It does not include the standard library by default. You must pass MiniCore::default() or a similar core stub. Without it, references to String or Vec fail with E0412 (cannot find type).

The database is your compiler. Query it, don't just trust it.

Realistic example with dependencies

Real tools often need to test multi-crate interactions. A linter might check that a binary uses a library correctly. An IDE might test "Go to Definition" across crate boundaries. Fixtures handle this with the deps key. You define multiple crates in the same string. The framework links them based on the dependency declarations.

You can also mark cursor positions. The $0 marker indicates where the cursor sits for analysis. Tools use this to test completions or diagnostics at a specific spot. If you are testing a refactoring tool, you might use $0..$1 to mark a range. The framework supports multiple markers depending on the feature.

/// Tests dependency resolution and cursor positioning.
#[test]
fn test_multi_crate_with_cursor() {
    // Define two crates: a library and a binary that depends on it.
    // The $0 marker indicates where the cursor sits for analysis.
    let fixture = r#"
//- /lib.rs crate:my_lib
pub struct Config {
    pub name: String,
}

//- /main.rs crate:my_bin deps:my_lib
use my_lib::Config;

fn main() {
    let cfg = Config { name: "test".into() };
    cfg.$0
}
"#;

    // Build the database with both crates.
    // The framework resolves my_bin -> my_lib.
    let (db, files) = RootDatabase::from_ra_fixture(fixture, MiniCore::default());

    // Find the cursor position in the virtual file.
    let cursor = files.get("/main.rs").unwrap().cursor_offset;

    // Verify the cursor is inside the expression.
    assert!(cursor > 0);
}

Mark the cursor. Test the spot where the user actually clicks.

Pitfalls and debugging

Fixtures can be tricky. If you forget the //- header for a file, the parser treats the text as part of the previous file. You get a syntax error deep inside a concatenated mess. The compiler rejects this with E0425 (cannot find value) or a parse error, depending on how the text merges.

Be careful with lines starting with //- inside your code. The fixture parser treats any line starting with //- as a new file header. If your code contains such a line, the parser splits the file there. You get a truncated file and a garbage file. To include //- in your code, add a space: // -. The parser ignores the space and treats it as a normal comment. This saves you from mysterious parse errors.

Another common trap is circular dependencies. If crate:a depends on crate:b and crate:b depends on crate:a, the database builder panics. The framework detects cycles and aborts. You need to restructure the fixture to break the cycle.

Fixtures do not include the standard library. The virtual database is empty by default. You must provide a core stub. MiniCore::default() gives you String, Vec, Option, and basic traits. If your test uses std::fs::read, the fixture fails. You need a larger stub or a mock. The framework cannot compile against the real std in a fixture. It would be too slow and fragile. Stick to MiniCore for syntax and type tests. Use real compilation for standard library interactions.

When a fixture test fails, the error message often points to the line in the raw string. That helps. If the failure is obscure, add a debug print to dump the virtual filesystem. Most fixture frameworks provide a method to list files or print the crate graph. Use this during development. Once the test passes, remove the debug print. Keep the test clean.

The community convention for naming fixtures is descriptive. test_fixture_example is weak. test_linter_catches_unused_var_in_main tells you what the test checks. The fixture string should be named fixture or code. Avoid generic names like s or data.

Check the dependencies. A missing link breaks the whole graph.

When to use fixtures

Use test fixtures when you are building compiler plugins, linters, or IDE features and need to verify behavior against code snippets. Use test fixtures when you want to test multi-crate interactions without managing temporary directories and Cargo.toml files. Use integration tests with real cargo commands when you need to verify build scripts, procedural macros that depend on external tools, or complex dependency resolution that the fixture database cannot simulate. Use unit tests with mock objects when you are testing pure logic that does not depend on Rust syntax or crate structure.

Pick the tool that matches the complexity. Fixtures for syntax, cargo for builds.

Where to go next