Автоматизация QA без программирования: Как я начал строить No-Code тулзу через конфиги

Дратути!

Работая в одной финтех компании 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




    Run and Result
    


    

Run and Result

После запуска выглядит это вот так

Стартовая страница
Стартовая страница

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

Выбор конфигурационного файла для загрузки
Выбор конфигурационного файла для загрузки

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

Результаты выполнения тестов
Результаты выполнения тестов

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

Если у кого-то возникло желание поконтрибьютить и начать развивать данный проект совместно — велком в репку

© Habrahabr.ru