์ธ๋ถ API, ํ์ผ ์์คํ
, ํ์ด๋จธ ๋ฑ์ ์์กด์ฑ์ ๋ชจํนํ์ฌ ๋
๋ฆฝ์ ์ธ ํ
์คํธ๋ฅผ ์์ฑํ๋ ๋ฐฉ๋ฒ์ ์์๋ด
๋๋ค.
API Mocking์ด๋?
ํ
์คํธ์์ ์ธ๋ถ ์์กด์ฑ(API, ํ์ผ ์์คํ
, ์๊ฐ ๋ฑ)์ ์ค์ ๋ก ์คํํ์ง ์๊ณ ๊ฐ์ง ๋์์ ์ ๊ณตํ๋ ๊ฒ์ Mocking์ด๋ผ๊ณ ํฉ๋๋ค. ์ด๋ฅผ ํตํด:
- ์ธ๋ถ ์๋น์ค ์ฅ์ ์ ๋ฌด๊ดํ๊ฒ ํ
์คํธ ์คํ
- ๋คํธ์ํฌ ํธ์ถ ์์ด ๋น ๋ฅธ ํ
์คํธ
- ์์ธก ๊ฐ๋ฅํ ๊ฒฐ๊ณผ๋ก ์์ ์ ์ธ ํ
์คํธ
- ๋น์ฉ์ด ๋ฐ์ํ๋ ์์
(๊ฒฐ์ , SMS ๋ฑ) ํํผ
Mocking์ ์ข
๋ฅ
Sonamu์์๋ ์ธ ๊ฐ์ง ๋ฐฉ์์ Mocking์ ์ง์ํฉ๋๋ค:
Vitest Mock
Vitest์ ๊ธฐ๋ณธ Mocking ๊ธฐ๋ฅ
Naite Mock
Naite๋ฅผ ํ์ฉํ ๋์ Mocking
Manual Mock
์๋์ผ๋ก ๊ตฌํํ Mock ๊ฐ์ฒด
Vitest Mock ์ฌ์ฉํ๊ธฐ
๋ชจ๋ Mocking
์ธ๋ถ ๋ชจ๋์ ๋์์ ๊ฐ์ง๋ก ๋์ฒดํฉ๋๋ค:
import { test, vi } from "vitest";
import axios from "axios";
// axios ๋ชจ๋ ์ ์ฒด๋ฅผ Mock์ผ๋ก ๋์ฒด
vi.mock("axios");
test("์ธ๋ถ API ํธ์ถ", async () => {
// Mock ํจ์์ ๋ฐํ๊ฐ ์ค์
vi.mocked(axios.get).mockResolvedValue({
data: { id: 1, name: "John" },
});
const result = await userService.fetchUser(1);
expect(result.name).toBe("John");
expect(axios.get).toHaveBeenCalledWith("/api/users/1");
});
ํจ์ Spy
์ค์ ํจ์์ ํธ์ถ์ ์ถ์ ํฉ๋๋ค:
import { test, vi } from "vitest";
import { emailService } from "../services/email.service";
test("์ด๋ฉ์ผ ๋ฐ์ก ํ์ธ", async () => {
// ํจ์ ํธ์ถ์ ์ถ์ ํ๋ Spy ์์ฑ
const sendSpy = vi.spyOn(emailService, "send").mockResolvedValue(true);
await userService.register({ email: "[email protected]" });
// ์ด๋ฉ์ผ ๋ฐ์ก ํจ์๊ฐ ํธ์ถ๋์๋์ง ํ์ธ
expect(sendSpy).toHaveBeenCalledWith({
to: "[email protected]",
subject: "ํ์๊ฐ์
์ ํ์ํฉ๋๋ค",
});
// Spy ์ ๋ฆฌ
sendSpy.mockRestore();
});
ํ์ด๋จธ Mocking
์๊ฐ ๊ด๋ จ ํจ์๋ฅผ ์ ์ดํฉ๋๋ค:
import { test, vi } from "vitest";
test("setTimeout ํ
์คํธ", async () => {
// ๊ฐ์ง ํ์ด๋จธ ์ฌ์ฉ
vi.useFakeTimers();
let executed = false;
setTimeout(() => {
executed = true;
}, 1000);
// 1์ด ์
expect(executed).toBe(false);
// ์๊ฐ 1์ด ์งํ
vi.advanceTimersByTime(1000);
// 1์ด ํ
expect(executed).toBe(true);
// ์ค์ ํ์ด๋จธ๋ก ๋ณต์
vi.useRealTimers();
});
bootstrap()๊ณผ ํ์ด๋จธ: Sonamu์ bootstrap() ํจ์๋ afterEach์์ ์๋์ผ๋ก vi.useRealTimers()๋ฅผ ํธ์ถํ์ฌ ํ์ด๋จธ๋ฅผ ๋ณต์ํฉ๋๋ค. ๋ณ๋๋ก ํธ์ถํ ํ์๊ฐ ์์ต๋๋ค.
๋ ์ง Mocking
ํ์ฌ ์๊ฐ์ ๊ณ ์ ํฉ๋๋ค:
import { test, vi } from "vitest";
test("์ค๋ ๋ ์ง ํ์ธ", () => {
// 2025๋
1์ 1์ผ๋ก ๊ณ ์
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
const today = new Date();
expect(today.getFullYear()).toBe(2025);
expect(today.getMonth()).toBe(0); // 0 = January
// ์์คํ
์๊ฐ ๋ณต์
vi.useRealTimers();
});
Naite Mock ์ฌ์ฉํ๊ธฐ
Naite์ ํน๋ณํ ํค ํจํด์ ์ฌ์ฉํ์ฌ ๋์ ์ผ๋ก Mock์ ์์ฑํฉ๋๋ค.
ํ์ผ ์์คํ
Mocking
๊ฐ์ ํ์ผ ์์ฑ
mock:fs/promises:virtualFileSystem ํค๋ก ๊ฐ์ ํ์ผ์ ๋ฑ๋กํฉ๋๋ค:
import { test } from "sonamu/test";
import { Naite } from "sonamu";
import { access, constants } from "fs/promises";
import { expect } from "vitest";
test("๊ฐ์ ํ์ผ ์์คํ
", async () => {
const filePath = "/tmp/my-virtual-file.txt";
// 1. ์ค์ ๋ก๋ ์กด์ฌํ์ง ์๋ ํ์ผ
await expect(
access(filePath, constants.F_OK)
).rejects.toThrow();
// 2. Naite๋ก ๊ฐ์ ํ์ผ ๋ฑ๋ก
Naite.t("mock:fs/promises:virtualFileSystem", filePath);
// 3. ์ด์ ํ์ผ์ด "์กด์ฌ"ํ๋ ๊ฒ์ฒ๋ผ ๋์
await expect(
access(filePath, constants.F_OK)
).resolves.toBeUndefined();
// 4. Mock ์ญ์
Naite.del("mock:fs/promises:virtualFileSystem");
// 5. ๋ค์ ์กด์ฌํ์ง ์์
await expect(
access(filePath, constants.F_OK)
).rejects.toThrow();
});
์ค์ ์์ : ํ์ผ ์
๋ก๋ ํ
์คํธ
import { test } from "sonamu/test";
import { Naite } from "sonamu";
import { fileService } from "../services/file.service";
import { expect } from "vitest";
test("ํ์ผ ์กด์ฌ ์ฌ๋ถ ํ์ธ", async () => {
const uploadPath = "/uploads/user/123/profile.jpg";
// ๊ฐ์ ํ์ผ ๋ฑ๋ก
Naite.t("mock:fs/promises:virtualFileSystem", uploadPath);
// ํ์ผ ์๋น์ค ํ
์คํธ
const exists = await fileService.checkExists(uploadPath);
expect(exists).toBe(true);
// Mock ์ ๋ฆฌ
Naite.del("mock:fs/promises:virtualFileSystem");
});
test("์ฌ๋ฌ ํ์ผ ์กด์ฌ ํ์ธ", async () => {
const files = [
"/uploads/file1.pdf",
"/uploads/file2.pdf",
"/uploads/file3.pdf",
];
// ์ฌ๋ฌ ํ์ผ ๋ฑ๋ก
for (const file of files) {
Naite.t("mock:fs/promises:virtualFileSystem", file);
}
// ๋ฐฐ์น ํ์ธ
const results = await fileService.checkMultiple(files);
expect(results.every(r => r.exists)).toBe(true);
// ์ ๋ฆฌ
Naite.del("mock:fs/promises:virtualFileSystem");
});
Naite Mock์ ์๋ ์๋ฆฌ
Naite๋ ํน๋ณํ ํค ํจํด(mock:*)์ ๊ฐ์งํ์ฌ ํด๋น ๋์์ ๊ฐ๋ก์ฑ๋๋ค:
// Sonamu ๋ด๋ถ (์์ฌ ์ฝ๋)
async function access(path: string) {
// Naite์์ mock:fs/promises:virtualFileSystem ํ์ธ
const mocked = Naite.get("mock:fs/promises:virtualFileSystem")
.result()
.includes(path);
if (mocked) {
// ๊ฐ์ ํ์ผ์ด ๋ฑ๋ก๋์ด ์์ผ๋ฉด ์ฑ๊ณต
return Promise.resolve();
}
// ์ค์ ํ์ผ ์์คํ
ํ์ธ
return realAccess(path);
}
Manual Mock ๊ตฌํํ๊ธฐ
๋ณต์กํ ์ธ๋ถ ์๋น์ค๋ ์ง์ Mock ํด๋์ค๋ฅผ ๊ตฌํํฉ๋๋ค.
Mock ํด๋์ค ์์ฑ
// __mocks__/payment.service.ts
export class MockPaymentService {
private payments: Map<string, any> = new Map();
async charge(amount: number, userId: number) {
const paymentId = `mock_${Date.now()}`;
this.payments.set(paymentId, {
id: paymentId,
amount,
userId,
status: "success",
createdAt: new Date(),
});
return {
success: true,
paymentId,
};
}
async getPayment(paymentId: string) {
return this.payments.get(paymentId) ?? null;
}
async refund(paymentId: string) {
const payment = this.payments.get(paymentId);
if (!payment) {
throw new Error("๊ฒฐ์ ์ ๋ณด๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค");
}
payment.status = "refunded";
return { success: true };
}
// ํ
์คํธ์ฉ ํฌํผ
clear() {
this.payments.clear();
}
}
Mock ์ฌ์ฉํ๊ธฐ
import { test, beforeEach } from "vitest";
import { MockPaymentService } from "./__mocks__/payment.service";
import { orderService } from "../services/order.service";
let mockPayment: MockPaymentService;
beforeEach(() => {
mockPayment = new MockPaymentService();
// ์ค์ ์๋น์ค๋ฅผ Mock์ผ๋ก ๊ต์ฒด
orderService.paymentService = mockPayment as any;
});
test("์ฃผ๋ฌธ ๊ฒฐ์ ", async () => {
const order = await orderService.create({
userId: 1,
items: [{ productId: 1, quantity: 2 }],
totalAmount: 50000,
});
// Mock ๊ฒฐ์ ์คํ
const result = await orderService.processPayment(order.id);
expect(result.success).toBe(true);
expect(result.paymentId).toBeDefined();
// Mock์ ๊ธฐ๋ก๋ ๊ฒฐ์ ํ์ธ
const payment = await mockPayment.getPayment(result.paymentId);
expect(payment?.amount).toBe(50000);
expect(payment?.status).toBe("success");
});
test("๊ฒฐ์ ์คํจ ์๋๋ฆฌ์ค", async () => {
// Mock์์ ์๋ฌ ๋ฐ์ํ๋๋ก ์ค์
mockPayment.charge = async () => {
throw new Error("์นด๋ ์น์ธ ์คํจ");
};
const order = await orderService.create({
userId: 1,
items: [{ productId: 1, quantity: 1 }],
totalAmount: 10000,
});
await expect(
orderService.processPayment(order.id)
).rejects.toThrow("์นด๋ ์น์ธ ์คํจ");
});
์ค์ ํ์ฉ ์์
์ธ๋ถ API ํธ์ถ Mocking
import { test, vi } from "vitest";
import axios from "axios";
import { weatherService } from "../services/weather.service";
vi.mock("axios");
test("๋ ์จ ์ ๋ณด ์กฐํ", async () => {
// API ์๋ต Mock
vi.mocked(axios.get).mockResolvedValue({
data: {
temperature: 25,
condition: "๋ง์",
humidity: 60,
},
});
const weather = await weatherService.getCurrentWeather("Seoul");
expect(weather.temperature).toBe(25);
expect(weather.condition).toBe("๋ง์");
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining("Seoul")
);
});
test("API ์๋ฌ ์ฒ๋ฆฌ", async () => {
// ๋คํธ์ํฌ ์๋ฌ ์๋ฎฌ๋ ์ด์
vi.mocked(axios.get).mockRejectedValue(
new Error("Network Error")
);
await expect(
weatherService.getCurrentWeather("Seoul")
).rejects.toThrow("๋ ์จ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ฌ ์ ์์ต๋๋ค");
});
SMS ๋ฐ์ก Mocking
import { test, vi } from "vitest";
import { smsService } from "../services/sms.service";
import { userService } from "../services/user.service";
test("ํ์๊ฐ์
SMS ๋ฐ์ก", async () => {
// SMS ๋ฐ์ก ํจ์ Mock
const sendSpy = vi
.spyOn(smsService, "send")
.mockResolvedValue({ success: true, messageId: "mock_123" });
await userService.register({
phone: "010-1234-5678",
name: "ํ๊ธธ๋",
});
// SMS ๋ฐ์ก ํ์ธ
expect(sendSpy).toHaveBeenCalledWith({
to: "010-1234-5678",
message: expect.stringContaining("์ธ์ฆ๋ฒํธ"),
});
sendSpy.mockRestore();
});
ํ์ผ ์
๋ก๋ Mocking
import { test } from "sonamu/test";
import { Naite } from "sonamu";
import { imageService } from "../services/image.service";
import { expect } from "vitest";
test("์ด๋ฏธ์ง ์
๋ก๋ ๋ฐ ์ฒ๋ฆฌ", async () => {
const originalPath = "/tmp/upload/original.jpg";
const thumbnailPath = "/tmp/upload/thumbnail.jpg";
// ๊ฐ์ ํ์ผ ๋ฑ๋ก
Naite.t("mock:fs/promises:virtualFileSystem", originalPath);
// ์ด๋ฏธ์ง ์ฒ๋ฆฌ (์ธ๋ค์ผ ์์ฑ)
await imageService.createThumbnail(originalPath, thumbnailPath);
// ์ธ๋ค์ผ๋ ๊ฐ์์ผ๋ก ์์ฑ๋์๋ค๊ณ ๊ฐ์
Naite.t("mock:fs/promises:virtualFileSystem", thumbnailPath);
// ๋ ํ์ผ ๋ชจ๋ ์กด์ฌ ํ์ธ
const exists = await imageService.checkBothExist(
originalPath,
thumbnailPath
);
expect(exists).toBe(true);
// ์ ๋ฆฌ
Naite.del("mock:fs/promises:virtualFileSystem");
});
ํ๊ฒฝ ๋ณ์ Mocking
import { test, vi } from "vitest";
import { configService } from "../services/config.service";
test("API ํค ์ค์ ", () => {
// ํ๊ฒฝ ๋ณ์ Mock
vi.stubEnv("API_KEY", "test_key_12345");
vi.stubEnv("API_URL", "https://test.api.com");
const config = configService.load();
expect(config.apiKey).toBe("test_key_12345");
expect(config.apiUrl).toBe("https://test.api.com");
// ํ๊ฒฝ ๋ณ์ ๋ณต์
vi.unstubAllEnvs();
});
Mocking ์ ๋ต
๊ณ์ธต๋ณ Mocking
ํ
์คํธ ๋ ๋ฒจ๋ณ Mocking:
-
Unit ํ
์คํธ: ๋ชจ๋ ์์กด์ฑ Mock
test("Service ๋จ์ ํ
์คํธ", async () => {
// Repository์ External API ๋ชจ๋ Mock
const mockRepo = { findById: vi.fn() };
const mockApi = { fetch: vi.fn() };
const service = new UserService(mockRepo, mockApi);
await service.getUser(1);
});
-
Integration ํ
์คํธ: ์ธ๋ถ ์์กด์ฑ๋ง Mock
test("Service + Repository ํตํฉ ํ
์คํธ", async () => {
// External API๋ง Mock, DB๋ ์ค์ ์ฌ์ฉ
vi.mock("axios");
const user = await userService.getUser(1);
});
-
E2E ํ
์คํธ: Mocking ์ต์ํ
test("์ ์ฒด ํ๋ฆ ํ
์คํธ", async () => {
// ๋น์ฉ์ด ๋ฐ์ํ๋ ์์
๋ง Mock (๊ฒฐ์ , SMS ๋ฑ)
vi.spyOn(paymentService, "charge").mockResolvedValue({
success: true,
});
const response = await request(app).post("/api/orders");
});
Mock ๋ฐ์ดํฐ ๊ด๋ฆฌ
ํ
์คํธ์ฉ Mock ๋ฐ์ดํฐ๋ฅผ ๋ณ๋ ํ์ผ๋ก ๊ด๋ฆฌํฉ๋๋ค:
// __fixtures__/users.ts
export const mockUsers = {
admin: {
id: 1,
username: "admin",
role: "admin",
email: "[email protected]",
},
user: {
id: 2,
username: "user",
role: "user",
email: "[email protected]",
},
guest: {
id: 3,
username: "guest",
role: "guest",
email: "[email protected]",
},
};
// __fixtures__/api-responses.ts
export const mockApiResponses = {
weather: {
success: {
temperature: 25,
condition: "๋ง์",
},
error: {
error: "API_ERROR",
message: "๋ ์จ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ฌ ์ ์์ต๋๋ค",
},
},
payment: {
success: {
success: true,
transactionId: "txn_12345",
},
failure: {
success: false,
error: "INSUFFICIENT_FUNDS",
},
},
};
์ฌ์ฉ:
import { mockUsers } from "./__fixtures__/users";
import { mockApiResponses } from "./__fixtures__/api-responses";
test("๊ด๋ฆฌ์ ๊ถํ ํ
์คํธ", async () => {
vi.mocked(userService.findById).mockResolvedValue(mockUsers.admin);
const hasPermission = await authService.checkAdmin(1);
expect(hasPermission).toBe(true);
});
test("๋ ์จ API ์ฑ๊ณต", async () => {
vi.mocked(axios.get).mockResolvedValue({
data: mockApiResponses.weather.success,
});
const weather = await weatherService.get("Seoul");
expect(weather.temperature).toBe(25);
});
์ฃผ์์ฌํญ
Mocking ์ ์ฃผ์์ฌํญ:
-
๊ณผ๋ํ Mocking ์ง์: Mock์ด ๋๋ฌด ๋ง์ผ๋ฉด ์ค์ ๋์๊ณผ ๊ดด๋ฆฌ๊ฐ ์๊น๋๋ค. ๊ผญ ํ์ํ ๋ถ๋ถ๋ง Mockํ์ธ์.
-
Mock ์ ๋ฆฌ:
afterEach์์ Mock์ ์ ๋ฆฌํ์ง ์์ผ๋ฉด ๋ค๋ฅธ ํ
์คํธ์ ์ํฅ์ ์ค ์ ์์ต๋๋ค.
afterEach(() => {
vi.restoreAllMocks();
Naite.del("mock:*");
});
-
Mock๊ณผ ์ค์ ๋์ ์ผ์น: Mock์ ๋์์ด ์ค์ ์๋น์ค์ ๋ค๋ฅด๋ฉด ํ
์คํธ๋ ํต๊ณผํด๋ ์ด์ ํ๊ฒฝ์์ ์คํจํ ์ ์์ต๋๋ค.
-
ํ์
์์ ์ฑ: Mock์๋ ํ์
์ ๋ช
์ํ์ฌ ๋ฐํ์ ์๋ฌ๋ฅผ ๋ฐฉ์งํ์ธ์.
// โ ํ์
์์
vi.mocked(service.method).mockResolvedValue({});
// โ
ํ์
๋ช
์
vi.mocked(service.method).mockResolvedValue({
id: 1,
name: "test",
} as User);
-
Naite Mock ๋ฒ์:
mock:* ํค๋ ์ ์ญ์ ์ผ๋ก ์๋ํ๋ฏ๋ก ํ
์คํธ ์ข
๋ฃ ์ ๋ฐ๋์ ์ญ์ ํ์ธ์.
๋ค์ ๋จ๊ณ