Skip to main content
This section provides guidelines for testing your IHP applications. It is highly recommended to write tests for your Controllers and Views to assert the logic and reach better code quality.

Setup

The following setup and tests can be viewed in the Blog example.
1

Add hspec and ihp-hspec dependencies

Add hspec and ihp-hspec in flake.nix:
haskellPackages = p: with p; [
    cabal-install
    # ...
    p.ihp
    
    hspec
    ihp-hspec
];
2

Rebuild environment

Rebuild environment with devenv up
3

Create Test/Main.hs module

Create a new Test/Main.hs module. Here you will import all your test specs.
# Test/Main.hs

module Main where

import Test.Hspec
import IHP.Prelude

import Test.Controller.PostsSpec

main :: IO ()
main = hspec do
    Test.Controller.PostsSpec.tests
4

Add a spec file for your controller

# Test/Controller/PostsSpec.hs
module Test.Controller.PostsSpec where

import Network.HTTP.Types.Status

import IHP.Prelude
import IHP.QueryBuilder (query)
import IHP.Test.Mocking
import IHP.Fetch

import IHP.FrameworkConfig
import IHP.HaskellSupport
import Test.Hspec
import Config

import Generated.Types
import Web.Routes
import Web.Types
import Web.Controller.Posts ()
import Web.FrontController ()
import Network.Wai
import IHP.ControllerPrelude
import IHP.Hspec (withIHPApp)

tests :: Spec
tests = aroundAll (withIHPApp WebApplication config) do
        describe "PostsController" $ do
            it "has no existing posts" $ withContext do
                count <- query @Post
                    |> fetchCount
                count `shouldBe` 0

            it "calling NewPostAction will render a new form" $ withContext do
                mockActionStatus NewPostAction `shouldReturn` status200

            it "creates a new post" $ withContext do
                response <- callActionWithParams CreatePostAction [("title", "Post title"), ("body", "Body of post")]

                let (Just location) = (lookup "Location" (responseHeaders response))
                location `shouldBe` "http://localhost:8000/Posts"

                -- Only one post should exist.
                count <- query @Post |> fetchCount
                count `shouldBe` 1

                -- Fetch the new post.
                post <- query @Post |> fetchOne

                post.title `shouldBe` "Post title"
                post.body `shouldBe` "Body of post"

            it "can show posts" $ withContext do
                post <- newRecord @Post
                    |> set #title "Lorem Ipsum"
                    |> set #body "**Mark down**"
                    |> createRecord

                response <- callAction ShowPostAction { postId = post.id }

                response `responseStatusShouldBe` status200
                response `responseBodyShouldContain` "Lorem Ipsum"

                -- For debugging purposes you could do the following, to
                -- see the HTML printed out on the terminal.
                body <- responseBody response
                putStrLn (cs body)

Execute the tests

You can run tests in multiple ways:
nix-shell
ghci
:l Test/Main
main
When entering ghci it might give a warning:
*** WARNING: . is writable by someone else, IGNORING!
Suggested fix: execute 'chmod go-w .'
In this case, follow the suggested fix, exit ghci (:q) and execute chmod go-w ..

Setting the Current User During Testing

Use withUser to call an action with a specific user during testing:
tests :: Spec
tests = aroundAll (withIHPApp WebApplication config) do
        describe "PostsController" $ do
            it "creates a new post" $ withContext do
                -- Create a user for our test case
                user <- newRecord @User
                    |> set #email "[email protected]"
                    |> createRecord

                -- Log into the user and then call CreatePostAction
                response <- withUser user do
                    callActionWithParams CreatePostAction [("title", "Post title"), ("body", "Body of post")]

                let (Just location) = (lookup "Location" (responseHeaders response))
                location `shouldBe` "http://localhost:8000/Posts"

                -- Only one post should exist
                count <- query @Post |> fetchCount
                count `shouldBe` 1

                -- Fetch the new post
                post <- query @Post |> fetchOne

                post.title `shouldBe` "Post title"
                post.body `shouldBe` "Body of post"

Test Outgoing Emails with Mailhog

Mailhog is a tool that allows you to test outgoing emails. It runs a fake SMTP server and a web interface to view the emails. We can use its API to test your email sending logic, and assert the contents of the emails.
1

Install Mailhog

Add to flake.nix:
# flake.nix
perSystem = { pkgs, ... }: {
    ihp = {
        enable = true;
        projectPath = ./.;
        packages = with pkgs; [
            # Native dependencies, e.g. imagemagick
            # Used for local development
            mailhog
        ];
        haskellPackages = p: with p; [
            # Haskell dependencies go here
            p.ihp
            cabal-install
            base
            wai
            text
            hlint
            hspec
        ];
    };

    # Start mailhog on `devenv up`.
    devenv.shells.default = {
        services.mailhog.enable = true;
    };
};
2

Create a Mail action

-- Web/Mail/Posts/PostView.hs

module Web.Mail.Posts.PostView where
import Web.View.Prelude
import IHP.MailPrelude

data PostViewMail = PostViewMail { post :: Post }

instance BuildMail PostViewMail where
    subject = "Showing Post " <> post.title
        where post = ?mail.post
    to PostViewMail { .. } = Address { addressName = Just "Firstname Lastname", addressEmail = "[email protected]" }
    from = "[email protected]"
    html PostViewMail { .. } = [hsx|
        A post was just viewed
    |]
3

Trigger the email

-- Web/Controller/Posts.hs
import IHP.Mail (sendMail)

action ShowPostAction { .. } = do
    post <- fetch postId

    -- Send mail.
    sendMail $ PostViewMail post

    render ShowView { .. }
4

Test using Mailhog API

-- Test/PostSpec.hs

module Test.PostSpec where

import IHP.Prelude
import IHP.FrameworkConfig
import IHP.Test.Mocking
import IHP.HaskellSupport
import IHP.ModelSupport
import Test.Hspec
import Config

import Generated.Types
import Web.Routes
import Web.Types
import Web.FrontController
import Network.Wai
import IHP.ControllerPrelude
import IHP.ViewPrelude hiding (query)
import Data.Text as Text
import Network.HTTP.Types.Status
import Network.HTTP.Client
import qualified Network.Wreq as Wreq
import Control.Lens ((^.))
import IHP.Hspec (withIHPApp)


tests :: Spec
tests = aroundAll (withIHPApp WebApplication config) do
    describe "Post" do
        it "should send an email on each page view" $ withContext do
            -- Optional: delete any previous emails in Mailhog.
            Wreq.delete "http://0.0.0.0:8025/api/v1/messages"

            -- Get random title.
            title <- generateAuthenticationToken

            -- Create a Post.
            post <- newRecord @Post
                |> set #title title
                |> createRecord

            response <-
                callAction $ ShowPostAction post.id

            -- Assert email was sent, and caught by Mailhog.
            documentBody <- do
                response <- Wreq.get "http://0.0.0.0:8025/api/v1/messages"
                pure (response ^. Wreq.responseBody)

            cs documentBody `shouldContain` ("Post " <> cs post.title)

Advanced

For more details on how to structure test suites see the Hspec manual (a Haskell testing library). You also might want to check out the Hedgehog library for automated property tests.

GitHub Actions

A GitHub Action workflow can be used to run the tests on CI and do deployments. Consult the IHP Boilerplate example for more details.

Build docs developers (and LLMs) love