메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°

ν…ŒμŠ€νŠΈ κΈ°λ³Έ

SonamuλŠ” Vitestλ₯Ό μ‚¬μš©ν•©λ‹ˆλ‹€.νŠΉμ§•:
  • Vite 기반으둜 맀우 λΉ λ₯Έ μ‹€ν–‰ 속도
  • Jest ν˜Έν™˜ API
  • TypeScript λ„€μ΄ν‹°λΈŒ 지원
  • HMR 지원
# λͺ¨λ“  ν…ŒμŠ€νŠΈ μ‹€ν–‰
pnpm test

# νŠΉμ • 파일만 μ‹€ν–‰
pnpm test user.model.test.ts

# Watch λͺ¨λ“œ (파일 λ³€κ²½ 감지)
pnpm test --watch

# Coverage 리포트
pnpm test --coverage

# UI λͺ¨λ“œ
pnpm test --ui
Scaffolding으둜 생성:
  1. Sonamu UI 접속
  2. Scaffolding νƒ­
  3. Entity 선택
  4. Template: model_test 선택
  5. Generate 클릭
μžλ™ μƒμ„±λ˜λŠ” 파일:
// user.model.test.ts
import { beforeAll, describe, expect, test } from "vitest";
import { UserModel } from "./user.model";

beforeAll(async () => {
  await UserModel.setTestData();
});

describe("UserModel", () => {
  test("findById", async () => {
    const user = await UserModel.findById(1);
    expect(user).toBeDefined();
    expect(user.id).toBe(1);
  });

  test("save", async () => {
    const user = await UserModel.save({
      name: "Test User",
      email: "test@example.com",
    });
    expect(user.id).toBeGreaterThan(0);
    expect(user.name).toBe("Test User");
  });
});

ν…ŒμŠ€νŠΈ 데이터

setTestData() λ©”μ„œλ“œ:
// user.model.ts
class UserModelClass extends BaseModelClass {
  static async setTestData() {
    await this.truncate();

    await this.bulkInsert([
      { id: 1, name: "John", email: "john@example.com" },
      { id: 2, name: "Jane", email: "jane@example.com" },
      { id: 3, name: "Bob", email: "bob@example.com" },
    ]);
  }
}
ν…ŒμŠ€νŠΈμ—μ„œ μ‚¬μš©:
import { beforeAll, describe, test } from "vitest";

beforeAll(async () => {
  await UserModel.setTestData();
  await PostModel.setTestData();
  await CommentModel.setTestData();
});

describe("UserModel", () => {
  test("findById", async () => {
    const user = await UserModel.findById(1);
    expect(user.name).toBe("John");
  });
});
beforeEach μ‚¬μš©:
import { beforeEach, describe, test } from "vitest";

beforeEach(async () => {
  await UserModel.setTestData();
});

describe("UserModel", () => {
  test("create user", async () => {
    const user = await UserModel.save({ name: "New User" });
    expect(user.id).toBeGreaterThan(3);
  });

  test("delete user", async () => {
    await UserModel.del(1);
    const user = await UserModel.findById(1);
    expect(user).toBeNull();
  });
});

λͺ¨λΈ ν…ŒμŠ€νŠΈ

