apps/node 중심 구현 — TCP+JSON transport, Hexagonal Architecture, mock/cli adapter, fx DI, SQLite 실행 이력 저장. edge/control-plane/worker는 cobra placeholder. 유닛 테스트 및 통합 테스트 클라이언트 계획서 추가.
12 KiB
Node 유닛 테스트 추가
이 파일을 읽는 구현 에이전트에게
각 체크리스트 항목을 완료한 뒤 [x]로 표시하세요.
중간 검증 명령을 실제로 실행하고 출력을 CODE_REVIEW.md 검증 결과 섹션에 붙여 넣으세요.
계획과 다르게 구현한 부분은 반드시 계획 대비 변경 사항에 기록하세요.
배경
IOP 노드 스캐폴드가 완성되었으나 테스트 파일이 없다. transport 레이어(frame/codec)와 핵심 컴포넌트(mock adapter, router)는 외부 의존성 없이 순수하게 테스트 가능한 구조이므로 유닛 테스트를 추가해 회귀 방지 기반을 만든다.
[TEST-1] transport/frame 유닛 테스트
문제
apps/node/internal/transport/frame.go:16-46의
readFrame / writeFrame 함수가 4-byte length-prefix 프레이밍을 올바르게 수행하는지
검증하는 테스트가 없다. maxFrameSize 초과 케이스도 미검증.
해결 방법
package transport (화이트박스) 테스트 파일을 생성한다.
unexported 함수를 같은 패키지에서 직접 호출한다.
// 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작성
테스트 작성
이 항목 자체가 테스트 작성 작업이므로 별도 추가 테스트 없음.
중간 검증
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의
encodeEnvelope / decodeEnvelope / encodePayload / decodePayload 함수에 테스트 없음.
JSON 인코딩 라운드트립과 잘못된 JSON 입력 시 에러 반환 여부가 미검증.
해결 방법
// 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작성
테스트 작성
이 항목 자체가 테스트 작성 작업.
중간 검증
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의
Execute 함수가 start → delta+ → complete 순서로 이벤트를 방출하는지,
context cancel 시 조기 종료하는지 미검증.
해결 방법
collectSink 헬퍼 타입으로 이벤트를 수집한 뒤 순서와 타입을 검증한다.
// 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작성
테스트 작성
이 항목 자체가 테스트 작성 작업.
중간 검증
go test ./apps/node/internal/adapters/mock/... -v
기대 결과: 3개 테스트 PASS (cancel 테스트는 50ms 내 종료)
[TEST-4] router 유닛 테스트
문제
apps/node/internal/router/router.go:24-47의
Resolve 함수가 adapter 이름 미지정 시 default 선택, 명시 시 해당 어댑터 선택,
미등록 어댑터 요청 시 에러 반환하는지 미검증.
해결 방법
// 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작성
테스트 작성
이 항목 자체가 테스트 작성 작업.
중간 검증
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 |
최종 검증
# 의존성 먼저 해결
go mod tidy
# 전체 테스트
go test ./apps/node/internal/transport/... \
./apps/node/internal/adapters/mock/... \
./apps/node/internal/router/... \
-v -count=1
# 전체 빌드 깨짐 없음 확인
go build ./...
기대 결과: 전체 테스트 PASS, 빌드 에러 없음