|
|
||
Двести тест-кейсов в Allure TestOps ждут автоматизации. На бумаге всё просто: открываешь Opencode, пишешь "сгенерируй тест для кейса 1234", агент подтягивает скилл, читает reference-файлы, дёргает MCP, выдаёт pytest-файл, прогоняет ruff и pytest. Копируешь в репо, коммитишь, берёшь следующий ID. Через неделю - двадцать тестов. Через месяц - восемьдесят. Через три месяца - все двести. На бумаге.
На практике на тридцатом тесте инженер замечает, что Opencode начал путаться. Агент забывает, какая фикстура создавала опубликованную коллекцию, дублирует импорты, вставляет хелпер прямо в тело теста. Причина не в модели и не в скилле. Причина в контекстном окне: один промт "сгенерируй 1 тест" сжигает восемьдесят-сто двадцать тысяч токен. После второго теста сессия переполнена, и третий уже не помещается. Дальше - галлюцинации, потеря шагов, деградация качества.
Время автоматизатора - конечный ресурс, и тратить его на монотонную генерацию скелета теста по шагам кейса неправильно. Эти часы нужны для расширения покрытия, разбора flaky и работы с хрупкими селекторами. Делегировать рутину агенту - естественно. Но делегирование не работает, если агенту каждый раз приходится заново осознавать устройство проекта, и тем более не работает, если он делает это в одной длинной сессии, пока не задохнётся.
Решение - фабрика. Не один длинный разговор, а конвейер из коротких сессий. Orchestrator-скрипт держит список ID кейсов и состояние очереди, для каждого кейса запускает свежий opencode run с минимальным промптом и автодопуском. Worker-сессия живёт пять-десять минут, генерирует один файл, валидирует, пишет отчёт и умирает. Восемь часов - двести файлов. Ниже - архитектура фабрики и рабочий код.
Я намеренно избегаю общих рассуждений про "AI повысит продуктивность". Речь о конкретных операциях: выгрузить ID из TestOps, разложить по очереди, запустить worker-сессию, проверить ruff, прогнать pytest, собрать отчёт, повторить для упавших. Каждый шаг диффабелен, каждый лог сохраняется, каждое решение агента видно в JSON-выводе. Это честная автоматизация рутины, а не чёрный ящик.
Прежде чем строить фабрику, важно понять, откуда берётся цифра. Контекстное окно Claude Sonnet 3.5/3.7 - около двухсот тысяч токен. Это听起来 много, но один цикл генерации теста съедает его почти целиком. Разложим по полочкам, что именно попадает в контекст за один прогон.
Скилл generate-test сам по себе весит пятнадцать-двадцать тысяч токен. Это правила именования, таблицы маппинга Feature - директория, шаблон теста, чеклист, ограничения по code style. Скилл подгружается целиком через плагин skill, и агент работает с ним, как с инструкцией.
Дальше идут три reference-файла: CLAUDE.md, API_REFERENCE.md, FIXTURES_REFERENCE.md. В сумме тридцать-сорок тысяч токен. Без них агент не знает, какие методы клиента вызывать и какие фикстуры создают сущности нужного типа. Пропустить - значит гадать, а угаданная фикстура в репозитории с сотней доменных областей почти всегда ошибочна.
Сам тест-кейс из Allure TestOps через MCP - ещё пять-десять тысяч токен. Кейс с тридцатью шагами и вложенными depth: 1 разрастается быстро, особенно если в expected_result длинные многострочные проверки. Соседние тесты как образец - один-три файла по тысяче строк - добавляют двадцать-тридцать тысяч токен. Без них агент клепает код "в вакууме", и стиль не совпадает с устоявшимся в домене.
Промежуточные tool-вызовы - grep, read, glob по репозиторию - добавляют ещё десять-двадцать тысяч. Каждый read большого файла или grep с богатым выводом съедает токены. Финальная генерация теста - пять-десять тысяч токен на выход модели. Ruff и pytest вместе - пять-пятнадцать тысяч, причём упавший pytest с трейсбэком раздувает лог до двадцати.
Итого: успешная генерация - восемьдесят-сто двадцать тысяч токен. Проблемная, с упавшим pytest и длинным трейсбэком, - до ста восьмидесяти. При лимите сессии в двести тысяч проходится один тест, максимум два. Третий уже не помещается, и агент начинает путать фикстуры, дублировать импорты, терять шаги кейса. Это не баг модели, это физика контекстного окна.
Идея фабрики проста: вместо одной длинной сессии, которая задыхается на третьем тесте, запускаем много коротких. Каждая worker-сессия - чистый контекст, одна задача, один выход. Orchestrator-скрипт не знает про устройство проекта, не загружает reference-файлы, не вызывает MCP. Он только итерирует ID и проверяет результат.
Схема потока данных выглядит так:
TestOps (200 кейсов)
| fetch_ids.py
v
ids.jsonl (очередь)
| orchestrator.sh
|........+........+........+.........
v v v v
worker 1 worker 2 worker 3 worker N
| | | |
v v v v
test_a.py test_b.py test_c.py test_n.py
| | | |
+--------+--------+--------+-----> state.json
|
v
final_report.md
|
v
retry_orchestrator.sh
Каждый worker - отдельный процесс opencode run со своим контекстом. Orchestrator держит состояние в state.json: для каждого ID хранится статус (pending, in_progress, done, failed, skipped), путь к сгенерированному файлу, результат ruff, результат pytest, причина падения если есть. Это позволяет прервать фабрику в любой момент и продолжить с того же места.
Важно: orchestrator и worker разделены жёстко. Orchestrator - bash-цикл, который ничего не знает про доменную область. Worker - opencode-сессия, которая знает про неё всё через скилл и reference-файлы. Если поменять скилл - фабрика продолжает работать без правки orchestrator. Если поменять модель - то же самое. Слои ответственности разнесены так, как принято в Infrastructure-as-Code.
Прежде чем запускать worker, нужно собрать список кейсов, которые подлежат автоматизации. В Allure TestOps это фильтр по AQL: automation = false and status = "Active". Получить ID можно двумя путями.
Первый - через MCP-инструмент allure_search_test_cases прямо внутри opencode-сессии. Это удобно, если orchestrator сам написан на opencode (через opencode run --agent build), но тогда orchestrator-сессия тоже тратит токены на MCP-вызовы. Второй - через отдельный Python-клиент TestOps вне opencode. Это чище: orchestrator остаётся тонким, MCP-трафик не раздувает его контекст.
Скрипт fetch_ids.py выгружает ID в формате JSONL - по одной записи на строку. Это позволяет обрабатывать очередь потоково, без загрузки всего массива в память:
import json
import os
import requests
BASE = "https://testops.company.ru/api/v1"
TOKEN = os.environ["TESTOPS_TOKEN"]
PROJECT_ID = 26
def fetch_ids():
headers = {"Authorization": f"Bearer {TOKEN}"}
rql = 'automation = false and status = "Active"'
url = f"{BASE}/projects/{PROJECT_ID}/testcases/search"
page = 0
while True:
resp = requests.post(url, json={"rql": rql, "page": page, "size": 100},
headers=headers, timeout=30)
resp.raise_for_status()
items = resp.json().get("items", [])
if not items:
break
for tc in items:
yield {"id": tc["id"], "name": tc["name"], "status": "pending"}
page += 1
with open("ids.jsonl", "w", encoding="utf-8") as f:
for entry in fetch_ids():
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
print(f"saved {sum(1 for _ in open('ids.jsonl'))} ids")
На выходе - ids.jsonl с двумястами записями. Каждая запись содержит ID кейса, его имя из TestOps и стартовый статус. Имя пригодится в отчёте, чтобы не открывать TestOps ради каждого упавшего теста.
Worker - это bash-обёртка вокруг opencode run. Она принимает ID кейса как аргумент, формирует промт, запускает opencode в неинтерактивном режиме с автодопуском, сохраняет лог. Промт короткий и всегда одинаковый - всю тяжесть знания про устройство проекта несёт скилл, а не промт.
#!/usr/bin/env bash
set -euo pipefail
TC_ID="$1"
SESSION_TITLE="autogen-$TC_ID-$(date +%s)"
LOG_DIR="./.factory/logs"
mkdir -p "$LOG_DIR"
opencode run \
--auto \
--agent build \
--title "$SESSION_TITLE" \
--format json \
"@@generate-test сгенерируй автотест по кейсу #$TC_ID из Allure TestOps.
После генерации обязательно запусти ruff и pytest на сгенерированном файле.
В конце ответа верни строго JSON-объект в одну строку:
{\"id\": $TC_ID, \"file_path\": \"...\", \"ruff_ok\": true/false,
\"pytest_ok\": true/false, \"error\": \"...\" или null}" \
> "$LOG_DIR/$TC_ID.log" 2>&1
echo "$LOG_DIR/$TC_ID.log"
Разбор флагов. --auto включает автоапрув всех permissions, которые не запрещены явно. Это критично: без него агент встанет на первом write и будет ждать ввода, а у нас двести итераций. Использовать --auto безопасно только в изолированном git-worktree или отдельной ветке - случайно перезаписать продакшен-файл в монорепо никто не хочет.
--agent build выбирает primary-агента с правом записи и bash. В Opencode из коробки это агент с правами edit: allow, bash: allow. Если нужен более осторожный режим - создаёте своего в ~/.config/opencode/agents/test-factory.md с явным списком разрешённых команд.
--title помечает сессию. Это важно для последующего аудита: opencode session list покажет все запуски фабрики, и по заголовку можно найти лог конкретного кейса. --format json даёт структурированный вывод - каждая транзакция агента как JSON-событие, что позволяет парсить результат без регулярок по тексту.
Промт намеренно короткий. Скилл generate-test уже знает про Шаги 1-9 (получить кейс, прочитать reference, посмотреть соседей, проверить коллизию @allure.id, сгенерировать, записать, прогнать ruff, запустить pytest). Промт лишь запускает скилл и фиксирует формат отчёта. Любая попытка впихнуть в промт устройство проекта - это повторное раздувание контекста, ровно то, от чего мы уходим.
Orchestrator - простой bash-цикл по ids.jsonl. Для каждой строки он проверяет статус в state.json и пропускает уже обработанные. Это даёт две полезных свойства: фабрику можно прервать в любой момент (Ctrl-C, обед, отвлёкся) и продолжить с того же места; один и тот же кейс не генерируется дважды, даже если orchestrator перезапущен.
#!/usr/bin/env bash
set -uo pipefail
STATE="./.factory/state.json"
LOG_DIR="./.factory/logs"
mkdir -p "$LOG_DIR"
[[ -f "$STATE" ]] || echo "{}" > "$STATE"
while IFS= read -r line; do
TC_ID=$(echo "$line" | jq -r '.id')
STATUS=$(jq -r --arg id "$TC_ID" '.[$id].status // "pending"' "$STATE")
if [[ "$STATUS" == "done" || "$STATUS" == "skipped" ]]; then
continue
fi
echo "[$(date +%H:%M:%S)] worker for $TC_ID (prev=$STATUS)"
LOG_FILE=$(./worker.sh "$TC_ID") || true
python3 ./scripts/parse_report.py \
--id "$TC_ID" \
--log "$LOG_FILE" \
--state "$STATE"
sleep 5
done < <(jq -c '.' ./ids.jsonl)
python3 ./scripts/summary.py --state "$STATE" > "$LOG_DIR/final_report.md"
echo "report: $LOG_DIR/final_report.md"
Пауза в пять секунд между worker-ами - не красота, а защита. Если TestOps или MCP-сервер начинают throttлить под нагрузкой, пауза даёт им передышку. Если rate-limit не угрожает - можно убрать, но тогда стоит следить за 429-ми в логах.
parse_report.py проходит по JSON-логу worker, извлекает итоговый JSON-отчёт агента (тот, что мы просили вернуть в промте), парсит его и обновляет state.json:
import json
import re
import sys
import argparse
from pathlib import Path
REPORT_RE = re.compile(r'\{"id":\s*\d+,[^{}]*\}', re.DOTALL)
def parse(log_path: Path) -> dict:
text = log_path.read_text(encoding="utf-8", errors="ignore")
matches = REPORT_RE.findall(text)
if not matches:
return {"status": "failed", "reason": "no JSON report in log"}
try:
return json.loads(matches[-1])
except json.JSONDecodeError:
return {"status": "failed", "reason": "report not parseable"}
def update_state(state_path: Path, tc_id: int, report: dict) -> None:
state = json.loads(state_path.read_text(encoding="utf-8"))
status = "done" if report.get("ruff_ok") and report.get("pytest_ok") else "failed"
state[str(tc_id)] = {
"status": status,
"file_path": report.get("file_path"),
"ruff_ok": report.get("ruff_ok", False),
"pytest_ok": report.get("pytest_ok", False),
"error": report.get("error"),
}
state_path.write_text(json.dumps(state, ensure_ascii=False, indent=2),
encoding="utf-8")
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument("--id", type=int, required=True)
p.add_argument("--log", required=True)
p.add_argument("--state", required=True)
a = p.parse_args()
report = parse(Path(a.log))
update_state(Path(a.state), a.id, report)
Регулярка REPORT_RE - намеренно простая. Если агент завернул отчёт в markdown-блок или окружил пояснениями, мы всё равно достаём последний JSON-объект с полем id. Это хрупко, но работает в девяноста процентах случаев. Альтернатива - ходить в opencode session list и забирать последний message через API, но это лишний слой.
Скилл generate-test уже включает шаг 7 (ruff) и шаг 8 (pytest). Worker-сессия прогоняет их автоматически. Но доверять агенту на слово нельзя - он может сказать "pytest_ok: true", а на деле pytest не запускался или упал в другом файле. Поэтому orchestrator дублирует проверку снаружи, через независимый Python-скрипт.
import json
import subprocess
from pathlib import Path
def validate_one(tc_id: int, state: dict) -> dict:
entry = state.get(str(tc_id), {})
file_path = Path(entry["file_path"]) if entry.get("file_path") else None
if not file_path or not file_path.exists():
return {"id": tc_id, "valid": False, "reason": "file not found"}
ruff = subprocess.run(
["ruff", "check", str(file_path)],
capture_output=True, timeout=30,
)
pytest = subprocess.run(
["pytest", str(file_path), "-x", "--tb=short", "--no-header", "-q"],
capture_output=True, timeout=180,
)
return {
"id": tc_id,
"file_path": str(file_path),
"ruff_ok": ruff.returncode == 0,
"pytest_ok": pytest.returncode == 0,
"ruff_stderr": ruff.stderr.decode()[:500],
"pytest_stdout": pytest.stdout.decode()[-2000:],
}
if __name__ == "__main__":
state = json.loads(Path("./.factory/state.json").read_text(encoding="utf-8"))
report = []
for tc_id_str, entry in state.items():
if entry.get("status") != "done":
continue
report.append(validate_one(int(tc_id_str), state))
Path("./.factory/validation.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(f"validated {len(report)} files")
Таймаут на pytest - сто восемьдесят секунд. Этого хватает для большинства тестов, но не для тех, что создают десяток сущностей и ждут публикации. Если pytest упал по таймауту - это не баг генерации, а сигнал, что тесту нужна оптимизация Arrange. Записываем в validation.json как pytest_ok: false с причиной timeout, и кейс попадает в retry.
Валидация снаружи важна по другой причине. Агент может "договориться" с собой: например, решить, что skipped-тест - это приемлемо, и поставить pytest.skip, чтобы формально пройти. Внешний скрипт считает skipped как fail: returncode == 0 у pytest и при skip, и при pass, но в pytest_stdout появляется 1 skipped. Если это произошло - кейс возвращается в очередь на перегенерацию с явным запретом skip.
Не все тесты пройдут с первого раза. Из двухсот реально успешно генерируется сто семьдесят-сто восемьдесят. Остальные падают по разным причинам. Кейс в TestOps без параметров, и агент сгенерировал тест с одной комбинацией-примером. Неизвестный Feature - агент положил файл в tests/regress_okko/misc/ и пометил как skipped. Pytest падает из-за бага в фикстуре, который обнаружился только на живом запуске.
Для упавших orchestrator запускает retry с расширенным промптом. К прошлой ошибке добавляется контекст, и агенту явно говорится, что предыдущая попытка провалилась. Это критично: без этой фразы агент повторит те же шаги и получит тот же результат.
#!/usr/bin/env bash
set -uo pipefail
STATE="./.factory/state.json"
RETRY_DIR="./.factory/retries"
mkdir -p "$RETRY_DIR"
FAILED=$(jq -r 'to_entries[] | select(.value.status == "failed") | .key' "$STATE")
for TC_ID in $FAILED; do
ATTEMPT=$(jq -r --arg id "$TC_ID" '.[$id].attempts // 0' "$STATE")
if [[ "$ATTEMPT" -ge 3 ]]; then
echo "$TC_ID: max retries, sending to backlog"
continue
fi
ERROR=$(jq -r --arg id "$TC_ID" '.[$id].error // "unknown"' "$STATE")
echo "[$(date +%H:%M:%S)] retry #$((ATTEMPT+1)) for $TC_ID: $ERROR"
opencode run --auto --agent build \
--title "retry-$TC_ID-$(date +%s)" --format json \
"@@generate-test сгенерируй тест для кейса #$TC_ID.
Предыдущая попытка упала: $ERROR
Не повторяй ту же ошибку. Если причина в параметрах кейса - спроси у пользователя
через вопрос, но не додумывай значения сам.
В конце верни JSON-отчёт." \
> "$RETRY_DIR/$TC_ID.log" 2>&1
python3 ./scripts/parse_report.py \
--id "$TC_ID" --log "$RETRY_DIR/$TC_ID.log" --state "$STATE"
jq --arg id "$TC_ID" --argjson a "$((ATTEMPT+1))" \
'.[$id].attempts = $a' "$STATE" > "$STATE.tmp" && mv "$STATE.tmp" "$STATE"
sleep 5
done
Лимит ретраев - три. После третьего провала кейс отправляется в backlog для ручного разбора. Это граница автоматизации: если три попытки не справились, значит проблема не в генерации, а в данных кейса, в баге фикстуры или в архитектуре тестового проекта. Дальше тратить токены впустую - неэффективно.
Важно разделять типы ошибок. Если error содержит "parameter" или "unknown Feature" - это запрос к человеку, retry тут не поможет. Если error - это таймаут pytest или ruff, retry имеет смысл. Простейший фильтр в orchestrator определяет, какие ошибки идут в retry, какие сразу в backlog:
if echo "$ERROR" | grep -qE "parameter|unknown|не хватает|спроси"; then echo "$TC_ID: needs human, skipping retry" jq --arg id "$TC_ID" '.[$id].status = "needs_human"' "$STATE" > tmp && mv tmp "$STATE" continue fi
Так фабрика не тратит попытки на кейсы, которые заведомо требуют участия человека. Это уважение к ресурсу - и к токенам модели, и к вниманию автоматизатора.
После всех итераций и ретраев summary.py рисует отчёт по состоянию очереди. Цель - не похвастаться "всё сработало", а дать инженеру конкретный список того, что требует его внимания.
import json
from collections import Counter
from pathlib import Path
state = json.loads(Path("./.factory/state.json").read_text(encoding="utf-8"))
total = len(state)
by_status = Counter(v["status"] for v in state.values())
reasons = Counter(v.get("error") for v in state.values() if v["status"] == "failed")
print(f"Total: {total}")
for s in ("done", "failed", "needs_human", "skipped"):
print(f" {s}: {by_status.get(s, 0)} ({by_status.get(s, 0)*100//total}%)")
print("\nTop failure reasons:")
for reason, n in reasons.most_common(5):
print(f" {n}x {reason[:80] if reason else 'unknown'}")
print("\nFiles generated:")
for tc_id, v in state.items():
if v["status"] == "done" and v.get("file_path"):
print(f" #{tc_id} {v['file_path']}")
Типичный отчёт после двухсот кейсов выглядит примерно так:
Total: 200 done: 178 (89%) failed: 15 (7%) needs_human: 7 (3%) Top failure reasons: 6x fixture not found: create_event 4x pytest timeout (180s exceeded) 3x unknown Feature: "Монетизация" 2x ruff: E501 line too long
Эти четыре строки - уже план работы на следующую неделю. Шесть кейсов падают из-за отсутствия фикстуры create_event - значит, нужно её добавить в FIXTURES_REFERENCE.md (или подключить модуль в pytest_plugins). Четыре теста упали по таймауту - их нужно профилировать и выносить Arrange в сессийные фикстуры. Три кейса попали на неизвестный Feature "Монетизация" - добавить строку в таблицу маппинга в CLAUDE.md. Два - длинные строки, которые ruff не пропустил - тривиальная правка.
Главное в этом отчёте - не "89% успеха", а конкретные имена проблем. Цифра успеха ничего не говорит инженеру. Имена говорят: "вот тут нужно доделать, вот тут - поправить скилл, вот тут - спросить у владельца кейса".
Фабрика делает за восемь часов то, что вручную заняло бы четыре-шесть недель. Это не преувеличение: в реальных цифрах один тест вручную - от одного до двух часов (прочитать кейс, найти фикстуры, сверить стиль, прогнать, исправить). Двести тестов - двести-четыреста часов. Рабочая неделя автоматизатора - около сорока часов. То есть пять-десять недель монотонной работы, в которой его компетенция не нужна.
Главный архитектурный принцип фабрики - orchestrator не умный, а простой. Цикл, state, retry. Вся тяжесть знания про устройство проекта - в worker-сессии и скилле. Это позволяет менять модель, не трогая orchestrator. Менять скилл - не трогая orchestrator. Менять доменную область (другой проект TestOps, другой репозиторий автотестов) - заменой скилла и reference-файлов, без правки bash-скриптов. Слои ответственности разнесены так, как принято в Infrastructure-as-Code.
Делегирование без проверки - сломанная автоматизация. Каждый сгенерированный файл проходит ruff и pytest снаружи, не на слово агенту. Что не прошло - попадает в retry, потом в backlog, потом в ручной разбор. Ничто не "просто принимается на веру". Это критично: если бы мы доверяли отчёту агента без внешней валидации, в репо ушло бы двадцать-тридцать файлов со скрытыми проблемами, и команда обнаружила бы их только на следующем прогоне CI, когда контекст уже потерян.
Время автоматизатора возвращается туда, где оно действительно нужно. Дизайн тест-сценариев - это его работа, не агента. Разбор flaky - его работа. Расширение покрытия в узких местах, где агент не понимает доменную логику - его работа. Рутина генерации скелета теста по шагам кейса - это именно та работа, которую машина делает лучше, быстрее и монотоннее. Но только при условии, что её результаты проверяемы. Opencode + фабрика сессий даёт такую проверяемость: каждый шаг агента виден в логе, каждая правка диффабельна, каждый тест прогоняется локально перед коммитом.
Двести тест-кейсов за один рабочий день - это не магия AI. Это дисциплина инженера: разнести слои, описать состояние, добавить retry, не доверять на слово. Модель - лишь ускоритель. Архитектура - решает.
|