FrontEnd

Test Double에 대해 알아보자

valleycho-tech 2025. 2. 12. 22:40

 테스트 더블(Test Double) 이란?

테스트 더블은 영화를 촬영할 때 배우를 대신하여 위험한 역할을 하는 스턴트 더블(Stunt Double)이라는 용어에서 유래된 단어이다.

자동화된 테스트를 작성할 때, 여러 객체들이 의존성을 갖는 경우 테스트 하기 까다로운 경우가 있습니다.

예를 들어서 프로덕션 코드에서 Service Layer는 Dao에 직접적으로 의존하고, 따라서 Database 까지 의존하는 형태를 갖습니다.

의존 관계가 간단한 경우 테스트 대상과 의존하고 있는 대상을 함께 테스트할 수 있습니다. 이를 Sociable Test 라고 합니다.

Sociable Test 에서 우리가 테스트할 Service 객체는 실제 동작하는 Dao 객체를 통해 데이터베이스에 액세스할 수 있습니다.

하지만 테스트 대상이 아닌 의존 대상의 결함으로 테스트가 실패하는 경우가 발생할 수 있습니다. 의존 대상으로 인해 테스트가 실패하는 것을 막기 위해 의존 대상 대신 실제 동작하는 것 처럼 보이는 별개의 객체를 만드는 것을 고려할 수 있을 것 입니다.

이 방식을 Solitary Test 라고 하며, 이때 만드는 별개의 객체를 테스트 더블 (Test Double) 이라고 합니다.

즉, 테스트 코드에서 Service가 데이터베이스를 실제로 조작하는 Dao 대신에 가짜 Dao를 사용하게 만드는 것 이라고 할 수 있습니다.

 

 

테스트 더블의 종류

1. Dummy

  • 객체가 필요하지만, 기능은 필요하지 않은 경우에 사용
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';

// 간단한 컴포넌트
const Form = ({ onSubmit }) => (
  <form onSubmit={(e) => { e.preventDefault(); onSubmit(); }}>
    <button type="submit">Submit</button>
  </form>
);

describe('Form Component', () => {
  test('calls onSubmit when the form is submitted', () => {
    // Dummy 함수 생성
    const dummySubmitHandler = jest.fn(); // Jest의 mock 함수 사용

    // 컴포넌트 렌더링
    render(<Form onSubmit={dummySubmitHandler} />);

    // 버튼 클릭 (Submit 이벤트 발생)
    fireEvent.click(screen.getByText('Submit'));

    // Dummy 함수가 호출되었는지 확인
    expect(dummySubmitHandler).toHaveBeenCalled();
    expect(dummySubmitHandler).toHaveBeenCalledTimes(1); // 호출 횟수 확인
  });
});

Dummy 사용: jest.fn()은 Jest에서 제공하는 가짜 함수로, 아무 동작도 하지 않는 테스트용 함수입니다. 이는 Dummy의 역할을 합니다.

2. Fake

  • 복잡한 로직이나 객체 내부에서 필요로 하는 다른 외부 객체들의 동작을 단순화하여 구현한 객체입니다.
// TodoList
import React, { useState } from 'react';

const ToDoList = ({ service }) => {
  const [tasks, setTasks] = useState([]);

  const fetchTasks = async () => {
    const data = await service.getTasks();
    setTasks(data);
  };

  const addTask = async (task) => {
    await service.addTask(task);
    fetchTasks();
  };

  return (
    <div>
      <ul>
        {tasks.map((task, index) => (
          <li key={index}>{task}</li>
        ))}
      </ul>
      <button onClick={() => addTask('New Task')}>Add Task</button>
    </div>
  );
};

export default ToDoList;

// FakeService
export const fakeService = (() => {
  let tasks = [];

  return {
    getTasks: async () => tasks,
    addTask: async (task) => {
      tasks.push(task);
    },
    reset: () => {
      tasks = []; // 테스트를 위해 상태 초기화
    },
  };
})();

// Test 코드
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import ToDoList from './ToDoList';
import { fakeService } from './fakeService';

describe('ToDoList Component with Fake Service', () => {
  beforeEach(() => {
    fakeService.reset(); // 매 테스트마다 상태 초기화
  });

  test('displays tasks from the fake service', async () => {
    // 초기 상태 설정
    fakeService.addTask('Initial Task');

    render(<ToDoList service={fakeService} />);

    // 버튼 클릭으로 태스크 로드
    const addButton = screen.getByText('Add Task');
    fireEvent.click(addButton);

    // UI가 업데이트되었는지 확인
    const taskItem = await screen.findByText('Initial Task');
    expect(taskItem).toBeInTheDocument();

    const newTaskItem = await screen.findByText('New Task');
    expect(newTaskItem).toBeInTheDocument();
  });
});

 

