Testing Bottle Applications: From Unit Tests to Integration
Bottle is a lightweight, fast WSGI micro-framework for Python that makes building small web applications a breeze. But no matter how small your app is, shipping without tests is a recipe for regressions, broken routes, and unhappy users. In this tutorial, we'll walk through a complete testing strategy for Bottle applications — starting with isolated unit tests and ending with full integration tests that exercise the entire request/response cycle.
Why Testing Bottle Applications Matters
Even though Bottle is minimal, the code you write on top of it still contains business logic, route handlers, template rendering, and external integrations. Tests give you the confidence to refactor, the safety net to catch regressions, and the documentation of how your app is supposed to behave. A good test suite also forces you to write more modular, decoupled code — because tightly coupled code is notoriously hard to test.
There are two main levels of testing we'll focus on:
- Unit tests — Verify individual functions, helpers, and business logic in isolation.
- Integration tests — Exercise the full Bottle application through its WSGI interface, simulating real HTTP requests.
Setting Up the Sample Application
Before we write tests, we need something to test. Let's build a small but realistic Bottle app that manages a list of tasks. We'll structure it so that logic is separated from routing — this makes unit testing much easier.
First, install Bottle and pytest:
pip install bottle pytest webtest
Now let's create our application. Save this as app.py:
from bottle import Bottle, request, response, JSONPlugin
import json
app = Bottle()
# In-memory storage for tasks
_tasks = []
_next_id = 1
def add_task(title, done=False):
"""Business logic: create and store a task."""
global _next_id
if not title or not isinstance(title, str):
raise ValueError("Title must be a non-empty string")
task = {"id": _next_id, "title": title, "done": done}
_tasks.append(task)
_next_id += 1
return task
def get_tasks():
"""Return all tasks."""
return list(_tasks)
def get_task(task_id):
"""Return a single task by id, or None."""
for task in _tasks:
if task["id"] == task_id:
return task
return None
def toggle_task(task_id):
"""Toggle the done status of a task. Returns the task or None."""
task = get_task(task_id)
if task is None:
return None
task["done"] = not task["done"]
return task
def reset_store():
"""Helper used by tests to clear state."""
global _tasks, _next_id
_tasks = []
_next_id = 1
@app.get("/tasks")
def list_tasks():
return {"tasks": get_tasks()}
@app.post("/tasks")
def create_task():
data = request.json
if not data or "title" not in data:
response.status = 400
return {"error": "Missing 'title' field"}
try:
task = add_task(data["title"], data.get("done", False))
response.status = 201
return task
except ValueError as e:
response.status = 400
return {"error": str(e)}
@app.get("/tasks/")
def show_task(task_id):
task = get_task(task_id)
if task is None:
response.status = 404
return {"error": "Task not found"}
return task
@app.post("/tasks//toggle")
def toggle_route(task_id):
task = toggle_task(task_id)
if task is None:
response.status = 404
return {"error": "Task not found"}
return task
@app.delete("/tasks/")
def delete_task(task_id):
global _tasks
task = get_task(task_id)
if task is None:
response.status = 404
return {"error": "Task not found"}
_tasks = [t for t in _tasks if t["id"] != task_id]
return {"deleted": task_id}
if __name__ == "__main__":
app.run(host="localhost", port=8080)
Notice how the business logic (add_task, get_task, toggle_task) is separated from the route handlers. This separation is the foundation of testable Bottle apps.
Writing Unit Tests
Unit tests focus on the smallest pieces of your application — the functions that contain your core logic. These tests should be fast, deterministic, and not require any HTTP machinery.
Create a file named test_unit.py:
import pytest
from app import add_task, get_task, get_tasks, toggle_task, reset_store
@pytest.fixture(autouse=True)
def clean_state():
"""Reset the in-memory store before each test."""
reset_store()
yield
reset_store()
def test_add_task_creates_task_with_incrementing_id():
task1 = add_task("Buy groceries")
task2 = add_task("Walk the dog")
assert task1["id"] == 1
assert task2["id"] == 2
assert task1["title"] == "Buy groceries"
assert task1["done"] is False
def test_add_task_with_explicit_done():
task = add_task("Read book", done=True)
assert task["done"] is True
def test_add_task_rejects_empty_title():
with pytest.raises(ValueError):
add_task("")
def test_add_task_rejects_non_string_title():
with pytest.raises(ValueError):
add_task(123)
def test_get_tasks_returns_copy():
add_task("Task A")
tasks = get_tasks()
tasks.append({"id": 999, "title": "Injected", "done": False})
# The internal store should not be affected
assert len(get_tasks()) == 1
def test_get_task_returns_none_for_missing_id():
assert get_task(999) is None
def test_get_task_returns_existing_task():
created = add_task("My task")
found = get_task(created["id"])
assert found is not None
assert found["title"] == "My task"
def test_toggle_task_flips_done_status():
task = add_task("Toggle me")
assert task["done"] is False
toggled = toggle_task(task["id"])
assert toggled["done"] is True
toggled_again = toggle_task(task["id"])
assert toggled_again["done"] is False
def test_toggle_task_returns_none_for_missing_id():
assert toggle_task(999) is None
Run these tests with:
pytest test_unit.py -v
These tests are pure Python — no HTTP, no Bottle internals involved. They run in milliseconds and tell you immediately whether your business logic is correct.
Key Principles for Unit Tests
- Test one behavior per test function. If a test fails, you should know exactly what broke.
- Use fixtures to reset state between tests so they remain independent.
- Test both the happy path and edge cases (empty input, wrong types, missing records).
- Keep unit tests free of external dependencies like databases or network calls.
Writing Integration Tests with WebTest
Unit tests verify your logic, but they don't tell you whether your routes are wired correctly, whether JSON parsing works, or whether status codes are right. For that, we need integration tests that send real HTTP-like requests through the Bottle WSGI app.
The easiest way to do this is with WebTest, a library that wraps your WSGI application and lets you simulate requests without starting an actual server. This is faster and more reliable than hitting a live server over a socket.
Create test_integration.py:
import pytest
import json
from webtest import TestApp
from app import app, reset_store
@pytest.fixture
def test_app():
reset_store()
yield TestApp(app)
reset_store()
def test_list_tasks_returns_empty_initially(test_app):
resp = test_app.get("/tasks")
assert resp.status_int == 200
assert resp.json == {"tasks": []}
def test_create_task_returns_201(test_app):
resp = test_app.post_json("/tasks", {"title": "New task"})
assert resp.status_int == 201
assert resp.json["title"] == "New task"
assert resp.json["done"] is False
assert "id" in resp.json
def test_create_task_missing_title_returns_400(test_app):
resp = test_app.post_json("/tasks", {}, expect_errors=True)
assert resp.status_int == 400
assert "error" in resp.json
def test_create_task_empty_title_returns_400(test_app):
resp = test_app.post_json("/tasks", {"title": ""}, expect_errors=True)
assert resp.status_int == 400
assert resp.json["error"] == "Title must be a non-empty string"
def test_get_single_task(test_app):
created = test_app.post_json("/tasks", {"title": "Find me"}).json
resp = test_app.get(f"/tasks/{created['id']}")
assert resp.status_int == 200
assert resp.json["title"] == "Find me"
def test_get_missing_task_returns_404(test_app):
resp = test_app.get("/tasks/999", expect_errors=True)
assert resp.status_int == 404
assert resp.json["error"] == "Task not found"
def test_toggle_task_endpoint(test_app):
created = test_app.post_json("/tasks", {"title": "Toggle me"}).json
resp = test_app.post(f"/tasks/{created['id']}/toggle")
assert resp.status_int == 200
assert resp.json["done"] is True
def test_toggle_missing_task_returns_404(test_app):
resp = test_app.post("/tasks/999/toggle", expect_errors=True)
assert resp.status_int == 404
def test_delete_task(test_app):
created = test_app.post_json("/tasks", {"title": "Delete me"}).json
resp = test_app.delete(f"/tasks/{created['id']}")
assert resp.status_int == 200
assert resp.json == {"deleted": created["id"]}
# Verify it's gone
resp = test_app.get(f"/tasks/{created['id']}", expect_errors=True)
assert resp.status_int == 404
def test_full_workflow(test_app):
"""Integration test covering a realistic user workflow."""
# Create three tasks
t1 = test_app.post_json("/tasks", {"title": "Task 1"}).json
t2 = test_app.post_json("/tasks", {"title": "Task 2"}).json
t3 = test_app.post_json("/tasks", {"title": "Task 3"}).json
# List them
listing = test_app.get("/tasks").json
assert len(listing["tasks"]) == 3
# Complete the second one
test_app.post(f"/tasks/{t2['id']}/toggle")
refreshed = test_app.get(f"/tasks/{t2['id']}").json
assert refreshed["done"] is True
# Delete the first one
test_app.delete(f"/tasks/{t1['id']}")
# Final list should have two tasks
final = test_app.get("/tasks").json
assert len(final["tasks"]) == 2
Run the full suite:
pytest -v
WebTest's TestApp handles JSON serialization, cookies, headers, and status code checking for you. The expect_errors=True flag tells WebTest not to raise an exception when a 4xx or 5xx status is returned, which is exactly what you want when testing error responses.
Testing with Bottle's Built-in Tools
If you prefer not to add WebTest as a dependency, you can use Bottle's own urllib-based test utilities or Python's wsgiref to simulate requests. Here's an example using the standard library only:
import io
import json
import pytest
from wsgiref.validate import validator
from app import app, reset_store
def make_request(method, path, body=None):
"""Simulate a WSGI request using Bottle's internal machinery."""
environ = {
"REQUEST_METHOD": method,
"PATH_INFO": path,
"SERVER_NAME": "localhost",
"SERVER_PORT": "8080",
"wsgi.input": io.BytesIO(body.encode() if body else b""),
"wsgi.errors": io.StringIO(),
"CONTENT_TYPE": "application/json",
"CONTENT_LENGTH": str(len(body) if body else 0),
}
responses = {}
def start_response(status, headers, exc_info=None):
responses["status"] = status
responses["headers"] = headers
result = app(environ, start_response)
body_bytes = b"".join(result)
return responses["status"], body_bytes.decode()
@pytest.fixture(autouse=True)
def clean():
reset_store()
yield
reset_store()
def test_list_tasks_with_wsgi():
status, body = make_request("GET", "/tasks")
assert status.startswith("200")
assert json.loads(body) == {"tasks": []}
def test_create_task_with_wsgi():
payload = json.dumps({"title": "WSGI task"})
status, body = make_request("POST", "/tasks", body=payload)
assert status.startswith("201")
data = json.loads(body)
assert data["title"] == "WSGI task"
This approach works but is more verbose. WebTest is recommended for most projects because it handles the tedious parts of request construction and response parsing.
Best Practices for Testing Bottle Applications
1. Separate Logic from Routing
The single most important thing you can do for testability is to keep business logic out of route handlers. Route handlers should parse input, call a function, and format output. The actual work happens in plain Python functions that are trivial to unit test.
2. Use Fixtures for State Management
Bottle apps often use module-level state (like our _tasks list). Always reset this state in a fixture so tests don't interfere with each other. For larger apps, consider dependency injection or a database with transactions that roll back after each test.
3. Test Status Codes and Response Bodies
Don't just check that a request succeeds — verify the HTTP status code, the response body structure, and any relevant headers. This catches subtle bugs like returning 200 when you meant 201.
4. Cover the Error Paths
It's tempting to only test the happy path, but error handling is where most bugs hide. Test invalid input, missing resources, and malformed JSON. Make sure your app returns appropriate status codes and helpful error messages.
5. Write End-to-End Workflow Tests
In addition to testing individual endpoints, write at least one test that simulates a full user journey — creating, reading, updating, and deleting resources in sequence. This catches issues that only appear when operations are combined.
6. Mock External Dependencies
If your Bottle app calls external APIs, sends emails, or queries a database, mock those dependencies in unit tests. Use unittest.mock.patch or a library like responses to fake HTTP calls. Save real external calls for a small number of slow integration tests.
from unittest.mock import patch
from app import add_task
@patch("app.send_notification")
def test_add_task_does_not_crash_if_notification_fails(mock_send):
mock_send.side_effect = Exception("SMTP down")
task = add_task("Important task")
assert task["title"] == "Important task"
7. Measure Coverage
Use pytest-cov to track how much of your code is exercised by tests:
pip install pytest-cov
pytest --cov=app --cov-report=term-missing
Aim for high coverage on your business logic, but don't obsess over 100% — some code paths (like framework internals) aren't worth testing directly.
Conclusion
Testing Bottle applications doesn't have to be complicated. By separating your business logic into pure functions, you can write fast unit tests that verify correctness in isolation. By wrapping your WSGI app with WebTest, you can write integration tests that exercise the full request/response cycle without the overhead of a real server. Together, these two layers give you a robust safety net that catches regressions early, documents expected behavior, and gives you the confidence to evolve your application over time. Start with the unit tests for your core logic, add integration tests for each route, and gradually build a suite that covers both the happy paths and the edge cases — your future self will thank you.