describe("UserModel CRUD", () => {
  test("findById", async () => {
    const user = await UserModel.findById(1);
    expect(user).toBeDefined();
    expect(user.id).toBe(1);
  });

  test("findMany", async () => {
    const result = await UserModel.findMany("A", { num: 10, page: 1 });
    expect(result.rows.length).toBeGreaterThan(0);
    expect(result.total).toBeGreaterThan(0);
  });

  test("save - create", async () => {
    const user = await UserModel.save({
      name: "New User",
      email: "new@example.com",
    });
    expect(user.id).toBeGreaterThan(0);
    expect(user.name).toBe("New User");
  });

  test("save - update", async () => {
    const user = await UserModel.save({
      id: 1,
      name: "Updated Name",
    });
    expect(user.id).toBe(1);
    expect(user.name).toBe("Updated Name");
  });

  test("del", async () => {
    await UserModel.del(1);
    const user = await UserModel.findById(1);
    expect(user).toBeNull();
  });
});
describe("UserModel Subsets", () => {
  test("Subset A", async () => {
    const result = await UserModel.findMany("A", { num: 10, page: 1 });
    expect(result.rows[0]).toHaveProperty("id");
    expect(result.rows[0]).toHaveProperty("name");
  });

  test("Subset WithPosts", async () => {
    const result = await UserModel.findMany("WithPosts", { num: 10, page: 1 });
    expect(result.rows[0]).toHaveProperty("posts");
    expect(Array.isArray(result.rows[0].posts)).toBe(true);
  });

  test("Subset ν•„λ“œ 쑴재 확인", async () => {
    const user = await UserModel.findById(1, "WithProfile");
    expect(user).toHaveProperty("profile");
    expect(user.profile).toBeDefined();
  });
});
describe("User Relations", () => {
  test("User -> Posts (HasMany)", async () => {
    const user = await UserModel.findById(1, "WithPosts");
    expect(user.posts).toBeDefined();
    expect(Array.isArray(user.posts)).toBe(true);
    expect(user.posts.length).toBeGreaterThan(0);
  });

  test("Post -> User (BelongsToOne)", async () => {
    const post = await PostModel.findById(1, "WithAuthor");
    expect(post.author).toBeDefined();
    expect(post.author.id).toBe(post.user_id);
  });

  test("User -> Profile (OneToOne)", async () => {
    const user = await UserModel.findById(1, "WithProfile");
    expect(user.profile).toBeDefined();
    expect(user.profile.user_id).toBe(user.id);
  });
});

API ν…ŒμŠ€νŠΈ

describe("User API", () => {
  test("listUsers", async () => {
    const result = await UserModel.listUsers({ num: 10, page: 1 });
    expect(result.rows.length).toBeLessThanOrEqual(10);
    expect(result.total).toBeGreaterThan(0);
  });

  test("createUser", async () => {
    const user = await UserModel.createUser({
      name: "API Test User",
      email: "apitest@example.com",
    });
    expect(user.id).toBeGreaterThan(0);
    expect(user.name).toBe("API Test User");
  });

  test("updateUser", async () => {
    const user = await UserModel.updateUser(1, {
      name: "Updated via API",
    });
    expect(user.id).toBe(1);
    expect(user.name).toBe("Updated via API");
  });

  test("deleteUser", async () => {
    await UserModel.deleteUser(1);
    const user = await UserModel.findById(1);
    expect(user).toBeNull();
  });
});
import { BadRequestError, NotFoundError } from "sonamu";

describe("User API Errors", () => {
  test("createUser - 이메일 쀑볡", async () => {
    await expect(
      UserModel.createUser({
        name: "Test",
        email: "john@example.com", // 이미 쑴재
      }),
    ).rejects.toThrow(BadRequestError);
  });

  test("findById - μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” ID", async () => {
    await expect(UserModel.findById(99999)).rejects.toThrow(NotFoundError);
  });

  test("deleteUser - κΆŒν•œ μ—†μŒ", async () => {
    await expect(
      UserModel.deleteUser(1), // κΆŒν•œ 체크 μ‹€νŒ¨
    ).rejects.toThrow(ForbiddenError);
  });
});

κ³ κΈ‰ ν…ŒμŠ€νŠΈ

describe("Transactions", () => {
  test("νŠΈλžœμž­μ…˜ 성곡", async () => {
    await UserModel.transaction(async (trx) => {
      const user = await UserModel.save({ name: "Tx User" }, { trx });
      const profile = await ProfileModel.save({ user_id: user.id, bio: "Bio" }, { trx });

      expect(user.id).toBeGreaterThan(0);
      expect(profile.user_id).toBe(user.id);
    });
  });

  test("νŠΈλžœμž­μ…˜ λ‘€λ°±", async () => {
    const initialCount = await UserModel.count();

    try {
      await UserModel.transaction(async (trx) => {
        await UserModel.save({ name: "Rollback User" }, { trx });
        throw new Error("κ°•μ œ μ—λŸ¬");
      });
    } catch (error) {
      // μ—λŸ¬ λ¬΄μ‹œ
    }

    const finalCount = await UserModel.count();
    expect(finalCount).toBe(initialCount); // λ³€ν™” μ—†μŒ
  });
});
describe("Async Operations", () => {
  test("이메일 전솑", async () => {
    const result = await UserModel.sendWelcomeEmail(1);
    expect(result.success).toBe(true);
  });

  test("파일 μ—…λ‘œλ“œ", async () => {
    const file = {
      buffer: Buffer.from("test content"),
      originalname: "test.txt",
    } as Express.Multer.File;

    const result = await UserModel.uploadAvatar(file);
    expect(result.url).toBeDefined();
    expect(result.url).toContain("avatar");
  });

  test("μ™ΈλΆ€ API 호좜", async () => {
    const result = await UserModel.fetchExternalData();
    expect(result).toBeDefined();
  });
});
import { vi } from "vitest";