실제 서비스 대신 간단히 동작을 흉내 내며, 외부 의존성을 줄이고, Tasks는 테스트간 독립적으로 함으로써, 테스트의 신뢰성을 높입니다.

 

3. Stub

  • 테스트에서 호출된 요청에 대해 미리 준비해둔 결과를 제공함으로써, Dummy 객체가 실제 동작하는 것처럼 만들어 놓은 객체입니다.
// UserProfile
import React, { useEffect, useState } from 'react';

const UserProfile = ({ userService }) => {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const fetchUser = async () => {
      const userData = await userService.getUser();
      setUser(userData);
    };
    fetchUser();
  }, [userService]);

  if (!user) {
    return <div>Loading...</div>;
  }

  return (
    <div>
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>
    </div>
  );
};

export default UserProfile;

// Stub Service
export const stubUserService = {
  getUser: jest.fn().mockResolvedValue({
    name: 'John Doe',
    email: 'john.doe@example.com',
  }),
};

// 테스트
import React from 'react';
import { render, screen } from '@testing-library/react';
import UserProfile from './UserProfile';
import { stubUserService } from './stubUserService';

describe('UserProfile Component with Stub Service', () => {
  test('renders user data from the stub service', async () => {
    // 컴포넌트 렌더링
    render(<UserProfile userService={stubUserService} />);

    // 초기 상태 확인
    expect(screen.getByText('Loading...')).toBeInTheDocument();

    // Stub 데이터가 표시되는지 확인
    const userName = await screen.findByText('John Doe');
    const userEmail = await screen.findByText('Email: john.doe@example.com');

    expect(userName).toBeInTheDocument();
    expect(userEmail).toBeInTheDocument();
  });
});

Stub 서비스는 실제 API 호출 없이 예상된 데이터를 제공합니다.

4. Spy

  • 실제 객체처럼 동작시킬 수도 있고, 필요한 부분에 대해서는 Stub로 만들어서 동작을 지정할 수 있으며, 특정 메서드가 제대로 호출되었는지 여부도 확인할 수 있습니다.
// ClickCounter
import React, { useState } from 'react';

const ClickCounter = ({ onButtonClick }) => {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    const newCount = count + 1;
    setCount(newCount);
    onButtonClick(newCount); // 콜백 호출
  };

  return (
    <div>
      <button onClick={handleClick}>Click Me</button>
      <p>Count: {count}</p>
    </div>
  );
};

export default ClickCounter;

// 테스트
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import ClickCounter from './ClickCounter';

describe('ClickCounter Component with Spy', () => {
  test('calls onButtonClick with the correct count', () => {
    // Spy 생성
    const onButtonClickSpy = jest.fn();

    // 컴포넌트 렌더링
    render(<ClickCounter onButtonClick={onButtonClickSpy} />);

    // 버튼 가져오기
    const button = screen.getByText('Click Me');

    // 첫 번째 클릭
    fireEvent.click(button);
    expect(onButtonClickSpy).toHaveBeenCalledWith(1); // 호출된 인자 확인

    // 두 번째 클릭
    fireEvent.click(button);
    expect(onButtonClickSpy).toHaveBeenCalledWith(2); // 호출된 인자 확인

    // 호출 횟수 확인
    expect(onButtonClickSpy).toHaveBeenCalledTimes(2);
  });
});

 

5. Mock

  • 호출에 대한 기대를 명세하고 내용에 따라 동작하도록 프로그래밍된 객체입니다.
// LoginForm
import React, { useState } from 'react';

const LoginForm = ({ onLogin }) => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const handleSubmit = (event) => {
    event.preventDefault();
    onLogin({ email, password }); // 콜백 호출
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Email:
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
      </label>
      <label>
        Password:
        <input
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </label>
      <button type="submit">Login</button>
    </form>
  );
};

export default LoginForm;

// 테스트
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import LoginForm from './LoginForm';

describe('LoginForm Component with Mock', () => {
  test('calls onLogin with email and password when the form is submitted', () => {
    // Mock 함수 생성
    const mockOnLogin = jest.fn();

    // 컴포넌트 렌더링
    render(<LoginForm onLogin={mockOnLogin} />);

    // 입력 필드 가져오기
    const emailInput = screen.getByLabelText('Email:');
    const passwordInput = screen.getByLabelText('Password:');
    const submitButton = screen.getByText('Login');

    // 값 입력
    fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
    fireEvent.change(passwordInput, { target: { value: 'password123' } });

    // 폼 제출
    fireEvent.click(submitButton);

    // Mock 호출 여부 및 인자 확인
    expect(mockOnLogin).toHaveBeenCalledTimes(1);
    expect(mockOnLogin).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123',
    });
  });
});