메인 콘텐츠로 건너뛰기
Sonamu의 Vitest 기반 테스트 시스템과 구조를 알아봅니다.

테스트 시스템 개요

Vitest

빠른 테스트 실행Vite 통합

Context 기반

인증 테스트권한 시뮬레이션

Transaction

자동 롤백격리된 테스트

Fixture 시스템

테스트 데이터재사용 가능

Vitest 설정

Sonamu 프로젝트의 테스트 환경은 vitest.config.tsglobal.ts로 구성됩니다.

vitest.config.ts

Sonamu는 getSonamuTestConfig() 함수를 제공하여 테스트 설정을 간편하게 구성할 수 있습니다.
api/vitest.config.ts
import { getSonamuTestConfig } from "sonamu/test";
import { defineConfig } from "vitest/config";

export default defineConfig(async () => ({
  test: await getSonamuTestConfig({
    include: ["src/**/*.test.ts"],
    exclude: ["**/node_modules/**", "**/dist/**"],
    globals: true,
    globalSetup: ["./src/testing/global.ts"],
  }),
}));

getSonamuTestConfig 옵션

getSonamuTestConfig()는 Vitest의 모든 옵션을 지원하며, Sonamu에 최적화된 기본값을 제공합니다.
옵션설명기본값
include테스트 파일 패턴["src/**/*.test.ts"]
exclude제외할 파일 패턴["**/node_modules/**"]
globals전역 API 사용 여부true
globalSetup전역 설정 파일-
setupFiles각 테스트 파일 전에 실행할 설정-

global.ts 설정

global.ts는 모든 테스트 실행 전에 한 번 실행되는 전역 설정 파일입니다. Sonamu의 setup 함수를 export하여 테스트 환경을 초기화합니다.
api/src/testing/global.ts
import dotenv from "dotenv";

dotenv.config();

// sonamu.config.ts의 test 설정을 기반으로 테스트 환경을 구성합니다.
export { setup } from "sonamu/test";
export { setup } from "sonamu/test"sonamu.config.tstest 설정을 읽어 병렬 테스트 환경(다중 테스트 DB)을 자동으로 구성합니다.

고급 설정 예시

커스텀 시퀀서나 리포터를 추가한 고급 설정 예시입니다.
api/vitest.config.ts
import { getSonamuTestConfig, NaiteVitestReporter } from "sonamu/test";
import { defineConfig } from "vitest/config";
import { PrioritySequencer } from "./custom-sequencer";

export default defineConfig(async () => ({
  plugins: [],
  test: await getSonamuTestConfig({
    include: ["src/**/*.test.ts"],
    exclude: ["src/**/*.test-hold.ts", "**/node_modules/**", "**/.yarn/**", "**/dist/**"],
    globals: true,
    globalSetup: ["./src/testing/global.ts"],
    setupFiles: ["./src/testing/setup-mocks.ts"],
    sequence: {
      sequencer: PrioritySequencer,  // 커스텀 테스트 순서 제어
    },
    reporters: ["default", NaiteVitestReporter],  // Naite 리포터
    restoreMocks: true,
    typecheck: {
      enabled: true,
      tsconfig: "./tsconfig.json",
      include: ["src/**/*type-safety.test.ts"],
    },
    coverage: {
      provider: "v8",
      reporter: ["text", "html"],
      include: ["src/**/*.ts"],
      exclude: ["**/*.test.ts", "**/testing/**", "**/node_modules/**", "**/dist/**"],
    },
    includeTaskLocation: true,
    server: {
      deps: {
        inline: ["sonamu"],
      },
    },
  }),
}));

bootstrap 함수

Sonamu는 bootstrap 함수로 테스트 환경을 초기화합니다.
// api/src/application/sonamu.test.ts
import { bootstrap, test } from "sonamu/test";
import { expect, vi } from "vitest";

// 테스트 환경 초기화
bootstrap(vi);

test("사용자 생성", async () => {
  // 테스트 코드
});

bootstrap의 역할