describe("Mocking", () => {
  test("μ™ΈλΆ€ μ„œλΉ„μŠ€ Mock", async () => {
    // EmailService.sendλ₯Ό Mock
    const sendMock = vi.spyOn(EmailService, "send").mockResolvedValue({ success: true });

    await UserModel.sendWelcomeEmail(1);

    expect(sendMock).toHaveBeenCalledTimes(1);
    expect(sendMock).toHaveBeenCalledWith(
      expect.objectContaining({
        to: "john@example.com",
        subject: "Welcome!",
      }),
    );

    sendMock.mockRestore();
  });

  test("Date.now Mock", async () => {
    const mockDate = new Date("2025-01-01");
    vi.setSystemTime(mockDate);

    const user = await UserModel.save({ name: "Test" });
    expect(user.created_at).toEqual(mockDate);

    vi.useRealTimers();
  });
});

ν…ŒμŠ€νŠΈ νŒ¨ν„΄

describe("Validation", () => {
  test.each([
    ["john@example.com", true],
    ["invalid-email", false],
    ["", false],
    ["test@", false],
  ])("이메일 검증: %s -> %s", async (email, expected) => {
    const isValid = await UserModel.validateEmail(email);
    expect(isValid).toBe(expected);
  });
});

describe("UserRole", () => {
  test.each([
    ["admin", true],
    ["user", false],
    ["guest", false],
  ])("κ΄€λ¦¬μž κΆŒν•œ: %s -> %s", async (role, expected) => {
    const user = await UserModel.findById(1);
    user.role = role;
    const isAdmin = await UserModel.isAdmin(user);
    expect(isAdmin).toBe(expected);
  });
});
describe("UserModel", () => {
  describe("CRUD Operations", () => {
    test("create", async () => {});
    test("read", async () => {});
    test("update", async () => {});
    test("delete", async () => {});
  });

  describe("Validations", () => {
    test("email validation", async () => {});
    test("password strength", async () => {});
  });

  describe("Relations", () => {
    test("posts", async () => {});
    test("profile", async () => {});
  });
});

Best Practices

DO:
  • βœ… 각 ν…ŒμŠ€νŠΈλŠ” λ…λ¦½μ μœΌλ‘œ μ‹€ν–‰ κ°€λŠ₯ν•΄μ•Ό 함
  • βœ… beforeAllμ΄λ‚˜ beforeEach둜 ν…ŒμŠ€νŠΈ 데이터 μ΄ˆκΈ°ν™”
  • βœ… 의미 μžˆλŠ” ν…ŒμŠ€νŠΈ 이름 μ‚¬μš©
  • βœ… ν•˜λ‚˜μ˜ ν…ŒμŠ€νŠΈμ—μ„œ ν•˜λ‚˜μ˜ κΈ°λŠ₯만 ν…ŒμŠ€νŠΈ
  • βœ… μ—λŸ¬ μΌ€μ΄μŠ€λ„ λ°˜λ“œμ‹œ ν…ŒμŠ€νŠΈ
DON’T:
  • ❌ ν…ŒμŠ€νŠΈ κ°„ μ˜μ‘΄μ„± λ§Œλ“€μ§€ μ•ŠκΈ°
  • ❌ μ‹€μ œ μ™ΈλΆ€ μ„œλΉ„μŠ€ ν˜ΈμΆœν•˜μ§€ μ•ŠκΈ° (Mock μ‚¬μš©)
  • ❌ ν”„λ‘œλ•μ…˜ DB μ‚¬μš©ν•˜μ§€ μ•ŠκΈ°
  • ❌ ν•˜λ“œμ½”λ”©λœ IDλ‚˜ λ‚ μ§œ μ‚¬μš© μ§€μ–‘
  • ❌ ν…ŒμŠ€νŠΈ μ‹€νŒ¨λ₯Ό λ¬΄μ‹œν•˜μ§€ μ•ŠκΈ°

κ΄€λ ¨ λ¬Έμ„œ