Skip to content

Commit 2ea7509

Browse files
committed
better isolated tests
1 parent bdc98ff commit 2ea7509

File tree

4 files changed

+315
-116
lines changed

4 files changed

+315
-116
lines changed

README.md

Lines changed: 87 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -294,10 +294,10 @@ Then head to `http://127.0.0.1:8000/docs`.
294294

295295
### 3.3 From Scratch
296296

297-
Install poetry:
297+
Install uv:
298298

299299
```sh
300-
pip install poetry
300+
pip install uv
301301
```
302302

303303
## 4. Usage
@@ -329,7 +329,7 @@ So you may skip to [5. Extending](#5-extending).
329329
In the `root` directory (`FastAPI-boilerplate` if you didn't change anything), run to install required packages:
330330

331331
```sh
332-
poetry install
332+
uv sync
333333
```
334334

335335
Ensuring it ran without any problem.
@@ -401,7 +401,7 @@ redis:alpine
401401
While in the `root` folder, run to start the application with uvicorn server:
402402

403403
```sh
404-
poetry run uvicorn src.app.main:app --reload
404+
uv run uvicorn src.app.main:app --reload
405405
```
406406

407407
> \[!TIP\]
@@ -471,7 +471,7 @@ docker-compose stop create_superuser
471471
While in the `root` folder, run (after you started the application at least once to create the tables):
472472

473473
```sh
474-
poetry run python -m src.scripts.create_first_superuser
474+
uv run python -m src.scripts.create_first_superuser
475475
```
476476

477477
### 4.3.3 Creating the first tier
@@ -516,17 +516,17 @@ Getting:
516516
While in the `src` folder, run Alembic migrations:
517517

518518
```sh
519-
poetry run alembic revision --autogenerate
519+
uv run alembic revision --autogenerate
520520
```
521521

522522
And to apply the migration
523523

524524
```sh
525-
poetry run alembic upgrade head
525+
uv run alembic upgrade head
526526
```
527527

528528
> [!NOTE]
529-
> If you do not have poetry, you may run it without poetry after running `pip install alembic`
529+
> If you do not have uv, you may run it without uv after running `pip install alembic`
530530
531531
## 5. Extending
532532

@@ -538,22 +538,22 @@ First, you may want to take a look at the project structure and understand what
538538
.
539539
├── Dockerfile # Dockerfile for building the application container.
540540
├── docker-compose.yml # Docker Compose file for defining multi-container applications.
541-
├── pyproject.toml # Poetry configuration file with project metadata and dependencies.
541+
├── pyproject.toml # Project configuration file with metadata and dependencies (PEP 621).
542+
├── uv.lock # uv lock file specifying exact versions of dependencies.
542543
├── README.md # Project README providing information and instructions.
543544
├── LICENSE.md # License file for the project.
544545
545-
├── tests # Unit and integration tests for the application.
546+
├── tests # Unit tests for the application.
546547
│ ├──helpers # Helper functions for tests.
547548
│ │ ├── generators.py # Helper functions for generating test data.
548-
│ │ └── mocks.py # Mock function for testing.
549+
│ │ └── mocks.py # Mock functions for testing.
549550
│ ├── __init__.py
550551
│ ├── conftest.py # Configuration and fixtures for pytest.
551-
│ └── test_user.py # Test cases for user-related functionality.
552+
│ └── test_user_unit.py # Unit test cases for user-related functionality.
552553
553554
└── src # Source code directory.
554555
├── __init__.py # Initialization file for the src package.
555556
├── alembic.ini # Configuration file for Alembic (database migration tool).
556-
├── poetry.lock # Poetry lock file specifying exact versions of dependencies.
557557
558558
├── app # Main application directory.
559559
│ ├── __init__.py # Initialization file for the app package.
@@ -737,13 +737,13 @@ class EntityDelete(BaseModel):
737737
Then, while in the `src` folder, run Alembic migrations:
738738

739739
```sh
740-
poetry run alembic revision --autogenerate
740+
uv run alembic revision --autogenerate
741741
```
742742

743743
And to apply the migration
744744

745745
```sh
746-
poetry run alembic upgrade head
746+
uv run alembic upgrade head
747747
```
748748

749749
### 5.6 CRUD
@@ -1299,7 +1299,7 @@ If you are using `docker compose`, the worker is already running.
12991299
If you are doing it from scratch, run while in the `root` folder:
13001300

13011301
```sh
1302-
poetry run arq src.app.core.worker.settings.WorkerSettings
1302+
uv run arq src.app.core.worker.settings.WorkerSettings
13031303
```
13041304

13051305
#### Database session with background tasks
@@ -1512,13 +1512,13 @@ If you are doing it from scratch, ensure your postgres and your redis are runnin
15121512
while in the `root` folder, run to start the application with uvicorn server:
15131513

15141514
```sh
1515-
poetry run uvicorn src.app.main:app --reload
1515+
uv run uvicorn src.app.main:app --reload
15161516
```
15171517

15181518
And for the worker:
15191519

15201520
```sh
1521-
poetry run arq src.app.core.worker.settings.WorkerSettings
1521+
uv run arq src.app.core.worker.settings.WorkerSettings
15221522
```
15231523
### 5.14 Create Application
15241524

@@ -1834,74 +1834,101 @@ And finally, on your browser: `http://localhost/docs`.
18341834

18351835
## 7. Testing
18361836

1837-
While in the tests folder, create your test file with the name "test\_{entity}.py", replacing entity with what you're testing
1837+
This project uses **fast unit tests** that don't require external services like databases or Redis. Tests are isolated using mocks and run in milliseconds.
1838+
1839+
### 7.1 Writing Tests
1840+
1841+
Create test files with the name `test_{entity}_unit.py` in the `tests/` folder, replacing `{entity}` with what you're testing:
18381842

18391843
```sh
1840-
touch test_items.py
1844+
touch tests/test_items_unit.py
18411845
```
18421846

1843-
Finally create your tests (you may want to copy the structure in test_user.py)
1847+
Follow the structure in `tests/test_user_unit.py` for examples. Our tests use:
18441848

1845-
Now, to run:
1849+
- **pytest** with **pytest-asyncio** for async support
1850+
- **unittest.mock** for mocking dependencies
1851+
- **AsyncMock** for async function mocking
1852+
- **Faker** for generating test data
18461853

1847-
### 7.1 Docker Compose
1854+
Example test structure:
18481855

1849-
First you need to uncomment the following part in the `docker-compose.yml` file:
1856+
```python
1857+
import pytest
1858+
from unittest.mock import AsyncMock, patch
1859+
from src.app.api.v1.users import write_user
18501860
1851-
```YAML
1852-
#-------- uncomment to run tests --------
1853-
# pytest:
1854-
# build:
1855-
# context: .
1856-
# dockerfile: Dockerfile
1857-
# env_file:
1858-
# - ./src/.env
1859-
# depends_on:
1860-
# - db
1861-
# - redis
1862-
# command: python -m pytest ./tests
1863-
# volumes:
1864-
# - .:/code
1861+
class TestWriteUser:
1862+
@pytest.mark.asyncio
1863+
async def test_create_user_success(self, mock_db, sample_user_data):
1864+
"""Test successful user creation."""
1865+
with patch("src.app.api.v1.users.crud_users") as mock_crud:
1866+
mock_crud.exists = AsyncMock(return_value=False)
1867+
mock_crud.create = AsyncMock(return_value=Mock(id=1))
1868+
1869+
result = await write_user(Mock(), sample_user_data, mock_db)
1870+
1871+
assert result.id == 1
1872+
mock_crud.create.assert_called_once()
18651873
```
18661874

1867-
You'll get:
1875+
### 7.2 Running Tests
18681876

1869-
```YAML
1870-
#-------- uncomment to run tests --------
1871-
pytest:
1872-
build:
1873-
context: .
1874-
dockerfile: Dockerfile
1875-
env_file:
1876-
- ./src/.env
1877-
depends_on:
1878-
- db
1879-
- redis
1880-
command: python -m pytest ./tests
1881-
volumes:
1882-
- .:/code
1877+
Run all unit tests:
1878+
1879+
```sh
1880+
uv run pytest
18831881
```
18841882

1885-
Start the Docker Compose services:
1883+
Run specific test file:
18861884

18871885
```sh
1888-
docker-compose up -d
1886+
uv run pytest tests/test_user_unit.py
18891887
```
18901888

1891-
It will automatically run the tests, but if you want to run again later:
1889+
Run specific test file:
18921890

18931891
```sh
1894-
docker-compose run --rm pytest
1892+
uv run pytest tests/test_user_unit.py
18951893
```
18961894

1897-
### 7.2 From Scratch
1895+
Run with verbose output:
1896+
1897+
```sh
1898+
uv run pytest -v
1899+
```
18981900

1899-
While in the `root` folder, run:
1901+
Run specific test:
19001902

19011903
```sh
1902-
poetry run python -m pytest
1904+
uv run pytest tests/test_user_unit.py::TestWriteUser::test_create_user_success
1905+
```
1906+
1907+
### 7.3 Test Configuration
1908+
1909+
Tests are configured in `pyproject.toml`:
1910+
1911+
```toml
1912+
[tool.pytest.ini_options]
1913+
filterwarnings = [
1914+
"ignore::PendingDeprecationWarning:starlette.formparsers",
1915+
]
19031916
```
19041917

1918+
### 7.4 Test Structure
1919+
1920+
- **Unit Tests** (`test_*_unit.py`): Fast, isolated tests with mocked dependencies
1921+
- **Fixtures** (`conftest.py`): Shared test fixtures and mock setups
1922+
- **Helpers** (`tests/helpers/`): Utilities for generating test data and mocks
1923+
1924+
### 7.5 Benefits of Our Approach
1925+
1926+
✅ **Fast**: Tests run in ~0.04 seconds
1927+
✅ **Reliable**: No external dependencies required
1928+
✅ **Isolated**: Each test focuses on one piece of functionality
1929+
✅ **Maintainable**: Easy to understand and modify
1930+
✅ **CI/CD Ready**: Run anywhere without infrastructure setup
1931+
19051932
## 8. Contributing
19061933

19071934
Read [contributing](CONTRIBUTING.md).

tests/conftest.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
from typing import Any, Callable, Generator
1+
from collections.abc import Callable, Generator
2+
from typing import Any
3+
from unittest.mock import AsyncMock, Mock
24

35
import pytest
46
from faker import Faker
57
from fastapi.testclient import TestClient
68
from sqlalchemy import create_engine
9+
from sqlalchemy.ext.asyncio import AsyncSession
710
from sqlalchemy.orm import sessionmaker
811
from sqlalchemy.orm.session import Session
912

@@ -37,3 +40,63 @@ def db() -> Generator[Session, Any, None]:
3740

3841
def override_dependency(dependency: Callable[..., Any], mocked_response: Any) -> None:
3942
app.dependency_overrides[dependency] = lambda: mocked_response
43+
44+
45+
@pytest.fixture
46+
def mock_db():
47+
"""Mock database session for unit tests."""
48+
return Mock(spec=AsyncSession)
49+
50+
51+
@pytest.fixture
52+
def mock_redis():
53+
"""Mock Redis connection for unit tests."""
54+
mock_redis = Mock()
55+
mock_redis.get = AsyncMock(return_value=None)
56+
mock_redis.set = AsyncMock(return_value=True)
57+
mock_redis.delete = AsyncMock(return_value=True)
58+
return mock_redis
59+
60+
61+
@pytest.fixture
62+
def sample_user_data():
63+
"""Generate sample user data for tests."""
64+
return {
65+
"name": fake.name(),
66+
"username": fake.user_name(),
67+
"email": fake.email(),
68+
"password": fake.password(),
69+
}
70+
71+
72+
@pytest.fixture
73+
def sample_user_read():
74+
"""Generate a sample UserRead object."""
75+
import uuid
76+
77+
from src.app.schemas.user import UserRead
78+
79+
return UserRead(
80+
id=1,
81+
uuid=uuid.uuid4(),
82+
name=fake.name(),
83+
username=fake.user_name(),
84+
email=fake.email(),
85+
profile_image_url=fake.image_url(),
86+
is_superuser=False,
87+
created_at=fake.date_time(),
88+
updated_at=fake.date_time(),
89+
tier_id=None,
90+
)
91+
92+
93+
@pytest.fixture
94+
def current_user_dict():
95+
"""Mock current user from auth dependency."""
96+
return {
97+
"id": 1,
98+
"username": fake.user_name(),
99+
"email": fake.email(),
100+
"name": fake.name(),
101+
"is_superuser": False,
102+
}

tests/helpers/generators.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,3 @@ def create_user(db: Session, is_super_user: bool = False) -> models.User:
2323
db.refresh(_user)
2424

2525
return _user
26-

0 commit comments

Comments
 (0)