← На главную

Покрываем 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

Как по мне, получилось вполне симпатично. Полную версию исходников вы найдете в этом архиве.