Sonamu의 Vitest 기반 테스트 시스템과 구조를 알아봅니다.
테스트 시스템 개요
Vitest 설정
Sonamu 프로젝트의 테스트 환경은 vitest.config.ts와 global.ts로 구성됩니다.
vitest.config.ts
Sonamu는 getSonamuTestConfig() 함수를 제공하여 테스트 설정을 간편하게 구성할 수 있습니다.
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.ts의 test 설정을 읽어 병렬 테스트 환경(다중 테스트 DB)을 자동으로 구성합니다.
고급 설정 예시
커스텀 시퀀서나 리포터를 추가한 고급 설정 예시입니다.
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(() => {
// 정리 작업
});
}
주요 기능:
- Sonamu 초기화: 테스트 모드로 프레임워크 초기화
- Transaction 관리: 각 테스트마다 자동 롤백
- 타이머 리셋: Vitest의 fake timers 초기화
- 테스트 리포팅: 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 값 | 설명 |
|---|
ip | string | "127.0.0.1" | 클라이언트 IP 주소 |
userAgent | string | "test-agent" | User-Agent 헤더 |
origin | string | "http://localhost" | 요청 Origin |
referer | string | undefined | undefined | Referer 헤더 |
acceptLanguage | string | "ko-KR" | Accept-Language 헤더 |
인증 관련
| 속성 | 타입 | Mock 값 | 설명 |
|---|
user | User | null | null | 인증된 사용자 (testAs로 설정) |
session | Record<string, any> | {} | 세션 데이터 |
passport | Passport | Mock 객체 | 인증 헬퍼 |
요청 메타데이터
| 속성 | 타입 | Mock 값 | 설명 |
|---|
requestId | string | 자동 생성 UUID | 요청 고유 ID |
timestamp | Date | 현재 시각 | 요청 시각 |
method | string | "GET" | HTTP 메서드 |
path | string | "/test" | 요청 경로 |
테스트 전용
| 속성 | 타입 | Mock 값 | 설명 |
|---|
naiteStore | NaiteStore | Naite.createStore() | Naite 로그 저장소 |
isMock | boolean | true | Mock 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"
});
주의사항:
- Mock Context는 테스트용: 실제 HTTP 요청과 다를 수 있음
- Passport 동작 안함:
login(), logout() 호출은 무시됨
- 세션 유지 안됨: 테스트 종료 시 모든 상태 초기화
- 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 객체와 비교됨
});
주의사항
테스트 작성 시 주의사항:
- bootstrap(vi) 필수: 테스트 파일마다 호출
- async 필수: 모든 테스트 함수는 async
- Transaction 기반: 테스트 종료 후 자동 롤백
- Context 주입: test/testAs가 자동으로 Context 설정
- 격리된 테스트: 테스트 간 의존성 없어야 함
다음 단계