// sonamu/src/testing/bootstrap.ts
export function bootstrap(vi: VitestUtils) {
  beforeAll(async () => {
    // Sonamu 테스트 모드로 초기화
    await Sonamu.initForTesting();
  });
  
  beforeEach(async () => {
    // 각 테스트마다 Transaction 시작
    await DB.createTestTransaction();
  });
  
  afterEach(async () => {
    // 타이머 리셋
    vi.useRealTimers();
    
    // Transaction 롤백 (자동 정리)
    await DB.clearTestTransaction();
  });
  
  afterAll(() => {
    // 정리 작업
  });
}
주요 기능:
  1. Sonamu 초기화: 테스트 모드로 프레임워크 초기화
  2. Transaction 관리: 각 테스트마다 자동 롤백
  3. 타이머 리셋: Vitest의 fake timers 초기화
  4. 테스트 리포팅: Naite 시스템에 결과 전달

test 함수

Sonamu는 Vitest의 test를 래핑한 커스텀 test 함수를 제공합니다.

기본 사용법

import { test } from "sonamu/test";
import { expect } from "vitest";
import { UserModel } from "@/models/user.model";

test("사용자 생성", async () => {
  const userModel = new UserModel();
  const { user } = await userModel.create({
    username: "john",
    email: "john@example.com",
    password: "password123",
  });
  
  expect(user.id).toBeGreaterThan(0);
  expect(user.username).toBe("john");
});

test("사용자 조회", async () => {
  const userModel = new UserModel();
  
  // 테스트 데이터 생성
  const { user } = await userModel.create({
    username: "jane",
    email: "jane@example.com",
    password: "password123",
  });
  
  // 조회
  const { user: found } = await userModel.getUser("C", user.id);
  
  expect(found.id).toBe(user.id);
  expect(found.username).toBe("jane");
});

Context 주입

test 함수는 자동으로 Mock Context를 주입합니다.
// 내부 동작
function getMockContext(): Context {
  return {
    ip: "127.0.0.1",
    session: {},
    user: null,  // 기본값: 로그인하지 않은 상태
    passport: {
      login: async () => {},
      logout: () => {},
    },
    naiteStore: Naite.createStore(),
  };
}

export const test = async (title: string, fn: TestFunction) => {
  return vitestTest(title, async (context) => {
    await runWithMockContext(async () => {
      await fn(context);
    });
  });
};

Mock Context 전체 속성

Mock Context는 실제 HTTP 요청 Context와 동일한 인터페이스를 제공합니다:

기본 속성

속성타입Mock 값설명
ipstring"127.0.0.1"클라이언트 IP 주소
userAgentstring"test-agent"User-Agent 헤더
originstring"http://localhost"요청 Origin
refererstring | undefinedundefinedReferer 헤더
acceptLanguagestring"ko-KR"Accept-Language 헤더

인증 관련

속성타입Mock 값설명
userUser | nullnull인증된 사용자 (testAs로 설정)
sessionRecord<string, any>{}세션 데이터
passportPassportMock 객체인증 헬퍼

요청 메타데이터

속성타입Mock 값설명
requestIdstring자동 생성 UUID요청 고유 ID
timestampDate현재 시각요청 시각
methodstring"GET"HTTP 메서드
pathstring"/test"요청 경로

테스트 전용

속성타입Mock 값설명
naiteStoreNaiteStoreNaite.createStore()Naite 로그 저장소
isMockbooleantrueMock Context 여부

Passport 객체 상세

interface Passport {
  // 로그인 (testAs에서 자동 호출됨)
  login: (user: User) => Promise<void>;

  // 로그아웃
  logout: () => void;

  // 세션 저장
  save: () => Promise<void>;

  // 세션 ID
  sessionID?: string;
}

// Mock Passport (test 함수)
const mockPassport: Passport = {
  login: async (user) => {
    // Mock: 아무 동작 안함
  },
  logout: () => {
    // Mock: 아무 동작 안함
  },
  save: async () => {
    // Mock: 아무 동작 안함
  },
  sessionID: undefined,
};

Context 사용 예제

import { test } from "sonamu/test";
import { Sonamu } from "sonamu";
import { expect } from "vitest";

test("Context 속성 접근", async () => {
  const context = Sonamu.getContext();

  // 기본 속성
  expect(context.ip).toBe("127.0.0.1");
  expect(context.userAgent).toBe("test-agent");
  expect(context.origin).toBe("http://localhost");
  expect(context.acceptLanguage).toBe("ko-KR");

  // 인증 상태
  expect(context.user).toBeNull();  // test()는 비인증 상태
  expect(context.session).toEqual({});

  // 메타데이터
  expect(context.requestId).toBeDefined();
  expect(context.timestamp).toBeInstanceOf(Date);
  expect(context.method).toBe("GET");
  expect(context.path).toBe("/test");

  // 테스트 전용
  expect(context.isMock).toBe(true);
  expect(context.naiteStore).toBeDefined();
});

