Testing¶
Comprehensive testing patterns for Aksara applications.
Overview¶
Aksara provides testing utilities built on pytest:
- Test client — Make API requests
- Database fixtures — Clean database per test
- Factories — Generate test data
- Mocking — Mock services and AI
Setup¶
Install Test Dependencies¶
Configure pytest¶
# pytest.ini
[pytest]
asyncio_mode = auto
testpaths = tests
python_files = test_*.py
python_functions = test_*
Or in pyproject.toml:
Basic Test Structure¶
Test Case Class¶
# tests/test_users.py
import pytest
from aksara.testing import AksaraTestCase
from myapp.models import User
class TestUserModel(AksaraTestCase):
async def asyncSetUp(self):
"""Run before each test."""
self.user = await User.objects.create(
email="test@example.com",
name="Test User"
)
async def asyncTearDown(self):
"""Run after each test."""
pass # Database is rolled back automatically
async def test_user_creation(self):
"""Test creating a user."""
assert self.user.id is not None
assert self.user.email == "test@example.com"
async def test_user_update(self):
"""Test updating a user."""
self.user.name = "Updated Name"
await self.user.save()
refreshed = await User.objects.get(id=self.user.id)
assert refreshed.name == "Updated Name"
Function-Based Tests¶
import pytest
from aksara.testing import async_test, db_session
from myapp.models import User
@pytest.fixture
async def user(db_session):
return await User.objects.create(
email="test@example.com",
name="Test User"
)
@async_test
async def test_user_exists(user):
assert await User.objects.filter(id=user.id).exists()
API Testing¶
Test Client¶
from aksara.testing import AksaraTestCase
class TestPostAPI(AksaraTestCase):
async def asyncSetUp(self):
self.user = await User.objects.create(
email="test@example.com",
password=hash_password("password")
)
self.token = create_token(self.user)
async def test_list_posts(self):
"""Test listing posts."""
response = await self.client.get("/api/posts/")
assert response.status_code == 200
assert isinstance(response.json(), list)
async def test_create_post_authenticated(self):
"""Test creating a post while authenticated."""
response = await self.client.post(
"/api/posts/",
json={"title": "Test", "content": "Content"},
headers={"Authorization": f"Bearer {self.token}"}
)
assert response.status_code == 201
assert response.json()["title"] == "Test"
async def test_create_post_unauthenticated(self):
"""Test creating a post without auth."""
response = await self.client.post(
"/api/posts/",
json={"title": "Test", "content": "Content"}
)
assert response.status_code == 401
Authentication Helpers¶
class TestAuthenticatedAPI(AksaraTestCase):
async def asyncSetUp(self):
self.user = await User.objects.create(
email="test@example.com",
password=hash_password("password")
)
# Login helper
self.authenticate(self.user)
async def test_protected_endpoint(self):
# Auth headers added automatically
response = await self.client.get("/api/me/")
assert response.status_code == 200
Request Assertions¶
async def test_post_detail(self):
post = await Post.objects.create(title="Test", content="Content")
response = await self.client.get(f"/api/posts/{post.id}/")
# Status code
assert response.status_code == 200
# Response data
data = response.json()
assert data["id"] == str(post.id)
assert data["title"] == "Test"
# Response headers
assert "application/json" in response.headers["content-type"]
Factories¶
Basic Factory¶
# tests/factories.py
from aksara.testing import Factory, Faker
from myapp.models import User, Post
class UserFactory(Factory):
class Meta:
model = User
email = Faker("email")
name = Faker("name")
password = "hashed_password"
is_active = True
class PostFactory(Factory):
class Meta:
model = Post
title = Faker("sentence")
content = Faker("paragraph")
author = Factory.SubFactory(UserFactory)
is_published = True
Using Factories¶
from tests.factories import UserFactory, PostFactory
class TestPosts(AksaraTestCase):
async def test_with_factories(self):
# Create single instance
user = await UserFactory.create()
# Create with overrides
admin = await UserFactory.create(
email="admin@example.com",
is_admin=True
)
# Create multiple
users = await UserFactory.create_batch(5)
# Create with related objects
post = await PostFactory.create(
author=admin,
title="Admin Post"
)
Factory Sequences¶
class UserFactory(Factory):
class Meta:
model = User
# Unique values using sequence
email = Factory.Sequence(lambda n: f"user{n}@example.com")
username = Factory.Sequence(lambda n: f"user{n}")
Factory Traits¶
class UserFactory(Factory):
class Meta:
model = User
email = Faker("email")
is_admin = False
is_active = True
class Params:
admin = Factory.Trait(is_admin=True)
inactive = Factory.Trait(is_active=False)
# Usage
admin = await UserFactory.create(admin=True)
inactive = await UserFactory.create(inactive=True)
Database Fixtures¶
Automatic Rollback¶
from aksara.testing import AksaraTestCase
class TestDatabase(AksaraTestCase):
# Database is automatically rolled back after each test
async def test_creates_data(self):
await User.objects.create(email="test@example.com")
count = await User.objects.count()
assert count == 1
async def test_clean_database(self):
# Previous test's data is gone
count = await User.objects.count()
assert count == 0
Shared Fixtures¶
import pytest
from aksara.testing import db_session
@pytest.fixture(scope="module")
async def test_data(db_session):
"""Create test data shared across module."""
user = await User.objects.create(email="shared@example.com")
posts = await PostFactory.create_batch(10, author=user)
return {"user": user, "posts": posts}
async def test_first(test_data):
assert len(test_data["posts"]) == 10
async def test_second(test_data):
# Same data available
assert test_data["user"].email == "shared@example.com"
Mocking¶
Mock External Services¶
from unittest.mock import AsyncMock, patch
class TestPayment(AksaraTestCase):
@patch("myapp.services.payment.process_payment")
async def test_checkout(self, mock_payment):
mock_payment.return_value = {"status": "success", "transaction_id": "123"}
response = await self.client.post(
"/api/checkout/",
json={"amount": 100}
)
assert response.status_code == 200
mock_payment.assert_called_once_with(amount=100)
Mock AI Services¶
from aksara.testing import mock_ai
class TestAIFeatures(AksaraTestCase):
async def test_ai_query(self):
with mock_ai(response="SELECT * FROM users"):
response = await self.client.post(
"/api/ai/query/",
json={"query": "Get all users"}
)
assert response.status_code == 200
Mock Database Queries¶
from unittest.mock import patch, AsyncMock
async def test_with_mocked_query():
mock_users = [User(id="1", email="test@example.com")]
with patch.object(User.objects, "all", new_callable=AsyncMock) as mock:
mock.return_value = mock_users
users = await User.objects.all()
assert len(users) == 1
Testing Async Code¶
Async Test Functions¶
import pytest
@pytest.mark.asyncio
async def test_async_function():
result = await some_async_function()
assert result == expected
# With AksaraTestCase, async is automatic
class TestAsync(AksaraTestCase):
async def test_no_decorator_needed(self):
result = await some_async_function()
assert result == expected
Testing Background Tasks¶
from aksara.testing import capture_tasks
class TestBackgroundTasks(AksaraTestCase):
async def test_creates_background_task(self):
with capture_tasks() as tasks:
response = await self.client.post("/api/send-email/")
assert len(tasks) == 1
assert tasks[0].name == "send_email"
Testing ViewSets¶
ViewSet Testing¶
from aksara.testing import AksaraTestCase
from myapp.viewsets import PostViewSet
class TestPostViewSet(AksaraTestCase):
async def test_list_action(self):
await PostFactory.create_batch(5)
response = await self.client.get("/api/posts/")
assert response.status_code == 200
assert len(response.json()) == 5
async def test_custom_action(self):
post = await PostFactory.create(is_published=False)
self.authenticate(post.author)
response = await self.client.post(f"/api/posts/{post.id}/publish/")
assert response.status_code == 200
# Verify database change
await post.refresh_from_db()
assert post.is_published is True
Permission Testing¶
class TestPermissions(AksaraTestCase):
async def test_admin_only_endpoint(self):
user = await UserFactory.create(is_admin=False)
self.authenticate(user)
response = await self.client.delete("/api/posts/123/")
assert response.status_code == 403
async def test_owner_can_edit(self):
post = await PostFactory.create()
self.authenticate(post.author)
response = await self.client.patch(
f"/api/posts/{post.id}/",
json={"title": "Updated"}
)
assert response.status_code == 200
Testing Migrations¶
from aksara.testing import MigrationTestCase
class TestMigrations(MigrationTestCase):
async def test_migration_0003(self):
# Apply up to migration before
await self.migrate("myapp", "0002")
# Create data in old schema
await self.execute("INSERT INTO posts (title) VALUES ('Test')")
# Apply the migration
await self.migrate("myapp", "0003")
# Verify migration worked
result = await self.execute("SELECT slug FROM posts WHERE title = 'Test'")
assert result[0]["slug"] == "test"
Test Configuration¶
Test Settings¶
# tests/conftest.py
import pytest
from aksara.testing import setup_test_database
@pytest.fixture(scope="session")
def test_settings():
return {
"DATABASE_URL": "postgresql://localhost/test_db",
"DEBUG": True,
"AI_MODE": False,
}
@pytest.fixture(scope="session", autouse=True)
async def setup_database(test_settings):
await setup_test_database(test_settings)
Test Markers¶
import pytest
# Skip slow tests
@pytest.mark.slow
async def test_slow_operation():
pass
# Run only in CI
@pytest.mark.ci_only
async def test_integration():
pass
# pytest.ini
# markers =
# slow: marks tests as slow
# ci_only: marks tests for CI only
Running Tests¶
Basic Commands¶
# Run all tests
aksara test
# Run specific file
aksara test tests/test_users.py
# Run specific test
aksara test tests/test_users.py::TestUserAPI::test_create
# Run with coverage
aksara test --cov=myapp
# Run with verbose output
aksara test -v
Test Coverage¶
# Generate coverage report
aksara test --cov=myapp --cov-report=html
# View report
open htmlcov/index.html
Best Practices¶
Do¶
- Test one thing per test
- Use descriptive test names
- Use factories for test data
- Clean up after tests
- Test edge cases
Don't¶
- Don't test framework code
- Don't rely on test order
- Don't share mutable state
- Don't skip error handling tests
Naming Conventions¶
# Good: descriptive, action-focused
async def test_create_user_with_valid_data():
async def test_create_user_fails_with_invalid_email():
async def test_delete_post_requires_authentication():
# Bad: vague, non-descriptive
async def test_user():
async def test_api():
async def test_1():