Rocket框架提供的測試架構十分簡單,只要產生出想要測試的Rocket實體,再將它提供給local模組下的Client結構體使用,Rocket應用程式就能搖身一變,可以直接用寫程式的方式來發送請求和接收回應。



在我們學習Rocket應用程式的測試之前,幾乎只會在main方法中呼叫rocket這個crate提供的ignite函數來建立Rocket實體。但從現在開始,我們要在有使用test屬性的函數中呼叫ignite函數了!

那我們需要把原先main方法中的整流片、應用程式狀態的實體、路由處理程序等也全都寫搬進測試函數中嗎?最好不要,因為加入這些東西不一定是我們接下來要跑的測試案例(test case)會用到的,全都加進去雖然不會對程式正確性有什麼影響,但鐵定會脫慢測試的執行速度!所以說,我們只要把有用到的組件註冊進這個拿來做測試用的Rocket實體即可,甚至我們也可以把要加進去測試的東西做一些適當的改變(例如設定檔的修改)。

舉例來說,修改一下上一章存取MySQL資料庫的例子,現在有以下的Rocket程式:

#[macro_use]
extern crate rocket;

use rocket::fairing::AdHoc;
use rocket_db_pools::{
    sqlx::{self, Row},
    Connection, Database,
};

#[derive(Database)]
#[database("example")]
struct MyDatabase(sqlx::MySqlPool);

#[put("/<key>/<value>")]
async fn put(mut db: Connection<MyDatabase>, key: String, value: String) -> &'static str {
    sqlx::query("INSERT INTO `example` (`key`, `value`) VALUES (?, ?)")
        .bind(key)
        .bind(value)
        .execute(&mut **db)
        .await
        .unwrap();

    "ok"
}

#[get("/<key>")]
async fn get(mut db: Connection<MyDatabase>, key: String) -> Option<String> {
    let result = sqlx::query("SELECT `value` FROM `example` WHERE `key` = ?")
        .bind(key)
        .fetch_optional(&mut **db)
        .await
        .unwrap();

    result.map(|row| row.get("value"))
}

#[get("/hello")]
fn hello() -> &'static str {
    "Hello World!"
}

#[launch]
fn rocket() -> _ {
    rocket::build()
        .attach(MyDatabase::init())
        .attach(AdHoc::try_on_ignite("MySQL Initializer", |rocket| {
            Box::pin(async move {
                let mut connection = match MyDatabase::fetch(&rocket) {
                    Some(pool) => match pool.acquire().await {
                        Ok(connection) => connection,
                        Err(_) => return Err(rocket),
                    },
                    None => return Err(rocket),
                };

                let result = sqlx::query(
                    "CREATE TABLE IF NOT EXISTS `example` ( `id` INT NOT NULL AUTO_INCREMENT, \
                     `key` varchar(100), `value` TEXT , PRIMARY KEY (`id`), UNIQUE KEY (`key`) )",
                )
                .execute(&mut *connection)
                .await;

                match result {
                    Ok(_) => Ok(rocket),
                    Err(_) => Err(rocket),
                }
            })
        }))
        .mount("/", routes![put, get])
        .mount("/", routes![hello])
}

資料庫的部份比較複雜一點,稍候再來說明,先來談談怎麼測試hello這個路由處理程序吧!首先在main.rs檔案中加入如下的程式碼:

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

    #[rocket::async_test]
    async fn test_hello() {
        let app = rocket::build().mount("/", routes![hello]);
    }
}

以上程式中,我們使用了#[rocket::async_test]屬性來建立我們的非同步測試程式。在test_hello函數中我們打算要測試hello這個路由處理程序的正確性,因此我們的Rocket實體只需要將hello路由處理程序註冊進來就好了,其它不相干的東西不用加進來。

再來就是建立Client結構實體。Rocket提供了同步版(local::blocking)和非同步版(local::asynchronous)的Client結構實體,建議使用非同步版的Client結構實體,以達最大相容性。

建立Client結構實體的程式如下:

#[cfg(test)]
mod test {
    use rocket::local::asynchronous::Client;

    use super::*;

    #[rocket::async_test]
    async fn test_hello() {
        let app = rocket::build().mount("/", routes![hello]);

        let client = Client::tracked(app).await.expect("valid rocket instance");
    }
}

