logo
Search검색어를 포함하는 게시물들이 최신순으로 표시됩니다.
    Table of Contents
    5장: 격리 프레임워크

    이미지 보기

    5장: 격리 프레임워크

    격리 프레임워크를 사용한 테스트

    • 25.03.09 작성

    • 읽는 데 12

    TOC

    참고

    본 내용은 단위 테스트의 기술을 읽고 정리한 내용입니다.
    책의 내용과 함께 개인적인 의견과 생각, 학습을 담아 작성하였습니다.

    격리 프레임워크

    격리 프레임워크의 정의

    • 런타임에 가짜 객체를 생성하고 설정할 수 있는 재사용 가능한 라이브러리
    • 객체나 함수 형태의 목이나 스텁을 동적으로 생성, 구성, 검증할 수 있게 해주는 프로그래밍 가능한 API

    격리 프레임워크의 효용

    • 작업 단위를 의존성에서 격리시킬 수 있다.
    • 반복적으로 코드를 작성하는 일을 줄여준다.
    • 테스트 지속성을 높여준다.

    격리 프레임워크 주의사항

    • 프레임워크를 남용할 수 있게 된다.
    • 테스트를 읽거나 신뢰할 수 없는 상황이 될 수 있다.

    격리 프레임워크의 유형

    느슨함의 정도로 구분
    • 느슨한 타입
      • 일반적으로 적은 설정과 보일러 플레이트
      • 함수형 스타일 코드에 적합
      • 제스트와 사이넌 등
    • 정적 타입
      • 더 객체 지향적
      • 타입스크립트 친화적
      • 전체 클래스와 인터페이스를 다룰 때 유용
      • substitute.js 등
    어떤 의존성을 가짜로 만들어야 하는지가 관건
    • 모듈 의존성
      • import, require
      • 느슨한 타입(ex. jest)
    • 함수형 의존성
      • 단일 함수와 고차 함수, 간단한 매개변수와 값
      • 느슨한 타입(ex. jest)
    • 객체 전체, 객체 계층 구조, 인터페이스
      • 객체지향적 프레임워크(substitute.js)

    동적으로 가짜 모듈 만들기

    jest.mock

    • jest에서 mock을 만들고 검증할 수 있는 방법
    • 테스트 파일의 가장 위쪽에 jest.mock([모듈 이름])으로 mock을 만들 대상 지정
    • 테스트에서 가짜 모듈을 불러와(require) 원하는 방식으로 사용
    jest.mock("./logger");
    jest.mock("./configuration-service");
    
    const { stringMatching } = expect;
    const { verifyPassword } = require("./password-verifier");
    const mockLoggerModule = require("./logger");
    const stubConfigModule = require("./configuration-service");
    
    describe("테스트 케이스", () => {
      afterEach(jest.resetAllMocks);
    
      test("테스트 내용", () => {
        // 가짜 모듈의 getLogLevel 함수의 반환값이 'info'를 반환하도록 설정
        stubConfigModule.getLogLevel.mockReturnValue("info");
    
        verifyPassword("anything", []);
    
        // 가짜 모듈의 모의 함수 호출 여부 검증
        expect(mockLoggerModule.info).toHaveBeenCalledWith(stringMatching(/PASS/));
      });
    
      test("테스트 내용", () => {
        // 가짜 모듈의 getLogLevel 함수의 반환값이 'info'를 반환하도록 설정
        stubConfigModule.getLogLevel.mockReturnValue("debug");
    
        verifyPassword("anything", []);
    
        // 가짜 모듈의 모의 함수 호출 여부 검증
        expect(mockLoggerModule.debug).toHaveBeenCalledWith(stringMatching(/PASS/));
      });
    });
    

    jest API 주의점

    • jest는 모두 mock이라는 단어를 사용한다.
      • stub이라는 단어를 mock과 동일한 의미로 사용하면 좋다.
    • jest.mock을 최상단에 두는 이유
      • JS 호이스팅 때문

    직접 의존성의 추상화 고민

    • jest.mock API의 장점
      • 내장된 의존성 때문에 쉽게 변경할 수 없는 모듈을 테스트하려는 요구사항을 충족할 수 있다.
    • jest.mock API의 단점
      • 제어권이 있는 코드까지 모두 가짜로 만들어 버린다.
    • 직접 의존성이 문제가 되는 이유
      • 추상화된 API가 아닌, 모듈 API를 테스트에서 직접 가짜로 만들어야 한다.
      • 이렇게 되면 모듈의 원래 API 설계가 테스트 구현에 강결합된다.
      • API가 변경될 때마다 수많은 테스트를 함께 변경해야 한다.
      • 포트와 어댑터 아키텍처를 통해 유지보수성을 유지할 수 있다.

    함수형 스타일의 동적 목과 스텁

    // 수동 접근 방식
    test("테스트 내용", () => {
      let logged = "";
      const mockLog = { info: (text) => (logged = text) };
      const passVerify = makeVerifier([], mockLog);
    
      passVerify("any input");
    
      expect(logged).toMatch(/PASS/);
    });
    
    // jest.fn 사용
    test("테스트 내용", () => {
      const mockLog = { info: jest.fn()};
      const passVerify = makeVerifier([], mockLog);
    
      passVerify("any input");
    
      expect(mockLog.info).toHaveBeenCalledWith(expect.stringMatching(/PASS/));
    });
    
    • jest.fn()을 사용하여 모의 함수 제작
      • 단일 함수 기반의 mock과 stub에 잘 맞는다.
    • toHaveBeenCalledWith 메서드를 사용하여 모의 함수가 호출되었는지 검증
    • expect.stringMatching은 jest의 Matcher
      • Matcher: 함수에 전달되는 매개변수 값을 검증하는 유틸리티 함수
      • 전체 Matcher는 expect 공식 문서 참고

    객체 지향 스타일의 동적 목과 스텁

    아래와 같은 복잡한 인터페이스를 살펴보자.

    export interface IComplecatedLogger {
      info(text: string, method: string): void;
      debug(text: string, method: string): void;
      warn(text: string, method: string): void;
      error(text: string, method: string): void;
    }
    

    이 인터페이스의 모의 객체를 만드려면 이렇게 복잡해진다.

    describe("...", () => {
      class FakeLogger implements IComplecatedLogger {
        debugText = "";
        debugMethod = "";
        infoText = "";
        infoMethod = "";
        ...
    
        debug(text: string, method: string) {
          this.debugText = text;
          this.debugMethod = method;
        }
    
        info(text: string, method: string) {
          this.infoText = text;
          this.infoMethod = method;
        }
    
        ...
      }
    
      test("...", () => {
        const mockLogger = new FakeLogger();
        const verifier = new PasswordVerifier([], mockLogger);
    
        verifier.verify("any input");
    
        expect(mockLogger.infoText).toMatch(/PASS/);
      })
    })
    

    이를 jest.fn을 사용하면 이렇게 개선할 수 있다.

    describe("...", () => {
      test("...", () => {
        const mockLogger: IComplecatedLogger = {
          info: jest.fn(),
          debug: jest.fn(),
          warn: jest.fn(),
          error: jest.fn(),
        };
    
        const verifier = new PasswordVerifier([], mockLogger);
    
        verifier.verify("any input");
    
        expect(mockLogger.info).toHaveBeenCalledWith(expect.stringMatching(/PASS/));
      })
    })
    
    • 단순히 객체를 정의하고 인터페이스의 각 함수에 jest.fn()으로 만든 모의 함수를 할당
    • 주의사항 : 인터페이스가 변경되는 경우 모의 객체 정의 코드를 수정해주어야 한다.
    • 저의 의견 : 이건 위의 상황도 같지 않나요?
    • 해결방안 : 모의 객체를 생성하는 팩토리 함수를 만들어 한 곳에서 처리

    동적 스텁 설정

    Jest는 모듈과 함수 의존성의 반환 값을 조작하는 기능을 제공한다.

    mockReturnValue

    test("...", () => {
      const stubFunc = jest.fn().mockReturnValue("abc");
      expect(stubFunc()).toBe("abc");
      expect(stubFunc()).toBe("abc");
      expect(stubFunc()).toBe("abc");
    });
    

    mockReturnValueOnce

    test("...", () => {
      const stubFunc = jest.fn()
              .mockReturnValueOnce("a")
              .mockReturnValueOnce("b")
              .mockReturnValueOnce("c");
    
      expect(stubFunc()).toBe("a");
      expect(stubFunc()).toBe("b");
      expect(stubFunc()).toBe("c");
      expect(stubFunc()).toBe(undefined); // 더 이상 반환할 값이 없으면 undefined
    });
    

    mockImplementation

    const mockFn = jest.fn((scalar: number) => 42 + scalar);
    
    mockFn(0); // 42
    mockFn(1); // 43
    
    mockFn.mockImplementation(scalar => 36 + scalar);
    
    mockFn(2); // 38
    mockFn(3); // 39
    

    mockImplementationOnce

    test("...", () => {
      const mockFn = jest
        .fn(() => 'default')
        .mockImplementationOnce(() => 'first call')
        .mockImplementationOnce(() => 'second call');
    
      mockFn(); // 'first call'
      mockFn(); // 'second call'
      mockFn(); // 'default'
      mockFn(); // 'default'
    });
    

    격리 프레임워크의 장점과 함정

    장점

    • 더 쉬운 가짜 모듈 생성
    • 더 쉬운 값/오류 생성
    • 더 쉬운 가짜 객체 생성

    잠재적 위험요소

    • 대부분의 경우 모의 객체가 불필요하다.
      • 모의 객체는 대부분의 단위 테스트에서 기본적으로 사용하면 안 된다.
      • 반환값을 검증하거나, 작업 단위의 동작 변화를 외부에서 확인하라.
      • 이 밖에도 더 쉬운 방법으로 우회할 수 있는지 확인하라.
    • 읽고 이해하기 어려운 테스트 코드
      • 검증 단계를 너무 많이 추가하지 말라.
      • 테스트를 더 작은 하위 테스트로 쪼개라.
    • 잘못된 대상 검증
      • 할 수 있기 때문이 아니라, 필요하고 중요한 부분을 검증하라.
    • 테스트당 하나 이상 목을 사용
      • 하나의 테스트는 하나의 관심사를 검증하라.
      • 각 종료점마다 별도의 테스트를 작성하라.
      • 테스트 이름을 일반적으로 짓지 말고 기능적으로 명확히 지어라.
      • 만약 그럴 수 없다면 테스트를 분리하라.
    • 테스트의 과도한 명세화
      • 테스트에 검증 항목이 너무 많으면 깨지기 쉽다.
      • mock 대신 stub을 사용하라.
      • 가능한 stub을 mock으로 사용하지 말라.

    총평

    jest 메서드 학습?

    격리 프레임워크... 보다는 그냥 jest의 fn()이라든지, matcher 등을 더 알아볼 수 있는 기회로 보였어요. 아직도 사실 격리 프레임워크라는 용어가 좀 와닿지만은 않는 것 같습니다.

    테스트의 함정에 집중하라

    격리 프레임워크의 함정이라고 소개된 부분에서, 제 생각엔 모든 테스트의 기저를 관통하는 가장 중요한 개념이 등장했습니다. "단지 가능하기 때문에 검증하지 말고, 실제로 의미 있는 동작을 검증하라."

    이게 진짜 중요한 것 같아요. 현업에서 스토리북 인터렉션 테스트를 작성하는데, 실제로 테스트 작성에 심취해서 불필요한 것들을 검증하는 경우가 더러 있었어요. 제가 딱 그런 테스트 초보자들이 하는 실수를 저지르고 있지 않았나 생각해봤습니다.

    가독성이 좋고, 꼭 필요한 것들을 검증하는 테스트를 짜는 것에 집중해야 된다는 사실을 다시 되새겼습니다.

    profile

    FE Developer 박승훈

    노력하는 자는 즐기는 자를 이길 수 없다