Testing¶
Automated testing is the practice of using software tools to automatically run tests on a software application or system, rather than relying on manual testing by humans. It is considered an essential part of software development as it helps increase productivity, ensure quality and performance goals are met, and provide faster feedback loops to developers. Automated tests can include various types such as unit tests, integration tests, end-to-end tests, and more.
While setting up automated tests can be tedious, the benefits of increased test coverage and productivity make it an important aspect of software development. Ellar aims to encourage the use of development best practices, including effective testing, by providing various features to assist developers and teams in creating and automating tests. These features include:
- automatically generated default unit tests files for components testing
- offering a util,
TestFactory class, that constructs an isolated module/application setup - making the Ellar dependency injection system accessible in the testing environment for convenient component mocking.
Ellar is compatible with unittest and pytest testing frameworks in python but in this documentation, we will be using pytest.
Getting started¶
You will need to install pytest
pip install pytest
Unit testing¶
In the following example, we test two classes: CarController and CarRepository. For this we need to use TestClientFactory to build them in isolation from the application since we are writing unit test.
Looking at the car module we scaffolded earlier, there is a tests folder provided and inside that folder there is test_controllers.py module. We are going to be writing unit test for CarController in there.
# project_name/car/tests/test_controllers.py
from project_name.apps.car.controllers import CarController
from project_name.apps.car.schemas import CreateCarSerializer, CarListFilter
from project_name.apps.car.services import CarRepository
class TestCarController:
def setup(self):
self.controller: CarController = CarController(repo=CarRepository())
async def test_create_action(self, anyio_backend):
result = await self.controller.create(
CreateCarSerializer(name="Mercedes", year=2022, model="CLS")
)
assert result == {
"id": "1",
"message": "This action adds a new car",
"model": "CLS",
"name": "Mercedes",
"year": 2022,
}
CarController to our CarRepository. This type of testing, where we manually instantiate the classes being tested, is commonly referred to as isolated testing because it is framework-independent Using Test Factory¶
Test factory function in ellar.testing package, is a great tool employ for a quick and better test setup. Let's rewrite the previous example using the built-in Test class:
# project_name/car/tests/test_controllers.py
from unittest.mock import patch
from ellar.di import ProviderConfig
from ellar.testing import Test
from project_name.apps.car.controllers import CarController
from project_name.apps.car.schemas import CreateCarSerializer, CarListFilter
from project_name.apps.car.services import CarRepository
class TestCarController:
def setup(self):
test_module = Test.create_test_module(
controllers=[CarController,],
providers=[ProviderConfig(CarRepository, use_class=CarRepository)]
)
self.controller: CarController = test_module.get(CarController)
async def test_create_action(self, anyio_backend):
result = await self.controller.create(
CreateCarSerializer(name="Mercedes", year=2022, model="CLS")
)
assert result == {
"id": "1",
"message": "This action adds a new car",
"model": "CLS",
"name": "Mercedes",
"year": 2022,
}
@patch.object(CarRepository, 'get_all', return_value=[dict(id=2, model='CLS',name='Mercedes', year=2023)])
async def test_get_all_action(self, mock_get_all, anyio_backend):
result = await self.controller.get_all(query=CarListFilter(offset=0, limit=10))
assert result == {
'cars': [
{
'id': 2,
'model': 'CLS',
'name': 'Mercedes',
'year': 2023
}
],
'message': 'This action returns all cars at limit=10, offset=0'
}
Test class, you can create an application execution context that simulates the entire Ellar runtime, providing hooks to easily manage class instances by allowing for mocking and overriding. The Test class has a create_test_module() method that takes a module metadata object as its argument (the same object you pass to the @Module() decorator). This method returns a TestingModule instance which in turn provides a few methods:
override_provider: Essential for overridingprovidersorguardswith a mocked type.create_application: This method will return an application instance for the isolated testing module.get_test_client: creates and return aTestClientfor the application which will allow you to make requests against your application, using thehttpxlibrary.
Automatic Dependency Resolution¶
When testing controllers that depend on services from other modules, manually registering all required modules in your test setup can be tedious and error-prone. Ellar provides automatic dependency resolution to eliminate this boilerplate.
The Problem¶
Consider a controller that requires services from multiple modules:
@Controller('/users')
class UserController:
def __init__(self, auth_service: IAuthService, db: IDatabaseService):
self.auth_service = auth_service
self.db = db
Without automatic resolution, you must manually register all dependencies:
# Manual approach - error-prone!
test_module = Test.create_test_module(
controllers=[UserController],
modules=[AuthModule, DatabaseModule, LoggingModule] # Must list EVERYTHING
)
If you forget to register a module, you get an UnsatisfiedRequirement error during test execution.
The Solution:¶
By providing the ApplicationModule in the Test.create_test_module() method, Ellar will automatically resolve missing dependencies needed to test the controller.
You can provide the ApplicationModule in two ways:
1. As a module type (direct import):
# New approach - automatic!
test_module = Test.create_test_module(
controllers=[UserController],
application_module=ApplicationModule # Automatically resolves dependencies!
)
2. As an import string (avoids circular imports):
# Using import string - useful to avoid circular imports
test_module = Test.create_test_module(
controllers=[UserController],
application_module="app.module:ApplicationModule" # Import string!
)
When you provide application_module, Ellar: 1. Analyzes the controller's __init__ parameters to identify required services 2. Searches the ApplicationModule tree to find modules that provide those services 3. Automatically includes those modules (and their nested dependencies) in the test module
How It Works¶
Controller Dependency Analysis¶
Ellar inspects your controller's __init__ signature to identify required services:
@Controller()
class AdminController:
def __init__(self, auth: IAuthService, db: IDatabaseService):
# Ellar detects: needs IAuthService and IDatabaseService
self.auth = auth
self.db = db
Recursive Module Resolution¶
When a module depends on other modules, all nested dependencies are automatically included:
@Module(
modules=[LoggingModule], # Nested dependency
providers=[ProviderConfig(IDatabaseService, use_class=DatabaseService)],
exports=[IDatabaseService]
)
class DatabaseModule:
pass
# When DatabaseModule is needed, LoggingModule is automatically included!
ForwardRefModule Support¶
ForwardRefModule instances are automatically resolved:
@Module(
modules=[
DatabaseModule,
ForwardRefModule(CacheModule) # Reference without setup
]
)
class ApplicationModule:
pass
# CacheModule is automatically resolved when needed!
test_module = Test.create_test_module(
controllers=[ProductController], # Needs ICacheService
application_module=ApplicationModule # Resolves ForwardRef automatically
)
Mocking and Overriding¶
You can still override specific modules for mocking - explicitly provided modules take precedence:
class MockAuthService(IAuthService):
def authenticate(self, token: str):
return {"user": "test_user"}
@Module(
providers=[ProviderConfig(IAuthService, use_class=MockAuthService)],
exports=[IAuthService]
)
class MockAuthModule:
pass
test_module = Test.create_test_module(
controllers=[UserController],
modules=[MockAuthModule], # Explicitly provided - takes precedence!
application_module=ApplicationModule # Still resolves other dependencies
)
# Result: Uses MockAuthModule for IAuthService, but DatabaseModule from ApplicationModule
Complete Isolation¶
When you need complete test isolation, simply omit the application_module parameter:
# Full isolation - no automatic resolution
test_module = Test.create_test_module(
controllers=[UserController],
modules=[MockAuthModule, MockDatabaseModule], # Must provide everything
# No application_module - complete isolation
)
Using Import Strings (Avoid Circular Imports)¶
If importing your ApplicationModule directly causes circular import issues, use an import string:
# In your test file - no direct import needed!
test_module = Test.create_test_module(
controllers=[UserController],
application_module="myapp.root_module:ApplicationModule" # String reference
)
This is especially useful when: - Your test file and ApplicationModule would create circular imports - You want to keep test files independent of the main application structure - You're testing across different packages
Example: Before and After¶
# Must manually register ALL dependencies
test_module = Test.create_test_module(
controllers=[AdminController],
modules=[
AuthModule,
DatabaseModule,
LoggingModule, # Don't forget nested deps!
CacheModule
]
)
# Dependencies automatically resolved!
test_module = Test.create_test_module(
controllers=[AdminController],
application_module=ApplicationModule
)
# AuthModule, DatabaseModule, LoggingModule, CacheModule all auto-included!
Tagged Dependencies Support¶
When controllers use InjectByTag for dependency injection, the resolver automatically finds and registers the appropriate modules:
from ellar.di import InjectByTag, ProviderConfig
from ellar.common.types import T
# Module with tagged provider
@Module(
providers=[
ProviderConfig(IUserRepository, use_class=UserRepository, tag="user_repo")
],
exports=[IUserRepository]
)
class UserModule:
pass
# Controller using tagged dependency (supports both syntaxes)
@Controller()
class UserController:
def __init__(self, user_repo: InjectByTag[T("user_repo")]): # Generic syntax
self.user_repo = user_repo
# Or use callable syntax: InjectByTag('user_repo')
# Automatic resolution works with tags!
test_module = Test.create_test_module(
controllers=[UserController],
application_module=ApplicationModule
)
# UserModule is automatically included because UserController needs tag 'user_repo'
Complex Nested Dependencies¶
The resolver handles deep dependency trees automatically:
@Module(
providers=[ProviderConfig(ILogger, use_class=Logger)],
exports=[ILogger]
)
class LoggingModule:
pass
@Module(
modules=[LoggingModule], # Nested dependency
providers=[ProviderConfig(IDatabase, use_class=PostgresDB)],
exports=[IDatabase]
)
class DatabaseModule:
pass
@Module(
modules=[DatabaseModule], # Even deeper nesting
providers=[ProviderConfig(IUserService, use_class=UserService)],
exports=[IUserService]
)
class UserModule:
pass
# Controller only knows about IUserService
@Controller()
class UserController:
def __init__(self, user_service: IUserService):
self.user_service = user_service
# All nested dependencies automatically resolved!
test_module = Test.create_test_module(
controllers=[UserController],
application_module=ApplicationModule
)
# Result: UserModule, DatabaseModule, and LoggingModule all included
Error Messages¶
When dependencies cannot be resolved, you get clear, actionable error messages:
@Controller()
class OrderController:
def __init__(self, payment_service: IPaymentService):
self.payment_service = payment_service
# If IPaymentService isn't provided by any module:
test_module = Test.create_test_module(
controllers=[OrderController],
application_module=ApplicationModule
)
# Raises: DependencyResolutionError with suggestions:
# OrderController requires IPaymentService, but it's not found in ApplicationModule.
# Please either:
# 1. Register the module providing IPaymentService in ApplicationModule
# 2. Register it explicitly in Test.create_test_module(modules=[...])
# 3. Provide it as a mock in providers=[...]
Benefits¶
- Zero Boilerplate: No need to manually track module dependencies
- Maintainable: Tests automatically adapt when module structure changes
- Fail-Fast: Missing dependencies caught at test setup time, not during execution
- Supports All Patterns: Works with regular DI, ForwardRef, and InjectByTag
- Flexible: Can still override specific modules for mocking
Practical Testing Patterns¶
Pattern 1: Integration Testing with Real Dependencies¶
Test controllers with actual service implementations:
def test_user_registration_flow():
"""Test full user registration with real services"""
test_module = Test.create_test_module(
controllers=[UserController],
application_module=ApplicationModule
)
client = test_module.get_test_client()
response = client.post("/users/register", json={
"email": "user@example.com",
"password": "secure123"
})
assert response.status_code == 201
assert response.json()["email"] == "user@example.com"
Pattern 2: Partial Mocking¶
Mock specific services while using real implementations for others:
def test_user_login_with_mock_email():
"""Test login flow with mocked email service"""
class MockEmailService(IEmailService):
sent_emails = []
def send(self, to: str, subject: str, body: str):
self.sent_emails.append({"to": to, "subject": subject})
@Module(
providers=[ProviderConfig(IEmailService, use_class=MockEmailService)],
exports=[IEmailService]
)
class MockEmailModule:
pass
test_module = Test.create_test_module(
controllers=[UserController],
modules=[MockEmailModule], # Override email service
application_module=ApplicationModule # Use real auth, database, etc.
)
client = test_module.get_test_client()
response = client.post("/users/login", json={
"email": "user@example.com",
"password": "password123"
})
# Verify email was sent
email_service = test_module.get(IEmailService)
assert len(email_service.sent_emails) == 1
assert "Login successful" in email_service.sent_emails[0]["subject"]
Pattern 3: Testing with Tagged Dependencies¶
Test controllers that use InjectByTag for flexible dependency injection:
def test_payment_processing_with_different_gateways():
"""Test payment with different gateway implementations"""
@Module(
providers=[
ProviderConfig(IPaymentGateway, use_class=StripeGateway, tag="stripe"),
ProviderConfig(IPaymentGateway, use_class=PayPalGateway, tag="paypal"),
],
exports=[IPaymentGateway]
)
class PaymentModule:
pass
@Controller()
class PaymentController:
def __init__(self, stripe: InjectByTag('stripe'), paypal: InjectByTag('paypal')):
self.stripe = stripe
self.paypal = paypal
test_module = Test.create_test_module(
controllers=[PaymentController],
application_module=ApplicationModule
)
controller = test_module.get(PaymentController)
assert isinstance(controller.stripe, StripeGateway)
assert isinstance(controller.paypal, PayPalGateway)
Pattern 4: Complete Test Isolation¶
For unit tests, provide all dependencies explicitly:
def test_user_service_logic_in_isolation():
"""Unit test UserService without any external dependencies"""
class InMemoryUserRepo(IUserRepository):
def __init__(self):
self.users = {}
def save(self, user):
self.users[user.id] = user
test_module = Test.create_test_module(
providers=[
UserService,
ProviderConfig(IUserRepository, use_class=InMemoryUserRepo)
]
# No application_module - complete isolation
)
service = test_module.get(UserService)
user = service.create_user("test@example.com", "password")
assert user.email == "test@example.com"
Overriding Providers¶
Ellar provides two ways to override providers for testing:
Method 1: Using override_provider() on TestingModule¶
This is the recommended approach for quickly overriding specific services after creating the test module:
from ellar.testing import Test
class MockAuthService(IAuthService):
def authenticate(self, token: str):
return {"user": "test_user", "authenticated": True}
# Create test module with automatic resolution
test_module = Test.create_test_module(
controllers=[UserController],
application_module=ApplicationModule
)
# Override specific provider
test_module.override_provider(IAuthService, use_class=MockAuthService)
# Or override with a specific instance
mock_instance = MockAuthService()
test_module.override_provider(IAuthService, use_value=mock_instance)
# Now create the application
app = test_module.create_application()
Key Features: - Called on the TestingModule instance - Supports both use_class and use_value parameters - Must be called before create_application() - Perfect for quick overrides without creating mock modules
Method 2: Providing Mock Modules¶
Create a dedicated mock module and pass it in the modules parameter. This approach is useful when: - You want to organize mock implementations into reusable modules - You need to override multiple related services at once - You want to share mock modules across multiple tests
from ellar.testing import Test
from ellar.common import Module, ProviderConfig
class MockAuthService(IAuthService):
def authenticate(self, token: str):
return {"user": "test_user", "authenticated": True}
# Create a mock module
@Module(
providers=[ProviderConfig(IAuthService, use_class=MockAuthService)],
exports=[IAuthService]
)
class MockAuthModule:
pass
# Pass the mock module explicitly
test_module = Test.create_test_module(
controllers=[UserController],
modules=[MockAuthModule], # Explicitly provided - takes precedence
application_module=ApplicationModule # Still resolves other dependencies
)
app = test_module.create_application()
# Result: Uses MockAuthService, but other dependencies (like Database) come from ApplicationModule
Key Features: - Mock modules explicitly passed take precedence over ApplicationModule - Can override multiple related services in one module - Great for organizing test fixtures - Reusable across multiple test cases
Choosing Between Methods¶
| Use Case | Method 1 (override_provider) | Method 2 (Mock Modules) |
|---|---|---|
| Quick single service override | ✅ Recommended | ❌ Overkill |
| Override multiple related services | ⚠️ Multiple calls needed | ✅ Recommended |
| Reusable across tests | ⚠️ Needs duplication | ✅ Recommended |
| Simple test scenarios | ✅ Recommended | ⚠️ More setup |
| Complex mock setup | ⚠️ Can get verbose | ✅ Recommended |
Example: Choosing the right method
# Use Method 1 for simple overrides
test_module = Test.create_test_module(
controllers=[UserController],
application_module=ApplicationModule
).override_provider(IAuthService, use_class=MockAuthService)
# Use Method 2 when overriding multiple services
@Module(
providers=[
ProviderConfig(IAuthService, use_class=MockAuthService),
ProviderConfig(IEmailService, use_class=MockEmailService),
ProviderConfig(ISmsService, use_class=MockSmsService),
],
exports=[IAuthService, IEmailService, ISmsService]
)
class MockNotificationModule:
pass
test_module = Test.create_test_module(
controllers=[UserController],
modules=[MockNotificationModule],
application_module=ApplicationModule
)
Overriding Guards¶
You can override UseGuards used in controllers during testing. For example, let's assume CarController has a guard JWTGuard
import typing
from ellar.common.compatible import AttributeDict
from ellar.common import UseGuards, Controller, ControllerBase
from ellar.core.guard import HttpBearerAuth
from ellar.di import injectable
@injectable()
class JWTGuard(HttpBearerAuth):
async def authenticate(self, connection, credentials) -> typing.Any:
# JWT verification goes here
return AttributeDict(is_authenticated=True, first_name='Ellar', last_name='ASGI Framework')
@UseGuards(JWTGuard)
@Controller('/car')
class CarController(ControllerBase):
...
JWTGuard with a MockAuthGuard as shown below. from ellar.testing import Test
from .controllers import CarController, JWTGuard
class MockAuthGuard(JWTGuard):
async def authenticate(self, connection, credentials) -> typing.Any:
# Jwt verification goes here.
return dict(first_name='Ellar', last_name='ASGI Framework')
class TestCarController:
def setup(self):
test_module = Test.create_test_module(
controllers=[CarController,]
).override_provider(JWTGuard, use_class=MockAuthGuard)
Create Application¶
We can access the application instance after setting up the TestingModule. You simply need to call create_application method of the TestingModule.
For example:
from ellar.di import ProviderConfig
from ellar.testing import Test
class TestCarController:
def setup(self):
test_module = Test.create_test_module(
controllers=[CarController,],
providers=[ProviderConfig(CarRepository, use_class=CarRepository)]
)
app = test_module.create_application()
car_repo = app.injector.get(CarRepository)
assert isinstance(car_repo, CarRepository)
Overriding Application Conf During Testing¶
Having different application configurations for different environments is a best practice in software development. It involves creating different sets of configuration variables, such as database connection details, API keys, and environment-specific settings, for different environments such as development, staging, and production.
During testing, there two ways to apply or modify configuration.
In config.py file, we can define another configuration for testing eg, class TestConfiguration and then we can apply it to config_module when creating TestingModule.
For example:
# project_name/config.py
...
class BaseConfig(ConfigDefaultTypesMixin):
DEBUG: bool = False
class TestingConfiguration(BaseConfig):
DEBUG = True
ANOTHER_CONFIG_VAR = 'Ellar'
TestingConfiguration inside project_name.config python module. Lets apply this to TestingModule. # project_name/car/tests/test_controllers.py
class TestCarController:
def setup(self):
test_module = Test.create_test_module(
controllers=[CarController,],
providers=[ProviderConfig(CarRepository, use_class=CarRepository)],
config_module='project_name.config:TestingConfiguration'
)
self.controller: CarController = test_module.get(CarController)
# project_name/tests/conftest.py
import os
from ellar.constants import ELLAR_CONFIG_MODULE
os.environ.setdefault(ELLAR_CONFIG_MODULE, 'project_name.config:TestingConfiguration')
This method doesn't require configuration file, we simply go ahead and define the configuration variables in a dictionary type set to config_module.
For instance:
# project_name/car/tests/test_controllers.py
class TestCarController:
def setup(self):
test_module = Test.create_test_module(
controllers=[CarController,],
providers=[ProviderConfig(CarRepository, use_class=CarRepository)],
config_module=dict(DEBUG=True, ANOTHER_CONFIG_VAR='Ellar')
)
self.controller: CarController = test_module.get(CarController)
End-to-End Test¶
End-to-end (e2e) testing operates on a higher level of abstraction than unit testing, assessing the interaction between classes and modules in a way that approximates user behavior with the production system.
As an application expands, manual e2e testing of every API endpoint becomes increasingly difficult, which is where automated e2e testing becomes essential in validating that the system's overall behavior is correct and aligned with project requirements.
To execute e2e tests, we adopt a similar configuration to that of unit testing, and Ellar's use of TestClient, a tool provided by Starlette, to facilitates the simulation of HTTP requests
TestClient¶
Starlette provides a TestClient for making requests ASGI Applications, and it's based on httpx library similar to requests.
from starlette.responses import HTMLResponse
from starlette.testclient import TestClient
async def app(scope, receive, send):
assert scope['type'] == 'http'
response = HTMLResponse('<html><body>Hello, world!</body></html>')
await response(scope, receive, send)
def test_app():
client = TestClient(app)
response = client.get('/')
assert response.status_code == 200
TestClient needs an ASGI Callable. It exposes the same interface as any other httpx session. In particular, note that the calls to make a request are just standard function calls, not awaitable. Let's see how we can use TestClient in writing e2e testing for CarController and CarRepository.
# project_name/car/tests/test_controllers.py
from ellar.di import ProviderConfig
from ellar.testing import Test, TestClient
from project_name.apps.car.controllers import CarController
from project_name.apps.car.services import CarRepository
class MockCarRepository(CarRepository):
def get_all(self):
return [dict(id=2, model='CLS',name='Mercedes', year=2023)]
class TestCarControllerE2E:
def setup(self):
test_module = Test.create_test_module(
controllers=[CarController,],
providers=[ProviderConfig(CarRepository, use_class=MockCarRepository)],
config_module=dict(
REDIRECT_SLASHES=True
)
)
self.client: TestClient = test_module.get_test_client()
def test_create_action(self):
res = self.client.post('/car', json=dict(
name="Mercedes", year=2022, model="CLS"
))
assert res.status_code == 200
assert res.json() == {
"id": "1",
"message": "This action adds a new car",
"model": "CLS",
"name": "Mercedes",
"year": 2022,
}
def test_get_all_action(self):
res = self.client.get('/car?offset=0&limit=10')
assert res.status_code == 200
assert res.json() == {
'cars': [
{
'id': 2,
'model': 'CLS',
'name': 'Mercedes',
'year': 2023
}
],
'message': 'This action returns all cars at limit=10, offset=0'
}
In the construct above, test_module.get_test_client() created an isolated application instance and used it to instantiate a TestClient. And with we are able to simulate request behaviour on CarController.