Gherkin 스펙은 당신에게 거짓말을 하고 있다.

의도적으로 그러는 것은 아니다. 처음에는 충실하게 출발했다. 하지만 6개의 스프린트가 지나고 누군가 checkout 흐름을 리팩토링하면서 When the user submits payment 스텝을 업데이트하는 것을 잊었다. .feature 파일은 여전히 통과한다. 왜냐하면 step definition은 여전히 존재하기 때문이다. 다만 그것이 호출하는 코드가 더 이상 시나리오가 실제로 묘사하는 내용과 일치하지 않을 뿐이다. 테스트는 녹색이고 자신감은 허위다. 이것이 BDD의 기본 궤도이며, 적극적으로 맞서 싸우지 않는 한 이 방향으로 간다.

문제는 개발자들이 게으르다는 것이 아니다. .feature 파일과 step definition 사이의 관계가 근본적으로 느슨하다는 것이다. Gherkin 시나리오는 문자열이다. Step definition은 그 문자열과 매칭되는 정규식이나 어노테이션이다. 시나리오 변경에 대응하는 코드 변경이 반드시 필요하다는 것을 강제하는 컴파일러는 존재하지 않는다. 툴체인은 사용자가 수동으로 둘을 맞춰줄 것이라고 가정한다. 하지만 그렇게 되지 않는다.

수동적 규율이 규모에서 실패하는 이유

모든 팀은 같은 계획으로 시작한다: 스펙을 작성하고, 스텝을 구현하고, 둘을 함께 업데이트한다. 첫 주에는 이게 통한다.

리팩토링 중에 물거품이 된다. 코드에서 도메인 개념의 이름을 바꿨는데, Gherkin은 여전히 예전 용어를 사용한다. 왜냐하면 그것을 바꾸려면 열 두 개의 feature 파일을 업데이트하고 프로덕트 팀과 다시 리뷰해야 하기 때문이다. 또는 새로운 검증 규칙을 추출했는데, 기존 시나리오가 암묵적으로 예전 동작에 의존하고 있었고 아무도 눈치채지 못했다. 왜냐하면 step definition이 조용하게 일반화되어 테스트가 계속 통과하도록 유지되었기 때문이다. 스펙은 병렬적이고 점점 더 부정확한 우주가 된다.

비용은 단순히 문서가 낡는다는 것이 아니다. 신뢰의 상실이다. 개발자들이 feature 파일이 현실을 묘사한다고 믿지 않기 시작하면, 그것을 읽는 것을 멈춘다. 그리고는 작성하는 것도 멈춘다. 그러면 다시 이해관계자들과 공유된 언어 없이 불투명한 이름의 단위 테스트로 회귀한다.

”동기화”가 실제로 의미하는 것

스펙을 동기화 상태로 유지한다는 것은 테스트를 통과시키는 것이 아니다. 통과는 쉽다. 동기화는 세 가지를 의미한다:

  1. 모든 Gherkin 스텝은 스펙이 말하는 대로 동작하는 대응하는 step definition을 가지고 있다.
  2. 모든 step definition은 적어도 하나의 시나리오에 의해 실제로 도달된다.
  3. 스펙의 언어는 코드베이스의 언어와 일치한다.

대부분의 팀은 첫 번째 항목만 검증하고, 그마저도 런타임에 한다. 세 가지를 모두 검증해야 하며, 코드가 머지되기 전 CI에서 수행해야 한다.

엄격한 바인딩으로 자동화된 스텝 검증

Cucumber 같은 툴에서 느슨한 문자열 매칭이 근본 원인이다. 스텝 정의를 빌드가 검증할 수 있는 일급 참조로 만들어 이것을 조일 수 있다.

TypeScript나 JavaScript 프로젝트에서는 정규식 기반 step definition을 Gherkin 스텝과 실제 함수 참조를 매핑하는 생성된 step registry로 대체할 수 있다. 핵심은 이 매핑이 수기로 작성되는 것이 아니라 생성된다는 점이다. 따라서 시나리오가 존재하지 않는 스텝을 참조하면 빌드가 실패한다.

다음은 커스텀 파서와 생성된 registry를 사용하는 최소 설정이다. 먼저, 빌드 시점에 .feature 파일을 파싱한다:

// scripts/validate-steps.ts
import { readFileSync, readdirSync } from 'fs';
import { parse } from '@cucumber/gherkin';
import { IdGenerator } from '@cucumber/messages';

const featureFiles = readdirSync('./features').filter(f => f.endsWith('.feature'));
const allSteps = new Set<string>();

for (const file of featureFiles) {
  const content = readFileSync(`./features/${file}`, 'utf-8');
  const gherkinDocument = parse(content, new IdGenerator());
  
  for (const feature of gherkinDocument.feature?.children || []) {
    for (const step of feature.scenario?.steps || []) {
      allSteps.add(step.text);
    }
  }
}

// Import the actual step registry from your test code
import { stepRegistry } from '../steps/registry';

const registeredSteps = new Set(Object.keys(stepRegistry));
const undefinedSteps = [...allSteps].filter(s => !registeredSteps.has(s));
const orphanedSteps = [...registeredSteps].filter(s => !allSteps.has(s));

if (undefinedSteps.length > 0) {
  console.error('Undefined steps:', undefinedSteps);
  process.exit(1);
}

if (orphanedSteps.length > 0) {
  console.error('Orphaned steps:', orphanedSteps);
  process.exit(1);
}

console.log(`Validated ${allSteps.size} steps against ${registeredSteps.size} definitions.`);

스텝 registry는 함수를 정확한 Gherkin 텍스트로 노출한다:

// steps/registry.ts
import { given, when, then } from './step-helpers';

