0Pricing
tRPC End-to-End Type Safe APIs · 강의

tRPC 절차 단위 테스트

인기 테스트 프레임워크를 사용하여 개별 tRPC 절차를 격리된 환경에서 단위 테스트하는 방법을 배웁니다.

tRPC 절차 단위 테스트은(는) CoddyKit의 무료 tRPC End-to-End Type Safe APIs 강의입니다. 이것은 4개 중 1번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 tRPC End-to-End Type Safe APIs 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. tRPC End-to-End Type Safe APIs 강의에는 총 4개의 강의가 포함되어 있습니다.

이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.

Unit Testing tRPC Procedures

Welcome! In this lesson, we'll dive into unit testing for tRPC. Unit testing focuses on verifying individual, isolated parts of your code, ensuring they work as expected.

For tRPC, a "unit" often refers to a single procedure (query or mutation). We'll learn how to test these procedures independently, without needing a full server running.

Benefits of Procedure Testing

Unit testing tRPC procedures offers several key advantages:

  • Reliability: Catch bugs early in development before they reach production.
  • Speed: Tests run quickly, providing instant feedback.
  • Isolation: Pinpoint issues to specific procedures, not complex integrations.
  • Type Safety: Ensures your procedure's inputs and outputs match your TypeScript definitions.

Essential Tools for tRPC Tests

While you can use various tools, popular choices for TypeScript projects include Jest or Vitest for the test runner. For tRPC backend unit tests, we primarily focus on TypeScript and a test runner's assertion capabilities.

We'll simulate how to call a procedure's internal handler directly for testing, which is the core concept regardless of your chosen test runner.

Deconstructing a Procedure Test

To unit test a tRPC procedure, you need to:

  1. Import the procedure.
  2. Create a mock context (ctx).
  3. Call the procedure's internal handler directly (e.g., _def.query or _def.mutation).
  4. Assert the returned value or any side effects.

Let's see a basic example:

// test-hello.ts
import { initTRPC } from '@trpc/server';

// 1. Define a simple tRPC procedure (or import from your app)
const t = initTRPC.create();
const helloProcedure = t.procedure
  .query(() => 'Hello tRPC');

// 2. Create a mock context (empty for this simple case)
const mockContext = {}; 

async function runHelloTest() {
  console.log("--- Testing 'helloProcedure' ---");

  // 3. Call the procedure's internal query handler
  const result = await helloProcedure._def.query({
    ctx: mockContext,
    input: undefined, // No input required
    path: 'hello',    // The procedure's path
    type: 'query'     // It's a query procedure
  });

  // 4. Assert the result
  console.log(`Expected: 'Hello tRPC'`);
  console.log(`Actual:   '${result}'`);

  if (result === 'Hello tRPC') {
    console.log("Test Passed: The procedure returned the correct message.");
  } else {
    console.log("Test Failed: Unexpected return value.");
  }
}

runHelloTest();

Isolating with Mock Context

The ctx object in tRPC procedures often contains important dependencies like user authentication status, database connections, or other services. To keep unit tests isolated, you must mock this context.

A mock context provides fake versions of these dependencies, allowing your procedure to run without interacting with real external systems.

// test-mock-context.ts
// Simulate a mock context for a procedure needing a user and database.

interface MockUser {
  id: string;
  name: string;
}

interface MockDB {
  findUserById: (id: string) => Promise<MockUser | null>;
  // ... other mock database methods
}

// Our mock database implementation
const mockDB: MockDB = {
  findUserById: async (id: string) => {
    console.log(`Mock DB: Finding user with ID: ${id}`);
    if (id === 'user123') {
      return { id: 'user123', name: 'Alice' };
    }
    return null;
  },
};

// Our mock tRPC context factory
const createMockContext = (user?: MockUser) => ({
  user: user || null,
  db: mockDB,
  // Add any other mocked services here
});

