๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ

ํ…Œ์ŠคํŠธ ๊ธฐ๋ณธ

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: "[email protected]"
    });
    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: "[email protected]" },
      { id: 2, name: "Jane", email: "[email protected]" },
      { id: 3, name: "Bob", email: "[email protected]" }
    ]);
  }
}
ํ…Œ์ŠคํŠธ์—์„œ ์‚ฌ์šฉ:
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: "[email protected]"
    });
    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: "[email protected]"
    });
    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: "[email protected]"  // ์ด๋ฏธ ์กด์žฌ
      })
    ).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: "[email protected]",
        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([
    ["[email protected]", 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๋‚˜ ๋‚ ์งœ ์‚ฌ์šฉ ์ง€์–‘
  • โŒ ํ…Œ์ŠคํŠธ ์‹คํŒจ๋ฅผ ๋ฌด์‹œํ•˜์ง€ ์•Š๊ธฐ

๊ด€๋ จ ๋ฌธ์„œ