# Node 유닛 테스트 추가 ## 이 파일을 읽는 구현 에이전트에게 각 체크리스트 항목을 완료한 뒤 `[x]`로 표시하세요. 중간 검증 명령을 실제로 실행하고 출력을 `CODE_REVIEW.md` 검증 결과 섹션에 붙여 넣으세요. 계획과 다르게 구현한 부분은 반드시 `계획 대비 변경 사항`에 기록하세요. ## 배경 IOP 노드 스캐폴드가 완성되었으나 테스트 파일이 없다. transport 레이어(frame/codec)와 핵심 컴포넌트(mock adapter, router)는 외부 의존성 없이 순수하게 테스트 가능한 구조이므로 유닛 테스트를 추가해 회귀 방지 기반을 만든다. --- ### [TEST-1] transport/frame 유닛 테스트 **문제** [apps/node/internal/transport/frame.go:16-46](apps/node/internal/transport/frame.go)의 `readFrame` / `writeFrame` 함수가 4-byte length-prefix 프레이밍을 올바르게 수행하는지 검증하는 테스트가 없다. maxFrameSize 초과 케이스도 미검증. **해결 방법** `package transport` (화이트박스) 테스트 파일을 생성한다. unexported 함수를 같은 패키지에서 직접 호출한다. ```go // apps/node/internal/transport/frame_test.go (신규) package transport import ( "bytes" "testing" ) func TestWriteReadFrame_roundtrip(t *testing.T) { payload := []byte(`{"type":"heartbeat","timestamp":1234}`) var buf bytes.Buffer if err := writeFrame(&buf, payload); err != nil { t.Fatal(err) } got, err := readFrame(&buf) if err != nil { t.Fatal(err) } if !bytes.Equal(got, payload) { t.Errorf("round-trip mismatch: got %q, want %q", got, payload) } } func TestWriteReadFrame_empty(t *testing.T) { var buf bytes.Buffer if err := writeFrame(&buf, []byte{}); err != nil { t.Fatal(err) } got, err := readFrame(&buf) if err != nil { t.Fatal(err) } if len(got) != 0 { t.Errorf("expected empty payload, got %d bytes", len(got)) } } func TestReadFrame_tooLarge(t *testing.T) { // 헤더에 maxFrameSize+1 을 직접 기록 var buf bytes.Buffer hdr := make([]byte, 4) // encoding/binary 없이 직접 big-endian 기록 sz := uint32(maxFrameSize + 1) hdr[0] = byte(sz >> 24) hdr[1] = byte(sz >> 16) hdr[2] = byte(sz >> 8) hdr[3] = byte(sz) buf.Write(hdr) _, err := readFrame(&buf) if err == nil { t.Error("expected error for oversized frame, got nil") } } ``` **수정 파일 및 체크리스트** - [ ] `apps/node/internal/transport/frame_test.go` 신규 생성 - [ ] `TestWriteReadFrame_roundtrip` 작성 - [ ] `TestWriteReadFrame_empty` 작성 - [ ] `TestReadFrame_tooLarge` 작성 **테스트 작성** 이 항목 자체가 테스트 작성 작업이므로 별도 추가 테스트 없음. **중간 검증** ```bash go test ./apps/node/internal/transport/... -run TestWriteReadFrame -v go test ./apps/node/internal/transport/... -run TestReadFrame -v ``` 기대 결과: 3개 테스트 PASS --- ### [TEST-2] transport/codec 유닛 테스트 **문제** [apps/node/internal/transport/codec.go:10-29](apps/node/internal/transport/codec.go)의 `encodeEnvelope` / `decodeEnvelope` / `encodePayload` / `decodePayload` 함수에 테스트 없음. JSON 인코딩 라운드트립과 잘못된 JSON 입력 시 에러 반환 여부가 미검증. **해결 방법** ```go // apps/node/internal/transport/codec_test.go (신규) package transport import ( "testing" "iop/packages/protocol" ) func TestEncodeDecodeEnvelope_roundtrip(t *testing.T) { orig := &protocol.Envelope{ ProtocolVersion: protocol.ProtocolVersion, RequestID: "req-001", RunID: "run-abc", Type: protocol.TypeHeartbeat, Payload: []byte(`{"timestamp":999}`), } data, err := encodeEnvelope(orig) if err != nil { t.Fatal(err) } got, err := decodeEnvelope(data) if err != nil { t.Fatal(err) } if got.RequestID != orig.RequestID { t.Errorf("RequestID: got %q, want %q", got.RequestID, orig.RequestID) } if got.RunID != orig.RunID { t.Errorf("RunID: got %q, want %q", got.RunID, orig.RunID) } if got.Type != orig.Type { t.Errorf("Type: got %q, want %q", got.Type, orig.Type) } } func TestDecodeEnvelope_invalidJSON(t *testing.T) { _, err := decodeEnvelope([]byte(`not-json`)) if err == nil { t.Error("expected error for invalid JSON, got nil") } } func TestEncodeDecodePayload_roundtrip(t *testing.T) { type inner struct { Foo string `json:"foo"` Bar int `json:"bar"` } orig := inner{Foo: "hello", Bar: 42} data, err := encodePayload(orig) if err != nil { t.Fatal(err) } var got inner if err := decodePayload(data, &got); err != nil { t.Fatal(err) } if got.Foo != orig.Foo || got.Bar != orig.Bar { t.Errorf("payload mismatch: got %+v, want %+v", got, orig) } } ``` **수정 파일 및 체크리스트** - [ ] `apps/node/internal/transport/codec_test.go` 신규 생성 - [ ] `TestEncodeDecodeEnvelope_roundtrip` 작성 - [ ] `TestDecodeEnvelope_invalidJSON` 작성 - [ ] `TestEncodeDecodePayload_roundtrip` 작성 **테스트 작성** 이 항목 자체가 테스트 작성 작업. **중간 검증** ```bash go test ./apps/node/internal/transport/... -run TestEncodeDecodeEnvelope -v go test ./apps/node/internal/transport/... -run TestDecodeEnvelope -v go test ./apps/node/internal/transport/... -run TestEncodeDecodePayload -v ``` 기대 결과: 3개 테스트 PASS --- ### [TEST-3] mock adapter 유닛 테스트 **문제** [apps/node/internal/adapters/mock/mock.go:38-72](apps/node/internal/adapters/mock/mock.go)의 `Execute` 함수가 start → delta+ → complete 순서로 이벤트를 방출하는지, context cancel 시 조기 종료하는지 미검증. **해결 방법** `collectSink` 헬퍼 타입으로 이벤트를 수집한 뒤 순서와 타입을 검증한다. ```go // apps/node/internal/adapters/mock/mock_test.go (신규) package mock import ( "context" "testing" "time" "go.uber.org/zap" "iop/apps/node/internal/runtime" ) type collectSink struct { events []runtime.RuntimeEvent } func (c *collectSink) Emit(_ context.Context, e runtime.RuntimeEvent) error { c.events = append(c.events, e) return nil } func TestMockExecute_sequence(t *testing.T) { m := New(zap.NewNop()) sink := &collectSink{} spec := runtime.ExecutionSpec{ RunID: "test-run-001", Adapter: Name, Input: map[string]any{"prompt": "hello world"}, } if err := m.Execute(context.Background(), spec, sink); err != nil { t.Fatalf("Execute: %v", err) } if len(sink.events) < 2 { t.Fatalf("expected at least 2 events, got %d", len(sink.events)) } // 첫 이벤트는 반드시 start if sink.events[0].Type != runtime.EventTypeStart { t.Errorf("first event: got %q, want %q", sink.events[0].Type, runtime.EventTypeStart) } // 마지막 이벤트는 반드시 complete last := sink.events[len(sink.events)-1] if last.Type != runtime.EventTypeComplete { t.Errorf("last event: got %q, want %q", last.Type, runtime.EventTypeComplete) } // complete 이벤트에 Usage 포함 여부 if last.Usage == nil { t.Error("complete event: expected Usage, got nil") } // RunID 일관성 for i, e := range sink.events { if e.RunID != spec.RunID { t.Errorf("event[%d].RunID: got %q, want %q", i, e.RunID, spec.RunID) } } } func TestMockExecute_cancel(t *testing.T) { m := New(zap.NewNop()) sink := &collectSink{} spec := runtime.ExecutionSpec{ RunID: "test-cancel-001", Adapter: Name, Input: map[string]any{"prompt": "long long long long long long long long prompt"}, } ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() err := m.Execute(ctx, spec, sink) // context cancel or deadline exceeded 는 정상 종료 케이스 if err != nil && err != context.Canceled && err != context.DeadlineExceeded { t.Errorf("unexpected error: %v", err) } } func TestMockCapabilities(t *testing.T) { m := New(zap.NewNop()) caps, err := m.Capabilities(context.Background()) if err != nil { t.Fatal(err) } if caps.AdapterName != Name { t.Errorf("AdapterName: got %q, want %q", caps.AdapterName, Name) } if len(caps.Models) == 0 { t.Error("expected at least one model") } if caps.MaxConcurrency <= 0 { t.Error("expected positive MaxConcurrency") } } ``` **수정 파일 및 체크리스트** - [ ] `apps/node/internal/adapters/mock/mock_test.go` 신규 생성 - [ ] `collectSink` 헬퍼 타입 작성 - [ ] `TestMockExecute_sequence` 작성 - [ ] `TestMockExecute_cancel` 작성 - [ ] `TestMockCapabilities` 작성 **테스트 작성** 이 항목 자체가 테스트 작성 작업. **중간 검증** ```bash go test ./apps/node/internal/adapters/mock/... -v ``` 기대 결과: 3개 테스트 PASS (cancel 테스트는 50ms 내 종료) --- ### [TEST-4] router 유닛 테스트 **문제** [apps/node/internal/router/router.go:24-47](apps/node/internal/router/router.go)의 `Resolve` 함수가 adapter 이름 미지정 시 default 선택, 명시 시 해당 어댑터 선택, 미등록 어댑터 요청 시 에러 반환하는지 미검증. **해결 방법** ```go // apps/node/internal/router/router_test.go (신규) package router import ( "context" "testing" "go.uber.org/zap" "iop/apps/node/internal/adapters" "iop/apps/node/internal/adapters/mock" "iop/apps/node/internal/runtime" ) func newTestRegistry() *adapters.Registry { reg := adapters.NewRegistry() reg.Register(mock.New(zap.NewNop())) return reg } func TestRouter_resolveDefault(t *testing.T) { r := New(newTestRegistry(), zap.NewNop()) spec, err := r.Resolve(context.Background(), runtime.RunRequest{ RunID: "r-001", // Adapter 비워두면 default 선택 }) if err != nil { t.Fatalf("Resolve: %v", err) } if spec.Adapter != mock.Name { t.Errorf("Adapter: got %q, want %q", spec.Adapter, mock.Name) } if spec.RunID != "r-001" { t.Errorf("RunID: got %q, want %q", spec.RunID, "r-001") } } func TestRouter_resolveExplicit(t *testing.T) { r := New(newTestRegistry(), zap.NewNop()) spec, err := r.Resolve(context.Background(), runtime.RunRequest{ RunID: "r-002", Adapter: mock.Name, Model: "mock-echo", }) if err != nil { t.Fatalf("Resolve: %v", err) } if spec.Adapter != mock.Name { t.Errorf("Adapter: got %q, want %q", spec.Adapter, mock.Name) } if spec.Model != "mock-echo" { t.Errorf("Model: got %q, want %q", spec.Model, "mock-echo") } } func TestRouter_resolveUnknown(t *testing.T) { r := New(newTestRegistry(), zap.NewNop()) _, err := r.Resolve(context.Background(), runtime.RunRequest{ RunID: "r-003", Adapter: "nonexistent-adapter", }) if err == nil { t.Error("expected error for unknown adapter, got nil") } } func TestRouter_emptyRegistry(t *testing.T) { r := New(adapters.NewRegistry(), zap.NewNop()) _, err := r.Resolve(context.Background(), runtime.RunRequest{RunID: "r-004"}) if err == nil { t.Error("expected error for empty registry, got nil") } } ``` **수정 파일 및 체크리스트** - [ ] `apps/node/internal/router/router_test.go` 신규 생성 - [ ] `newTestRegistry` 헬퍼 작성 - [ ] `TestRouter_resolveDefault` 작성 - [ ] `TestRouter_resolveExplicit` 작성 - [ ] `TestRouter_resolveUnknown` 작성 - [ ] `TestRouter_emptyRegistry` 작성 **테스트 작성** 이 항목 자체가 테스트 작성 작업. **중간 검증** ```bash go test ./apps/node/internal/router/... -v ``` 기대 결과: 4개 테스트 PASS --- ## 의존 관계 및 구현 순서 TEST-1, TEST-2 는 외부 iop 패키지 의존성이 없어 독립 실행 가능. TEST-3 은 `go.uber.org/zap` 의존 → `go mod tidy` 완료 후 실행. TEST-4 는 mock 패키지 의존 → TEST-3 파일 존재 후 실행. 권장 순서: TEST-1 → TEST-2 → TEST-3 → TEST-4 ## 수정 파일 요약 | 파일 | 항목 | |------|------| | `apps/node/internal/transport/frame_test.go` | TEST-1 | | `apps/node/internal/transport/codec_test.go` | TEST-2 | | `apps/node/internal/adapters/mock/mock_test.go` | TEST-3 | | `apps/node/internal/router/router_test.go` | TEST-4 | ## 최종 검증 ```bash # 의존성 먼저 해결 go mod tidy # 전체 테스트 go test ./apps/node/internal/transport/... \ ./apps/node/internal/adapters/mock/... \ ./apps/node/internal/router/... \ -v -count=1 # 전체 빌드 깨짐 없음 확인 go build ./... ``` 기대 결과: 전체 테스트 PASS, 빌드 에러 없음