async function demonstrateMockContext() {
  console.log("--- Demonstrating Mock Context ---");
  
  const ctxWithUser = createMockContext({ id: 'user123', name: 'Alice' });
  console.log("Context with user: ", JSON.stringify(ctxWithUser.user));
  
  const ctxWithoutUser = createMockContext();
  console.log("Context without user: ", JSON.stringify(ctxWithoutUser.user));

  // You can even test mock DB calls
  const foundUser = await ctxWithUser.db.findUserById('user123');
  console.log("Found user via mock DB: ", JSON.stringify(foundUser));
}

demonstrateMockContext();

Example: Querying User Profile

Let's combine what we've learned to test a query that fetches a user's profile. This procedure will depend on the ctx for user information and a mock database.

We'll create a mock context that simulates an authenticated user and assert the correct profile is returned.

// test-user-query.ts
import { initTRPC } from '@trpc/server';

// Define our mock DB and User types
interface MockUser { id: string; name: string; email: string; }
interface MockDB { getUserById: (id: string) => Promise<MockUser | null>; }

// Mock DB implementation
const mockDB: MockDB = {
  getUserById: async (id: string) => {
    if (id === 'alice123') {
      return { id: 'alice123', name: 'Alice', email: 'alice@example.com' };
    }
    return null;
  },
};

// Create a mock context for our tests
const createMockContext = (userId?: string) => ({
  user: userId ? { id: userId, name: 'TestUser' } : null, // Simplified user obj
  db: mockDB,
});

// Our tRPC setup
const t = initTRPC.create();

// The procedure we want to test
const getUserProfile = t.procedure
  .query(async ({ ctx }) => {
    if (!ctx.user) {
      throw new Error('Unauthorized');
    }
    const user = await ctx.db.getUserById(ctx.user.id);
    if (!user) {
      throw new Error('User not found');
    }
    return { id: user.id, name: user.name, email: user.email };
  });

async function runUserProfileTest() {
  console.log("--- Testing 'getUserProfile' ---");

  // Test Case 1: Authorized user
  const authCtx = createMockContext('alice123');
  try {
    const result = await getUserProfile._def.query({
      ctx: authCtx, input: undefined, path: 'getUserProfile', type: 'query'
    });
    console.log("Authorized User Test Result: ", JSON.stringify(result));
    if (result.id === 'alice123' && result.name === 'Alice') {
      console.log("Authorized User Test Passed!");
    } else {
      console.log("Authorized User Test Failed!");
    }
  } catch (error: any) {
    console.log("Authorized User Test Failed with error: ", error.message);
  }

  // Test Case 2: Unauthorized user
  const unauthCtx = createMockContext(undefined);
  try {
    await getUserProfile._def.query({
      ctx: unauthCtx, input: undefined, path: 'getUserProfile', type: 'query'
    });
    console.log("Unauthorized User Test Failed: Should have thrown an error.");
  } catch (error: any) {
    console.log("Unauthorized User Test Result (Expected Error): ", error.message);
    if (error.message === 'Unauthorized') {
      console.log("Unauthorized User Test Passed!");
    } else {
      console.log("Unauthorized User Test Failed: Unexpected error message.");
    }
  }
}

runUserProfileTest();

Validating Inputs with Zod

Many procedures accept input, often validated using Zod schemas. When unit testing, you need to provide valid and invalid inputs to ensure both the procedure's logic and its Zod validation work correctly.

The input parameter in the _def.query or _def.mutation handler is where you pass these test inputs.

// test-post-by-id.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

// Mock DB for posts
interface MockPost { id: string; title: string; content: string; }
interface MockDB { getPostById: (id: string) => Promise<MockPost | null>; }

const mockDB: MockDB = {
  getPostById: async (id: string) => {
    if (id === 'post1') {
      return { id: 'post1', title: 'First Post', content: 'Lorem ipsum...' };
    }
    return null;
  },
};

const t = initTRPC.create();
const mockContext = { db: mockDB }; // Simple context

