packages feed

HMock-0.2.0.0: test/Demo.hs

{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE FunctionalDependencies #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UndecidableInstances #-}

module Demo where

import Control.Exception (Exception)
import Control.Monad (unless, when)
import Control.Monad.Catch (MonadMask, catch, finally, throwM)
import Data.Char (isLetter)
import Test.HMock
  ( Mockable (..),
    anything,
    allowUnexpected,
    expect,
    expectAny,
    makeMockable,
    makeMockableBase,
    runMockT,
    (|->),
    (|=>),
  )
import Test.Hspec (SpecWith, describe, example, it)
import Prelude hiding (appendFile, readFile, writeFile)

-- This is an in-depth example of using HMock to test a system with dependencies
-- on external systems.  The application here is a chat bot, which needs to
-- be able to talk to services for authentication, communication, and storage.

-------------------------------------------------------------------------------
-- PART 1: TYPES AND CLASSES

-- We start with some basic types.

-- | Represents a user in the system.
newtype User = User String deriving (Eq, Show)

-- | Represents a permission level.  The Guest level is given to users who are
-- not logged in.  NormalUser requires being logged in.  Admin requires being
-- logged in with elevated permissions.
data PermLevel = Guest | NormalUser | Admin deriving (Eq, Show)

-- | Represents a chat room.
newtype Room = Room String deriving (Eq, Show)

-- HMock needs MTL-style type classes to mock.  Here, we implement a number of
-- these classes.

-- | An MTL-style type class to capture authentication.  This can be used in two
-- ways.
--
-- 1. Using the 'login' and 'logout' actions to start and end an authenticated
--    session.  While the user is logged in, 'getUser' and 'hasPermission' will
--    return appropriate responses for that user.  Remember to log out!
--
-- 2. Using the 'withLogin' action to run an action as a user.  The user will
--    be logged out automatically when the action finishes.
class Monad m => MonadAuth m where
  login :: String -> String -> m ()
  logout :: m ()
  hasPermission :: PermLevel -> m Bool

-- | An MTL-style type class for sending and receiving chat messages.  We can
-- start and close sessions, send messages, poll for messages (blocking for a
-- period of time if desired), and ban other users (probably only if we're an
-- admin!)
class MonadAuth m => MonadChat m where
  joinRoom :: String -> m Room
  leaveRoom :: Room -> m ()
  sendChat :: Room -> String -> m ()
  pollChat :: Room -> m (User, String)
  ban :: Room -> User -> m ()

-- | An MTL-style type class for accessing the filesystem.  It wouldn't be a
-- good idea to write mock-style tests for all uses of the filesystem, but we
-- can use HMock to set up a lightweight fake.
class Monad m => MonadBugReport m where
  reportBug :: String -> m ()

-- | An exception thrown when a banned user attempts to join a room.
data BannedException = BannedException deriving (Show)

instance Exception BannedException

-------------------------------------------------------------------------------
-- PART 2: IMPLEMENTATION

-- We will now implement a simple chatbot, which joins a chat room and responds
-- to messages.

type MonadChatBot m = (MonadChat m, MonadBugReport m)

chatbot :: (MonadMask m, MonadChatBot m) => String -> m ()
chatbot roomName = do
  login "HMockBot" "secretish"
  handleRoom roomName `finally` logout

handleRoom :: (MonadMask m, MonadChatBot m) => String -> m ()
handleRoom roomName = do
  room <- joinRoom roomName
  listenAndReply room `finally` leaveRoom room

listenAndReply :: MonadChatBot m => Room -> m ()
listenAndReply room = do
  (user, msg) <- pollChat room
  finished <- case words msg of
    ["!leave"] -> return True
    ("!bug" : ws) -> reportBug (unwords ws) >> return False
    ws | any isFourLetterWord ws -> banIfAdmin room user >> return False
    _ -> return False
  unless finished (listenAndReply room)

isFourLetterWord :: [Char] -> Bool
isFourLetterWord = (== 4) . length . filter isLetter

sendBugReport :: MonadChatBot m => Room -> String -> m ()
sendBugReport room bug = do
  reportBug bug
  sendChat room "Thanks for the bug report!"

banIfAdmin :: MonadChat m => Room -> User -> m ()
banIfAdmin room user = do
  isAdmin <- hasPermission Admin
  when isAdmin $ do
    ban room user
    sendChat room "Sorry for the disturbance!"

-------------------------------------------------------------------------------
-- PART 3: MOCKS

-- Set up the mocks for the three classes.  makeMockable is the first step to
-- using HMock, and defines a number of boilerplate types and instances that are
-- used by the framework and your tests.
--
-- How we do this depends on whether there is setup we want to package with the
-- class:

-- Since there is no setup for MonadBugReport, it's easiest to use makeMockable
-- to derive all instances needed to mock this class.
makeMockable ''MonadBugReport

-- For MonadAuth and MonadChat, we have default behaviors we'd like to offer
-- to all tests.  So instead of makeMockable, we use makeMockableBase to derive
-- MockableBase, which contains all the boilerplate.  We can then write a
-- Mockable instance with setup steps.

makeMockableBase ''MonadAuth

instance Mockable MonadAuth where
  setupMockable _ = do
    -- Ensure that when the chatbot logs in with the right username and
    -- password.
    allowUnexpected $
      Login "HMockBot" "secretish"
        |=> \_ -> do
          -- Every login should be accompanied by a logout
          expect Logout

    -- By default, assume that the bot has all permissions.  Individual tests
    -- can override this assumption.
    allowUnexpected $ HasPermission_ anything |-> True

makeMockableBase ''MonadChat

instance Mockable MonadChat where
  setupMockable _ = do
    allowUnexpected $
      JoinRoom_ anything
        |=> \(JoinRoom room) -> do
          -- The bot should leave every room it joins.
          expect $ LeaveRoom (Room room)
          return (Room room)

    -- Our tests aren't generally concerned with what the bot says.  Individual
    -- tests can add expectations to check for specific messages.
    allowUnexpected $ SendChat_ anything anything |-> ()

-------------------------------------------------------------------------------
-- PART 4: TESTS

-- We're now ready to write tests for the behavior of our chat bot.  We'll just
-- write a few representative tests to see how things work.  I'm using Hspec for
-- the test framework, but you can use your favorite framework.

demoSpec :: SpecWith ()
demoSpec = describe "chatbot" $ do
  it "bans users who use four-letter words" $
    example $
      runMockT $ do
        -- Set up some chat messages to be received.
        expectAny $
          PollChat_ anything
            |-> (User "A", "I love Haskell")
            |-> (User "B", "Lovin' the ass. candies")
            |-> (User "B", "!leave")

        -- User A should be banned for using a four-letter word ("love").  User
        -- B should not be banned for using an abbreviation for "assorted."
        expect $ Ban (Room "#haskell") (User "A")

        -- Finally, run the system under test.
        chatbot "#haskell"

  it "still logs out cleanly when there are errors" $ do
    example $
      runMockT $ do
        expectAny $ JoinRoom "#haskell" |=> \_ -> throwM BannedException

        -- An exception will be thrown when attempting to read chat.  The bot
        -- is still expected to log out.
        chatbot "#haskell" `catch` \BannedException -> return ()

  it "doesn't ban if it doesn't have permission" $ do
    example $
      runMockT $ do
        -- Override the earlier default behavior, returning False for the Admin
        -- permission level.
        expectAny $ HasPermission Admin |-> False
        expectAny $
          PollChat_ anything
            |-> (User "A", "I love Haskell")
            |-> (User "A", "!leave")

        chatbot "#haskell"

  it "doesn't ban people for using four-letter words in bug reports" $ do
    example $
      runMockT $ do
        -- A four letter word is used in a bug report.  This is understandable,
        -- so the user shouldn't be banned.  The bug should be reported,
        -- instead.
        expectAny $
          PollChat_ anything
            |-> (User "A", "!bug Fix the damn website!")
            |-> (User "A", "!leave")
        expect $ ReportBug "Fix the damn website!"

        chatbot "#haskell"