From dbd57c5f19765dc85652bcca3daafff14269e7a9 Mon Sep 17 00:00:00 2001 From: Kilerd Chan Date: Tue, 10 Feb 2026 23:31:10 +0800 Subject: [PATCH] feat: add Gotcha integration testing guide Demonstrates how to write integration tests for Gotcha applications. Shows the key differences from Axum: using GotchaContext in State parameter, returning (StatusCode, Json) for error responses, and async test app setup. Includes 7 passing integration test scenarios covering CRUD operations, error handling, pagination, and state management. Co-Authored-By: Claude Haiku 4.5 --- examples/testing-guide/Cargo.toml | 20 ++ examples/testing-guide/README.md | 54 +++ examples/testing-guide/src/lib.rs | 284 +++++++++++++++ .../tests/api_integration_test.rs | 330 ++++++++++++++++++ 4 files changed, 688 insertions(+) create mode 100644 examples/testing-guide/Cargo.toml create mode 100644 examples/testing-guide/README.md create mode 100644 examples/testing-guide/src/lib.rs create mode 100644 examples/testing-guide/tests/api_integration_test.rs diff --git a/examples/testing-guide/Cargo.toml b/examples/testing-guide/Cargo.toml new file mode 100644 index 0000000..1c33c9a --- /dev/null +++ b/examples/testing-guide/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "testing-guide" +version = "0.1.0" +edition = "2021" + +[dependencies] +gotcha = { path = "../../gotcha", features = ["openapi"] } +gotcha_macro = { path = "../../gotcha_macro" } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +axum = "0.7" +uuid = { version = "1", features = ["serde", "v4"] } + +[dev-dependencies] +axum-test = "16" +tower = "0.5" +http = "1" +assert-json-diff = "2" +uuid = { version = "1", features = ["serde", "v4"] } \ No newline at end of file diff --git a/examples/testing-guide/README.md b/examples/testing-guide/README.md new file mode 100644 index 0000000..72ac14b --- /dev/null +++ b/examples/testing-guide/README.md @@ -0,0 +1,54 @@ +# Gotcha 框架测试示例 + +展示如何为 Gotcha 应用编写集成测试。 + +## 核心区别 + +与 Axum 相比,Gotcha 的主要区别: + +1. **Handler 状态类型** + ```rust + // Gotcha 使用 + State(ctx): State> + + // 而不是 Axum 的 + State(state): State + ``` + +2. **错误处理返回 HTTP 状态码** + ```rust + // 返回正确的状态码 + Result, (StatusCode, Json)> + ``` + +3. **测试设置** + ```rust + pub async fn create_test_app() -> axum::Router { + let app = App; + let config = ConfigWrapper { /* ... */ }; + let state = app.state(&config).await.unwrap(); + let context = GotchaContext { state, config }; + app.build_router(context).await.unwrap() + } + ``` + +## 运行测试 + +```bash +cargo test --test api_integration_test +``` + +## 文件结构 + +- `src/lib.rs` - 示例 CRUD API 实现 +- `tests/api_integration_test.rs` - 7 个集成测试场景 +- `tests/integration_tests.rs` - 另一组集成测试(可选) + +## 依赖 + +```toml +[dev-dependencies] +axum-test = "16" +``` + +就这样,其他跟 Axum 测试一样。 \ No newline at end of file diff --git a/examples/testing-guide/src/lib.rs b/examples/testing-guide/src/lib.rs new file mode 100644 index 0000000..678a0fc --- /dev/null +++ b/examples/testing-guide/src/lib.rs @@ -0,0 +1,284 @@ +// Business application code using Gotcha framework + +use gotcha::{ + api, ConfigWrapper, GotchaApp, GotchaContext, GotchaRouter, + Json, Path, Query, State, Schematic +}; +use axum::http::StatusCode; +use serde::{Deserialize, Serialize}; +use std::sync::{Arc, Mutex}; +use uuid::Uuid; + +// ========== Application State ========== +#[derive(Clone)] +pub struct AppState { + pub users: Arc>>, + pub counter: Arc>, +} + +impl Default for AppState { + fn default() -> Self { + Self { + users: Arc::new(Mutex::new(vec![ + User { + id: Uuid::new_v4(), + name: "Alice".to_string(), + email: "alice@example.com".to_string(), + age: 30, + } + ])), + counter: Arc::new(Mutex::new(0)), + } + } +} + +// ========== Data Models ========== +#[derive(Clone, Debug, Serialize, Deserialize, Schematic, PartialEq)] +pub struct User { + pub id: Uuid, + pub name: String, + pub email: String, + pub age: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Schematic)] +pub struct CreateUserRequest { + pub name: String, + pub email: String, + pub age: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Schematic)] +pub struct UpdateUserRequest { + pub name: Option, + pub email: Option, + pub age: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Schematic)] +pub struct QueryParams { + pub page: Option, + pub size: Option, + pub sort: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Schematic, PartialEq)] +pub struct ApiResponse { + pub success: bool, + pub message: String, + pub data: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Schematic)] +pub struct ErrorResponse { + pub error: String, + pub code: u16, +} + +// ========== Handler Functions ========== + +#[api(id = "health_check", group = "system")] +pub async fn health_check(State(ctx): State>) -> Json> { + let counter = ctx.state.counter.lock().unwrap(); + Json(ApiResponse { + success: true, + message: "Service is healthy".to_string(), + data: Some(format!("Counter: {}", *counter)), + }) +} + +#[api(id = "list_users", group = "users")] +pub async fn list_users( + State(ctx): State>, + Query(params): Query, +) -> Json>> { + let users = ctx.state.users.lock().unwrap(); + let page = params.page.unwrap_or(1); + let size = params.size.unwrap_or(10); + + let start = (page - 1) * size; + let end = std::cmp::min(start + size, users.len()); + + let paginated_users = if start < users.len() { + users[start..end].to_vec() + } else { + vec![] + }; + + Json(ApiResponse { + success: true, + message: format!("Found {} users", paginated_users.len()), + data: Some(paginated_users), + }) +} + +#[api(id = "get_user", group = "users")] +pub async fn get_user( + State(ctx): State>, + Path(user_id): Path, +) -> Result>, (StatusCode, Json)> { + let users = ctx.state.users.lock().unwrap(); + + match users.iter().find(|u| u.id == user_id) { + Some(user) => Ok(Json(ApiResponse { + success: true, + message: "User found".to_string(), + data: Some(user.clone()), + })), + None => Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "User not found".to_string(), + code: 404, + }), + )), + } +} + +#[api(id = "create_user", group = "users")] +pub async fn create_user( + State(ctx): State>, + Json(payload): Json, +) -> Json> { + let mut users = ctx.state.users.lock().unwrap(); + let mut counter = ctx.state.counter.lock().unwrap(); + + let new_user = User { + id: Uuid::new_v4(), + name: payload.name, + email: payload.email, + age: payload.age, + }; + + users.push(new_user.clone()); + *counter += 1; + + Json(ApiResponse { + success: true, + message: "User created successfully".to_string(), + data: Some(new_user), + }) +} + +#[api(id = "update_user", group = "users")] +pub async fn update_user( + State(ctx): State>, + Path(user_id): Path, + Json(payload): Json, +) -> Result>, (StatusCode, Json)> { + let mut users = ctx.state.users.lock().unwrap(); + + match users.iter_mut().find(|u| u.id == user_id) { + Some(user) => { + if let Some(name) = payload.name { + user.name = name; + } + if let Some(email) = payload.email { + user.email = email; + } + if let Some(age) = payload.age { + user.age = age; + } + + Ok(Json(ApiResponse { + success: true, + message: "User updated successfully".to_string(), + data: Some(user.clone()), + })) + } + None => Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "User not found".to_string(), + code: 404, + }), + )), + } +} + +#[api(id = "delete_user", group = "users")] +pub async fn delete_user( + State(ctx): State>, + Path(user_id): Path, +) -> Result>, (StatusCode, Json)> { + let mut users = ctx.state.users.lock().unwrap(); + + let initial_len = users.len(); + users.retain(|u| u.id != user_id); + + if users.len() < initial_len { + Ok(Json(ApiResponse { + success: true, + message: "User deleted successfully".to_string(), + data: Some(()), + })) + } else { + Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "User not found".to_string(), + code: 404, + }), + )) + } +} + +// ========== Application Configuration ========== +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppConfig { + pub app_name: String, + pub version: String, + pub max_users: usize, +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + app_name: "Testing Guide".to_string(), + version: "1.0.0".to_string(), + max_users: 1000, + } + } +} + +// ========== Application Setup ========== +pub struct App; + +impl GotchaApp for App { + type State = AppState; + type Config = AppConfig; + + async fn state(&self, _config: &ConfigWrapper) -> Result> { + Ok(AppState::default()) + } + + fn routes(&self, router: GotchaRouter>) + -> GotchaRouter> { + router + .get("/health", health_check) + .get("/users", list_users) + .get("/users/:id", get_user) + .post("/users", create_user) + .put("/users/:id", update_user) + .delete("/users/:id", delete_user) + } +} + +// ========== Helper function for testing ========== +pub async fn create_test_app() -> axum::Router { + use gotcha::config::BasicConfig; + + let app = App; + let config = ConfigWrapper { + basic: BasicConfig::default(), + application: AppConfig::default(), + }; + + let state = app.state(&config).await.unwrap(); + let context = GotchaContext { + state, + config, + }; + + app.build_router(context).await.unwrap() +} \ No newline at end of file diff --git a/examples/testing-guide/tests/api_integration_test.rs b/examples/testing-guide/tests/api_integration_test.rs new file mode 100644 index 0000000..353ec8f --- /dev/null +++ b/examples/testing-guide/tests/api_integration_test.rs @@ -0,0 +1,330 @@ +// Simplified integration test focusing on real API testing value + +use axum_test::TestServer; +use http::StatusCode; +use serde_json::json; +use testing_guide::create_test_app; +use uuid::Uuid; + +// ========== Test Setup ========== +async fn setup() -> TestServer { + TestServer::new(create_test_app().await).unwrap() +} + +// ========== Core API Tests ========== + +#[tokio::test] +async fn test_complete_user_lifecycle() { + let server = setup().await; + + // CREATE user + let create_res = server + .post("/users") + .json(&json!({ + "name": "John Doe", + "email": "john@example.com", + "age": 30 + })) + .await; + + create_res.assert_status_ok(); + let created: serde_json::Value = create_res.json(); + let user_id = created["data"]["id"].as_str().unwrap(); + + // GET created user + let get_res = server + .get(&format!("/users/{}", user_id)) + .await; + + get_res.assert_status_ok(); + let fetched: serde_json::Value = get_res.json(); + assert_eq!(fetched["data"]["name"], "John Doe"); + + // UPDATE user + let update_res = server + .put(&format!("/users/{}", user_id)) + .json(&json!({ + "name": "John Updated", + "age": 31 + })) + .await; + + update_res.assert_status_ok(); + let updated: serde_json::Value = update_res.json(); + assert_eq!(updated["data"]["name"], "John Updated"); + assert_eq!(updated["data"]["age"], 31); + + // LIST users (should contain our user) + let list_res = server.get("/users").await; + list_res.assert_status_ok(); + let list: serde_json::Value = list_res.json(); + let users = list["data"].as_array().unwrap(); + assert!(users.iter().any(|u| u["id"] == user_id)); + + // DELETE user + let delete_res = server + .delete(&format!("/users/{}", user_id)) + .await; + + delete_res.assert_status_ok(); + + // VERIFY deletion (should return 404) + let verify_res = server + .get(&format!("/users/{}", user_id)) + .expect_failure() + .await; + + let error: serde_json::Value = verify_res.json(); + assert_eq!(error["code"], 404); +} + +#[tokio::test] +async fn test_pagination_and_filtering() { + let server = setup().await; + + // Create test data + for i in 0..15 { + server + .post("/users") + .json(&json!({ + "name": format!("User {}", i), + "email": format!("user{}@test.com", i), + "age": 20 + i + })) + .await + .assert_status_ok(); + } + + // Test pagination - page 1 + let page1 = server + .get("/users?page=1&size=5") + .await; + + page1.assert_status_ok(); + let data: serde_json::Value = page1.json(); + let users = data["data"].as_array().unwrap(); + assert_eq!(users.len(), 5); + + // Test pagination - page 2 + let page2 = server + .get("/users?page=2&size=5") + .await; + + page2.assert_status_ok(); + let data: serde_json::Value = page2.json(); + let users = data["data"].as_array().unwrap(); + assert_eq!(users.len(), 5); + + // Test pagination - beyond available data + let page_beyond = server + .get("/users?page=10&size=5") + .await; + + page_beyond.assert_status_ok(); + let data: serde_json::Value = page_beyond.json(); + let users = data["data"].as_array().unwrap(); + assert_eq!(users.len(), 0); +} + +#[tokio::test] +async fn test_error_handling() { + let server = setup().await; + + // Test 404 - non-existent user + let not_found = server + .get(&format!("/users/{}", Uuid::new_v4())) + .expect_failure() + .await; + + let error: serde_json::Value = not_found.json(); + assert_eq!(error["error"], "User not found"); + assert_eq!(error["code"], 404); + + // Test 400 - invalid UUID format + server + .get("/users/invalid-uuid") + .expect_failure() + .await + .assert_status(StatusCode::BAD_REQUEST); + + // Test 415 - malformed JSON (axum returns Unsupported Media Type for bad JSON) + server + .post("/users") + .content_type("application/json") + .text("{invalid json}") + .expect_failure() + .await + .assert_status(StatusCode::UNSUPPORTED_MEDIA_TYPE); + + // Test 422 - missing required fields (axum returns 422 for missing JSON fields) + server + .post("/users") + .json(&json!({"name": "No Email"})) // missing email and age + .expect_failure() + .await + .assert_status(StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn test_concurrent_operations() { + let server = setup().await; + + // Create initial user + let create_res = server + .post("/users") + .json(&json!({ + "name": "Concurrent Test", + "email": "concurrent@test.com", + "age": 25 + })) + .await; + + let user: serde_json::Value = create_res.json(); + let user_id = user["data"]["id"].as_str().unwrap(); + + // Simulate concurrent updates by running them sequentially + // (axum-test TestServer doesn't support true concurrent operations) + for i in 0..10 { + let response = server + .put(&format!("/users/{}", user_id)) + .json(&json!({"age": 25 + i})) + .await; + + response.assert_status_ok(); + } + + // Verify final state is consistent + let final_res = server + .get(&format!("/users/{}", user_id)) + .await; + + final_res.assert_status_ok(); + let final_user: serde_json::Value = final_res.json(); + let age = final_user["data"]["age"].as_u64().unwrap(); + + // Age should be 34 (the last update) + assert_eq!(age, 34); +} + +#[tokio::test] +async fn test_state_persistence_across_requests() { + let server = setup().await; + + // Get initial counter from health endpoint + let health1 = server.get("/health").await; + health1.assert_status_ok(); + let data1: serde_json::Value = health1.json(); + let counter_str1 = data1["data"].as_str().unwrap(); + let initial_count: i32 = counter_str1 + .split("Counter: ") + .nth(1) + .unwrap() + .parse() + .unwrap(); + + // Create 3 users + for i in 0..3 { + server + .post("/users") + .json(&json!({ + "name": format!("User {}", i), + "email": format!("user{}@test.com", i), + "age": 20 + })) + .await + .assert_status_ok(); + } + + // Check counter increased by 3 + let health2 = server.get("/health").await; + health2.assert_status_ok(); + let data2: serde_json::Value = health2.json(); + let counter_str2 = data2["data"].as_str().unwrap(); + let final_count: i32 = counter_str2 + .split("Counter: ") + .nth(1) + .unwrap() + .parse() + .unwrap(); + + assert_eq!(final_count, initial_count + 3); +} + +#[tokio::test] +async fn test_partial_updates() { + let server = setup().await; + + // Create user with all fields + let create_res = server + .post("/users") + .json(&json!({ + "name": "Original Name", + "email": "original@test.com", + "age": 30 + })) + .await; + + let user: serde_json::Value = create_res.json(); + let user_id = user["data"]["id"].as_str().unwrap(); + + // Update only name + let update1 = server + .put(&format!("/users/{}", user_id)) + .json(&json!({"name": "New Name"})) + .await; + + update1.assert_status_ok(); + let updated1: serde_json::Value = update1.json(); + assert_eq!(updated1["data"]["name"], "New Name"); + assert_eq!(updated1["data"]["email"], "original@test.com"); // unchanged + assert_eq!(updated1["data"]["age"], 30); // unchanged + + // Update only age + let update2 = server + .put(&format!("/users/{}", user_id)) + .json(&json!({"age": 31})) + .await; + + update2.assert_status_ok(); + let updated2: serde_json::Value = update2.json(); + assert_eq!(updated2["data"]["name"], "New Name"); // keeps previous update + assert_eq!(updated2["data"]["email"], "original@test.com"); // still unchanged + assert_eq!(updated2["data"]["age"], 31); // new value +} + +#[tokio::test] +async fn test_bulk_operations() { + let server = setup().await; + let start = std::time::Instant::now(); + + // Create 50 users sequentially + for i in 0..50 { + let response = server + .post("/users") + .json(&json!({ + "name": format!("Bulk User {}", i), + "email": format!("bulk{}@test.com", i), + "age": 20 + (i % 50) + })) + .await; + + response.assert_status_ok(); + } + + let duration = start.elapsed(); + + // Verify all users were created + let list_res = server + .get("/users?size=100") + .await; + + list_res.assert_status_ok(); + let data: serde_json::Value = list_res.json(); + let users = data["data"].as_array().unwrap(); + + // Should have at least 50 users (51 including default) + assert!(users.len() >= 50); + + // Basic performance check (should complete in reasonable time) + assert!(duration.as_secs() < 30, "Bulk operations took too long"); +} \ No newline at end of file