testAs에서의 Context

import { testAs } from "sonamu/test";
import { Sonamu } from "sonamu";
import { expect } from "vitest";

testAs(
  { id: 1, username: "testuser", role: "admin" },
  "인증된 Context 확인",
  async () => {
    const context = Sonamu.getContext();

    // user가 설정됨
    expect(context.user).not.toBeNull();
    expect(context.user?.id).toBe(1);
    expect(context.user?.username).toBe("testuser");
    expect(context.user?.role).toBe("admin");

    // passport도 user를 포함
    expect(context.passport).toBeDefined();

    // 나머지는 동일
    expect(context.ip).toBe("127.0.0.1");
    expect(context.isMock).toBe(true);
  }
);

커스텀 Context 설정

특수한 경우 Context를 직접 설정할 수 있습니다:
import { test } from "sonamu/test";
import { Sonamu } from "sonamu";

test("커스텀 IP 설정", async () => {
  const context = Sonamu.getContext();

  // Context 속성 직접 수정 (주의: 권장하지 않음)
  (context as any).ip = "192.168.1.100";
  (context as any).userAgent = "Custom Agent";

  // 수정된 Context 사용
  console.log(context.ip);  // "192.168.1.100"
});
주의사항:
  1. Mock Context는 테스트용: 실제 HTTP 요청과 다를 수 있음
  2. Passport 동작 안함: login(), logout() 호출은 무시됨
  3. 세션 유지 안됨: 테스트 종료 시 모든 상태 초기화
  4. Context 수정 비권장: 직접 수정보다 testAs() 사용 권장
Context 속성 요약:
  • 기본: ip, userAgent, origin, referer, acceptLanguage
  • 인증: user, session, passport
  • 메타데이터: requestId, timestamp, method, path
  • 테스트: naiteStore, isMock

testAs 함수

특정 사용자로 인증된 상태에서 테스트할 수 있습니다.

기본 사용법

import { testAs } from "sonamu/test";
import { expect } from "vitest";
import { PostModel } from "@/models/post.model";

testAs(
  { id: 1, username: "admin", role: "admin" },  // 인증된 사용자
  "관리자는 모든 게시글을 삭제할 수 있다",
  async () => {
    const postModel = new PostModel();
    
    // Context.user가 위의 사용자로 설정됨
    await postModel.deletePost(123);
    
    // 삭제 확인
    const deleted = await postModel.findById("C", 123);
    expect(deleted).toBeNull();
  }
);

testAs(
  { id: 2, username: "user", role: "user" },  // 일반 사용자
  "일반 사용자는 자신의 게시글만 삭제할 수 있다",
  async () => {
    const postModel = new PostModel();
    
    // Context.user.id === 2인 상태
    await expect(
      postModel.deletePost(999)  // 다른 사용자의 게시글
    ).rejects.toThrow("권한이 없습니다");
  }
);

권한 테스트

import { testAs } from "sonamu/test";

// 관리자 테스트
testAs(
  { id: 1, role: "admin" },
  "관리자는 사용자 목록을 볼 수 있다",
  async () => {
    const userModel = new UserModel();
    const { users } = await userModel.getUsers({ page: 1, pageSize: 10 });
    
    expect(users.length).toBeGreaterThan(0);
  }
);

// 일반 사용자 테스트
testAs(
  { id: 2, role: "user" },
  "일반 사용자는 사용자 목록을 볼 수 없다",
  async () => {
    const userModel = new UserModel();
    
    await expect(
      userModel.getUsers({ page: 1, pageSize: 10 })
    ).rejects.toThrow();
  }
);

test.skip, test.only, test.todo

Vitest의 기능들을 그대로 사용할 수 있습니다.
import { test } from "sonamu/test";

// 스킵
test.skip("아직 구현되지 않은 테스트", async () => {
  // 이 테스트는 실행되지 않음
});

// 이 테스트만 실행
test.only("이 테스트만 실행", async () => {
  // 다른 테스트는 무시되고 이것만 실행됨
});

// TODO 표시
test.todo("나중에 작성할 테스트");

test.each

여러 입력값으로 동일한 테스트를 반복할 수 있습니다.
import { test } from "sonamu/test";
import { expect } from "vitest";

