Standards and conventions for writing tests in the TaimakoAI backend.
# Run full suite
make backend-test # via Docker
uv run pytest tests/ -v # locally
# Run by category
uv run pytest tests/unit/ -v # fast, no DB
uv run pytest tests/api/ -v # API endpoints
uv run pytest tests/integration/ -v # multi-component
# Run a single file or test
uv run pytest tests/api/test_plans.py -v
uv run pytest tests/unit/test_security.py::TestVerifyToken::test_verify_token_expired -vtests/
conftest.py # Root fixtures: db_session, client, auth helpers
factories.py # factory-boy model factories
unit/ # Pure logic tests. No DB, no HTTP.
test_response_wrapper.py
test_security.py
test_subscription_tier.py
test_subscription_factory.py
test_paystack_service.py
test_whatsapp_service.py
test_email_service.py
tools/ # Agent tool tests
conftest.py # Tool-specific fixtures (mock_tool_context, etc.)
test_say_hello.py
test_say_goodbye.py
test_analyze_sentiment.py
test_get_context.py
test_escalate_to_human.py
api/ # Endpoint tests. Uses TestClient + in-memory SQLite.
test_root.py
test_auth_google.py
test_auth_local.py
test_business.py
test_documents.py
test_plans.py
test_subscription.py
test_webhook.py
test_escalation.py
test_analytics.py
test_widget_settings.py
test_widget_chat.py
test_whatsapp_api.py
integration/ # Multi-component tests.
test_rag_scoped.py
test_automated_analysis.py
test_escalation_flow.py
| You are testing... | Put it in... |
|---|---|
| A pure function (no DB, no HTTP) | tests/unit/ |
| A utility, helper, or service method | tests/unit/ |
| An agent tool (say_hello, get_context...) | tests/unit/tools/ |
| An API endpoint (request -> response) | tests/api/ |
| A workflow that spans multiple services | tests/integration/ |
Follow test_<what>_<scenario>_<expected>:
def test_verify_token_expired_returns_none():
...
def test_list_plans_no_active_plans_returns_empty():
...
def test_webhook_duplicate_reference_skips_processing():
...Group related tests in classes. Use nested classes for sub-categories:
class TestVerifyToken:
def test_valid_token(self):
...
def test_expired_token(self):
...The root conftest.py provides these fixtures:
| Fixture | Returns | Use when... |
|---|---|---|
db_session |
SQLAlchemy Session (in-memory SQLite) | You need a database |
client |
FastAPI TestClient | Testing endpoints |
authenticated_client |
(client, user) |
Endpoint requires auth |
auth_client_with_business |
(client, user, business) |
Endpoint requires a business |
auth_client_with_widget |
(client, user, business, widget) |
Widget endpoint testing |
mock_vector_db |
MagicMock | Mocking ChromaDB |
Factory sessions are auto-bound -- just call UserFactory() etc. without any setup.
Use factory-boy factories from tests/factories.py for test data:
from tests.factories import UserFactory, BusinessFactory, PlanFactory
def test_something(db_session):
user = UserFactory(email="custom@test.com")
business = BusinessFactory(user=user, user_id=user.id, subscription_tier="nexus")
plan = PlanFactory(name="nexus", tier=2, price=10000)
db_session.commit() # flush to DB
...Available factories: UserFactory, BusinessFactory, WidgetSettingsFactory, GuestUserFactory, ChatSessionFactory, EscalationFactory, GuestMessageFactory, PlanFactory, PaymentTransactionFactory.
When you add a new model, add a factory for it.
External services (Gemini, ChromaDB, Paystack, WhatsApp API) are mocked at import time in conftest.py. For endpoint-level mocking:
from unittest.mock import patch, AsyncMock
MOCK_SERVICE = "app.services.subscription.factory.SubscriptionServiceFactory.get_service"
def test_initialize(self, auth_client_with_business, db_session):
client, user, business = auth_client_with_business
plan = PlanFactory(name="spark", tier=1)
db_session.commit()
mock_svc = MagicMock()
mock_svc.initialize_subscription.return_value = {"authorization_url": "https://pay.test"}
with patch(MOCK_SERVICE, return_value=mock_svc):
resp = client.post("/subscription/initialize", json={"plan_id": plan.id, "provider": "paystack"})
assert resp.status_code == 200For async services (like run_conversation):
@patch("app.api.widget.run_conversation", new_callable=AsyncMock, return_value="Hello!")
def test_chat(self, mock_ai, client, ...):
...All endpoints return {status, message, data}. Assert against this:
resp = client.get("/public/plans")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "success"
assert isinstance(body["data"], list)- Add a new API endpoint -- Add to the corresponding
tests/api/test_*.pyfile. - Add a new agent tool -- Add a new file in
tests/unit/tools/. - Add a new service or utility -- Add a unit test in
tests/unit/. - Fix a bug -- Write a regression test that would have caught the bug.
- Change business logic (credit calculation, subscription flow, limits) -- Update or add tests verifying the new behavior.
- Pure HTML/template changes.
- Config-only changes (env vars, settings).
- Database migrations (these are tested by running
alembic upgrade head).
- All existing tests pass:
uv run pytest tests/ -v - Lint passes:
uv run ruff check . - New code has corresponding tests
- No hardcoded secrets or real API keys in test files
- Factory used for test data (not raw SQL or manual model construction)
-
One assertion per concept. A test can have multiple
assertlines, but they should all verify the same logical thing. -
Don't test framework behavior. Don't test that FastAPI returns 422 for missing required fields -- that's Pydantic's job.
-
Use factories, not raw models.
UserFactory()handles UUID generation, timestamps, and defaults. ManualUser(id=..., email=..., ...)is fragile. -
Mock at the boundary. Mock
run_conversation, not individual LLM calls. MockSubscriptionServiceFactory.get_service(), nothttpx.Client.post. -
Commit after factories. Factories call
session.flush()but notsession.commit(). Always calldb_session.commit()before making API requests. -
Keep tests independent. Each test gets a fresh database. Never depend on state from another test.
-
Async tests just work. The pytest config has
asyncio_mode = "auto". Just writeasync def test_...and it runs.