기능 플래그 × BDD 시나리오#
한마디로#
"이 버튼 잠깐 감추자"고 플래그를 켜는 PR 과, "이 버튼을 눌러 본다"는 테스트를 추가하는 PR 이 서로 다른 파일이라 git 은 깨끗하게 합칩니다. 각자의 CI 도 자기 브랜치만 보니 둘 다 초록불입니다. 그런데 합쳐진 순간 없는 버튼을 찾는 테스트가 되어 통합 브랜치가 빨간불이 됩니다. 이 스킬은 플래그를 켤 때 테스트를 함께 처리하는 절차입니다.
무엇을·언제#
- 무엇을 해 주나요: 무엇이 꺼졌는지에 따라 테스트를 보존할지(skip) 계약을 갱신할지를 판정하는 기준을 주고, 사람이 빠뜨려도 CI 가 잡게 합니다.
- 언제 쓰이나요: 기능 플래그로 버튼·메뉴를 숨길 때, 그리고 나중에 다시 켤 때.
- 좋은 점: 병렬로 진행되는 두 PR 이 각자 초록불인데 합치면 빨간불이 되는 유형은 리뷰로 잡히지 않습니다. 절차와 가드가 유일한 방어입니다.
핵심 용어#
| 용어 | 쉬운 설명 |
|---|---|
| 기능 플래그 | 코드 배포는 그대로 두고 기능만 껐다 켜는 스위치 |
| 게이팅 | 조건에 따라 위젯을 그리거나 안 그리는 것 |
| BDD 시나리오 | 사람이 읽는 문장으로 쓴 테스트 |
| skip | 테스트를 지우지 않고 잠시 실행에서 빼는 것 |
| Key | 테스트가 위젯을 찾는 이름표 |
왜 이 문서가 생겼나#
한 PR 이 플래그를 켜서 버튼을 전 환경 미노출로 바꿨고, 같은 시기 다른 PR 이 그 버튼을 단언하는 BDD 시나리오를 추가했습니다.
# 시나리오를 추가한 PR
When I tap the { ' purchase_refund_button ' } widget at index { ' 0 ' } # ← 이 Key 가
// 플래그를 켠 PR
final showRefundAction = !TemporarilyDisabledFeatures.selfRefund && …; // ← 안 그려진다
git 은 서로 다른 파일이라 깨끗이 머지했고, 각 PR 의 CI 는 자기 브랜치 기준이라 둘 다 green 이었습니다. 충돌은 머지 결과에서만 드러났습니다.
⚠️ 이 규약만으로는 사람이 체크리스트를 지켰을 때만 유효합니다. 병렬 머지가 무검출로 통과하는 구조적 원인(통합 브랜치 보호·머지 큐 부재)은 별건입니다 — 둘이 함께 있어야 재발이 막힙니다.
두 가지 처리 — 무엇이 꺼졌는가로 갈린다#
| 상황 | 처리 | 근거 |
|---|---|---|
| 기능 전체가 off | .feature 태그를 패키지 dart_test.yaml 에서 skip |
시나리오는 "재개하면 성립할 미래 사양" — 삭제하면 재개 시 커버리지를 처음부터 다시 써야 한다 |
| 게이팅 조건만 변경 (예: 프로덕션 한정 → 전 환경) | 시나리오 단언을 갱신 (should not be displayed) |
기능은 살아 있고 계약이 달라진 것 — skip 하면 현재 계약이 무검증으로 남는다 |
skip 하는 경우#
# feature/ < pkg > /dart_test.yaml
tags:
refund:
description: " self-service 환불 시나리오 (purchase_history.feature) "
# 재개 시 플래그를 false 로 되돌리고 **이 skip 항목도 함께 제거**한다.
skip: " 환불 기능 일시 미노출 — 재개 시 이 항목 제거 "
⚠️ Gherkin 은 Dart 상수를 읽을 수 없습니다. 플래그 하나를
false로 되돌리는 것만으로는 시나리오가 살아나지 않습니다 — 플래그 dartdoc 에 "재개 시 이 skip 도 제거"를 적어 두세요.
단언을 갱신하는 경우#
Scenario: 프로덕션이 아니어도 공유 생성 진입 버튼은 숨겨진다
Given the share list is loaded with no shares
Then the { ' annotation_share_create_entry ' } widget should not be displayed
CI 가드#
플래그가 true 일 때 그 플래그가 게이팅하는 Key 를 긍정적으로 단언하는 시나리오가
skip 없이 살아 있으면 실패시킵니다.
python3 .github/scripts/check_feature_flag_scenarios.py # 위반 시 exit 1
python3 .github/scripts/check_feature_flag_scenarios.py --report # 추출 현황
python3 .github/scripts/test_check_feature_flag_scenarios.py # 가드 자체의 회귀 테스트
| 시나리오 | 판정 |
|---|---|
the {'k'} widget should not be displayed | ✅ 계약을 갱신한 것 |
the {'k'} widget should be displayed · I tap the {'k'} … |
❌ 렌더되지 않는 위젯을 찾는다 |
위 ❌ 이지만 태그가 dart_test.yaml 에서 skip | ✅ 사양으로 보존 |
⚠️ --report 를 함께 보라 — 0건이 "안전"이 아닐 수 있다#
게이팅 추출은 정적 근사입니다. Key 가 없으면 대조할 것이 없어 0건이 나오는데, 그건 안전이 아니라 가드의 사각지대라는 뜻입니다.
🔒 숨김 cart: (게이팅된 Key 없음) ← 위젯 전체를 early-return 으로 숨김
🔒 숨김 noteTaking: (게이팅된 Key 없음) ← 그 버튼에 Key 가 없다
find.byIcon·텍스트로 찾는 시나리오는 Key 기반 가드가 닿지 않습니다. 그 계열을 게이팅할
때는 사람이 직접 전수 검색하세요.
rg -n " 노트|제보|장바구니 " --glob ' *.feature '
플래그를 켤 때 체크리스트#
-
숨기는 위젯의 Key 를
.feature전수 검색 (rg "\{'그_key'\}" --glob '*.feature') - Key 가 없는 위젯이면 아이콘·텍스트 기준으로도 검색
- 발견한 시나리오를 위 표대로 skip 또는 단언 갱신
- skip 했다면 플래그 dartdoc 에 "재개 시 이 skip 도 제거" 를 명시
- 가드 스크립트 통과 +
--report로 사각지대 확인
플래그를 끌(재개할) 때#
dart_test.yaml의 대응 skip 항목 제거- 갱신했던
should not be displayed단언을 원래 계약으로 되돌림 - 플래그 dartdoc 의 "재개 조건" 이 실제로 해소됐는지 확인
관련#
- patrol-bdd-conventions — BDD 작성 규약
- bdd-canonical-steps — 공용 step 사전
- parallel-session-collision — 병렬 작업이 무검출로 합쳐지는 문제 일반