Skip to content

Commit d062a8a

Browse files
authored
Merge pull request #80 from eadwinCode/testing_doc
WIP: Testing Documentation
2 parents 321b1a2 + 61afd0d commit d062a8a

File tree

83 files changed

+1144
-652
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

83 files changed

+1144
-652
lines changed

docs/basics/testing.md

Lines changed: 358 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,358 @@
1-
## Coming Soon
1+
Automated testing is the practice of using software tools to automatically run tests on a software application or system,
2+
rather than relying on manual testing by humans. It is considered an essential part of software development as it
3+
helps increase productivity, ensure quality and performance goals are met, and provide faster feedback loops to developers.
4+
Automated tests can include various types such as unit tests, integration tests, end-to-end tests, and more.
5+
6+
While setting up automated tests can be tedious, the benefits of increased test coverage and productivity make it an important aspect of software development.
7+
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.
8+
These features include:
9+
10+
- automatically generated default unit tests files for components testing
11+
- offering a util, `TestClientFactory`, that constructs an isolated module/application setup
12+
- making the Ellar dependency injection system accessible in the testing environment for convenient component mocking.
13+
14+
Ellar is compatible with `unittest` and `pytest` testing frameworks in python but in this documentation, we will be using `pytest`.
15+
16+
## **Getting started**
17+
You will need to install `pytest`
18+
19+
```shell
20+
pip install pytest
21+
```
22+
23+
## **Unit testing**
24+
In the following example, we test two classes: `CarController` and `CarRepository`. For this we need to use `TestClientFactory` to build
25+
them in isolation from the application since we are writing unit test.
26+
27+
Looking at the `car` module we scaffolded earlier, there is a `tests` folder provided and inside that folder there is `test_controllers.py` module.
28+
We are going to be writing unit test for `CarController` in there.
29+
30+
```python
31+
# project_name/car/tests/test_controllers.py
32+
from project_name.apps.car.controllers import CarController
33+
from project_name.apps.car.schemas import CreateCarSerializer, CarListFilter
34+
from project_name.apps.car.services import CarRepository
35+
36+
37+
class TestCarController:
38+
def setup(self):
39+
self.controller: CarController = CarController(repo=CarRepository())
40+
41+
async def test_create_action(self, anyio_backend):
42+
result = await self.controller.create(
43+
CreateCarSerializer(name="Mercedes", year=2022, model="CLS")
44+
)
45+
46+
assert result == {
47+
"id": "1",
48+
"message": "This action adds a new car",
49+
"model": "CLS",
50+
"name": "Mercedes",
51+
"year": 2022,
52+
}
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+
}
92+
93+
@patch.object(CarRepository, 'get_all', return_value=[dict(id=2, model='CLS',name='Mercedes', year=2023)])
94+
async def test_get_all_action(self, mock_get_all, anyio_backend):
95+
result = await self.controller.get_all(query=CarListFilter(offset=0, limit=10))
96+
97+
assert result == {
98+
'cars': [
99+
{
100+
'id': 2,
101+
'model': 'CLS',
102+
'name': 'Mercedes',
103+
'year': 2023
104+
}
105+
],
106+
'message': 'This action returns all cars at limit=10, offset=0'
107+
}
108+
```
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`.

docs/commands/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11

2-
# Introduction
2+
# Ellar CLI
33
Ellar CLI is an abstracted tool for the Ellar web framework that helps in the standard project scaffold of the
44
framework, module project scaffold, running the project local server using UVICORN, and running custom commands registered in the application module or any Ellar module.
55

docs/openapi/index.md

Lines changed: 0 additions & 1 deletion
This file was deleted.

ellar/common/decorators/request.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import typing as t
22

33
from ellar.constants import ON_REQUEST_SHUTDOWN_KEY, ON_REQUEST_STARTUP_KEY
4-
from ellar.core.events import EventHandler
4+
from ellar.events import EventHandler
55

66

77
def set_attr_key(handle: t.Callable, key: str, value: t.Any) -> None:

ellar/core/__init__.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
UJSONResponse,
2121
)
2222
from .templating import render_template, render_template_string
23-
from .testclient import TestClient, TestClientFactory
2423

2524
__all__ = [
2625
"App",
@@ -40,8 +39,6 @@
4039
"BaseHttpAuth",
4140
"GuardCanActivate",
4241
"Config",
43-
"TestClientFactory",
44-
"TestClient",
4542
"JSONResponse",
4643
"UJSONResponse",
4744
"ORJSONResponse",

0 commit comments

Comments
 (0)