본문 바로가기
source-code/software

싱글톤 패턴 단위 테스트를 작성하는 3가지 방법

by mattew4483 2024. 11. 19.
728x90
반응형

Backgrounds

싱글톤 패턴(Singleton Pattern)이란?

싱글톤 패턴은 애플리케이션 내에서 특정 클래스의 인스턴스가 단 하나만 생성되도록 보장하는 디자인 패턴입니다. 데이터베이스 연결, 설정값 관리, 공통 서비스 제공 등 애플리케이션 전역에서 공유되어야 하는 리소스를 관리할 때 자주 사용됩니다.

class Singleton {
  private static instance: Singleton | null = null;

  private constructor() {}

  static getInstance(): Singleton {
    if (!this.instance) {
      this.instance = new Singleton();
    }
    return this.instance;
  }
}

 

단위 테스트 작성 시 문제점

하지만 싱글톤 패턴을 적용한 모듈의 단위 테스트 작성 시, 아래와 같은 어려움과 마주하게 됩니다.

 

1. 상태 공유

싱글톤 인스턴스는 애플리케이션 전역에서 공유되므로, 이전 테스트의 상태가 다음 테스트에 영향을 미칠 수 있습니다.

 

2. 전역 의존성 문제

싱글톤 객체를 직접 호출하면 테스트 환경에 따라 원하는 동작을 설정하기 어렵습니다.

 

3. 모킹(Mock)의 어려움

싱글톤 인스턴스를 테스트 환경에서 모킹 하거나 독립적인 인스턴스를 생성하는 과정이 복잡할 수 있습니다.

 

// Singleton.ts
export class Singleton {
  private static instance: Singleton | null = null;
  private data: string | null = null;

  private constructor() {}

  static getInstance(): Singleton {
    if (!this.instance) {
      this.instance = new Singleton();
    }
    return this.instance;
  }

  setData(value: string) {
    this.data = value;
  }

  getData() {
    return this.data;
  }
}

// Singleton.test.ts
import { Singleton } from "./Singleton";

describe("Singleton Tests", () => {
  let instance: Singleton;
  
  beforeEach(() => {
    // 테스트마다 Singleton.getInstance()를 호출해도 Singleton 인스턴스는 재활용됨
    instance = Singleton.getInstance();
  });

  it("stores data in singleton instance", () => {
    const instance = Singleton.getInstance();
    instance.setData("Test 1");

    expect(instance.getData()).toBe("Test 1");
  });

  it("reuses the same instance across tests", () => {
    const instance = Singleton.getInstance();

    // 이전 테스트에서 저장한 값이 남아 있음
    expect(instance.getData()).toBe("Test 1"); // 실패 예상
  });
});

 

위 예시 코드처럼 it 블록별로 독립적인 테스트를 작성하려 해도, 싱글톤의 특성상 상태가 공유되기 때문에 테스트 결과가 의도치 않게 영향을 받습니다. 이로 인해 각 테스트는 독립적으로 수행되어야하며, 다른 단위 테스트에 영향을 받아서는 안된다는 FIRST 원칙을 위배하게 됩니다!

 

단위 테스트 작성법

테스트 친화적인 싱글톤 설계와 단위 테스트 작성법은 크게 세 가지로 나눌 수 있습니다.

 

1. 테스트용 인스턴스 초기화 메서드 추가

싱글톤 클래스에 테스트 환경에서만 호출 가능한 초기화 메서드를 추가하여 기존 싱글톤 인스턴스를 교체할 수 있도록 설계합니다.

class Singleton {
  private static instance: Singleton | null = null;

  private constructor() {}

  static getInstance(): Singleton {
    if (!this.instance) {
      this.instance = new Singleton();
    }
    return this.instance;
  }

  // 테스트 환경에서 싱글톤 인스턴스 초기화
  static resetInstance(): void {
    this.instance = null;
  }
}

 

테스트 환경에서는 resetInstance를 호출하여 상태를 초기화한 뒤 싱글톤 객체를 테스트할 수 있습니다.

