Дратути!
Работая в одной финтех компании TL QA, я столкнулся с тем, что уровень моих сотрудников по автоматизации не дотягивает до нужного, а рутину хотелось бы автоматизировать. В компании использовался Python (вроде все легко и просто), но все попытки обучить персонал через четкий индивидуальный план развития заканчивались тем, что у сотрудника «не хватало» времени на обучение и поднятие своего грейда как специалиста.
В какой-то момент времени мне пришла в голову идея создания инструмента автоматизации тестирования через конфигурационные файлы. Делать велосипед на подобии Cucumber особо не хотелось, да и сам инструмент в моих фантазиях не подразумевал писать дополнительный код при расширении функционала.
Итак, приступим к интересному.
Первый вариант реализации
В моей голове нарисовался примерный yaml конфиг и я начал думать уже над реализацией "движка" данного инструмента.
tests:
- type: "rest_api"
name: "Test Payment API"
request:
url: "https://api.payment.ru/payment"
method: "GET"
assertions:
- status_code: 200
Так как в компании основной стек был Python, особого выбора ЯП и технологий у меня не было. Стек выбрал для начала Python, Flask.

В файле app.py каких-то интересных моментов, на мой взгляд, нет.
# app.py
from flask import Flask, render_template, request, jsonify
from runner import running_test_config
import uuid
import os
app = Flask(__name__)
UPLOAD_FOLDER = 'uploads'
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
@app.route('/')
def index():
return render_template('index.html')
@app.route('/run-tests', methods=['POST'])
def run_tests():
if 'config' not in request.files:
return jsonify({'error': 'No file uploaded'}), 400
file = request.files['config']
config_path = os.path.join(UPLOAD_FOLDER, f"{uuid.uuid4()}.yaml")
file.save(config_path)
try:
results = running_test_config(config_path)
return jsonify(results)
except Exception as e:
return jsonify({'error': str(e)}), 400
if __name__ == '__main__':
app.run(debug=True)
Больше хотел бы рассказать про runner.py и его изменений в процессе разработки.
В первой его версии получилось вот такое решение, которое чем-то напоминает Cucumber
и имеется жесткую привязку к файлу (возможно криво выразился, сейчас покажу)
import yaml
import requests
def run_api_test(test_config):
response = requests.request(
test_config['request']['method'],
test_config['request']['url']
)
results = []
for assertion in test_config['assertions']:
if 'status_code' in assertion:
results.append({
'passed': response.status_code == assertion['status_code'],
'message': f"Expected status {assertion['status_code']}, got {response.status_code}"
})
return results
def running_test_config(config_path):
with open(config_path, 'r') as f:
config = yaml.safe_load(f)
results = []
for test in config.get('tests'):
if test.get('type') == 'rest_api':
results.extend(run_api_test(test))
return {'tests': results}
В методе run_api_test
имеется цикл for
, в котором все завязывается на статичные значения, которых может и не быть в конфигурационном файле или наоборот, блоки в конфиги есть, а проверки в методе нет. Речь про вот этот кусок кода:
if 'status_code' in assertion:
results.append({
'passed': response.status_code == assertion['status_code'],
'message': f"Expected status {assertion['status_code']}, got {response.status_code}"
})
Хотелось получать все динамически и не лазить в "движок" инструмента при каких либо добавлениях проверок.
Второй вариант реализации
Опять же начну с новой структуры yaml
конфига - на данный момент актуальный. Для примера взял открытый swagger.
tests:
- type: "rest_api"
name: "store/inventory"
request:
url: "https://petstore.swagger.io/v2/store/inventory"
method: "GET"
assertions:
- status_code:
expected_result: 200
actual_result: "{{status}}"
- body:
expected_result: 16
actual_result: "{{body.sold}}"
Основное изменение в данном файле, по сравнению с первым вариантом, это появление actual_result: "{{status}}"
, куда передается значение из ответа запроса.
app.py
остался без изменений, а вот метод run_api_test
из runner.py
пришлось перекроить целиком и полностью, чтобы достичь своей цели (конечно не уверен, что на больших конфигах это сработает гладко и я все предусмотрел). Чтобы не описывать весь код, оставлю комменты. ))
def run_api_test(test_config):
# Отправка HTTP-запроса по данным из конфига
response = requests.request(
test_config['request']['method'],
test_config['request']['url']
)
results = []
# Контекст для подстановки значений в шаблоны (статус код и тело ответа())
context = {
'status': response.status_code,
'body': response.json()
}
def change_values(data):
"""Рекурсивно заменяем значения в yaml вида '{{status}}' на реальные значения из контекста."""
if isinstance(data, dict):
# Рекурсивно обходим шаблоны (переменные вида '{{status}}')
return {k: change_values(v) for k, v in data.items()}
if isinstance(data, str) and data.startswith('{{') and data.endswith('}}'):
# Извлекаем пути из шаблона
path = data[2:-2].strip().split('.')
value = context
try:
# Ищем значения по цепочке ключей/значений
for p in path:
value = value[p] if isinstance(value, dict) else value[int(p)]
return value
except (KeyError, IndexError, TypeError):
return None
# Возвращаем данные как есть, если это не шаблон
return data
# Обработка всех проверок из конфига
for assertion in test_config['assertions']:
# Создаем копию проверок, чтобы не менять исходный конфиг
assertion_copy = deepcopy(assertion)
# Определяем тип проверки (первый ключ в словаре)
assertion_type = list(assertion.keys())[0]
assertion_data = assertion_copy[assertion_type]
# Заменяем шаблоны в данных утверждения на реальные значения
resolved_data = change_values(assertion_data)
# Формируем результат проверки
result = {
'endpoint': test_config['name'],
'type': assertion_type,
'expected': resolved_data['expected_result'],
'actual': resolved_data['actual_result'],
'passed': resolved_data['actual_result'] == resolved_data['expected_result']
}
results.append(result)
return results
На первый взгляд кажется, что все хорошо и можно запускать наши пробные тесты. Делаем это с помощью python3 app.py
Чуть на забыл. У нас же есть еще и index.html
<!DOCTYPE html>
<html>
<head>
<title>Run and Result</title>
<style>
.container { max-width: 800px; margin: 0 auto; }
.test-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
font-family: Arial, sans-serif;
}
.test-table th {
background-color: #f5f5f5;
padding: 12px;
text-align: left;
border-bottom: 2px solid #ddd;
}
.test-table td {
padding: 10px;
border-bottom: 1px solid #eee;
}
.test-pass {
background-color: #e8f5e9;
color: #2e7d32;
}
.test-fail {
background-color: #ffebee;
color: #c62828;
}
</style>
</head>
<body>
<div class="container" align="center">
<h1>Run and Result</h1>
<input type="file" id="configFile" accept=".yaml">
<button onclick="runTests()">Run Tests</button>
<div id="results"></div>
</div>
<script>
async function runTests() {
const file = document.getElementById('configFile').files[0];
if (!file) return;
const formData = new FormData();
formData.append('config', file);
try {
const response = await fetch('/run-tests', {
method: 'POST',
body: formData
});
const data = await response.json();
displayResults(data.tests);
} catch (error) {
console.error(error);
}
}
function displayResults(tests) {
const resultsDiv = document.getElementById('results');
resultsDiv.innerHTML = `
<table class="test-table">
<thead>
<tr>
<th>Endpoint</th>
<th>Test Name</th>
<th>Actual Result</th>
<th>Expected Result</th>
<th>Result</th>
</tr>
</thead>
<tbody>
${tests.map(test => `
<tr class="${test.passed ? 'test-pass' : 'test-fail'}">
<td>${test.endpoint}</td>
<td>${test.type}</td>
<td>${test.actual ?? 'N/A'}</td>
<td>${test.expected ?? 'N/A'}</td>
<td>${test.passed ? '✓ Passed' : '✗ Failed'}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
</script>
</body>
</html>
После запуска выглядит это вот так

Необходимо выбрать наш сконфигурированный yaml
файл и нажать Run Tests

В результате работы инструмента мы получаем вот такой результат

В дальнейших планах
1. добавить метод обработки UI тестов, GraphQL тестов
2. добавить генерацию allure report или разработать свой минимальный аналог
Спасибо за внимание.
Если у кого-то возникло желание поконтрибьютить и начать развивать данный проект совместно - велком в репку