logo
Search검색어를 포함하는 게시물들이 최신순으로 표시됩니다.
    Table of Contents
    3장: 의존성 분리와 스텁

    이미지 보기

    3장: 의존성 분리와 스텁

    의존성을 분리하고 유지보수에 좋은 테스트 코드

    • 25.02.25 작성

    • 읽는 데 17

    TOC

    참고

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

    의존성

    의존성의 정의

    • 코드에서 의존하는 외부 요소
    • 시간, 비동기 실행, 파일 시스템, 네트워크 등

    의존성 유형

    외부로 나가는 의존성

    • 작업 단위의 종료점
    • ex. logger, DB 저장, 이메일 발송 등
    • 대부분 동사로 표현(호출, 저장, 보내기, 알림)
    • fire-and-forget 패턴

    내부로 들어오는 의존성

    • 종료점을 나타내지 않는 의존성
    • 최종 동작에 대한 요구 사항을 표시하는 것은 아님
    • 특수한 데이터나 동작을 작업 단위에 제공
    • ex. DB 쿼리 결과, 파일 내용, 네트워크 응답 결과
    • 이전 작업의 결과

    스텁(stub)

    스텁은 내부로 들어오는 의존성(간접 입력)을 대체

    • 테스트 대상 코드가 외부 시스템이나 데이터에 의존하지 않고도 동작하게 함
    • 스텁은 검증하지 않는다. 따라서 하나의 테스트에서 여러 스텁을 쓸 수 있다.

    더미 객체

    • 목적 : 테스트에서 사용될 값을 지정하는 데 사용 / SUT 메서드를 호출하는 경우 부수적인 인수로만 사용
    • 사용법 : 진입점의 매개변수로 보내거나 준비(arrange) 단계의 인수

    테스트 스텁

    • 목적 : 다른 SW 구성 요소의 간접 입력에 의존할 때 독립적으로 로직을 검증하는 데 사용
    • 사용법 : 의존성으로 주입하고 SUT에 특정 값이나 동작을 반환하도록 구성

    목(mock)

    목은 외부로 나가는 의존성(간접 출력 또는 종료점)을 대체

    • 가짜 모듈이나 객체 및 호출 여부를 검증하는 함수
    • 하나의 테스트에 목은 하나만 사용하는 것이 일반적

    테스트 스파이

    • 목적 : 다른 SW 구성 요소에 간접 출력을 보낼 때 독립적으로 로직을 검증하는 데 사용
    • 사용법 : 실제 객체의 메서드를 오버라이드하고, 오버라이드한 함수가 예상대로 호출되었는지 확인

    모의 객체

    • 목적 : 다른 SW 구성 요소에 대한 간접 출력에 의존하는 경우 독립적으로 로직을 검증하는 데 사용
    • 사용법 : 가짜 객체를 SUT 의존성으로 주입하고, 가짜 객체가 예상대로 호출되었는지 확인

    스텁을 사용하는 일반적인 설계 방식

    • 테스트를 언제 실행하든 이전 실행과 같은 결과를 보장해야 한다.
    • 이를 위해 스텁은 존재한다.

    매개변수화 방식

    const verifyPassword2 = (input, rules) => {
      const dayOfWeek = moment().day();
      if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
        throw Error('Weekend is not allowed');
      }
    
      ...
    }
    

    보다는

    const verifyPassword2 = (input, rules, today) => {
      if ([SATURDAY, SUNDAY].includes(today)) {
        throw Error('Weekend is not allowed');
      }
    
      ...
    }
    

    로 테스트 대상의 내부에서 의존성을 많이 품는 구조에서 순수 함수로의 의존성 분리가 좋다.

    • 의존성은 함수 외부에서 주입한다.
    • 이때 실제 의존성을 사용하지 않고 스텁을 사용해 상황이나 환경에 관계 없이 테스트를 일관성 있게 유지할 수 있다.
    • 이런 접근 방식을 의존성 역전(Dependency Inversion) 이라고 한다. (제어의 역전(IoC; Inversion of Control))

    의존성, 주입, 제어

    의존성(dependency)

    • 테스트에서 제어할 수 없어 테스트 환경과 코드 유지보수를 어렵게 만드는 요소
    • 시간, 파일 시스템, 네트워크, 난수 등

    제어(control)

    • 의존성의 동작 방식을 결정할 수 있는 능력
    • 의존성을 생성하는 주체가 그 의존성을 제어
    • 매개변수 전달 전: 외부 의존성이 제어
    • 매개변수 전달 후: 테스트가 의존성 제어
    • 이를 의존성 제어 역전이라고 말함

    제어의 역전(IoC; Inversion of Control)

    의존성을 외부에서 주입받도록 코드 설계를 변경하는 것

    의존성 주입(DI; Dependency Injection)

    • 주입 지점(injection point): 의존성을 주입하는 지점

    심(seam)

    • 의존성 주입 지점의 다른 말
    • SW의 서로 다른 부분이 만나는 지점
    • 단위 테스트의 유지 보수성과 가독성에 중요한 역할

    함수를 사용하는 방식

    함수 주입

    const verifyPassword2 = (input, rules, getDayFn) => {
      const dayOfWeek = getDayFn();
      if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
        throw Error('Weekend is not allowed');
      }
    
      ...
    }
    
    describe('verifyPassword3 - dummy function', () => {
      it('on weekend, throw error', () => {
        const alwaysSunday = () => SUNDAY;
        expect(() => verifyPassword2('input', 'rules', alwaysSunday)).toThrowError('Weekend is not allowed');
      });
    });
    
    • 특정 상황에서 예외를 만드는 것 가능
    • 테스트 내에서 특정한 동작을 하도록 하는 것이 가능

    부분 적용을 이용한 의존성 주입

    • 팩토리 함수는 고차 함수의 일정(미리 정의 context를 가진 함수 반환)
    • 팩토리 함수를 테스트의 준비(arrange) 단계에서 사용하고, 반환된 함수를 실행(act) 단계에서 사용
    const makeVerifier = (rules, dayOfWeekFn) => {
      return function(input) {
        // 현재 날짜가 토요일 또는 일요일인 경우 오류가 발생한다.
        if ([SATURDAY, SUNDAY].icludes(dayOfWeekF∩())) {
          throw new Error("It's the weekend!");
        }
          // 이곳에 다른 코드를 작성한다.
      }
    }
    
    describe('verifier', () => {
      test('factory method: on weekends, throws exceptions', () => {
        const alwaysSunday = () => SUNDAY;
        const verifyPassword = makeVerifier([], alwaysSunday);
        expect(() => verifyPassword('anything')).toThrowError("It's the weekend!");
      });
    });
    

    저의 의견 지금처럼 고차 함수로 한 번 더 감싸서 함수를 주입하는 이점을 잘은 모르겠네요.

    모듈을 이용한 주입

    추상화 과정

    심(seam)을 이용하고, 모듈을 추상화한다.

    const originalDependencies = {
      moment: require('moment'),
    }
    
    let dependencies = { ...originalDependencies };
    
    const inject = (fakes) => {
      Object.assign(dependencies, fakes);
      return function reset() {
        dependencies = { ...originalDependencies };
      }
    }
    
    const SUNDAY = 0;
    const SATURDAY = 6;
    
    const verifyPassword = (input) => {
      const dayOfWeek = dependencies.moment().day();
      if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
        throw Error('Weekend is not allowed');
      }
    }
    
    module.exports = {
      SUNDAY,
      SATURDAY,
      verifyPassword,
      inject,
    }
    

    변경점

    • 의존성들의 총괄 객체로 추상화. 한곳에서 관리하고 교체 가능
    • 실제 의존성을 가짜 의존성으로 대체 가능

    사용부

    const { verifyPassword, inject, SUNDAY, SATURDAY } = require('./verify-password');
    
    const injectDate = (newDay) => {
      const reset = inject({
        moment: function() {
          // moment.js 모듈의 API 위조
          return {
            day: () => newDay,
          }
        }
      });
      return reset;
    }
    
    describe('verifyPassword', () => {
      it('on weekends, throw error', () => {
        const reset = injectDate(SUNDAY);
    
        expect(() => verifyPassword('any input')).toThrowError('Weekend is not allowed');
        reset();
      });
    });
    

    장단점

    • 장점 테스트에서의 의존성 문제를 확실히 해결
    • 장점 사용이 비교적 쉬움
    • 단점 테스트가 가짜로 만든 의존성 API에 강결합

    포트와 어댑터

    • 제어할 수 없는 서드 파티 의존성은 중간 추상화를 한다. 포트(Port)와 어댑터(Adapter) 아키텍처(헥사고날(Hexagonal) 아키텍처, 어니언(Onion) 아키텍처라고도 한다)가 좋은 예시
    • 포트
      • 시스템의 내부와 외부를 연결하는 인터페이스
      • 시스템이 외부와 통신하는 방법 정의
    • 어댑터
      • 포트를 통해 들어오는 요청을 처리하는 구체적인 구현체
      • 외부 시스템과 내부 시스템의 정보 교환을 번역
    • 장점1: 유연성
      • 외부 시스템의 변경에도 어댑터만 변경하면 됨
    • 장점2: 테스트 용이성
      • 실제 외부 시스템을 사용하지 않고 가짜 어댑터를 사용해 테스트 가능
    • 장점3: 유지 보수성
      • 코드 변경이 다른 부분에 미치는 영향 최소화 가능

    저의 의견

    확실히 외부 의존성과 테스트 코드로 이어지는 중간 과정에 의존성 체인을 끊어주는 과정, 그리고 주입된 가짜 의존성을 사용하고 reset을 통해 되돌려 놓는 과정이 정교해서 좋았습니다. 하지만 코드 복잡도 관점에서 살펴보면 복잡도가 많이 복잡하고, 유지보수성이 과연 충분히 좋아졌는가 생각이 들었습니다. 정말 이렇게까지 해야 할까요...?

    생성자 함수를 사용하여 객체 지향적으로 전환

    생성자 함수란?

    • 팩토리 함수와 동일한 결과를 얻을 수 있는 보다 객체 지향적인 JS 방식
    • 호출 가능한 메서드를 가진 객체를 반환
    • new 키워드를 사용하여 이 함수를 호출하면 this 참조를 가진 특별한 객체를 얻을 수 있다.
    // *.js
    const Verifier = function(rules, dayOfWeekFn) {
      this.verify = function(input) {
        if ([SATURDAY, SUNDAY].includes(dayOfWeekFn())) {
          throw Error('Weekend is not allowed');
        }
        ...
      }
    }
    
    // *.spec.js
    const { SUNDAY, SATURDAY, Verifier } = require('./verify-password');
    
    test('constructor function: on weekends, throw error', () => {
      const alwaysSunday = () => SUNDAY;
      const verifier = new Verifier([], alwaysSunday);
      expect(() => verifier.verify('anything')).toThrowError('Weekend is not allowed');
    });
    

    객체 지향적으로 의존성을 주입하는 방법

    생성자 주입

    클래스의 생성자를 이용하여 의존성을 주입하는 설계
    // *.js
    class Verifier {
      constructor(rules, dayOfWeekFn) {
        this.rules = rules;
        this.dayOfWeekFn = dayOfWeekFn;
      }
    
      verify(input) {
        if ([SATURDAY, SUNDAY].includes(this.dayOfWeekFn())) {
          throw Error('Weekend is not allowed');
        }
        ...
      }
    }
    
    // *.spec.js
    const { SUNDAY, SATURDAY, Verifier } = require('./verify-password');
    
    test('constructor function: on weekends, throw error', () => {
      const alwaysSunday = () => SUNDAY;
      const verifier = new Verifier([], alwaysSunday);
      expect(() => verifier.verify('anything')).toThrowError('Weekend is not allowed');
    });
    
    • 객체 지향적 구조를 위해 코드가 점점 장황해짐(객체 지향 특징)
    • 반면 함수형 스타일의 코드는 더 간결한 경우가 많음
    • 저의 의견 저는 함수형으로 할게요^-^

    유지보수성

    • 생성자를 사용하여 클래스를 만드는 과정을 팩토리 함수로 분리하면 좋다.
    • 생성자 함수의 로직이 변경되어 다수의 테스트가 한꺼번에 깨지더라도 생성자 함수만 수정하면 테스트 복구 가능

    함수 대신 객체 주입

    날짜를 조회하는 단순 함수가 아니라, 완전히 context를 가지는 객체를 주입하는 것

    // real-time-provider.js
    import moment from 'moment';
    
    const RealTimeProvider = () => {
      this.getDay = () => moment().day();
    }
    
    // verifier.js
    class Verifier {
      constructor(rules, timeProvider) {
        this.rules = rules;
        this.timeProvider = timeProvider;
      }
    
      verify(input) {
        if ([SATURDAY, SUNDAY].includes(this.timeProvider.getDay())) {
          throw Error('Weekend is not allowed');
        }
      }
    }
    

    그리고 Verfier 클래스를 생성하는 VerfierFactory를 만든다.

    // verifier.js
    const verifierFactory = (rules) => {
      return new Verifier(rules, new RealTimeProvider());
    }
    

    이렇게 되면, 실제 moment 의존성을 사용하지 않는 FakeTimeProvider를 만들어 사용할 수 있다.

    // fake-time-provider.js
    const FakeTimeProvider = (fakeDay) => {
      this.getDay = () => fakeDay;
    }
    
    // verifier.spec.js
    const { SUNDAY, SATURDAY, verifierFactory } = require('./verifier');
    
    test('verifier function: on weekends, throw error', () => {
      const verifier = new Verifier([], new FakeTimeProvider(SUNDAY));
      expect(() => verifier.verify('anything')).toThrowError('Weekend is not allowed');
    });
    

    공통 인터페이스 추출

    JS의 유연함을 줄이고 준수해야 하는 구조적 규칙을 정의

    export interface TimeProvider {
      getDay(): number;
    }
    

    이런 인터페이스를 정의했다. 의미는 return 타입으로 number를 가지는 getDay 메서드를 가지기만 하면 된다는 것.

    export class RealTimeProvider implements TimeProvider {
      getDay() {
        return moment().day();
      }
    }
    

    그리고 위처럼 실제 moment 의존성을 사용하는 클래스를 만들었다.

    export class Verifier {
      private _timeProvider: TimeProvider;
    
      constructor(rules: Rule[], timeProvider: TimeProvider) {
        this._rules = rules;
        this._timeProvider = timeProvider;
      }
    
      verify(input: string): string[] {
        const isWeekend = [SATURDAY, SUNDAY].filter(x => x === this._timeProvider.getDay());
    
        if (isWeekend.length > 0) {
          throw Error('Weekend is not allowed');
        }
    
        ...
      }
    }
    

    그리고 timeProvider를 실제로 활용하는 클래스를 이렇게 만들 때, 가짜 timeProvider를 구현부는 아래와 같다.

    class FakeTimeProvider implements TimeProvider {
      fakeDay: number;
    
      getDay() {
        return this.fakeDay;
      }
    }
    

    이렇게 하면 테스트 코드는 아래와 같이 작성할 수 있다.

    describe('verifier', () => {
      it('on weekends, throw error', () => {
        const stubTimeProvider = new FakeTimeProvider();
        stubTimeProvider.fakeDay = SUNDAY;
        const verifier = new Verifier([], stubTimeProvider);
    
        expect(() => verifier.verify('anything')).toThrowError('Weekend is not allowed');
      });
    });
    

    총평

    처음에는 테스트 대상(함수) 내부에서 의존성을 바로 호출했어요. 의도적으로 만들어낸 테스트하기 안 좋은 케이스였어요. 테스트 대상-외부 의존성이 바로 연결되어 일관성을 유지하지 못했죠.

    시작은 'seam'을 뚫어서 테스트 대상 외부에서 의존성을 주입하고, 테스트 대상은 이를 그저 활용하는 방식이 기초에요. 그리고 그 주입을 단순히 매개변수로 할 것인지, 함수로 할 것인지, 모듈로 할 것인지, 아예 공간을 넘길 것인지 등 규모를 키워가며 여러 방법론을 소개했어요.

    중간에는 굳이 이렇게 해야 하나? 라는 생각이 들 정도로 복잡한 케이스들이 몇 개 있었는데 지나고나니 모든 방법들을 나열한 거구나 생각이 들고 다시 되돌아가서 읽어보게 되더라고요.

    저자가 내용의 말미에 이야기한 것처럼 '정답은 없다'는 게 맞다고 생각해요. 그 모든 스펙트럼을 맛 보았다는 것에 의의를 두고 싶어요. (저는 선택한다면 매개변수 주입 방식을 선택할 것 같네요.)

    profile

    FE Developer 박승훈

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