๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
Mock Context๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํ…Œ์ŠคํŠธ๋ฅผ ๊ฒฉ๋ฆฌํ•˜๊ณ , ์ธ์ฆ ์ƒํƒœ๋ฅผ ์ œ์–ดํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์•Œ์•„๋ด…๋‹ˆ๋‹ค.

runWithMockContext๋ž€?

runWithMockContext๋Š” Sonamu์˜ ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์—์„œ ๊ฒฉ๋ฆฌ๋œ Context๋ฅผ ์ œ๊ณตํ•˜๋Š” ํ•จ์ˆ˜์ž…๋‹ˆ๋‹ค. Sonamu๋Š” AsyncLocalStorage๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๊ฐ ์š”์ฒญ๋งˆ๋‹ค ๋…๋ฆฝ๋œ Context๋ฅผ ์œ ์ง€ํ•˜๋Š”๋ฐ, ํ…Œ์ŠคํŠธ์—์„œ๋„ ๋™์ผํ•œ ๋ฉ”์ปค๋‹ˆ์ฆ˜์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

Context์˜ ๊ตฌ์„ฑ ์š”์†Œ

Mock Context๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์†์„ฑ์„ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค:
type Context = {
  ip: string;              // ํด๋ผ์ด์–ธํŠธ IP ์ฃผ์†Œ
  session: object;         // ์„ธ์…˜ ๋ฐ์ดํ„ฐ
  user: PassportUser | null;  // ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž ์ •๋ณด
  passport: {
    login: (user: PassportUser) => Promise<void>;
    logout: () => void;
  };
  naiteStore: NaiteStore;  // Naite ๋กœ๊ทธ ์ €์žฅ์†Œ
  request: FastifyRequest;
  reply: FastifyReply;
  headers: IncomingHttpHeaders;
  // ... ๊ธฐํƒ€ ์†์„ฑ
};
ํ…Œ์ŠคํŠธ์—์„œ๋Š” ๊ฐ„์†Œํ™”๋œ Mock Context๊ฐ€ ์ œ๊ณต๋ฉ๋‹ˆ๋‹ค:
{
  ip: "127.0.0.1",
  session: {},
  user: null,
  passport: {
    login: async () => {},
    logout: () => {},
  },
  naiteStore: Naite.createStore(),
}

๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ•

์ผ๋ฐ˜ ํ…Œ์ŠคํŠธ (๋น„์ธ์ฆ)

Sonamu์—์„œ ์ œ๊ณตํ•˜๋Š” test() ํ•จ์ˆ˜๋Š” ์ž๋™์œผ๋กœ runWithMockContext๋ฅผ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค.
import { test } from "sonamu/test";
import { Sonamu } from "sonamu";
import { expect } from "vitest";

test("Context ์ ‘๊ทผ ํ…Œ์ŠคํŠธ", async () => {
  // test() ํ•จ์ˆ˜๊ฐ€ ์ž๋™์œผ๋กœ runWithMockContext๋ฅผ ํ˜ธ์ถœํ•˜๋ฏ€๋กœ
  // ๋ฐ”๋กœ Context์— ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค
  const context = Sonamu.getContext();
  
  expect(context.ip).toBe("127.0.0.1");
  expect(context.user).toBeNull();
  expect(context.session).toEqual({});
});

์ง์ ‘ ์‚ฌ์šฉํ•˜๊ธฐ

runWithMockContext๋ฅผ ์ง์ ‘ ํ˜ธ์ถœํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค:
import { runWithMockContext } from "sonamu/test";
import { Sonamu } from "sonamu";

await runWithMockContext(async () => {
  const context = Sonamu.getContext();
  
  // ์ด ๋ธ”๋ก ๋‚ด์—์„œ๋Š” Mock Context๊ฐ€ ํ™œ์„ฑํ™”๋ฉ๋‹ˆ๋‹ค
  console.log(context.ip); // "127.0.0.1"
});

์ธ์ฆ๋œ ์‚ฌ์šฉ์ž๋กœ ํ…Œ์ŠคํŠธํ•˜๊ธฐ

