Version: v1.1.0
Status: Final (Phase 4 + REQ-W01 addendum)
Last updated: 2026-06-17
Owner: test-engineer (주 작성) · developer (adapter·TUI 섹션 공동 기여)
Sources: docs/PRD.md v1.1.0 · docs/TUI_DESIGN.md v1.3.0 · docs/ARCHITECTURE.md v1.1.0 · docs/PROJECT_STRUCTURE.md v1.1.0 · docs/CONVENTIONS.md v1.1.0 · docs/TECH_STACK.md v1.0.0 · _workspace/02_okta_domain_input.md · _workspace/edit-form-users/02_okta_domain_input.md · _workspace/edit-form-users/02_pm_prd_draft.md · _workspace/edit-form-users/03_tui_design_draft.md
이 문서는 ota의 테스트 단일 출처다. 어떤 코드든 이 문서의 원칙·패턴·도구와 일치해야 한다. 새 테스트를 작성하기 전 본 문서를 읽고, 테스트 전략이 본 문서와 충돌하면 문서를 먼저 업데이트한다.
- Fail-First TDD — 모든 테스트는 처음에 반드시 실패한다. 실패 이유가 "구현 부재"임을 확인한 후 구현으로 이동한다. 실패 로그 증거는
_workspace/05_test_fail_log_<YYYY-MM-DD>.txt에 append. - 경계면 정합성 (Three-Way Triangulation) — PRD REQ ↔ 구현 ↔ 테스트가 삼각 일치해야 한다. QA(Phase 7)는 이 세 꼭짓점을 교차 읽기로 검증한다. 테스트는 REQ-ID를 반드시 주석 또는 함수명에 명시한다.
- 회귀 방지 자동화 — 사용자·QA가 발견한 모든 버그는 먼저 실패 테스트로 재현된 뒤 수정된다. 회귀 테스트 없이 버그를 수정하지 않는다.
- 100% 커버리지 추구 금지 — 커버리지는 결과물이지 목표가 아니다. 핵심 도메인 95%+, 서비스 85%+, 어댑터 70%+, TUI 60%+ (§9 참조).
- SDK 자체 동작 테스트 금지 — Okta SDK는 Okta의 책임. 우리는 SDK ↔ 어댑터 경계만 검증한다.
- 픽셀 단위 UI 비교 금지 — ANSI 토큰 수준 골든 diff로 충분. 터미널 폰트·렌더러별 차이는 명세 밖.
이미 통과하는 구현에 테스트를 덧붙이는 경우:
- 회귀 방지 목적은 정당하나 설계 피드백 가치는 없음.
- 파일 상단 주석 필수:
// Lock-in test (not Fail-First derived): <이유> - 예외는 소수로 유지. 새 요구사항은 항상 Red부터.
┌──────────────────────────┐
│ E2E (실 Okta tenant) │ minimal, opt-in (-tags=integration)
│ 소수, 느림, 수동 트리거 │ scripts + Developer Free tenant
├──────────────────────────┤
│ TUI Flow (teatest) │ 인터랙션·상태 전이 검증
│ 중간, 1~3초/테스트 │ 골든 파일 diff
├──────────────────────────┤
│ TUI Render (View) │ 순수 렌더 스냅샷, teatest 없음
│ 많음, <50ms/테스트 │ 빠른 회귀 체크
├──────────────────────────┤
│ Adapter Integration │ httptest.Server + 고정 JSON fixture
│ 중간, <200ms/테스트 │ Link 헤더·429·errorCode·페이지네이션
├──────────────────────────┤
│ Unit │ 순수 도메인 로직, 픽스처 없음
│ 압도적 다수, <10ms │ 파서·매퍼·필터·에러맵
└──────────────────────────┘
| 레이어 | 대상 패키지 | 외부 의존 | 속도 | 예시 |
|---|---|---|---|---|
| Unit | internal/domain/*, internal/okta/{ratelimit,pagination,errormap} |
없음 | <10ms | UserStatus 파싱, Link 헤더 파서, errorCode 매핑 |
| Adapter Integration | internal/okta/* (HTTP 경계). Port 계약도 여기서 검증. |
httptest.Server + JSON fixture | <200ms | OktaUsersAdapter가 Link 헤더 순회, 429+Retry-After 준수, Get→ErrNotFound |
| TUI Render | internal/tui/*/view.go |
주입된 fake Port | <50ms | UsersListModel.View() 골든 diff |
| TUI Flow | internal/tui/* Update 체인 |
주입된 fake Port | 1~3s | /alice → Enter → Detail 전환 |
| E2E (manual) | cmd/ota |
실 Okta Developer tenant | 수초~수분 | 스모크: 기동 → Users List 표시 |
MVP에서 Port 구현체는 Okta 어댑터 1개뿐이므로 별도의 "Contract" 레이어를 두지 않는다. Port 계약 검증(Get→ErrNotFound, List→빈 slice 등)은 Adapter Integration 테스트가 그대로 담당한다. 대체 구현(예: v0.2 오프라인 데모용 in-memory fake 또는 v0.3+ 다른 IdP)이 등장하는 시점에 §13의 계약 테스트 패턴을 도입한다.
- 원칙: Unit 압도적 다수, 경계면당 Integration 1개 이상, TUI Flow는 시나리오 대표성만.
- 강제 수치 아님: 7:3(수량) / 1:1(시간)은 목표치. 의미 있는 테스트가 늘어나 비율이 깨져도 문제없음.
| 용도 | 선정 | 버전 정책 | 비고 |
|---|---|---|---|
| Assertion | github.com/stretchr/testify/assert, require |
latest | assert 기본, 후속 단언을 멈춰야 하면 require |
| Interface mock | 수동 fake | — | 기본. internal/*/fakes/ 패키지에 모음 (§3.2) |
| Interface mock (예외) | github.com/stretchr/testify/mock |
latest | 계약이 극도로 복잡할 때만. gomock/mockgen 금지 |
| HTTP 흉내 | net/http/httptest.Server |
stdlib | 기본. SDK 클라이언트의 base URL을 교체 |
| HTTP 흉내 (보조) | github.com/jarcoal/httpmock |
latest | SDK 내부 transport 교체가 불가할 때만 |
| TUI flow | github.com/charmbracelet/x/exp/teatest |
latest | §6 참조 |
| Deep diff | github.com/google/go-cmp/cmp |
latest | JSON/구조체 비교 |
| 골든 파일 | testdata/golden/<test-name>.golden |
— | -update 플래그로만 갱신 |
| Goroutine leak | go.uber.org/goleak |
latest | TestMain에서 VerifyTestMain |
| Race detector | go test -race |
go toolchain | CI 필수 |
| Coverage | go test -coverprofile + go tool cover |
go toolchain | 목표치 gate는 CI에서 체크 |
| Lint | golangci-lint |
latest | testfx 하위 포함. depguard로 SDK 직접 import 차단 |
| Vulnerability | govulncheck |
latest | CI 주 1회 |
- testify — 표준 assertion, 팀 친숙도. 단,
testify/mock은 stringly-typed 호출이 리팩터링 취약 → fake 우선. - teatest — Bubbletea 공식 실험적 패키지. 현재 유일한 실용 옵션. 내부 goroutine이 goleak와 상호작용하므로 §9.3 allowlist 필요.
- httptest.Server — SDK가
*http.Client교체를 허용하므로 base URL만 httptest 서버로 돌려 실제 SDK 경로를 검증.httpmock은 SDK가 내부 transport를 직접 쓸 때 최후수단. - go-cmp —
reflect.DeepEqual보다 해석력 높은 diff. 중첩 JSON 검증에 유리.
// internal/service/fakes/users_port_fake.go
package fakes
import (
"context"
"testing"
"github.com/tedilabs/ota/internal/domain"
)
// UsersPortFake는 domain.UsersPort를 테스트 용도로 구현한다.
// Func 필드를 설정하지 않은 메서드가 호출되면 t.Fatal로 즉시 실패한다.
type UsersPortFake struct {
t *testing.T
ListFunc func(ctx context.Context, f domain.UserFilter) ([]domain.User, domain.PageInfo, error)
GetFunc func(ctx context.Context, id string) (domain.User, error)
GroupsFunc func(ctx context.Context, id string) ([]domain.Group, error)
FactorsFunc func(ctx context.Context, id string) ([]domain.Factor, error)
}
func NewUsersPort(t *testing.T) *UsersPortFake {
t.Helper()
return &UsersPortFake{t: t}
}
func (f *UsersPortFake) List(ctx context.Context, filter domain.UserFilter) ([]domain.User, domain.PageInfo, error) {
f.t.Helper()
if f.ListFunc == nil {
f.t.Fatalf("UsersPortFake.List called but ListFunc is not set")
}
return f.ListFunc(ctx, filter)
}
// ... Get, Groups, Factors 동일 패턴이점: IDE 탐색·리팩터링 친화, 명시적, 컴파일러가 인터페이스 변경을 잡아준다.
ota/
├── internal/
│ ├── domain/
│ │ ├── user.go
│ │ ├── user_test.go # 외부 테스트 (package domain_test)
│ │ ├── ports.go # UsersPort, GroupsPort, ...
│ │ ├── errors.go # Error, ErrNotFound, ...
│ │ └── errors_test.go
│ ├── service/
│ │ ├── users_service.go
│ │ ├── users_service_test.go
│ │ └── fakes/ # Port fake 집합
│ │ ├── users_port_fake.go
│ │ ├── groups_port_fake.go
│ │ └── ...
│ ├── okta/
│ │ ├── users_adapter.go
│ │ ├── users_adapter_test.go # httptest 기반 integration
│ │ ├── ratelimit/
│ │ │ ├── monitor.go
│ │ │ └── monitor_test.go # 순수 유닛
│ │ ├── pagination/
│ │ │ ├── link.go
│ │ │ └── link_test.go
│ │ ├── errormap/
│ │ │ ├── map.go
│ │ │ └── map_test.go
│ │ ├── testfx/ # 테스트 전용, 비-prod 보장
│ │ │ ├── fake_server.go # NewFakeOktaServer(t, scenario)
│ │ │ ├── fixtures.go # LoadFixture(t, path) 헬퍼
│ │ │ └── scrub.go # record 스크럽 유틸
│ │ └── integration/
│ │ └── live_test.go # //go:build integration
│ ├── tui/
│ │ ├── shared/
│ │ ├── users/
│ │ │ ├── list_model.go
│ │ │ ├── list_view.go
│ │ │ ├── list_render_test.go # View() 스냅샷
│ │ │ ├── list_flow_test.go # teatest
│ │ │ └── testdata/
│ │ │ ├── Test_UsersList_Initial.golden
│ │ │ └── Test_UsersList_FilterThenDetail.golden
│ │ └── ...
│ ├── clock/
│ │ ├── clock.go # Clock, Jitter interface + real impls
│ │ └── fake.go # FakeClock (Advance, NewTimer, ...)
│ ├── mask/
│ │ └── mask_test.go
│ └── logger/
│ ├── logger.go
│ ├── masking_handler.go
│ └── masking_handler_test.go
├── testdata/
│ └── oktaapi/
│ ├── fixtures_manifest.yaml # 캡처 시각·scrub 상태·tenant edition
│ ├── users/
│ │ ├── list_page1.json
│ │ ├── list_page1_link_header.txt
│ │ ├── list_page2.json
│ │ ├── detail_active.json
│ │ ├── factors_all_types.json
│ │ └── groups_of_user.json
│ ├── groups/
│ ├── grouprules/
│ ├── policies/
│ │ ├── okta_sign_on.json
│ │ ├── access_policy.json
│ │ ├── password.json
│ │ ├── mfa_enroll.json
│ │ ├── profile_enrollment.json # raw-only
│ │ ├── post_auth_session.json # raw-only
│ │ └── idp_discovery.json # raw-only
│ ├── logs/
│ │ ├── tail_initial.json
│ │ ├── tail_poll_next.json
│ │ ├── rate_limited_429.json
│ │ └── failed_signins.json
│ └── errors/
│ ├── E0000001_validation.json
│ ├── E0000004_auth.json
│ ├── E0000006_forbidden.json
│ ├── E0000007_not_found.json
│ ├── E0000011_token_expired.json
│ ├── E0000022_delete_blocked.json
│ ├── E0000038_feature_disabled.json
│ └── E0000047_rate_limit.json
├── scripts/
│ ├── record-fixture.go # 실 tenant → testdata 캡처
│ └── record-fixture.md # 스크럽 규칙
└── Makefile
- 외부 (
package foo_test) 기본 — 인터페이스 사용자 관점. 노출 API만 사용. - 내부 (
package foo) 예외 — unexported 심볼 직접 검증이 불가피할 때만. 테스트 파일 상단 주석으로 정당화.
- 실제 런타임 경로에서 제외되어야 한다. 2가지 방법 중 하나 채택:
- 별도 패키지 (
internal/okta/testfx) —*_test.go가 아닌 일반.go파일이지만 production 진입점(cmd/ota)이 import하지 않으면 실제 바이너리에 포함되지 않음. 더 간단. - 빌드 태그
//go:build testfx.
- 별도 패키지 (
- 채택: 방법 1 (패키지 분리). lint 규칙으로
cmd/ota에서internal/okta/testfximport 금지.
internal/domain/ports.go에UsersPort,GroupsPort,RulesPort,PoliciesPort,LogsPort선언.internal/okta/*_adapter.go가 구현체 제공.- Service 레이어 및 TUI는 도메인 인터페이스만 import.
- lint 강제:
internal/app/,internal/tui/,internal/service/에서github.com/okta/okta-sdk-golang직접 import 금지 (golangci-lintdepguard).
※ Port 위치는
internal/domain/ports.go로 확정 (2026-04-24, developer + test-engineer 합의). ARCHITECTURE §6.2 · PROJECT_STRUCTURE §2에도 동일 반영.
- Record:
scripts/record-fixture.go --scenario <name>로 실 Developer tenant에서 1회 캡처 → 자동 스크럽 →testdata/oktaapi/에 저장. - Replay: 기본
go test는 오프라인.testfx.NewFakeOktaServer(t, scenario)가 fixture를 로드하여 httptest.Server로 재생. - 검증:
//go:build integration태그로 실 tenant 호출 테스트 별도 제공 (§9.4).
모든 캡처는 저장 전 아래 치환을 거친다. scripts/record-fixture.md에서 상세 규칙 관리.
| 필드 | 치환 |
|---|---|
이메일 (profile.email, profile.login, actor.alternateId) |
user-<hash>@redacted.example.com (hash는 stable) |
전화 (profile.mobilePhone, factor.profile.phoneNumber) |
+1-555-000-NNNN (NNNN은 결정론적 4자리) |
Authorization 헤더 |
완전 제거 |
Set-Cookie |
완전 제거 |
X-Okta-Request-Id |
00000000-0000-0000-0000-<index> 고정 |
ipAddress, geographicalContext |
198.51.100.1 / 비워둠 |
| 조직 커스텀 profile 필드 | 값만 ***REDACTED***, 키는 보존 |
uuid (Log event) |
결정론적 UUID로 재생성 |
# testdata/oktaapi/fixtures_manifest.yaml
fixtures:
- path: users/list_page1.json
captured_at: 2026-05-02T10:00:00Z
tenant_edition: developer-free
endpoint: GET /api/v1/users?limit=200
scenario: users-list-basic
scrubbed:
- emails
- request_ids
notes: |
3 users, all ACTIVE. Used by UsersRepo_List_Basic tests.Phase 7 QA가 manifest를 읽어 fixture drift(실 tenant 응답 스키마 변화)를 추적할 수 있다.
00u_active_alice— ACTIVE, factors=[push(Okta Verify), sms], groups=[eng]00u_active_bob— ACTIVE, factors=[webauthn]00u_suspended— SUSPENDED (REQ-R01 AC-2 색상 분기)00u_locked— LOCKED_OUT00u_password_expired— PASSWORD_EXPIRED00u_deprovisioned— DEPROVISIONED (REQ-R01 AC-2 DELETED와 혼동 방지)00u_staged,00u_provisioned— 경계 상태
00g_engineering— OKTA_GROUP, 동적 (Group Rule target, RULE 배지 테스트)00g_sales— OKTA_GROUP, 정적00g_everyone— BUILT_IN (REQ-R02 AC-3 대용량 배너)00g_app_synced— APP_GROUP
0pr_active— ACTIVE, expression=user.department == "Engineering"0pr_inactive— INACTIVE0pr_invalid— INVALID (REQ-R03 AC-2 경고색)
- OKTA_SIGN_ON × 1 (system=true) + 1 custom
- ACCESS_POLICY × 2
- PASSWORD × 1
- MFA_ENROLL × 1
- PROFILE_ENROLLMENT, POST_AUTH_SESSION, IDP_DISCOVERY × 각 1 (raw-only)
user.session.startSUCCESS × 3, FAILURE × 2group.rule.deactivate× 1 (REQ-R05 AC-5 프리셋 iii)system.api_token.create× 1 (REQ-C04 AC-5 토큰 수명 힌트)
- 8종 전부 (§7 에러 매핑 테이블)
- 쓴다: 화면 전환, 키 입력 시퀀스, tail 폴링 등 이벤트 루프가 필요한 시나리오.
- 쓰지 않는다: 단일 상태에서의 렌더링 검증. View() 출력을 직접 비교하는 편이 10~20배 빠르고 명확하다.
Model은 생성자 경로로만 초기 상태를 주입한다. 테스트-only setter(SetUsers 등) 금지 (CONVENTIONS §8.1 · §10.1 Elm 원칙). 초기 데이터가 필요하면 Port fake가 동기적으로 반환한 뒤 UsersLoadedMsg를 직접 Update(msg)로 전달하거나, 생성자에 WithInitialUsers(xs) Option을 추가한다.
// internal/tui/users/list_render_test.go
func Test_UsersListModel_Render_WithActiveAndSuspendedRows(t *testing.T) {
t.Parallel()
fakeClock := clock.NewFake(time.Date(2026, 4, 24, 12, 0, 0, 0, time.UTC))
// 방법 A: 생성자 Option으로 초기 데이터 주입
model := users.NewListModel(users.Deps{
Clock: fakeClock,
Width: 120, Height: 30,
}, users.WithInitialUsers([]domain.User{
{ID: "00u1", Profile: domain.UserProfile{Login: "alice@acme.com"}, Status: domain.StatusActive},
{ID: "00u2", Profile: domain.UserProfile{Login: "bob@acme.com"}, Status: domain.StatusSuspended},
}))
// 방법 B (동등): Update를 통해 상태 유도
// m := users.NewListModel(deps)
// m, _ = m.Update(users.UsersLoadedMsg{Items: xs}).(users.ListModel), ...
out := model.View()
golden.Assert(t, out, "testdata/Test_UsersListModel_Render_Mixed.golden")
}두 방법 중 팀은 **방법 A (Option)**를 기본으로 한다.
Update경유(방법 B)는 메시지 타입까지 테스트에서 구성해야 해 번잡하며, Option은 production에서도 부트스트랩(예: 캐시 hydration)에 재사용 가능한 경로다.
// internal/tui/users/list_flow_test.go
func Test_UsersListFlow_FilterAlice_OpensDetail(t *testing.T) {
t.Parallel()
port := fakes.NewUsersPort(t)
port.ListFunc = func(_ context.Context, _ domain.UserFilter) ([]domain.User, domain.PageInfo, error) {
return []domain.User{
{ID: "00u1", Profile: domain.UserProfile{Login: "alice@acme.com"}, Status: domain.StatusActive},
{ID: "00u2", Profile: domain.UserProfile{Login: "bob@acme.com"}, Status: domain.StatusActive},
}, domain.PageInfo{}, nil
}
port.GetFunc = func(_ context.Context, id string) (domain.User, error) {
return domain.User{ID: id, Profile: domain.UserProfile{Login: "alice@acme.com"}, Status: domain.StatusActive}, nil
}
fakeClock := clock.NewFake(time.Date(2026, 4, 24, 12, 0, 0, 0, time.UTC))
model := users.NewListModel(users.Deps{Port: port, Clock: fakeClock})
tm := teatest.NewTestModel(t, model,
teatest.WithInitialTermSize(100, 30),
)
// 초기 fetch 결과 도착 대기
teatest.WaitFor(t, tm.Output(), func(b []byte) bool {
return bytes.Contains(b, []byte("alice@acme.com"))
}, teatest.WithCheckInterval(10*time.Millisecond), teatest.WithDuration(2*time.Second))
// '/alice' 입력 후 Enter
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")})
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("alice")})
tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
// 선택 상태에서 Enter로 상세 진입
tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
// 최종 출력 골든 비교
out, err := io.ReadAll(tm.FinalOutput(t, teatest.WithFinalTimeout(3*time.Second)))
require.NoError(t, err)
teatest.RequireEqualOutput(t, out)
// golden: testdata/Test_UsersListFlow_FilterAlice_OpensDetail.golden
// 업데이트: go test -update ./internal/tui/users/
}- 경로:
internal/tui/<resource>/testdata/<TestName>.golden - 업데이트:
go test -update ./...— CI에서는 금지 - 업데이트 시 git diff를 PR 본문에 포함하여 리뷰어가 검토
- 터미널 크기는
teatest.WithInitialTermSize로 고정 (기본 100×30) - ANSI 이스케이프 포함 그대로 저장 (스타일 회귀도 감지)
Phase 6c에서 발견된 회귀(테스트는 PASS인데 사용자에게는 빈 화면처럼 보임)를 막기 위해 Phase 6d는 시각 충실도 골든을 별도 트랙으로 도입한다. 이전 6.4의 *.golden 파일은 teatest 출력 전체(ANSI 포함)를 저장하지만, 시각 골든은 다음과 같이 분리되어 사람이 읽고 리뷰할 수 있는 형태로 유지된다.
- 경로:
internal/tui/<resource>/testdata/golden/<scenario>.txt - 포맷: NO_COLOR + ANSI strip 후의 plain text (박스 보더
╭─╮│╰─╯├┤유지, 컬럼 정렬 보존) - 갱신:
go test -update ./internal/tui/...—internal/testfx가-update플래그를 처리하여 기존 파일을 덮어쓴다. - 컬러 프로필 고정:
testfx.PinTestEnvironment()가NO_COLOR=1과lipgloss.SetColorProfile(termenv.Ascii)를 같이 적용. 각*_golden_test.go의init()에서 호출. - ANSI Strip:
testfx.StripANSI(view)—charmbracelet/x/ansi.Strip래퍼. 컬러 프로필 고정과 이중 안전장치. - 소스 오브 트루스: 각 골든은
docs/TUI_DESIGN.md§16 의 ASCII 와이어프레임을 그대로 옮긴 것. 디자이너가 §16 을 갱신하면 -update 로 동기화.
Fail-First 운영 모드: Phase 6d-3 (App Shell chrome) ~ 6d-6 (Error surfacing) 가 진행 중인 동안 골든 비교는 t.Skip 으로 비활성화하고 spec lock-in (assert.Contains 기반) 만 Active 로 둔다. 개발자가 해당 Phase 를 마치면 골든 테스트의 Skip 을 제거하고 -update 한 번 실행하여 골든을 활성화. 이 절차는 _workspace/06d_test_red_log.txt 에 기록된다.
- 예측가능한 조건을 기다린다: 특정 문자열/패턴 등장, 카운터 변화
time.Sleep금지: 본질적으로 flaky- 타임아웃: 네트워크 없으므로 2s로 충분. 그보다 오래 걸리면 설계 문제
첫 teatest Test_UsersListFlow_FilterAlice_OpensDetail 를 3회 연속 실행하여 측정한 실측치. 각 실행 ~20ms로 매우 빠르고 안정적. flaky 없음.
- 단일 실행 시간: 0.02s (-race 포함). 플로우 내부 cmd 3~4개(Init → fetch → filter → Get) 수행.
WaitFor타임아웃: 2s로 선언했지만 실제로는 10ms 미만에 predicate 매치. fake Port가 Cmd 내부 I/O 없이 즉시 반환하므로 대기가 거의 없음.FinalOutput타임아웃: 3s로 선언. 실제 드레인은 즉시. 실제 I/O 기반 Integration에서는 값 조정 필요 가능.- 3회 반복 안정성:
go test -count=33회 모두 0.02s PASS. 타이밍 기반 flakiness 없음.
- stripping 불필요 — Bubbletea가 터미널 폭에 맞춘 ANSI 이스케이프 포함 바이트를 Output에 기록하지만,
bytes.Contains기반 predicate + 골든 파일이 ANSI 포함 비교를 자연 수용. - 골든 비교 시
teatest.RequireEqualOutput이 정규화 도움을 주지만, 본 테스트는require.Contains사용이 충분. - 터미널 크기
teatest.WithInitialTermSize(100, 30)고정. 이 값 변경 시 줄바꿈 위치가 달라지므로 골든 파일 함께 재생성.
users.NewListModel(Deps{Port, Clock})→ Init() →fetchUsersCmd트리거- teatest 내부가 Cmd 실행 → fake
ListFunc호출 →UsersLoadedMsg반환 - Model.Update가 Msg 수신 → 내부 상태
.items채움 → View에 렌더 WaitForpredicateContains("alice@...")매치 시점에 다음 키 주입 진행
3회 실행 모두 누출 없음. 현재 시점 TestMain에서 goleak 실행 시 추가 allowlist 불필요. Phase 7 QA 중 teatest 동시 복수 호출 경로 발견 시 재조사. 예비용 스니펫:
// internal/tui/users/main_test.go (예비 — 현재는 불필요)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m,
// 필요 시 아래 추가:
// goleak.IgnoreTopFunction("github.com/charmbracelet/bubbletea.(*Program).Run"),
)
}- 본 테스트는 Resize 이벤트를 명시적으로 주입하지 않음.
WithInitialTermSize후 고정 폭 사용. - Resize 전파 신뢰성은 별도 시나리오 테스트가 필요 (Phase 7 시 추가). Bubbletea가
tea.WindowSizeMsg를 Update에 전달하는 경로를 model이 store + View에서 width 참조하는지 확인.
teatest는 fake Port + FakeClock 조합에서 결정론적이고 빠름. Phase 6 TUI Flow 계층의 기반으로 안정.
REQ-W01 Users Edit (SCR-012)은 ota의 첫 mutation 화면이자 internal/tui/shared/form/ 위젯의 첫 사용처. 테스트 레이어가 셋으로 분리된다:
| 레이어 | 위치 | 도구 | 검증 대상 |
|---|---|---|---|
| Form 위젯 단위 | internal/tui/shared/form/form_test.go |
순수 unit | dirty 추적·검증·키 가로채기·focus 이동 — Port fake 없음 |
| Edit screen 통합 | internal/tui/users/edit_*_test.go |
teatest + UsersPortFake | fetch/save Cmd·상태머신 전이·에러 매핑·navStack pop |
| 계약/Port | internal/okta/users_test.go + internal/service/users_test.go |
httptest.Server + fake | UpdateProfile partial-merge body·errormap·재시도 정책 |
// internal/tui/shared/form/form_test.go
func Test_Form_Dirty_TrackedPerKeystroke(t *testing.T) {
t.Parallel()
specs := []form.FieldSpec{
{Key: "firstName", Label: "First Name", Kind: form.KindText, Required: true},
{Key: "lastName", Label: "Last Name", Kind: form.KindText, Required: true},
}
initial := map[string]string{"firstName": "Alice", "lastName": "Smith"}
f := form.New(specs, initial)
require.Equal(t, 0, f.Dirty())
// type "Alicia" — append "ia" to firstName (assuming first field focused)
f, _ = f.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("ia")})
require.Equal(t, 1, f.Dirty())
require.Equal(t, "Aliceia", f.Snapshot()["firstName"]) // depends on impl
// revert by Ctrl+U (textinput clears) + type "Alice"
f, _ = f.Update(tea.KeyMsg{Type: tea.KeyCtrlU})
f, _ = f.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("Alice")})
assert.Equal(t, 0, f.Dirty(), "reverting to snapshot value clears dirty")
}핵심: Port fake가 없다. Form은 도메인을 모르므로 순수 메시지 → 상태 검증으로 충분.
// internal/tui/users/edit_save_test.go
func Test_UserEdit_Save_PartialMergeBody_Success(t *testing.T) {
t.Parallel()
var capturedPatch domain.UserProfilePatch
port := fakes.NewUsersPort(t)
port.GetFunc = func(_ context.Context, id string) (domain.User, error) {
return domain.User{
ID: id,
Status: domain.UserStatusActive,
Profile: domain.UserProfile{
Login: "alice@acme.com", Email: "alice@acme.com",
FirstName: "Alice", LastName: "Smith",
Department: "Eng",
// ... 11 fields populated
},
}, nil
}
port.UpdateProfileFunc = func(_ context.Context, _ string, patch domain.UserProfilePatch) (domain.User, error) {
capturedPatch = patch
// simulate server echoing the updated user
return domain.User{ID: "00u1", Profile: domain.UserProfile{
Login: "alice@acme.com", Email: "alice@acme.com",
FirstName: *patch.FirstName, LastName: "Smith",
Department: "Eng",
}}, nil
}
svc := service.NewUsersService(port)
model := users.NewEditModel(users.EditDeps{Svc: svc, UserID: "00u1"})
tm := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(120, 30))
// wait for loaded
teatest.WaitFor(t, tm.Output(), func(b []byte) bool {
return bytes.Contains(b, []byte("Alice"))
}, teatest.WithDuration(2*time.Second))
// edit firstName: focus already on first field (Identity / First Name)
tm.Send(tea.KeyMsg{Type: tea.KeyEnd}) // cursor to end
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("ia")}) // Alice → Alicia
// wait dirty marker
teatest.WaitFor(t, tm.Output(), func(b []byte) bool {
return bytes.Contains(b, []byte("1 change"))
}, teatest.WithDuration(1*time.Second))
// Ctrl+S
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlS})
// wait pop (e.g., toast "Updated alice")
teatest.WaitFor(t, tm.Output(), func(b []byte) bool {
return bytes.Contains(b, []byte("Updated"))
}, teatest.WithDuration(2*time.Second))
// AC-4.2: partial-merge body only contains firstName
require.NotNil(t, capturedPatch.FirstName)
assert.Equal(t, "Alicia", *capturedPatch.FirstName)
assert.Nil(t, capturedPatch.LastName, "unchanged fields must be omitted")
assert.Nil(t, capturedPatch.Email, "unchanged fields must be omitted")
assert.Nil(t, capturedPatch.Department,"unchanged fields must be omitted")
}// internal/tui/users/edit_save_test.go
func Test_UserEdit_Save_400Validation_InlineFieldErrors(t *testing.T) {
t.Parallel()
port := fakes.NewUsersPort(t)
// Get: same as success above
port.UpdateProfileFunc = func(_ context.Context, _ string, _ domain.UserProfilePatch) (domain.User, error) {
return domain.User{}, &domain.BadRequestError{
Raw: "Api validation failed: profile",
Causes: []domain.FieldError{
{Field: "email", Summary: "email: Email is not valid"},
{Field: "department", Summary: "department: Cannot exceed 100 characters"},
},
}
}
// ... wire model, tm, edit some field, send Ctrl+S ...
// wait field-level inline error attached to "Email" row
teatest.WaitFor(t, tm.Output(), func(b []byte) bool {
return bytes.Contains(b, []byte("! Email is not valid"))
}, teatest.WithDuration(2*time.Second))
// also for Department
out := string(takeFrame(tm))
assert.Contains(t, out, "! Cannot exceed 100 characters")
// AC-6: form must NOT close — Esc still goes to discard confirm
tm.Send(tea.KeyMsg{Type: tea.KeyEscape})
teatest.WaitFor(t, tm.Output(), func(b []byte) bool {
return bytes.Contains(b, []byte("Discard"))
}, teatest.WithDuration(1*time.Second))
}| 단축키 | 시점 | 테스트 | 예상 동작 |
|---|---|---|---|
e |
List/Detail 화면 | Test_UsersList_eKey_EmitsOpenUserEditMsg, Test_UserDetail_eKey_EmitsOpenUserEditMsg |
OpenUserEditMsg{ID} 발송 |
Tab / Shift+Tab |
editing | Test_Form_TabCyclesFocus_SkipsReadOnly |
focus 이동 (read-only login skip) |
Ctrl+S (Dirty=0) |
editing | Test_Form_CtrlS_Empty_NoSaveCommand_FooterHint |
save Cmd 미발사, footer "No changes to save" |
Ctrl+S (Dirty>0, valid) |
editing | Test_UserEdit_Save_PartialMergeBody_Success |
save Cmd 발사 |
Ctrl+S (validation fail) |
editing | Test_UserEdit_Save_ClientValidationFails_FocusFirstInvalid |
save Cmd 미발사, focus 첫 invalid 필드 |
Esc (Dirty=0) |
editing | Test_UserEdit_Esc_Clean_PopsNav |
popNav, no modal |
Esc (Dirty>0) |
editing | Test_UserEdit_Esc_Dirty_OpensDiscardConfirm |
OverlayDiscardConfirm open |
y on DiscardConfirm |
overlay | Test_DiscardConfirm_Y_DiscardsAndPops |
popNav, changes lost |
n / Esc on DiscardConfirm |
overlay | Test_DiscardConfirm_N_ReturnsToEditing |
overlay close, form preserved |
Alt+m |
editing | Test_Form_AltM_TogglesAllPII |
mobilePhone + secondEmail mask/unmask 전환 |
Ctrl+C |
saving | Test_UserEdit_CtrlC_DuringSaving_CancelsAndPreservesInput |
ctx cancel, draft 보존 |
Esc |
saving | Test_UserEdit_Esc_DuringSaving_NoOp |
무시, footer hint 표시 |
각 행은 teatest 시나리오 1개로 구현. table-driven으로 묶기 어려운 이유: 각 시나리오의 초기 상태 / 키 시퀀스가 다름. 다만 §8.7의 11 fields × dirty matrix는 table-driven.
// internal/service/fakes/users_port_fake.go — 확장
type UsersPortFake struct {
t *testing.T
// ... 기존 필드 ...
UpdateProfileFunc func(ctx context.Context, userID string, patch domain.UserProfilePatch) (domain.User, error)
}
func (f *UsersPortFake) UpdateProfile(ctx context.Context, userID string, patch domain.UserProfilePatch) (domain.User, error) {
f.t.Helper()
if f.UpdateProfileFunc == nil {
f.t.Fatalf("UsersPortFake.UpdateProfile called but UpdateProfileFunc is not set")
}
return f.UpdateProfileFunc(ctx, userID, patch)
}검증 시뮬레이션 헬퍼 (UsersPortFake에 추가):
// ValidationErrorFake returns a UpdateProfileFunc that always rejects with
// BadRequestError for the given (field → message) pairs. Used to drive
// AC-6 server-side validation tests.
func ValidationErrorFake(causes map[string]string) func(context.Context, string, domain.UserProfilePatch) (domain.User, error) {
fields := make([]domain.FieldError, 0, len(causes))
for k, v := range causes {
fields = append(fields, domain.FieldError{Field: k, Summary: k + ": " + v})
}
return func(_ context.Context, _ string, _ domain.UserProfilePatch) (domain.User, error) {
return domain.User{}, &domain.BadRequestError{Causes: fields, Raw: "Api validation failed"}
}
}1. REQ-ID 또는 AC 단위로 요구사항 선정
2. 테스트 파일에 실패 테스트 작성 (구현은 stub 또는 부재)
3. go test ./... → 실패 확인
4. 실패 로그를 _workspace/05_test_fail_log_YYYY-MM-DD.txt에 append
5. 최소 구현 작성 → Green
6. 리팩터링 (녹색 유지)
7. 커밋 (테스트 + 구현 + 문서 동시)
_workspace/05_test_fail_log_YYYY-MM-DD.txt에 REQ-ID별로 섹션을 만들고 append:
## REQ-R01 AC-1 Users 리스트 기본 컬럼
### 2026-05-01 10:23 (Red)
$ go test ./internal/tui/users/ -run Test_UsersListModel_Render
--- FAIL: Test_UsersListModel_Render (0.00s)
list_render_test.go:38: undefined: users.NewListModel
### 2026-05-01 10:41 (Red 두번째 — 컴파일 통과, 단정 실패)
$ go test ./internal/tui/users/ -run Test_UsersListModel_Render
--- FAIL: Test_UsersListModel_Render (0.02s)
golden mismatch (see diff):
expected column 'STATUS' at index 0, got ''
### 2026-05-01 10:58 (Green)
$ go test ./internal/tui/users/ -run Test_UsersListModel_Render
ok github.com/tedilabs/ota/internal/tui/users 0.123s
### 2026-05-01 11:12 (Refactor — 여전히 Green)
...
- 테스트가 처음부터 통과: Fail-First 위반. 기존 구현이 우연히 충족시키는 경우라면 Lock-in 테스트(§1.3)로 명시하거나 assertion을 더 구체적으로.
- 테스트가 구현을 1:1로 반영: 행동을 검증해야지 구조를 반영하면 안 된다. 내부 심볼 직접 호출 지양.
- 과도한 mock: 전체 시스템을 mock으로 덮으면 통합 버그를 놓친다. 최소 1~2 레벨 위까지는 실제 코드.
전체 REQ-ID 매핑은 §12에. 여기서는 고위험 시나리오 6종의 설계 의도만 요약.
| AC | 테스트 | 레이어 |
|---|---|---|
| AC-1 Remaining ≤ 10% 노란 경고 | RateLimitMonitor_Warns_WhenRemainingBelowTenPercent |
Unit |
| AC-2 Retry-After 준수 + ±20% jitter | Transport_Retries429_WithRetryAfterAndJitter |
Adapter integration (httptest) |
| AC-2 3회 실패 후 에러 | Transport_GivesUp_After3Retries |
Adapter integration |
AC-3 tail 일시정지 + 재개 시 since 유지 |
LogsTail_PausesOnRateLimit_ResumesWithSameSince |
Unit (fakeClock) |
| AC-4 카테고리별 last-observed | RateLimitMonitor_RecordsPerCategory |
Unit |
AC-5 /logs 분당 ~120 안전 마진 |
LogsTail_DefaultInterval_KeepsBudget |
Unit |
| AC-6 30초 TTL 캐시 + 강제 무효화 | Cache_TTL_Expires · Cache_ForceInvalidate |
Unit |
| 시나리오 | 테스트 |
|---|---|
| Link 헤더 next 파싱 | LinkHeader_ParsesNextCursor · LinkHeader_ReturnsEmpty_WhenNoNext |
| 2~3페이지 순차 fetch | UsersAdapter_ListAll_IteratesAllPages |
| 병렬 요청 금지 (순차 보장) | UsersAdapter_ListAll_IsSequential |
| context 취소 전파 | UsersAdapter_ListAll_CancelsGracefully |
| 멤버 페이지 소진 중 사용자 중단 | GroupMembersIterator_StopsOnSignal |
에러 모델은 sentinel 기본 + errors.As 타입으로 부가 정보 추출 (CONVENTIONS §4.2 · ARCHITECTURE §9). errorCode 8종 전부 테이블 드리븐:
func Test_ErrorMap_FromResponse_MapsAllKnownCodes(t *testing.T) {
t.Parallel()
cases := []struct{
name string
fixture string // testdata/oktaapi/errors/<file>.json
wantSentinel error
wantSummary string // substring match (사용자 메시지 원재료)
}{
{"validation", "E0000001_validation.json", domain.ErrBadRequest, "Api validation failed"},
{"auth", "E0000004_auth.json", domain.ErrTokenInvalid, "API token invalid"},
{"forbidden", "E0000006_forbidden.json", domain.ErrForbidden, "Insufficient permissions"},
{"not_found", "E0000007_not_found.json", domain.ErrNotFound, "Resource not found"},
{"token_expired", "E0000011_token_expired.json", domain.ErrTokenInvalid, "Token expired"},
{"delete_blocked", "E0000022_delete_blocked.json", domain.ErrBadRequest, "Deactivate before"},
{"feature_disabled", "E0000038_feature_disabled.json", domain.ErrFeatureDisabled, "feature is disabled"},
{"rate_limit", "E0000047_rate_limit.json", domain.ErrRateLimited, "Rate limit"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
resp := testfx.LoadHTTPResponse(t, "testdata/oktaapi/errors/"+tc.fixture)
err := errormap.FromResponse(resp)
// sentinel 확인
require.ErrorIs(t, err, tc.wantSentinel)
// errorSummary 보존 (사용자 메시지 원재료)
assert.Contains(t, err.Error(), tc.wantSummary)
})
}
}
// E0000001의 errorCauses 필드별 보존은 전용 타입으로 검증
func Test_ErrorMap_BadRequest_PreservesCauses(t *testing.T) {
t.Parallel()
resp := testfx.LoadHTTPResponse(t, "testdata/oktaapi/errors/E0000001_validation.json")
err := errormap.FromResponse(resp)
var bre *domain.BadRequestErr
require.ErrorAs(t, err, &bre)
require.NotEmpty(t, bre.Causes, "E0000001 must include errorCauses")
// Okta가 전달한 필드별 원인이 그대로 보존되어 UI에서 표시 가능해야 함 (REQ-U04 AC-3)
assert.Contains(t, bre.Causes[0].Summary, "login")
}
// 429의 Retry-After는 RateLimitedErr로 꺼낸다
func Test_ErrorMap_RateLimit_ExposesRetryAfter(t *testing.T) {
t.Parallel()
resp := testfx.LoadHTTPResponse(t, "testdata/oktaapi/errors/E0000047_rate_limit.json")
err := errormap.FromResponse(resp)
var rle *domain.RateLimitedErr
require.ErrorAs(t, err, &rle)
assert.Greater(t, rle.RetryAfter, time.Duration(0))
}| 시나리오 | 테스트 | 레이어 |
|---|---|---|
| SMS phoneNumber 기본 마스킹 | Factor_SMS_MasksPhoneByDefault |
Unit |
| secondEmail 마스킹 포맷 | Profile_MasksSecondEmail |
Unit |
:unmask 후 [M!] 배지 |
UserDetail_Unmask_ShowsBadge |
TUI flow (teatest) |
y 복사 (마스킹 상태) |
UserDetail_CopyMasked_CopiesMasked |
Unit (clipboard fake) |
| 화면 전환 시 자동 재마스킹 | UserDetail_Navigate_AutoRemasks |
TUI flow |
| 60초 inactivity 자동 재마스킹 | UserDetail_Inactivity_AutoRemasks |
Unit (fakeClock) |
| peek test: 로그·크래시에 raw PII 부재 | Secrets_NeverLeakToDebugLog |
Unit (§11) |
| Authorization 헤더 마스킹 | DebugLog_AuthHeader_IsMasked |
Unit |
| AC | 테스트 |
|---|---|
AC-2 since 마지막 published + 1ms |
LogsTail_Since_AdvancesByMinPlusOneMs |
| AC-2 기본 7초 간격 | LogsTail_DefaultPollInterval_Is7s |
AC-2 adaptive: X-Rate-Limit-Limit < 60 → 15초 |
LogsTail_AdaptivePolling_UpgradesTo15s_OnLowLimit |
| AC-3 새 이벤트 카운터 | LogsTail_NewEventsBadge_IncrementsOnArrival (TUI flow) |
AC-3 f 자동 스크롤 토글 |
LogsTail_FollowToggle_PausesAutoScroll (TUI flow) |
| AC-3 429 자동 일시정지 | LogsTail_Paused_WhenRateLimited |
| AC-4 DESCENDING 최신순 히스토리 | LogsList_Descending_LatestFirst |
| AC-5 프리셋 5종 | LogsPresets_ApplyFilter (table-driven × 5) |
| AC-6 actor.id → User 점프 | LogEvent_ActorJump_OpensUserDetail (TUI flow) |
| AC-7 UTC/로컬 토글 | LogTime_Format_RespectsTzSetting |
- Rich 4종: table-driven
Test_Policies_RichType_<TYPE>_ActionSummary— OKTA_SIGN_ON, ACCESS_POLICY, PASSWORD, MFA_ENROLL - Raw 3종:
Test_Policies_RawOnlyType_ListColumns_UniformAcrossTypes,Test_Policies_RawOnlyType_DetailShowsRawJSON,Test_Policies_RawOnlyType_MenuShowsRawViewBadge
internal/tui/shared/form/ 와 internal/tui/users/edit*.go 를 다층으로 검증. 핵심 매트릭스 3종:
| AC | 테스트 함수 | 레이어 |
|---|---|---|
| AC-1.1 (e from list) | Test_UsersList_eKey_EmitsOpenUserEditMsg |
TUI unit |
| AC-1.2 (e from detail) | Test_UserDetail_eKey_EmitsOpenUserEditMsg (table — Pretty/JSON/YAML 탭 × 3) |
TUI unit |
| AC-1.3 (latest GET on entry) | Test_UserEdit_OnEntry_CallsPortGet_Once |
TUI flow |
| AC-1.4 (loading abort with Esc) | Test_UserEdit_Loading_EscAborts |
TUI flow |
| AC-1.5 (4xx blocks form open) | Test_UserEdit_Loading_4xx_DoesNotOpenForm (table — 401/403/404) |
TUI flow |
| AC-2 (11 fields × 4 sections) | Test_UserEdit_FieldCatalog_HasAll11Fields_In4Sections |
Render |
| AC-2 (login read-only) | Test_UserEdit_LoginField_IsReadOnly_NotInDiff |
TUI unit |
| AC-3.1 (required empty) | Test_UserEdit_RequiredField_EmptyShowsInline (table — firstName/lastName/email × 3) |
Form unit |
| AC-3.2 (email loose format) | Test_UserEdit_EmailField_LooseValidation (table — valid/invalid × 6) |
Form unit |
| AC-3.3 (phone E.164 hint) | Test_UserEdit_MobilePhone_E164Hint_NoBlock |
Form unit |
| AC-3.5 (no client uniqueness lookup) | Test_UserEdit_NoPreSaveUniquenessGetCall |
TUI flow |
| AC-4.1 (Ctrl+S triggers save) | Test_UserEdit_Save_PartialMergeBody_Success |
TUI flow |
| AC-4.2 (diff-only body, omit) | §8.7.2 table-driven | TUI flow + port assertion |
| AC-4.3 (saving disables input + Esc) | Test_UserEdit_Saving_InputDisabled_EscNoop |
TUI flow |
| AC-4.4 (1s save guard) | Test_UserEdit_Saving_PostSuccess_1sGuard_DisablesSave (FakeClock) |
TUI flow |
| AC-4.5 (success cache patch + toast) | Test_UserEdit_Save_Success_BroadcastsUserUpdatedMsg + Test_UsersList_ReceivesUserUpdatedMsg_PatchesRow |
TUI flow |
| AC-5.1 (clean Esc) | Test_UserEdit_Esc_Clean_PopsImmediately |
TUI flow |
| AC-5.2 (dirty Esc → modal) | Test_UserEdit_Esc_Dirty_OpensDiscardConfirm + Test_DiscardConfirm_Y_Discards / Test_DiscardConfirm_N_Preserves |
TUI flow |
| AC-5.3 (saving Esc noop) | Test_UserEdit_Esc_DuringSaving_NoOp |
TUI flow |
| AC-6 (errorCauses inline) | §8.7.3 table-driven | TUI flow |
| AC-6.1 (field prefix match) | Test_Form_ApplyServerErrors_PrefixMatchesFieldSpecKey |
Form unit |
| AC-6.2 (inline clears on edit) | Test_Form_InlineError_ClearsOnKeystrokeInThatField |
Form unit |
| AC-7.1~7.5 (PII mask lifecycle) | Test_UserEdit_PII_DefaultMasked + Test_UserEdit_PII_FocusAutoUnmask + Test_UserEdit_PII_BlurRemasksUnmodified + Test_UserEdit_AltM_TogglesAllPII |
TUI flow |
| AC-7.6 (no PII in debug log) | Test_UserEdit_DebugLog_NoRawPII (peek test, §11) |
Unit |
| AC-8.1 (keyboard only) | implicit — all teatest 시나리오가 키보드만 사용 | — |
| AC-8.2 (NO_COLOR markers) | Test_UserEdit_Render_NoColor_ShowsDirtyAsterisk_RequiredText_InlineExclam |
Render (visual golden §6.4a) |
| AC-8.3 (80x24 viewport) | Test_UserEdit_NarrowTerminal_LabelInputTwoLines_ViewportScroll |
Render |
| AC-9 (dirty/diff tracking) | §8.7.4 table-driven | Form unit |
| AC-10.1 (cache untainted on cancel) | Test_UserEdit_Discard_DoesNotPatchListCache |
TUI flow |
| AC-10.2 (background polls continue) | Test_UserEdit_DoesNotPauseLogsTailPolling |
TUI flow (LogsModel + EditModel 동시) |
| AC-10.3 (selected row restored on close) | Test_UserEdit_AfterClose_RestoresSelectedRow |
TUI flow |
// internal/tui/users/edit_save_test.go
func Test_UserEdit_Save_PartialMerge_OnlyDirtyFieldsInBody(t *testing.T) {
t.Parallel()
type fieldCase struct {
Key string // FieldSpec.Key — Okta API field name
Input string // value typed by user
Read func(domain.UserProfilePatch) *string // accessor on patch
}
cases := []fieldCase{
{"firstName", "Alicia", func(p domain.UserProfilePatch) *string { return p.FirstName }},
{"lastName", "Smyth", func(p domain.UserProfilePatch) *string { return p.LastName }},
{"displayName", "Ali S.", func(p domain.UserProfilePatch) *string { return p.DisplayName }},
{"nickName", "aly", func(p domain.UserProfilePatch) *string { return p.NickName }},
{"email", "x@y.com", func(p domain.UserProfilePatch) *string { return p.Email }},
{"title", "Lead", func(p domain.UserProfilePatch) *string { return p.Title }},
{"division", "RnD-2", func(p domain.UserProfilePatch) *string { return p.Division }},
{"department", "Platform", func(p domain.UserProfilePatch) *string { return p.Department }},
{"employeeNumber", "ENG-099", func(p domain.UserProfilePatch) *string { return p.EmployeeNumber }},
{"mobilePhone", "+14155551234", func(p domain.UserProfilePatch) *string { return p.MobilePhone }},
{"secondEmail", "a@b.com", func(p domain.UserProfilePatch) *string { return p.SecondEmail }},
}
for _, tc := range cases {
t.Run("only "+tc.Key, func(t *testing.T) {
t.Parallel()
var got domain.UserProfilePatch
port := newPortWithLoadedUser(t, loadedAlice)
port.UpdateProfileFunc = func(_ context.Context, _ string, p domain.UserProfilePatch) (domain.User, error) {
got = p
return loadedAlice, nil
}
tm := startEditFlow(t, port, "00u1")
focusField(tm, tc.Key) // helper: Tab until labelMatches
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlU}) // clear field
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tc.Input)})
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlS})
waitForSaveComplete(t, tm)
// Only this field set; all others nil
require.NotNil(t, tc.Read(got), "%s must be set in patch", tc.Key)
assert.Equal(t, tc.Input, *tc.Read(got))
for _, other := range cases {
if other.Key == tc.Key {
continue
}
assert.Nil(t, other.Read(got), "%s must be nil (omit) when not edited", other.Key)
}
})
}
}// internal/tui/users/edit_save_test.go
func Test_UserEdit_Save_BadRequestError_InlineFieldErrors(t *testing.T) {
t.Parallel()
cases := []struct {
name string
causes map[string]string // field -> reason
wantInline map[string]string // expected substring per field in rendered output
wantOther []string // non-field causes shown in footer "Other errors:"
}{
{
name: "single field validation",
causes: map[string]string{"email": "Email is not valid"},
wantInline: map[string]string{"Email": "! Email is not valid"},
},
{
name: "two field validations",
causes: map[string]string{
"email": "Email is not valid",
"department": "Cannot exceed 100 characters",
},
wantInline: map[string]string{
"Email": "! Email is not valid",
"Department": "! Cannot exceed 100 characters",
},
},
{
name: "unknown field prefix → other errors",
causes: map[string]string{"customField_x": "Forbidden"},
wantOther: []string{"customField_x"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
port := newPortWithLoadedUser(t, loadedAlice)
port.UpdateProfileFunc = fakes.ValidationErrorFake(tc.causes)
tm := startEditFlow(t, port, "00u1")
editAnyField(tm) // force dirty so Ctrl+S triggers save
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlS})
frame := waitForFrame(t, tm)
for label, want := range tc.wantInline {
assert.Contains(t, frame, want, "%s inline error", label)
}
for _, other := range tc.wantOther {
assert.Contains(t, frame, other, "other error must appear in footer")
}
assert.NotContains(t, frame, "popNav called", "form must NOT close on 400")
})
}
}// internal/tui/shared/form/form_test.go
func Test_Form_DirtyMatrix_AllSections(t *testing.T) {
t.Parallel()
specs := usersedit.FieldSpecs() // production catalog — exported test helper or testfx
type op struct {
field string
value string // empty = no edit; non-empty = SetValue
}
cases := []struct {
name string
ops []op
wantDirty int
wantKeys []string
}{
{"no edit", nil, 0, nil},
{"one identity field", []op{{"firstName", "Alicia"}}, 1, []string{"firstName"}},
{"one contact field PII", []op{{"mobilePhone", "+1..."}}, 1, []string{"mobilePhone"}},
{"cross-section",
[]op{{"firstName", "Alicia"}, {"department", "Eng-2"}, {"secondEmail", "x@y"}},
3, []string{"firstName", "department", "secondEmail"}},
{"revert restores clean",
[]op{{"firstName", "Alicia"}, {"firstName", "Alice"}},
0, nil},
{"all 11 dirty",
[]op{ /* 11 ops */ },
11, /* all keys */},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
f := form.New(specs, loadedAliceProfile)
for _, o := range tc.ops {
f = applyEdit(f, o.field, o.value)
}
assert.Equal(t, tc.wantDirty, f.Dirty())
assert.ElementsMatch(t, tc.wantKeys, f.DirtyFields())
})
}
}§6.7.5 참조. Phase 5 진입 직전 internal/service/fakes/users_port_fake.go 에 UpdateProfileFunc 필드와 UpdateProfile 메서드 추가 + 검증 헬퍼 (ValidationErrorFake) 추가.
// internal/okta/users_test.go
func Test_OktaUsersAdapter_UpdateProfile_PartialMerge_BodyShape(t *testing.T) {
t.Parallel()
var capturedBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "POST", r.Method)
require.Equal(t, "/api/v1/users/00u1", r.URL.Path)
capturedBody, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"00u1","profile":{"login":"a@x","firstName":"Alicia","lastName":"Smith"}}`))
}))
defer srv.Close()
client := okta.NewTestClient(t, srv.URL, "token")
a := okta.NewUsersAdapter(client)
first := "Alicia"
patch := domain.UserProfilePatch{FirstName: &first}
user, err := a.UpdateProfile(context.Background(), "00u1", patch)
require.NoError(t, err)
assert.Equal(t, "Alicia", user.Profile.FirstName)
var sent struct {
Profile map[string]any `json:"profile"`
}
require.NoError(t, json.Unmarshal(capturedBody, &sent))
assert.Equal(t, "Alicia", sent.Profile["firstName"])
assert.NotContains(t, sent.Profile, "lastName", "unchanged fields must be omitted")
assert.NotContains(t, sent.Profile, "email", "unchanged fields must be omitted")
// ... 9 more assertNotContains for the other 9 fields
}
func Test_OktaUsersAdapter_UpdateProfile_ErrorMapping(t *testing.T) {
t.Parallel()
cases := []struct {
name string
fixture string
wantErr error
}{
{"400 validation", "errors/E0000001_validation.json", new(domain.BadRequestError)},
{"403 forbidden", "errors/E0000006_forbidden.json", domain.ErrForbidden},
{"404 not found", "errors/E0000007_not_found.json", domain.ErrNotFound},
{"429 rate limited","errors/E0000047_rate_limit.json",new(domain.RateLimitedError)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { /* serve fixture, assert errors.As/Is */ })
}
}
// EMPTY PATCH GUARD — service-level only, but adapter should also panic-guard
func Test_OktaUsersAdapter_UpdateProfile_EmptyPatch_ReturnsErrEmptyPatch_NoHTTPCall(t *testing.T) {
t.Parallel()
called := false
srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
called = true
}))
defer srv.Close()
a := okta.NewUsersAdapter(okta.NewTestClient(t, srv.URL, "tok"))
_, err := a.UpdateProfile(context.Background(), "00u1", domain.UserProfilePatch{})
assert.ErrorIs(t, err, domain.ErrEmptyPatch)
assert.False(t, called, "no HTTP call must be made for empty patch (D-W13)")
}| 잡 | 명령 | 게이트 |
|---|---|---|
test-unit |
go test -race -count=1 ./... |
race free, 전부 통과 |
test-integration (httptest) |
위 잡이 포함 (태그 없이 httptest 기반만) | 동일 |
lint |
golangci-lint run |
경고 0 |
vuln |
govulncheck ./... |
알려진 취약점 없음 |
cover-gate |
coverage thresholds 체크 (§9.2) | 하회 시 실패 |
(manual) e2e-live |
go test -tags=integration ./..., workflow_dispatch |
OKTA_ORG_URL/TOKEN secret 필요 |
| 패키지 prefix | 최소 | 근거 |
|---|---|---|
internal/domain/... |
95% | 순수 로직, mock 불필요 |
internal/service/... |
85% | Port fake로 대부분 커버 가능 |
internal/okta/... |
75% | integration으로 보완. ARCHITECTURE §17 / CONVENTIONS 동일 기준과 통일 |
internal/okta/{ratelimit,pagination,errormap} |
95% | 경계 유닛, 철저 |
internal/tui/... |
60% | View 스냅샷 + teatest flow. 스타일 분기는 snapshot이 커버 |
Gate는 PRD §6.3의 "핵심 도메인 ≥ 70%" 기준을 상향. Phase 5 말부터 활성화.
현재 상태: 실측 결과 teatest Users list 플로우 3회 연속 실행에서 goroutine 누출 0건. 현재 시점 allowlist가 필요하지 않음 — goleak.VerifyTestMain(m)을 allowlist 없이 호출하는 것으로 충분.
// internal/<package>_test.go (필요한 패키지별로 1개)
// 현재 권장 형태 (2026-04-24 실측 기준):
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}allowlist가 필요해지는 조건 (Phase 7 이후 관찰 예정):
- 동일 테스트 안에서 teatest.NewTestModel을 2회 이상 호출하는 경우 — bubbletea Program goroutine이 완전히 종료되지 않을 수 있음
tea.Cmd안에서 수동go func스폰을 사용하는 경우 (CONVENTIONS §9 위반)- Logs tail polling 테스트가 ctx cancel 없이 early-return하는 경우
위 중 하나라도 발견되면 아래 템플릿으로 allowlist 추가:
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m,
goleak.IgnoreTopFunction("github.com/charmbracelet/bubbletea.(*Program).Run"),
goleak.IgnoreAnyFunction("github.com/charmbracelet/x/exp/teatest.(*TestModel).startLoop"),
)
}- 범위: 각 패키지 단위로 1개
TestMain - allowlist는 최소한으로. 실제 leak은 반드시 수정 (CONVENTIONS §13.9).
- 파일 헤더:
//go:build integration // +build integration package okta_test
- 환경:
OKTA_ORG_URL,OKTA_API_TOKEN(Read-Only Admin) - 실행:
make test-integration(로컬/manual dispatch) - CI 기본 미실행 — PRD §10.1의 E2E를 수동/옵션으로 유지
- 재시도 금지. 한 번이라도 flaky면 근본 원인 조사.
- 임시 격리 수단:
t.Skip("flaky: <link to issue>")+//go:build flaky태그 - 원인 수정 후 복원. 수정 전 merge 금지.
.PHONY: test test-short test-race test-integration test-cover test-update-golden lint vuln
test:
go test -race -count=1 ./...
test-short:
go test -race -short -count=1 ./...
test-race:
go test -race -count=1 -timeout=60s ./...
test-integration:
OKTA_ORG_URL=$${OKTA_ORG_URL:?set OKTA_ORG_URL} \
OKTA_API_TOKEN=$${OKTA_API_TOKEN:?set OKTA_API_TOKEN} \
go test -race -tags=integration -count=1 ./...
test-cover:
go test -race -coverprofile=coverage.out -covermode=atomic ./...
go tool cover -func=coverage.out | tail -n 1
go tool cover -html=coverage.out -o coverage.html
test-update-golden:
go test -update ./internal/tui/...
lint:
golangci-lint run
vuln:
govulncheck ./...make test-short— 작업 중 빠른 피드백 (-short로 teatest 스킵 가능)- 커밋 전
make test— race 포함 전체 - 골든 변경 시
make test-update-golden+git diff testdata/리뷰
testing.Short()가 true면 teatest flow 테스트를 스킵- 순수 unit·render·adapter integration은 항상 실행
- CI는
-short없이 실행 (teatest 포함)
PRD §6.2 + TUI_DESIGN §7에 정의된 PII/secret 마스킹이 실제로 모든 출력 경로에서 작동함을 기계적으로 검증.
- 디버그 로그 파일 (
~/.cache/ota/debug.log, REQ-O01) - panic/crash 스택트레이스
- 세션 에러 히스토리 (
:errors) - 골든 파일 (teatest)
- 클립보드 복사 (마스킹 상태에서
y) - tea.Model 내부 상태 (덤프 지점)
func Test_Secrets_NeverLeakToDebugLog(t *testing.T) {
t.Parallel()
const (
rawToken = "00a-SECRET-TOKEN-VALUE-xyz"
rawPhone = "+1-555-123-4567"
rawEmail = "alice@secret.com"
)
var buf bytes.Buffer
handler := logger.NewMaskingHandler(slog.NewJSONHandler(&buf, nil))
log := slog.New(handler)
// 실제 HTTP 요청 로깅 시뮬레이션
log.Error("okta request failed",
"authorization", "SSWS "+rawToken,
"phone", rawPhone,
"email", rawEmail,
"user_id", "00u123")
out := buf.String()
// raw 값이 어떤 경로로도 로그에 나타나면 실패
assert.NotContains(t, out, rawToken, "raw token must not appear in log")
assert.NotContains(t, out, rawPhone, "raw phone must not appear in log")
assert.NotContains(t, out, rawEmail, "raw email must not appear in log")
// 마스킹 흔적은 있어야
assert.Contains(t, out, "***", "masked marker must appear")
}func Test_Secrets_NeverLeakToGoldenFiles(t *testing.T) {
t.Parallel()
// 모든 testdata/**/*.golden 파일을 grep
patterns := []*regexp.Regexp{
regexp.MustCompile(`(?i)\bAuthorization: SSWS \S+`),
regexp.MustCompile(`\+1-\d{3}-\d{3}-\d{4}`), // raw US phone
regexp.MustCompile(`[a-z0-9._-]+@(?!redacted\.example\.com)[a-z0-9-]+\.[a-z]+`),
}
filepath.WalkDir("../../", func(path string, d fs.DirEntry, err error) error {
if !strings.HasSuffix(path, ".golden") { return nil }
b, err := os.ReadFile(path)
require.NoError(t, err)
for _, p := range patterns {
if m := p.Find(b); m != nil {
t.Errorf("potential PII leak in %s: %q", path, string(m))
}
}
return nil
})
}runtime/debug.Stack()으로 캡처 → 마스킹 처리 적용된 Writer로 출력- 테스트: panic을 유발하는 구조체에 토큰 필드를 넣고, 크래시 로거 출력에 원본 없음 검증
규약: 각 REQ-ID의 AC에 최소 1개 테스트 함수를 매핑. AC가 복합적이면 여러 테스트로 분할. Phase 5 시작 전 테스트 이름을 예약(stub +
t.Skip)하고, 작성 시 하나씩 풀어감.
| REQ-ID | AC | 테스트 파일 | 테스트 함수 | 레이어 |
|---|---|---|---|---|
| REQ-U01 | AC-1 | internal/keys/keys_test.go |
Test_Navigation_ArrowKeysEquivalentToVim |
Unit |
| REQ-U01 | AC-2 | internal/config/keybinding_test.go |
Test_Keybindings_OverrideFromConfig |
Unit |
| REQ-U01 | AC-3 | internal/tui/*/list_flow_test.go |
Test_*_NavigationConsistent |
TUI flow |
| REQ-U01 | AC-4 | internal/tui/shared/textinput_test.go |
Test_TextInput_UsesReadlineKeysInInsertMode |
Unit |
| REQ-U02 | AC-1~4 | internal/tui/cmdpalette/palette_test.go |
Test_CmdPalette_Commands (table) · Test_CmdPalette_TabComplete · Test_CmdPalette_PartialMatch · Test_CmdPalette_HistoryPersists |
TUI flow |
| REQ-U03 | AC-1~4 | internal/tui/shared/incsearch_test.go |
Test_IncSearch_TypeResponseUnder50ms · Test_IncSearch_EnterConfirmsEscCancels · Test_IncSearch_CaseToggle · Test_IncSearch_NextPrevMatch |
Unit + TUI |
| REQ-U04 | AC-1 | internal/tui/users/list_flow_test.go |
Test_UsersList_SlashKeyTriggersQSearch |
TUI flow |
| REQ-U04 | AC-2 | internal/service/users_service_test.go |
Test_UsersService_SearchExpression_UsesScimSearchParam |
Unit |
| REQ-U04 | AC-3 | internal/okta/errormap/map_test.go |
Test_ErrorMap_FromResponse_MapsAllKnownCodes |
Unit |
| REQ-U04 | AC-4 | internal/tui/logs/presets_test.go |
Test_LogsPresets_AppliesFilter (table × 5) |
Unit |
| REQ-U04 | AC-5 | internal/tui/users/help_test.go |
Test_UsersHelp_IncludesEventualConsistencyNote |
Unit |
| REQ-U05 | AC-1~3 | internal/tui/users/detail_flow_test.go |
Test_UserDetail_TabTransitionUnder300ms_Cached · Test_UserDetail_LoadingIndicatorAndCancel · Test_UserDetail_BreadcrumbPath |
TUI flow |
| REQ-U06 | AC-1~3 | internal/tui/help/help_test.go |
Test_Help_ContextSensitive · Test_Help_Searchable · Test_Help_ReflectsUserBindings |
TUI flow |
| REQ-U07 | AC-1~2 | internal/tui/quit_test.go |
Test_Quit_CtrlCDoubleSkipsProtection · Test_Quit_ProtectionDisabledByConfig |
Unit |
| REQ-R01 | AC-1 | internal/tui/users/list_render_test.go |
Test_UsersList_DefaultColumns |
Render |
| REQ-R01 | AC-2 | internal/tui/users/list_render_test.go |
Test_UsersList_StatusColors (table × 7 상태) |
Render |
| REQ-R01 | AC-3 | internal/tui/users/detail_render_test.go |
Test_UserDetail_TabsPresent (Profile/Credentials/Timestamps/Groups/Factors/Logs) |
Render |
| REQ-R01 | AC-4 | internal/okta/users_adapter_test.go |
Test_UsersAdapter_ListFirstPage_Under1s |
Adapter integration |
| REQ-R01 | AC-5 | internal/service/users_service_test.go |
Test_UsersService_QVsSearchModes |
Unit |
| REQ-R01 | AC-6 | internal/tui/users/factors_render_test.go |
Test_UsersFactors_AllTypes_Rendered (table × 7 factor types) · Test_UsersFactors_PhoneNumberMasked · Test_UsersFactors_IDHiddenByDefault |
Render + Unit |
| REQ-R01 | AC-7 | internal/tui/users/help_test.go |
Test_UsersHelp_ExplainsDeletedVsDeprovisioned |
Unit |
| REQ-R02 | AC-1 | internal/tui/groups/list_render_test.go |
Test_GroupsList_Columns_WithRuleBadgeWhenDynamic |
Render |
| REQ-R02 | AC-2 | internal/service/groups_service_test.go |
Test_GroupsService_FilterParam_UsesScimFilter |
Unit |
| REQ-R02 | AC-3 | internal/tui/groups/members_flow_test.go |
Test_Members_BuiltInShowsLargeBanner · Test_Members_EveryoneLabeled · Test_Members_ProgressiveLoadingCanAbort |
TUI flow |
| REQ-R02 | AC-4 | internal/tui/groups/detail_flow_test.go |
Test_GroupDetail_AppsTabLazyLoads_DashOnForbidden |
TUI flow |
| REQ-R02 | AC-5 | internal/tui/groups/members_flow_test.go |
Test_Members_CountShownAfterPageExhaust |
TUI flow |
| REQ-R03 | AC-1~6 | internal/tui/rules/list_render_test.go, detail_render_test.go |
Test_Rules_ListColumns · Test_Rules_StatusColors (ACTIVE/INACTIVE/INVALID) · Test_Rules_DetailExpressionMonospace · Test_Rules_TargetGroupIDResolution · Test_Rules_DetailDeactivationWarningBanner · Test_RulesAdapter_DefaultLimit200 |
Mixed |
| REQ-R04 | AC-1~8 | internal/tui/policies/*_test.go, internal/domain/policies/catalog_test.go |
7종 × 렌더 + 4 rich × action summary + raw 3종 × 3 tests + Test_PolicyCatalog_AddNewType_NoUIChange |
Mixed |
| REQ-R05 | AC-1~9 | internal/tui/logs/*_test.go, internal/service/logs_service_test.go |
§8.5 전부 | Mixed |
| REQ-C01 | AC-1~4 | internal/config/loader_test.go |
Test_Config_ParseErrorReportsLineColumn · Test_Config_CLIFlagOverridePath · Test_Config_Sections_Required · Test_Config_CommentsPreserved |
Unit |
| REQ-C02 | AC-1~4 | internal/config/profile_test.go, internal/app/profile_switch_test.go |
Test_Profile_PerTenantFields · Test_Profile_CLIFlag · Test_Profile_SwitchResetsStateUnder2s · Test_Profile_TokenNotInFile |
Unit + TUI flow |
| REQ-C03 | AC-1~4 | internal/keys/*_test.go |
Test_Keys_DefaultsDocumented · Test_Keys_UserMappingWinsOnConflict · Test_Keys_InvalidKeyNameWarns · Test_Keys_ReloadRequiresRestart |
Unit |
| REQ-C04 | AC-1~6 | internal/auth/*_test.go |
Test_Auth_PriorityFallback · Test_Auth_InteractiveTokenNotPersisted · Test_Auth_MissingTokenExitsWithGuide · Test_Auth_ErrorMapping (table × errorCode) · Test_Auth_TokenAgeHintBestEffort · Test_Auth_OAuth2NotInMVP |
Unit |
| REQ-C05 | AC-1~4 | internal/logger/masking_handler_test.go, internal/mask/*_test.go |
Test_Secrets_TokenZeroCopyLifecycle · Test_DebugLog_AuthHeader_IsMasked · Test_CrashTrace_NoTokenInStack · Test_ConfigExamples_NoPlaintextSecret |
Unit |
| REQ-E01 | AC-1~6 | §8.1 전부 | 상동 | Unit + Adapter integration |
| REQ-E02 | AC-1~3 | internal/tui/shared/toast_test.go |
Test_Toast_3SecondAutoDismiss · Test_Toast_DuplicateShowsCounter · Test_ErrorsCommand_ShowsHistory |
TUI flow |
| REQ-E03 | AC-1~3 | internal/tui/shared/offline_test.go |
Test_Offline_StatusBarIndicator · Test_Offline_CachedDataStillReadable · Test_Offline_AutoRefreshOnRecovery |
TUI flow |
| REQ-O01 | AC-1~4 | internal/logger/debuglog_test.go |
Test_DebugLog_DisabledByDefault · Test_DebugLog_CapturesHTTPAndState · Test_DebugLog_RotatesAt10MB · Test_DebugLog_TailCommand |
Unit |
| REQ-W01 | AC-1~10 | §8.7 전부 (edit_flow_test.go / edit_save_test.go / edit_pii_test.go / form_test.go / errmap_test.go / users_test.go) |
§8.7 매트릭스 — 11 fields × 4 sections × dirty matrix table-driven + 6 HTTP 에러 시나리오 | Mixed (Form unit + TUI flow + Adapter integration) |
Phase 5 선행 과제: 위 매트릭스의 모든 테스트 함수명을 _test.go에 t.Skip("not yet implemented — REQ-XXX AC-Y")로 예약. 이로써 REQ → 테스트 → 구현 추적 경로가 Phase 5 시작 시점에 이미 성립한다.
Port 인터페이스(예: domain.UsersPort)의 구현체가 여러 개(실 Okta 어댑터 / in-memory fake / record-replay 기반) 생기더라도 동일 계약을 만족하는지 보장.
// internal/domain/ports_contract.go
// (테스트가 아니라 일반 함수. 각 구현체 _test.go가 import하여 호출)
package domain_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tedilabs/ota/internal/domain"
)
type UsersPortFactory func(t *testing.T) domain.UsersPort
func RunUsersPortContract(t *testing.T, factory UsersPortFactory) {
t.Run("Get nonexistent returns ErrNotFound", func(t *testing.T) {
port := factory(t)
_, err := port.Get(context.Background(), "00u-nonexistent")
assert.ErrorIs(t, err, domain.ErrNotFound)
})
t.Run("List empty returns empty slice not nil", func(t *testing.T) {
port := factory(t)
users, _, err := port.List(context.Background(), domain.UserFilter{})
require.NoError(t, err)
assert.Equal(t, []domain.User{}, users)
})
t.Run("Context cancellation propagates", func(t *testing.T) {
port := factory(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, _, err := port.List(ctx, domain.UserFilter{})
assert.ErrorIs(t, err, context.Canceled)
})
// 추가 계약: List의 PageInfo 형식, Filter의 연산자 지원 등
}// internal/okta/users_adapter_contract_test.go
func Test_OktaUsersAdapter_Contract(t *testing.T) {
domain_test.RunUsersPortContract(t, func(t *testing.T) domain.UsersPort {
srv := testfx.NewFakeOktaServer(t, testfx.ContractScenario)
return okta.NewUsersAdapter(srv.Client(), srv.URL)
})
}
// internal/service/fakes/inmem_users_port_test.go
func Test_InMemUsersPort_Contract(t *testing.T) {
domain_test.RunUsersPortContract(t, func(t *testing.T) domain.UsersPort {
return fakes.NewInMemUsersPort()
})
}- Get: 존재/부재/비활성 사용자
- List: 빈/필터 매치/복수 페이지/페이지 없음
- Context 취소 →
errors.Is(err, context.Canceled)전파 - Error 매핑: 적어도
ErrNotFound,ErrUnauthorized는 모든 구현이 동일 매핑
| 날짜 | 버전 | 변경 | 작성 |
|---|---|---|---|
| 2026-04-24 | v0.9 | 초안. D-A~D-L 합의 대부분 반영. | test-engineer |
| 2026-04-24 | v0.9.1 | developer 리뷰 B1~B5 반영: Contract 레이어 → Adapter Integration 병합(§2.1), View 스냅샷 예시 SetUsers 제거(§6.2), Coverage okta 75% 통일(§9.2), teatest 실측 Phase 5 초반으로 연기(§6.6), Appendix A 업데이트. |
test-engineer |
| 2026-04-24 | v1.0.0 | developer 교차 리뷰 통과. ARCHITECTURE/PROJECT_STRUCTURE/CONVENTIONS/TECH_STACK v1.0.0 정합성 확인 (Port 위치·testdata 분리·fakes 경로·go-cmp 추가 전부 반영). Appendix A의 teatest/goleak/Makefile 항목은 Phase 5 초반 실측 후 본문 업데이트. | test-engineer |
| 2026-04-24 | v1.0.1 | Phase 6 Users list teatest 첫 Green 실측 반영: §6.6 타이밍·ANSI·Cmd 순서 구체 기록, §9.3 goleak은 현 시점 allowlist 불필요 확인. Appendix A의 B4/goleak 이월 항목 해소. | test-engineer |
| 2026-06-17 | v1.1.0 | REQ-W01 (Users Profile Edit) addendum: §6.7 Form 화면 teatest 패턴 신설 (Form unit / Edit screen / Adapter integration 3층 분리, UsersPortFake.UpdateProfile 확장 가이드 + ValidationErrorFake 헬퍼). §8.7 REQ-W01 AC 매트릭스 (11 fields × dirty matrix table-driven, errorCauses 매핑 table, partial-merge body assertion table). §12 매트릭스에 REQ-W01 행 추가. Sources에 REQ-W01 PRD/TUI Design addendum 포함. | test-engineer |
- D-A 최종 결정:
internal/domain/ports.go확정 (2026-04-24, developer + test-engineer 합의). §4.3 반영 완료. - D-M Error 타입: developer 방식 수용 — sentinel 기본 + 추가 정보는
errors.As로 꺼내는 타입(BadRequestErr{Causes},RateLimitedErr{RetryAfter}) 병행. CONVENTIONS §4.2 · ARCHITECTURE §6.1 기준. typed Error struct 제안은 철회. - CONVENTIONS §13 테스트 섹션: test-engineer 기여 완료 (2026-04-24, CONVENTIONS v0.1.1-draft).
- B1 Contract 레이어 병합: 완료 (§2.1).
- B2 SetUsers 제거: 완료 (§6.2 + CONVENTIONS §8.1).
- B3 Coverage 75%: 완료 (§9.2).
- B5 testdata 위치: TUI 패키지-로컬 + 루트 공유 분리 채택 완료 (CONVENTIONS §13.4). PROJECT_STRUCTURE §7는 developer가 업데이트 예정.
- B4 / §6.6 teatest 실측: 완료 (2026-04-24, Phase 6). Users list 플로우 3회 실행 0.02s·flaky 없음·ANSI stripping 불필요 확정.
- goleak allowlist: 완료 (2026-04-24). 실측 결과 누출 0건 → 현재 allowlist 불필요. 예비 스니펫 §9.3에 문서화.
- Makefile: 완료 (2026-04-24, developer Task #23+#46).
/Makefile존재 ·build/test/test-short/test-race/test-integration/test-e2e/lint/vuln/ci/run/fmt/tidy/clean타겟 전부 정의. PROJECT_STRUCTURE §9 contract 준수.
- REQ-U04 AC-5 ↔ 도메인 §3.2 eventually consistent
- REQ-R01 AC-6 ↔ 도메인 §7 MFA Factors
- REQ-R02 AC-3 ↔ 도메인 §1.3 Everyone 그룹
- REQ-R03 전부 ↔ 도메인 §1.4 Group Rules + §5 EL
- REQ-R04 AC-1~8 ↔ 도메인 §1.6 Policies
- REQ-R05 전부 ↔ 도메인 §1.7 System Logs + §2 페이지네이션
- REQ-E01 전부 ↔ 도메인 §2.2 Rate Limit
- REQ-C04 AC-4 ↔ 도메인 §2.3 에러 응답
END of TESTING.md v1.0.0. Phase 5 첫 teatest PR이 Appendix A 잔여 항목(§6.6 실측, §9.3 goleak allowlist, Makefile dry-run)을 본문 값으로 치환한다.