// Singleton.test.ts
import { Singleton } from "./Singleton";

describe("Singleton", () => {
  beforeEach(() => {
    Singleton.resetInstance(); // 각 테스트 전에 인스턴스를 초기화
  });

  it("should store and retrieve data within the same test", () => {
    const instance = Singleton.getInstance();
    instance.setData("Test Data");

    expect(instance.getData()).toBe("Test Data"); // 같은 테스트 내에서는 정상 작동
  });

  it("should not retain state across tests", () => {
    const instance = Singleton.getInstance();

    expect(instance.getData()).toBeNull(); // 이전 테스트의 상태가 남아 있지 않음
  });

  it("should return a new instance after reset", () => {
    const instance1 = Singleton.getInstance();
    Singleton.resetInstance();
    const instance2 = Singleton.getInstance();

    expect(instance1).not.toBe(instance2); // 새로운 인스턴스 생성 확인
  });
});

 

이를 통해 테스트 환경에서 항상 새로운 인스턴스를 생성하여 상태를 초기화할 수 있기 때문에, 단위 테스트 간 독립적인 결과를 보장할 수 있습니다. 단위 테스트 환경에서 상태 관리 문제를 해결하는 간단하고 효과적인 방법이기도 하죠!

 

하지만 프로덕션 레벨에까지 테스트 전용 메서드가 추가되기 때문에, 본래의 의도와는 다르게, 애플리케이션 내 인스턴스가 하나만 존재함을 보장할 수는 없다 는 치명적인 문제를 갖고 있습니다. (따라서 사용하지 않는 편이 좋겠죠)

 

2. 의존성 주입(DI)을 사용

싱글톤 인스턴스를 직접 생성하지 않고, DI 컨테이너나 팩토리 함수를 통해 인스턴스를 주입받도록 설계합니다. 테스트 환경에서는 독립적인 인스턴스를 주입할 수 있는 테스트 전용 모듈 생성에 사용하는 것이죠.

// Singleton.ts (기존과 동일)
class Singleton {
  private static instance: Singleton | null = null;

  private constructor() {}

  static getInstance(): Singleton {
    if (!this.instance) {
      this.instance = new Singleton();
    }
    return this.instance;
  }
}


// SingletonTestUtils.ts (DI 컨테이너 및 주입 함수 작성)
// DI 컨테이너
let singletonProvider: () => Singleton = Singleton.getInstance;

// 테스트를 위한 인스턴스 설정
export function setTestSingletonInstance(instance: Singleton): void {
  singletonProvider = () => instance;
}

// 싱글톤 인스턴스 주입 함수
export function getSingletonInstance(): Singleton {
  return singletonProvider();
}

 

테스트 환경에서는 SingletonTestUtils을 통해 싱글톤 인스턴스의 동작을 테스트하는 것이죠!

// Singleton.test.ts
import { Singleton } from "./Singleton";
import { getSingletonInstance, setSingletonProvider } from "./SingletonProvider";

describe("Singleton with DI Container", () => {
  beforeEach(() => {
    // 테스트용 커스텀 싱글톤 인스턴스를 제공
    setSingletonProvider(() => {
      const testInstance = Singleton.getInstance()
      testInstance.setData(null); // 초기화
      return testInstance;
    });
  });

  afterEach(() => {
    // 테스트 종료 후 DI 컨테이너를 초기화 (실제 환경 복구)
    setSingletonProvider(() => new Singleton());
  });

  it("should use a custom singleton instance in tests", () => {
    const instance = getSingletonInstance();
    instance.setData("Test Data");

    expect(instance.getData()).toBe("Test Data"); // 커스텀 인스턴스 정상 동작 확인
  });

  it("should not retain state across tests", () => {
    const instance = getSingletonInstance();

    expect(instance.getData()).toBeNull(); // 이전 테스트 상태가 남아있지 않음
  });

  it("should allow multiple custom behaviors", () => {
    setSingletonProvider(() => {
      const customInstance = new Singleton();
      customInstance.setData("Custom Data");
      return customInstance;
    });

    const instance = getSingletonInstance();
    expect(instance.getData()).toBe("Custom Data"); // 커스텀 인스턴스 확인
  });
});

 