在使用Client結構實體的tracked關聯函數時,Rocket實體就把Rocket啟動之前的步驟都走完,因此有可能會因為設定檔錯誤、整流片錯誤、路由處理程序的衝突等而導致回傳Result列舉的Err變體。

這個Client結構實體可以當作是一個小型的HTTP客戶端,除了有發送請求和接收回應等基本功能外,也會記錄Cookie的狀態。

如果要用Client結構實體來產生GET /hello請求,程式如下:

let req = client.get("/hello");

這個req變數所儲存的值為LocalRequest結構體的實體,我們可以利用這個結構實體來調整等等要送出的HTTP請求的內容。不過,在這個測試案例中,我們直接使用dispatch方法讓它發送請求。這邊雖然是說發送請求,但不會真的透過外部網路或是內部網路來傳遞HTTP訊息啦!所以速度是非常快的,不必擔心。

let mut res = req.dispatch().await;

LocalRequest結構實體的dispatch方法會直接將HTTP回應回傳出來。這個res變數所儲存的值為LocalResponse結構體的實體。將變數設為可變的是因為等等我們要從HTTP主體中拿出資料。

再來我們可以檢查回應的HTTP狀態螞以及HTTP主體是否分別是我們預期的http::Status::OkHello World!。完整的測試程式碼如下:

#[cfg(test)]
mod test {
    use rocket::{http::Status, local::asynchronous::Client};

    use super::*;

    #[rocket::async_test]
    async fn test_hello() {
        let app = rocket::build().mount("/", routes![hello]);

        let client = Client::tracked(app).await.expect("valid rocket instance");

        let req = client.get("/hello");

        let res = req.dispatch().await;

        assert_eq!(Status::Ok, res.status());
        assert_eq!("Hello World!", res.into_string().await.expect("valid string"));
    }
}

如此一來這個測試案例就完成啦!

接下來要來測試資料庫的路由處理程序,原則上我們會比較希望在測試時是使用與開發時不同的資料庫,避免測試失敗時在開發用的資料庫留下很多莫名其妙的資料。而且也可以在測試開始進行時,事先將資料庫中的內容清除掉。為了使Rocket框架吃不同的設定,在這第二個測試案例中,我們會在Rocket.toml中加入名為test的Profile設定區塊,並在測試程式中寫程式來控制Rocket套用test這個Profile。當然我們也是可以直接把所有設定寫在測試的程式碼中。

Rocket.toml中添加以下設定:

[test.databases.example]
url = "mysql://magic_rocket:4M{}^;dkn7Eg@localhost:3306/magic_rocket_test"

接著先修改我們原先的程式,讓MySQL Initializer整流片的建立獨立成一個函數,方便在測試程式中重用。如下:

#[macro_use]
extern crate rocket;

use rocket::fairing::AdHoc;
use rocket_db_pools::{
    sqlx::{self, Row},
    Connection, Database,
};

#[derive(Database)]
#[database("example")]
struct MyDatabase(sqlx::MySqlPool);

#[put("/<key>/<value>")]
async fn put(mut db: Connection<MyDatabase>, key: String, value: String) -> &'static str {
    sqlx::query("INSERT INTO `example` (`key`, `value`) VALUES (?, ?)")
        .bind(key)
        .bind(value)
        .execute(&mut **db)
        .await
        .unwrap();

    "ok"
}

#[get("/<key>")]
async fn get(mut db: Connection<MyDatabase>, key: String) -> Option<String> {
    let result = sqlx::query("SELECT `value` FROM `example` WHERE `key` = ?")
        .bind(key)
        .fetch_optional(&mut **db)
        .await
        .unwrap();

    result.map(|row| row.get("value"))
}

#[get("/hello")]
fn hello() -> &'static str {
    "Hello World!"
}

fn create_mysql_initializer(drop_table: bool) -> AdHoc {
    AdHoc::try_on_ignite("MySQL Initializer", move |rocket| {
        Box::pin(async move {
            let mut connection = match MyDatabase::fetch(&rocket) {
                Some(pool) => match pool.acquire().await {
                    Ok(connection) => connection,
                    Err(_) => return Err(rocket),
                },
                None => return Err(rocket),
            };

            if drop_table
                && sqlx::query("DROP TABLE IF EXISTS `example`")
                    .execute(&mut *connection)
                    .await
                    .is_err()
            {
                return Err(rocket);
            }

            let result = sqlx::query(
                "CREATE TABLE IF NOT EXISTS `example` ( `id` INT NOT NULL AUTO_INCREMENT, `key` \
                 varchar(100), `value` TEXT , PRIMARY KEY (`id`), UNIQUE KEY (`key`) )",
            )
            .execute(&mut *connection)
            .await;

            match result {
                Ok(_) => Ok(rocket),
                Err(_) => Err(rocket),
            }
        })
    })
}

