하스켈에서 테스트 mocking 하기

Eunmin Kim·2022년 6월 3일
2

하스켈 일기

목록 보기
3/12
post-thumbnail

어제는 어니언 아키텍처에 대해 적어봤다. 어니언 아키텍처는 Tagless Final이라고 하는 패턴으로 구현했다. 타입 변수로 만들어진 모나드 컨텍스트를 사용하는 곳에서 타입에 따라 결정할 수 있었다. 테스트 코드 역시 테스트를 위한 모나드 타입을 만들어 의존성의 목(Mock)이나 스텁(Stub)을 만들면 된다.

newtype TestApp a = TestApp { unTestApp :: IO a }
  deriving (Functor, Applicative, Monad, MonadIO)

instance UserRepository TestApp where
  save user = do
    liftIO $ putStrLn "이렇게 동작해라!"
    pure True

main :: IO ()
main = do
  unTestApp $ createUser 1 "Eunmin"
  pure ()

하지만 테스트 케이스마다 다른 스텁을 사용해야 한다면 어떻게 해야 할까? TestApp과 같은 타입을 여러 개 만들어야 할까?

newtype TestApp1 a = TestApp1 { unTestApp1 :: IO a }
  deriving (Functor, Applicative, Monad, MonadIO)

instance UserRepository TestApp1 where
  save user = do
    liftIO $ putStrLn "이렇게 동작해라!"
    pure True

newtype TestApp2 a = TestApp2 { unTestApp2 :: IO a }
  deriving (Functor, Applicative, Monad, MonadIO)

instance UserRepository TestApp2 where
  save user = do
    liftIO $ putStrLn "저렇게 동작해라!"
    pure True
    
main :: IO ()
main = do
  unTestApp1 $ createUser 1 "Eunmin"
  
  unTestApp2 $ createUser 1 "Eunmin"
  pure ()

상당히 번거로운 일이다. Clojure에는 제한된 컨텍스트에서 값의 바인딩을 바꿔서 실행할 수 있는 with-redefs라고 하는 기능이 있다. ClojureDocs 설명에도 쓰여 있는 것처럼 테스트를 mocking을 할 때 많이 사용한다. 아래 코드는 ClojureDocs 올라와 있는 예제 코드이다.

(ns http)

(defn post [url]
  {:body "Hello world"})

(ns app
  (:require [clojure.test :refer [deftest is run-tests]]))

(deftest is-a-macro
  (with-redefs [http/post (fn [url] {:body "Goodbye world"})]
    (is (= {:body "Goodbye world"} (http/post "http://service.com/greet")))))

(run-tests)

with-redefs 컨택스트 안에서 http/post 함수를 뒤에 정의한 함수로 바꿔서 실행한다.
하스켈은 이런 기능이 없을까? 많이 찾아보지는 않았지만 그런 기능은 아직 찾지 못했다. 대신 Practical Web Development with Haskell라고 하는 책에서 사용하는 방법이 편리하다고 생각해서 프로젝트에 적용해 사용 중이다.
아이디어는 간단하다. 의존성 타입 클래스를 TestApp이 구현할 때 타입 클래스의 메소드들이 함수를 받을 수 있다면 테스트를 실행할 때 함수를 전달하고 그 함수를 실행할 수 있도록 하는 것이다.
그런데 문제가 있다. 위에 나온 UserRepository 타입 클래스의 함수 시그니처를 바꾸지 않고 save 메서드에 어떻게 함수를 전달한다는 말인가? 여기서 Tagless final의 가치가 나온다. save의 시그니처는 아래와 같다.

class UserRepository m where
  save :: User -> m Bool

saveUser를 받을 수만 있고 내가 실행하게 만들 함수를 전달할 방법이 없어 보인다. 함수를 전달하려면 save :: (User -> IO Bool) -> User -> m Bool처럼 함수 시그니처를 바꿔야 한다. 하지만 m이 결정되지 않은 것을 볼 수 있다. 이 말은 m 모나드 컨텍스트가 나중에 어떤 이팩트라도 추가할 수 있는 가능성을 열어 둔 것과 같다. 따라서 내가 실행할 컨택스트인 TestAppReader 모나드 이팩트를 사용할 수 있다면 함수 시그니처를 바꾸지 않아도 함수를 Reader 모나드로 전달할 수 있다. 먼저 고치기 어제 만든 예제 코드를 테스트 코드에 연결해보자. 테스트 패키지는 hspec을 사용했다.

{-# LANGUAGE GeneralizedNewtypeDeriving #-}

module Hello.WorldSpec where

import           Control.Monad.IO.Class
import           Test.Hspec

-- Domain
data User = User
  { userId   :: Int
  , userName :: String
  } deriving (Eq, Show)

class UserRepository m where
  save :: User -> m Bool

-- UserService
createUser :: (UserRepository m) => Int -> String -> m Bool
createUser userId userName = do
  save user
  where
    user = User userId userName

-- Implementation
newtype TestApp a = TestApp { unTestApp :: IO a }
  deriving (Functor, Applicative, Monad, MonadIO)

instance UserRepository TestApp where
  save user = do
    liftIO $ putStrLn $ show user ++ "가 잘 저장되었다."
    pure True

spec :: Spec
spec = do
  describe "createUser" $
    it "잘 동작해야 한다" $ do
      result <- unTestApp $ createUser 1 "Eunmin"
      result `shouldBe` True

하려고 하는 동작은 save 구현에서 내가 만든 함수를 넘겨서 그것을 실행하도록 하는 것이다. 먼저 save 함수의 구현을 고쳐보자.

instance UserRepository TestApp where
  save user = do
    f <- ask
    f user

save 함수는 현재 모나드 컨택스트 안에서 Reader에서 읽어 온(ask) 함수 f를 그냥 실행해서 리턴하는 일만 한다. 나중에 구현체는 Reader 모나드를 실행할 때 전달해주면 된다.
그럼 Reader 모나드에서 읽을 함수 f의 타입은 무엇일까? User를 넘겼고 save의 리턴 타입인 m Bool이여야 한다. 여기서 mTestApp이 될 것이다. 따라서 f의 타입은 User -> TestApp Bool이 된다. 여러 군데 사용할 것이기 때문에 타입 동의어로 선언해 두자.

type SaveF = User -> TestApp Bool

이제 TestAppReader 모나드로 동작하도록 만들자. 분명 IO도 사용할 가능성이 높기 때문에 ReaderIOReaderT로 섞자.

newtype TestApp a = TestApp { unTestApp :: ReaderT SaveF IO a }
  deriving (Functor, Applicative, Monad, MonadIO, MonadReader SaveF)

마지막으로 실행하는 곳에서 함숫값과 함께 Reader 모나드를 실행해 주면 된다.

spec :: Spec
spec = do
  describe "createUser" $ do
    it "이렇게 동작해야 한다" $ do
      result <- runReaderT (unTestApp $ createUser 1 "Eunmin") (\ user -> do
                                liftIO $ putStrLn "이렇게 동작해라!"
                                pure True
                            )
      result `shouldBe` True

    it "저렇게 동작해야 한다" $ do
      result <- runReaderT (unTestApp $ createUser 1 "Eunmin") (\ user -> do
                                liftIO $ putStrLn "저렇게 동작해라!"
                                pure False
                            )
      result `shouldBe` False

여기까지 해도 쓸만하지만 mocking 해야 할 함수가 여러 개면 Reader 값을 SaveF로 할 수는 없을 것이다. 이런 경우에는 Reader의 값을 데이터로 만들어 여러 개 함수를 넣을 수 있도록 해야 한다. 예를 들어 UserRepository 말고 NotificationService라는 것이 있다고 해보자.

-- Domain
data User = User
  { userId   :: Int
  , userName :: String
  } deriving (Eq, Show)

class UserRepository m where
  save :: User -> m Bool

class NotificationService m where
  notify :: String -> String -> m Bool

-- UserService
createUser :: (Monad m, UserRepository m, NotificationService m) 
           => Int -> String -> m Bool
createUser userId userName = do
  save user
  notify userName "사용자가 생성 됨"
  where
    user = User userId userName

이제 NotificationServicenotify도 mocking이 필요하다. 따라서 Reader 모나드 값은 다음과 같이 함수 두 개를 갖는 데이터로 만드는 것이 좋겠다.

data Fixture m = Fixture
  { _save   :: User -> m Bool
  , _notify :: String -> String -> m Bool
  }

다음으로 Reader 값을 Fixture로 바꾸자.

newtype TestApp a = TestApp { unTestApp :: ReaderT (Fixture TestApp) IO a }
  deriving (Functor, Applicative, Monad, MonadIO, MonadReader (Fixture TestApp))

이제 save 구현체를 바꿔야 한다. 왜냐하면 기존에는 ask했을 때 바로 쓸 수 있는 함수가 나왔는데 이제는 Fixture 타입의 값이 나오기 때문에 그중에서 _save 필드 값을 가져와야 save에서 쓸 수 있는 함수가 된다. 이렇게 복합적인 Reader 값에서 특정 값을 가져와 사용하는 경우가 많기 때문에 Reader 모나드에는 asks라는 함수를 제공한다. asks는 인자로 Reader 값 중에 어떤 값을 선택할지 고르는 함수를 받는다. 여기서는 _save 필드명 자체가 셀렉터 함수로 동작하기 때문에 asks 함수의 인자로 _save를 넘기면 된다.

instance UserRepository TestApp where
  save user = do
    f <- asks _save
    f user

마찬가지로 NotificationServicenotify도 구현해보자.

instance NotificationService TestApp where
  notify sender message = do
    f <- asks _notify
    f sender message

notify는 인자가 두 개다.
이제 사용하는 부분만 바꾸면 된다. 앞에서는 Reader 모나드를 실행할 때 함수를 넘겼지만 이제는 Fixture 값을 넘겨야 한다. 일단 기본 동작은 예외를 발생시키는 undefined를 넣어 기본 Fixture 값을 만들어 넘기자.

spec :: Spec
spec = do
  let fixture = Fixture
                  { _save = undefined
                  , _notify = undefined
                  }
  describe "createUser" $ do
    it "이렇게 동작해야 한다" $ do
      result <- runReaderT (unTestApp $ createUser 1 "Eunmin") fixture
      result `shouldBe` True

    it "저렇게 동작해야 한다" $ do
      result <- runReaderT (unTestApp $ createUser 1 "Eunmin") fixture
      result `shouldBe` False

이 상태로 테스트를 실행하면 _save_notify를 사용하는 createUser에서 undefined 때문에 예외가 발생한다. 따라서 fixture 값을 넘길 때 레코드 값을 실제 구현 함수로 업데이트해서 전달하자.

spec :: Spec
spec = do
  let fixture = Fixture
                  { _save = undefined
                  , _notify = undefined
                  }
  describe "createUser" $ do
    it "이렇게 동작해야 한다" $ do
      result <- runReaderT (unTestApp $ createUser 1 "Eunmin") fixture
                    { _save = \ user -> do
                        liftIO $ putStrLn "이렇게 동작하고"
                        pure True
                    , _notify = \ sender message -> do
                        liftIO $ putStrLn $ sender ++ "가 '" ++ message ++ "'를 보냈네"
                        pure True
                    }
      result `shouldBe` True

    it "저렇게 동작해야 한다" $ do
      result <- runReaderT (unTestApp $ createUser 1 "Eunmin") fixture
                    { _save = \ user -> do
                        liftIO $ putStrLn "저렇게 동작하고"
                        pure True
                    , _notify = \ sender message -> do
                        liftIO $ putStrLn "기록을 안 남기겠다!"
                        pure False
                    }
      result `shouldBe` False

이렇게 하면 함수 여러 개를 mocking 할 수 있다. 여기서는 기본 동작을 모두 undefined로 정의했지만 적절한 기본 동작을 지정하면 모두 구현하지 않아도 된다. 또 테스트 코드에서 실행하는 함수만 mocking 하면 되기 때문에 큰 부담이 되지 않는다.
여기서 하나 더 유틸리티 함수를 만들어 볼 수 있는 것은 함수를 받아서 그 함수를 실행하는 구현이 반복되는 것인데 이 부분은 간단하게 유틸리티 함수를 만들어서 해결할 수 있다.

instance UserRepository TestApp where
  save = dispatch1 _save

instance NotificationService TestApp where
  notify = dispatch2 _notify

dispatch1 :: MonadReader r m => (r -> t -> m b) -> t -> m b
dispatch1 getter param = do
  f <- asks getter
  f param

dispatch2 :: MonadReader r m => (r -> t -> t2 -> m b) -> t -> t2 -> m b
dispatch2 getter param1 param2 = do
  f <- asks getter
  f param1 param2

전체 코드는 다음과 같다.

{-# LANGUAGE GeneralizedNewtypeDeriving #-}

module Hello.WorldSpec where

import           Control.Monad.IO.Class
import           Control.Monad.Reader
import           Test.Hspec

-- Domain
data User = User
  { userId   :: Int
  , userName :: String
  } deriving (Eq, Show)

class UserRepository m where
  save :: User -> m Bool

class NotificationService m where
  notify :: String -> String -> m Bool

-- UserService
createUser :: (Monad m, UserRepository m, NotificationService m)
           => Int -> String -> m Bool
createUser userId userName = do
  save user
  notify userName "사용자가 생성 됨"
  where
    user = User userId userName

-- Implementation
newtype TestApp a = TestApp { unTestApp :: ReaderT (Fixture TestApp) IO a }
  deriving (Functor, Applicative, Monad, MonadIO, MonadReader (Fixture TestApp))

data Fixture m = Fixture
  { _save   :: User -> m Bool
  , _notify :: String -> String -> m Bool
  }

instance UserRepository TestApp where
  save = dispatch1 _save

instance NotificationService TestApp where
  notify = dispatch2 _notify

dispatch1 :: MonadReader r m => (r -> t -> m b) -> t -> m b
dispatch1 getter param = do
  f <- asks getter
  f param

dispatch2 :: MonadReader r m => (r -> t -> t2 -> m b) -> t -> t2 -> m b
dispatch2 getter param1 param2 = do
  f <- asks getter
  f param1 param2

spec :: Spec
spec = do
  let fixture = Fixture
                  { _save = undefined
                  , _notify = undefined
                  }
  describe "createUser" $ do
    it "이렇게 동작해야 한다" $ do
      result <- runReaderT (unTestApp $ createUser 1 "Eunmin") fixture
                    { _save = \ user -> do
                        liftIO $ putStrLn "이렇게 동작하고"
                        pure True
                    , _notify = \ sender message -> do
                        liftIO $ putStrLn $ sender ++ "가 '" ++ message ++ "'를 보냈네"
                        pure True
                    }
      result `shouldBe` True

    it "저렇게 동작해야 한다" $ do
      result <- runReaderT (unTestApp $ createUser 1 "Eunmin") fixture
                    { _save = \ user -> do
                        liftIO $ putStrLn "저렇게 동작하고"
                        pure True
                    , _notify = \ sender message -> do
                        liftIO $ putStrLn "기록을 안 남기겠다!"
                        pure False
                    }
      result `shouldBe` False

끄읏~!

profile
Functional Programmer @Constacts, Inc.

0개의 댓글