testAs() ์‚ฌ์šฉ

ํŠน์ • ์‚ฌ์šฉ์ž๋กœ ๋กœ๊ทธ์ธํ•œ ์ƒํƒœ๋ฅผ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ํ•˜๋ ค๋ฉด testAs()๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค:
import { testAs } from "sonamu/test";
import { Sonamu } from "sonamu";
import { expect } from "vitest";

testAs(
  { id: 1, username: "john" },  // ์‚ฌ์šฉ์ž ์ •๋ณด
  "์ธ์ฆ๋œ ์‚ฌ์šฉ์ž ํ…Œ์ŠคํŠธ",
  async () => {
    const context = Sonamu.getContext();
    
    expect(context.user).toEqual({ id: 1, username: "john" });
  }
);

ํƒ€์ž… ์•ˆ์ „์„ฑ

testAs()๋Š” ์ œ๋„ค๋ฆญ์„ ์ง€์›ํ•˜์—ฌ ํƒ€์ž… ์•ˆ์ „์„ฑ์„ ๋ณด์žฅํ•ฉ๋‹ˆ๋‹ค:
type MyUser = {
  id: number;
  username: string;
  role: "admin" | "user";
};

testAs<MyUser>(
  { id: 1, username: "admin", role: "admin" },
  "๊ด€๋ฆฌ์ž ๊ถŒํ•œ ํ…Œ์ŠคํŠธ",
  async () => {
    const context = Sonamu.getContext();
    
    // context.user๋Š” MyUser | null ํƒ€์ž…์œผ๋กœ ์ถ”๋ก ๋ฉ๋‹ˆ๋‹ค
    expect(context.user?.role).toBe("admin");
  }
);

์‹ค์ „ ์˜ˆ์ œ

Model ๋ฉ”์„œ๋“œ ํ…Œ์ŠคํŠธ

import { test, testAs } from "sonamu/test";
import { userModel } from "../application/user/user.model";
import { expect } from "vitest";

test("์‚ฌ์šฉ์ž ๋ชฉ๋ก ์กฐํšŒ (๋น„์ธ์ฆ)", async () => {
  // Mock Context๊ฐ€ ์ž๋™์œผ๋กœ ์ œ๊ณต๋จ
  const users = await userModel.findMany({});
  
  expect(Array.isArray(users)).toBe(true);
});

testAs(
  { id: 1, username: "john" },
  "๋‚ด ํ”„๋กœํ•„ ์กฐํšŒ (์ธ์ฆ)",
  async () => {
    // context.user = { id: 1, username: "john" }
    const profile = await userModel.getMyProfile();
    
    expect(profile.username).toBe("john");
  }
);

๊ถŒํ•œ ๊ฒ€์ฆ ํ…Œ์ŠคํŠธ

import { testAs } from "sonamu/test";
import { postModel } from "../application/post/post.model";
import { expect } from "vitest";

testAs(
  { id: 1, role: "admin" },
  "๊ด€๋ฆฌ์ž๋Š” ๋ชจ๋“  ๊ฒŒ์‹œ๊ธ€ ์‚ญ์ œ ๊ฐ€๋Šฅ",
  async () => {
    const result = await postModel.delete({ id: 999 });
    expect(result.success).toBe(true);
  }
);

testAs(
  { id: 2, role: "user" },
  "์ผ๋ฐ˜ ์‚ฌ์šฉ์ž๋Š” ํƒ€์ธ ๊ฒŒ์‹œ๊ธ€ ์‚ญ์ œ ๋ถˆ๊ฐ€",
  async () => {
    await expect(
      postModel.delete({ id: 999 })
    ).rejects.toThrow("๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค");
  }
);

Context ์†์„ฑ ์กฐ์ž‘

ํŠน์ • ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์œ„ํ•ด Context ์†์„ฑ์„ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:
import { test } from "sonamu/test";
import { Sonamu } from "sonamu";
import { expect } from "vitest";

