DEV Community

Cover image for Unit testing with SeaOrm
Mila Kyrashchuk
Mila Kyrashchuk

Posted on

Unit testing with SeaOrm

A few weeks ago, I submitted my BSc thesis and project, where I developed the identity management application. The API was implemented in Rust, with Actix-Web framework, and SeaOrm for database communication.

The University's expectation was to create a Django backend and HTML+CSS frontend, and cover them with unit-tests. However when it came to mocking Postgres connection with SeaOrm, I quickly became confused.

The SeaOrm provides a MockDatabase (which is great!), but to use it we must fill it with query or execution results first. The documentation has an overview for the basic find and insert methods, others are scarcely mentioned in various StackOverflow answers.

This post describes the logic and code snippets, that I had to mock for my project: update / save, select partial model or a tuple, and delete cascade.

Let's assume we're building the application for the vet clinic for dogs, and the part of the database design includes the following relationships:
entity relationship diagram

Update and Save

While save can update or insert, both functions expect to fill the query result with the models.
For example, when testing the following code:

pub async fn update_dog_chip(
    id: Uuid,
    chip_id: String,
    conn: &DatabaseConnection,
) -> Result<(), DbErr> {
    if let Some(dog) = dog::Entity::find_by_id(id).one(conn).await? {
        let mut active_model: dog::ActiveModel = dog.into();
        active_model.chip_id = ActiveValue::Set(chip_id);
        active_model.save(conn).await?;
        // Save here will default to update as the ID is already set
        // but the mocking logic is same
        // active.update(conn).await?;
    }
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

To mock the database response to test this function, the connection should include two Dog models in separate arrays: one for results of the select query, and another one for save operation:

#[tokio::test]
    async fn test_update_dog_chip() -> Result<(), DbErr> {
        let dog_id = Uuid::default();
        let conn = MockDatabase::new(DatabaseBackend::Sqlite)
            .append_query_results([
                [dog::Model {
                    chip_id: "chip_1".to_string(),
                    ..Default::default()
                }],
                [dog::Model {
                    chip_id: "chip_2".to_string(),
                    ..Default::default()
                }],
            ])
            .into_connection();
        update_dog_chip(dog_id, "chip_2".to_string(), &conn).await?;
        Ok(())
    }
Enter fullscreen mode Exit fullscreen mode

Partial models and Tuples

To mock query results that are not just models, but structures that implement DerivePartialModel trait, as well as tuples, we must use BTreeMap.

Let's create a partial model of the Dog, where only the dog's name and chip ID are returned.

#[derive(Debug, DerivePartialModel, PartialEq)]
#[sea_orm(entity = "dog::Entity")]
pub struct DogIdentification {
    pub name: String,
    pub chip_id: String,
}

// The function to test
pub async fn get_dog(id: Uuid, conn: &DatabaseConnection
) -> Option<DogIdentification> {
    dog::Entity::find_by_id(id)
        .into_partial_model()
        .one(conn)
        .await
        .ok()
        .flatten()
}
Enter fullscreen mode Exit fullscreen mode

To mock the results of the database query, the connection should include BTreeMap with the selected fields from the partial model.

#[tokio::test]
async fn test_get_dog() -> Result<(), DbErr> {
    let dog_name = "Buddy".to_string();
    let chip_id = "chip_1".to_string();

    let conn = MockDatabase::new(DatabaseBackend::Sqlite)
        .append_query_results([[BTreeMap::from([
            ("name", Value::String(Some(dog_name.clone()))),
            ("chip_id", Value::String(Some(chip_id.clone()))),
        ])]])
        .into_connection();

    let dog_opt = get_dog(Uuid::default(), &conn).await;
    assert!(dog_opt.is_some());
    let dog = dog_opt.unwrap();
    let expected = DogIdentification {
        name: dog_name,
        chip_id,
    };
    assert_eq!(dog, expected);
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Nested partial models

If we want to get the partial model, that includes results of the left join, we need to add to the BTreeMap those fields with the table name appended as prefix. Let's modify the previous example:

#[derive(Debug, DerivePartialModel, PartialEq)]
#[sea_orm(entity = "owner::Entity")]
pub struct OwnerPartialModel { // <----- Owner's information
    pub name: String,
    pub surname: String,
}

#[derive(Debug, DerivePartialModel, PartialEq)]
#[sea_orm(entity = "dog::Entity")]
pub struct DogWithOwner {
    pub name: String,
    pub chip_id: String,
    #[sea_orm(nested)]    // <----- added nested field
    pub owner: OwnerPartialModel, 
}

// The function to test
pub async fn get_dog_with_owner(id: Uuid, conn: &DatabaseConnection
) -> Option<DogWithOwner> {
    dog::Entity::find_by_id(id)
        .left_join(owner::Entity)
        .into_partial_model()
        .one(conn)
        .await
        .ok()
        .flatten()
}


#[tokio::test]
async fn test_get_dog_with_owner() -> Result<(), DbErr> {
    let dog_name = "Buddy".to_string();
    let chip_id = "chip_1".to_string();
    let owner_name = "Jane".to_string();
    let owner_surname = "Doe".to_string();
    let conn = MockDatabase::new(DatabaseBackend::Sqlite)
        .append_query_results([[BTreeMap::from([
            ("name", Value::String(Some(dog_name.clone()))),
            ("chip_id", Value::String(Some(chip_id.clone()))),
            // Adding the owner's table name as prefix to the fields
            ("owner_name", Value::String(Some(owner_name.clone()))),
            ("owner_surname",
               Value::String(Some(owner_surname.clone()))),
        ])]])
        .into_connection();
    let dog_opt = get_dog_with_owner(Uuid::default(), &conn).await;
    assert!(dog_opt.is_some());
    let dog = dog_opt.unwrap();
    let expected = DogWithOwner {
        name: dog_name,
        chip_id,
        owner: OwnerPartialModel {
            name: owner_name,
            surname: owner_surname,
        },
    };
    assert_eq!(dog, expected);
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Delete cascade

This was, for me, the most confusing test case, because the order of models that should be appended to the query results matters! We need to pay close attention to the one-to-many relationship the Dog table has.

// Dog model
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Default)]
#[sea_orm(table_name = "dog")]
pub struct Model {
    // ....fields here

    // First, the procedure table
    #[sea_orm(has_many)]
    pub procedures: HasMany<crate::procedure::Entity>,
    // Next, the visits
    #[sea_orm(has_many)]
    pub visits: HasMany<crate::visit::Entity>,
    // At lastly, vaccinations
    #[sea_orm(has_many)]
    pub vaccinations: HasMany<crate::vaccination::Entity>,
}

// Procedures model has no further relationships

// Visit's model has three more
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "visit")]
pub struct Model {
    // ....fields here

