Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Testing

Testing your bot ensures that handlers behave correctly, filters match the right updates, and persistence stores data as expected. This chapter covers strategies from unit testing individual components to integration testing with real persistence backends.

Unit Testing Filters

Filters are pure functions of &Update -> FilterResult. Test them without a running bot by constructing Update values from JSON:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use rust_tg_bot::ext::prelude::{Filter, FilterResult, Update};

    fn make_update(json_val: serde_json::Value) -> Update {
        serde_json::from_value(json_val).unwrap()
    }

    fn text_message_update(text: &str) -> Update {
        make_update(json!({
            "update_id": 1,
            "message": {
                "message_id": 1,
                "date": 0,
                "chat": { "id": 1, "type": "private" },
                "from": { "id": 1, "is_bot": false, "first_name": "Test" },
                "text": text
            }
        }))
    }

    #[test]
    fn text_length_filter_accepts_valid() {
        let filter = TextLengthFilter::new(1, 100);
        let update = text_message_update("Hello!");
        assert!(filter.check_update(&update).is_match());
    }

    #[test]
    fn text_length_filter_rejects_empty() {
        let filter = TextLengthFilter::new(1, 100);
        let update = text_message_update("");
        assert!(!filter.check_update(&update).is_match());
    }

    #[test]
    fn text_length_filter_rejects_too_long() {
        let filter = TextLengthFilter::new(1, 5);
        let update = text_message_update("This is too long");
        assert!(!filter.check_update(&update).is_match());
    }
}
}

Testing Built-in Filters

The same approach works for built-in filters:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use rust_tg_bot::ext::prelude::{TEXT, COMMAND};

    #[test]
    fn text_filter_matches_text_messages() {
        let update = text_message_update("hello");
        assert!(TEXT().0.check_update(&update).is_match());
    }

    #[test]
    fn command_filter_matches_commands() {
        let update = make_update(json!({
            "update_id": 1,
            "message": {
                "message_id": 1,
                "date": 0,
                "chat": { "id": 1, "type": "private" },
                "from": { "id": 1, "is_bot": false, "first_name": "Test" },
                "text": "/start",
                "entities": [{
                    "type": "bot_command",
                    "offset": 0,
                    "length": 6
                }]
            }
        }));
        assert!(COMMAND().0.check_update(&update).is_match());
    }
}
}

Testing Filter Composition

Verify that combined filters behave correctly:

#![allow(unused)]
fn main() {
#[test]
fn text_and_not_command() {
    let filter = TEXT() & !COMMAND();

    // Plain text should match
    let text_update = text_message_update("hello");
    assert!(filter.0.check_update(&text_update).is_match());

    // Command should not match
    let cmd_update = make_update(json!({
        "update_id": 1,
        "message": {
            "message_id": 1,
            "date": 0,
            "chat": { "id": 1, "type": "private" },
            "from": { "id": 1, "is_bot": false, "first_name": "Test" },
            "text": "/start",
            "entities": [{ "type": "bot_command", "offset": 0, "length": 6 }]
        }
    }));
    assert!(!filter.0.check_update(&cmd_update).is_match());
}
}

Testing Handler Logic

Handlers contain your business logic. Test the logic by extracting it into pure functions:

#![allow(unused)]
fn main() {
// In your bot code:
fn format_greeting(first_name: &str, visit_count: i64) -> String {
    if visit_count == 0 {
        format!("Welcome, {first_name}!")
    } else {
        format!("Welcome back, {first_name}! Visit #{}", visit_count + 1)
    }
}

// In your tests:
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_first_visit() {
        assert_eq!(format_greeting("Alice", 0), "Welcome, Alice!");
    }

    #[test]
    fn greeting_return_visit() {
        assert_eq!(
            format_greeting("Bob", 4),
            "Welcome back, Bob! Visit #5"
        );
    }
}
}

This pattern – extract logic into testable functions, call them from handlers – is the most reliable way to test bot behaviour without mocking the Telegram API.

Testing Persistence

In-Memory SQLite

Use SqlitePersistence::in_memory() to test persistence logic without touching the filesystem:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use rust_tg_bot::ext::persistence::sqlite::SqlitePersistence;
    use rust_tg_bot::ext::persistence::base::BasePersistence;
    use std::collections::HashMap;

    #[tokio::test]
    async fn sqlite_round_trip() {
        let persistence = SqlitePersistence::in_memory()
            .expect("failed to create in-memory SQLite");

        // Write user data
        let mut data = HashMap::new();
        data.insert("name".to_string(), serde_json::json!("Alice"));
        persistence.update_user_data(42, &data).await.unwrap();

        // Read it back
        let all_users = persistence.get_user_data().await.unwrap();
        let user_42 = all_users.get(&42).unwrap();
        assert_eq!(
            user_42.get("name").and_then(|v| v.as_str()),
            Some("Alice"),
        );
    }
}
}

