Last month I watched a colleague's PR sit in review for three days. Not because the code was bad—it was a simple bug fix, maybe twenty lines. The problem was CI. Every time he pushed, the pipeline would run for fifteen minutes, then fail on some random test. He'd re-run it. Sometimes it passed. Sometimes it didn't. By day three, reviewers had moved on to other things.
When I looked at the logs, the pattern was familiar. The tests were hitting Telegram's real API. Some failed due to rate limits. Others timed out waiting for responses. One particularly creative failure happened because the test bot's token had been rotated and nobody updated the CI secret.
This is the story of how we fixed that pipeline—and how you can build a Telegram bot test suite that runs in CI/CD without any API tokens, network access, or prayers to the demo gods.
The Hidden Cost of "Real" Integration Tests
Let's talk about what happens when you test against the real Telegram API in CI.
The token problem. You need a bot token to talk to Telegram. Where does that token come from in CI? Usually a secret. Now you have a secret to manage. Someone rotates it and forgets to update GitHub. Someone copies the workflow file to a new repo and wonders why tests fail. Someone accidentally logs the token in a debug statement and now you're scrambling to revoke it.
Secrets management sounds simple until you're debugging why your nightly build has been red for a week.
The network problem. CI runners live in data centers. Data centers have firewalls, proxies, and occasionally, network issues. Telegram's servers are fast, but "fast" still means 50-200ms per request. A test that sends ten messages takes at least half a second just in network latency. A test suite with a hundred such tests? You're looking at a minute of pure waiting, assuming nothing times out.
And things do time out. I've seen tests that pass locally fail in CI because the runner happened to be in a region with higher latency to Telegram's servers. These failures are maddening because they're not reproducible. Run the same test again and it might pass.
The rate limit problem. Telegram enforces limits on how fast bots can send messages. The exact limits depend on factors Telegram doesn't fully document, but roughly: send too many messages too fast, and you get temporarily blocked. Run fifty tests in parallel, each sending a few messages, and you'll hit those limits. Now your tests fail not because of bugs, but because you're testing too enthusiastically.
The "solution" most teams reach for is running tests sequentially and adding sleeps between them. This works until your test suite grows. Sequential tests with network latency and artificial delays add up fast. What started as a two-minute pipeline becomes ten minutes, then twenty.
The flakiness spiral. Developers stop running the full suite locally. They push and pray. CI becomes a bottleneck instead of a safety net. When a test fails, the first instinct is "re-run it" rather than "investigate it." Eventually, certain tests get marked as "known flaky" and ignored entirely. That's not testing. That's theater.
The Solution: Mock Locally, Test Everything
Here's the thing about teloxide bots: they don't actually care about Telegram. They care about an HTTP endpoint that speaks the Telegram Bot API protocol. By default, that endpoint is api.telegram.org. But it doesn't have to be.
teremock spins up a local HTTP server that implements 40+ Telegram Bot API methods. When you create a MockBot, it configures the teloxide Bot to use localhost instead of Telegram. Your handlers run exactly as they would in production—the state machine transitions, the database queries execute, the responses get sent—except there's no network call leaving the machine.
If you're new to teremock, check out the introduction article for the basics. This article focuses specifically on getting everything working in CI/CD.
The Simplest CI Pipeline
Let's start with a bot that doesn't use a database. Here's the complete GitHub Actions workflow:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Swatinem/rust-cache@v2
- run: cargo test --workspace
That's it. No secrets block. No environment variables. No special network configuration.
Compare this to what you'd need for real API tests:
# The old way - don't do this
env:
TELOXIDE_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
# Plus handling for rate limits, retries, timeouts...
The teremock approach eliminates an entire category of CI configuration. No tokens to rotate. No secrets to manage across repositories. No "why is this environment variable not set" debugging sessions.
The speed difference is dramatic too. Network round-trips that took 50-200ms each now take microseconds. A test suite that took five minutes against the real API finishes in fifteen seconds.
Adding PostgreSQL for Stateful Bots
Most production bots aren't stateless. They track conversations, store user preferences, manage orders or subscriptions. That state typically lives in PostgreSQL.
GitHub Actions makes it easy to spin up a PostgreSQL service container. Here's the complete workflow:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_db
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: Swatinem/rust-cache@v2
- name: Run tests
env:
DATABASE_URL: postgres://postgres:postgres@localhost/test_db
run: cargo test --workspace
Let's break down what's happening here.
The services block tells GitHub Actions to start a PostgreSQL container before your job runs. The container gets a health check configured so the job waits until PostgreSQL is actually ready to accept connections—no more "connection refused" race conditions.
The ports mapping exposes PostgreSQL on the standard port 5432. From your test code's perspective, it's just a normal PostgreSQL server running on localhost.
The DATABASE_URL environment variable tells sqlx where to connect. This same variable works for both running migrations and for #[sqlx::test] to create isolated test databases.
One important detail: the health check options. Without them, your tests might start before PostgreSQL finishes initializing, leading to sporadic "connection refused" failures. The --health-cmd pg_isready tells Docker to verify PostgreSQL is actually accepting connections before declaring the container healthy.
Parallel Database Tests in CI
If your tests use #[serial] to avoid database conflicts, they'll run sequentially even in CI. That's slow. For a deep dive into parallel database testing with #[sqlx::test], see the parallel database testing article.
The short version: #[sqlx::test] creates a fresh database for each test, runs your migrations, and drops it afterward. Tests can't interfere with each other because they're literally talking to different databases. This enables full parallelism—on an 8-core CI runner, 8 tests run simultaneously.
The CI workflow above already supports this. The DATABASE_URL points to a PostgreSQL server where sqlx can create temporary databases. No additional configuration needed.
Caching for Faster Builds
The Swatinem/rust-cache@v2 action caches your compiled dependencies between runs. This typically cuts build times by 60-80% on subsequent runs.
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
The cache-on-failure: true option is useful during development—even if tests fail, you still cache the successful compilation. This speeds up the "fix and re-run" cycle.
For workspaces with multiple crates, the cache handles everything automatically. For monorepos with separate Cargo.toml files (like an examples/ directory), you might need additional configuration:
- uses: Swatinem/rust-cache@v2
with:
workspaces: |
.
examples
Adding Clippy and Formatting Checks
A complete CI pipeline usually includes more than just tests. Here's what the teremock repository uses:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- name: Check formatting
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Run tests
env:
DATABASE_URL: postgres://postgres:postgres@localhost/postgres
run: cargo test --workspace
The order matters here. Formatting checks are instant—if someone forgot to run cargo fmt, fail fast rather than waiting for compilation. Clippy runs next because it catches issues during compilation. Tests run last since they take the longest.
The -D warnings flag for Clippy treats warnings as errors. This prevents "I'll fix that warning later" from becoming "that warning has been there for six months."
Environment Variables and Secrets
One of the biggest wins of mock-based testing is eliminating secrets from CI. But you might still need environment variables for other purposes—database URLs, feature flags, test configuration.
For non-sensitive values, define them directly in the workflow:
env:
DATABASE_URL: postgres://postgres:postgres@localhost/test_db
RUST_LOG: debug
MY_FEATURE_FLAG: enabled
For values that differ between environments, use GitHub's environment variables feature:
- name: Run tests
env:
DATABASE_URL: ${{ vars.DATABASE_URL }}
run: cargo test
Note the difference: secrets.X for sensitive values, vars.X for non-sensitive configuration. Using vars instead of secrets means values show up in logs, making debugging easier.
Handling Test Failures
When tests fail in CI, you need enough information to debug without re-running locally. A few techniques help:
Preserve test output. By default, Cargo captures test output and only shows it for failures. The --nocapture flag shows all output, but that's usually too noisy. Instead, use RUST_LOG to control verbosity:
- name: Run tests
env:
RUST_LOG: my_bot=debug
DATABASE_URL: postgres://postgres:postgres@localhost/test_db
run: cargo test --workspace
Upload artifacts on failure. If your tests generate logs or other artifacts, upload them:
- name: Upload logs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-logs
path: target/test-logs/
Matrix testing for multiple Rust versions. If you support multiple Rust versions, test them all:
strategy:
matrix:
rust: [stable, beta, 1.83]
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ matrix.rust }}
The Complete Production Pipeline
Putting it all together, here's a battle-tested CI configuration for a Telegram bot with database persistence:
name: CI
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
jobs:
ci:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- name: Check formatting
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Run tests
env:
DATABASE_URL: postgres://postgres:postgres@localhost/postgres
run: cargo test --workspace
- name: Upload test artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-output
path: target/
retention-days: 7
This pipeline:
- Starts PostgreSQL before tests run
- Caches dependencies for faster subsequent runs
- Fails fast on formatting issues
- Catches lint warnings with Clippy
- Runs all tests in parallel with database isolation
- Preserves artifacts if something fails
Total runtime on a warm cache: 2-3 minutes. Most of that is compilation. The tests themselves take seconds.
What You Don't Need Anymore
Let me be explicit about what this approach eliminates:
-
No
TELOXIDE_TOKENsecret. The mock server doesn't need a real token. - No retry logic for flaky network tests. There's no network to be flaky.
- No rate limit handling. The mock server doesn't rate limit.
-
No
--test-threads=1to avoid conflicts. Database isolation handles that. - No "known flaky" test annotations. Tests either pass or fail deterministically.
- No special network configuration. Everything runs on localhost.
The pipeline my colleague was fighting with? After migrating to this approach, it went from fifteen minutes with random failures to three minutes with consistent passes. The PR that sat in review for three days would have merged in an hour.
That's the goal: CI that helps you ship faster, not CI that becomes another thing to debug.
Links:
- Documentation
If you're setting up CI for a Telegram bot and run into issues, open an issue on GitHub. I've debugged more pipeline configurations than I'd like to admit, and I'm happy to help.
Top comments (0)