
지금까지 우리는 fluo DI가 내부적으로 어떻게 동작하는지 그 흐름을 처음부터 끝까지 추적했다. Decorator metadata, token/provider 모델, module graph, bootstrap, resolution planning, instance creation, scope/disposal까지 따라왔다면 이제 자연스럽게 이런 질문이 남는다.
이렇게 명시적으로 쌓아 올린 DI 구조는 테스트에서 어떤 이점을 만들까?
이 글은 그 질문에 대한 답을 정리하는 일종의 부록이다. . 핵심은 단순히 테스트 API를 제공한다는 데 있지 않다. fluo는 production 환경에서 사용하던 dependency graph를 거의 그대로 유지하면서도, 테스트에 필요한 경계만 선택적으로 교체할 수 있도록 설계되어 있다.
덕분에 테스트를 위해 별도의 애플리케이션 구조를 다시 조립할 필요가 없다. 실제 실행 환경과 거의 동일한 graph 위에서 테스트를 수행하되, 외부 API·DB·시간·랜덤 값처럼 통제해야 하는 지점만 명시적으로 override하면 된다.
그리고 이런 특성은 testing package의 편의 기능에서 나온 것이 아니다. 지금까지 살펴본 token 기반 resolution, provider graph, module composition 같은 DI 구조가 그대로 테스트 레이어까지 이어진 결과라고 할 수 있다.
1. 테스트가 graph에서 시작한다는 것
fluo의 module-level 테스트는 rootModule에서 시작한다.
const module = await createTestingModule({ rootModule: AppModule })
// 실제 repository 대신 mock provider를 등록해 service 로직만 검증한다.
.overrideProvider(USER_REPOSITORY, mockRepo)
.compile();이 API에서 중요한 부분은 createTestingModule()이라는 이름보다 { rootModule: AppModule }이다. 테스트가 임의의 class 묶음이나 reflection metadata에서 시작하지 않고, 애플리케이션이 실제로 사용하는 module graph에서 시작한다는 뜻이기 때문이다.
앞선 module graph 글에서 봤듯이 fluo는 provider를 “어딘가에 등록된 class”로 보지 않는다. provider는 특정 module boundary 안에 등록되고, imports/exports 관계를 통해 visibility가 결정된다. 따라서 테스트도 같은 질문을 던진다.
- 이 provider는 실제 module graph 안에서 보이는가?
- 이 service가 의존하는 token은 같은 graph에서 해석되는가?
- 이 feature module은 production bootstrap과 같은 모양으로 컴파일되는가?
단위 테스트에서는 class를 직접 만들면 된다. 하지만 module boundary, provider visibility, override wiring이 계약이라면 graph를 컴파일해야 한다. createTestingModule({ rootModule })은 그 위치에 있다.
2. Override는 graph를 망가뜨리는 것이 아니라 경계를 바꾸는 것
테스트에서 fake를 넣는 방식은 크게 두 가지가 있다. 첫째, class를 직접 만들고 fake dependency를 constructor에 넘기는 방식이다. 이건 빠르고 단순한 unit test에 좋다.
const repo = createMock<UserRepository>({
// 테스트가 관심 있는 method만 지정하고 나머지는 mock helper에 맡긴다.
findById: vi.fn().mockResolvedValue({ id: '1', name: 'Ada' }),
});
const service = new UserService(repo);둘째, 실제 module graph를 컴파일하되 특정 token만 fake로 바꾸는 방식이다.
const module = await createTestingModule({ rootModule: UsersModule })
// DI wiring은 유지하되 외부 저장소 provider만 테스트 더블로 바꾼다.
.overrideProvider(UserRepository, repo)
.compile();
const service = await module.resolve(UserService);두 방식의 차이는 “DI container를 쓰느냐”가 아니다. 무엇을 검증하려는지의 차이다.
- 순수 service branch를 보고 싶다 → 직접 생성한다.
- provider registration과 module wiring까지 보고 싶다 → graph를 컴파일하고 override한다.
fluo에서 override가 자연스럽게 읽히는 이유는 dependency가 token으로 명시되어 있기 때문이다. 숨겨진 design metadata를 바꾸는 것이 아니라, graph 안의 특정 token 해석을 교체한다. 그래서 테스트 코드만 봐도 “어떤 경계가 production에서 fake로 바뀌었는지”가 드러난다.
3. Module override도 graph shape를 잃지 않기 위한 장치다
가끔은 provider 하나가 아니라 module 전체를 바꾸고 싶다. 예를 들어 billing feature는 그대로 테스트하되 외부 결제 provider를 가진 StripeModule만 fake module로 바꾸고 싶을 수 있다.
const module = await createTestingModule({ rootModule: AppModule })
// 결제 SDK처럼 무거운 경계는 module 단위로 fake 구현으로 교체한다.
.overrideModule(StripeModule, FakeStripeModule)
.compile();여기서도 핵심은 “테스트 전용 wrapper module로 완전히 새 graph를 만든다”가 아니다. fluo의 overrideModule(source, replacement)는 imported module edge를 바꾸되, authored root module과 compiled module identity를 최대한 보존한다.
이 점은 DI 시리즈의 module graph 문맥에서 중요하다. 테스트가 production과 다른 이름의 graph를 보게 되면 diagnostics, graph assertion, module boundary가 실제 애플리케이션과 달라진다. fluo는 module replacement를 허용하면서도 테스트가 바라보는 graph shape는 production에 가깝게 유지한다.
즉 module override는 편의를 위해 graph를 지우는 기능이 아니라, production graph의 의미를 유지한 채 특정 외부 경계만 바꾸기 위한 기능이다.
4. resolve()와 get()은 같은 container 의미 위에 있다
compile()이 끝나면 테스트는 provider를 꺼낼 수 있다. resolve()는 production container의 비동기 resolution path와 가장 가깝다. Async factory, request scope, singleton cache, circular dependency handling 같은 실제 DI 경로를 그대로 탄다.
const service = await module.resolve(UserService);
const config = module.get(ConfigService);get()은 테스트 편의를 위한 동기 resolver다. 다만 “대충 새로 만드는 shortcut”은 아니다. 현재 fluo testing은 동기적으로 설명 가능한 singleton graph에 대해 get()과 resolve()가 같은 instance identity를 공유하도록 맞춰져 있다. get()이 먼저 singleton을 만들면 이후 resolve()도 같은 객체를 보고, resolve()가 먼저 실행된 뒤에도 sync-resolvable singleton은 get() 쪽으로 승격될 수 있다.
반대로 async factory나 async graph는 여전히 get() 대상이 아니다. 그런 provider는 resolve()를 사용해야 한다. 이 경계 덕분에 테스트는 편리한 동기 접근을 얻으면서도, production에서 비동기인 dependency를 우연히 동기처럼 다루지 않는다.
이 역시 앞에서 본 DI 구조의 연장이다. fluo의 container는 provider kind, scope, cache를 명시적으로 알고 있기 때문에 testing layer도 “동기적으로 안전한 graph”와 “비동기 resolution이 필요한 graph”를 구분할 수 있다.
5. Request test는 scope와 lifecycle을 확인하는 위치다
request scope와 disposal까지 살펴본 뒤라면 HTTP 테스트도 다르게 보인다. createTestApp()은 단순히 “서버 없이 요청 보내는 helper”가 아니다. 실제 runtime bootstrap과 dispatcher를 사용하면서, 테스트용 request context middleware를 앞에 추가한다.
const app = await createTestApp({ rootModule: AppModule });
const response = await app
.request('POST', '/users/')
// principal()은 인증 guard를 통과한 사용자 컨텍스트를 테스트에서 직접 주입한다.
.principal({ subject: 'user-1', roles: ['admin'] })
.body({ name: 'Ada' })
.send();
expect(response.status).toBe(201);
await app.close();이 경로는 controller method를 직접 호출하는 것과 다르다. Guard, interceptor, DTO validation, request context, response serialization 같은 request-facing behavior가 dispatcher를 통과한다. 즉 request scope가 실제로 열리고 닫히는 위치를 검증할 수 있다.
앞선 scope/disposal 글에서 본 것처럼 request-local cache, child scope, reverse-order cleanup은 단순한 내부 구현이 아니다. request pipeline을 테스트할 때 “요청마다 독립된 dependency lifetime이 유지되는가”라는 신뢰로 이어진다.
그래서 일반 애플리케이션 테스트에서는 app.request(...).send()가 기본 경로다. dispatch()나 raw request/response stub은 adapter/runtime contract 자체를 검증해야 하는 낮은 수준의 테스트에 남겨 두는 편이 좋다.
6. Mock helper는 DI를 대체하지 않는다
createMock()과 createDeepMock()은 테스트 setup을 줄여 주지만, fluo의 DI 의미를 숨기지는 않는다.
import { createMock, createDeepMock } from '@fluojs/testing/mock';
// createMock은 얕은 mock, createDeepMock은 중첩 객체 method까지 mock이 필요할 때 쓴다.
const repo = createMock<UserRepository>({ findById: vi.fn() });
const mailer = createDeepMock(MailService);이 mock들은 어디까지나 fake object를 쉽게 만들기 위한 도구다. Unit test에서는 constructor에 직접 넘기고, module test에서는 overrideProvider()나 mockToken()을 통해 graph 안에 넣는다.
중요한 점은 mock이 “자동으로 주입되는 것”이 아니라는 점이다. fluo는 테스트에서도 dependency boundary를 명시한다. 어떤 token을 어떤 fake로 바꿨는지 테스트 코드에 남는다. 이 명시성이 테스트 실패를 읽기 쉽게 만든다.
마치며
숨겨진 reflection에 기대는 프레임워크에서는 테스트가 종종 production과 다른 setup을 만든다. 반대로 fluo는 rootModule, token, provider, module graph, scope를 테스트에서도 그대로 드러낸다. 그래서 테스트는 production graph를 흉내 내는 별도 세계가 아니라, production graph를 필요한 만큼 작게 열어 보는 작업에 가깝다.
결국 fluo에서 테스트하기 쉽다는 말은 “mock API가 많다”는 뜻이 아니다. Dependency boundary가 명시적이고, graph shape가 보존되며, lifetime이 예측 가능하기 때문에 테스트가 쉬워진다는 뜻이다.
더 읽어보기
2026.05.10
Instance Creation — resolve에서 new까지
이전 포스트에서 우리는 “어느 provider를 어느 캐시에 저장할 것인가”를 결정하는 Planning 레이어를 살펴봤다. 이번 파트에서는 그 다음 단계, 즉 실제 인스턴스를 생성하는 Execution 레이어를 따라간다. 출발점은 container.resolve(token)이고, 마지막…
2026.05.09
Resolution Planning — 조회 캐시와 스코프 라우팅
앞선 파트에서 bootstrapModule()이 모듈 그래프를 컴파일하고 provider를 Container에 등록하는 과정을 살펴봤다. 이번 파트에서는 그 다음 단계, 즉 “이 토큰을 어떻게 찾고, 어느 캐시에 저장하며, 어떤 스코프로 라우팅할 것인가”를 결정하는 Resolution…
2026.05.09
Bootstrap & Lifecycle — 앱이 살아나는 6단계
bootstrapModule — 동기식 기반 작업 bootstrap의 출발점은 동기 함수 bootstrapModule()이다. 이 함수는 그래프 컴파일과 컨테이너 등록을 책임진다. 여기에는 비동기가 없다. lifecycle hook이나 HTTP 연결 같은 부수 효과는 상위 레이어가 담당…
2026.05.08
Module Graph 컴파일 — DFS, 가시성 검증, 에러 설계
컴파일의 전체 흐름 모듈 그래프 컴파일은 compileModuleGraph() 하나로 시작된다. export function compileModuleGraph(rootModule: ModuleType, options: BootstrapModuleOptions = {}): Compiled…
2026.05.11
Ecosystem — DI 위에서 동작하는 패키지들
프레임워크의 DI는 core 패키지 안에서만 의미가 있으면 부족하다. 실제 가치는 다른 패키지들이 같은 규칙 위에 올라갈 때 드러난다. CQRS는 compiled module graph를 읽어 handler를 발견하고, Config는 dynamic module과 lifecycle hoo…
2026.05.11
store와 state가 함께 만드는 상태 미들웨어 파이프라인
상태 관리 코어를 작게 유지하려고 하면 곧바로 한 가지 질문을 만나게 된다. 상태를 읽고, 바꾸고, 구독하게 해주는 것만으로 충분한가. 처음에는 충분해 보인다. 하지만 실제 애플리케이션으로 들어가면 업데이트를 기록하고 싶고, 잘못된 값은 막고 싶고, 들어온 값을 정규화하고 싶고, 상태가…
2026.05.10
프레임워크 밖의 상태를 UI 안으로 들이는 법
외부 상태는 프레임워크 내부에서 만들어진 상태가 아니다. React의 useState, Vue의 ref, Svelte의 $state, Solid의 createSignal처럼 프레임워크가 직접 소유하고 추적하는 값이 아니라, 프레임워크 바깥에서 먼저 존재하고 바깥에서 변하는 값이다. We…
2026.05.10
Scope & Disposal — 인스턴스의 생애주기와 정리
이전 포스트를 통해 우리는 인스턴스가 어떻게 만들어지는지 살펴봤다. 이번 파트는 그 반대 방향, 인스턴스가 어떻게 살고 어떻게 사라지는가를 다룬다. fluo DI의 세 가지 스코프(singleton, request, transient)의 생애주기와 dispose()의 역순 정리 메커니즘…
댓글
댓글을 불러오는 중...