Rocket框架提供的測試架構十分簡單,只要產生出想要測試的Rocket
實體,再將它提供給local
模組下的Client
結構體使用,Rocket應用程式就能搖身一變,可以直接用寫程式的方式來發送請求和接收回應。
在我們學習Rocket應用程式的測試之前,幾乎只會在main
方法中呼叫rocket
這個crate提供的ignite
函數來建立Rocket
實體。但從現在開始,我們要在有使用test
屬性的函數中呼叫ignite
函數了!
那我們需要把原先main
方法中的整流片、應用程式狀態的實體、路由處理程序等也全都寫搬進測試函數中嗎?最好不要,因為加入這些東西不一定是我們接下來要跑的測試案例(test case)會用到的,全都加進去雖然不會對程式正確性有什麼影響,但鐵定會脫慢測試的執行速度!所以說,我們只要把有用到的組件註冊進這個拿來做測試用的Rocket
實體即可,甚至我們也可以把要加進去測試的東西做一些適當的改變(例如設定檔的修改)。
舉例來說,修改一下上一章存取MySQL資料庫的例子,現在有以下的Rocket程式:
#![feature(proc_macro_hygiene, decl_macro)]
#[macro_use]
extern crate rocket;
#[macro_use]
extern crate rocket_contrib;
use rocket::fairing::AdHoc;
use rocket_contrib::databases::mysql::Conn;
#[database("example")]
struct MyDatabase(Conn);
#[put("/data/<key>/<value>")]
fn put(mut db: MyDatabase, key: String, value: String) -> &'static str {
db.prep_exec(r"INSERT INTO `example` (`key`, `value`) VALUES (?, ?)", (key, value)).unwrap();
"ok"
}
#[get("/data/<key>")]
fn get(mut db: MyDatabase, key: String) -> Option<String> {
let db: &mut Conn = &mut *db;
let mut result = db.prep_exec(r"SELECT `value` FROM `example` WHERE `key` = ?", (key, )).unwrap();
match result.next() {
Some(row) => {
Some(row.unwrap().take("value").unwrap())
}
None => {
None
}
}
}
#[get("/hello")]
fn hello() -> &'static str {
"Hello World!"
}
fn main() {
rocket::ignite()
.attach(MyDatabase::fairing())
.attach(AdHoc::on_attach("MySQL Initializer", |rocket| {
let mut db: MyDatabase = MyDatabase::get_one(&rocket).unwrap();
db.query(r"CREATE TABLE IF NOT EXISTS `example` ( `id` INT NOT NULL AUTO_INCREMENT, `key` varchar(100), `value` TEXT , PRIMARY KEY (`id`), UNIQUE KEY (`key`) )").unwrap();
Ok(rocket)
}))
.mount("/", routes![put, get])
.mount("/", routes![hello])
.launch();
}
資料庫的部份比較複雜一點,稍候再來說明,先來談談怎麼測試hello
這個路由處理程序吧!
從建立Rocket
實體開始,程式如下:
let rocket = rocket::ignite().mount("/", routes![hello]);
由於hello
這個路由處理程序並沒有用到其它的應用程式狀態,因此我們的Rocket
實體只需要將hello
路由處理程序註冊進來就好了。
再來就是建立local::Client
結構實體,程式如下:
use rocket::local::Client;
...
let client = Client::new(rocket).expect("valid rocket instance");
在使用local::Client
結構實體的new
關聯函數時,Rocket
實體就把Rocket啟動之前的步驟都走完,因此有可能會因為設定檔錯誤、整流片錯誤、路由處理程序的衝突等而導致回傳Result
列舉的Err
變體。
這個local::Client
結構實體可以當作是一個小型的HTTP客戶端,除了有發送請求和接收回應等基本功能外,也會記錄Cookie的狀態。
如果要用local::Client
結構實體來產生GET /hello
請求,程式如下:
let req = client.get("/hello");
這個req
變數所儲存的值為local::LocalRequest
結構體的實體,我們可以利用這個結構實體來調整等等要送出的HTTP請求的內容。不過,在這個測試案例中,我們直接使用dispatch
方法讓它發送請求。這邊雖然是說發送請求,但不會真的透過外部網路或是內部網路來傳遞HTTP訊息啦!所以速度是非常快的,不必擔心。
let mut res = req.dispatch();
local::LocalRequest
結構實體的dispatch
方法會直接將HTTP回應回傳出來。這個res
變數所儲存的值為local::LocalResponse
結構體的實體。將變數設為可變的是因為等等我們要從HTTP主體中拿出資料。
再來我們可以檢查回應的HTTP狀態螞以及HTTP主體是否分別是我們預期的http::Status::Ok
與Hello World!
。
use rocket::http::Status;
...
assert_eq!(Status::Ok, res.status());
如此一來這個測試案例就完成啦!
接下來要來測試資料庫的路由處理程序,原則上我們會比較希望在測試時是使用與開發時不同的資料庫,避免測試失敗時在開發用的資料庫留下很多莫名其妙的資料。而且也可以在測試開始進行時,事先將資料庫中的內容清除掉。為了使Rocket框架吃不同的設定,在這第二個測試案例中,我們就寫程式來設定Rocket吧!
use std::collections::HashMap;
use rocket::{Config, config::Environment};
const MYSQL_TEST_USERNAME: &'static str = "magic_rocket";
const MYSQL_TEST_PASSWORD: &'static str = "4M{}^;dkn7Eg";
const MYSQL_TEST_DATABASE: &'static str = "magic_rocket_test";
fn create_config() -> Config {
Config::build(Environment::Development).extra("databases", {
let mut map = HashMap::with_capacity(1);
let mut map_config = HashMap::with_capacity(1);
map_config.insert("url", format!("mysql://{}:{}@localhost:3306/{}", MYSQL_TEST_USERNAME, MYSQL_TEST_PASSWORD, MYSQL_TEST_DATABASE));
map.insert("example", map_config);
map
}).unwrap()
}
fn create_mysql_initializer() -> AdHoc {
AdHoc::on_attach("MySQL Initializer", |rocket| {
let mut db: MyDatabase = MyDatabase::get_one(&rocket).unwrap();
db.query(r"DROP TABLE IF EXISTS `example`;").unwrap();
db.query(r"CREATE TABLE `example` ( `id` INT NOT NULL AUTO_INCREMENT, `key` varchar(100), `value` TEXT , PRIMARY KEY (`id`), UNIQUE KEY (`key`) )").unwrap();
Ok(rocket)
})
}
...
let rocket = rocket::custom(create_config())
.attach(MyDatabase::fairing())
.attach(create_mysql_initializer())
.mount("/", routes![put, get]);
因為用來測試資料庫的Config
結構實體和初始化資料庫的整流片在每個測試案例中都需要重新建立,因此將它們寫成了獨立的函數,方便重用。
接著就是產生local::Client
結構實體來進行測試了!
let client = Client::new(rocket).unwrap();
{
let req = client.put("/data/hello/world");
let mut res = req.dispatch();
assert_eq!(Status::Ok, res.status());
assert_eq!("ok".to_string(), res.body_string().unwrap());
}
{
let req = client.get("/data/hello");
let mut res = req.dispatch();
assert_eq!(Status::Ok, res.status());
assert_eq!("world".to_string(), res.body_string().unwrap());
}
如此一來這個測試案例也完成啦!如果還想要再撰寫其它資料庫的測試案例,因為Cargo專案在進行測試的時候是多執行緒來同時執行不同測試的,所以不同測試的結果有可能會互相干擾。為了避免這個問題,可以使用一個全域的互斥鎖,參考這篇文章來了解一下Rust全域靜態變數的使用方式。把互斥鎖用在資料庫的測試中並不能說是一個很有效率的作法,不過寫起來還挺方便的。
於是最後,完整的Rocket程式含測試的程式碼如下:
#![feature(proc_macro_hygiene, decl_macro)]
#[macro_use]
extern crate rocket;
#[macro_use]
extern crate rocket_contrib;
#[cfg(test)]
#[macro_use]
extern crate lazy_static;
use rocket::fairing::AdHoc;
use rocket_contrib::databases::mysql::Conn;
#[database("example")]
struct MyDatabase(Conn);
#[put("/data/<key>/<value>")]
fn put(mut db: MyDatabase, key: String, value: String) -> &'static str {
db.prep_exec(r"INSERT INTO `example` (`key`, `value`) VALUES (?, ?)", (key, value)).unwrap();
"ok"
}
#[get("/data/<key>")]
fn get(mut db: MyDatabase, key: String) -> Option<String> {
let db: &mut Conn = &mut *db;
let mut result = db.prep_exec(r"SELECT `value` FROM `example` WHERE `key` = ?", (key, )).unwrap();
match result.next() {
Some(row) => {
Some(row.unwrap().take("value").unwrap())
}
None => {
None
}
}
}
#[get("/hello")]
fn hello() -> &'static str {
"Hello World!"
}
fn main() {
rocket::ignite()
.attach(MyDatabase::fairing())
.attach(AdHoc::on_attach("MySQL Initializer", |rocket| {
let mut db: MyDatabase = MyDatabase::get_one(&rocket).unwrap();
db.query(r"CREATE TABLE IF NOT EXISTS `example` ( `id` INT NOT NULL AUTO_INCREMENT, `key` varchar(100), `value` TEXT , PRIMARY KEY (`id`), UNIQUE KEY (`key`) )").unwrap();
Ok(rocket)
}))
.mount("/", routes![put, get])
.mount("/", routes![hello])
.launch();
}
#[cfg(test)]
mod test {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
use rocket::local::Client;
use rocket::http::Status;
use rocket::{Config, config::Environment};
const MYSQL_TEST_USERNAME: &'static str = "magic_rocket";
const MYSQL_TEST_PASSWORD: &'static str = "4M{}^;dkn7Eg";
const MYSQL_TEST_DATABASE: &'static str = "magic_rocket_test";
lazy_static! {
static ref LOCK: Mutex<()> = Mutex::new(());
}
#[test]
fn test_hello() {
let rocket = rocket::ignite().mount("/", routes![hello]);
let client = Client::new(rocket).unwrap();
let req = client.get("/hello");
let mut res = req.dispatch();
assert_eq!(Status::Ok, res.status());
assert_eq!("Hello World!".to_string(), res.body_string().unwrap());
}
fn create_config() -> Config {
Config::build(Environment::Development).extra("databases", {
let mut map = HashMap::with_capacity(1);
let mut map_config = HashMap::with_capacity(1);
map_config.insert("url", format!("mysql://{}:{}@localhost:3306/{}", MYSQL_TEST_USERNAME, MYSQL_TEST_PASSWORD, MYSQL_TEST_DATABASE));
map.insert("example", map_config);
map
}).unwrap()
}
fn create_mysql_initializer() -> AdHoc {
AdHoc::on_attach("MySQL Initializer", |rocket| {
let mut db: MyDatabase = MyDatabase::get_one(&rocket).unwrap();
db.query(r"DROP TABLE IF EXISTS `example`;").unwrap();
db.query(r"CREATE TABLE `example` ( `id` INT NOT NULL AUTO_INCREMENT, `key` varchar(100), `value` TEXT , PRIMARY KEY (`id`), UNIQUE KEY (`key`) )").unwrap();
Ok(rocket)
})
}
#[test]
fn test_database_get_put_1() {
let lock = LOCK.lock().unwrap();
let rocket = rocket::custom(create_config())
.attach(MyDatabase::fairing())
.attach(create_mysql_initializer())
.mount("/", routes![put, get]);
let client = Client::new(rocket).unwrap();
{
let req = client.put("/data/hello/world");
let mut res = req.dispatch();
assert_eq!(Status::Ok, res.status());
assert_eq!("ok".to_string(), res.body_string().unwrap());
}
{
let req = client.get("/data/hello");
let mut res = req.dispatch();
assert_eq!(Status::Ok, res.status());
assert_eq!("world".to_string(), res.body_string().unwrap());
}
drop(lock);
}
#[test]
fn test_database_get_put_2() {
let lock = LOCK.lock().unwrap();
let rocket = rocket::custom(create_config())
.attach(MyDatabase::fairing())
.attach(create_mysql_initializer())
.mount("/", routes![get]);
let client = Client::new(rocket).unwrap();
{
let req = client.get("/data/hello");
let res = req.dispatch();
assert_eq!(Status::NotFound, res.status());
}
drop(lock);
}
}
執行以下Cargo指令,就可以開始進行測試啦!
總結
從以上最後寫出來的完整程式中可以發現,我們寫的測試程式比有實質功能的主程式還要多!一個好的Web應用程式就是必須要撰寫很多測試來驗證各種不同的商業邏輯,確保當下以及日後改版過的程式依然能夠正確運行!還好Rocket提供的測試架構還算好寫,不需額外看起來稀奇古怪的設定值與設定檔,只要有Rocket
實體,就可以產生出local::Client
結構實體來進行測試。
這個系列的文章就到這裡為止了,Rocket真的是一個既優雅方便、容易維護、效能也不錯的Web框架。只不過有幾個項目是筆者認為的Rocket明顯不足的地方,列表如下,方便以後追蹤:
- 疑似因為還在用舊版的hyper導致HTTP的keep-alive機制的相容性不高。
- 無
multipart/form-data
支援。(0.5版本之後應該會有) - 請求守衛可以在不複製原始HTTP請求的標頭資料下,將通過解析的部份回傳出來使用;然而當請求守衛支援請求狀態(request-local cache)時,就必須把資料從HTTP請求中複製出來。(Rust程式語言效能好的原因有一部份在於它可以避免掉很多複製記憶體資料的步驟,而請求狀態把這個優勢扼殺掉了。)
- 無完整的CORS(Cross-Origin Resource Sharing)支援。(0.5版本之後應該會有)
- 無法安全關閉。管理員應該要能發送關閉通知給Rocket應用程式,使其不再接受新的請求,並且在所有的路由處理程序都執行結束後才關閉程式。(0.7版本之後應該會有)
- 自訂的Log難以融入Rocket框架。(目前大部份應該都還是用
println
巨集或是log這個crate提供的巨集來輸出Log)