test.each([
  { input: "user1@example.com", expected: true },
  { input: "invalid-email", expected: false },
  { input: "user@domain", expected: false },
  { input: "user@example.co.kr", expected: true },
])("이메일 검증: $input → $expected", async ({ input, expected }) => {
  const isValid = validateEmail(input);
  expect(isValid).toBe(expected);
});

Transaction 자동 롤백

각 테스트는 독립된 Transaction에서 실행되고 자동으로 롤백됩니다.
import { test } from "sonamu/test";

test("사용자 생성 테스트", async () => {
  const userModel = new UserModel();
  
  // 데이터베이스에 데이터 삽입
  await userModel.create({
    username: "test-user",
    email: "test@example.com",
    password: "password",
  });
  
  // 테스트 종료 후 자동으로 롤백됨
  // → 데이터베이스는 깨끗한 상태 유지
});

test("다음 테스트는 깨끗한 DB에서 시작", async () => {
  const userModel = new UserModel();
  
  // 이전 테스트의 "test-user"는 없음
  const { users } = await userModel.getUsers({ page: 1, pageSize: 10 });
  expect(users.find(u => u.username === "test-user")).toBeUndefined();
});
장점:
  • 테스트 간 격리
  • 데이터 정리 불필요
  • 빠른 실행 (실제 INSERT/DELETE 없음)

테스트 파일 구조

// api/src/models/user.model.test.ts
import { bootstrap, test, testAs } from "sonamu/test";
import { expect, vi } from "vitest";
import { UserModel } from "./user.model";

// 1. bootstrap으로 테스트 환경 초기화
bootstrap(vi);

// 2. 테스트 그룹 (describe는 선택사항)
test("사용자 생성", async () => {
  const userModel = new UserModel();
  const { user } = await userModel.create({
    username: "john",
    email: "john@example.com",
    password: "password123",
  });
  
  expect(user.id).toBeGreaterThan(0);
  expect(user.username).toBe("john");
});

test("중복 이메일 검증", async () => {
  const userModel = new UserModel();
  
  // 첫 번째 사용자 생성
  await userModel.create({
    username: "user1",
    email: "duplicate@example.com",
    password: "password",
  });
  
  // 같은 이메일로 다시 생성 시도
  await expect(
    userModel.create({
      username: "user2",
      email: "duplicate@example.com",
      password: "password",
    })
  ).rejects.toThrow("이미 존재하는 이메일입니다");
});

testAs(
  { id: 1, role: "admin" },
  "관리자는 사용자를 삭제할 수 있다",
  async () => {
    const userModel = new UserModel();
    
    // 사용자 생성
    const { user } = await userModel.create({
      username: "temp",
      email: "temp@example.com",
      password: "password",
    });
    
    // 삭제
    await userModel.deleteUser(user.id);
    
    // 확인
    const deleted = await userModel.findById("C", user.id);
    expect(deleted).toBeNull();
  }
);

Context 접근

테스트 내에서 Context를 직접 사용할 수 있습니다.
import { test } from "sonamu/test";
import { Sonamu } from "sonamu";

test("Context에서 사용자 정보 확인", async () => {
  const context = Sonamu.getContext();
  
  // Mock context이므로 user는 null
  expect(context.user).toBeNull();
  expect(context.ip).toBe("127.0.0.1");
});
import { testAs } from "sonamu/test";
import { Sonamu } from "sonamu";

testAs(
  { id: 1, username: "admin" },
  "인증된 Context 확인",
  async () => {
    const context = Sonamu.getContext();
    
    expect(context.user).not.toBeNull();
    expect(context.user?.id).toBe(1);
    expect(context.user?.username).toBe("admin");
  }
);

비동기 테스트

모든 테스트는 async 함수여야 합니다.
import { test } from "sonamu/test";

// ✅ 올바른 사용
test("비동기 테스트", async () => {
  const result = await someAsyncFunction();
  expect(result).toBe("expected");
});

// ❌ 잘못된 사용
test("동기 테스트", () => {  // async 없음!
  const result = someAsyncFunction();  // await 없음!
  expect(result).toBe("expected");  // Promise 객체와 비교됨
});

주의사항

테스트 작성 시 주의사항:
  1. bootstrap(vi) 필수: 테스트 파일마다 호출
  2. async 필수: 모든 테스트 함수는 async
  3. Transaction 기반: 테스트 종료 후 자동 롤백
  4. Context 주입: test/testAs가 자동으로 Context 설정
  5. 격리된 테스트: 테스트 간 의존성 없어야 함

다음 단계