테스트 시스템 개요
Vitest
빠른 테스트 실행Vite 통합
Context 기반
인증 테스트권한 시뮬레이션
Transaction
자동 롤백격리된 테스트
Fixture 시스템
테스트 데이터재사용 가능
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: "[email protected]",
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: "[email protected]",
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);
});
});
};
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: "[email protected]", expected: true },
{ input: "invalid-email", expected: false },
{ input: "user@domain", expected: false },
{ input: "[email protected]", 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: "[email protected]",
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: "[email protected]",
password: "password123",
});
expect(user.id).toBeGreaterThan(0);
expect(user.username).toBe("john");
});
test("중복 이메일 검증", async () => {
const userModel = new UserModel();
// 첫 번째 사용자 생성
await userModel.create({
username: "user1",
email: "[email protected]",
password: "password",
});
// 같은 이메일로 다시 생성 시도
await expect(
userModel.create({
username: "user2",
email: "[email protected]",
password: "password",
})
).rejects.toThrow("이미 존재하는 이메일입니다");
});
testAs(
{ id: 1, role: "admin" },
"관리자는 사용자를 삭제할 수 있다",
async () => {
const userModel = new UserModel();
// 사용자 생성
const { user } = await userModel.create({
username: "temp",
email: "[email protected]",
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 설정
- 격리된 테스트: 테스트 간 의존성 없어야 함