#[launch]
fn rocket() -> _ {
    rocket::build()
        .attach(MyDatabase::init())
        .attach(create_mysql_initializer(false))
        .mount("/", routes![put, get])
        .mount("/", routes![hello])
}

// ...

然後在測試程式中撰寫產生Rocket設定的函數,方便不同測試案例重複使用。如下:

#[cfg(test)]
mod test {
    use std::env;

    use rocket::{config::Config, figment::Figment, http::Status, local::asynchronous::Client};

    use super::*;

    fn create_config() -> Figment {
        env::set_var("ROCKET_PROFILE", "test");

        Config::figment()
    }

    #[rocket::async_test]
    async fn test_hello() {
        let app = rocket::custom(create_config()).mount("/", routes![hello]);

        let client = Client::tracked(app).await.expect("valid rocket instance");

        let req = client.get("/hello");

        let res = req.dispatch().await;

        assert_eq!(Status::Ok, res.status());

        assert_eq!("Hello World!", res.into_string().await.expect("valid string"));
    }
}

接著就是撰寫第二個測試案例了!如下:

#[cfg(test)]
mod test {
    use std::env;

    use rocket::{config::Config, figment::Figment, http::Status, local::asynchronous::Client};

    use super::*;

    fn create_config() -> Figment {
        env::set_var("ROCKET_PROFILE", "test");

        Config::figment()
    }

    #[rocket::async_test]
    async fn test_hello() {
        let app = rocket::custom(create_config()).mount("/", routes![hello]);

        let client = Client::tracked(app).await.expect("valid rocket instance");

        let req = client.get("/hello");

        let res = req.dispatch().await;

        assert_eq!(Status::Ok, res.status());

        assert_eq!("Hello World!", res.into_string().await.expect("valid string"));
    }

    #[rocket::async_test]
    async fn test_database_get_put() {
        let app = rocket::custom(create_config())
            .attach(MyDatabase::init())
            .attach(create_mysql_initializer(true))
            .mount("/", routes![put, get]);

        let client = Client::tracked(app).await.expect("valid rocket instance");

        {
            let req = client.put("/hello/world");

            let res = req.dispatch().await;

            assert_eq!(Status::Ok, res.status());
            assert_eq!("ok", res.into_string().await.expect("valid string"));
        }

        {
            let req = client.get("/hello");

            let res = req.dispatch().await;

            assert_eq!(Status::Ok, res.status());
            assert_eq!("world", res.into_string().await.expect("valid string"));
        }
    }
}

如此一來這個測試案例也完成啦!如果還想要再撰寫其它資料庫的測試案例,因為Cargo專案在進行測試的時候是多執行緒來同時執行不同測試的,所以不同測試的結果有可能會互相干擾。為了避免這個問題,可以使用一個全域的互斥鎖,參考這篇文章來了解一下Rust全域靜態變數的使用方式。把互斥鎖用在資料庫的測試中並不能說是一個很有效率的作法,不過寫起來還挺方便的。

於是最後,完整的Rocket程式含測試的程式碼如下:

#[macro_use]
extern crate rocket;

use rocket::fairing::AdHoc;
use rocket_db_pools::{
    sqlx::{self, Row},
    Connection, Database,
};

#[derive(Database)]
#[database("example")]
struct MyDatabase(sqlx::MySqlPool);

#[put("/<key>/<value>")]
async fn put(mut db: Connection<MyDatabase>, key: String, value: String) -> &'static str {
    sqlx::query("INSERT INTO `example` (`key`, `value`) VALUES (?, ?)")
        .bind(key)
        .bind(value)
        .execute(&mut **db)
        .await
        .unwrap();

    "ok"
}

