omgkit 2.1.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugin/skills/SKILL_STANDARDS.md +743 -0
- package/plugin/skills/databases/mongodb/SKILL.md +797 -28
- package/plugin/skills/databases/prisma/SKILL.md +776 -30
- package/plugin/skills/databases/redis/SKILL.md +885 -25
- package/plugin/skills/devops/aws/SKILL.md +686 -28
- package/plugin/skills/devops/github-actions/SKILL.md +684 -29
- package/plugin/skills/devops/kubernetes/SKILL.md +621 -24
- package/plugin/skills/frameworks/django/SKILL.md +920 -20
- package/plugin/skills/frameworks/express/SKILL.md +1361 -35
- package/plugin/skills/frameworks/fastapi/SKILL.md +1260 -33
- package/plugin/skills/frameworks/laravel/SKILL.md +1244 -31
- package/plugin/skills/frameworks/nestjs/SKILL.md +1005 -26
- package/plugin/skills/frameworks/rails/SKILL.md +594 -28
- package/plugin/skills/frameworks/spring/SKILL.md +528 -35
- package/plugin/skills/frameworks/vue/SKILL.md +1296 -27
- package/plugin/skills/frontend/accessibility/SKILL.md +1108 -34
- package/plugin/skills/frontend/frontend-design/SKILL.md +1304 -26
- package/plugin/skills/frontend/responsive/SKILL.md +847 -21
- package/plugin/skills/frontend/shadcn-ui/SKILL.md +976 -38
- package/plugin/skills/frontend/tailwindcss/SKILL.md +831 -35
- package/plugin/skills/frontend/threejs/SKILL.md +1298 -29
- package/plugin/skills/languages/javascript/SKILL.md +935 -31
- package/plugin/skills/methodology/brainstorming/SKILL.md +597 -23
- package/plugin/skills/methodology/defense-in-depth/SKILL.md +832 -34
- package/plugin/skills/methodology/dispatching-parallel-agents/SKILL.md +665 -31
- package/plugin/skills/methodology/executing-plans/SKILL.md +556 -24
- package/plugin/skills/methodology/finishing-development-branch/SKILL.md +595 -25
- package/plugin/skills/methodology/problem-solving/SKILL.md +429 -61
- package/plugin/skills/methodology/receiving-code-review/SKILL.md +536 -24
- package/plugin/skills/methodology/requesting-code-review/SKILL.md +632 -21
- package/plugin/skills/methodology/root-cause-tracing/SKILL.md +641 -30
- package/plugin/skills/methodology/sequential-thinking/SKILL.md +262 -3
- package/plugin/skills/methodology/systematic-debugging/SKILL.md +571 -32
- package/plugin/skills/methodology/test-driven-development/SKILL.md +779 -24
- package/plugin/skills/methodology/testing-anti-patterns/SKILL.md +691 -29
- package/plugin/skills/methodology/token-optimization/SKILL.md +598 -29
- package/plugin/skills/methodology/verification-before-completion/SKILL.md +543 -22
- package/plugin/skills/methodology/writing-plans/SKILL.md +590 -18
- package/plugin/skills/omega/omega-architecture/SKILL.md +838 -39
- package/plugin/skills/omega/omega-coding/SKILL.md +636 -39
- package/plugin/skills/omega/omega-sprint/SKILL.md +855 -48
- package/plugin/skills/omega/omega-testing/SKILL.md +940 -41
- package/plugin/skills/omega/omega-thinking/SKILL.md +703 -50
- package/plugin/skills/security/better-auth/SKILL.md +1065 -28
- package/plugin/skills/security/oauth/SKILL.md +968 -31
- package/plugin/skills/security/owasp/SKILL.md +894 -33
- package/plugin/skills/testing/playwright/SKILL.md +764 -38
- package/plugin/skills/testing/pytest/SKILL.md +873 -36
- package/plugin/skills/testing/vitest/SKILL.md +980 -35
|
@@ -1,58 +1,895 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pytest
|
|
3
|
-
description: Python testing with pytest
|
|
3
|
+
description: Python testing with pytest including fixtures, parametrization, mocking, async testing, and CI integration
|
|
4
|
+
category: testing
|
|
5
|
+
triggers:
|
|
6
|
+
- pytest
|
|
7
|
+
- python testing
|
|
8
|
+
- unit testing python
|
|
9
|
+
- fixtures
|
|
10
|
+
- test automation
|
|
4
11
|
---
|
|
5
12
|
|
|
6
|
-
# Pytest
|
|
13
|
+
# Pytest
|
|
7
14
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
15
|
+
Enterprise-grade **Python testing framework** following industry best practices. This skill covers fixtures, parametrization, mocking, async testing, markers, plugins, and CI integration patterns used by top engineering teams.
|
|
16
|
+
|
|
17
|
+
## Purpose
|
|
18
|
+
|
|
19
|
+
Build comprehensive Python test suites:
|
|
20
|
+
|
|
21
|
+
- Write clear and maintainable unit tests
|
|
22
|
+
- Create reusable fixtures for test setup
|
|
23
|
+
- Implement parametrized tests for edge cases
|
|
24
|
+
- Mock external dependencies effectively
|
|
25
|
+
- Test async code with pytest-asyncio
|
|
26
|
+
- Generate coverage reports
|
|
27
|
+
- Integrate with CI/CD pipelines
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
### 1. Configuration Setup
|
|
12
32
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
33
|
+
```ini
|
|
34
|
+
# pytest.ini
|
|
35
|
+
[pytest]
|
|
36
|
+
testpaths = tests
|
|
37
|
+
python_files = test_*.py *_test.py
|
|
38
|
+
python_classes = Test*
|
|
39
|
+
python_functions = test_*
|
|
40
|
+
addopts =
|
|
41
|
+
-v
|
|
42
|
+
--strict-markers
|
|
43
|
+
--tb=short
|
|
44
|
+
-ra
|
|
45
|
+
--cov=src
|
|
46
|
+
--cov-report=term-missing
|
|
47
|
+
--cov-report=html:coverage_html
|
|
48
|
+
--cov-fail-under=80
|
|
49
|
+
markers =
|
|
50
|
+
slow: marks tests as slow (deselect with '-m "not slow"')
|
|
51
|
+
integration: marks tests as integration tests
|
|
52
|
+
unit: marks tests as unit tests
|
|
53
|
+
smoke: marks tests as smoke tests
|
|
54
|
+
filterwarnings =
|
|
55
|
+
error
|
|
56
|
+
ignore::DeprecationWarning
|
|
57
|
+
asyncio_mode = auto
|
|
16
58
|
```
|
|
17
59
|
|
|
18
|
-
|
|
60
|
+
```toml
|
|
61
|
+
# pyproject.toml
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
testpaths = ["tests"]
|
|
64
|
+
python_files = ["test_*.py"]
|
|
65
|
+
addopts = "-v --strict-markers --tb=short"
|
|
66
|
+
markers = [
|
|
67
|
+
"slow: marks tests as slow",
|
|
68
|
+
"integration: marks tests as integration tests",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[tool.coverage.run]
|
|
72
|
+
source = ["src"]
|
|
73
|
+
branch = true
|
|
74
|
+
omit = ["*/tests/*", "*/__init__.py"]
|
|
75
|
+
|
|
76
|
+
[tool.coverage.report]
|
|
77
|
+
exclude_lines = [
|
|
78
|
+
"pragma: no cover",
|
|
79
|
+
"def __repr__",
|
|
80
|
+
"raise NotImplementedError",
|
|
81
|
+
"if TYPE_CHECKING:",
|
|
82
|
+
]
|
|
83
|
+
fail_under = 80
|
|
84
|
+
show_missing = true
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### 2. Fixture Patterns
|
|
88
|
+
|
|
19
89
|
```python
|
|
90
|
+
# tests/conftest.py
|
|
91
|
+
from typing import Generator, AsyncGenerator
|
|
92
|
+
from collections.abc import Iterator
|
|
93
|
+
import pytest
|
|
94
|
+
from unittest.mock import MagicMock, AsyncMock
|
|
95
|
+
from sqlalchemy import create_engine
|
|
96
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
97
|
+
from httpx import AsyncClient
|
|
98
|
+
|
|
99
|
+
from src.database import Base
|
|
100
|
+
from src.main import app
|
|
101
|
+
from src.models import User, Organization
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Database fixtures
|
|
105
|
+
@pytest.fixture(scope="session")
|
|
106
|
+
def engine():
|
|
107
|
+
"""Create test database engine."""
|
|
108
|
+
engine = create_engine(
|
|
109
|
+
"postgresql://test:test@localhost:5432/test_db",
|
|
110
|
+
echo=False,
|
|
111
|
+
)
|
|
112
|
+
Base.metadata.create_all(engine)
|
|
113
|
+
yield engine
|
|
114
|
+
Base.metadata.drop_all(engine)
|
|
115
|
+
|
|
116
|
+
|
|
20
117
|
@pytest.fixture
|
|
21
|
-
def
|
|
22
|
-
|
|
118
|
+
def db_session(engine) -> Generator[Session, None, None]:
|
|
119
|
+
"""Create database session with automatic rollback."""
|
|
120
|
+
connection = engine.connect()
|
|
121
|
+
transaction = connection.begin()
|
|
122
|
+
session = sessionmaker(bind=connection)()
|
|
23
123
|
|
|
124
|
+
yield session
|
|
125
|
+
|
|
126
|
+
session.close()
|
|
127
|
+
transaction.rollback()
|
|
128
|
+
connection.close()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# Factory fixtures
|
|
24
132
|
@pytest.fixture
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
133
|
+
def user_factory(db_session: Session):
|
|
134
|
+
"""Factory for creating test users."""
|
|
135
|
+
def _create_user(
|
|
136
|
+
email: str = "test@example.com",
|
|
137
|
+
name: str = "Test User",
|
|
138
|
+
is_active: bool = True,
|
|
139
|
+
**kwargs,
|
|
140
|
+
) -> User:
|
|
141
|
+
user = User(
|
|
142
|
+
email=email,
|
|
143
|
+
name=name,
|
|
144
|
+
is_active=is_active,
|
|
145
|
+
**kwargs,
|
|
146
|
+
)
|
|
147
|
+
db_session.add(user)
|
|
148
|
+
db_session.commit()
|
|
149
|
+
db_session.refresh(user)
|
|
150
|
+
return user
|
|
151
|
+
|
|
152
|
+
return _create_user
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@pytest.fixture
|
|
156
|
+
def test_user(user_factory) -> User:
|
|
157
|
+
"""Create a standard test user."""
|
|
158
|
+
return user_factory(
|
|
159
|
+
email="testuser@example.com",
|
|
160
|
+
name="Test User",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@pytest.fixture
|
|
165
|
+
def admin_user(user_factory) -> User:
|
|
166
|
+
"""Create an admin test user."""
|
|
167
|
+
return user_factory(
|
|
168
|
+
email="admin@example.com",
|
|
169
|
+
name="Admin User",
|
|
170
|
+
is_admin=True,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# Mock fixtures
|
|
175
|
+
@pytest.fixture
|
|
176
|
+
def mock_email_service() -> MagicMock:
|
|
177
|
+
"""Mock email service."""
|
|
178
|
+
mock = MagicMock()
|
|
179
|
+
mock.send_email.return_value = {"message_id": "test-123"}
|
|
180
|
+
return mock
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@pytest.fixture
|
|
184
|
+
def mock_payment_gateway() -> MagicMock:
|
|
185
|
+
"""Mock payment gateway."""
|
|
186
|
+
mock = MagicMock()
|
|
187
|
+
mock.charge.return_value = {
|
|
188
|
+
"id": "ch_123",
|
|
189
|
+
"status": "succeeded",
|
|
190
|
+
"amount": 1000,
|
|
191
|
+
}
|
|
192
|
+
mock.refund.return_value = {
|
|
193
|
+
"id": "re_123",
|
|
194
|
+
"status": "succeeded",
|
|
195
|
+
}
|
|
196
|
+
return mock
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# Async fixtures
|
|
200
|
+
@pytest.fixture
|
|
201
|
+
async def async_client() -> AsyncGenerator[AsyncClient, None]:
|
|
202
|
+
"""Create async HTTP client for API testing."""
|
|
203
|
+
async with AsyncClient(app=app, base_url="http://test") as client:
|
|
204
|
+
yield client
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@pytest.fixture
|
|
208
|
+
def mock_async_service() -> AsyncMock:
|
|
209
|
+
"""Mock async service."""
|
|
210
|
+
mock = AsyncMock()
|
|
211
|
+
mock.fetch_data.return_value = {"data": "test"}
|
|
212
|
+
return mock
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# Environment fixtures
|
|
216
|
+
@pytest.fixture(autouse=True)
|
|
217
|
+
def env_setup(monkeypatch):
|
|
218
|
+
"""Set up test environment variables."""
|
|
219
|
+
monkeypatch.setenv("ENV", "test")
|
|
220
|
+
monkeypatch.setenv("DEBUG", "false")
|
|
221
|
+
monkeypatch.setenv("DATABASE_URL", "postgresql://test:test@localhost/test")
|
|
222
|
+
|
|
29
223
|
|
|
30
|
-
|
|
31
|
-
|
|
224
|
+
# Cleanup fixtures
|
|
225
|
+
@pytest.fixture(autouse=True)
|
|
226
|
+
def cleanup_uploads(tmp_path):
|
|
227
|
+
"""Clean up uploaded files after tests."""
|
|
228
|
+
yield
|
|
229
|
+
# Cleanup happens automatically with tmp_path
|
|
32
230
|
```
|
|
33
231
|
|
|
34
|
-
|
|
232
|
+
### 3. Parametrized Tests
|
|
233
|
+
|
|
35
234
|
```python
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
235
|
+
# tests/test_validators.py
|
|
236
|
+
import pytest
|
|
237
|
+
from src.validators import (
|
|
238
|
+
validate_email,
|
|
239
|
+
validate_password,
|
|
240
|
+
validate_phone,
|
|
241
|
+
validate_url,
|
|
242
|
+
ValidationError,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class TestEmailValidation:
|
|
247
|
+
"""Test email validation."""
|
|
248
|
+
|
|
249
|
+
@pytest.mark.parametrize(
|
|
250
|
+
"email",
|
|
251
|
+
[
|
|
252
|
+
"user@example.com",
|
|
253
|
+
"user.name@example.com",
|
|
254
|
+
"user+tag@example.com",
|
|
255
|
+
"user@subdomain.example.com",
|
|
256
|
+
"user123@example.co.uk",
|
|
257
|
+
],
|
|
258
|
+
)
|
|
259
|
+
def test_valid_emails(self, email: str):
|
|
260
|
+
"""Test that valid emails pass validation."""
|
|
261
|
+
assert validate_email(email) is True
|
|
262
|
+
|
|
263
|
+
@pytest.mark.parametrize(
|
|
264
|
+
"email,error_message",
|
|
265
|
+
[
|
|
266
|
+
("", "Email is required"),
|
|
267
|
+
("invalid", "Invalid email format"),
|
|
268
|
+
("@example.com", "Invalid email format"),
|
|
269
|
+
("user@", "Invalid email format"),
|
|
270
|
+
("user@.com", "Invalid email format"),
|
|
271
|
+
("user space@example.com", "Invalid email format"),
|
|
272
|
+
],
|
|
273
|
+
)
|
|
274
|
+
def test_invalid_emails(self, email: str, error_message: str):
|
|
275
|
+
"""Test that invalid emails raise appropriate errors."""
|
|
276
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
277
|
+
validate_email(email)
|
|
278
|
+
assert error_message in str(exc_info.value)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class TestPasswordValidation:
|
|
282
|
+
"""Test password validation."""
|
|
283
|
+
|
|
284
|
+
@pytest.mark.parametrize(
|
|
285
|
+
"password",
|
|
286
|
+
[
|
|
287
|
+
"SecurePass123!",
|
|
288
|
+
"MyP@ssw0rd",
|
|
289
|
+
"Complex!Pass99",
|
|
290
|
+
"Valid#Password1",
|
|
291
|
+
],
|
|
292
|
+
)
|
|
293
|
+
def test_valid_passwords(self, password: str):
|
|
294
|
+
"""Test that valid passwords pass validation."""
|
|
295
|
+
assert validate_password(password) is True
|
|
296
|
+
|
|
297
|
+
@pytest.mark.parametrize(
|
|
298
|
+
"password,expected_errors",
|
|
299
|
+
[
|
|
300
|
+
("short", ["at least 8 characters"]),
|
|
301
|
+
("nouppercase123!", ["uppercase letter"]),
|
|
302
|
+
("NOLOWERCASE123!", ["lowercase letter"]),
|
|
303
|
+
("NoNumbers!", ["digit"]),
|
|
304
|
+
("NoSpecialChar123", ["special character"]),
|
|
305
|
+
("", ["Password is required"]),
|
|
306
|
+
],
|
|
307
|
+
ids=[
|
|
308
|
+
"too_short",
|
|
309
|
+
"no_uppercase",
|
|
310
|
+
"no_lowercase",
|
|
311
|
+
"no_numbers",
|
|
312
|
+
"no_special",
|
|
313
|
+
"empty",
|
|
314
|
+
],
|
|
315
|
+
)
|
|
316
|
+
def test_invalid_passwords(self, password: str, expected_errors: list[str]):
|
|
317
|
+
"""Test that invalid passwords return appropriate errors."""
|
|
318
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
319
|
+
validate_password(password)
|
|
320
|
+
|
|
321
|
+
error_message = str(exc_info.value)
|
|
322
|
+
for expected in expected_errors:
|
|
323
|
+
assert expected in error_message
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class TestComplexParametrization:
|
|
327
|
+
"""Test complex parametrization patterns."""
|
|
328
|
+
|
|
329
|
+
@pytest.mark.parametrize(
|
|
330
|
+
"input_value,transform,expected",
|
|
331
|
+
[
|
|
332
|
+
(10, "double", 20),
|
|
333
|
+
(10, "triple", 30),
|
|
334
|
+
(5, "double", 10),
|
|
335
|
+
(5, "triple", 15),
|
|
336
|
+
],
|
|
337
|
+
)
|
|
338
|
+
def test_transformations(self, input_value, transform, expected):
|
|
339
|
+
"""Test value transformations."""
|
|
340
|
+
from src.transforms import apply_transform
|
|
341
|
+
|
|
342
|
+
result = apply_transform(input_value, transform)
|
|
343
|
+
assert result == expected
|
|
344
|
+
|
|
345
|
+
@pytest.mark.parametrize("x", [1, 2, 3])
|
|
346
|
+
@pytest.mark.parametrize("y", [10, 20])
|
|
347
|
+
def test_cartesian_product(self, x: int, y: int):
|
|
348
|
+
"""Test all combinations of x and y."""
|
|
349
|
+
from src.math_utils import multiply
|
|
350
|
+
|
|
351
|
+
result = multiply(x, y)
|
|
352
|
+
assert result == x * y
|
|
43
353
|
```
|
|
44
354
|
|
|
45
|
-
|
|
355
|
+
### 4. Mocking and Patching
|
|
356
|
+
|
|
46
357
|
```python
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
358
|
+
# tests/test_services.py
|
|
359
|
+
import pytest
|
|
360
|
+
from unittest.mock import patch, MagicMock, AsyncMock, call
|
|
361
|
+
from datetime import datetime, timedelta
|
|
362
|
+
|
|
363
|
+
from src.services.user_service import UserService
|
|
364
|
+
from src.services.notification_service import NotificationService
|
|
365
|
+
from src.services.payment_service import PaymentService
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class TestUserService:
|
|
369
|
+
"""Test UserService with mocking."""
|
|
370
|
+
|
|
371
|
+
@pytest.fixture
|
|
372
|
+
def user_service(self, db_session, mock_email_service):
|
|
373
|
+
"""Create UserService with mocked dependencies."""
|
|
374
|
+
return UserService(
|
|
375
|
+
db=db_session,
|
|
376
|
+
email_service=mock_email_service,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
def test_create_user_sends_welcome_email(
|
|
380
|
+
self,
|
|
381
|
+
user_service: UserService,
|
|
382
|
+
mock_email_service: MagicMock,
|
|
383
|
+
):
|
|
384
|
+
"""Test that creating a user sends welcome email."""
|
|
385
|
+
user = user_service.create_user(
|
|
386
|
+
email="new@example.com",
|
|
387
|
+
name="New User",
|
|
388
|
+
password="SecurePass123!",
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
mock_email_service.send_email.assert_called_once_with(
|
|
392
|
+
to="new@example.com",
|
|
393
|
+
template="welcome",
|
|
394
|
+
context={"name": "New User"},
|
|
395
|
+
)
|
|
396
|
+
assert user.email == "new@example.com"
|
|
397
|
+
|
|
398
|
+
@patch("src.services.user_service.datetime")
|
|
399
|
+
def test_user_last_login_updated(
|
|
400
|
+
self,
|
|
401
|
+
mock_datetime: MagicMock,
|
|
402
|
+
user_service: UserService,
|
|
403
|
+
test_user,
|
|
404
|
+
):
|
|
405
|
+
"""Test that last login is updated on authentication."""
|
|
406
|
+
fixed_time = datetime(2024, 1, 15, 10, 30, 0)
|
|
407
|
+
mock_datetime.utcnow.return_value = fixed_time
|
|
408
|
+
|
|
409
|
+
user_service.authenticate(test_user.email, "password")
|
|
410
|
+
|
|
411
|
+
assert test_user.last_login == fixed_time
|
|
412
|
+
|
|
413
|
+
def test_password_reset_flow(
|
|
414
|
+
self,
|
|
415
|
+
user_service: UserService,
|
|
416
|
+
mock_email_service: MagicMock,
|
|
417
|
+
test_user,
|
|
418
|
+
):
|
|
419
|
+
"""Test complete password reset flow."""
|
|
420
|
+
# Request reset
|
|
421
|
+
token = user_service.request_password_reset(test_user.email)
|
|
422
|
+
|
|
423
|
+
mock_email_service.send_email.assert_called_with(
|
|
424
|
+
to=test_user.email,
|
|
425
|
+
template="password_reset",
|
|
426
|
+
context={"token": token, "name": test_user.name},
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
# Reset password
|
|
430
|
+
user_service.reset_password(token, "NewSecurePass123!")
|
|
431
|
+
|
|
432
|
+
# Verify new password works
|
|
433
|
+
assert user_service.authenticate(
|
|
434
|
+
test_user.email,
|
|
435
|
+
"NewSecurePass123!",
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class TestNotificationService:
|
|
440
|
+
"""Test NotificationService with multiple mocks."""
|
|
441
|
+
|
|
442
|
+
@pytest.fixture
|
|
443
|
+
def notification_service(self):
|
|
444
|
+
"""Create NotificationService with mocked providers."""
|
|
445
|
+
with patch("src.services.notification_service.SMSProvider") as mock_sms, \
|
|
446
|
+
patch("src.services.notification_service.PushProvider") as mock_push:
|
|
447
|
+
|
|
448
|
+
mock_sms_instance = MagicMock()
|
|
449
|
+
mock_push_instance = MagicMock()
|
|
450
|
+
mock_sms.return_value = mock_sms_instance
|
|
451
|
+
mock_push.return_value = mock_push_instance
|
|
452
|
+
|
|
453
|
+
service = NotificationService()
|
|
454
|
+
service._sms = mock_sms_instance
|
|
455
|
+
service._push = mock_push_instance
|
|
456
|
+
|
|
457
|
+
yield service
|
|
458
|
+
|
|
459
|
+
def test_send_notification_all_channels(
|
|
460
|
+
self,
|
|
461
|
+
notification_service: NotificationService,
|
|
462
|
+
):
|
|
463
|
+
"""Test sending notifications to all channels."""
|
|
464
|
+
notification_service.notify(
|
|
465
|
+
user_id="user_123",
|
|
466
|
+
message="Test notification",
|
|
467
|
+
channels=["sms", "push"],
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
notification_service._sms.send.assert_called_once()
|
|
471
|
+
notification_service._push.send.assert_called_once()
|
|
472
|
+
|
|
473
|
+
def test_notification_retry_on_failure(
|
|
474
|
+
self,
|
|
475
|
+
notification_service: NotificationService,
|
|
476
|
+
):
|
|
477
|
+
"""Test notification retry logic."""
|
|
478
|
+
notification_service._sms.send.side_effect = [
|
|
479
|
+
Exception("Network error"),
|
|
480
|
+
Exception("Network error"),
|
|
481
|
+
{"message_id": "success"},
|
|
482
|
+
]
|
|
483
|
+
|
|
484
|
+
result = notification_service.notify(
|
|
485
|
+
user_id="user_123",
|
|
486
|
+
message="Test",
|
|
487
|
+
channels=["sms"],
|
|
488
|
+
max_retries=3,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
assert notification_service._sms.send.call_count == 3
|
|
492
|
+
assert result["sms"]["status"] == "success"
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
class TestPaymentService:
|
|
496
|
+
"""Test PaymentService with payment gateway mock."""
|
|
497
|
+
|
|
498
|
+
@pytest.fixture
|
|
499
|
+
def payment_service(self, db_session, mock_payment_gateway):
|
|
500
|
+
"""Create PaymentService with mocked gateway."""
|
|
501
|
+
return PaymentService(
|
|
502
|
+
db=db_session,
|
|
503
|
+
gateway=mock_payment_gateway,
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
def test_process_payment_success(
|
|
507
|
+
self,
|
|
508
|
+
payment_service: PaymentService,
|
|
509
|
+
mock_payment_gateway: MagicMock,
|
|
510
|
+
test_user,
|
|
511
|
+
):
|
|
512
|
+
"""Test successful payment processing."""
|
|
513
|
+
result = payment_service.process_payment(
|
|
514
|
+
user_id=test_user.id,
|
|
515
|
+
amount=1000,
|
|
516
|
+
currency="usd",
|
|
517
|
+
payment_method_id="pm_123",
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
mock_payment_gateway.charge.assert_called_once_with(
|
|
521
|
+
amount=1000,
|
|
522
|
+
currency="usd",
|
|
523
|
+
payment_method="pm_123",
|
|
524
|
+
metadata={"user_id": test_user.id},
|
|
525
|
+
)
|
|
526
|
+
assert result["status"] == "succeeded"
|
|
527
|
+
|
|
528
|
+
def test_process_payment_failure_rolls_back(
|
|
529
|
+
self,
|
|
530
|
+
payment_service: PaymentService,
|
|
531
|
+
mock_payment_gateway: MagicMock,
|
|
532
|
+
test_user,
|
|
533
|
+
db_session,
|
|
534
|
+
):
|
|
535
|
+
"""Test that failed payment rolls back database changes."""
|
|
536
|
+
mock_payment_gateway.charge.side_effect = Exception("Card declined")
|
|
537
|
+
|
|
538
|
+
with pytest.raises(Exception, match="Card declined"):
|
|
539
|
+
payment_service.process_payment(
|
|
540
|
+
user_id=test_user.id,
|
|
541
|
+
amount=1000,
|
|
542
|
+
currency="usd",
|
|
543
|
+
payment_method_id="pm_123",
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
# Verify no payment record was created
|
|
547
|
+
from src.models import Payment
|
|
548
|
+
payments = db_session.query(Payment).filter_by(user_id=test_user.id).all()
|
|
549
|
+
assert len(payments) == 0
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
### 5. Async Testing
|
|
553
|
+
|
|
554
|
+
```python
|
|
555
|
+
# tests/test_async_services.py
|
|
556
|
+
import pytest
|
|
557
|
+
from unittest.mock import AsyncMock, patch
|
|
558
|
+
from httpx import AsyncClient
|
|
559
|
+
|
|
560
|
+
from src.services.async_service import AsyncDataService
|
|
561
|
+
from src.main import app
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
@pytest.mark.asyncio
|
|
565
|
+
class TestAsyncDataService:
|
|
566
|
+
"""Test async data service."""
|
|
567
|
+
|
|
568
|
+
@pytest.fixture
|
|
569
|
+
def async_service(self) -> AsyncDataService:
|
|
570
|
+
"""Create async data service."""
|
|
571
|
+
return AsyncDataService()
|
|
572
|
+
|
|
573
|
+
async def test_fetch_data_success(
|
|
574
|
+
self,
|
|
575
|
+
async_service: AsyncDataService,
|
|
576
|
+
):
|
|
577
|
+
"""Test successful data fetching."""
|
|
578
|
+
with patch.object(
|
|
579
|
+
async_service,
|
|
580
|
+
"_http_client",
|
|
581
|
+
new_callable=AsyncMock,
|
|
582
|
+
) as mock_client:
|
|
583
|
+
mock_client.get.return_value.json.return_value = {
|
|
584
|
+
"items": [{"id": 1}, {"id": 2}]
|
|
585
|
+
}
|
|
586
|
+
mock_client.get.return_value.status_code = 200
|
|
587
|
+
|
|
588
|
+
result = await async_service.fetch_data("test-endpoint")
|
|
589
|
+
|
|
590
|
+
assert len(result["items"]) == 2
|
|
591
|
+
|
|
592
|
+
async def test_fetch_data_with_retry(
|
|
593
|
+
self,
|
|
594
|
+
async_service: AsyncDataService,
|
|
595
|
+
):
|
|
596
|
+
"""Test data fetching with retry on failure."""
|
|
597
|
+
with patch.object(
|
|
598
|
+
async_service,
|
|
599
|
+
"_http_client",
|
|
600
|
+
new_callable=AsyncMock,
|
|
601
|
+
) as mock_client:
|
|
602
|
+
# First two calls fail, third succeeds
|
|
603
|
+
mock_client.get.side_effect = [
|
|
604
|
+
Exception("Connection error"),
|
|
605
|
+
Exception("Timeout"),
|
|
606
|
+
AsyncMock(
|
|
607
|
+
json=AsyncMock(return_value={"data": "success"}),
|
|
608
|
+
status_code=200,
|
|
609
|
+
),
|
|
610
|
+
]
|
|
611
|
+
|
|
612
|
+
result = await async_service.fetch_data_with_retry(
|
|
613
|
+
"test-endpoint",
|
|
614
|
+
max_retries=3,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
assert result == {"data": "success"}
|
|
618
|
+
assert mock_client.get.call_count == 3
|
|
619
|
+
|
|
620
|
+
async def test_concurrent_fetches(
|
|
621
|
+
self,
|
|
622
|
+
async_service: AsyncDataService,
|
|
623
|
+
):
|
|
624
|
+
"""Test concurrent data fetching."""
|
|
625
|
+
with patch.object(
|
|
626
|
+
async_service,
|
|
627
|
+
"_http_client",
|
|
628
|
+
new_callable=AsyncMock,
|
|
629
|
+
) as mock_client:
|
|
630
|
+
mock_client.get.return_value.json.return_value = {"id": 1}
|
|
631
|
+
mock_client.get.return_value.status_code = 200
|
|
632
|
+
|
|
633
|
+
results = await async_service.fetch_multiple(
|
|
634
|
+
["endpoint1", "endpoint2", "endpoint3"]
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
assert len(results) == 3
|
|
638
|
+
assert mock_client.get.call_count == 3
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
@pytest.mark.asyncio
|
|
642
|
+
class TestAPIEndpoints:
|
|
643
|
+
"""Test API endpoints with async client."""
|
|
644
|
+
|
|
645
|
+
async def test_get_users(self, async_client: AsyncClient, test_user):
|
|
646
|
+
"""Test GET /users endpoint."""
|
|
647
|
+
response = await async_client.get("/api/users")
|
|
648
|
+
|
|
649
|
+
assert response.status_code == 200
|
|
650
|
+
data = response.json()
|
|
651
|
+
assert len(data["users"]) >= 1
|
|
652
|
+
|
|
653
|
+
async def test_create_user(self, async_client: AsyncClient):
|
|
654
|
+
"""Test POST /users endpoint."""
|
|
655
|
+
response = await async_client.post(
|
|
656
|
+
"/api/users",
|
|
657
|
+
json={
|
|
658
|
+
"email": "newuser@example.com",
|
|
659
|
+
"name": "New User",
|
|
660
|
+
"password": "SecurePass123!",
|
|
661
|
+
},
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
assert response.status_code == 201
|
|
665
|
+
data = response.json()
|
|
666
|
+
assert data["user"]["email"] == "newuser@example.com"
|
|
667
|
+
|
|
668
|
+
async def test_authentication_flow(self, async_client: AsyncClient, test_user):
|
|
669
|
+
"""Test complete authentication flow."""
|
|
670
|
+
# Login
|
|
671
|
+
login_response = await async_client.post(
|
|
672
|
+
"/api/auth/login",
|
|
673
|
+
json={
|
|
674
|
+
"email": test_user.email,
|
|
675
|
+
"password": "testpassword",
|
|
676
|
+
},
|
|
677
|
+
)
|
|
678
|
+
assert login_response.status_code == 200
|
|
679
|
+
token = login_response.json()["access_token"]
|
|
680
|
+
|
|
681
|
+
# Access protected endpoint
|
|
682
|
+
protected_response = await async_client.get(
|
|
683
|
+
"/api/me",
|
|
684
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
685
|
+
)
|
|
686
|
+
assert protected_response.status_code == 200
|
|
687
|
+
assert protected_response.json()["email"] == test_user.email
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
### 6. Test Markers and Custom Plugins
|
|
691
|
+
|
|
692
|
+
```python
|
|
693
|
+
# tests/markers.py
|
|
694
|
+
import pytest
|
|
695
|
+
from functools import wraps
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def slow(func):
|
|
699
|
+
"""Mark test as slow."""
|
|
700
|
+
return pytest.mark.slow(func)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def integration(func):
|
|
704
|
+
"""Mark test as integration test."""
|
|
705
|
+
return pytest.mark.integration(func)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def requires_db(func):
|
|
709
|
+
"""Mark test as requiring database."""
|
|
710
|
+
return pytest.mark.requires_db(func)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
# tests/conftest.py - Custom markers configuration
|
|
714
|
+
def pytest_configure(config):
|
|
715
|
+
"""Configure custom markers."""
|
|
716
|
+
config.addinivalue_line(
|
|
717
|
+
"markers",
|
|
718
|
+
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
|
|
719
|
+
)
|
|
720
|
+
config.addinivalue_line(
|
|
721
|
+
"markers",
|
|
722
|
+
"integration: marks tests as integration tests",
|
|
723
|
+
)
|
|
724
|
+
config.addinivalue_line(
|
|
725
|
+
"markers",
|
|
726
|
+
"requires_db: marks tests as requiring database connection",
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def pytest_collection_modifyitems(config, items):
|
|
731
|
+
"""Modify test collection based on markers."""
|
|
732
|
+
if config.getoption("--skip-slow"):
|
|
733
|
+
skip_slow = pytest.mark.skip(reason="--skip-slow option provided")
|
|
734
|
+
for item in items:
|
|
735
|
+
if "slow" in item.keywords:
|
|
736
|
+
item.add_marker(skip_slow)
|
|
737
|
+
|
|
738
|
+
if not config.getoption("--run-integration"):
|
|
739
|
+
skip_integration = pytest.mark.skip(
|
|
740
|
+
reason="need --run-integration option to run"
|
|
741
|
+
)
|
|
742
|
+
for item in items:
|
|
743
|
+
if "integration" in item.keywords:
|
|
744
|
+
item.add_marker(skip_integration)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def pytest_addoption(parser):
|
|
748
|
+
"""Add custom command line options."""
|
|
749
|
+
parser.addoption(
|
|
750
|
+
"--skip-slow",
|
|
751
|
+
action="store_true",
|
|
752
|
+
default=False,
|
|
753
|
+
help="Skip slow tests",
|
|
754
|
+
)
|
|
755
|
+
parser.addoption(
|
|
756
|
+
"--run-integration",
|
|
757
|
+
action="store_true",
|
|
758
|
+
default=False,
|
|
759
|
+
help="Run integration tests",
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
# tests/test_with_markers.py
|
|
764
|
+
import pytest
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
@pytest.mark.slow
|
|
768
|
+
def test_slow_operation():
|
|
769
|
+
"""This test takes a long time."""
|
|
770
|
+
import time
|
|
771
|
+
time.sleep(5)
|
|
772
|
+
assert True
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
@pytest.mark.integration
|
|
776
|
+
def test_external_api():
|
|
777
|
+
"""This test requires external API."""
|
|
778
|
+
import requests
|
|
779
|
+
response = requests.get("https://api.example.com/health")
|
|
780
|
+
assert response.status_code == 200
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
@pytest.mark.requires_db
|
|
784
|
+
def test_database_operation(db_session):
|
|
785
|
+
"""This test requires database."""
|
|
786
|
+
result = db_session.execute("SELECT 1")
|
|
787
|
+
assert result.scalar() == 1
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
## Use Cases
|
|
791
|
+
|
|
792
|
+
### Test Organization for Large Projects
|
|
793
|
+
|
|
51
794
|
```
|
|
795
|
+
tests/
|
|
796
|
+
├── conftest.py # Shared fixtures
|
|
797
|
+
├── unit/ # Unit tests
|
|
798
|
+
│ ├── conftest.py
|
|
799
|
+
│ ├── test_models.py
|
|
800
|
+
│ ├── test_validators.py
|
|
801
|
+
│ └── test_utils.py
|
|
802
|
+
├── integration/ # Integration tests
|
|
803
|
+
│ ├── conftest.py
|
|
804
|
+
│ ├── test_api.py
|
|
805
|
+
│ ├── test_database.py
|
|
806
|
+
│ └── test_external_services.py
|
|
807
|
+
├── e2e/ # End-to-end tests
|
|
808
|
+
│ ├── conftest.py
|
|
809
|
+
│ └── test_workflows.py
|
|
810
|
+
└── fixtures/ # Shared test data
|
|
811
|
+
├── users.json
|
|
812
|
+
└── products.json
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
### CI/CD Integration
|
|
816
|
+
|
|
817
|
+
```yaml
|
|
818
|
+
# .github/workflows/test.yml
|
|
819
|
+
name: Tests
|
|
820
|
+
|
|
821
|
+
on: [push, pull_request]
|
|
822
|
+
|
|
823
|
+
jobs:
|
|
824
|
+
test:
|
|
825
|
+
runs-on: ubuntu-latest
|
|
826
|
+
services:
|
|
827
|
+
postgres:
|
|
828
|
+
image: postgres:15
|
|
829
|
+
env:
|
|
830
|
+
POSTGRES_PASSWORD: test
|
|
831
|
+
POSTGRES_DB: test_db
|
|
832
|
+
ports:
|
|
833
|
+
- 5432:5432
|
|
834
|
+
|
|
835
|
+
steps:
|
|
836
|
+
- uses: actions/checkout@v4
|
|
837
|
+
|
|
838
|
+
- name: Set up Python
|
|
839
|
+
uses: actions/setup-python@v5
|
|
840
|
+
with:
|
|
841
|
+
python-version: "3.12"
|
|
52
842
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
843
|
+
- name: Install dependencies
|
|
844
|
+
run: |
|
|
845
|
+
pip install -r requirements-dev.txt
|
|
846
|
+
|
|
847
|
+
- name: Run unit tests
|
|
848
|
+
run: pytest tests/unit -v --cov=src --cov-report=xml
|
|
849
|
+
|
|
850
|
+
- name: Run integration tests
|
|
851
|
+
run: pytest tests/integration -v --run-integration
|
|
852
|
+
env:
|
|
853
|
+
DATABASE_URL: postgresql://postgres:test@localhost:5432/test_db
|
|
854
|
+
|
|
855
|
+
- name: Upload coverage
|
|
856
|
+
uses: codecov/codecov-action@v4
|
|
857
|
+
with:
|
|
858
|
+
file: coverage.xml
|
|
58
859
|
```
|
|
860
|
+
|
|
861
|
+
## Best Practices
|
|
862
|
+
|
|
863
|
+
### Do's
|
|
864
|
+
|
|
865
|
+
- Use descriptive test names that explain the scenario
|
|
866
|
+
- Create reusable fixtures for common setup
|
|
867
|
+
- Use parametrize for testing multiple inputs
|
|
868
|
+
- Mock external dependencies
|
|
869
|
+
- Group related tests in classes
|
|
870
|
+
- Use markers to categorize tests
|
|
871
|
+
- Write tests before or alongside code
|
|
872
|
+
- Keep tests independent and isolated
|
|
873
|
+
- Use factories for test data creation
|
|
874
|
+
- Run tests in parallel with pytest-xdist
|
|
875
|
+
|
|
876
|
+
### Don'ts
|
|
877
|
+
|
|
878
|
+
- Don't share state between tests
|
|
879
|
+
- Don't use sleep for timing issues
|
|
880
|
+
- Don't test implementation details
|
|
881
|
+
- Don't write tests that depend on order
|
|
882
|
+
- Don't ignore flaky tests
|
|
883
|
+
- Don't over-mock (test real behavior when possible)
|
|
884
|
+
- Don't use hardcoded paths
|
|
885
|
+
- Don't skip writing tests for edge cases
|
|
886
|
+
- Don't leave commented-out test code
|
|
887
|
+
- Don't test framework functionality
|
|
888
|
+
|
|
889
|
+
## References
|
|
890
|
+
|
|
891
|
+
- [Pytest Documentation](https://docs.pytest.org/)
|
|
892
|
+
- [pytest-asyncio](https://pytest-asyncio.readthedocs.io/)
|
|
893
|
+
- [pytest-mock](https://pytest-mock.readthedocs.io/)
|
|
894
|
+
- [pytest-cov](https://pytest-cov.readthedocs.io/)
|
|
895
|
+
- [Testing Best Practices](https://docs.pytest.org/en/latest/explanation/goodpractices.html)
|