Backend Testing - Mardens-Inc/Pricing-App GitHub Wiki
This document explains the testing infrastructure, patterns, and best practices for the Pricing App backend.
- Testing Overview
- Test Structure
- Running Tests
- Writing Integration Tests
- Test Patterns
- Test Fixtures
- Common Assertions
- Debugging Tests
-
Framework: Rust's built-in
#[test]attribute -
Integration:
actix_web::testutilities -
Logging:
pretty_env_loggerfor test output -
Location:
tests/directory (integration tests)
Integration Tests Only: The project currently uses integration tests that test the API as a whole. Unit tests would be written within each module file if needed.
tests/
├── hash_compatibility.rs # ID hashing tests
├── icons.rs # Icon endpoint tests
├── inventorying.rs # Inventory operations tests
├── list.rs # Location CRUD tests
├── server_information.rs # Server info tests
├── spreadsheets.rs # Excel/CSV processing tests
└── assets/ # Test fixtures
├── icon.jpg
├── spreadsheets.csv
└── spreadsheets.xlsx
#[actix_web::test]
async fn test_name() {
// 1. Initialize logging
let _ = env_logger::builder()
.is_test(true)
.filter_level(LevelFilter::Trace)
.try_init();
// 2. Initialize test environment
initialize_asset_directories().unwrap();
// 3. Create database pool
let data = DatabaseConnectionData::from_file("dev-server.json").unwrap();
let pool = create_pool(&data).await.unwrap();
// 4. Initialize database tables
list_db::initialize(&pool).await.unwrap();
// 5. Create test service
let app = test::init_service(
App::new()
.app_data(web::Data::new(pool.clone()))
.configure(list::configure)
).await;
// 6. Create request
let req = test::TestRequest::post()
.uri("/api/list")
.set_json(json!({
"name": "Test Location",
"location": "Test City"
}))
.to_request();
// 7. Call service
let resp = test::call_service(&app, req).await;
// 8. Assert response
assert_eq!(resp.status(), 200);
let body = test::read_body(resp).await;
let result: LocationResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(result.name, "Test Location");
}# Run all tests
cargo test
# Run specific test file
cargo test --test spreadsheets
cargo test --test inventorying
cargo test --test list
# Run specific test function
cargo test test_create_location
# Run tests with output visible
cargo test -- --nocapture
# Run with debug logging
RUST_LOG=debug cargo test -- --nocapture
# Run with trace logging (very verbose)
RUST_LOG=trace cargo test -- --nocaptureBy default, tests run in parallel. For database tests, you may want sequential:
# Run tests one at a time
cargo test -- --test-threads=1# Run tests matching pattern
cargo test list
# Run tests NOT matching pattern
cargo test --skip slowFile: tests/list.rs
use actix_web::{test, web, App};
use pricing_app_lib::list;
use database_common_lib::database_connection::{DatabaseConnectionData, create_pool};
use sqlx::MySqlPool;
use serde_json::json;
#[actix_web::test]
async fn test_create_location() {
// Setup logging
let _ = env_logger::builder()
.is_test(true)
.filter_level(LevelFilter::Debug)
.try_init();
// Setup database
let data = DatabaseConnectionData::from_file("dev-server.json").unwrap();
let pool = create_pool(&data).await.unwrap();
list_db::initialize(&pool).await.unwrap();
// Create test app
let app = test::init_service(
App::new()
.app_data(web::Data::new(pool.clone()))
.configure(list::configure)
).await;
// Create location
let create_req = test::TestRequest::post()
.uri("/api/list")
.set_json(json!({
"name": "Test Store",
"location": "Portland",
"po": "PO-123",
"vendor": "Vendor A",
"department": "Dept B",
"image": "store.png"
}))
.to_request();
let resp = test::call_service(&app, create_req).await;
assert_eq!(resp.status(), 200);
// Parse response
let body = test::read_body(resp).await;
let location: Location = serde_json::from_slice(&body).unwrap();
assert_eq!(location.name, "Test Store");
assert_eq!(location.location, "Portland");
assert!(!location.id.is_empty()); // ID should be hashed string
// Verify in database
let get_req = test::TestRequest::get()
.uri(&format!("/api/list/{}", location.id))
.to_request();
let resp = test::call_service(&app, get_req).await;
assert_eq!(resp.status(), 200);
// Cleanup
let delete_req = test::TestRequest::delete()
.uri(&format!("/api/list/{}", location.id))
.to_request();
let resp = test::call_service(&app, delete_req).await;
assert_eq!(resp.status(), 200);
}File: tests/spreadsheets.rs
use actix_multipart::Multipart;
use actix_web::{test, web, App};
use pricing_app_lib::sheets;
#[actix_web::test]
async fn test_upload_spreadsheet() {
// Setup
let _ = env_logger::builder().is_test(true).try_init();
initialize_asset_directories().unwrap();
let app = test::init_service(
App::new().configure(sheets::configure)
).await;
// Read test file
let file_bytes = std::fs::read("tests/assets/spreadsheets.xlsx").unwrap();
// Create multipart request
let boundary = "----boundary";
let body = format!(
"--{}\r\n\
Content-Disposition: form-data; name=\"file\"; filename=\"test.xlsx\"\r\n\
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\r\n\
\r\n\
{}\r\n\
--{}--\r\n",
boundary,
String::from_utf8_lossy(&file_bytes),
boundary
);
let req = test::TestRequest::post()
.uri("/api/spreadsheet/prepare")
.insert_header((
"content-type",
format!("multipart/form-data; boundary={}", boundary)
))
.set_payload(body)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), 200);
let body = test::read_body(resp).await;
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(result["identifier"].is_string());
assert!(result["preview"].is_array());
}#[actix_web::test]
async fn test_protected_endpoint() {
let app = test::init_service(
App::new()
.app_data(web::Data::new(pool.clone()))
.wrap(AuthenticationMiddleware::new())
.configure(inventory::configure)
).await;
// Get auth token
let token = get_test_auth_token().await;
// Make authenticated request
let req = test::TestRequest::get()
.uri("/api/inventory/x7J8kLm9N2pQr4Tv")
.insert_header(("Authorization", format!("Bearer {}", token)))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), 200);
}
async fn get_test_auth_token() -> String {
// Implementation to get test JWT token
"test_token_here".to_string()
}struct TestContext {
pool: MySqlPool,
location_id: String,
}
impl TestContext {
async fn setup() -> Self {
let data = DatabaseConnectionData::from_file("dev-server.json").unwrap();
let pool = create_pool(&data).await.unwrap();
// Initialize tables
list_db::initialize(&pool).await.unwrap();
// Create test location
let location = create_test_location(&pool).await;
Self {
pool,
location_id: location.id,
}
}
async fn teardown(self) {
// Clean up test data
let id = decode_single(&self.location_id).unwrap();
list_db::delete(&self.pool, id).await.unwrap();
}
}
#[actix_web::test]
async fn test_with_context() {
let ctx = TestContext::setup().await;
// Test code using ctx.pool and ctx.location_id
ctx.teardown().await;
}// Assert JSON contains field
fn assert_json_field(json: &Value, field: &str) {
assert!(json[field].is_string(), "Field '{}' should be string", field);
}
// Assert response is success
fn assert_success(resp: &actix_web::dev::ServiceResponse) {
assert!(
resp.status().is_success(),
"Expected success status, got {}",
resp.status()
);
}
// Assert contains substring
fn assert_contains(haystack: &str, needle: &str) {
assert!(
haystack.contains(needle),
"Expected '{}' to contain '{}'",
haystack,
needle
);
}#[actix_web::test]
async fn test_invalid_id_returns_400() {
let app = test::init_service(
App::new()
.app_data(web::Data::new(pool.clone()))
.configure(list::configure)
).await;
let req = test::TestRequest::get()
.uri("/api/list/invalid_id")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), 400);
let body = test::read_body(resp).await;
let error: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(error["error"].as_str().unwrap().contains("Invalid ID"));
}
#[actix_web::test]
async fn test_not_found_returns_404() {
let app = test::init_service(
App::new()
.app_data(web::Data::new(pool.clone()))
.configure(list::configure)
).await;
// Use valid hashed ID format that doesn't exist
let fake_id = encode_single(999999);
let req = test::TestRequest::get()
.uri(&format!("/api/list/{}", fake_id))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), 404);
}Test files are stored in tests/assets/:
tests/assets/
├── icon.jpg # Test icon image
├── spreadsheets.csv # Test CSV file
└── spreadsheets.xlsx # Test Excel file
// Read test file
let csv_path = "tests/assets/spreadsheets.csv";
let csv_content = std::fs::read_to_string(csv_path).unwrap();
// Use in test
let records = csv::parse_csv(csv_path).unwrap();
assert_eq!(records.len(), 10);async fn create_test_location(pool: &MySqlPool) -> Location {
let location = LocationRequest {
name: "Test Location".to_string(),
location: "Test City".to_string(),
po: "PO-TEST".to_string(),
vendor: "Test Vendor".to_string(),
department: "Test Dept".to_string(),
image: "test.png".to_string(),
};
list_db::create(pool, &location).await.unwrap()
}
async fn create_test_inventory_table(pool: &MySqlPool, location_id: u64) {
let headers = vec![
"description".to_string(),
"price".to_string(),
"quantity".to_string(),
];
inventory_db::create(location_id, headers, &pool).await.unwrap();
}// Success
assert_eq!(resp.status(), 200);
assert_eq!(resp.status(), 201); // Created
assert!(resp.status().is_success());
// Client errors
assert_eq!(resp.status(), 400); // Bad Request
assert_eq!(resp.status(), 401); // Unauthorized
assert_eq!(resp.status(), 404); // Not Found
assert!(resp.status().is_client_error());
// Server errors
assert_eq!(resp.status(), 500); // Internal Server Error
assert!(resp.status().is_server_error());// Read body as string
let body = test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(body_str.contains("expected text"));
// Parse JSON
let body = test::read_body(resp).await;
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["name"], "Expected Value");
// Deserialize to struct
let body = test::read_body(resp).await;
let location: Location = serde_json::from_slice(&body).unwrap();
assert_eq!(location.name, "Expected Name");let resp = test::call_service(&app, req).await;
// Check header exists
assert!(resp.headers().contains_key("content-type"));
// Check header value
let content_type = resp.headers().get("content-type").unwrap();
assert_eq!(content_type, "application/json");# Debug level
RUST_LOG=debug cargo test -- --nocapture
# Trace level (very verbose)
RUST_LOG=trace cargo test -- --nocapture
# Specific module
RUST_LOG=pricing_app_lib::list=debug cargo test -- --nocapture
# SQL queries
RUST_LOG=sqlx=debug cargo test -- --nocapture#[actix_web::test]
async fn test_debug() {
let resp = test::call_service(&app, req).await;
// Print status
println!("Status: {:?}", resp.status());
// Print headers
println!("Headers: {:?}", resp.headers());
// Print body
let body = test::read_body(resp).await;
println!("Body: {:?}", String::from_utf8_lossy(&body));
// Print JSON pretty
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
println!("JSON: {}", serde_json::to_string_pretty(&json).unwrap());
}# Install debugger
cargo install rust-gdb
# Run test with debugger
rust-gdb --args cargo test test_name -- --nocapture# Use different config file
cargo test -- --ignored
#[test]
#[ignore] // Ignored by default
async fn test_production_scenario() {
// Production-like test
}- Test one thing per test - Keep tests focused
-
Use descriptive test names -
test_create_location_with_invalid_name - Clean up after tests - Delete test data
- Use fixtures - Reuse test data
- Test error cases - Don't just test happy path
- Assert meaningful things - Not just status codes
- Don't depend on test order - Tests should be independent
- Don't use production database - Use test database
- Don't leave test data - Clean up
- Don't skip assertions - Always verify results
- Don't test implementation details - Test behavior
Bad:
#[actix_web::test]
async fn test() {
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), 200);
// No verification of actual behavior
}Good:
#[actix_web::test]
async fn test_create_location_stores_correct_data() {
// Arrange
let location_data = json!({
"name": "Portland Store",
"location": "Portland, ME"
});
// Act
let resp = create_location(&app, &location_data).await;
// Assert
assert_eq!(resp.status(), 201);
let created: Location = parse_response_body(resp).await;
assert_eq!(created.name, "Portland Store");
assert_eq!(created.location, "Portland, ME");
// Verify persistence
let fetched = get_location(&app, &created.id).await;
assert_eq!(fetched.name, created.name);
// Cleanup
delete_location(&app, &created.id).await;
}# Install tarpaulin
cargo install cargo-tarpaulin
# Run coverage
cargo tarpaulin --out Html
# View report
open tarpaulin-report.html- Endpoints: 100% (all endpoints should have tests)
- Database operations: 90%+ (critical paths tested)
- Error handling: 80%+ (major error cases covered)
- Backend Modules - Understand what to test in each module
- Backend API Design - Learn endpoint patterns for testing
- Common Tasks - Add tests when creating features
Last Updated: 2025-11-04