export const stepRegistry: Record<string, Function> = {
  'the user is logged in': given.theUserIsLoggedIn,
  'the user adds an item to the cart': when.theUserAddsAnItemToTheCart,
  'the total should be {int}': then.theTotalShouldBe,
};

given, when, then 객체는 함수를 담은 평범한 모듈이다. 정규식 마법은 없다. 개발자가 Gherkin 텍스트를 바꾸면 registry에 대응하는 항목을 추가해야 하고, 그렇지 않으면 빌드가 실패한다. 시나리오를 삭제하면 고아 스텝 탐지가 남은 정의를 잡아낸다.

머지 전 CI에 연결하라

개발자가 로컬에서 실행하는 스크립트는 개발자가 잊어버리는 스크립트다. 검증이 빌드를 실패시키도록 만들어야 한다.

테스트 파이프라인에 추가하라:

# .github/workflows/ci.yml
jobs:
  validate-specs:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npx ts-node scripts/validate-steps.ts
      - run: npm test

중요한 디테일은 validate-steps.ts가 실제 테스트 스위트보다 먼저 실행된다는 점이다. feature 파일과 step definition 사이에 불일치가 있으면, 낡은 로직 위에서 조용히 통과할 수도 있는 수많은 cucumber 시나리오를 실행하는 것이 아니라, 명확한 에러로 빠르게 실패해야 한다.

살아있는 문서는 생성된 리포트를 필요로 한다

검증은 문법적 정렬을 유지하지만, 스펙이 읽기 쉽고 유용하다는 것을 보장하지는 않는다. 이를 위해서는 feature 파일에서 HTML 리포트를 생성하고 main 브랜치에 머지될 때마다 게시하는 living documentation 파이프라인이 필요하다.

Cucumber Reports나 Pickles 같은 툴은 .feature 파일을 브라우저로 볼 수 있는 문서로 변환할 수 있다. 핵심은 이 문서가 CI가 검증하는 것과 동일한 파일에서 생성된다는 점이다. 시나리오가 제거되면 문서에서도 사라진다. 언어가 바뀌면 문서도 자동으로 업데이트된다. 유지보수할 두 번째 진실 출처는 없다.

리포트를 CI 아티팩트로 게시하거나 정적 사이트에 배포하라:

# .github/workflows/docs.yml
jobs:
  publish-docs:
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - run: npm install -g @picklesdoc/pickles
      - run: pickles --feature-directory=./features --output-directory=./docs
      - uses: actions/upload-pages-artifact@v3
        with:
          path: ./docs

이해관계자들은 원시 Gherkin을 읽을 필요가 없다. 그들은 현재 상태라고 신뢰할 수 있는 읽기 쉬운 페이지가 필요하다. 자동화가 그 신뢰를 구축한다.

트레이드오프: 엄격함 대 표현력

registry 접근법에는 비용이 따른다. /^the user adds (\d+) items? to the cart$/ 같은 정규식 패턴의 유연성을 잃는다. 모든 변형이 명시적인 항목이나, 타입이 지정된 자리표시자를 가진 파라미터화된 스텝이 된다. 이것은 장황하다.

대안은 정규식을 유지하면서도 패턴이 너무 광범위하거나 스텝 텍스트가 어떤 알려진 패턴과도 매칭되지 않을 때 경고를 내보내는 더 엄격한 린터를 추가하는 것이다. Cucumber의 내장 dry-runpublish 플래그를 사용하고, 사용되지 않는 step definition을 검사하는 커스텀 린터와 결합하면, 장황함의 20%로 안전성의 80%를 얻을 수 있다.

# Dry-run parses all features without executing them, surfacing undefined steps
npx cucumber-js --dry-run

이것은 registry 접근법보다 덜 엄격하다. 정의되지 않은 스텝은 잡아내지만 고아 스텝은 잡아내지 않으며, 의미적 정렬을 강제하지도 않는다. 대규모 기존 스위트를 가진 팀에게는 실용적인 출발점이다. 새 프로젝트라면 registry 접근법이 한 달 안에 보람을 돌려준다.

우리가 시도했지만 통하지 않은 것들

코드 주석에서 Gherkin을 생성하는 것을 실험해 보았다. 개발자가 테스트 메서드에 어노테이션을 달면 툴이 .feature 파일을 생성하는 아이디어였다. 실패했다. 왜냐하면 Gherkin은 비개발자도 읽을 수 있어야 하는데, 메서드 이름에서 생성된 산문은 읽을 수 없다. 심지어 산문도 아니다.

또한 모든 스펙 변경에 대해 페어 프로그래밍을 강제하는 것도 시도해 보았다. 도움이 되었지만 확장되지는 않았다. 문제는 기계적이고, 해결책도 기계적이어야 한다.

오늘 당장 정의되지 않은 스텝 탐지부터 시작하라

기존 Cucumber 스위트가 있다면, 가장 작지만 유용한 변경은 CI 파이프라인에 --dry-run을 추가하는 것이다. 5분이면 되고, 가장 흔한 드리프트를 잡아낸다: 리팩토링된 시나리오가 더 이상 어떤 step definition과도 매칭되지 않는 경우.

새롭게 시작한다면 registry 기반 접근법을 고려하라. 명시적 매핑의 선행 비용은 빌드 타임 보장과 스펙이 조용히 낡아가는 것을 걱정하지 않고 자유롭게 리팩토링할 수 있는 자신감으로 상환된다.

Gherkin 스펙은 시스템이 무엇을 하는지 묘사해야 한다. 그것을 신뢰할 수 없다면, 그것은 그냥 비싼 주석에 불과하다. 스펙을 정직하게 유지하는 검증을 자동화하거나, 그것들이 당신에게 거짓말을 하는 것을 받아들여라.