test("ํŠน์ • IP์—์„œ์˜ ์ ‘๊ทผ ํ…Œ์ŠคํŠธ", async () => {
  const context = Sonamu.getContext();
  
  // Context ์†์„ฑ ์ˆ˜์ •
  context.ip = "192.168.1.100";
  
  const result = await someService.checkIPRestriction();
  expect(result.allowed).toBe(true);
});

test() vs testAs() ๋น„๊ต

๋น„์ธ์ฆ ํ…Œ์ŠคํŠธ
test("์ œ๋ชฉ", async () => {
  // context.user === null
});
์‚ฌ์šฉ ์‹œ๊ธฐ:
  • ์ธ์ฆ์ด ํ•„์š” ์—†๋Š” ๊ณต๊ฐœ API ํ…Œ์ŠคํŠธ
  • ์ธ์ฆ ์—ฌ๋ถ€์™€ ๋ฌด๊ด€ํ•œ ๋กœ์ง ํ…Œ์ŠคํŠธ
  • Model์˜ ๊ธฐ๋ณธ CRUD ์ž‘์—… ํ…Œ์ŠคํŠธ

๋‚ด๋ถ€ ๊ตฌ์กฐ

getMockContext()

Mock Context๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค:
function getMockContext(): Context {
  return {
    ip: "127.0.0.1",
    session: {},
    user: null,
    passport: {
      login: async () => {},
      logout: () => {},
    },
    naiteStore: Naite.createStore(),
  } as unknown as Context;
}
ํŠน์ง•:
  • ip: ๋กœ์ปฌ ํ™˜๊ฒฝ์„ ๋‚˜ํƒ€๋‚ด๋Š” 127.0.0.1
  • session: ๋นˆ ๊ฐ์ฒด๋กœ ์ดˆ๊ธฐํ™”
  • user: ๊ธฐ๋ณธ๊ฐ’์€ null (๋น„์ธ์ฆ ์ƒํƒœ)
  • passport: Mock ํ•จ์ˆ˜๋กœ ์‹ค์ œ ์ธ์ฆ ๋กœ์ง์€ ์‹คํ–‰๋˜์ง€ ์•Š์Œ
  • naiteStore: ๊ฐ ํ…Œ์ŠคํŠธ๋งˆ๋‹ค ๋…๋ฆฝ๋œ ๋กœ๊ทธ ์ €์žฅ์†Œ

AsyncLocalStorage ๊ฒฉ๋ฆฌ

Sonamu๋Š” Node.js์˜ AsyncLocalStorage๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ Context๋ฅผ ๊ฒฉ๋ฆฌํ•ฉ๋‹ˆ๋‹ค:
export async function runWithContext(
  context: Context | null, 
  fn: () => Promise<void>
) {
  await Sonamu.asyncLocalStorage.run(
    { context: context ?? getMockContext() }, 
    fn
  );
}

export async function runWithMockContext(fn: () => Promise<void>) {
  await runWithContext(getMockContext(), fn);
}
๊ฒฉ๋ฆฌ์˜ ์ด์ :
  • ํ…Œ์ŠคํŠธ ๊ฐ„ Context๊ฐ€ ์„ž์ด์ง€ ์•Š์Œ
  • ๋ณ‘๋ ฌ ํ…Œ์ŠคํŠธ ์‹คํ–‰ ์‹œ์—๋„ ์•ˆ์ „
  • ๊ฐ ํ…Œ์ŠคํŠธ๋Š” ๋…๋ฆฝ๋œ Naite ๋กœ๊ทธ ์ €์žฅ์†Œ๋ฅผ ๊ฐ€์ง

test() ๋ž˜ํผ์˜ ์ž‘๋™ ์›๋ฆฌ

