Tests
By convention, Mountaineer applications use pytest and pytest-asyncio for comprehensive testing. The framework provides patterns for testing controllers, sideeffects, database operations, and frontend integrations with proper isolation and setup. You can use whatever testing framework you want if you're willing to go off road.
When you create a new project with create-mountaineer-app, it automatically sets up a complete testing environment with the necessary dependencies and configuration.
Test Structure
Project Setup
Every new Mountaineer project includes a __tests__/ directory with a pre-configured testing environment:
my_webapp/
├── __tests__/
│ ├── __init__.py
│ ├── conftest.py # Test configuration and fixtures
│ └── test_*.py # Your test files
├── controllers/
├── models/
├── views/
└── pyproject.toml
Test Configuration
Base Configuration (conftest.py)
The template provides a robust test configuration that handles database setup and dependency injection. The generated conftest.py includes:
- Automatic config setup with test database configuration
- Database connection fixture that provides clean isolation between tests
- Schema recreation for each test to ensure isolation
You can view the complete implementation in the template conftest.py.
Key Fixtures Available
The generated test configuration provides these fixtures:
config: Auto-used fixture that sets up test-time configurationdb_connection: Async fixture providing a clean database connection for each test
Environment Variables
Create a .env.test file for test-specific configuration:
TEST_POSTGRES_HOST=localhost
TEST_POSTGRES_USER=my_webapp
TEST_POSTGRES_PASSWORD=mysecretpassword
TEST_POSTGRES_DB=my_webapp_test_db
POSTGRES_PORT=5438
Core Testing Patterns
Testing Controller Render Functions
The render() function is the heart of every controller - it defines what data your frontend receives. Testing render functions ensures that your controllers produce the correct data structure and handle various input scenarios properly.
What we're testing: The render function should return the expected data payload based on the current database state and request parameters. This is essentially testing your "read" operations.
Testing Render with Data
The most common scenario is testing that when a user visits a page, they see the correct data from the database. This test validates your application's "happy path" - when everything is working correctly and you have data to display.
We set up some test data in the database, create a mock request (simulating a user visiting the page), call the render method, and verify both the response structure and the actual content.
import pytest
from fastapi import Request
from unittest.mock import Mock
from my_webapp.controllers.home import HomeController, HomeRender
from my_webapp.models.todo import TodoItem
@pytest.mark.asyncio
async def test_home_render_with_todos(db_connection):
"""Test that the home controller renders todo data correctly."""
controller = HomeController()
# Set up test data in the database - this simulates having real todos
await db_connection.insert([
TodoItem(description="Learn Mountaineer", completed=False),
TodoItem(description="Build awesome app", completed=True)
])
# Create a mock request (this would normally come from FastAPI)
mock_request = Mock(spec=Request)
mock_request.client = Mock()
mock_request.client.host = "127.0.0.1"
# Call the render method - this is what happens when a user visits the page
result = await controller.render(
request=mock_request,
db_connection=db_connection
)
# Verify the response structure and data
assert isinstance(result, HomeRender)
assert result.client_ip == "127.0.0.1"
assert len(result.todos) == 2
# Check that both todos are present with correct data
todo_descriptions = [todo.description for todo in result.todos]
assert "Learn Mountaineer" in todo_descriptions
assert "Build awesome app" in todo_descriptions
Testing Render with Empty State
Your application should gracefully handle empty states, which is common when users first visit your app or when they've cleared all their data. This test ensures that your render function doesn't break when there's no data to display.
@pytest.mark.asyncio
async def test_home_render_empty_state(db_connection):
"""Test that the render function handles empty data gracefully."""
controller = HomeController()
mock_request = Mock(spec=Request)
mock_request.client = Mock()
mock_request.client.host = "192.168.1.1"
# Call render with empty database
result = await controller.render(
request=mock_request,
db_connection=db_connection
)
# Should still return valid structure with empty todos
assert isinstance(result, HomeRender)
assert result.client_ip == "192.168.1.1"
assert len(result.todos) == 0
Testing Action Functions (Sideeffects & Passthroughs)
Action functions handle the "write" operations in your application - creating, updating, or deleting data. These tests ensure that user interactions properly modify your application state.
What we're testing: Action functions should correctly process user input, update the database, and trigger appropriate render refreshes. Both @sideeffect and @passthrough decorated functions follow similar testing patterns.
Testing Data Creation
This test validates the most common user interaction - adding new data to your application. When users submit a form or click a button to create something new, you want to ensure that data is correctly saved to the database.
Since this uses a @sideeffect decorator, it will also trigger a render refresh on the frontend, keeping the UI in sync with the database.
import pytest
from my_webapp.controllers.home import HomeController
from my_webapp.models.todo import TodoItem
from iceaxe import select
@pytest.mark.asyncio
async def test_add_todo_action(db_connection):
"""Test adding a new todo item via sideeffect."""
controller = HomeController()
# Set up initial state - maybe the user already has one todo
await db_connection.insert([
TodoItem(description="Existing task", completed=False)
])
# Simulate user action - adding a new todo
await controller.add_todo(
payload={"description": "New important task"},
db_connection=db_connection
)
# Verify the database was updated correctly
todos = await db_connection.exec(select(TodoItem))
assert len(todos) == 2 # Original + new todo
# Find the new todo and verify its properties
new_todos = [todo for todo in todos if todo.description == "New important task"]
assert len(new_todos) == 1
new_todo = new_todos[0]
assert new_todo.completed is False # Should default to incomplete
assert new_todo.id is not None # Should have generated ID
Testing Data Updates
This test covers modification operations - when users interact with existing data to change its state. A common example is checking off a todo item to mark it complete.
The test ensures that only the intended fields are modified while leaving other data unchanged.
@pytest.mark.asyncio
async def test_toggle_todo_action(db_connection):
"""Test updating a todo's completion status."""
controller = HomeController()
# Create a todo to modify
todo_id = "550e8400-e29b-41d4-a716-446655440000"
await db_connection.insert([
TodoItem(
id=todo_id,
description="Task to complete",
completed=False
)
])
# Simulate user toggling the todo completion
await controller.toggle_todo(
payload={"todo_id": todo_id},
db_connection=db_connection
)
# Verify the todo was updated
todos = await db_connection.exec(select(TodoItem))
updated_todo = todos[0]
assert updated_todo.completed is True
assert updated_todo.description == "Task to complete" # Description unchanged
Testing Error Handling
Your action functions should properly handle invalid input and provide appropriate feedback to users. This test ensures that when users submit malformed or incomplete data, your application gracefully rejects it without corrupting the database.
@pytest.mark.asyncio
async def test_action_with_validation_error(db_connection):
"""Test that action functions handle invalid input properly."""
controller = HomeController()
# Test with invalid payload - missing required field
with pytest.raises(ValidationError):
await controller.add_todo(
payload={"invalid_field": "value"}, # Missing 'description'
db_connection=db_connection
)
# Verify no data was created in the database
todos = await db_connection.exec(select(TodoItem))
assert len(todos) == 0
Running Tests
Basic Test Execution
# Run all tests
pytest
# Run tests with verbose output
pytest -v
# Run specific test file
pytest __tests__/test_render.py
# Run tests matching a pattern
pytest -k "render"
Database Testing
Make sure your test database is running before executing database tests:
# Start test database (if using Docker)
docker compose -f docker-compose.test.yml up -d
# Run database tests
pytest __tests__/ -v
# Clean up
docker compose -f docker-compose.test.yml down
Reference
For more examples of testing patterns, see: