partest 0.1.8__tar.gz
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.
Potentially problematic release.
This version of partest might be problematic. Click here for more details.
- partest-0.1.8/PKG-INFO +157 -0
- partest-0.1.8/README.md +136 -0
- partest-0.1.8/partest/__init__.py +7 -0
- partest-0.1.8/partest/allure_graph.py +20 -0
- partest-0.1.8/partest/call_storage.py +4 -0
- partest-0.1.8/partest/client.py +146 -0
- partest-0.1.8/partest/coverage.py +90 -0
- partest-0.1.8/partest/methods.py +2 -0
- partest-0.1.8/partest/parparser.py +214 -0
- partest-0.1.8/partest/test_types.py +8 -0
- partest-0.1.8/partest/utils/__init__.py +4 -0
- partest-0.1.8/partest/utils/ascii.py +14 -0
- partest-0.1.8/partest/utils/checking.py +73 -0
- partest-0.1.8/partest/utils/date.py +24 -0
- partest-0.1.8/partest/utils/logger.py +38 -0
- partest-0.1.8/partest/zorro_report.py +66 -0
- partest-0.1.8/partest.egg-info/PKG-INFO +157 -0
- partest-0.1.8/partest.egg-info/SOURCES.txt +22 -0
- partest-0.1.8/partest.egg-info/dependency_links.txt +1 -0
- partest-0.1.8/partest.egg-info/requires.txt +6 -0
- partest-0.1.8/partest.egg-info/top_level.txt +1 -0
- partest-0.1.8/setup.cfg +4 -0
- partest-0.1.8/setup.py +30 -0
partest-0.1.8/PKG-INFO
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: partest
|
|
3
|
+
Version: 0.1.8
|
|
4
|
+
Summary: This is a module for the rapid implementation of test cases with coverage tracking. This module contains a call counter for specific endpoints and their methods. As well as the function of determining the types of tests that need to be counted.
|
|
5
|
+
Home-page: https://github.com/Dec01/partest
|
|
6
|
+
Author: dec01
|
|
7
|
+
Author-email: parschin.ewg@yandex.ru
|
|
8
|
+
Project-URL: GitHub, https://github.com/Dec01/partest
|
|
9
|
+
Keywords: autotest partest test coverage
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: httpx>=0.27.2
|
|
16
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
17
|
+
Requires-Dist: matplotlib>=3.9.2
|
|
18
|
+
Requires-Dist: allure-pytest>=2.8.18
|
|
19
|
+
Requires-Dist: pytest-asyncio>=0.23.7
|
|
20
|
+
Requires-Dist: pytest==8.3.3
|
|
21
|
+
|
|
22
|
+
## HI!
|
|
23
|
+
|
|
24
|
+
This is a framework for API autotests with coverage assessment. Detailed instructions in the process of writing. It is better to check with the author how to use it. Tools are used:
|
|
25
|
+
|
|
26
|
+
* pytest
|
|
27
|
+
* httpx
|
|
28
|
+
* allure
|
|
29
|
+
|
|
30
|
+
Files are required for specific work:
|
|
31
|
+
|
|
32
|
+
**conftest.py** - it must have a fixture's inside:
|
|
33
|
+
|
|
34
|
+
```commandline
|
|
35
|
+
@pytest.fixture(scope="session")
|
|
36
|
+
def api_client(domain):
|
|
37
|
+
return ApiClient(domain=domain)
|
|
38
|
+
```
|
|
39
|
+
```commandline
|
|
40
|
+
@pytest.fixture(scope='session', autouse=True)
|
|
41
|
+
def clear_call_data():
|
|
42
|
+
"""Фикстура для очистки данных перед запуском тестов."""
|
|
43
|
+
global call_count, call_type
|
|
44
|
+
api_call_storage.call_count.clear()
|
|
45
|
+
api_call_storage.call_type.clear()
|
|
46
|
+
yield
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**confpartest.py** - It must have variables inside:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
swagger_files = {
|
|
53
|
+
'test1': ['local', '../public/swagger/app-openapi.yaml'],
|
|
54
|
+
'test2': ['local', '../public/swagger/app-openapi2.yaml'],
|
|
55
|
+
'test3': ['url', 'https://url.ru']
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test_types_coverage = ['default', '405', 'param']
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The project must have a test that displays information about the coverage in allure. The name of it **test_zorro.py**:
|
|
62
|
+
|
|
63
|
+
```commandline
|
|
64
|
+
|
|
65
|
+
async def test_display_final_call_counts(self):
|
|
66
|
+
report_lines = []
|
|
67
|
+
total_coverage_percentage = 0
|
|
68
|
+
total_endpoints = 0
|
|
69
|
+
total_calls_excluding_generation = 0
|
|
70
|
+
|
|
71
|
+
for (method, endpoint, description), count in call_count.items():
|
|
72
|
+
types = set(call_type[(method, endpoint, description)])
|
|
73
|
+
total_endpoints += 1
|
|
74
|
+
|
|
75
|
+
# Подсчет вызовов, исключая тип 'generation_data'
|
|
76
|
+
if 'generation_data' not in types:
|
|
77
|
+
total_calls_excluding_generation += count
|
|
78
|
+
|
|
79
|
+
# Проверка на наличие обязательных типов тестов
|
|
80
|
+
coverage_status = "Недостаточное покрытие ❌"
|
|
81
|
+
matched_types = set(types).intersection(types)
|
|
82
|
+
count_matched = len(matched_types)
|
|
83
|
+
|
|
84
|
+
# Логика для определения статуса покрытия и расчета процента
|
|
85
|
+
if count_matched == len(types): # Все типы присутствуют
|
|
86
|
+
coverage_status = "Покрытие выполнено ✅"
|
|
87
|
+
total_coverage_percentage += 100
|
|
88
|
+
elif count_matched == 2:
|
|
89
|
+
coverage_status = "Покрытие выполнено на 66% 🔔"
|
|
90
|
+
total_coverage_percentage += 66
|
|
91
|
+
elif count_matched == 1:
|
|
92
|
+
coverage_status = "Покрытие выполнено на 33% ❌"
|
|
93
|
+
total_coverage_percentage += 33
|
|
94
|
+
else:
|
|
95
|
+
coverage_status = "Недостаточное покрытие ❌"
|
|
96
|
+
total_coverage_percentage += 0
|
|
97
|
+
|
|
98
|
+
report_line = (
|
|
99
|
+
f"\n{description}\nЭндпоинт: {endpoint}\nМетод: {method} | "
|
|
100
|
+
f"Обращений: {count}, Типы тестов: {', '.join(types)}\n{coverage_status}\n"
|
|
101
|
+
)
|
|
102
|
+
report_lines.append(report_line)
|
|
103
|
+
|
|
104
|
+
# Вычисление общего процента покрытия
|
|
105
|
+
if total_endpoints > 0:
|
|
106
|
+
average_coverage_percentage = total_coverage_percentage / total_endpoints
|
|
107
|
+
else:
|
|
108
|
+
average_coverage_percentage = 0
|
|
109
|
+
|
|
110
|
+
border = "*" * 50
|
|
111
|
+
summary = f"{border}\nОбщий процент покрытия: {average_coverage_percentage:.2f}%\nОбщее количество вызовов (исключая 'generation_data'): {total_calls_excluding_generation}\n{border}\n"
|
|
112
|
+
|
|
113
|
+
# Добавляем сводку в начало отчета
|
|
114
|
+
report_lines.insert(0, summary)
|
|
115
|
+
|
|
116
|
+
create_chart(call_count)
|
|
117
|
+
|
|
118
|
+
with open('api_call_counts.png', 'rb') as f:
|
|
119
|
+
allure.attach(f.read(), name='Оценка покрытия', attachment_type=allure.attachment_type.PNG)
|
|
120
|
+
|
|
121
|
+
allure.attach("\n".join(report_lines), name='Отчет по вызовам API', attachment_type=allure.attachment_type.TEXT)
|
|
122
|
+
|
|
123
|
+
assert True
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
What does the test look like:
|
|
129
|
+
|
|
130
|
+
```commandline
|
|
131
|
+
async def test_get(self, api_client):
|
|
132
|
+
endpoint = 'https://ya.ru'
|
|
133
|
+
response = await api_client.make_request(
|
|
134
|
+
'GET',
|
|
135
|
+
endpoint,
|
|
136
|
+
params='limit=1',
|
|
137
|
+
expected_status_code=200,
|
|
138
|
+
validate_model=Models.ValidateGet,
|
|
139
|
+
type=types.type_default
|
|
140
|
+
)
|
|
141
|
+
assert response is not None
|
|
142
|
+
assert isinstance(response, dict)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
All available data that the client can accept:
|
|
146
|
+
```
|
|
147
|
+
method: str,
|
|
148
|
+
endpoint: str,
|
|
149
|
+
add_url1: Optional[str] = '',
|
|
150
|
+
add_url2: Optional[str] = '',
|
|
151
|
+
add_url3: Optional[str] = '',
|
|
152
|
+
params: Optional[Dict[str, Any]] = None,
|
|
153
|
+
headers: Optional[Dict[str, str]] = None,
|
|
154
|
+
data: Optional[Dict[str, Any]] = None,
|
|
155
|
+
expected_status_code: Optional[int] = None,
|
|
156
|
+
validate_model: Optional[Type[BaseModel]] = None,
|
|
157
|
+
type: Optional[str] = None```
|
partest-0.1.8/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
## HI!
|
|
2
|
+
|
|
3
|
+
This is a framework for API autotests with coverage assessment. Detailed instructions in the process of writing. It is better to check with the author how to use it. Tools are used:
|
|
4
|
+
|
|
5
|
+
* pytest
|
|
6
|
+
* httpx
|
|
7
|
+
* allure
|
|
8
|
+
|
|
9
|
+
Files are required for specific work:
|
|
10
|
+
|
|
11
|
+
**conftest.py** - it must have a fixture's inside:
|
|
12
|
+
|
|
13
|
+
```commandline
|
|
14
|
+
@pytest.fixture(scope="session")
|
|
15
|
+
def api_client(domain):
|
|
16
|
+
return ApiClient(domain=domain)
|
|
17
|
+
```
|
|
18
|
+
```commandline
|
|
19
|
+
@pytest.fixture(scope='session', autouse=True)
|
|
20
|
+
def clear_call_data():
|
|
21
|
+
"""Фикстура для очистки данных перед запуском тестов."""
|
|
22
|
+
global call_count, call_type
|
|
23
|
+
api_call_storage.call_count.clear()
|
|
24
|
+
api_call_storage.call_type.clear()
|
|
25
|
+
yield
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**confpartest.py** - It must have variables inside:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
swagger_files = {
|
|
32
|
+
'test1': ['local', '../public/swagger/app-openapi.yaml'],
|
|
33
|
+
'test2': ['local', '../public/swagger/app-openapi2.yaml'],
|
|
34
|
+
'test3': ['url', 'https://url.ru']
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test_types_coverage = ['default', '405', 'param']
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The project must have a test that displays information about the coverage in allure. The name of it **test_zorro.py**:
|
|
41
|
+
|
|
42
|
+
```commandline
|
|
43
|
+
|
|
44
|
+
async def test_display_final_call_counts(self):
|
|
45
|
+
report_lines = []
|
|
46
|
+
total_coverage_percentage = 0
|
|
47
|
+
total_endpoints = 0
|
|
48
|
+
total_calls_excluding_generation = 0
|
|
49
|
+
|
|
50
|
+
for (method, endpoint, description), count in call_count.items():
|
|
51
|
+
types = set(call_type[(method, endpoint, description)])
|
|
52
|
+
total_endpoints += 1
|
|
53
|
+
|
|
54
|
+
# Подсчет вызовов, исключая тип 'generation_data'
|
|
55
|
+
if 'generation_data' not in types:
|
|
56
|
+
total_calls_excluding_generation += count
|
|
57
|
+
|
|
58
|
+
# Проверка на наличие обязательных типов тестов
|
|
59
|
+
coverage_status = "Недостаточное покрытие ❌"
|
|
60
|
+
matched_types = set(types).intersection(types)
|
|
61
|
+
count_matched = len(matched_types)
|
|
62
|
+
|
|
63
|
+
# Логика для определения статуса покрытия и расчета процента
|
|
64
|
+
if count_matched == len(types): # Все типы присутствуют
|
|
65
|
+
coverage_status = "Покрытие выполнено ✅"
|
|
66
|
+
total_coverage_percentage += 100
|
|
67
|
+
elif count_matched == 2:
|
|
68
|
+
coverage_status = "Покрытие выполнено на 66% 🔔"
|
|
69
|
+
total_coverage_percentage += 66
|
|
70
|
+
elif count_matched == 1:
|
|
71
|
+
coverage_status = "Покрытие выполнено на 33% ❌"
|
|
72
|
+
total_coverage_percentage += 33
|
|
73
|
+
else:
|
|
74
|
+
coverage_status = "Недостаточное покрытие ❌"
|
|
75
|
+
total_coverage_percentage += 0
|
|
76
|
+
|
|
77
|
+
report_line = (
|
|
78
|
+
f"\n{description}\nЭндпоинт: {endpoint}\nМетод: {method} | "
|
|
79
|
+
f"Обращений: {count}, Типы тестов: {', '.join(types)}\n{coverage_status}\n"
|
|
80
|
+
)
|
|
81
|
+
report_lines.append(report_line)
|
|
82
|
+
|
|
83
|
+
# Вычисление общего процента покрытия
|
|
84
|
+
if total_endpoints > 0:
|
|
85
|
+
average_coverage_percentage = total_coverage_percentage / total_endpoints
|
|
86
|
+
else:
|
|
87
|
+
average_coverage_percentage = 0
|
|
88
|
+
|
|
89
|
+
border = "*" * 50
|
|
90
|
+
summary = f"{border}\nОбщий процент покрытия: {average_coverage_percentage:.2f}%\nОбщее количество вызовов (исключая 'generation_data'): {total_calls_excluding_generation}\n{border}\n"
|
|
91
|
+
|
|
92
|
+
# Добавляем сводку в начало отчета
|
|
93
|
+
report_lines.insert(0, summary)
|
|
94
|
+
|
|
95
|
+
create_chart(call_count)
|
|
96
|
+
|
|
97
|
+
with open('api_call_counts.png', 'rb') as f:
|
|
98
|
+
allure.attach(f.read(), name='Оценка покрытия', attachment_type=allure.attachment_type.PNG)
|
|
99
|
+
|
|
100
|
+
allure.attach("\n".join(report_lines), name='Отчет по вызовам API', attachment_type=allure.attachment_type.TEXT)
|
|
101
|
+
|
|
102
|
+
assert True
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
What does the test look like:
|
|
108
|
+
|
|
109
|
+
```commandline
|
|
110
|
+
async def test_get(self, api_client):
|
|
111
|
+
endpoint = 'https://ya.ru'
|
|
112
|
+
response = await api_client.make_request(
|
|
113
|
+
'GET',
|
|
114
|
+
endpoint,
|
|
115
|
+
params='limit=1',
|
|
116
|
+
expected_status_code=200,
|
|
117
|
+
validate_model=Models.ValidateGet,
|
|
118
|
+
type=types.type_default
|
|
119
|
+
)
|
|
120
|
+
assert response is not None
|
|
121
|
+
assert isinstance(response, dict)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
All available data that the client can accept:
|
|
125
|
+
```
|
|
126
|
+
method: str,
|
|
127
|
+
endpoint: str,
|
|
128
|
+
add_url1: Optional[str] = '',
|
|
129
|
+
add_url2: Optional[str] = '',
|
|
130
|
+
add_url3: Optional[str] = '',
|
|
131
|
+
params: Optional[Dict[str, Any]] = None,
|
|
132
|
+
headers: Optional[Dict[str, str]] = None,
|
|
133
|
+
data: Optional[Dict[str, Any]] = None,
|
|
134
|
+
expected_status_code: Optional[int] = None,
|
|
135
|
+
validate_model: Optional[Type[BaseModel]] = None,
|
|
136
|
+
type: Optional[str] = None```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import matplotlib.pyplot as plt
|
|
2
|
+
|
|
3
|
+
def create_chart(call_count):
|
|
4
|
+
''' Creating a graph that contains data on endpoint calls '''
|
|
5
|
+
methods = []
|
|
6
|
+
counts = []
|
|
7
|
+
|
|
8
|
+
for (method, endpoint, description), count in call_count.items():
|
|
9
|
+
methods.append(f"{description} ({method})")
|
|
10
|
+
counts.append(count)
|
|
11
|
+
|
|
12
|
+
plt.figure(figsize=(10, 6))
|
|
13
|
+
plt.barh(methods, counts, color='skyblue')
|
|
14
|
+
plt.xlabel('Количество вызовов')
|
|
15
|
+
plt.title('Количество вызовов API по методам и описаниям')
|
|
16
|
+
plt.tight_layout()
|
|
17
|
+
|
|
18
|
+
# Сохранение графика в файл
|
|
19
|
+
plt.savefig('api_call_counts.png')
|
|
20
|
+
plt.close()
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from typing import Optional, Dict, Any, Type
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ValidationError
|
|
5
|
+
from partest import track_api_calls
|
|
6
|
+
from partest.utils import Logger, errordesc, StatusCode
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ApiClient:
|
|
10
|
+
"""
|
|
11
|
+
ApiClient serves not only as a client for making requests for endpoints, but also for processing these requests.
|
|
12
|
+
|
|
13
|
+
Attributes
|
|
14
|
+
----------
|
|
15
|
+
:param domain:
|
|
16
|
+
:param verify:
|
|
17
|
+
:param follow_redirects:
|
|
18
|
+
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def __init__(self, domain, verify=False, follow_redirects=True):
|
|
23
|
+
self.domain = domain
|
|
24
|
+
self.verify = verify
|
|
25
|
+
self.follow_redirects = follow_redirects
|
|
26
|
+
self.logger = Logger()
|
|
27
|
+
|
|
28
|
+
@track_api_calls
|
|
29
|
+
async def make_request(
|
|
30
|
+
self,
|
|
31
|
+
method: str,
|
|
32
|
+
endpoint: str,
|
|
33
|
+
add_url1: Optional[str] = '',
|
|
34
|
+
add_url2: Optional[str] = '',
|
|
35
|
+
add_url3: Optional[str] = '',
|
|
36
|
+
params: Optional[Dict[str, Any]] = None,
|
|
37
|
+
headers: Optional[Dict[str, str]] = None,
|
|
38
|
+
data: Optional[Dict[str, Any]] = None,
|
|
39
|
+
expected_status_code: Optional[int] = None,
|
|
40
|
+
validate_model: Optional[Type[BaseModel]] = None,
|
|
41
|
+
type: Optional[str] = None
|
|
42
|
+
|
|
43
|
+
) -> Optional[Dict[str, Any]]:
|
|
44
|
+
|
|
45
|
+
url = f"{self.domain}{endpoint}{add_url1}{add_url2}{add_url3}"
|
|
46
|
+
self.logger.log_request(method, url, params=params, headers=headers, data=data)
|
|
47
|
+
|
|
48
|
+
async with httpx.AsyncClient(verify=self.verify, follow_redirects=self.follow_redirects) as client:
|
|
49
|
+
try:
|
|
50
|
+
response = await self._perform_request(client, method, url, params, headers, data)
|
|
51
|
+
|
|
52
|
+
self.logger.log_response(response)
|
|
53
|
+
|
|
54
|
+
if expected_status_code is not None:
|
|
55
|
+
self._check_status_code(response.status_code, expected_status_code, response, data, validate_model)
|
|
56
|
+
|
|
57
|
+
return response.json()
|
|
58
|
+
|
|
59
|
+
except httpx.HTTPStatusError as err:
|
|
60
|
+
return self._handle_http_error(err, data)
|
|
61
|
+
|
|
62
|
+
except httpx.RequestError as e:
|
|
63
|
+
self.logger.error(f"An error occurred: {e}")
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
async def _perform_request(self, client, method: str, url: str, params: Optional[Dict[str, Any]],
|
|
67
|
+
headers: Optional[Dict[str, str]], data: Optional[Dict[str, Any]]) -> httpx.Response:
|
|
68
|
+
if method == "GET":
|
|
69
|
+
return await client.get(url, params=params, headers=headers)
|
|
70
|
+
elif method == "POST":
|
|
71
|
+
return await client.post(url, json=data, params=params, headers=headers)
|
|
72
|
+
elif method == "PUT":
|
|
73
|
+
return await client.put(url, json=data, params=params, headers=headers)
|
|
74
|
+
elif method == "PATCH":
|
|
75
|
+
return await client.patch(url, json=data, params=params, headers=headers)
|
|
76
|
+
elif method == "DELETE":
|
|
77
|
+
return await client.delete(url, params=params, headers=headers)
|
|
78
|
+
else:
|
|
79
|
+
raise ValueError("Unsupported HTTP method")
|
|
80
|
+
|
|
81
|
+
def _check_status_code(self, actual_code: int, expected_code: int, response: httpx.Response,
|
|
82
|
+
request_data: Optional[Dict[str, Any]], validate_model: Optional[Type[BaseModel]]):
|
|
83
|
+
""" We check whether the actual status code corresponds to the expected status code. """
|
|
84
|
+
if actual_code != expected_code:
|
|
85
|
+
error_description = errordesc()
|
|
86
|
+
error_description.codeExpected = expected_code
|
|
87
|
+
error_description.codeActual = actual_code
|
|
88
|
+
error_description.responseBody = response
|
|
89
|
+
error_description.requestBody = request_data
|
|
90
|
+
self.logger.error(errordesc.status(
|
|
91
|
+
codeExpected=expected_code,
|
|
92
|
+
codeActual=error_description.codeActual,
|
|
93
|
+
responseBody=error_description.responseBody
|
|
94
|
+
))
|
|
95
|
+
raise AssertionError(f"Expected status code {expected_code}, but got {actual_code}")
|
|
96
|
+
|
|
97
|
+
if validate_model:
|
|
98
|
+
try:
|
|
99
|
+
data = response.json()
|
|
100
|
+
assert validate_model.response_default(data)
|
|
101
|
+
except ValidationError as e:
|
|
102
|
+
self.logger.error(errordesc.validate(
|
|
103
|
+
validateModel=validate_model,
|
|
104
|
+
validateData=data,
|
|
105
|
+
error=str(e)
|
|
106
|
+
))
|
|
107
|
+
raise AssertionError(f"Response data validation failed: {e}")
|
|
108
|
+
|
|
109
|
+
def _handle_http_error(self, err: httpx.HTTPStatusError, request_data: Optional[Dict[str, Any]]):
|
|
110
|
+
""" Exception handling. """
|
|
111
|
+
error_description = errordesc()
|
|
112
|
+
error_description.codeExpected = StatusCode.ok
|
|
113
|
+
error_description.codeActual = err.response.status_code
|
|
114
|
+
error_description.responseBody = err.response
|
|
115
|
+
error_description.requestBody = request_data
|
|
116
|
+
self.logger.error(errordesc.status(
|
|
117
|
+
codeExpected=StatusCode.ok,
|
|
118
|
+
codeActual=error_description.codeActual,
|
|
119
|
+
responseBody=error_description.responseBody
|
|
120
|
+
))
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
class Get(ApiClient):
|
|
124
|
+
async def get(self, endpoint, add_url1=None, add_url2=None, add_url3=None, params=None, headers=None, data=None, expected_status_code=None, validate_model=None):
|
|
125
|
+
return await self.make_request("GET", endpoint, add_url1=add_url1, add_url2=add_url2, add_url3=add_url3, params=params, data=data, headers=headers,
|
|
126
|
+
expected_status_code=expected_status_code, validate_model=validate_model)
|
|
127
|
+
|
|
128
|
+
class Post(ApiClient):
|
|
129
|
+
async def post(self, endpoint, add_url1=None, add_url2=None, add_url3=None, params=None, data=None, headers=None, expected_status_code=None, validate_model=None):
|
|
130
|
+
return await self.make_request("POST", endpoint, add_url1=add_url1, add_url2=add_url2, add_url3=add_url3, params=params, data=data, headers=headers,
|
|
131
|
+
expected_status_code=expected_status_code, validate_model=validate_model)
|
|
132
|
+
|
|
133
|
+
class Patch(ApiClient):
|
|
134
|
+
async def patch(self, endpoint, add_url1=None, add_url2=None, add_url3=None, params=None, data=None, headers=None, expected_status_code=None, validate_model=None):
|
|
135
|
+
return await self.make_request("PATCH", endpoint, add_url1=add_url1, add_url2=add_url2, add_url3=add_url3, params=params, data=data, headers=headers,
|
|
136
|
+
expected_status_code=expected_status_code, validate_model=validate_model)
|
|
137
|
+
|
|
138
|
+
class Put(ApiClient):
|
|
139
|
+
async def put(self, endpoint, add_url1=None, add_url2=None, add_url3=None, params=None, data=None, headers=None, expected_status_code=None, validate_model=None):
|
|
140
|
+
return await self.make_request("PUT", endpoint, add_url1=add_url1, add_url2=add_url2, add_url3=add_url3, params=params, data=data, headers=headers,
|
|
141
|
+
expected_status_code=expected_status_code, validate_model=validate_model)
|
|
142
|
+
|
|
143
|
+
class Delete(ApiClient):
|
|
144
|
+
async def delete(self, endpoint, add_url1=None, add_url2=None, add_url3=None, params=None, data=None, headers=None, expected_status_code=None, validate_model=None):
|
|
145
|
+
return await self.make_request("DELETE", endpoint, add_url1=add_url1, add_url2=add_url2, add_url3=add_url3, params=params, data=data, headers=headers,
|
|
146
|
+
expected_status_code=expected_status_code, validate_model=validate_model)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from functools import wraps
|
|
3
|
+
from typing import Callable
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from confpartest import swagger_files
|
|
7
|
+
from partest.call_storage import call_count, call_type
|
|
8
|
+
from partest.parparser import SwaggerSettings
|
|
9
|
+
|
|
10
|
+
swagger_settings = SwaggerSettings(swagger_files)
|
|
11
|
+
paths_info = swagger_settings.collect_paths_info()
|
|
12
|
+
|
|
13
|
+
def track_api_calls(func: Callable) -> Callable:
|
|
14
|
+
"""Decorator for tracking API calls."""
|
|
15
|
+
|
|
16
|
+
@wraps(func)
|
|
17
|
+
async def wrapper(*args, **kwargs):
|
|
18
|
+
method = args[1]
|
|
19
|
+
endpoint = args[2]
|
|
20
|
+
test_type = kwargs.get('type', 'unknown')
|
|
21
|
+
|
|
22
|
+
# Собираем параметры пути из paths_info
|
|
23
|
+
path_params = {}
|
|
24
|
+
for path in paths_info:
|
|
25
|
+
for param in path['parameters']:
|
|
26
|
+
if param.type == 'path':
|
|
27
|
+
if param.name not in path_params:
|
|
28
|
+
if param.schema is not None:
|
|
29
|
+
if 'enum' in param.schema:
|
|
30
|
+
path_params[param.name] = param.schema['enum']
|
|
31
|
+
else:
|
|
32
|
+
path_params[param.name] = []
|
|
33
|
+
else:
|
|
34
|
+
path_params[param.name] = []
|
|
35
|
+
|
|
36
|
+
# Processing the add_url parameters
|
|
37
|
+
for i in range(1, 4):
|
|
38
|
+
add_url = kwargs.get(f'add_url{i}')
|
|
39
|
+
if add_url:
|
|
40
|
+
new_param = re.sub(r'^.', '', add_url)
|
|
41
|
+
matched = False
|
|
42
|
+
remaining_param = None
|
|
43
|
+
for param_name, enum_values in path_params.items():
|
|
44
|
+
if new_param in enum_values:
|
|
45
|
+
endpoint += '/{' + f'{param_name}' + '}'
|
|
46
|
+
matched = True
|
|
47
|
+
break
|
|
48
|
+
else:
|
|
49
|
+
remaining_param = param_name
|
|
50
|
+
|
|
51
|
+
if not matched and remaining_param:
|
|
52
|
+
# If no match is found, add the remaining parameter
|
|
53
|
+
endpoint += '/{' + f'{remaining_param}' + '}'
|
|
54
|
+
|
|
55
|
+
# Check if the current method and endpoint match any of the paths_info
|
|
56
|
+
if method is not None and endpoint is not None:
|
|
57
|
+
matched_any = False # Flag for tracking whether a match has been found
|
|
58
|
+
for path in paths_info:
|
|
59
|
+
if path['method'] == method and path['path'] == endpoint:
|
|
60
|
+
key = (method, endpoint, path['description'])
|
|
61
|
+
|
|
62
|
+
if key not in call_count:
|
|
63
|
+
call_count[key] = 0
|
|
64
|
+
call_type[key] = []
|
|
65
|
+
call_count[key] += 1
|
|
66
|
+
call_type[key].append(test_type)
|
|
67
|
+
matched_any = True
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
# After checking all the paths, add the not found endpoints with 0 calls
|
|
71
|
+
for path in paths_info:
|
|
72
|
+
key = (path['method'], path['path'], path['description'])
|
|
73
|
+
if key not in call_count:
|
|
74
|
+
call_count[key] = 0
|
|
75
|
+
call_type[key] = []
|
|
76
|
+
|
|
77
|
+
response = await func(*args, **kwargs)
|
|
78
|
+
|
|
79
|
+
return response
|
|
80
|
+
|
|
81
|
+
return wrapper
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_valid_uuid(uuid_to_test, version=4):
|
|
85
|
+
"""Checks whether the string is a valid UUID."""
|
|
86
|
+
try:
|
|
87
|
+
uuid_obj = UUID(uuid_to_test, version=version)
|
|
88
|
+
except ValueError:
|
|
89
|
+
return False
|
|
90
|
+
return str(uuid_obj) == uuid_to_test
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
import requests
|
|
3
|
+
|
|
4
|
+
class Parameter:
|
|
5
|
+
"""Class representing a parameter in an API path."""
|
|
6
|
+
def __init__(self, name, param_type, required=False, description='', schema=None):
|
|
7
|
+
self.name = name
|
|
8
|
+
self.type = param_type
|
|
9
|
+
self.required = required
|
|
10
|
+
self.description = description
|
|
11
|
+
self.schema = schema
|
|
12
|
+
|
|
13
|
+
def __repr__(self):
|
|
14
|
+
return f"Parameter(name={self.name}, type={self.type}, required={self.required}, description={self.description}, schema={self.schema})"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RequestBody:
|
|
18
|
+
"""Class representing the request body of an API path."""
|
|
19
|
+
def __init__(self, content):
|
|
20
|
+
self.content = content
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def resolve_schema(schema_ref, swagger_dict):
|
|
24
|
+
return OpenAPIParser.resolve_ref(schema_ref, swagger_dict)
|
|
25
|
+
|
|
26
|
+
def __repr__(self):
|
|
27
|
+
return f"RequestBody(content={self.content})"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Response:
|
|
31
|
+
"""Class representing a response from an API path."""
|
|
32
|
+
def __init__(self, status_code, content):
|
|
33
|
+
self.status_code = status_code
|
|
34
|
+
self.content = content
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def resolve_schema(schema_ref, swagger_dict):
|
|
38
|
+
return OpenAPIParser.resolve_ref(schema_ref, swagger_dict)
|
|
39
|
+
|
|
40
|
+
def __repr__(self):
|
|
41
|
+
return f"Response(status_code={self.status_code}, content={self.content})"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Path:
|
|
45
|
+
"""Class representing a path in an API."""
|
|
46
|
+
def __init__(self, path, method, description, parameters, request_body, responses):
|
|
47
|
+
self.path = path
|
|
48
|
+
self.method = method
|
|
49
|
+
self.description = description
|
|
50
|
+
self.parameters = parameters
|
|
51
|
+
self.request_body = request_body
|
|
52
|
+
self.responses = responses
|
|
53
|
+
|
|
54
|
+
def __repr__(self):
|
|
55
|
+
return f"Path(path={self.path}, method={self.method}, description={self.description}, parameters={self.parameters}, request_body={self.request_body}, responses={self.responses})"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class OpenAPIParser:
|
|
59
|
+
"""Class for parsing OpenAPI specifications."""
|
|
60
|
+
def __init__(self, swagger_dict):
|
|
61
|
+
self.swagger_dict = swagger_dict
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def resolve_ref(ref, swagger_dict):
|
|
65
|
+
parts = ref.split('/')
|
|
66
|
+
resolved = swagger_dict
|
|
67
|
+
for part in parts[1:]:
|
|
68
|
+
resolved = resolved[part]
|
|
69
|
+
return resolved
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def load_swagger_yaml(cls, source_type, file_path=None):
|
|
73
|
+
"""Loads the Swagger YAML file from a local file or a URL."""
|
|
74
|
+
if source_type == 'local':
|
|
75
|
+
with open(file_path, 'r') as file:
|
|
76
|
+
swagger_dict = yaml.safe_load(file)
|
|
77
|
+
elif source_type == 'url':
|
|
78
|
+
response = requests.get(file_path)
|
|
79
|
+
response.raise_for_status()
|
|
80
|
+
swagger_dict = yaml.safe_load(response.text)
|
|
81
|
+
else:
|
|
82
|
+
raise ValueError("Invalid source type. Use 'local' or 'url'.")
|
|
83
|
+
|
|
84
|
+
return cls(swagger_dict)
|
|
85
|
+
|
|
86
|
+
def extract_paths_info(self):
|
|
87
|
+
"""Extracts path information from the Swagger specification."""
|
|
88
|
+
paths = self.swagger_dict.get('paths', {})
|
|
89
|
+
result = []
|
|
90
|
+
|
|
91
|
+
for path, methods in paths.items():
|
|
92
|
+
for method, details in methods.items():
|
|
93
|
+
parameters = self.extract_parameters(details)
|
|
94
|
+
request_body = self.extract_request_body(details)
|
|
95
|
+
responses = self.extract_responses(details)
|
|
96
|
+
description = self.safe_get_description(details)
|
|
97
|
+
result.append(Path(
|
|
98
|
+
path=path,
|
|
99
|
+
method=method.upper(),
|
|
100
|
+
description=description,
|
|
101
|
+
parameters=parameters,
|
|
102
|
+
request_body=request_body,
|
|
103
|
+
responses=responses
|
|
104
|
+
))
|
|
105
|
+
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
def extract_parameters(self, details):
|
|
109
|
+
"""Extracts parameters from the method details."""
|
|
110
|
+
parameters = []
|
|
111
|
+
if 'parameters' in details:
|
|
112
|
+
for param in details['parameters']:
|
|
113
|
+
resolved_param = self.resolve_param(param)
|
|
114
|
+
parameters.append(resolved_param)
|
|
115
|
+
return parameters
|
|
116
|
+
|
|
117
|
+
def extract_request_body(self, details):
|
|
118
|
+
"""Extracts the request body from method details."""
|
|
119
|
+
if 'requestBody' in details:
|
|
120
|
+
content = details['requestBody'].get('content', {})
|
|
121
|
+
if 'application/json' in content:
|
|
122
|
+
if 'schema' in content['application/json'] and '$ref' in content['application/json']['schema']:
|
|
123
|
+
schema_ref = content['application/json']['schema']['$ref']
|
|
124
|
+
resolved_schema = RequestBody.resolve_schema(schema_ref, self.swagger_dict)
|
|
125
|
+
content['application/json']['schema'] = resolved_schema
|
|
126
|
+
return RequestBody(content['application/json'])
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
def extract_responses(self, details):
|
|
130
|
+
"""Extracts responses from method details."""
|
|
131
|
+
responses = {}
|
|
132
|
+
if 'responses' in details:
|
|
133
|
+
for code, response in details['responses'].items():
|
|
134
|
+
if 'content' in response and 'application/json' in response['content']:
|
|
135
|
+
content = response['content']['application/json']
|
|
136
|
+
if 'schema' in content and '$ref' in content['schema']:
|
|
137
|
+
schema_ref = content['schema']['$ref']
|
|
138
|
+
resolved_schema = Response.resolve_schema(schema_ref, self.swagger_dict)
|
|
139
|
+
content['schema'] = resolved_schema
|
|
140
|
+
responses[code] = Response(code, response)
|
|
141
|
+
return responses
|
|
142
|
+
|
|
143
|
+
def resolve_param(self, param):
|
|
144
|
+
"""Resolves a parameter definition."""
|
|
145
|
+
if '$ref' in param:
|
|
146
|
+
resolved_param = self.resolve_ref(param['$ref'], self.swagger_dict)
|
|
147
|
+
schema_content = None
|
|
148
|
+
if 'schema' in resolved_param and '$ref' in resolved_param['schema']:
|
|
149
|
+
schema_ref = resolved_param['schema']['$ref']
|
|
150
|
+
schema_content = self.resolve_ref(schema_ref, self.swagger_dict)
|
|
151
|
+
return Parameter(
|
|
152
|
+
name=resolved_param['name'],
|
|
153
|
+
param_type=resolved_param['in'],
|
|
154
|
+
required=resolved_param.get('required', False),
|
|
155
|
+
description=resolved_param.get('description', ''),
|
|
156
|
+
schema=schema_content
|
|
157
|
+
)
|
|
158
|
+
else:
|
|
159
|
+
schema_content = None
|
|
160
|
+
if 'schema' in param and '$ref' in param['schema']:
|
|
161
|
+
schema_ref = param['schema']['$ref']
|
|
162
|
+
schema_content = self.resolve_ref(schema_ref, self.swagger_dict)
|
|
163
|
+
|
|
164
|
+
return Parameter(
|
|
165
|
+
name=param['name'],
|
|
166
|
+
param_type=param['in'],
|
|
167
|
+
required=param.get('required', False),
|
|
168
|
+
description=param.get('description', ''),
|
|
169
|
+
schema=schema_content
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def safe_get_description(self, details):
|
|
173
|
+
"""Safely retrieves the description from details."""
|
|
174
|
+
if isinstance(details, dict):
|
|
175
|
+
return details.get('description', '')
|
|
176
|
+
elif isinstance(details, list) and details:
|
|
177
|
+
return details[0].get('description', '') if isinstance(details[0], dict) else ''
|
|
178
|
+
return ''
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class SwaggerSettings:
|
|
182
|
+
"""Class for managing Swagger settings and loading Swagger files."""
|
|
183
|
+
def __init__(self, swagger_files):
|
|
184
|
+
self.local_files = []
|
|
185
|
+
self.swaggers = []
|
|
186
|
+
self.add_swagger(swagger_files)
|
|
187
|
+
|
|
188
|
+
def add_swagger(self, swagger_dict):
|
|
189
|
+
"""Adds swaggger definitions from a dictionary to the swaggers list."""
|
|
190
|
+
for name, (source_type, path) in swagger_dict.items():
|
|
191
|
+
self.swaggers.append((source_type, path))
|
|
192
|
+
|
|
193
|
+
def load_swagger(self):
|
|
194
|
+
"""Loads swaggger definitions and returns their data."""
|
|
195
|
+
all_extracted_data = []
|
|
196
|
+
for source_type, path in self.swaggers:
|
|
197
|
+
parser = OpenAPIParser.load_swagger_yaml(source_type, path)
|
|
198
|
+
extracted_data = parser.extract_paths_info()
|
|
199
|
+
all_extracted_data.extend(extracted_data)
|
|
200
|
+
return all_extracted_data
|
|
201
|
+
|
|
202
|
+
def collect_paths_info(self):
|
|
203
|
+
"""Collects path information from all swagger definitions."""
|
|
204
|
+
extracted_data = self.load_swagger()
|
|
205
|
+
paths_info = []
|
|
206
|
+
|
|
207
|
+
for item in extracted_data:
|
|
208
|
+
paths_info.append({
|
|
209
|
+
'description': item.description,
|
|
210
|
+
'path': item.path,
|
|
211
|
+
'method': item.method,
|
|
212
|
+
'parameters': item.parameters
|
|
213
|
+
})
|
|
214
|
+
return paths_info
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class BColors:
|
|
2
|
+
HEADER = '\033[95m'
|
|
3
|
+
OKBLUE = '\033[94m'
|
|
4
|
+
OKCYAN = '\033[96m'
|
|
5
|
+
OKGREEN = '\033[92m'
|
|
6
|
+
WARNING = '\033[93m'
|
|
7
|
+
FAIL = '\033[91m'
|
|
8
|
+
ENDC = '\033[0m'
|
|
9
|
+
BOLD = '\033[1m'
|
|
10
|
+
UNDERLINE = '\033[4m'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MethodTypes:
|
|
14
|
+
type_list = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class errordesc:
|
|
5
|
+
def __init__(self):
|
|
6
|
+
self.codeExpected = None
|
|
7
|
+
self.codeActual = None
|
|
8
|
+
self.responseBody = None
|
|
9
|
+
self.responseHeader = None
|
|
10
|
+
self.requestHeader = None
|
|
11
|
+
self.requestBody = None
|
|
12
|
+
self.payloadElement = None
|
|
13
|
+
self.dataElement = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def status(cls, codeExpected=200, codeActual=None, responseBody=None):
|
|
18
|
+
try:
|
|
19
|
+
body = f'Response body: \n{json.dumps(responseBody.json(), indent=4, ensure_ascii=False)}\n'
|
|
20
|
+
except:
|
|
21
|
+
body = f'Response (non-JSON): {responseBody.text}\n'
|
|
22
|
+
finally:
|
|
23
|
+
desc = (f"\n\n---->\nОшибка! Пришел некорректный статус код!\n"
|
|
24
|
+
f"Ожидали код: {codeExpected} <\> Получили код: {codeActual}\n"
|
|
25
|
+
f"{body}\n<----\n\n")
|
|
26
|
+
return desc
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def validate(cls, validateData=None, validateModel=None, error=None):
|
|
30
|
+
try:
|
|
31
|
+
return "\n\n---->\nОшибка валидации, объекты сравнения:", error, validateModel, '\n', validateData, "\nПодробнее в принт-логах.\n<----\n\n"
|
|
32
|
+
except:
|
|
33
|
+
return "Нет тела или модели"
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def _ifelse(cls):
|
|
37
|
+
try:
|
|
38
|
+
return "\n\n---->\nНе выполнены условия для выполнения теста, падение.\n<----\n\n"
|
|
39
|
+
except:
|
|
40
|
+
return "Чёт пошло не так"
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def element(cls, payloadElement=None, dataElement=None, requestBody=None, responseBody=None ):
|
|
44
|
+
try:
|
|
45
|
+
if requestBody is not None and responseBody is not None:
|
|
46
|
+
_resp_body = f'Response body: \n{json.dumps(responseBody.json(), indent=4, ensure_ascii=False)}\n'
|
|
47
|
+
_req_body = f'Request body: \n{json.dumps(requestBody.json(), indent=4, ensure_ascii=False)}\n'
|
|
48
|
+
else:
|
|
49
|
+
_resp_body = ""
|
|
50
|
+
_req_body = ""
|
|
51
|
+
except:
|
|
52
|
+
if requestBody is not None and responseBody is not None:
|
|
53
|
+
_resp_body = f'Response (non-JSON): {responseBody.text}\n'
|
|
54
|
+
_req_body = f'Request (non-JSON): {requestBody.text}\n'
|
|
55
|
+
else:
|
|
56
|
+
_resp_body = ""
|
|
57
|
+
_req_body = ""
|
|
58
|
+
finally:
|
|
59
|
+
desc = (f"\n\n---->\nОшибка! Получили не то значение элемента что ожидали!\n"
|
|
60
|
+
f"Ожидали значение: {payloadElement} <\> Получили значение: {dataElement}\n"
|
|
61
|
+
f"{_resp_body}\n{_req_body}\n<----\n\n")
|
|
62
|
+
return desc
|
|
63
|
+
|
|
64
|
+
def __str__(self):
|
|
65
|
+
return "Ответ не валиден"
|
|
66
|
+
|
|
67
|
+
class StatusCode:
|
|
68
|
+
ok = 200
|
|
69
|
+
bad_request = 400
|
|
70
|
+
not_allowed = 405
|
|
71
|
+
forbidden = 403
|
|
72
|
+
not_found = 404
|
|
73
|
+
exception_400 = [200, 400]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DateGen:
|
|
5
|
+
def __init__(self):
|
|
6
|
+
self._current_date = datetime.now()
|
|
7
|
+
self._formats = {
|
|
8
|
+
"default": "%Y-%m-%dT%H:%M:00+03:00",
|
|
9
|
+
"short": "%Y-%m-%d",
|
|
10
|
+
"long": "%Y-%m-%d %H:%M:%S",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def get_start_date(cls, days=0, format="default"):
|
|
15
|
+
start_date = cls()._current_date - timedelta(days=days)
|
|
16
|
+
return start_date.strftime(cls()._formats[format])
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def get_end_date(cls, days=0, format="default"):
|
|
20
|
+
end_date = cls()._current_date + timedelta(days=days)
|
|
21
|
+
return end_date.strftime(cls()._formats[format])
|
|
22
|
+
|
|
23
|
+
def __str__(self):
|
|
24
|
+
return self._current_date.strftime("%Y-%m-%d")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import configparser
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from faker.providers.bank.en_PH import logger
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Logger:
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self.logger = logging.getLogger(__name__)
|
|
11
|
+
self.setup_logger()
|
|
12
|
+
|
|
13
|
+
def setup_logger(self):
|
|
14
|
+
parser = configparser.ConfigParser()
|
|
15
|
+
parser.read('pytest.ini')
|
|
16
|
+
|
|
17
|
+
def get_log(self):
|
|
18
|
+
return self.logger
|
|
19
|
+
|
|
20
|
+
def log_request(self, method, url, params=None, headers=None, data=None):
|
|
21
|
+
self.logger.info(
|
|
22
|
+
f'{"=" * 14}REQUEST INFO{"=" * 14}\nRequest Method: {method} \nURL: {url} \nParams: {params} \nHeaders: {headers} \nData: {data}\n{"=" * 13}↓RESPONSE INFO↓{"=" * 13}')
|
|
23
|
+
|
|
24
|
+
def log_response(self, response):
|
|
25
|
+
try:
|
|
26
|
+
self.logger.info(
|
|
27
|
+
f'Response StatusCode: {response.status_code}\nCookies: {response.cookies}\nHeaders: {response.headers}, \nData: {json.dumps(response.json(), indent=4, ensure_ascii=False)}')
|
|
28
|
+
except json.JSONDecodeError:
|
|
29
|
+
self.logger.info(f'Response (non-JSON): {response.text}\n')
|
|
30
|
+
|
|
31
|
+
def error(self, message):
|
|
32
|
+
self.logger.error(message)
|
|
33
|
+
|
|
34
|
+
def log_str(self, str):
|
|
35
|
+
return self.logger.info(str)
|
|
36
|
+
|
|
37
|
+
def __str__(self):
|
|
38
|
+
return logger.info()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import allure
|
|
2
|
+
|
|
3
|
+
from partest.test_types import TypesTestCases
|
|
4
|
+
from partest.allure_graph import create_chart
|
|
5
|
+
from partest.call_storage import call_count, call_type
|
|
6
|
+
from confpartest import test_types_coverage, test_types_exception
|
|
7
|
+
|
|
8
|
+
types = TypesTestCases
|
|
9
|
+
required_types = test_types_coverage
|
|
10
|
+
exception_types = test_types_exception
|
|
11
|
+
|
|
12
|
+
def zorro():
|
|
13
|
+
"""Function for displaying the total number of API calls and test types."""
|
|
14
|
+
report_lines = []
|
|
15
|
+
total_coverage_percentage = 0
|
|
16
|
+
total_endpoints = 0
|
|
17
|
+
total_calls_excluding_generation = 0
|
|
18
|
+
|
|
19
|
+
for (method, endpoint, description), count in call_count.items():
|
|
20
|
+
types = set(call_type[(method, endpoint, description)])
|
|
21
|
+
total_endpoints += 1
|
|
22
|
+
|
|
23
|
+
if 'generation_data' in types and len(types) == 1:
|
|
24
|
+
pass
|
|
25
|
+
else:
|
|
26
|
+
total_calls_excluding_generation += count
|
|
27
|
+
|
|
28
|
+
coverage_status = "Недостаточное покрытие ❌"
|
|
29
|
+
present_types = [test_type for test_type in required_types if test_type in types]
|
|
30
|
+
coverage_count = len(present_types)
|
|
31
|
+
required_count = len(required_types)
|
|
32
|
+
|
|
33
|
+
if any(exception_type in types for exception_type in exception_types):
|
|
34
|
+
coverage_percentage = 100
|
|
35
|
+
coverage_status = "Покрытие выполнено на 100% ✅ (исключение)"
|
|
36
|
+
elif coverage_count == required_count:
|
|
37
|
+
coverage_percentage = 100
|
|
38
|
+
coverage_status = "Покрытие выполнено ✅"
|
|
39
|
+
elif coverage_count > 0:
|
|
40
|
+
coverage_percentage = (coverage_count / required_count) * 100
|
|
41
|
+
coverage_status = f"Покрытие выполнено на {coverage_percentage:.2f}% 🔔"
|
|
42
|
+
else:
|
|
43
|
+
coverage_percentage = 0
|
|
44
|
+
|
|
45
|
+
total_coverage_percentage += coverage_percentage
|
|
46
|
+
|
|
47
|
+
report_line = (
|
|
48
|
+
f"\n{description}\nЭндпоинт: {endpoint}\nМетод: {method} | "
|
|
49
|
+
f"Обращений: {count}, Типы тестов: {', '.join(types)}\n{coverage_status}\n"
|
|
50
|
+
)
|
|
51
|
+
report_lines.append(report_line)
|
|
52
|
+
|
|
53
|
+
if total_endpoints > 0:
|
|
54
|
+
average_coverage_percentage = total_coverage_percentage / total_endpoints
|
|
55
|
+
else:
|
|
56
|
+
average_coverage_percentage = 0
|
|
57
|
+
|
|
58
|
+
border = "*" * 50
|
|
59
|
+
summary = f"{border}\nОбщий процент покрытия: {average_coverage_percentage:.2f}%\nОбщее количество вызовов (исключая 'generation_data'): {total_calls_excluding_generation}\n{border}\n"
|
|
60
|
+
report_lines.insert(0, summary)
|
|
61
|
+
create_chart(call_count)
|
|
62
|
+
|
|
63
|
+
with open('api_call_counts.png', 'rb') as f:
|
|
64
|
+
allure.attach(f.read(), name='Оценка покрытия', attachment_type=allure.attachment_type.PNG)
|
|
65
|
+
|
|
66
|
+
allure.attach("\n".join(report_lines), name='Отчет по вызовам API', attachment_type=allure.attachment_type.TEXT)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: partest
|
|
3
|
+
Version: 0.1.8
|
|
4
|
+
Summary: This is a module for the rapid implementation of test cases with coverage tracking. This module contains a call counter for specific endpoints and their methods. As well as the function of determining the types of tests that need to be counted.
|
|
5
|
+
Home-page: https://github.com/Dec01/partest
|
|
6
|
+
Author: dec01
|
|
7
|
+
Author-email: parschin.ewg@yandex.ru
|
|
8
|
+
Project-URL: GitHub, https://github.com/Dec01/partest
|
|
9
|
+
Keywords: autotest partest test coverage
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: httpx>=0.27.2
|
|
16
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
17
|
+
Requires-Dist: matplotlib>=3.9.2
|
|
18
|
+
Requires-Dist: allure-pytest>=2.8.18
|
|
19
|
+
Requires-Dist: pytest-asyncio>=0.23.7
|
|
20
|
+
Requires-Dist: pytest==8.3.3
|
|
21
|
+
|
|
22
|
+
## HI!
|
|
23
|
+
|
|
24
|
+
This is a framework for API autotests with coverage assessment. Detailed instructions in the process of writing. It is better to check with the author how to use it. Tools are used:
|
|
25
|
+
|
|
26
|
+
* pytest
|
|
27
|
+
* httpx
|
|
28
|
+
* allure
|
|
29
|
+
|
|
30
|
+
Files are required for specific work:
|
|
31
|
+
|
|
32
|
+
**conftest.py** - it must have a fixture's inside:
|
|
33
|
+
|
|
34
|
+
```commandline
|
|
35
|
+
@pytest.fixture(scope="session")
|
|
36
|
+
def api_client(domain):
|
|
37
|
+
return ApiClient(domain=domain)
|
|
38
|
+
```
|
|
39
|
+
```commandline
|
|
40
|
+
@pytest.fixture(scope='session', autouse=True)
|
|
41
|
+
def clear_call_data():
|
|
42
|
+
"""Фикстура для очистки данных перед запуском тестов."""
|
|
43
|
+
global call_count, call_type
|
|
44
|
+
api_call_storage.call_count.clear()
|
|
45
|
+
api_call_storage.call_type.clear()
|
|
46
|
+
yield
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**confpartest.py** - It must have variables inside:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
swagger_files = {
|
|
53
|
+
'test1': ['local', '../public/swagger/app-openapi.yaml'],
|
|
54
|
+
'test2': ['local', '../public/swagger/app-openapi2.yaml'],
|
|
55
|
+
'test3': ['url', 'https://url.ru']
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test_types_coverage = ['default', '405', 'param']
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The project must have a test that displays information about the coverage in allure. The name of it **test_zorro.py**:
|
|
62
|
+
|
|
63
|
+
```commandline
|
|
64
|
+
|
|
65
|
+
async def test_display_final_call_counts(self):
|
|
66
|
+
report_lines = []
|
|
67
|
+
total_coverage_percentage = 0
|
|
68
|
+
total_endpoints = 0
|
|
69
|
+
total_calls_excluding_generation = 0
|
|
70
|
+
|
|
71
|
+
for (method, endpoint, description), count in call_count.items():
|
|
72
|
+
types = set(call_type[(method, endpoint, description)])
|
|
73
|
+
total_endpoints += 1
|
|
74
|
+
|
|
75
|
+
# Подсчет вызовов, исключая тип 'generation_data'
|
|
76
|
+
if 'generation_data' not in types:
|
|
77
|
+
total_calls_excluding_generation += count
|
|
78
|
+
|
|
79
|
+
# Проверка на наличие обязательных типов тестов
|
|
80
|
+
coverage_status = "Недостаточное покрытие ❌"
|
|
81
|
+
matched_types = set(types).intersection(types)
|
|
82
|
+
count_matched = len(matched_types)
|
|
83
|
+
|
|
84
|
+
# Логика для определения статуса покрытия и расчета процента
|
|
85
|
+
if count_matched == len(types): # Все типы присутствуют
|
|
86
|
+
coverage_status = "Покрытие выполнено ✅"
|
|
87
|
+
total_coverage_percentage += 100
|
|
88
|
+
elif count_matched == 2:
|
|
89
|
+
coverage_status = "Покрытие выполнено на 66% 🔔"
|
|
90
|
+
total_coverage_percentage += 66
|
|
91
|
+
elif count_matched == 1:
|
|
92
|
+
coverage_status = "Покрытие выполнено на 33% ❌"
|
|
93
|
+
total_coverage_percentage += 33
|
|
94
|
+
else:
|
|
95
|
+
coverage_status = "Недостаточное покрытие ❌"
|
|
96
|
+
total_coverage_percentage += 0
|
|
97
|
+
|
|
98
|
+
report_line = (
|
|
99
|
+
f"\n{description}\nЭндпоинт: {endpoint}\nМетод: {method} | "
|
|
100
|
+
f"Обращений: {count}, Типы тестов: {', '.join(types)}\n{coverage_status}\n"
|
|
101
|
+
)
|
|
102
|
+
report_lines.append(report_line)
|
|
103
|
+
|
|
104
|
+
# Вычисление общего процента покрытия
|
|
105
|
+
if total_endpoints > 0:
|
|
106
|
+
average_coverage_percentage = total_coverage_percentage / total_endpoints
|
|
107
|
+
else:
|
|
108
|
+
average_coverage_percentage = 0
|
|
109
|
+
|
|
110
|
+
border = "*" * 50
|
|
111
|
+
summary = f"{border}\nОбщий процент покрытия: {average_coverage_percentage:.2f}%\nОбщее количество вызовов (исключая 'generation_data'): {total_calls_excluding_generation}\n{border}\n"
|
|
112
|
+
|
|
113
|
+
# Добавляем сводку в начало отчета
|
|
114
|
+
report_lines.insert(0, summary)
|
|
115
|
+
|
|
116
|
+
create_chart(call_count)
|
|
117
|
+
|
|
118
|
+
with open('api_call_counts.png', 'rb') as f:
|
|
119
|
+
allure.attach(f.read(), name='Оценка покрытия', attachment_type=allure.attachment_type.PNG)
|
|
120
|
+
|
|
121
|
+
allure.attach("\n".join(report_lines), name='Отчет по вызовам API', attachment_type=allure.attachment_type.TEXT)
|
|
122
|
+
|
|
123
|
+
assert True
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
What does the test look like:
|
|
129
|
+
|
|
130
|
+
```commandline
|
|
131
|
+
async def test_get(self, api_client):
|
|
132
|
+
endpoint = 'https://ya.ru'
|
|
133
|
+
response = await api_client.make_request(
|
|
134
|
+
'GET',
|
|
135
|
+
endpoint,
|
|
136
|
+
params='limit=1',
|
|
137
|
+
expected_status_code=200,
|
|
138
|
+
validate_model=Models.ValidateGet,
|
|
139
|
+
type=types.type_default
|
|
140
|
+
)
|
|
141
|
+
assert response is not None
|
|
142
|
+
assert isinstance(response, dict)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
All available data that the client can accept:
|
|
146
|
+
```
|
|
147
|
+
method: str,
|
|
148
|
+
endpoint: str,
|
|
149
|
+
add_url1: Optional[str] = '',
|
|
150
|
+
add_url2: Optional[str] = '',
|
|
151
|
+
add_url3: Optional[str] = '',
|
|
152
|
+
params: Optional[Dict[str, Any]] = None,
|
|
153
|
+
headers: Optional[Dict[str, str]] = None,
|
|
154
|
+
data: Optional[Dict[str, Any]] = None,
|
|
155
|
+
expected_status_code: Optional[int] = None,
|
|
156
|
+
validate_model: Optional[Type[BaseModel]] = None,
|
|
157
|
+
type: Optional[str] = None```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
setup.cfg
|
|
3
|
+
setup.py
|
|
4
|
+
partest/__init__.py
|
|
5
|
+
partest/allure_graph.py
|
|
6
|
+
partest/call_storage.py
|
|
7
|
+
partest/client.py
|
|
8
|
+
partest/coverage.py
|
|
9
|
+
partest/methods.py
|
|
10
|
+
partest/parparser.py
|
|
11
|
+
partest/test_types.py
|
|
12
|
+
partest/zorro_report.py
|
|
13
|
+
partest.egg-info/PKG-INFO
|
|
14
|
+
partest.egg-info/SOURCES.txt
|
|
15
|
+
partest.egg-info/dependency_links.txt
|
|
16
|
+
partest.egg-info/requires.txt
|
|
17
|
+
partest.egg-info/top_level.txt
|
|
18
|
+
partest/utils/__init__.py
|
|
19
|
+
partest/utils/ascii.py
|
|
20
|
+
partest/utils/checking.py
|
|
21
|
+
partest/utils/date.py
|
|
22
|
+
partest/utils/logger.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
partest
|
partest-0.1.8/setup.cfg
ADDED
partest-0.1.8/setup.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def readme():
|
|
5
|
+
with open('README.md', 'r') as f:
|
|
6
|
+
return f.read()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
setup(
|
|
10
|
+
name='partest',
|
|
11
|
+
version='0.1.8',
|
|
12
|
+
author='dec01',
|
|
13
|
+
author_email='parschin.ewg@yandex.ru',
|
|
14
|
+
description='This is a module for the rapid implementation of test cases with coverage tracking. This module contains a call counter for specific endpoints and their methods. As well as the function of determining the types of tests that need to be counted.',
|
|
15
|
+
long_description=readme(),
|
|
16
|
+
long_description_content_type='text/markdown',
|
|
17
|
+
url='https://github.com/Dec01/partest',
|
|
18
|
+
packages=find_packages(exclude=['src']),
|
|
19
|
+
install_requires=['httpx>=0.27.2', 'pyyaml>=6.0.2', 'matplotlib>=3.9.2', 'allure-pytest>=2.8.18', 'pytest-asyncio>=0.23.7', 'pytest==8.3.3'],
|
|
20
|
+
classifiers=[
|
|
21
|
+
'Programming Language :: Python :: 3',
|
|
22
|
+
'License :: OSI Approved :: MIT License',
|
|
23
|
+
'Operating System :: OS Independent'
|
|
24
|
+
],
|
|
25
|
+
keywords='autotest partest test coverage',
|
|
26
|
+
project_urls={
|
|
27
|
+
'GitHub': 'https://github.com/Dec01/partest'
|
|
28
|
+
},
|
|
29
|
+
python_requires='>=3.8'
|
|
30
|
+
)
|