Backend Testing - Mardens-Inc/Pricing-App GitHub Wiki

This document explains the testing infrastructure, patterns, and best practices for the Pricing App backend.

Table of Contents


Testing Overview

Testing Framework

  • Framework: Rust's built-in #[test] attribute
  • Integration: actix_web::test utilities
  • Logging: pretty_env_logger for test output
  • Location: tests/ directory (integration tests)

Test Types

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.

Test Files

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

Test Structure

Basic Test Template

#[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");
}

Running Tests

Basic Commands

# 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 -- --nocapture

Parallel vs Sequential

By default, tests run in parallel. For database tests, you may want sequential:

# Run tests one at a time
cargo test -- --test-threads=1

Filtering Tests

# Run tests matching pattern
cargo test list

# Run tests NOT matching pattern
cargo test --skip slow

Writing Integration Tests

Example: Testing Location Creation

File: 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);
}

Example: Testing File Upload

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());
}

Example: Testing with Authentication

#[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()
}

Test Patterns

Setup/Teardown Pattern

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;
}

Assertion Helpers

// 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
    );
}

Testing Error Cases

#[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 Fixtures

Asset Files

Test files are stored in tests/assets/:

tests/assets/
├── icon.jpg              # Test icon image
├── spreadsheets.csv      # Test CSV file
└── spreadsheets.xlsx     # Test Excel file

Using Fixtures

// 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);

Creating Test Data

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();
}

Common Assertions

Status Code Assertions

// 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());

Body Assertions

// 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");

Header Assertions

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");

Debugging Tests

Enable Logging

# 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

Print Debugging

#[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());
}

Using Debugger

# Install debugger
cargo install rust-gdb

# Run test with debugger
rust-gdb --args cargo test test_name -- --nocapture

Test with Production Database

# Use different config file
cargo test -- --ignored

#[test]
#[ignore] // Ignored by default
async fn test_production_scenario() {
    // Production-like test
}

Best Practices

Do's

  1. Test one thing per test - Keep tests focused
  2. Use descriptive test names - test_create_location_with_invalid_name
  3. Clean up after tests - Delete test data
  4. Use fixtures - Reuse test data
  5. Test error cases - Don't just test happy path
  6. Assert meaningful things - Not just status codes

Don'ts

  1. Don't depend on test order - Tests should be independent
  2. Don't use production database - Use test database
  3. Don't leave test data - Clean up
  4. Don't skip assertions - Always verify results
  5. Don't test implementation details - Test behavior

Example: Good vs Bad Test

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;
}

Test Coverage

Measuring Coverage

# Install tarpaulin
cargo install cargo-tarpaulin

# Run coverage
cargo tarpaulin --out Html

# View report
open tarpaulin-report.html

Coverage Goals

  • Endpoints: 100% (all endpoints should have tests)
  • Database operations: 90%+ (critical paths tested)
  • Error handling: 80%+ (major error cases covered)

Next Steps


Last Updated: 2025-11-04

⚠️ **GitHub.com Fallback** ⚠️