Покрываем REST-сервис на Rust тестами
Написанный нами ранее REST-сервис на Rust в общем и целом работает. Однако в реальных проектах недостаточно просто написать код. Необходимо добавить тесты, а также удостовериться в высокой степени покрытия кода тестами. Чем мы сегодня и займемся.
Может показаться, что в тестировании проектов на Tokio нет ничего сложного. Создаем файл tests/foobar.rs, и пишем в нем что-то вроде:
#[tokio::test]
async fn test_something() {
/* ... код теста ... */
assert_eq!(result, expected_result)
}
Зависимости тестов указываем в Cargo.toml в секции [dev-dependencies]. Запускаем командой cargo test.
Однако это хорошо работает лишь для модульных тестов. Наш REST-сервис очень простой, и модульными тестами покрывать в нем нечего. Нужны интеграционные тесты. На предложенный фреймворк они ложатся не так удачно.
Причин несколько. Объявляемые тесты считаются независимыми. Они могут выполняться в произвольном порядке и вообще параллельно. Глобальных setup и teardown на набор тестов, как это сделано в PyTest, не предусмотрено. Получается, что setup / teardown нужно выполнять в каждом отдельном тесте. Для интеграционных тестов это приводит к большим накладным расходам.
Что ж, будем работать с тем, что есть, и попробуем как-нибудь выкрутиться. После некоторых экспериментов я пришел к такому решению:
struct ServerGuard { child: Child }
impl Drop for ServerGuard {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
async fn setup(client: &reqwest::Client) -> ServerGuard {
let binary = env!("CARGO_BIN_EXE_phonebook");
let server = ServerGuard { child: Command::new(binary).spawn().unwrap() };
for _ in 0..10 {
if client.get("http://127.0.0.1:8080/api/v1/records/0")
.send().await.is_ok() {
return server;
}
sleep(Duration::from_millis(100)).await;
}
panic!("Server did not become ready in time")
}
async fn shutdown(mut server: ServerGuard, client: &reqwest::Client) {
client.post("http://127.0.0.1:8080/api/v1/commands/shutdown")
.send().await.unwrap();
server.child.wait().unwrap();
}
/* ... здесь код самих тестов ... */
async fn run(name: &str, f: impl Future<Output = ()>) {
print!("test {name}...");
f.await;
println!(" ok")
}
#[tokio::test]
async fn test_all() {
let client = reqwest::Client::new();
let server = setup(&client).await;
run("create_record", test_create_record(&client)).await;
run("read_record", test_read_record(&client)).await;
run("update_record", test_update_record(&client)).await;
run("delete_record", test_delete_record(&client)).await;
shutdown(server, &client).await
}
В качестве HTTP-клиента был использован Reqwest. Примеры посылки GET- и POST-запросов – перед вашими глазами. Это довольно скучный крейт.
Функция setup() запускает сервис в дочернем процессе и дожидается готовности сервиса, после чего возвращает хэндлер процесса, обернутый в ServerGuard. Последний реализует трейт Drop. Таким образом, дочерний процесс будет завершен даже в том случае, если один из тестов запаникует. Функция shutdown() останавливает сервис штатным образом. Это необходимо, чтобы процесс записал на диск всю информацию о покрытии кода тестами. Функция run() нужна для того, чтобы по выводу в stdout было ясно, какой из тестов упал.
Сами же тесты выглядят как-то так:
/* Тест на обновление существующей записи */
async fn test_update_record(client: &reqwest::Client) {
let response = client
.put("http://127.0.0.1:8080/api/v1/records/1")
.json(&json!({"name": "Bob", "phone": "456"}))
.send().await.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::OK);
let response = client
.get("http://127.0.0.1:8080/api/v1/records/1")
.send().await.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::OK);
let body: Value = response.json().await.unwrap();
assert_eq!(body, json!({"id": 1, "name": "Bob", "phone": "456"}))
}
Для более лучшего покрытия также имеются проверки, например, что обновление несуществующей записи возвращает 404.
Код сервиса остался почти без изменений, если не считать добавленного кода штатной остановки:
static SHUTDOWN: Notify = Notify::const_new();
async fn shutdown() -> impl IntoResponse {
SHUTDOWN.notify_one();
StatusCode::OK
}
#[tokio::main]
async fn main() {
/* ... */
let app = Router::new()
/* ... */
.route("/api/v1/commands/shutdown", post(shutdown))
.with_state(phonebook);
/* ... */
axum::serve(listener, app)
.with_graceful_shutdown(async { SHUTDOWN.notified().await })
.await.unwrap();
}
Проверим, все ли тесты проходят:
$ cargo test --test integration_test -- --nocapture
running 1 test
Listening on 127.0.0.1:8080
test create_record... ok
test read_record... ok
test update_record... ok
test delete_record... ok
test test_all ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Для определения покрытия кода тестами устанавливаем дополнительные компоненты:
$ rustup component add llvm-tools
$ cargo install cargo-llvm-cov
... после чего говорим:
$ cargo llvm-cov --test integration_test --html --open -- --nocapture
Как по мне, получилось вполне симпатично. Полную версию исходников вы найдете в этом архиве.