← Back to DevBytes

Testing Bottle Applications: Unit Tests to Integration

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:

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles