naite-like
v0.1.1
Published
A test framework that embeds test traces in production code using AsyncLocalStorage
Maintainers
Readme
Naite (나이테)
나이테(Naite)는 프로덕션 코드 내에 주석과 동일한 형태로 테스트 코드를 삽입하고, 함수를 실행하는 과정에서 이 값들을 관찰하는 방식으로 코드를 테스트하는 테스트 프레임워크입니다.
나이테는 AsyncLocalStorage를 사용해 이를 달성하며, Vite 혹은 esbuild의 dead code elimination으로 테스트 코드가 삭제되므로 라이브 코드에는 영향을 주지 않습니다.
크레딧 및 면책 조항
이 테스트 프레임워크는 Minsang Kim의 글 테스트를 위해 코드를 쪼개지 마세요: Naite와 함께하는 관찰 기반 테스팅를 기반으로, AI를 활용하여 만든 프레임워크입니다.
면책 조항: 이 구현체는 원작자의 공식 릴리스가 아닌 독립적인 프로젝트입니다. 추후 공식 Naite가 공개될 경우 이 프로젝트는 deprecated될 예정입니다.
주요 특징
- 함수가 완성되지 않아도 테스트 가능 - 구현 중인 함수도 중간 단계까지 검증
- 중간 과정을 바로 검증 - 함수 내부 실행 흐름을 트레이스로 확인
- 함수 쪼개기 강박 사라짐 - 테스트를 위해 함수를 무리하게 분리할 필요 없음
- 컨텍스트 유지 - AsyncLocalStorage로 테스트 간 격리 보장
- 의존성 제로 - Node.js 내장 모듈만 사용, 런타임 의존성 없음
설치
# npm
npm install naite-like
# pnpm
pnpm add naite-like
# yarn
yarn add naite-like요구사항: Node.js >= 12.17.0
빠른 시작
1. 프로덕션 코드에 트레이스 삽입
import { Naite } from 'naite-like';
export async function queryComplexResult(params: QueryParams): Promise<QueryResult> {
Naite.t('step', '첫번째 쿼리 생성');
const query1 = buildFirstQuery(params);
Naite.t('query1', query1);
Naite.t('step', '두번째 쿼리 생성');
const query2 = buildSecondQuery(params);
Naite.t('query2', query2);
Naite.t('step', '최종 결과 계산');
const result = await calculateResult(query1, query2);
Naite.t('result', result);
return result;
}2. 테스트 작성
import { describe, it, expect } from 'vitest';
import { Naite, runWithContext } from 'naite-like';
import { queryComplexResult } from './query.js';
describe('QueryTest', () => {
it('should generate queries in correct order', async () => {
await runWithContext(async () => {
try {
await queryComplexResult({ userId: 123 });
} catch (error) {
// 에러가 나도 상관없음!
} finally {
// 실행 흐름을 검증
expect(Naite.get('step').last()).toBe('최종 결과 계산');
expect(Naite.get('query1').first()).toBeDefined();
expect(Naite.get('query2').first()).toBeDefined();
}
});
});
});API 문서
Naite
메인 클래스로, 트레이스 기록 및 조회를 담당합니다.
Naite.t(key: string, value: unknown): void
트레이스를 기록합니다. NODE_ENV=test일 때만 작동합니다.
Naite.t('userId', 123);
Naite.t('step', 'processing data');
Naite.t('result', { success: true });Naite.get(keyPattern: string): NaiteQuery
키 또는 와일드카드 패턴으로 트레이스를 조회합니다.
// 정확한 키 매칭
Naite.get('userId').first(); // 123
// 와일드카드 패턴
Naite.get('syncer:*').result(); // 모든 syncer:* 트레이스
Naite.get('syncer:*:user').result(); // syncer:{any}:user 형태Naite.getAll(): Record<string, unknown[]>
모든 트레이스를 객체 형태로 반환합니다.
const all = Naite.getAll();
// { userId: [123], step: ['processing data'], ... }Naite.del(key: string): void
특정 키의 트레이스를 삭제합니다.
Naite.del('userId');Naite.createStore(): NaiteStore
새로운 빈 스토어를 생성합니다 (고급 사용).
NaiteQuery
체이닝 방식으로 트레이스를 필터링하고 조회합니다.
.result(): unknown[]
모든 데이터를 배열로 반환합니다.
Naite.get('key').result(); // [value1, value2, value3].first(): unknown | undefined
첫 번째 데이터를 반환합니다.
Naite.get('key').first(); // value1.last(): unknown | undefined
마지막 데이터를 반환합니다.
Naite.get('key').last(); // value3.at(index: number): unknown | undefined
n번째 데이터를 반환합니다 (0-based).
Naite.get('key').at(1); // value2.fromFile(fileName: string): NaiteQuery
특정 파일에서 기록된 트레이스만 필터링합니다.
Naite.get('trace')
.fromFile('syncer.test.ts')
.result();.fromFunction(funcName: string, options?): NaiteQuery
특정 함수에서 기록된 트레이스만 필터링합니다.
// 모든 호출 (직접 + 간접)
Naite.get('trace').fromFunction('renderTemplate').result();
// 직접 호출만
Naite.get('trace').fromFunction('renderTemplate', { from: 'direct' }).result();
// 간접 호출만
Naite.get('trace').fromFunction('renderTemplate', { from: 'indirect' }).result();.where(path: string, operator: ComparisonOperator, value: unknown): NaiteQuery
데이터 필드 조건으로 필터링합니다.
// 같음
Naite.get('user').where('data.userId', '=', 123).result();
// 비교 연산자
Naite.get('count').where('data.value', '>', 10).result();
// 문자열 포함
Naite.get('log').where('data.message', 'includes', 'error').result();지원 연산자: >, <, >=, <=, =, !=, includes
.getTraces(): NaiteTrace[]
원본 트레이스 객체 배열을 반환합니다 (디버깅용).
const traces = Naite.get('key').getTraces();
// [{ key, data, stack, at }, ...]컨텍스트 관리
runWithContext<T>(fn: () => T | Promise<T>): Promise<T>
새로운 Naite 컨텍스트를 생성하고 함수를 실행합니다.
await runWithContext(async () => {
Naite.t('key', 'value');
expect(Naite.get('key').first()).toBe('value');
});runWithExistingContext<T>(store: NaiteStore, fn: () => T | Promise<T>): Promise<T>
기존 스토어를 사용하여 함수를 실행합니다.
const store = Naite.createStore();
await runWithExistingContext(store, () => {
Naite.t('key1', 'value1');
});
await runWithExistingContext(store, () => {
Naite.t('key2', 'value2');
});
// store에는 key1, key2가 모두 존재getContext(): NaiteStore | undefined
현재 컨텍스트의 스토어를 반환합니다.
const store = getContext();
if (store) {
// 컨텍스트 내부
}고급 사용법
메서드 체이닝
여러 필터를 조합하여 정확한 트레이스를 찾을 수 있습니다.
const result = Naite.get('trace')
.fromFile('syncer.test.ts')
.fromFunction('renderTemplate')
.where('data.userId', '=', 123)
.first();와일드카드 패턴
계층적 키 구조에서 강력한 쿼리가 가능합니다.
// syncer:로 시작하는 모든 키
Naite.get('syncer:*').result();
// 중간에 와일드카드
Naite.get('syncer:*:user').result();
// 여러 와일드카드
Naite.get('*:*:user').result();스택 정보 활용
각 트레이스는 호출 스택 정보를 포함합니다.
const traces = Naite.get('key').getTraces();
traces.forEach(trace => {
console.log('Key:', trace.key);
console.log('Data:', trace.data);
console.log('Called from:', trace.stack[0].functionName);
console.log('File:', trace.stack[0].filePath);
console.log('Line:', trace.stack[0].lineNumber);
console.log('Timestamp:', trace.at);
});프로덕션 빌드에서 제거
Vite나 esbuild 사용 시, 자동으로 dead code elimination이 적용됩니다.
// vite.config.ts
export default defineConfig({
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
},
});프로덕션 빌드 시 Naite.t() 호출이 모두 제거됩니다.
사용 예제
예제 1: 복잡한 쿼리 함수 테스트
// src/query.ts
export async function buildComplexQuery(userId: number) {
Naite.t('step', 'Fetch user');
const user = await fetchUser(userId);
Naite.t('user', user);
if (!user) {
Naite.t('step', 'User not found');
throw new Error('User not found');
}
Naite.t('step', 'Build query');
const query = { ...user, timestamp: Date.now() };
Naite.t('query', query);
return query;
}
// tests/query.test.ts
it('should handle missing user', async () => {
await runWithContext(async () => {
try {
await buildComplexQuery(999);
} catch (error) {
expect(Naite.get('step').last()).toBe('User not found');
expect(Naite.get('user').first()).toBeNull();
}
});
});예제 2: 다중 트레이스 검증
// src/processor.ts
export function processItems(items: Item[]) {
items.forEach((item, index) => {
Naite.t('process:item', { index, id: item.id });
if (item.valid) {
Naite.t('process:valid', item.id);
} else {
Naite.t('process:invalid', item.id);
}
});
}
// tests/processor.test.ts
it('should process all items', async () => {
await runWithContext(() => {
processItems([
{ id: 1, valid: true },
{ id: 2, valid: false },
{ id: 3, valid: true },
]);
// 모든 아이템 처리 확인
expect(Naite.get('process:*').result()).toHaveLength(6);
// 유효한 아이템만
expect(Naite.get('process:valid').result()).toEqual([1, 3]);
// 무효한 아이템만
expect(Naite.get('process:invalid').result()).toEqual([2]);
});
});예제 3: 조건부 필터링
it('should filter by conditions', async () => {
await runWithContext(() => {
Naite.t('metric', { cpu: 45, memory: 512 });
Naite.t('metric', { cpu: 78, memory: 1024 });
Naite.t('metric', { cpu: 92, memory: 2048 });
// CPU 사용률이 80 이상인 메트릭
const highCpu = Naite.get('metric')
.where('data.cpu', '>=', 80)
.result();
expect(highCpu).toHaveLength(1);
expect(highCpu[0].cpu).toBe(92);
// 메모리가 1GB 이상인 메트릭
const highMemory = Naite.get('metric')
.where('data.memory', '>=', 1024)
.result();
expect(highMemory).toHaveLength(2);
});
});라이센스
MIT (https://github.com/cartanova-ai/sonamu/blob/ac2e3883629b9c12f10c945654aa5682a50fb529/modules/sonamu/package.json#L29 에 기반함)
