Episode 6: Testing in Rust — Because Even Pet Projects Deserve Some Love
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:
- gear_type_to_code — transforms a GearType into an i32
- 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:
- A string literal like “dofus_db/data”.
- A temporary directory (TempDir::new()).
- 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.