// Procedure with Zod input validation
const getPostById = t.procedure
  .input(z.object({
    id: z.string().min(1, "ID cannot be empty") // Simplified validation for example
  }))
  .query(async ({ input, ctx }) => {
    const post = await ctx.db.getPostById(input.id);
    if (!post) {
      throw new Error('Post not found');
    }
    return post;
  });

async function runPostByIdTest() {
  console.log("--- Testing 'getPostById' ---");

  // Test Case 1: Valid input, post found
  try {
    const result = await getPostById._def.query({
      ctx: mockContext,
      input: { id: 'post1' }, // Valid input
      path: 'getPostById',
      type: 'query'
    });
    console.log("Valid Input (Found) Test Result: ", JSON.stringify(result));
    if (result.id === 'post1') {
      console.log("Valid Input (Found) Test Passed!");
    } else {
      console.log("Valid Input (Found) Test Failed!");
    }
  } catch (error: any) {
    console.log("Valid Input (Found) Test Failed with error: ", error.message);
  }

  // Test Case 2: Valid input, post not found
  try {
    await getPostById._def.query({
      ctx: mockContext,
      input: { id: 'post2' }, // Valid format, but not in mock DB
      path: 'getPostById',
      type: 'query'
    });
    console.log("Valid Input (Not Found) Test Failed: Should have thrown error.");
  } catch (error: any) {
    console.log("Valid Input (Not Found) Test Result (Expected Error): ", error.message);
    if (error.message === 'Post not found') {
      console.log("Valid Input (Not Found) Test Passed!");
    } else {
      console.log("Valid Input (Not Found) Test Failed: Unexpected error.");
    }
  }
}

runPostByIdTest();

Unit Testing Data Changes

Mutation procedures modify data. When testing them, you need to ensure they perform the correct actions and return the expected results. The key here is to mock any external systems (like a database) to verify that the mutation interacts with them as expected.

You'll often use mock functions (e.g., Jest mocks) to track calls to your mocked dependencies.

// test-create-user.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

// Mock DB for users
interface UserInput { name: string; email: string; }
interface MockDB { createUser: (data: UserInput) => Promise<{ id: string } & UserInput>; }

// --- Mocking 'jest.fn' for runnable code --- 
// In a real environment, Jest would provide this. This is a simplified stand-in.
declare const jest: any; // Declare jest to avoid TS error in this isolated snippet
if (typeof jest === 'undefined') {
  globalThis.jest = {
    fn: (impl?: Function) => {
      const mockFunc: any = (...args: any[]) => {
        mockFunc.calls.push(args);
        return impl ? impl(...args) : undefined;
      };
      mockFunc.calls = [];
      mockFunc.mockClear = () => { mockFunc.calls = []; };
      return mockFunc;
    }
  };
}
// --- End Mocking 'jest.fn' --- 

// Implement a mock database with a 'mock' function for createUser
const mockCreateUser = jest.fn(async (data: UserInput) => {
  // Simulate database ID generation
  const newId = `user_${Math.random().toString(36).substr(2, 9)}`;
  console.log(`Mock DB: Creating user with ID ${newId}`);
  return { id: newId, ...data };
});

const mockDB: MockDB = {
  createUser: mockCreateUser,
};

const t = initTRPC.create();
const mockContext = { db: mockDB };

// Our mutation procedure
const createUser = t.procedure
  .input(z.object({
    name: z.string().min(3, "Name too short"),
    email: z.string().email("Invalid email format")
  }))
  .mutation(async ({ input, ctx }) => {
    const newUser = await ctx.db.createUser(input);
    return { success: true, userId: newUser.id };
  });

async function runCreateUserTest() {
  console.log("--- Testing 'createUser' Mutation ---");

  // Reset mock before each test (conceptual)
  mockCreateUser.mockClear();

  // Test Case 1: Valid input
  const validInput = { name: 'Bob', email: 'bob@example.com' };
  try {
    const result = await createUser._def.mutation({
      ctx: mockContext,
      input: validInput,
      path: 'createUser',
      type: 'mutation'
    });
    console.log("Valid Input Test Result: ", JSON.stringify(result));
    
    // Assertions: check return value and if mock was called
    if (result.success && result.userId) {
      console.log("Valid Input Test Passed: Mutation returned success.");
    } else {
      console.log("Valid Input Test Failed: Unexpected return value.");
    }
    // In a real test, you'd assert mockCreateUser was called with validInput
    console.log(`Mock createUser called: ${mockCreateUser.mock.calls.length > 0 ? 'Yes' : 'No'}`);
    if (mockCreateUser.mock.calls.length > 0 && mockCreateUser.mock.calls[0][0].name === 'Bob') {
      console.log("Mock createUser was called with correct data.");
    }
  } catch (error: any) {
    console.log("Valid Input Test Failed with error: ", error.message);
  }

  // Test Case 2: Invalid input (email)
  mockCreateUser.mockClear(); // Clear for next test
  const invalidInput = { name: 'Charlie', email: 'invalid-email' };
  try {
    await createUser._def.mutation({
      ctx: mockContext,
      input: invalidInput,
      path: 'createUser',
      type: 'mutation'
    });
    console.log("Invalid Input Test Failed: Should have thrown Zod error.");
  } catch (error: any) {
    console.log("Invalid Input Test Result (Expected Zod Error): ", error.message);
    if (error.message.includes('Invalid email format')) {
      console.log("Invalid Input Test Passed: Zod validation caught the error.");
    } else {
      console.log("Invalid Input Test Failed: Unexpected error message.");
    }
    console.log(`Mock createUser called: ${mockCreateUser.mock.calls.length > 0 ? 'Yes' : 'No'}`);
    if (mockCreateUser.mock.calls.length === 0) {
      console.log("Mock createUser was NOT called (as expected due to Zod error).");
    }
  }
}

runCreateUserTest();

Tips for Effective tRPC Tests

To maximize the value of your unit tests:

  • Focus on Isolation: Mock all external dependencies (DB, API calls, auth).
  • Test Edge Cases: Include valid, invalid, and boundary inputs.
  • Descriptive Names: Use clear test names that explain what's being tested.
  • Small & Focused: Each test should ideally verify one specific piece of logic.
  • Assert Correctly: Check both return values and side effects (e.g., if a mock function was called).

Testing Your Knowledge

When unit testing a tRPC mutation procedure that interacts with a database, which of the following practices are crucial for maintaining test isolation and effectiveness?

Lesson Summary

Great job! You've learned the fundamentals of unit testing tRPC procedures.

  • We covered why unit testing is important for reliability and speed.
  • You saw how to call procedure handlers directly and mock the ctx object.
  • We practiced testing both query and mutation procedures, including those with Zod input validation.

Keep practicing these techniques to build robust and maintainable tRPC APIs!

자주 묻는 질문

“tRPC 절차 단위 테스트” 강의는 무료인가요?

네 — “tRPC 절차 단위 테스트” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 tRPC End-to-End Type Safe APIs 강의 전체를 잠금 해제할 수 있습니다. tRPC End-to-End Type Safe APIs 강의에는 총 4개의 강의가 포함되어 있습니다.

“tRPC 절차 단위 테스트”에서 뭘 배우나요?

인기 테스트 프레임워크를 사용하여 개별 tRPC 절차를 격리된 환경에서 단위 테스트하는 방법을 배웁니다. 브라우저에서 직접 실행하는 실습 코드로 tRPC End-to-End Type Safe APIs을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

tRPC End-to-End Type Safe APIs을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 tRPC End-to-End Type Safe APIs은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 1번째 강의입니다.

“tRPC 절차 단위 테스트” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 tRPC End-to-End Type Safe APIs 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 tRPC End-to-End Type Safe APIs 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. tRPC 절차 단위 테스트
  2. tRPC 라우터 통합 테스트
  3. 종단 간 테스트 전략
  4. tRPC 테스트에서 컨텍스트와 종속성 모킹하기
← tRPC End-to-End Type Safe APIs(으)로 돌아가기