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

Julien Truffaut

30th 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.

© 2026 RustJobs.dev, All rights reserved.