Search code examples
unit-testinghaskelltypeclasscabalcabal-install

Instance of fake IO typeclass is found in production code but not in testing


I have many IO-based actions, one of the simplest being the following:

-- Loop.hs
module Loop where
import System.Console.ANSI (setCursorPosition)
type Pos = (Int, Int)
setCursorPosition' :: Pos -> IO ()
setCursorPosition' = uncurry setCursorPosition

At which point, starting from the one above, I decided to write these functions in terms of a type constraint implemented by IO, rather than hard-coding IO, as suggested by this answer.

So what I did consisted of

  • defining a FakeIO typeclass and its trivial implementation for IO:
    -- Interfaces.hs
    module Interfaces where
    import qualified System.Console.ANSI as ANSI (setCursorPosition)
    
    class FakeIO m where
      setCursorPosition :: Int -> Int -> m ()
    
    instance FakeIO IO where
      setCursorPosition = ANSI.setCursorPosition
    
  • changed setCursorPosition' to use this interface:
    -- Loop.hs
    module Loop where
    import Interfaces
    type Pos = (Int, Int)
    setCursorPosition' :: FakeIO m => Pos -> m ()
    setCursorPosition' = uncurry setCursorPosition
    

This resulted in the program to still work fine (via cabal run), testifying that the "refactoring" was correct.

But when I tried leveraging this refactoring for the purpose of testing, I got stuck. What I did was writing the following test:

-- test/Main.hs
module Main where

import Control.Monad (unless)
import System.Exit (exitFailure)
import MTLPrelude (State, execState, modify')
import Test.QuickCheck

import Loop
import Interfaces

data MockTerminal = MockTerminal {
  pos :: (Int, Int)
} deriving Eq

instance FakeIO (State MockTerminal) where
  setCursorPosition y x = modify' $ \m -> MockTerminal { pos = (y, x) }

main :: IO ()
main = do
  result <- quickCheckResult tCenter
  unless (isSuccess result) exitFailure

tCenter :: Bool
tCenter = (setCursorPosition' (1,1))
          `execState` MockTerminal { pos = (0,0)}
          == MockTerminal { pos = (1,1) }

which fails to compile (via cabal test) because

error: [GHC-39999]
    • No instance for ‘snakegame-0.1.0.0:Loop:Interfaces.FakeIO
                         (StateT MockTerminal Identity)’
        arising from a use of ‘setCursorPosition'’
    • In the first argument of ‘execState’, namely
        ‘(setCursorPosition' (1, 1))’
      In the first argument of ‘(==)’, namely
        ‘(setCursorPosition' (1, 1))
           `execState` MockTerminal {pos = (0, 0)}’
      In the expression:
        (setCursorPosition' (1, 1)) `execState` MockTerminal {pos = (0, 0)}
          == MockTerminal {pos = (1, 1)}
   |
41 | tCenter = (setCursorPosition' (1,1))
   |            ^^^^^^^^^^^^^^^^^^

which I don't understand, because instance FakeIO (State MockTerminal) should be precisely the snakegame-0.1.0.0:Loop:Interfaces.FakeIO (StateT MockTerminal Identity) instance that the compiler is claiming doesn't exist.

Furthermore, if I change the test to use setCursorPosition 1 1 instead of setCursorPosition' (1,1), it compiles and passes, revealing that the instance is making its job indeed.

So something must be off with how that instance integrates with the definition of setCursorPosition'.


I've shrunk the example to the following 4 files:

$ tree !(dist-newstyle)
cabal.project  [error opening dir]
LICENSE  [error opening dir]
Session.vim  [error opening dir]
snakegame.cabal  [error opening dir]
src
├── Interfaces.hs
├── Loop.hs
└── Main.hs
test
└── Main.hs

2 directories, 8 files

of which:

-- src/Main.hs
module Main where

import Loop

main :: IO ()
main = setCursorPosition' (1,1)
-- src/Loop.hs
module Loop (setCursorPosition') where

import Interfaces
type Pos = (Int, Int)

setCursorPosition' :: FakeIO m => Pos -> m ()
setCursorPosition' = uncurry setCursorPosition
-- test/Main.hs
module Main where

import Control.Monad (unless)
import System.Exit (exitFailure)
import MTLPrelude (State, execState, modify')
import Test.QuickCheck

import Loop
import Interfaces

data MockTerminal = MockTerminal {
  pos :: (Int, Int)
} deriving Eq

instance FakeIO (State MockTerminal) where
  setCursorPosition y x = modify' $ \m -> MockTerminal { pos = (y, x) }
  putChar _ = modify' id

main :: IO ()
main = do
  result <- quickCheckResult tCenter
  unless (isSuccess result) exitFailure

tCenter :: Bool
tCenter = (setCursorPosition' (1,1))
          `execState` MockTerminal { pos = (0,0)}
          == MockTerminal { pos = (1,1)}
cabal-version: 3.0
name: snakegame
version: 0.1.0.0

common common
    default-language: GHC2024
    build-depends: base >= 4.19.1.0
                 , ansi-terminal
                 , mtl-prelude

common warnings
    ghc-options: -Wall

executable snakegame
    import: warnings, common
    main-is: Main.hs
    other-modules: Loop
                 , Interfaces
    hs-source-dirs: src

library Loop
    import: warnings, common
    exposed-modules: Loop
    hs-source-dirs: src

library Interfaces
    import: warnings, common
    exposed-modules: Interfaces
    hs-source-dirs: src

test-suite Test
    import: warnings, common
    type: exitcode-stdio-1.0
    main-is: Main.hs
    build-depends: QuickCheck
                 , Interfaces
                 , Loop
    hs-source-dirs: test
packages: .

with-compiler: ghc-9.10.1

Solution

  • Your Haskell source code is all fine. The problem you're running into is from Cabal. To fix it, do the following:

    1. Create a directory called app
    2. Move Main.hs from src/ to app/
    3. Replace the contents of snakegame.cabal with the following:
    cabal-version: 3.0
    name: snakegame
    version: 0.1.0.0
    
    common common
        default-language: GHC2024
        build-depends: base >= 4.19.1.0
                     , ansi-terminal
                     , mtl-prelude
    
    common warnings
        ghc-options: -Wall
    
    executable snakegame
        import: warnings, common
        main-is: Main.hs
        build-depends: snakegame
        hs-source-dirs: app
    
    library
        import: warnings, common
        exposed-modules: Loop
                       , Interfaces
        hs-source-dirs: src
    
    test-suite Test
        import: warnings, common
        type: exitcode-stdio-1.0
        main-is: Main.hs
        build-depends: QuickCheck
                     , snakegame
        hs-source-dirs: test
    

    The problem was that you were saying that Loop and Interfaces each belonged to multiple libraries, so you were ending up with multiple copies of them, and the FakeIO that you declared the instance for wasn't the same copy as the one in the constraint in setCursorPosition'.