function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } const p = (text) => '

' + text + '

'; const h2 = (text) => '

' + text + '

'; const code = (text) => '
' + escapeHtml(text) + '
'; const ol = (items) => '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; const figure = (src, alt, caption) => '
' + alt + '
' + caption + '
'; const table = (caption, headers, rows) => '
' + headers.map((item) => '').join('') + '' + rows.map((row) => '' + row.map((item) => '').join('') + '').join('') + '
' + caption + '
' + item + '
' + item + '
'; function plainText(content) { return content .replace(/<[^>]+>/g, ' ') .replaceAll(' ', ' ') .replaceAll('"', '"') .replaceAll(''', "'") .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('&', '&') .replace(/\s+/g, ' ') .trim(); } function bodyText(content) { return plainText(content.replace(/

Проверяемые источники<\/h2>[\s\S]*?(?=

|$)/, '')); } const sources = [ { title: 'OpenAPI Specification v3.1.0, 15 февраля 2021', url: 'https://spec.openapis.org/oas/v3.1.0', note: 'версионная первичная спецификация, доступная до июля 2023. OAS описывает интерфейс HTTP API и типы; для части свойств она прямо оставляет прикладную семантику потребляющему приложению.', }, { title: 'Pact Docs: Introduction to contract testing, обновлено 30 августа 2022', url: 'https://docs.pact.io/', note: 'официальная документация Pact, исторически доступная до июля 2023. Она различает статическую schema/specification и contract by example, который формируется выполнением consumer tests.', }, { title: 'Pact Docs: Verifying Pacts, обновлено 11 августа 2022', url: 'https://docs.pact.io/provider', note: 'официальная документация Pact, исторически доступная до июля 2023. В ней provider verification привязан к локальному provider или CI, состояниям provider и опубликованному результату; этот sidecar такого запуска не выполняет.', }, ]; function sourceList() { return ''; } function revision(meta, parts) { const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + '\n' + sourceList(); const proseLength = bodyText(contentHtml).length; if (proseLength < 5000 || proseLength > 15000) { throw new Error(meta.slug + ': основной текст вне диапазона 5 000–15 000 знаков: ' + proseLength); } return { ...meta, contentHtml, proseLength }; } const SYNTHETIC_INPUT = 'synthetic-contract-input-v1'; const SYNTHETIC_RESPONSE = 'synthetic-provider-response-v1'; const SYNTHETIC_REFERENCE_TIME = '2023-07-10T00:00:00Z'; const SYNTHETIC_SCOPE = Object.freeze({ execution: 'in-memory-only', pactExecuted: false, consumerApplicationRun: false, providerApplicationRun: false, networkObserved: false, actualProviderVerification: 'not-run', productionCompatibility: 'not-claimed', }); function scope() { return Object.freeze({ ...SYNTHETIC_SCOPE }); } function hasText(value) { return typeof value === 'string' && value.trim().length > 0; } function isIsoTimestamp(value) { if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(value)) return false; const parsed = new Date(value); return !Number.isNaN(parsed.valueOf()) && parsed.toISOString().replace('.000Z', 'Z') === value; } /** * Создаёт только явно помеченный учебный contract object. Функция не читает * файлы, не запускает Pact, не поднимает consumer/provider и не делает сетевых * запросов. Имена сервиса, сценария и значения ниже синтетические. */ export function createSyntheticContract(input) { const base = { kind: 'synthetic-contract-object-v1', scope: scope() }; if (!input || input.marker !== SYNTHETIC_INPUT) { return Object.freeze({ ...base, accepted: false, reason: 'synthetic-marker-required' }); } const required = ['consumer', 'provider', 'operation', 'scenario']; for (const key of required) { if (!hasText(input[key])) return Object.freeze({ ...base, accepted: false, reason: 'missing-' + key }); } if (input.operation !== 'GET /v1/subscriptions/{id}') { return Object.freeze({ ...base, accepted: false, reason: 'unexpected-synthetic-operation' }); } if (input.scenario !== 'renewal-screen-needs-an-actionable-date') { return Object.freeze({ ...base, accepted: false, reason: 'unexpected-synthetic-scenario' }); } return Object.freeze({ ...base, accepted: true, marker: 'synthetic-only', contractVersion: 'contract-2023-07-baseline', consumer: input.consumer, provider: input.provider, interaction: Object.freeze({ request: Object.freeze({ method: 'GET', path: '/v1/subscriptions/sub-42' }), providerState: 'synthetic subscription is renewable', }), schema: Object.freeze({ id: 'non-empty string', state: Object.freeze(['active', 'paused']), renewalAt: 'ISO timestamp or null', }), semanticExpectation: Object.freeze({ id: 'renewal-screen-active-needs-date', statement: 'В синтетическом сценарии active означает, что renewalAt содержит будущую относительно synthetic reference time дату действия.', appliesOnlyToScenario: input.scenario, actionableState: 'active', referenceTime: SYNTHETIC_REFERENCE_TIME, }), }); } function schemaResult(value) { const reasons = []; if (!value || typeof value !== 'object' || Array.isArray(value)) { reasons.push('response-not-an-object'); } else { if (!hasText(value.id)) reasons.push('id-not-a-non-empty-string'); if (!['active', 'paused'].includes(value.state)) reasons.push('state-not-in-synthetic-enum'); if (!(value.renewalAt === null || isIsoTimestamp(value.renewalAt))) reasons.push('renewalAt-not-null-or-iso-timestamp'); } return Object.freeze({ matches: reasons.length === 0, reasons: Object.freeze(reasons) }); } function hasExpectedSyntheticContract(contract) { return contract?.accepted === true && contract.marker === 'synthetic-only' && contract.contractVersion === 'contract-2023-07-baseline' && contract.semanticExpectation?.id === 'renewal-screen-active-needs-date' && contract.semanticExpectation.appliesOnlyToScenario === 'renewal-screen-needs-an-actionable-date' && contract.semanticExpectation.actionableState === 'active' && contract.semanticExpectation.referenceTime === SYNTHETIC_REFERENCE_TIME; } /** * Это не provider verification. Функция лишь показывает, как один учебный * response проходит schema и отдельное semantic expectation. Даже true не * означает, что provider был запущен, Pact исполнялся или release совместим. */ export function inspectSyntheticProviderResponse(contract, response) { const base = { kind: 'synthetic-contract-inspection-v1', scope: scope() }; if (!hasExpectedSyntheticContract(contract)) { return Object.freeze({ ...base, accepted: false, reason: 'accepted-synthetic-contract-required' }); } if (!response || response.marker !== SYNTHETIC_RESPONSE) { return Object.freeze({ ...base, accepted: false, reason: 'synthetic-response-marker-required' }); } const schema = schemaResult(response.value); const activeNeedsActionableDate = response.value.state === contract.semanticExpectation.actionableState; const semanticExpectationMatch = schema.matches && (!activeNeedsActionableDate || response.value.renewalAt > contract.semanticExpectation.referenceTime); return Object.freeze({ ...base, accepted: true, responseKind: 'synthetic-response-only', schemaMatch: schema.matches, schemaReasons: schema.reasons, semanticExpectationMatch, syntheticCriteriaWouldPass: schema.matches && semanticExpectationMatch, actualProviderVerification: 'not-run', productionCompatibility: 'not-claimed', }); } /** * Rollback сохраняет лишь учебный snapshot договора. Он не меняет deployed * provider, broker, schema registry, feature flag, данные или версию API. */ export function prepareSyntheticRollbackPlan(contract) { if (!hasExpectedSyntheticContract(contract)) { return Object.freeze({ accepted: false, reason: 'accepted-synthetic-contract-required', scope: scope() }); } return Object.freeze({ accepted: true, kind: 'synthetic-contract-rollback-plan-v1', marker: 'synthetic-only', snapshot: Object.freeze({ contractVersion: contract.contractVersion, semanticExpectation: contract.semanticExpectation.id, }), deployment: 'not-performed', scope: scope(), }); } export function rollbackSyntheticContractPlan(plan) { if (!plan?.accepted || plan.marker !== 'synthetic-only' || !plan.snapshot) { return Object.freeze({ restored: false, reason: 'synthetic-rollback-plan-required', scope: scope() }); } return Object.freeze({ restored: true, kind: 'synthetic-contract-rollback-result-v1', contractVersion: plan.snapshot.contractVersion, semanticExpectation: plan.snapshot.semanticExpectation, deployment: 'not-performed', actualProviderVerification: 'not-run', productionCompatibility: 'not-claimed', scope: scope(), }); } export function runContractFixture() { const contract = createSyntheticContract({ marker: SYNTHETIC_INPUT, consumer: 'synthetic-portal-web', provider: 'synthetic-billing-api', operation: 'GET /v1/subscriptions/{id}', scenario: 'renewal-screen-needs-an-actionable-date', }); const unmarkedContract = createSyntheticContract({ consumer: 'synthetic-portal-web', provider: 'synthetic-billing-api', operation: 'GET /v1/subscriptions/{id}', scenario: 'renewal-screen-needs-an-actionable-date', }); const expectedResponse = inspectSyntheticProviderResponse(contract, { marker: SYNTHETIC_RESPONSE, value: Object.freeze({ id: 'sub-42', state: 'active', renewalAt: '2023-07-19T00:00:00Z' }), }); const semanticDrift = inspectSyntheticProviderResponse(contract, { marker: SYNTHETIC_RESPONSE, value: Object.freeze({ id: 'sub-42', state: 'active', renewalAt: null }), }); const expiredDate = inspectSyntheticProviderResponse(contract, { marker: SYNTHETIC_RESPONSE, value: Object.freeze({ id: 'sub-42', state: 'active', renewalAt: '2023-07-09T00:00:00Z' }), }); const impossibleDate = inspectSyntheticProviderResponse(contract, { marker: SYNTHETIC_RESPONSE, value: Object.freeze({ id: 'sub-42', state: 'active', renewalAt: '2023-02-30T00:00:00Z' }), }); const alteredContract = inspectSyntheticProviderResponse({ ...contract, semanticExpectation: Object.freeze({ ...contract.semanticExpectation, referenceTime: '2023-07-20T00:00:00Z' }), }, { marker: SYNTHETIC_RESPONSE, value: Object.freeze({ id: 'sub-42', state: 'active', renewalAt: '2023-07-19T00:00:00Z' }), }); const schemaBreak = inspectSyntheticProviderResponse(contract, { marker: SYNTHETIC_RESPONSE, value: Object.freeze({ id: 'sub-42', state: 7, renewalAt: null }), }); const rejectedResponse = inspectSyntheticProviderResponse(contract, { value: Object.freeze({ id: 'sub-42', state: 'active', renewalAt: null }), }); const plan = prepareSyntheticRollbackPlan(contract); const restored = rollbackSyntheticContractPlan(plan); return Object.freeze({ assertions: Object.freeze({ markedContractAccepted: contract.accepted === true, unmarkedContractRejected: unmarkedContract.accepted === false && unmarkedContract.reason === 'synthetic-marker-required', contractCarriesSyntheticScope: contract.scope.execution === 'in-memory-only' && contract.scope.networkObserved === false, expectedResponseMatchesSchema: expectedResponse.schemaMatch === true, expectedResponseMeetsSemanticExpectation: expectedResponse.semanticExpectationMatch === true, expectedResponseOnlyMeetsSyntheticCriteria: expectedResponse.syntheticCriteriaWouldPass === true && expectedResponse.actualProviderVerification === 'not-run', semanticDriftStillMatchesSchema: semanticDrift.schemaMatch === true, semanticDriftBreaksConsumerMeaning: semanticDrift.semanticExpectationMatch === false, semanticDriftCannotBeCalledVerified: semanticDrift.actualProviderVerification === 'not-run' && semanticDrift.productionCompatibility === 'not-claimed', expiredDateStillMatchesSchema: expiredDate.schemaMatch === true && expiredDate.schemaReasons.length === 0, expiredDateBreaksActionableMeaning: expiredDate.semanticExpectationMatch === false, impossibleCalendarDateBreaksSchema: impossibleDate.schemaMatch === false && impossibleDate.semanticExpectationMatch === false, alteredContractRejected: alteredContract.accepted === false && alteredContract.reason === 'accepted-synthetic-contract-required', malformedValueBreaksSchema: schemaBreak.schemaMatch === false && schemaBreak.semanticExpectationMatch === false, responseWithoutMarkerRejected: rejectedResponse.accepted === false && rejectedResponse.reason === 'synthetic-response-marker-required', fixtureDidNotExecutePact: expectedResponse.scope.pactExecuted === false, fixtureDidNotRunApplications: expectedResponse.scope.consumerApplicationRun === false && expectedResponse.scope.providerApplicationRun === false, fixtureDidNotObserveNetwork: expectedResponse.scope.networkObserved === false, rollbackRestoresContractSnapshot: restored.restored === true && restored.contractVersion === 'contract-2023-07-baseline' && restored.semanticExpectation === 'renewal-screen-active-needs-date', rollbackDoesNotClaimDeployment: restored.deployment === 'not-performed' && restored.actualProviderVerification === 'not-run' && restored.productionCompatibility === 'not-claimed', }), samples: Object.freeze({ contract, unmarkedContract, expectedResponse, semanticDrift, expiredDate, impossibleDate, alteredContract, schemaBreak, rejectedResponse, plan, restored }), }); } const syntheticCode = `import { createSyntheticContract, inspectSyntheticProviderResponse, } from './web/scripts/upgrade-2023-07.mjs'; const contract = createSyntheticContract({ marker: 'synthetic-contract-input-v1', consumer: 'synthetic-portal-web', provider: 'synthetic-billing-api', operation: 'GET /v1/subscriptions/{id}', scenario: 'renewal-screen-needs-an-actionable-date', }); const changedMeaning = inspectSyntheticProviderResponse(contract, { marker: 'synthetic-provider-response-v1', value: { id: 'sub-42', state: 'active', renewalAt: null }, }); console.log(changedMeaning.schemaMatch); // true console.log(changedMeaning.semanticExpectationMatch); // false console.log(changedMeaning.actualProviderVerification); // not-run`; const fixtureCommand = `node web/scripts/upgrade-2023-07.mjs --verify-fixture # PASS означает только: synthetic in-memory contract различил # форму JSON и одну semantic expectation. # PASS не означает: Pact запускался, consumer/provider были подняты, # сеть наблюдалась или production-совместимость доказана.`; const practice = revision({ slug: 'editorial-2023-07-practice-contract-tests', title: 'Контракт API до релиза: зафиксировать смысл ответа, а не только JSON', categories: ['Тестирование', 'API'], cover: '/assets/editorial/2023/contract-tests-2023-consumer-provider.svg', excerpt: 'Как записать один сценарий consumer/provider так, чтобы schema match не скрыл изменение смысла поля до релиза.', readingMinutes: 12, }, [ p('Провайдер может поменять смысл уже существующего поля и не сломать форму ответа. В учебном примере GET /v1/subscriptions/sub-42 по-прежнему возвращает 200, строковый id, допустимый state: "active" и renewalAt: null. JSON проходит описанную схему, но экран продления у consumer рассчитывал получить точную дату. Ошибка проявляется после релиза не потому, что пропал ключ, а потому, что старое слово active стало обозначать более широкое состояние.'), p('Цена такого расхождения обычно выше одной пустой кнопки. Consumer начинает объяснять пользователю чужое состояние, поддержка получает неповторяемый сценарий, а provider получает срочный откат без ясного критерия. Если команда смотрит только на HTTP-статус и типы, она может решить, что выпуск совместим, хотя нужное действие уже невозможно. Поэтому контракт полезно начинать не с полного описания API, а с одного поведения, за которое отвечает конкретный consumer.'), h2('Один сценарий, а не обещание за весь API'), p('Здесь сценарий узкий: экран продления запрашивает одну подписку и должен показать действие только тогда, когда ответ содержит применимую дату. Consumer называется synthetic-portal-web, provider — synthetic-billing-api; это учебные имена, не наблюдение из чужой системы. Request и response не отправляются никуда. Они нужны, чтобы отделить три разных вопроса: совпадает ли форма, выполняется ли ожидание consumer и есть ли настоящий результат provider verification.'), p('Первый вопрос относится к schema. В нём можно проверить, что id — непустая строка, state входит в перечисление, а renewalAt имеет календарно валидную ISO-дату или null. Второй вопрос относится к смыслу конкретного сценария: для экрана продления active без даты или с уже прошедшей датой не даёт пользователю выполнимого действия. Третий вопрос относится к процессу: настоящий provider verification выполняет опубликованный contract против согласованной версии provider и сохраняет результат. Эти вопросы не являются разными названиями одного PASS.'), table('Три уровня доказательства для одного ответа', ['Уровень', 'Что проверяется', 'Ответ active + null', 'Чего результат не доказывает'], [ ['Schema match', 'поля, типы, enum и nullable-граница', 'может быть PASS', 'что consumer способен завершить свой сценарий'], ['Semantic expectation', 'обещание «для экрана продления есть применимая дата»', 'FAIL', 'что provider реально запускался'], ['Provider verification', 'исполнение interaction на согласованной версии provider в нужном state', 'возможен только после реального запуска', 'все сценарии, не вошедшие в contract'], ['Release decision', 'связка contract version, provider result и версии consumer', 'не принимается по одной схеме', 'качество UX, нагрузку и миграции данных'], ]), h2('Запишите минимальный contract как проверяемое намерение'), p('В OpenAPI полезно описать границы интерфейса: путь, метод, статус, поля и допустимый null. Версионный OAS 3.1.0 называет себя описанием HTTP-интерфейса и отдельно говорит, что часть семантики Schema Object определяется приложением. Значит, схема нужна, но не обязана выразить правило конкретного экрана. Если пытаться спрятать всё значение поля в один nullable-тип, reviewer увидит форму, но не увидит потребность consumer.'), p('Consumer-driven contract добавляет другой артефакт: пример interaction, который нужен реальному коду consumer. В документации Pact такой contract возникает при исполнении consumer test и описывает конкретную пару request/response. Для нашего сценария важно не слово Pact, а дисциплина: назвать provider state, на котором ожидается дата, и назвать точную версию артефакта. Без provider state пример остаётся красивым JSON, который provider может воспроизвести только случайно.'), h2('Исполнимый synthetic fixture: увидеть границу, не подменить запуск'), p('Ниже не находится библиотека Pact и не запускается HTTP. Функция принимает только объекты с явными маркерами synthetic-contract-input-v1 и synthetic-provider-response-v1. Она специально возвращает actualProviderVerification: "not-run" даже для хорошего учебного ответа. Благодаря этому пример можно выполнить локально и одновременно нельзя выдать его за результат consumer/provider проверки.'), code(syntheticCode), p('Здесь active + null даёт schemaMatch: true: типы и nullable-правило соблюдены. Но semanticExpectationMatch: false, потому что выбранный экран не может показать дату действия. Fixture также отклоняет для смысла дату раньше фиксированного synthetic reference time 2023-07-10T00:00:00Z, а несуществующую календарную дату уже не пропускает schema. Это не доказывает дефект в чьём-либо API. Это лишь показывает, что правило consumer не было покрыто структурной проверкой. Когда такой результат найден в реальном change, его надо превратить в вопрос к владельцам поведения, а не в автоматическое требование сделать поле non-null везде.'), figure('/assets/editorial/2023/contract-tests-2023-consumer-provider.svg', 'Три роли учебного contract flow: consumer формулирует сценарий экрана продления, артефакт фиксирует request, provider state и semantic expectation, а provider verification требует отдельного исполненного результата для точных версий.', 'Схема показывает роли и границы доказательства. Стрелки не являются сетевой трассой, а блок provider verification не означает, что он выполнялся для этого sidecar.'), h2('Маршрут: симптом → причина → проверка → действие'), ol([ 'Симптом. Запишите видимый эффект без диагноза: 200 и валидная форма ответа есть, но экран не может показать обещанное действие.', 'Причина. Найдите поле, чьё бытовое имя скрывает правило. В примере это не «неверный JSON», а незафиксированное значение active для одного consumer-сценария.', 'Проверка формы. Сверьте путь, статус, обязательные поля, enum и nullable-границу с версией schema. Отметьте отдельно, какие значения форма сознательно допускает.', 'Проверка смысла. Добавьте один пример, где consumer реально принимает решение. Назовите provider state и минимальный ответ, без которого сценарий должен отказать или показать иной путь.', 'Действие. Передайте contract вместе с версией consumer и ожидаемым provider state. Настоящий verifier должен исполнить его отдельно; до результата нельзя писать «совместимо».', 'Решение выпуска. Привяжите результат к конкретным версиям и известным consumer. Не переносите PASS одной interaction на невписанные методы и редкие состояния.', ]), h2('Что именно требуется от настоящей provider verification'), p('Реальная provider verification — это не сериализация JSON в CI-логе. Нужны как минимум contract, версия provider, управляемый provider state, фактическое исполнение interaction и результат, который можно сопоставить с версией. В документации Pact отдельно советуют проверять provider локально или в CI, а не против уже развернутого сервиса: так можно контролировать состояния и зависимости. Это процессная рекомендация, не обещание, что любой набор mock данных воспроизводит production.'), p('Успешный provider verification отвечает на узкий вопрос: данный provider в подготовленном состоянии удовлетворил данному набору interactions. Он сильнее, чем schema match, потому что связывает ожидание с поведением. Но он всё ещё не заменяет тест самого consumer, проверку авторизации, миграцию данных, наблюдение после выпуска или анализ всех возможных клиентов. Граница важна: чем точнее названа, тем меньше ложного спокойствия после зелёного job.'), h2('Ограничение, rollback и следующий шаг'), p('Fixture этого пакета создаёт только замороженные объекты в памяти. Он не вызывает Pact, broker, HTTP-клиент, provider, сеть, CI, schema registry или production. Его PASS не сообщает о совместимости реальных версий и не утверждает, что state где-либо действительно менялся. Даже rollback в fixture восстанавливает только snapshot учебного contract, а не endpoint, данные, флаг или опубликованный артефакт.'), p('Для настоящего изменения rollback стоит спланировать до выкладки: какие версии consumer ещё читают старое значение, можно ли временно оставить старый смысл или добавить новое явное поле, кто отменяет публикацию contract и где будет виден результат. Следующий малый шаг — взять один реальный consumer-сценарий, записать его без предположений о production и назначить владельца provider verification. Если provider state или версия не названы, выпуск ещё не имеет достаточного условия готовности.'), h2('Историческая граница июля 2023'), p('К июлю 2023 уже были доступны OAS 3.1.0 от 15.02.2021 и приведённые страницы документации Pact, обновлённые в августе 2022. Они помогают различить описание интерфейса, contract by example и provider verification. Ни один источник не сообщает ничего о synthetic сервисах этого примера и не превращает их в доказательство production-совместимости.'), ]); const mechanism = revision({ slug: 'editorial-2023-07-mechanism-contract-tests', title: 'Почему schema match не равен совместимости: три слоя контракта API', categories: ['Тестирование', 'API'], cover: '/assets/editorial/2023/contract-tests-2023-compatibility-matrix.svg', excerpt: 'Разбор границы между формой JSON, ожиданием consumer и настоящей provider verification на одном сценарии продления.', readingMinutes: 12, }, [ p('Команда может честно показать валидный OpenAPI-документ и всё равно выпустить несовместимое изменение. Это происходит, когда provider сохраняет форму: ключи те же, типы те же, null разрешён, HTTP-статус успешный. Но consumer использовал поле как обещание действия. В учебном ответе state: "active" остаётся допустимым, а renewalAt становится null; структура не возражает, экран продления теряет свою предпосылку.'), p('Цена ошибки в том, что разные команды начинают спорить об одном слове разными фактами. Provider говорит «схема не менялась», consumer говорит «сценарий сломан», release engineer видит зелёную проверку JSON и не понимает, должен ли остановить выпуск. Без разделения уровней любая из этих фраз выглядит убедительно и ни одна не даёт решения. Нужна модель, которая показывает, какое наблюдение отвечает на какой вопрос и какой PASS разрешает следующий шаг.'), h2('Слой 1: schema описывает форму и её допустимую вариативность'), p('Schema полезна именно потому, что делает форму проверяемой. В OAS 3.1.0 Schema Object может описать типы, перечисления, composition и пример. Для renewalAt учебный договор допускает ISO-строку или null, а для state — active или paused. Такой контракт помогает заметить исчезнувший обязательный ключ, число вместо строки и неизвестное значение enum до того, как это увидит пользователь.'), p('Однако форма не создаёт предметный смысл автоматически. Тот же OAS говорит, что для свойств, чья семантика задаётся приложением, спецификация оставляет её потребляющему приложению. Это не недостаток стандарта: API может обслуживать разные клиентов, и одна общая schema не знает, какое действие должен показать конкретный экран. Ошибка начинается, когда команда прочитывает технический PASS как обещание бизнес-поведения, которое не было записано.'), table('Матрица: что означает каждый результат', ['Проверка', 'Вход active + null', 'Что можно сказать после PASS', 'Чего говорить нельзя'], [ ['Schema validator', 'форма допустима', 'ключи и типы соответствуют описанию', 'экран продления может продолжить сценарий'], ['Consumer semantic expectation', 'условие не выполнено', 'consumer явно заметил недостающую дату', 'provider был запущен или найден дефект'], ['Provider verification', 'требует отдельного исполнения', 'конкретный provider удовлетворил конкретные interactions', 'все API-клиенты и состояния совместимы'], ['Release gate', 'требует версий и результатов', 'можно принять решение в заранее названной границе', 'последствий после выпуска не будет'], ]), h2('Слой 2: semantic expectation принадлежит сценарию consumer'), p('Semantic expectation должна быть короткой и наблюдаемой. В этом пакете она звучит так: «для сценария экрана продления ответ с state: active содержит календарно валидную renewalAt позже фиксированного synthetic reference time». Это не определение active для всего домена и не указание provider менять модель; reference time нужен только детерминированному fixture и не является временем реальной системы. Это контракт одной потребности consumer. Если для другого клиента active + null допустим, его scenario должен быть записан отдельно, а не размыт внутри общего enum.'), p('У такого правила есть две части. Первая — context: какой request, provider state и путь пользователя привели к ответу. Вторая — decision: что consumer делает при значении. Если оставить только пример JSON без решения, он легко превратится в случайный fixture. Если оставить только фразу «нужна дата», reviewer не поймёт, при каком state provider обязан её вернуть. Связка context + decision даёт проверяемый контракт, но ещё не подтверждает поведение настоящего provider.'), h2('Слой 3: provider verification — выполненный факт, а не поле в JSON'), p('В Pact consumer-driven contract формируется при запуске consumer tests как набор конкретных request/response interactions. Дальше provider verification воспроизводит их против provider в подготовленном состоянии. Успешная проверка должна иметь адресуемый результат: какой contract, какая версия provider, какой provider state и где записан PASS/FAIL. Без этих четырёх частей слово «verified» нельзя отличить от ручного предположения.'), p('Важно не подменять этот процесс проверкой schema. Provider может отдавать структурно валидный ответ и всё же не удовлетворять примеру, который consumer опубликовал для нужного state. И наоборот, строгий consumer может ожидать лишнее поле, хотя schema и другие клиенты его не требуют. Поэтому contract review должен спросить: это общая форма интерфейса, ожидание конкретного consumer или уже выполненная verification? Ответ выбирает следующий механизм, а не победителя в споре.'), h2('Исполнимый fixture отделяет три флага'), p('В примере ниже сначала создаётся только marked synthetic contract. Затем в память передаётся ответ, который структурно допустим, но нарушает expectation экрана. Функция возвращает три раздельных поля: schemaMatch, semanticExpectationMatch и actualProviderVerification. Последнее остаётся not-run при любом входе; это защита от ложного вывода «fixture проверил provider».') , code(syntheticCode), p('Если выполнить команду fixture, она дополнительно проверит отрицательные ветки: object без synthetic marker не принимается, число в state и несуществующая календарная дата ломают schema, а active + null и дата до synthetic reference time проходят форму, но не проходят semantic expectation. Результат syntheticCriteriaWouldPass означает лишь, что правила in-memory выполнены. Он не публикует verification result, не знает версию брокера и не пытается вычислить production compatibility.'), figure('/assets/editorial/2023/contract-tests-2023-compatibility-matrix.svg', 'Матрица трёх учебных ответов: active с датой проходит форму и semantic expectation; active без даты проходит форму, но нарушает смысл; числовой state нарушает уже schema. Настоящая provider verification показана отдельной колонкой как неисполненная.', 'Матрица сравнивает правила учебной модели. Отметки PASS и FAIL не являются результатами реального OpenAPI validator, Pact, provider или release pipeline.'), h2('Маршрут: симптом → причина → проверка → действие'), ol([ 'Симптом. Найдите пример, где ответ выглядит корректно в логе, но consumer не может завершить конкретное действие. Не начинайте со слова «ломается API».', 'Причина. Разделите форму и смысл. Выпишите, какие значения schema допускает намеренно, и какое из них consumer интерпретирует уже, чем provider.', 'Проверка schema. Сверьте версию OAS, required-поля, типы, enum и nullable. Если ответ здесь FAIL, не обсуждайте семантику, пока не названо структурное нарушение.', 'Проверка смысла. Запишите provider state и decision consumer. Для правила active + date укажите, что должно произойти, если дата отсутствует: другой экран, явный отказ или временное сообщение.', 'Проверка provider. Подготовьте реальную execution-среду и привяжите её к версии contract и provider. Не переназывайте synthetic fixture в verification.', 'Действие. Выберите границу изменения: добавить новое явное поле, ввести новый state, сохранить старое значение на период миграции или остановить выпуск до согласования клиентов.', ]), h2('Почему один contract не отменяет версионную работу'), p('Даже хороший пример покрывает только известную потребность. Consumer-driven подход намеренно тестирует используемые interactions, а не весь бесконечный набор возможных состояний provider. Это делает feedback быстрым, но требует честного inventory: какие consumers известны, какие версии ещё поддерживаются, кто владеет provider state и как публикуется результат. Иначе команда создаёт contract, который защищает новый клиент, но случайно объявляет безопасным выпуск для старого.'), p('Версия OAS тоже должна быть названа рядом с документом. OAS 3.1.0 определяет свою семантику и совместимость tooling в линии 3.1.*, но API implementation version — отдельное понятие. Нельзя заменить версию provider номером спецификации или наоборот. В release decision полезно хранить оба указателя: какой API contract потреблял consumer и какую сборку provider фактически проверяли. Это избавляет расследование от догадки по дате merge.'), h2('Ограничения, rollback и следующий шаг'), p('Этот fixture не моделирует реальные HTTP-заголовки, auth, retries, provider states, broker, matcher-алгоритм Pact, consumer client или зависимости provider. Он не валидирует OpenAPI-документ и не сравнивает deployed версии. Synthetic значения выбраны только для объяснения границы active + null. Поэтому нельзя читать его PASS как утверждение о реальном контракте или о том, что достаточно сделать поле обязательным.'), p('Rollback учебной модели восстанавливает snapshot contract-2023-07-baseline и специально сообщает deployment: not-performed. В настоящем выпуске надо отдельно проверить, не начал ли consumer писать новое значение, не использует ли provider новое значение в данных и можно ли временно отдать обе интерпретации. Следующий шаг — добавить к одному реальному PR таблицу из этой статьи и потребовать два разных evidence: schema diff и результат provider verification для точных версий.'), h2('Историческая граница июля 2023'), p('Описанная модель использует только возможности и терминологию, доступные к июлю 2023: OAS 3.1.0 был опубликован в феврале 2021, а страницы Pact о contract by example и verification были обновлены в августе 2022. Поздние инструменты, результаты запусков и состояние конкретных библиотек сюда не переносятся задним числом.'), ]); const field = revision({ slug: 'editorial-2023-07-field-contract-tests', title: 'Смысл поля изменился после релиза: как собрать доказательство до отката', categories: ['Тестирование', 'API'], cover: '/assets/editorial/2023/contract-tests-2023-verification-gate.svg', excerpt: 'Практика разбора, когда schema осталась валидной, а consumer получил новый смысл поля после релиза.', readingMinutes: 13, }, [ p('Проблема после релиза может выглядеть обманчиво спокойно: consumer получает валидный ответ и всё же теряет пользовательский сценарий. Учебная картина проста: provider отдаёт 200, id, state: "active" и renewalAt: null; schema допускает этот JSON. Экран продления раньше трактовал active как готовность показать дату. В этот раз он не может завершить действие. Это пример для разбора, не отчёт об инциденте, но именно так выглядит опасный класс расхождений.'), p('Цена спешки — откат по чужому предположению. Если в первые минуты написать «provider сломал contract», можно вернуть полезное изменение или пропустить consumer, который уже зависит от нового значения. Если написать «всё валидно», можно оставить пользователя в тупике. Нужна короткая запись доказательств: что наблюдали, что пока только интерпретируем, какая проверка ещё не была выполнена и кто вправе остановить выпуск. Тогда аварийное решение становится обратимым, а не громким.'), h2('Сначала разделите наблюдение, гипотезу и решение'), p('Наблюдение — это то, что действительно можно показать: версия consumer, request, полученный response, версия provider, время и источник лога. В учебном пакете этих данных нет, поэтому нельзя выдумывать их под пример. Гипотеза — объяснение: provider расширил значение active, а consumer не зафиксировал более узкое условие. Решение — отдельный выбор: остановить выпуск, включить fallback, добавить явное поле или подготовить обратимую миграцию. Смешивать эти строки опасно: гипотеза легко превращается в «доказанный дефект».'), p('Такое разделение помогает и в спокойном release review. Contract не должен храниться как безымянный JSON в репозитории. Ему нужен consumer, provider, scenario, версия и владелец следующей проверки. Provider state особенно важен: фраза «верните active» недостаточна, пока не ясно, в каком бизнес-состоянии дата обязана быть доступна. Это защищает от теста, который случайно проходит на удобных данных и не представляет нужный сценарий.'), table('Минимальная запись разбора расхождения', ['Строка', 'Что записать', 'Пример для учебного случая', 'Что не следует выводить'], [ ['Наблюдение', 'адресуемый response и версии, если они есть', 'synthetic active + null', 'что это произошло в production'], ['Schema result', 'какая версия schema и какие правила проверены', 'id, enum state, nullable renewalAt', 'что consumer поведение сохранено'], ['Semantic expectation', 'scenario и решение consumer', 'экран продления ждёт actionable date', 'что правило глобально для домена'], ['Provider verification', 'contract, provider state, версия и результат исполнения', 'not-run в этом sidecar', 'что достаточно одного JSON из лога'], ['Release decision', 'владелец, срок и безопасное действие', 'не выпускать без нужного evidence', 'что rollback уже безопасен'], ]), h2('Соберите доказательство в правильном порядке'), p('Первым делом сохраните исходный contract и не переписывайте его под текущий ответ. Иначе расследование теряет точку сравнения. Затем подтвердите schema match отдельным инструментом или review документa: required, enum, nullable, статус и media type. Если JSON структурно не соответствует, это уже достаточная причина исправлять форму. Если соответствует, не закрывайте задачу: переходите к тому условию, из-за которого consumer принял неверное решение.'), p('Semantic expectation удобно записать как одно предложение с отрицательной веткой: «в scenario renewal-screen-needs-an-actionable-date consumer не показывает действие, если state: active пришёл без календарно валидной ISO-даты renewalAt позже согласованного времени отсчёта». В fixture время отсчёта фиксировано и synthetic; в реальном change его определяют владельцы сценария. Это не навязывает provider реализацию и не объявляет null недопустимым для всех запросов. Оно только делает различие видимым. Дальше владельцы продукта и API могут решить, нужна ли новая семантика, новое поле или иной сценарий интерфейса.'), h2('Исполнимый synthetic разбор не выдаёт себя за инцидент'), p('Команда может локально прогнать небольшой fixture, чтобы убедиться, что сама запись различает форму и смысл. Он принимает только явно помеченный synthetic response, хранит в памяти пять случаев и никуда не отправляет данные. Для active + null и даты до synthetic reference time отчёт даёт schema PASS и semantic FAIL; для несуществующей даты schema тоже FAIL. Поле actualProviderVerification остаётся not-run. Это полезно для ревью текста, но не для решения о реальном выпуске.'), code(fixtureCommand), p('Отдельно fixture проверяет rollback учебного плана. Он возвращает только идентификатор baseline contract и semantic rule; он не отменяет deploy, не меняет provider и не публикует verification result. Такая нарочитая неполнота важна в аварийной ситуации: если rollback нельзя описать без фразы «потом разберёмся с версиями», то он ещё не готов. Сначала называют версии и обратимые действия, затем дают команду на изменение.'), figure('/assets/editorial/2023/contract-tests-2023-verification-gate.svg', 'Шлюз выпуска для contract change: consumer scenario создаёт версионный артефакт, provider state и точная версия provider проходят отдельную verification, после чего владелец принимает release decision. Отсутствующий результат ведёт к остановке, а не к зелёному статусу.', 'Схема показывает порядок доказательств и точки остановки. Она не запускает Pact, broker, CI, сеть или provider и не является фактическим журналом выпуска.'), h2('Когда остановить выпуск, а когда не трогать provider'), p('Остановить выпуск разумно, когда неизвестна связка «какой consumer — какой contract — какая версия provider — какой результат». Это не наказание за неполный тест, а признание отсутствующего evidence. Нельзя безопасно заменять эту связку скриншотом успешного ответа или фразой «раньше работало». Если же semantic expectation оказался ошибочным и provider всегда имел более широкий смысл, изменение может быть на стороне consumer. Но это решение требует подтверждения владельца домена, а не вывода из nullable-поля.'), p('Не стоит автоматически откатывать provider только потому, что один старый consumer не понял новое значение. Иногда безопаснее добавить новое явное поле, временно поддержать оба значения или обновить consumer раньше переключения. Выбор зависит от данных, версий клиентов, срока совместимости и возможности наблюдать переход. Contract tests помогают увидеть границу заранее; они не решают за команду, насколько дорого поддерживать два поведения.'), h2('Маршрут: симптом → причина → проверка → действие'), ol([ 'Симптом. Зафиксируйте один пользовательский эффект и один response. Не добавляйте в первую запись предположение о виновной команде.', 'Причина. Сформулируйте гипотезу о расхождении смысла: какое поле provider расширил, а какое условие consumer трактовал уже.', 'Проверка формы. Проверьте OAS/schema, статус, media type, required, enum и nullable. Отдельно сохраните версию документа и версию provider, если она известна.', 'Проверка сценария. Назовите consumer, provider state, конкретный request и decision интерфейса. Затем подготовьте реальную provider verification; synthetic fixture может проверить только ясность модели.', 'Действие. До результата выберите обратимый путь: остановка выкладки, явный fallback или сохранение старого поведения. Не объявляйте production compatibility по schema PASS.', 'Следующий контроль. После решения сохраните result с версиями и добавьте scenario в contract inventory. Иначе следующий release снова начнёт расследование с нуля.', ]), h2('Как выглядит достаточный provider verification evidence'), p('Для provider verification нужно больше, чем правильный contract file. Нужны точная версия contract, запуск provider в контролируемом состоянии, исполнение interactions, результат PASS/FAIL и связь с версией provider. В типичном Pact-процессе consumer test формирует контракт, provider проверяет его у себя, а результат публикуется для решения о выпуске. В этой цепочке каждая версия отвечает на свой вопрос; пропуск одной нельзя заполнить фразой «JSON совпал».'), p('Перед выпуском полезно также посмотреть, не относится ли scenario к скрытой зависимости: auth, feature flag, миграции или downstream provider. Pact-документация советует не stub-ить слой до извлечения и проверки request body, иначе verifier может принять произвольный input. Эта оговорка не означает, что любой проект обязан использовать именно Pact. Она показывает общий принцип: verification должна проходить через ту границу, на которой действительно принимается контрактное решение.'), h2('Ограничения, rollback и следующий шаг'), p('Здесь нет production response, сети, browser trace, CI run, Pact Broker, consumer client, provider process, информации об auth или результата настоящей schema validation. Все service names, версии и данные имеют marker synthetic и существуют только в памяти Node. Содержимое статьи не утверждает, что nullable-поле где-либо изменилось, и не даёт разрешения выпускать или откатывать чужую систему.'), p('Следующий практический шаг — выбрать один реальный change, сохранить его исходный contract, добавить semantic expectation только для использующего его consumer и заранее согласовать rollback. В rollback checklist должны быть версии клиентов, правило переключения, способ вернуть старое поведение, владелец решения и проверка после изменения. Если хотя бы один пункт неизвестен, это не повод имитировать зелёную verification; это повод сузить change до обратимого шага.'), h2('Историческая граница июля 2023'), p('К июлю 2023 OAS 3.1.0 уже фиксировал разделение между описанием интерфейса и прикладной семантикой, а официальная документация Pact уже описывала consumer-driven contracts и проверку provider до deploy. Пример использует эти идеи без заявления о версии конкретной библиотеки, фактическом запуске или доступе к чьему-либо broker.'), ]); export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item); function verifyFixture() { const report = runContractFixture(); const failed = Object.entries(report.assertions).filter(([, value]) => value !== true).map(([key]) => key); if (failed.length) { process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n'); process.exitCode = 1; return; } const count = Object.keys(report.assertions).length; process.stdout.write('PASS fixture: ' + count + '/' + count + ' assertions\n'); } if (process.argv.includes('--verify-fixture')) verifyFixture(); if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');