fp-ts둜 Typescript ν•¨μˆ˜ν˜• ν”„λ‘œκ·Έλž˜λ° μ‹œμž‘ν•˜κΈ° 0

λ³Έ ν¬μŠ€νŠΈλŠ” fp-ts 곡식 λ¬Έμ„œμ˜ Learning Resources에 μžˆλŠ” Getting Startedμ—μ„œ μ†Œκ°œν•˜λŠ” λ¬Έμ„œλ“€μ„ λ²ˆμ—­ν•˜λ©° ν•™μŠ΅ν•œ λ¬Έμ„œμž…λ‹ˆλ‹€. 원본 λ¬Έμ„œλŠ” λ§ν¬μ—μ„œ 확인할 수 있으며 μž‘μ„±ν•œ μ½”λ“œλ“€μ€ μ—¬κΈ°μ—μ„œ 확인할 수 μžˆμŠ΅λ‹ˆλ‹€.

fp-tsλ₯Ό μ΄μš©ν•œ λΉ„ν•¨μˆ˜ν˜• μ½”λ“œμ™€μ˜ μƒν˜Έ μš΄μš©μ„±

λ•Œλ‘œλŠ” ν•¨μˆ˜ν˜• ν”„λ‘œκ·Έλž˜λ° μŠ€νƒ€μΌλ‘œ μž‘μ„±λ˜μ§€ μ•Šμ€ μ½”λ“œμ™€ μƒν˜Έ μš΄μ˜ν•΄μ•Ό ν•˜λŠ” κ²½μš°κ°€ μžˆμŠ΅λ‹ˆλ‹€. κ·ΈλŸ¬ν•œ μƒν™©μ—μ„œ fp-tsλ₯Ό μ΄μš©ν•΄ ν•΄κ²°ν•˜λŠ” 방법을 μ‚΄νŽ΄λ³΄κ² μŠ΅λ‹ˆλ‹€.

Sentinels

  • 유슀 μΌ€μ΄μŠ€: μ‹€νŒ¨ν•  수 있고 μ½”λ“œ λ„λ©”μΈμ˜ νŠΉμˆ˜ν•œ 값을 λ°˜ν™˜ν•  수 μžˆλŠ” API
  • μ˜ˆμ‹œ: Array.prototype.findIndex
  • ν•΄κ²° 방법: Option
import { Option, none, some } from 'fp-ts/Option';

function findIndex<A>(
  as: Array<A>,
  predicate: (a: A) => boolean
): Option<number> {
  const index = as.findIndex(predicate);
  return index === -1 ? none : some(index);
}

findIndex ν•¨μˆ˜ ν…ŒμŠ€νŠΈν•˜κΈ°

Array.prototype.findIndexλŠ” 값을 찾지 λͺ»ν–ˆμ„ 경우 -1μ΄λΌλŠ” νŠΉμˆ˜ν•œ 값을 λ°˜ν™˜ν•©λ‹ˆλ‹€.

Array.prototype.findIndex ν•¨μˆ˜λ₯Ό Option νƒ€μž…μ„ μ΄μš©ν•΄ fp-ts와 ν•¨κ»˜ μ‚¬μš©ν•  수 μžˆλ„λ‘ λ³€κ²½ν–ˆλ‹€. -1이 λ°˜ν™˜λ  경우 none을 λ°˜ν™˜ν•˜κ³  그렇지 μ•Šμ€ 경우 some을 λ°˜ν™˜ν•˜λ„λ‘ μž‘μ„±ν–ˆμŠ΅λ‹ˆλ‹€.

μž‘μ„±ν•œ findIndex ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜κΈ° μœ„ν•œ νŒ¨ν‚€μ§€λŠ” μ•„λž˜μ™€ κ°™μŠ΅λ‹ˆλ‹€.

  • findIndex ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜κΈ° μœ„ν•œ νŒ¨ν‚€μ§€
import * as N from 'fp-ts/number';
import { isSome, none, some, getEq, Option, isNone } from 'fp-ts/lib/Option';
import { findIndex } from '../findIndex';
  • findIndexΒ ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜λŠ” μ½”λ“œ

ν…ŒμŠ€νŠΈμ½”λ“œμ—μ„œλŠ” findIndexκ°€ predicate의 쑰건에 λ§žλŠ” 값을 μ°Ύμ•˜μ„ 경우 isSome ν•¨μˆ˜λ₯Ό μ΄μš©ν•΄ some인지 ν™•μΈν•œ ν›„ ν•΄λ‹Ή κ°’μ˜ 인덱슀λ₯Ό some으둜 감싸 λ°˜ν™˜λœ κ°’κ³Ό 같은지 ν™•μΈν•©λ‹ˆλ‹€.

쑰건에 λ§žλŠ” 값을 찾지 λͺ»ν–ˆμ„ 경우 isNone ν•¨μˆ˜λ₯Ό μ΄μš©ν•΄ none인지 ν™•μΈν•œ ν›„ noneκ³Ό 같은지 ν™•μΈν•©λ‹ˆλ‹€.

describe('findIndexν•¨μˆ˜ ν…ŒμŠ€νŠΈ (Sentinels)', () => {
  let array: number[] = [1, 2, 3, 5];
  let result: Option<number>;
  const E = getEq(N.Eq);
  it('findIndexν•¨μˆ˜κ°€ μ‘΄μž¬ν•˜λŠ” 값을 μ°Ύμ•˜μ„ 경우', () => {
    result = findIndex(array, a => a === 1);
    expect(isSome(result)).toBeTruthy();
    expect(E.equals(result, some(0))).toBeTruthy();
  });
  it('findIndexν•¨μˆ˜κ°€ 값을 찾지 λͺ»ν–ˆμ„ 경우', () => {
    result = findIndex(array, a => a === 4);
    expect(isNone(result)).toBeTruthy();
    expect(E.equals(result, none)).toBeTruthy();
  });
});

undefined와 null

  • 유슀 μΌ€μ΄μŠ€: μ‹€νŒ¨ν•˜κ³  undefinedλ‚˜ null을 λ°˜ν™˜ν•  수 μžˆλŠ” API
  • μ˜ˆμ‹œ: Array.prototype.find
  • ν•΄κ²° 방법: Option, fromNullable
import { Option, fromNullable } from 'fp-ts/Option';

export function find<A>(as: Array<A>, predicate: (a: A) => boolean): Option<A> {
  return fromNullable(as.find(predicate));
}

findν•¨μˆ˜ ν…ŒμŠ€νŠΈν•˜κΈ°

Array.prototype에 μžˆλŠ” find ν•¨μˆ˜λŠ” nullμ΄λ‚˜ undefined와 같은 Falsy값을 λ°˜ν™˜ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

fromNullable ν•¨μˆ˜λ₯Ό μ΄μš©ν•΄ find ν•¨μˆ˜λ₯Ό 감싸 Option νƒ€μž…μ„ λ°˜ν™˜ν•˜λŠ” ν•¨μˆ˜λ‘œ λ§Œλ“€μ—ˆμŠ΅λ‹ˆλ‹€.

μž‘μ„±ν•œ find ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜κΈ° μœ„ν•œ νŒ¨ν‚€μ§€λŠ” μ•„λž˜μ™€ κ°™μŠ΅λ‹ˆλ‹€.

  • find ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜κΈ° μœ„ν•œ νŒ¨ν‚€μ§€
import * as N from 'fp-ts/number';
import { isSome, none, some, getEq, Option, isNone } from 'fp-ts/lib/Option';
import { find } from '../find';
  • findΒ ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜λŠ” μ½”λ“œ

μƒˆλ‘œ μž‘μ„±ν•œ find ν•¨μˆ˜λŠ” 값을 μ°Ύμ•˜μ„ 경우 some νƒœκ·Έλ₯Ό κ°–λŠ” 값을 λ°˜ν™˜ν•©λ‹ˆλ‹€. 값을 μ°Ύμ•˜μ„ κ²½μš°μ—λŠ” isSomeΒ λ©”μ„œλ“œκ°€ trueλ₯Ό λ°˜ν™˜ν•˜λŠ”μ§€ ν™•μΈν•˜κ³  값이 some으둜 감싸진 κ°’κ³Ό 같은지 ν™•μΈν•©λ‹ˆλ‹€.

λ°˜λ©΄μ— 값을 찾지 λͺ»ν–ˆμ„ 경우 none νƒœκ·Έλ₯Ό κ°–λŠ” 값을 λ°˜ν™˜ν•©λ‹ˆλ‹€. 값을 찾지 λͺ»ν–ˆμ„ κ²½μš°μ—λŠ” isNone λ©”μ„œλ“œκ°€ trueλ₯Ό λ°˜ν™˜ν•˜λŠ”μ§€ ν™•μΈν•˜κ³  값이 noneκ³Ό 같은지 ν™•μΈν•©λ‹ˆλ‹€.

describe('findν•¨μˆ˜ ν…ŒμŠ€νŠΈ (undefined와 null)', () => {
  let array: number[] = [1, 2, 3, 5];
  let result: Option<number>;
  const E = getEq(N.Eq);
  it('findν•¨μˆ˜κ°€ μ‘΄μž¬ν•˜λŠ” 값을 μ°Ύμ•˜μ„ 경우', () => {
    result = find(array, a => a === 1);
    expect(isSome(result)).toBeTruthy();
    expect(E.equals(result, some(1))).toBeTruthy();
  });
  it('findν•¨μˆ˜κ°€ 값을 찾지 λͺ»ν–ˆμ„ 경우', () => {
    result = find(array, a => a === 4);
    expect(isNone(result)).toBeTruthy();
    expect(E.equals(result, none)).toBeTruthy();
  });
});

μ˜ˆμ™Έ

  • 유즈 μΌ€μ΄μŠ€: throw될 수 μžˆλŠ” API
  • μ˜ˆμ‹œ: JSON.parse
  • ν•΄κ²° 방법: Eiter, tryCatch
import { Either, tryCatch } from 'fp-ts/Either';

function parse(s: string): Either<Error, unknown> {
  return tryCatch(
    () => JSON.parse(s),
    reason => new Error(String(reason))
  );
}

parse ν•¨μˆ˜ ν…ŒμŠ€νŠΈν•˜κΈ°

JSON.parse ν•¨μˆ˜λŠ” 인자둜 잘λͺ»λœ λ¬Έμžμ—΄μ΄ 전달될 경우 μ˜ˆμ™Έλ₯Ό λ°œμƒμ‹œν‚΅λ‹ˆλ‹€. fp-ts의 tryCatchλ₯Ό μ΄μš©ν•˜λ©΄ fp-ts와 ν•¨κ»˜ μ‚¬μš©ν•  수 μžˆλ„λ‘ Either νƒ€μž…μ„ λ°˜ν™˜μ‹œν‚¬ 수 μžˆμŠ΅λ‹ˆλ‹€.

μž‘μ„±ν•œ parse ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜κΈ° μœ„ν•œ νŒ¨ν‚€μ§€λŠ” μ•„λž˜μ™€ κ°™μŠ΅λ‹ˆλ‹€.

  • parse ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜κΈ° μœ„ν•œ νŒ¨ν‚€μ§€
import { isLeft, isRight, getOrElse } from 'fp-ts/Either';
import { parse } from '../parse';
  • parseΒ ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜λŠ” μ½”λ“œ

parse ν•¨μˆ˜κ°€ μ„±κ³΅ν•˜λŠ” κ°’κ³Ό μ‹€νŒ¨ν•˜λŠ” 값을 μ€€λΉ„ν•œ ν›„ parse ν•¨μˆ˜κ°€ μ •μƒμ μœΌλ‘œ μ‹€ν–‰λ˜λŠ” 경우 Either νŒ¨ν‚€μ§€μ— μžˆλŠ” isRight ν•¨μˆ˜λ₯Ό μ‚¬μš©ν•΄ μ •μƒμ μœΌλ‘œ μ½”λ“œκ°€ μ‹€ν–‰λ˜μ—ˆλŠ”μ§€ ν™•μΈν•©λ‹ˆλ‹€. κ·Έ ν›„ getOrElse ν•¨μˆ˜λ₯Ό μ΄μš©ν•΄ Rightμ—μ„œ 값을 κΊΌλ‚΄ κΈ°λŒ€ν•œ 객체와 값이 같은지 ν™•μΈν•©λ‹ˆλ‹€.

λ°˜λ©΄μ— μ‹€νŒ¨ν–ˆμ„ λ•ŒλŠ” isLeft ν•¨μˆ˜λ₯Ό μ΄μš©ν•΄ Leftκ°€ λ°˜ν™˜λ˜μ—ˆλŠ”μ§€ ν™•μΈν•©λ‹ˆλ‹€. getOrElse ν•¨μˆ˜μ˜ 인자둜 μ „λ‹¬λœ ν•¨μˆ˜κ°€ λ°˜ν™˜ν•œ κ°’κ³Ό κΈ°λŒ€ν•˜λŠ” 값이 같은지 ν™•μΈν•©λ‹ˆλ‹€.

describe('parseν•¨μˆ˜ ν…ŒμŠ€νŠΈ (μ˜ˆμ™Έ)', () => {
  const success = '{"a": 1, "b": 2}';
  const fail = '{"a": 1, "b"}';
  let result;
  it('parseν•¨μˆ˜κ°€ μ •μƒμ μœΌλ‘œ 싀행됐을 경우', () => {
    result = parse(success);
    expect(isRight(result)).toBeTruthy();
    expect(getOrElse(() => ({ error: true }))(result)).toMatchObject({
      a: 1,
      b: 2,
    });
  });
  it('parseν•¨μˆ˜ μ‹€ν–‰ 쀑 μ˜ˆμ™Έκ°€ λ°œμƒν–ˆμ„ 경우', () => {
    result = parse(fail);
    expect(isLeft(result)).toBeTruthy();
    expect(getOrElse(() => ({ a: 1 }))(result)).toMatchObject({
      a: 1,
    });
  });
});

λ¬΄μž‘μœ„ κ°’

  • 유즈 μΌ€μ΄μŠ€: 비결정둠적인 값을 λ°˜ν™˜ν•˜λŠ” API
  • μ˜ˆμ‹œ: Math.random
  • ν•΄κ²° 방법: IO
import { IO } from 'fp-ts/IO';

const random: IO<number> = () => Math.random();

random ν•¨μˆ˜ ν…ŒμŠ€νŠΈν•˜κΈ°

Math.random ν•¨μˆ˜λŠ” μ ˆλŒ€ μ‹€νŒ¨ν•˜μ§€ μ•Šμ§€λ§Œ, 비결정둠적인 값을 λ°˜ν™˜ν•©λ‹ˆλ‹€. λ”°λΌμ„œ fp-ts와 ν•¨κ»˜ μ‚¬μš©ν•˜κΈ° μœ„ν•΄μ„œ IO νƒ€μž…μœΌλ‘œ 감싸 값을 λ°˜ν™˜ν•˜λ©΄ λ©λ‹ˆλ‹€.

μž‘μ„±ν•œ random ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜κΈ° μœ„ν•œ νŒ¨ν‚€μ§€λŠ” μ•„λž˜μ™€ κ°™μŠ΅λ‹ˆλ‹€.

  • random ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜κΈ° μœ„ν•œ νŒ¨ν‚€μ§€
import { random } from '../random';
  • randomΒ ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜λŠ” μ½”λ“œ

Math.random은 μ•žμ—μ„œ μ„€λͺ…ν–ˆλ“―이 비결정둠적인 값을 λ°˜ν™˜ν•©λ‹ˆλ‹€. λ”°λΌμ„œ 값을 mockingν•˜λ„λ‘ λ„μ™€μ£ΌλŠ” jest.fn ν•¨μˆ˜κ°€ λ°˜ν™˜ν•œ jest.Mock μΈμŠ€ν„΄μŠ€μ˜ mockReturnValue ν•¨μˆ˜λ₯Ό μ΄μš©ν•΄ κ³ μ •μ μœΌλ‘œ 값을 λ°˜ν™˜ν•  수 있게 ν•΄μ€λ‹ˆλ‹€. κ·Έ ν›„ mocking된 값이 μž‘μ„±ν•œ random ν•¨μˆ˜κ°€ λ°˜ν™˜ν•˜λŠ” κ°’κ³Ό 같은지 ν™•μΈν•©λ‹ˆλ‹€.

describe('randomν•¨μˆ˜ ν…ŒμŠ€νŠΈ (랜덀)', () => {
  let result;
  it('Math.randomν•¨μˆ˜λ₯Ό mockingν•΄ ν…ŒμŠ€νŠΈν•˜κΈ°', () => {
    Math.random = jest.fn().mockReturnValue(0.5);
    result = random();
    expect(result).toBe(0.5);
  });
});

동기 λΆ€μˆ˜ 효과

  • 유즈 μΌ€μ΄μŠ€: μ „μ—­ μƒνƒœλ₯Ό μ½κ±°λ‚˜ μ“°λŠ” API
  • μ˜ˆμ‹œ: localStorage.getItem
  • ν•΄κ²° 방법: IO
import { Option, fromNullable } from 'fp-ts/Option';
import { IO } from 'fp-ts/IO';

function getItem(key: string): IO<Option<string>> {
  return () => fromNullable(localStorage.getItem(key));
}

getItem ν•¨μˆ˜ ν…ŒμŠ€νŠΈν•˜κΈ°

localStorage.getItem ν•¨μˆ˜λŠ” 값을 찾지 λͺ»ν•˜λ©΄ null을 λ°˜ν™˜ν•˜λŠ” ν•¨μˆ˜μž…λ‹ˆλ‹€. λ”°λΌμ„œ fromNullable을 μ΄μš©ν•΄ 값을 찾지 λͺ»ν•˜λ©΄ none을 λ°˜ν™˜ν•˜κ³  값을 μ°Ύμ•˜μ„ 경우 some을 λ°˜ν™˜ν•˜λ„λ‘ κ΅¬ν˜„ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

μž‘μ„±ν•œ getItem ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜κΈ° μœ„ν•œ νŒ¨ν‚€μ§€λŠ” μ•„λž˜μ™€ κ°™μŠ΅λ‹ˆλ‹€.

  • getItem ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜κΈ° μœ„ν•œ νŒ¨ν‚€μ§€
import * as S from 'fp-ts/string';
import { isSome, isNone, none, some, getEq, Option } from 'fp-ts/Option';
import { getItem } from '../getItem';
  • getItemΒ ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜λŠ” μ½”λ“œ

localStorage.getItem ν•¨μˆ˜λŠ” λΈŒλΌμš°μ €μƒμ—μ„œ μ‚¬μš©ν•  수 μžˆλŠ” ν•¨μˆ˜μ΄λ―€λ‘œ jest.fn을 μ΄μš©ν•΄ μ›ν•˜λŠ” keyκ°€ 듀어왔을 κ²½μš°μ—λ§Œ 값을 λ°˜ν™˜ν•˜λ„λ‘ mockingν•΄ μ€λ‹ˆλ‹€. μ—¬κΈ°μ„œ μ£Όμ˜ν•΄μ•Ό ν•  점은 κ΅¬ν˜„ν•œ getItem ν•¨μˆ˜λŠ” ν•¨μˆ˜λ₯Ό λ°˜ν™˜ν•˜λŠ” ν•¨μˆ˜μ΄λ―€λ‘œ ν•¨μˆ˜ 호좜 μ—°μ‚°μžλ₯Ό ν•œ 번 더 μ‚¬μš©ν•΄μ•Ό ν•©λ‹ˆλ‹€.

getItem ν•¨μˆ˜κ°€ λ°˜ν™˜ν•œ ν•¨μˆ˜κ°€ μ„±κ³΅μ μœΌλ‘œ 값을 가져왔을 경우 some인지 ν™•μΈν•œ ν›„ 값이 같은지 비ꡐ해주고 값을 찾지 λͺ»ν•΄ none을 λ°˜ν™˜ν•  경우 none인지 ν™•μΈν•©λ‹ˆλ‹€.

describe('getItemν•¨μˆ˜ ν…ŒμŠ€νŠΈ (동기 λΆ€μˆ˜ 효과)', () => {
  window.localStorage.__proto__.getItem = jest.fn(key => {
    if (key === 'success') return 'success';
    return null;
  });
  let result: Option<string>;
  const E = getEq(S.Eq);
  it('getItemν•¨μˆ˜κ°€ 값을 μ •μƒμ μœΌλ‘œ κ°€μ Έμ˜¨ 경우', () => {
    result = getItem('success')();
    expect(isSome(result)).toBeTruthy();
    expect(E.equals(result, some('success'))).toBeTruthy();
  });
  it('getItemν•¨μˆ˜κ°€ 값을 κ°€μ Έμ˜€μ§€ λͺ»ν–ˆμ„ 경우', () => {
    result = getItem('fail')();
    expect(isNone(result)).toBeTruthy();
    expect(E.equals(result, none)).toBeTruthy();
  });
});
  • 유즈 μΌ€μ΄μŠ€: μ „μ—­ μƒνƒœλ₯Ό 읽고 / μ“°κ³  throwν•  μˆ˜μžˆλŠ” API
  • μ˜ˆμ‹œ: readFileSync
  • ν•΄κ²° 방법: IOEither, tryCatch
import * as fs from 'fs';
import { IOEither, tryCatch } from 'fp-ts/IOEither';

function readFileSync(path: string): IOEither<Error, string> {
  return tryCatch(
    () => fs.readFileSync(path, 'utf8'),
    reason => new Error(String(reason))
  );
}

readFileSync ν•¨μˆ˜ ν…ŒμŠ€νŠΈν•˜κΈ°

fs의 readFileSync ν•¨μˆ˜λŠ” νŒŒμΌμ„ λ™κΈ°μ μœΌλ‘œ 읽어 λ¬Έμžμ—΄ 값을 λ°˜ν™˜ν•˜λŠ” ν•¨μˆ˜μž…λ‹ˆλ‹€. λ§€κ°œλ³€μˆ˜λ‘œ μ „λ‹¬λœ κ²½λ‘œμ— 파일이 μ—†μœΌλ©΄ μ˜ˆμ™Έλ₯Ό λ°œμƒμ‹œν‚΅λ‹ˆλ‹€. IOEither νƒ€μž…κ³Ό tryCatch ν•¨μˆ˜λ₯Ό μ΄μš©ν•΄ readFileSync ν•¨μˆ˜λ₯Ό κ°œμ„ ν•  수 μžˆμŠ΅λ‹ˆλ‹€. νŒŒμΌμ„ μ •μƒμ μœΌλ‘œ μ½μ—ˆμ„ 경우 Right둜 string을 λ°˜ν™˜ν•˜κ³  μ˜ˆμ™Έκ°€ λ°œμƒν•  경우 Left둜 Errorλ₯Ό λ°˜ν™˜ν•©λ‹ˆλ‹€.

μž‘μ„±ν•œ readFileSync ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜κΈ° μœ„ν•œ νŒ¨ν‚€μ§€λŠ” μ•„λž˜μ™€ κ°™μŠ΅λ‹ˆλ‹€.

  • readFileSync ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜κΈ° μœ„ν•œ νŒ¨ν‚€μ§€
import * as fs from 'fs';
import { Either, isRight, getOrElse, isLeft } from 'fp-ts/lib/Either';
import { readFileSync } from '../readFileSync';
  • readFileSyncΒ ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜λŠ” μ½”λ“œ

fs의 readFileSync ν•¨μˆ˜λŠ” readonlyμ΄λ―€λ‘œ spyOn을 μ΄μš©ν•΄ 값을 mockingν•΄ μ€λ‹ˆλ‹€. μ›ν•˜λŠ” κ²½λ‘œκ°€ μ „λ‹¬λ˜μ—ˆμ„ λ•Œλ§Œ λ¬Έμžμ—΄μ„ λ°˜ν™˜ν•˜κ³  그렇지 μ•Šμ€ 경우 μ˜ˆμ™Έλ₯Ό λ°œμƒμ‹œν‚΅λ‹ˆλ‹€. μ •μƒμ μœΌλ‘œ readFileSync ν•¨μˆ˜κ°€ μ‹€ν–‰λ˜μ—ˆμ„ 경우 Right νƒ€μž…μΈμ§€ ν™•μΈν•œ ν›„ getOrElse둜 λ°˜ν™˜λœ κ²°κ³Όκ°€ mocking된 λ¬Έμžμ—΄μΈμ§€ ν™•μΈν•©λ‹ˆλ‹€. μ˜ˆμ™Έκ°€ λ°œμƒν–ˆμ„ 경우 Left νƒ€μž…μΈμ§€ ν™•μΈν•œ ν›„ getOrElse둜 λ°˜ν™˜λœ κ²°κ³Όκ°€ getOrElse ν•¨μˆ˜κ°€ λ°˜ν™˜ν•œ λ¬Έμžμ—΄μΈμ§€ ν™•μΈν•©λ‹ˆλ‹€.

describe('readFileSyncν•¨μˆ˜ ν…ŒμŠ€νŠΈ (동기 λΆ€μˆ˜ 효과)', () => {
  jest.spyOn(fs, 'readFileSync').mockImplementation(path => {
    if (path === 'success.txt') return 'success';
    throw new Error(`${path} is not found.`);
  });
  let result: Either<Error, string>;
  it('readFileSyncκ°€ μ •μƒμ μœΌλ‘œ 값을 가져왔을 경우', () => {
    result = readFileSync('success.txt')();
    expect(isRight(result)).toBeTruthy();
    expect(getOrElse(() => 'fail')(result)).toBe('success');
  });
  it('readFileSyncν•¨μˆ˜ μ‹€ν–‰ 쀑 μ˜ˆμ™Έκ°€ λ°œμƒν–ˆμ„ 경우', () => {
    result = readFileSync('fail.txt')();
    expect(isLeft(result)).toBeTruthy();
    expect(getOrElse(() => 'fail')(result)).toBe('fail');
  });
});

비동기 λΆ€μˆ˜ 효과

  • 유즈 μΌ€μ΄μŠ€: 비동기 계산을 μˆ˜ν–‰ν•˜λŠ” API
  • μ˜ˆμ‹œ: ν‘œμ€€ μž…λ ₯μœΌλ‘œλΆ€ν„° 읽을 λ•Œ
  • ν•΄κ²° 방법: Task
import { createInterface } from 'readline';
import { Task } from 'fp-ts/Task';

const read: Task<string> = () =>
  new Promise<string>(resolve => {
    const rl = createInterface({
      input: process.stdin,
      output: process.stdout,
    });
    rl.question('', answer => {
      rl.close();
      resolve(answer);
    });
  });

read ν•¨μˆ˜ ν…ŒμŠ€νŠΈν•˜κΈ°

Task νƒ€μž…μ€ 값을 λ°˜ν™˜ν•˜κ³  μ ˆλŒ€λ‘œ μ‹€νŒ¨ν•˜μ§€ μ•ŠλŠ” 비동기 μž‘μ—…μ„ λ‚˜νƒ€λ‚Ό λ•Œ μ‚¬μš©ν•©λ‹ˆλ‹€. read ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜κΈ° μœ„ν•΄μ„œλŠ” readline의 createInterface ν•¨μˆ˜λ₯Ό mockingν•΄μ•Ό ν•©λ‹ˆλ‹€. createInterface ν•¨μˆ˜μ˜ qeustion ν•¨μˆ˜μ™€ close ν•¨μˆ˜λ₯Ό μ•„λž˜μ²˜λŸΌ mocking ν•©λ‹ˆλ‹€.

  • read ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜κΈ° μœ„ν•œ createInterface ν•¨μˆ˜ mocking

__mocks__/readline.js

module.exports = {
  createInterface: jest.fn().mockReturnValue({
    question: jest.fn().mockImplementationOnce((questionText, cb) => {
      cb('success');
    }),
    close: () => undefined,
  }),
};

μž‘μ„±ν•œ read ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜κΈ° μœ„ν•œ νŒ¨ν‚€μ§€λŠ” μ•„λž˜μ™€ κ°™μŠ΅λ‹ˆλ‹€.

  • read ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜κΈ° μœ„ν•œ νŒ¨ν‚€μ§€
import { read } from '../read';
  • readΒ ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜λŠ” μ½”λ“œ

jest.mock ν•¨μˆ˜λ₯Ό ν˜ΈμΆœν•΄ readline νŒ¨ν‚€μ§€λ₯Ό mockingν•©λ‹ˆλ‹€. read ν•¨μˆ˜λŠ” μ ˆλŒ€λ‘œ μ‹€νŒ¨ν•˜μ§€ μ•ŠμœΌλ©° 비동기 μž‘μ—…μ„ μ²˜λ¦¬ν•˜λ―€λ‘œ async, awaitꡬ문을 μ΄μš©ν•΄ read ν•¨μˆ˜λ₯Ό ν˜ΈμΆœν•΄ 값을 μ–»μ–΄ mockingν•œ κ°’κ³Ό 같은지 ν™•μΈν•©λ‹ˆλ‹€.

jest.mock('readline');

describe('readν•¨μˆ˜ ν…ŒμŠ€νŠΈ (비동기 λΆ€μˆ˜ 효과)', () => {
  it('readκ°€ μ •μƒμ μœΌλ‘œ 값을 μ½μ—ˆμ„ 경우', async () => {
    const result = await read();
    expect(result).toBe('success');
  });
});
  • 유즈 μΌ€μ΄μŠ€: 비동기 계산을 μˆ˜ν–‰ν•˜κ³  거뢀될 수 μžˆλŠ” API
  • μ˜ˆμ‹œ: ν‘œμ€€ fetch
  • ν•΄κ²° 방법: TaskEither, tryCatch
import { TaskEither, tryCatch } from 'fp-ts/TaskEither';

function get(url: string): TaskEither<Error, string> {
  return tryCatch(
    () => fetch(url).then(res => res.text()),
    reason => new Error(String(reason))
  );
}

get ν•¨μˆ˜ ν…ŒμŠ€νŠΈν•˜κΈ°

fetch ν•¨μˆ˜λŠ” 비동기 μž‘μ—…μ„ μ²˜λ¦¬ν•˜λ©° μ˜ˆμ™Έλ₯Ό λ°œμƒμ‹œν‚¬ 수 μžˆμŠ΅λ‹ˆλ‹€. fetch ν•¨μˆ˜λ₯Ό κ°œμ„ ν•˜κΈ° μœ„ν•΄ TaskEither νƒ€μž…μ„ μ΄μš©ν•΄ get ν•¨μˆ˜λ₯Ό μž‘μ„±ν–ˆμŠ΅λ‹ˆλ‹€.

μž‘μ„±ν•œ get ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜κΈ° μœ„ν•œ νŒ¨ν‚€μ§€λŠ” μ•„λž˜μ™€ κ°™μŠ΅λ‹ˆλ‹€.

  • get ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜κΈ° μœ„ν•œ νŒ¨ν‚€μ§€
import { Either, getOrElse, isLeft, isRight } from 'fp-ts/lib/Either';
import { get } from '../get';
  • getΒ ν•¨μˆ˜λ₯Ό ν…ŒμŠ€νŠΈν•˜λŠ” μ½”λ“œ

fetch ν•¨μˆ˜λŠ” Promise<Response>λ₯Ό λ°˜ν™˜ν•˜λ©° get ν•¨μˆ˜ λ‚΄λΆ€μ—μ„œ μ‚¬μš©ν•œ text ν•¨μˆ˜ λ˜ν•œ Promiseλ₯Ό λ°˜ν™˜ν•©λ‹ˆλ‹€. jest.fn을 μ΄μš©ν•΄ ν•¨μˆ˜λ₯Ό mockingν•˜κ³  νŠΉμ • λ¬Έμžμ—΄μ΄ λ“€μ–΄μ˜¬ 경우 Promise.resolve둜 값을 λ°˜ν™˜ν•΄ μ€λ‹ˆλ‹€. μ›ν•˜λŠ” λ¬Έμžμ—΄μ΄ 아닐 경우 Promise.reject둜 κ±°λΆ€ν•©λ‹ˆλ‹€.

async, await ꡬ문을 μ΄μš©ν•΄ get ν•¨μˆ˜λ‘œλΆ€ν„° ν•¨μˆ˜λ₯Ό λ°›μ•„ ν˜ΈμΆœμ‹œν‚΅λ‹ˆλ‹€. μ •μƒμ μœΌλ‘œ 값을 κ°€μ Έμ˜¨ 경우 isRight ν•¨μˆ˜λ‘œ Right νƒ€μž…μΈμ§€ ν™•μΈν•œ ν›„ getOrElse ν•¨μˆ˜λ‘œ Right μ•ˆμ˜ 값을 κ°€μ Έμ˜€λŠ”μ§€ ν™•μΈν•©λ‹ˆλ‹€. κ±°λΆ€λ‹Ήν•œ 경우 Left인지 ν™•μΈν•œ ν›„ getOrElse ν•¨μˆ˜μ— μ „λ‹¬λœ 값이 λ°˜ν™˜λ˜μ—ˆλŠ”μ§€ ν™•μΈν•©λ‹ˆλ‹€.

describe('getν•¨μˆ˜ ν…ŒμŠ€νŠΈ (비동기 λΆ€μˆ˜ 효과)', () => {
  global.fetch = jest.fn(url => {
    if (url === 'https://success.com') {
      return Promise.resolve({
        text: () => Promise.resolve('success'),
      }) as Promise<Response>;
    }
    return Promise.reject('fail');
  });
  let result: Either<Error, string>;
  it('getν•¨μˆ˜κ°€ μ •μƒμ μœΌλ‘œ 값을 가져왔을 경우', async () => {
    result = await get('https://success.com')();
    expect(isRight(result)).toBeTruthy();
    expect(getOrElse(() => 'fail')(result)).toBe('success');
  });
  it('getν•¨μˆ˜ μ‹€ν–‰ 쀑 μ˜ˆμ™Έκ°€ λ°œμƒν–ˆμ„ 경우', async () => {
    result = await get('https://fail.com')();
    expect(isLeft(result)).toBeTruthy();
    expect(getOrElse(() => 'fail')(result)).toBe('fail');
  });
});

Written by@Minsu Kim
Software Engineer at KakaoPay Corp.