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.
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.
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:
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.
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>>
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(())
}
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:
Inside the function, you can easily convert it:
let path: &Path = base_path.as_ref();
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.