    // First, the symptoms table
    #[sea_orm(has_many)]
    pub symptoms: HasMany<crate::symptom::Entity>,
    // And the prescription for the given visit table
    #[sea_orm(has_many)]
    pub visit_prescriptions: HasMany<crate::visit_prescription::Entity>,

// Vaccination model has no further relationships

// The function to test
pub async fn delete_dog(dog_id: Uuid, conn: &DatabaseConnection
) -> Result<(), DbErr> {
    if let Some(dog) = dog::Entity::find_by_id(dog_id).one(conn).await? {
        dog.cascade_delete(conn).await?;
    }
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Based on that definition, the order is:

  1. Mock getting dog model
  2. Mock getting procedure::Model -> Mock deletion
  3. Mock getting visit::Model, symptom::Model, and visit_prescriptions::Model in three different arrays -> Mock 3 separate deletions
  4. Mock getting vaccination::Model -> Mock deletion
  5. Finally, mock deleting the dog model!
#[tokio::test]
async fn test_cascade_delete_dog() -> Result<(), DbErr> {
    let id = Uuid::max();
    let time = datetime!(2026-04-08 00:00:01);
    let conn = MockDatabase::new(DatabaseBackend::Sqlite)
        // First, we find the model to delete
        .append_query_results([[dog::Model {
            id,
            name: "Jack".to_string(),
            owner_id: Default::default(),
            chip_id: "chip_1".to_string(),
        }]])
        // First one-to-many relationship in the `dog` table
        // is procedure, we must get it and then delete it
        .append_query_results([[procedure::Model {
            id,
            dog_id: Uuid::default(),
            procedure_description: "test procedure".to_string(),
            datetime: time.clone(),
        }]])
        .append_exec_results([MockExecResult::default()])
        // Next relationship in the `dog` table is visits, 
        // which in turn has few one-to-many relationships
        .append_query_results([
            // first, select the visit model
            [visit::Model {
                id,
                dog_id: Uuid::default(),
                diagnosis: "test diagnosis".to_string(),
                datetime: time.clone(),
            }
                .into_mock_row()],
            // then, the first 1-to-m relationship in the `visit` table
            [symptom::Model {
                id,
                visit_id: Uuid::new_v4(),
                symptom: "Test symptom".to_string(),
            }
                .into_mock_row()],
            // then, the next 1-to-m relationship in the `visit` table
            [visit_prescription::Model {
                id,
                visit_id: Uuid::new_v4(),
                prescription_id: Default::default(),
                datetime: time.clone(),
            }
                .into_mock_row()],
        ])
        // remove all three models
        .append_exec_results([MockExecResult::default()])
        .append_exec_results([MockExecResult::default()])
        .append_exec_results([MockExecResult::default()])
        // The last relationship in the `dog` table 
        // is vaccinations, get the model and delete it
        .append_query_results([[vaccination::Model {
            id,
            dog_id: Uuid::default(),
            vaccine_sku: "vaccine_000".to_string(),
            date: date!(2026-04-08).into(),
        }]])
        .append_exec_results([MockExecResult::default()])
        // Finally, mock deleting the `dog` model
        .append_exec_results([MockExecResult::default()])
        .into_connection();
    let result = delete_dog(Uuid::default(), &conn).await;
    assert!(result.is_ok());
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

You can find the code here: https://github.com/MilaKyr/sea-orm-test-examples.

What is your experience with SeaOrm and mocking database functions?
Do you prefer to use Traits and write custom mocking structures?

Cover photo by Illia Horokhovsky on Unsplash.

Top comments (0)