Episode 6: Testing in Rust — Because Even Pet Projects Deserve Some Love

Julien Truffaut image

Julien Truffaut

30 October 2025

Today, we’re taking a short break from the main development of our dofus-opti tool to talk about testing.

Some people might argue that I should’ve started with tests (looking at you, TDD enthusiasts 👀), but remember — this is a pet project, and my main enemy isn’t bugs… it’s boredom. So I’m focusing on the parts that keep me motivated. That said, I do want to learn how to test in Rust — and testing the existing API is a great way to see how it all fits together.

Unit Tests in Rust

I was surprised to learn that Rust unit tests are written in the same file to the code they test!

fn plus(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn plus_test() {
        assert_eq!(plus(2, 3), 5);
    }
}

At first, I found that odd, but it actually makes sense. Having tests close to their functions acts as documentation — like mini examples for future readers. I do wonder, though, if this might lead to massive Rust files in larger projects. Time will tell.

Testing the Parser

In a previous episode, we built a parser to convert DofusDB data into our model. Part of that involved mapping integer codes to enums like GearType or CharacteristicType. For this kind of mapping, I like to write round-trip tests: if you convert one way and back, you should get the same result. Originally, I had two functions:

  1. gear_type_to_code — transforms a GearType into an i32
  2. parse_gear_type — transforms an i32 into a Result<GearType>

Then Bryan Abate suggested a cleaner design using a newtype and the From trait — much more idiomatic and elegant:

struct DofusDbTypeId(i32);
impl From<&GearType> for DofusDbTypeId {
    fn from(gear_type: &GearType) -> Self {
        let id = match gear_type {
            GearType::Amulet => 1,
            GearType::Axe    => 19,
            GearType::Belt   => 30,
            // ...
        };
        DofusDbTypeId(id)
    }
}

fn parse_gear_type(id: DofusDbTypeId) -> Result<GearType, String> {
    // ...
}

Now we can easily write a round-trip test for all GearType values:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_valid_gear_types() {
        for gear_type in ALL_GEAR_TYPES {
            let type_id = DofusDbTypeId::from(gear_type);
            assert_eq!(parse_gear_type(type_id), Ok(gear_type.clone()));
        }
    }
}

Running it:

> cargo test
running 1 test
test dofus_db_parser::tests::parse_valid_gear_types ... ok
test result: ok. 1 passed; 0 failed; finished in 0.00s

I added similar tests for other enums, as well as failure cases to ensure the parser rejects invalid data. You can check out all the parser tests here.

Testing File I/O

In the previous episode, we saved all the gears fetched from DofusDB into local files, one per gear:

dofus_db/
└── data/
    ├── Amulet/
    │   ├── amulet_croconecklace.json
    │   ├── amulet_helsephine_love.json
    │   └── ...
    ├── Belt/
    │   ├── belt_minotoror.json
    │   ├── belt_ogivol.json
    │   └── ...
    ├── Boots/
    │   ├── boots_pink_dragoon.json
    │   ├── boots_royal_mouth.json
    │   └── ...

To test this properly, it makes sense to move the file-related logic out of main.rs into a dedicated module, say dofus_db_file.rs:

const DOFUS_DB_EXPORT_PATH: &str = "dofus_db/data";

fn save_gears(
    gear_type: &GearType,
    gears: &Vec<serde_json::Value>
) -> Result<()>

fn read_gears(
    gear_type: &GearType
) -> Result<Vec<serde_json::Value>>

One issue quickly stands out: the export path is hardcoded. For testing, we’d rather use a temporary directory (e.g., under /tmp) so we can write, read, and clean up without touching real data. We can solve this by adding an extra parameter:

fn save_gears(
    base_path: &str,
    gear_type: &GearType,
    gears: &Vec<serde_json::Value>,
) -> Result<()>

fn read_gears(
    base_path: &str,
    gear_type: &GearType,
) -> Result<Vec<serde_json::Value>>

Temporary Directories with tempfile

How do we create a temporary directory? Turns out there’s a crate for that — tempfile 😄 Since we only need it for tests, we can add it under dev-dependencies in Cargo.toml:

[dev-dependencies]
tempfile = "3"

Now we can write a simple round-trip test:

use tempfile::TempDir;

#[test]
fn write_read_gears() -> anyhow::Result<()> {
    let json_1 = r#"{ "name": { "en": "Great Amulet", "fr": "Grande Amulette" } }"#;
    let json_2 = r#"{ "foo": "bar" }"#;

    let json_values = vec![json_1, json_2]
        .into_iter()
        .map(serde_json::from_str)
        .collect::<Result<Vec<_>, _>>()?;

    let base_dir = TempDir::new()?;
    let gear_type = GearType::Amulet;

    save_gears(&base_dir, &gear_type, &json_values)?;
    let read_json_values = read_gears(&base_dir, &gear_type)?;

    assert_eq!(json_values, read_json_values);
    Ok(())
}

Making Paths More Flexible

There’s one small issue: TempDir::new() returns a TempDir, not a &str. We can make our API more flexible by using a generic path type:

fn save_gears<P: AsRef<Path>>(
    base_path: P,
    gear_type: &GearType,
    gears: &Vec<serde_json::Value>,
) -> Result<()>

fn read_gears<P: AsRef<Path>>(
    base_path: P,
    gear_type: &GearType,
) -> Result<Vec<serde_json::Value>>

This way, base_path can be:

  1. A string literal like “dofus_db/data”.
  2. A temporary directory (TempDir::new()).
  3. A Path built with Path::new(“dofus_db”).join(“data”).

Inside the function, you can easily convert it:

let path: &Path = base_path.as_ref();

Conclusion

That’s it for today! I’m glad I finally took the time to write some tests — not only did I verify the existing logic, but I also improved the file API and learned about a neat little trick with AsRef<Path>.

As usual, all the code for this episode is available here.