#[get("/<key>")]
async fn get(mut db: Connection<MyDatabase>, key: String) -> Option<String> {
    let result = sqlx::query("SELECT `value` FROM `example` WHERE `key` = ?")
        .bind(key)
        .fetch_optional(&mut **db)
        .await
        .unwrap();

    result.map(|row| row.get("value"))
}

#[get("/hello")]
fn hello() -> &'static str {
    "Hello World!"
}

fn create_mysql_initializer(drop_table: bool) -> AdHoc {
    AdHoc::try_on_ignite("MySQL Initializer", move |rocket| {
        Box::pin(async move {
            let mut connection = match MyDatabase::fetch(&rocket) {
                Some(pool) => match pool.acquire().await {
                    Ok(connection) => connection,
                    Err(_) => return Err(rocket),
                },
                None => return Err(rocket),
            };

            if drop_table
                && sqlx::query("DROP TABLE IF EXISTS `example`")
                    .execute(&mut *connection)
                    .await
                    .is_err()
            {
                return Err(rocket);
            }

            let result = sqlx::query(
                "CREATE TABLE IF NOT EXISTS `example` ( `id` INT NOT NULL AUTO_INCREMENT, `key` \
                 varchar(100), `value` TEXT , PRIMARY KEY (`id`), UNIQUE KEY (`key`) )",
            )
            .execute(&mut *connection)
            .await;

            match result {
                Ok(_) => Ok(rocket),
                Err(_) => Err(rocket),
            }
        })
    })
}

#[launch]
fn rocket() -> _ {
    rocket::build()
        .attach(MyDatabase::init())
        .attach(create_mysql_initializer(false))
        .mount("/", routes![put, get])
        .mount("/", routes![hello])
}

#[cfg(test)]
mod test {
    use std::{env, sync::Mutex};

    use rocket::{config::Config, figment::Figment, http::Status, local::asynchronous::Client};

    use super::*;

    static LOCK: Mutex<()> = Mutex::new(());

    fn create_config() -> Figment {
        env::set_var("ROCKET_PROFILE", "test");

        Config::figment()
    }

    #[rocket::async_test]
    async fn test_hello() {
        let app = rocket::custom(create_config()).mount("/", routes![hello]);

        let client = Client::tracked(app).await.expect("valid rocket instance");

        let req = client.get("/hello");

        let res = req.dispatch().await;

        assert_eq!(Status::Ok, res.status());

        assert_eq!("Hello World!", res.into_string().await.expect("valid string"));
    }

    #[allow(clippy::await_holding_lock)]
    #[rocket::async_test]
    async fn test_database_get_put_1() {
        let _lock = LOCK.lock().unwrap();

        let app = rocket::custom(create_config())
            .attach(MyDatabase::init())
            .attach(create_mysql_initializer(true))
            .mount("/", routes![put, get]);

        let client = Client::tracked(app).await.expect("valid rocket instance");

        {
            let req = client.put("/hello/world");

            let res = req.dispatch().await;

            assert_eq!(Status::Ok, res.status());
            assert_eq!("ok", res.into_string().await.expect("valid string"));
        }

        {
            let req = client.get("/hello");

            let res = req.dispatch().await;

            assert_eq!(Status::Ok, res.status());
            assert_eq!("world", res.into_string().await.expect("valid string"));
        }
    }

    #[allow(clippy::await_holding_lock)]
    #[rocket::async_test]
    async fn test_database_get_put_2() {
        let _lock = LOCK.lock().unwrap();

        let app = rocket::custom(create_config())
            .attach(MyDatabase::init())
            .attach(create_mysql_initializer(true))
            .mount("/", routes![get]);

        let client = Client::tracked(app).await.expect("valid rocket instance");

        {
            let req = client.put("/hello/world");

            let res = req.dispatch().await;

            assert_eq!(Status::NotFound, res.status());
        }
    }
}

總結

從以上最後寫出來的完整程式中可以發現,我們寫的測試程式比有實質功能的主程式還要多!一個好的Web應用程式就是必須要撰寫很多測試來驗證各種不同的商業邏輯,確保當下以及日後改版過的程式依然能夠正確運行!還好Rocket提供的測試架構還算好寫,不需額外看起來稀奇古怪的設定值與設定檔,只要有Rocket實體,就可以產生出Client結構實體來進行測試。

這個系列的文章就到這裡為止了,Rocket真的是一個既優雅方便、容易維護、效能也不錯的Web框架!