어제는 어니언 아키텍처에 대해 적어봤다. 어니언 아키텍처는 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
save
는 User
를 받을 수만 있고 내가 실행하게 만들 함수를 전달할 방법이 없어 보인다. 함수를 전달하려면 save :: (User -> IO Bool) -> User -> m Bool
처럼 함수 시그니처를 바꿔야 한다. 하지만 m
이 결정되지 않은 것을 볼 수 있다. 이 말은 m
모나드 컨텍스트가 나중에 어떤 이팩트라도 추가할 수 있는 가능성을 열어 둔 것과 같다. 따라서 내가 실행할 컨택스트인 TestApp
이 Reader
모나드 이팩트를 사용할 수 있다면 함수 시그니처를 바꾸지 않아도 함수를 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
이여야 한다. 여기서 m
은 TestApp
이 될 것이다. 따라서 f
의 타입은 User -> TestApp Bool
이 된다. 여러 군데 사용할 것이기 때문에 타입 동의어로 선언해 두자.
type SaveF = User -> TestApp Bool
이제 TestApp
이 Reader
모나드로 동작하도록 만들자. 분명 IO
도 사용할 가능성이 높기 때문에 Reader
와 IO
를 ReaderT
로 섞자.
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
이제 NotificationService
의 notify
도 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
마찬가지로 NotificationService
의 notify
도 구현해보자.
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
끄읏~!