Sonamu์˜ test() ํ•จ์ˆ˜๋Š” Vitest์˜ test()๋ฅผ ๋ž˜ํ•‘ํ•˜์—ฌ ์ž๋™์œผ๋กœ Mock Context๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค:
export const test = async (
  title: string, 
  fn: TestFunction<object>, 
  options?: TestOptions
) => {
  return vitestTest(title, options, async (context) => {
    await runWithMockContext(async () => {
      try {
        await fn(context);
        context.task.meta.traces = Naite.getAllTraces();
      } catch (e: unknown) {
        context.task.meta.traces = Naite.getAllTraces();
        throw e;
      }
    });
  });
};
์ฒ˜๋ฆฌ ๊ณผ์ •:
  1. Vitest์˜ test() ์‹คํ–‰
  2. runWithMockContext()๋กœ Mock Context ์ƒ์„ฑ ๋ฐ ํ™œ์„ฑํ™”
  3. ํ…Œ์ŠคํŠธ ํ•จ์ˆ˜ ์‹คํ–‰
  4. ์„ฑ๊ณต/์‹คํŒจ ์—ฌ๋ถ€์™€ ๋ฌด๊ด€ํ•˜๊ฒŒ Naite ๋กœ๊ทธ ์ˆ˜์ง‘
  5. Context ์ž๋™ ์ •๋ฆฌ

testAs() ๊ตฌ์กฐ

testAs()๋Š” ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ์ถ”๊ฐ€๋กœ ๋ฐ›์•„ Context๋ฅผ ํ™•์žฅํ•ฉ๋‹ˆ๋‹ค:
export const testAs = async <User extends AuthContext["user"]>(
  user: User,
  title: string,
  fn: TestFunction<object>,
  options?: TestOptions,
) => {
  return vitestTest(title, options, async (context) => {
    await runWithContext(
      {
        ...getMockContext(),
        user,  // ์‚ฌ์šฉ์ž ์ •๋ณด ์ถ”๊ฐ€
      },
      async () => {
        try {
          await fn(context);
          context.task.meta.traces = Naite.getAllTraces();
        } catch (e: unknown) {
          context.task.meta.traces = Naite.getAllTraces();
          throw e;
        }
      },
    );
  });
};

์ฃผ์˜์‚ฌํ•ญ

Context ์‚ฌ์šฉ ์‹œ ์ฃผ์˜์‚ฌํ•ญ:
  1. test() ์™ธ๋ถ€์—์„œ Context ์ ‘๊ทผ ๋ถˆ๊ฐ€: Sonamu.getContext()๋Š” ํ…Œ์ŠคํŠธ ํ•จ์ˆ˜ ๋‚ด๋ถ€์—์„œ๋งŒ ํ˜ธ์ถœํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์™ธ๋ถ€์—์„œ ํ˜ธ์ถœํ•˜๋ฉด undefined๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.
  2. Context ์ˆ˜์ •์˜ ์˜ํ–ฅ ๋ฒ”์œ„: Context๋ฅผ ์ˆ˜์ •ํ•˜๋ฉด ํ•ด๋‹น ํ…Œ์ŠคํŠธ ์ „์ฒด์— ์˜ํ–ฅ์„ ๋ฏธ์นฉ๋‹ˆ๋‹ค. ํ…Œ์ŠคํŠธ ์ข…๋ฃŒ ์‹œ ์ž๋™์œผ๋กœ ์ •๋ฆฌ๋ฉ๋‹ˆ๋‹ค.
  3. testAs() ํŒŒ๋ผ๋ฏธํ„ฐ ์ˆœ์„œ: ์‚ฌ์šฉ์ž ์ •๋ณด๊ฐ€ ์ฒซ ๋ฒˆ์งธ ํŒŒ๋ผ๋ฏธํ„ฐ์ž…๋‹ˆ๋‹ค.
    // โœ… ์˜ฌ๋ฐ”๋ฅธ ์‚ฌ์šฉ
    testAs(user, "์ œ๋ชฉ", async () => {});
    
    // โŒ ์ž˜๋ชป๋œ ์‚ฌ์šฉ
    testAs("์ œ๋ชฉ", user, async () => {});
    
  4. ํƒ€์ž… ์•ˆ์ „์„ฑ: testAs()์˜ ์‚ฌ์šฉ์ž ํƒ€์ž…์€ AuthContext["user"]๋ฅผ ํ™•์žฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๋‹ค์Œ ๋‹จ๊ณ„