Temporary JSON Files

For JsonFilePersistence, use a temporary directory:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use rust_tg_bot::ext::persistence::json_file::JsonFilePersistence;
    use rust_tg_bot::ext::persistence::base::BasePersistence;

    #[tokio::test]
    async fn json_file_round_trip() {
        let dir = tempfile::tempdir().unwrap();
        let prefix = dir
            .path()
            .join("test_bot")
            .to_string_lossy()
            .to_string();

        let persistence = JsonFilePersistence::new(&prefix, true, false);

        let mut data = std::collections::HashMap::new();
        data.insert("key".to_string(), serde_json::json!("value"));
        persistence.update_bot_data(&data).await.unwrap();
        persistence.flush().await.unwrap();

        let loaded = persistence.get_bot_data().await.unwrap();
        assert_eq!(
            loaded.get("key").and_then(|v| v.as_str()),
            Some("value"),
        );
    }
}
}

Testing Conversation State

Test conversation state transitions by directly manipulating the state store:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn state_transitions() {
        let store: StateStore = Arc::new(RwLock::new(HashMap::new()));

        // Simulate /start
        {
            let mut s = store.write().await;
            s.insert(1, UserState {
                conv: ConvState::Top(TopState::SelectingAction),
                ..Default::default()
            });
        }

        // Verify state
        {
            let s = store.read().await;
            assert_eq!(
                s.get(&1).unwrap().conv,
                ConvState::Top(TopState::SelectingAction),
            );
        }

        // Simulate transition to member level
        {
            let mut s = store.write().await;
            let us = s.get_mut(&1).unwrap();
            us.conv = ConvState::Member(MemberState::SelectingLevel);
        }

        // Verify transition
        {
            let s = store.read().await;
            assert_eq!(
                s.get(&1).unwrap().conv,
                ConvState::Member(MemberState::SelectingLevel),
            );
        }
    }
}
}

Integration Testing Patterns

For full integration tests that exercise the entire handler pipeline, you have two options.

1. Build the Application but Do Not Run It

Create the Application, register handlers, but do not call run_polling(). You can verify handler registration and filter matching:

#![allow(unused)]
fn main() {
#[tokio::test]
async fn integration_test_setup() {
    let token = "fake-token-for-testing";
    let app = ApplicationBuilder::new().token(token).build();

    app.add_handler(
        MessageHandler::new(TEXT() & !COMMAND(), echo), 0,
    ).await;

    // The app is configured but not connected to Telegram.
    // Verify handler registration, filter composition, etc.
}
}

2. Test with Fake Updates

Feed hand-crafted updates through the update sender to test the full pipeline:

#![allow(unused)]
fn main() {
#[tokio::test]
async fn test_update_processing() {
    let token = "fake-token-for-testing";
    let app = ApplicationBuilder::new().token(token).build();

    // Register handlers...

    app.initialize().await.unwrap();
    app.start().await.unwrap();

    // Send a fake update through the channel
    let raw_update = serde_json::from_value(json!({
        "update_id": 1,
        "message": {
            "message_id": 1,
            "date": 0,
            "chat": { "id": 1, "type": "private" },
            "from": { "id": 1, "is_bot": false, "first_name": "Test" },
            "text": "hello"
        }
    })).unwrap();

    app.update_sender().send(raw_update).await.unwrap();

    // Allow time for processing
    tokio::time::sleep(std::time::Duration::from_millis(100)).await;

    app.stop().await.ok();
}
}

Note: API calls will fail without a real token, but this tests the dispatch pipeline.

Test Organisation

A recommended project layout for tests:

crates/
  my-bot/
    src/
      main.rs
      handlers.rs       # Handler functions
      filters.rs        # Custom filters
      logic.rs          # Pure business logic
    tests/
      filters_test.rs   # Unit tests for filters
      logic_test.rs     # Unit tests for business logic
      persistence_test.rs  # Integration tests for persistence

Keep handler functions thin – they should extract data from the update, call business logic functions, and send responses. Test the business logic functions directly.

Tips

  • Use serde_json::json! to construct updates. It is the fastest way to create test fixtures.
  • Test filters independently. Filters are the easiest part of the bot to test thoroughly.
  • Test state machines with direct store manipulation. You do not need a running bot to verify state transitions.
  • Use #[tokio::test] for async tests. The persistence backends require an async runtime.
  • Use tempfile for filesystem tests. It automatically cleans up temporary directories.
  • Keep business logic pure. Functions that take plain values and return plain values are trivial to test.

Next Steps

  • Deployment – deploy your tested bot to production.
  • Custom Filters – write filters that are easy to test in isolation.