Skip to content

Commit 61afd0d

Browse files
committed
Added testing docs
1 parent ab3fc90 commit 61afd0d

File tree

10 files changed

+310
-37
lines changed

10 files changed

+310
-37
lines changed

docs/basics/testing.md

Lines changed: 291 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,14 @@ These features include:
1313

1414
Ellar is compatible with `unittest` and `pytest` testing frameworks in python but in this documentation, we will be using `pytest`.
1515

16-
## Getting started
16+
## **Getting started**
1717
You will need to install `pytest`
1818

1919
```shell
2020
pip install pytest
2121
```
2222

23-
## Unit testing
23+
## **Unit testing**
2424
In the following example, we test two classes: `CarController` and `CarRepository`. For this we need to use `TestClientFactory` to build
2525
them in isolation from the application since we are writing unit test.
2626

@@ -29,8 +29,6 @@ We are going to be writing unit test for `CarController` in there.
2929

3030
```python
3131
# project_name/car/tests/test_controllers.py
32-
from unittest.mock import patch
33-
3432
from project_name.apps.car.controllers import CarController
3533
from project_name.apps.car.schemas import CreateCarSerializer, CarListFilter
3634
from project_name.apps.car.services import CarRepository
@@ -52,6 +50,45 @@ class TestCarController:
5250
"name": "Mercedes",
5351
"year": 2022,
5452
}
53+
```
54+
In example above, we aren't really testing anything Ellar-specific. Notice that we are not using dependency injection; rather,
55+
we pass an instance of `CarController` to our `CarRepository`.
56+
This type of testing, where we manually instantiate the classes being tested, is commonly referred to as **isolated testing** because it is framework-independent
57+
58+
## **Using Test Factory**
59+
**Test** factory function in `ellar.testing` package, is a great tool employ for a quick and better test setup.
60+
Let's rewrite the previous example using the built-in `Test` class:
61+
62+
```python
63+
# project_name/car/tests/test_controllers.py
64+
from unittest.mock import patch
65+
from ellar.di import ProviderConfig
66+
from ellar.testing import Test
67+
from project_name.apps.car.controllers import CarController
68+
from project_name.apps.car.schemas import CreateCarSerializer, CarListFilter
69+
from project_name.apps.car.services import CarRepository
70+
71+
72+
class TestCarController:
73+
def setup(self):
74+
test_module = Test.create_test_module(
75+
controllers=[CarController,],
76+
providers=[ProviderConfig(CarRepository, use_class=CarRepository)]
77+
)
78+
self.controller: CarController = test_module.get(CarController)
79+
80+
async def test_create_action(self, anyio_backend):
81+
result = await self.controller.create(
82+
CreateCarSerializer(name="Mercedes", year=2022, model="CLS")
83+
)
84+
85+
assert result == {
86+
"id": "1",
87+
"message": "This action adds a new car",
88+
"model": "CLS",
89+
"name": "Mercedes",
90+
"year": 2022,
91+
}
5592

5693
@patch.object(CarRepository, 'get_all', return_value=[dict(id=2, model='CLS',name='Mercedes', year=2023)])
5794
async def test_get_all_action(self, mock_get_all, anyio_backend):
@@ -69,9 +106,253 @@ class TestCarController:
69106
'message': 'This action returns all cars at limit=10, offset=0'
70107
}
71108
```
72-
In example above, we aren't really testing anything Ellar-specific. Notice that we are not using dependency injection; rather,
73-
we pass an instance of `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
74-
## Using TestClientFactory
75-
## Controller Testing
76-
## Module Testing
77-
## Mocking Services
109+
With the `Test` class, you can create an application execution context that simulates the entire Ellar runtime,
110+
providing hooks to easily manage class instances by allowing for mocking and overriding.
111+
112+
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).
113+
This method returns a `TestingModule` instance which in turn provides a few methods:
114+
115+
- [**`override_provider`**](#overriding-providers): Essential for overriding `providers` or `guards` with a mocked type.
116+
- [**`create_application`**](#create-application): This method will return an application instance for the isolated testing module.
117+
- [**`get_test_client`**](#testclient): creates and return a `TestClient` for the application which will allow you to make requests against your application, using the `httpx` library.
118+
119+
### **Overriding Providers**
120+
`TestingModule` `override_provider` method allows you to provide an alternative for a provider type or a guard type. For example:
121+
122+
```python
123+
from ellar.testing import Test
124+
125+
class MockCarRepository(CarRepository):
126+
pass
127+
128+
class TestCarController:
129+
def setup(self):
130+
test_module = Test.create_test_module(
131+
controllers=[CarController,]
132+
).override_provider(
133+
CarRepository, use_class=MockCarRepository
134+
)
135+
```
136+
`override_provider` takes the same arguments as `ellar.di.ProviderConfig` and in fact, it builds to `ProvideConfig` behind the scenes.
137+
In example above, we created a `MockCarRepository` for `CarRepository` and applied it as shown above.
138+
We can also create an instance of `MockCarRepository` and have it behave as a singleton within the scope of `test_module` instance.
139+
140+
```python
141+
from ellar.testing import Test
142+
143+
class MockCarRepository(CarRepository):
144+
pass
145+
146+
class TestCarController:
147+
def setup(self):
148+
test_module = Test.create_test_module(
149+
controllers=[CarController,]
150+
).override_provider(CarRepository, use_value=MockCarRepository())
151+
```
152+
We this, anywhere `CarRepository` is needed, a `MockCarRepository()` instance will be applied.
153+
154+
In same way, we can override `Guards` used in controllers during testing. For example, lets assume `CarController` has a guard `JWTGuard`
155+
156+
```python
157+
import typing
158+
from ellar.compatible.dict import AttributeDict
159+
from ellar.common import Guards, Controller
160+
from ellar.core import ControllerBase
161+
from ellar.core.guard import HttpBearerAuth
162+
from ellar.di import injectable
163+
164+
165+
@injectable()
166+
class JWTGuard(HttpBearerAuth):
167+
async def authenticate(self, connection, credentials) -> typing.Any:
168+
# JWT verification goes here
169+
return AttributeDict(is_authenticated=True, first_name='Ellar', last_name='ASGI Framework')
170+
171+
172+
@Guards(JWTGuard)
173+
@Controller('/car')
174+
class CarController(ControllerBase):
175+
...
176+
```
177+
During testing, we can replace `JWTGuard` with a `MockAuthGuard` as shown below.
178+
179+
```python
180+
from ellar.testing import Test
181+
from .controllers import CarController, JWTGuard
182+
183+
class MockAuthGuard(JWTGuard):
184+
async def authenticate(self, connection, credentials) -> typing.Any:
185+
# Jwt verification goes here.
186+
return dict(first_name='Ellar', last_name='ASGI Framework')
187+
188+
189+
class TestCarController:
190+
def setup(self):
191+
test_module = Test.create_test_module(
192+
controllers=[CarController,]
193+
).override_provider(JWTGuard, use_class=MockAuthGuard)
194+
```
195+
### **Create Application**
196+
We can access the application instance after setting up the `TestingModule`. You simply need to call `create_application` method of the `TestingModule`.
197+
198+
For example:
199+
```python
200+
from ellar.di import ProviderConfig
201+
from ellar.testing import Test
202+
203+
class TestCarController:
204+
def setup(self):
205+
test_module = Test.create_test_module(
206+
controllers=[CarController,],
207+
providers=[ProviderConfig(CarRepository, use_class=CarRepository)]
208+
)
209+
app = test_module.create_application()
210+
car_repo = app.injector.get(CarRepository)
211+
assert isinstance(car_repo, CarRepository)
212+
```
213+
214+
### **Overriding Application Conf During Testing**
215+
Having different application configurations for different environments is a best practice in software development.
216+
It involves creating different sets of configuration variables, such as database connection details, API keys, and environment-specific settings,
217+
for different environments such as development, staging, and production.
218+
219+
During testing, there two ways to apply or modify configuration.
220+
221+
=== "In a file"
222+
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`.
223+
224+
For example:
225+
226+
```python
227+
# project_name/config.py
228+
229+
...
230+
231+
class BaseConfig(ConfigDefaultTypesMixin):
232+
DEBUG: bool = False
233+
234+
class TestingConfiguration(BaseConfig):
235+
DEBUG = True
236+
ANOTHER_CONFIG_VAR = 'Ellar'
237+
238+
```
239+
We have created `TestingConfiguration` inside `project_name.config` python module. Lets apply this to TestingModule.
240+
241+
```python
242+
# project_name/car/tests/test_controllers.py
243+
244+
class TestCarController:
245+
def setup(self):
246+
test_module = Test.create_test_module(
247+
controllers=[CarController,],
248+
providers=[ProviderConfig(CarRepository, use_class=CarRepository)],
249+
config_module='project_name.config.TestingConfiguration'
250+
)
251+
self.controller: CarController = test_module.get(CarController)
252+
```
253+
=== "Inline"
254+
This method doesn't require configuration file, we simply go ahead and define the configuration variables in a dictionary type set to `config_module`.
255+
256+
For instance:
257+
258+
```python
259+
# project_name/car/tests/test_controllers.py
260+
261+
class TestCarController:
262+
def setup(self):
263+
test_module = Test.create_test_module(
264+
controllers=[CarController,],
265+
providers=[ProviderConfig(CarRepository, use_class=CarRepository)],
266+
config_module=dict(DEBUG=True, ANOTHER_CONFIG_VAR='Ellar')
267+
)
268+
self.controller: CarController = test_module.get(CarController)
269+
```
270+
271+
272+
## **End-to-End Test**
273+
**End-to-end (e2e)** testing operates on a higher level of abstraction than unit testing, assessing the interaction between
274+
classes and modules in a way that approximates user behavior with the production system.
275+
276+
As an application expands, manual e2e testing of every API endpoint becomes increasingly difficult,
277+
which is where automated e2e testing becomes essential in validating that the system's overall behavior is correct and
278+
aligned with project requirements.
279+
280+
To execute e2e tests, we adopt a similar configuration to that of unit testing,
281+
and Ellar's use of **TestClient**, a tool provided by Starlette, to facilitates the simulation of HTTP requests
282+
283+
### **TestClient**
284+
Starlette provides a [TestClient](https://www.starlette.io/testclient/) for making requests ASGI Applications, and it's based on [httpx](https://www.python-httpx.org/) library similar to requests.
285+
```python
286+
from starlette.responses import HTMLResponse
287+
from starlette.testclient import TestClient
288+
289+
290+
async def app(scope, receive, send):
291+
assert scope['type'] == 'http'
292+
response = HTMLResponse('<html><body>Hello, world!</body></html>')
293+
await response(scope, receive, send)
294+
295+
296+
def test_app():
297+
client = TestClient(app)
298+
response = client.get('/')
299+
assert response.status_code == 200
300+
```
301+
In example above, `TestClient` needs an `ASGI` Callable. It exposes the same interface as any other `httpx` session.
302+
In particular, note that the calls to make a request are just standard function calls, not awaitable.
303+
304+
Let's see how we can use `TestClient` in writing e2e testing for `CarController` and `CarRepository`.
305+
306+
```python
307+
# project_name/car/tests/test_controllers.py
308+
from unittest.mock import patch
309+
from ellar.di import ProviderConfig
310+
from ellar.testing import Test, TestClient
311+
from project_name.apps.car.controllers import CarController
312+
from project_name.apps.car.schemas import CreateCarSerializer, CarListFilter
313+
from project_name.apps.car.services import CarRepository
314+
315+
316+
class TestCarControllerE2E:
317+
def setup(self):
318+
test_module = Test.create_test_module(
319+
controllers=[CarController,],
320+
providers=[ProviderConfig(CarRepository, use_class=CarRepository)],
321+
config_module=dict(
322+
REDIRECT_SLASHES=True
323+
)
324+
)
325+
self.client: TestClient = test_module.get_test_client()
326+
327+
def test_create_action(self):
328+
res = self.client.post('/car', json=dict(
329+
name="Mercedes", year=2022, model="CLS"
330+
))
331+
assert res.status_code == 200
332+
assert res.json() == {
333+
"id": "1",
334+
"message": "This action adds a new car",
335+
"model": "CLS",
336+
"name": "Mercedes",
337+
"year": 2022,
338+
}
339+
340+
@patch.object(CarRepository, 'get_all', return_value=[dict(id=2, model='CLS',name='Mercedes', year=2023)])
341+
def test_get_all_action(self, mock_get_all):
342+
res = self.client.get('/car?offset=0&limit=10')
343+
assert res.status_code == 200
344+
assert res.json() == {
345+
'cars': [
346+
{
347+
'id': 2,
348+
'model': 'CLS',
349+
'name': 'Mercedes',
350+
'year': 2023
351+
}
352+
],
353+
'message': 'This action returns all cars at limit=10, offset=0'
354+
}
355+
```
356+
357+
In the construct above, `test_module.get_test_client()` created an isolated application instance and used it to instantiate a `TestClient`.
358+
And with we are able to simulate request behaviour on `CarController`.

ellar/testing/module.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,3 @@ def create_test_module(
121121
return cls.TESTING_MODULE(
122122
testing_module, global_guards=global_guards, config_module=config_module
123123
)
124-
125-
@classmethod
126-
def create_test_module_from_module(
127-
cls,
128-
module: t.Type[t.Union[ModuleBase, t.Any]],
129-
config_module: str = None,
130-
) -> TestingModule:
131-
"""
132-
Create a TestingModule from an existing module
133-
"""
134-
return cls.TESTING_MODULE(module, global_guards=[], config_module=config_module)

tests/test_application/test_application_functions.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@
2828
from .config import ConfigTrustHostConfigure
2929
from .sample import AppAPIKey, ApplicationModule
3030

31-
test_module = Test.create_test_module_from_module(
32-
module=ApplicationModule, config_module=get_class_import(ConfigTrustHostConfigure)
31+
test_module = Test.create_test_module(
32+
modules=(ApplicationModule,),
33+
config_module=get_class_import(ConfigTrustHostConfigure),
3334
)
3435

3536

tests/test_application/test_replacing_app_services.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ def exception_400(self, context: IHostContext, exc: Exception):
108108
"Exception 400 handled by ExampleModule.exception_400"
109109
)
110110

111-
tm = Test.create_test_module_from_module(ExampleModule)
111+
tm = Test.create_test_module(modules=[ExampleModule])
112112

113113
assert hasattr(NewExceptionMiddlewareService, "worked") is False
114114
client = tm.get_test_client()

tests/test_cache/test_module_setup.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44

55

66
def test_cache_module_setup_works():
7-
tm = Test.create_test_module_from_module(
8-
CacheModule.setup(
9-
default=LocalMemCacheBackend(),
10-
local=LocalMemCacheBackend(version=2, ttl=400),
11-
)
7+
tm = Test.create_test_module(
8+
modules=[
9+
CacheModule.setup(
10+
default=LocalMemCacheBackend(),
11+
local=LocalMemCacheBackend(version=2, ttl=400),
12+
)
13+
]
1214
)
1315

1416
cache_service = tm.get(ICacheService)

0 commit comments

Comments
 (0)