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::Ok
與Hello 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框架!