NestJS, InversifyJS 같은 DI 컨테이너를 사용하면, 테스트와 프로덕션 환경에서 다른 설정을 쉽게 적용할 수도 있습니다.

 

3. 생성자 함수 protected 설정 + 테스트 코드 Mock 구현

싱글톤 클래스의 생성자를 protected로 설정하여 직접 호출을 제한하고, 테스트 코드에서 이를 상속받아 Mock 클래스를 생성하는 방식입니다.

// Singleton.ts
export class Singleton {
  private static instance: Singleton | null = null;

  protected constructor() {} // protected로 설정

  static getInstance(): Singleton {
    if (!this.instance) {
      this.instance = new Singleton();
    }
    return this.instance;
  }
}

 

이를 통해 인스턴스 객체에서는 생성자 함수를 호출할 수 없지만, 상속받은 클래스에서는 호출할 수 있게 됩니다.

→ 테스트 코드에서 Singleton class를 상속받은 Mock을 구현한 뒤, Singleton의 동작을 테스트하는 것이죠!

 

// Singleton.test.ts
import { Singleton } from "./Singleton";

// Mock 클래스 생성
class MockSingleton extends Singleton {
  private mockData: string | null = null;

  constructor() {
    super();
  }
}

describe("Singleton Tests with Mock", () => {
  it("allows mocking of Singleton", () => {
    const mockInstance = new MockSingleton();

    // MockSingleton은 Singleton의 하위 클래스임을 확인
    expect(mockInstance).toBeInstanceOf(Singleton);
  });

  it("verifies behavior with mocked instance", () => {
    const mockInstance1 = new MockSingleton();
    const mockInstance2 = new MockSingleton();

    // Mock 인스턴스는 서로 독립적임
    expect(mockInstance1).not.toBe(mockInstance2);

    // 각 Mock 인스턴스는 독립적으로 동작
    mockInstance1.setData("Mock Data 1");
    mockInstance2.setData("Mock Data 2");

    expect(mockInstance1.getData()).toBe("Mock Data 1");
    expect(mockInstance2.getData()).toBe("Mock Data 2");
  });

  it("does not interfere with actual Singleton behavior", () => {
    const actualInstance = Singleton.getInstance();
    actualInstance.setData("Real Singleton Data");

    const mockInstance = new MockSingleton();
    mockInstance.setData("Mock Data");

    // 실제 Singleton은 MockSingleton과 독립적으로 유지됨
    expect(actualInstance.getData()).toBe("Real Singleton Data");
    expect(mockInstance.getData()).toBe("Mock Data");
  });
});

 

이 방법을 통해 싱글톤 클래스의 주요 로직은 유지하면서도, 테스트 환경에서 Mock 인스턴스를 통해 독립적인 테스트가 가능하도록 설계할 수 있습니다.. 특히 protected 생성자를 통해 생성 제한과 Mock 확장을 동시에 만족할 수 있다는 점에서 유용하죠!

 

한계 및 개선 방안

물론 위 세가지 방법 역시 1) 싱글톤 설계의 원칙 위반 가능성 존재 2) 테스트와 실제 구현 간의 불일치 가능성 존재 3) 코드 복잡성 증가 라는 문제점이 존재합니다.

 

하지만, 그렇다고 해서 단위 테스트 코드를 작성하지 않거나 원칙을 위배하는 것 역시 최대한 지양해야 합니다.

따라서 복잡한 시스템이나 단일성이 중요한 환경에서는 프레임워크에서 제공하는 DI 컨테이너를 사용하고, 싱글톤 객체를 포함한 사용처에 대한 통합 테스트를 통해 해당 모듈의 올바른 동작을 테스트하려는 노력이 추가적으로 필요합니다.

 

728x90
반응형