dillonkearns/elm-pages/12.1.0

NOTE: the latest version is 12.3.0

Test.BackendTask

Write pure tests for your BackendTasks and Scripts that you can run with elm-test. No HTTP calls, no file I/O, or side effects of any kind. Just regular Elm tests.

Common scripting tasks like file operations, logging, and environment variables are emulated for you. You only need to tell the test how to simulate unpredictable events in the outside world (like HTTP responses and shell commands), and what starting state you want for your file system and environment variables. Everything else will be emulated for you.

You can test a BackendTask directly using fromBackendTask and fromBackendTaskWith. For testing full Script values including CLI option parsing, use fromScript and fromScriptWith.

Predictable effects are emulated automatically

No simulation calls needed. Set the initial state with withFile and assert on the results with ensureFile.

import BackendTask exposing (BackendTask)
import FatalError exposing (FatalError)
import Json.Decode as Decode
import Pages.Script as Script
import Test exposing (test)
import Test.BackendTask as BackendTaskTest


generateDotEnv : String -> BackendTask FatalError ()
generateDotEnv outputPath =
    BackendTask.File.jsonFile configDecoder "config.json"
        |> BackendTask.allowFatal
        |> BackendTask.andThen
            (\config ->
                Script.writeFile
                    { path = outputPath
                    , body = toDotEnv config
                    }
                    |> BackendTask.allowFatal
            )


test "generates .env from config" <|
    \() ->
        generateDotEnv ".env"
            |> BackendTaskTest.fromBackendTaskWith
                (BackendTaskTest.init
                    |> BackendTaskTest.withFile "config.json"
                        """{"host": "localhost", "port": 3000, "debug": true}"""
                )
            |> BackendTaskTest.ensureFile ".env"
                "HOST=localhost\nPORT=3000\nDEBUG=true"
            |> BackendTaskTest.expectSuccess


configDecoder : Decode.Decoder { host : String, port_ : Int, debug : Bool }
configDecoder =
    Decode.map3 (\h p d -> { host = h, port_ = p, debug = d })
        (Decode.field "host" Decode.string)
        (Decode.field "port" Decode.int)
        (Decode.field "debug" Decode.bool)


toDotEnv : { host : String, port_ : Int, debug : Bool } -> String
toDotEnv config =
    [ "HOST=" ++ config.host
    , "PORT=" ++ String.fromInt config.port_
    , "DEBUG=" ++ (if config.debug then "true" else "false")
    ]
        |> String.join "\n"

See Also

Automatic Virtual State Emulation

These effects are emulated automatically against virtual state:

File operations:

  • Script.writeFile, Script.removeFile, Script.copyFile, Script.move (assert with ensureFile)
  • BackendTask.File.rawFile, BackendTask.File.jsonFile, BackendTask.File.exists (withFile sets initial state)
  • BackendTask.Glob (matches against files set with withFile)

Output:

Environment:

Database:

  • Pages.Db.get, Pages.Db.update, Pages.Db.transaction (withDb sets initial state, assert with expectDb)

CLI parsing:

  • Script.withCliOptions, Script.withoutCliOptions (parsed from args passed to fromScript)

Streams:

  • Stream.fromString, Stream.stdin, Stream.stdout, Stream.stderr
  • Stream.fileRead, Stream.fileWrite, Stream.gzip, Stream.unzip

Everything else represents outside data and effects (HTTP requests, shell commands, BackendTask.Custom calls) which you must simulate using the functions below. If your test case encounters one of these which is not simulated, it will fail with a clear message with instructions for how to simulate it.

Building

fromBackendTask : BackendTask FatalError a -> BackendTaskTest a

Start a test from a BackendTask FatalError a. Predictable effects like Script.log and Script.writeFile are resolved automatically. You only need to simulate external effects like HTTP requests and BackendTask.Custom.run calls.

import BackendTask
import Pages.Script as Script
import Test.BackendTask as BackendTaskTest


Script.log "Hello!"
    |> BackendTask.allowFatal
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.expectSuccess
fromBackendTaskWith : TestSetup -> BackendTask FatalError a -> BackendTaskTest a

Start a test with a configured TestSetup. Use this when you need to seed initial files, environment variables, or other state.

import BackendTask.File
import Test.BackendTask as BackendTaskTest


BackendTask.File.rawFile "config.json"
    |> BackendTask.allowFatal
    |> BackendTask.andThen (\content -> Script.log content)
    |> BackendTaskTest.fromBackendTaskWith
        (BackendTaskTest.init
            |> BackendTaskTest.withFile "config.json" """{"key":"value"}"""
        )
    |> BackendTaskTest.ensureStdout [ """{"key":"value"}""" ]
    |> BackendTaskTest.expectSuccess
fromScript : List String -> Script -> BackendTaskTest ()

Start a test from a Script with simulated CLI arguments. This tests the full script including CLI option parsing.

import Test.BackendTask as BackendTaskTest


myScript
    |> BackendTaskTest.fromScript [ "--name", "Dillon" ]
    |> BackendTaskTest.ensureStdout [ "Hello, Dillon!" ]
    |> BackendTaskTest.expectSuccess
fromScriptWith : TestSetup -> List String -> Script -> BackendTaskTest ()

Like fromScript but with a configured TestSetup.

import Test.BackendTask as BackendTaskTest


myScript
    |> BackendTaskTest.fromScriptWith
        (BackendTaskTest.init
            |> BackendTaskTest.withFile "config.json" "{}"
        )
        []
    |> BackendTaskTest.expectSuccess

Test Setup

Seed initial state before the test starts running.

init : TestSetup

An empty test setup with no seeded state.

withFile : String -> String -> TestSetup -> TestSetup

Seed a file into the virtual filesystem.

BackendTaskTest.init
    |> BackendTaskTest.withFile "config.json"
        """{"key":"value"}"""
withBinaryFile : String -> Bytes -> TestSetup -> TestSetup

Seed a binary file into the virtual filesystem.

import Bytes.Encode


BackendTaskTest.init
    |> BackendTaskTest.withBinaryFile "data.bin"
        (Bytes.Encode.encode
        (Bytes.Encode.unsignedInt8 42)
        )
withStdin : String -> TestSetup -> TestSetup

Seed stdin content for stream pipelines that read from Stream.stdin.

BackendTaskTest.init
    |> BackendTaskTest.withStdin "hello from stdin"
withEnv : String -> String -> TestSetup -> TestSetup

Seed an environment variable for BackendTask.Env.get and BackendTask.Env.expect.

BackendTaskTest.init
    |> BackendTaskTest.withEnv "API_KEY" "secret123"
withTime : Posix -> TestSetup -> TestSetup

Set a fixed virtual time for BackendTask.Time.now. Without this, any use of BackendTask.Time.now will produce a test error with a helpful message.

import Time


BackendTaskTest.init
    |> BackendTaskTest.withTime (Time.millisToPosix 0)
withRandomSeed : Int -> TestSetup -> TestSetup

Set a fixed random seed for BackendTask.Random.int32 and BackendTask.Random.generate. Without this, any use of BackendTask.Random will produce a test error with a helpful message.

BackendTaskTest.init
    |> BackendTaskTest.withRandomSeed 42
withWhich : String -> String -> TestSetup -> TestSetup

Register a command as available for Script.which and Script.expectWhich. Commands not registered will return Nothing from Script.which.

BackendTaskTest.init
    |> BackendTaskTest.withWhich "node" "/usr/bin/node"
{ a
| schemaVersion : Int
, schemaHash : String
, encode : db -> Bytes
, seed : db
}
-> TestSetup
-> TestSetup

Seed the virtual DB with the default seed value from the generated testConfig.

The elm-pages Script DB is a pure Elm type that is serialized to disk. Since it's just a plain Elm value, the test framework can realistically simulate it. Reads, writes, and updates all work against an in-memory copy. The only difference from production is that instead of reading the initial value from a binary file of the serialized Elm type on disk, you provide it directly with this function or withDbSetTo.

import Pages.Db
import Test.BackendTask as BackendTaskTest


BackendTaskTest.init
    |> BackendTaskTest.withDb Pages.Db.testConfig
db
-> { a
| schemaVersion : Int
, schemaHash : String
, encode : db -> Bytes
}
-> TestSetup
-> TestSetup

Seed the virtual DB with a specific initial value.

import Pages.Db
import Test.BackendTask as BackendTaskTest


BackendTaskTest.init
    |> BackendTaskTest.withDbSetTo { counter = 5 } Pages.Db.testConfig

Simulating HTTP

simulateHttpGet : String -> Value -> BackendTaskTest a -> BackendTaskTest a

Simulate a pending HTTP GET request resolving with the given JSON response body.

import Json.Encode as Encode
import Test.BackendTask as BackendTaskTest


myTask
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.simulateHttpGet
        "https://api.example.com/data"
        (Encode.object [ ( "key", Encode.string "value" ) ])
    |> BackendTaskTest.expectSuccess
simulateHttpPost : String -> Value -> BackendTaskTest a -> BackendTaskTest a

Simulate a pending HTTP POST request resolving with the given JSON response body.

import Json.Encode as Encode
import Test.BackendTask as BackendTaskTest


myTask
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.simulateHttpPost
        "https://api.example.com/items"
        (Encode.object [ ( "id", Encode.int 42 ) ])
    |> BackendTaskTest.expectSuccess
{ method : String
, url : String
}
-> { statusCode : Int
, statusText : String
, headers : List ( String, String )
, body : Value
}
-> BackendTaskTest a
-> BackendTaskTest a

Simulate any HTTP request with full control over method, status code, headers, and body.

import Test.BackendTask as BackendTaskTest
import Json.Encode as Encode


myTask
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.simulateHttp
        { method = "PUT", url = "https://api.example.com/item/1" }
        { statusCode = 204
        , statusText = "No Content"
        , headers = []
        , body = Encode.null
        }
    |> BackendTaskTest.expectSuccess
simulateHttpError : String -> String -> HttpError -> BackendTaskTest a -> BackendTaskTest a

Simulate a pending HTTP request failing with an HttpError.

import Test.BackendTask as BackendTaskTest


myTask
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.simulateHttpError "GET" "https://api.example.com/data" BackendTaskTest.NetworkError
    |> BackendTaskTest.expectFailure
simulateHttpStream : String -> String -> BackendTaskTest a -> BackendTaskTest a

Simulate a pending stream pipeline containing an HTTP stream part (Stream.http or Stream.httpWithInput). The framework handles simulatable parts around the HTTP request. You only provide the response body.

import Test.BackendTask as BackendTaskTest


myTask
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.simulateHttpStream "https://api.example.com" "response body"
    |> BackendTaskTest.expectSuccess
= NetworkError
| Timeout

The type of HTTP error to simulate with simulateHttpError.

BackendTaskTest.simulateHttpError
    "GET"
    "https://api.example.com/data"
    BackendTaskTest.NetworkError

Simulating Commands

simulateCommand : String -> String -> BackendTaskTest a -> BackendTaskTest a

Simulate a pending Stream.command / Script.command / Script.exec call. The framework handles simulatable parts around the command. You provide the command name and its output.

import Test.BackendTask as BackendTaskTest


myTask
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.simulateCommand "grep" "matched line\n"
    |> BackendTaskTest.expectSuccess

Simulating Custom Ports

simulateCustom : String -> Value -> BackendTaskTest a -> BackendTaskTest a

Simulate a pending BackendTask.Custom.run call resolving with the given JSON value. The port name must exactly match the first argument passed to BackendTask.Custom.run.

import Json.Encode as Encode
import Test.BackendTask as BackendTaskTest


myTask
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.simulateCustom "hashPassword" (Encode.string "hashed_secret123")
    |> BackendTaskTest.expectSuccess
simulateCustomStream : String -> String -> BackendTaskTest a -> BackendTaskTest a

Simulate a pending stream pipeline containing a custom stream part (Stream.customRead, Stream.customWrite, or Stream.customDuplex). The framework handles simulatable parts around the custom part. You only provide its output.

import Test.BackendTask as BackendTaskTest


myTask
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.simulateCustomStream "myTransform" "transformed output"
    |> BackendTaskTest.expectSuccess

Simulating Interactive Input

simulateQuestion : String -> String -> BackendTaskTest a -> BackendTaskTest a

Simulate a pending Script.question call. The prompt must match the prompt text passed to Script.question.

import Test.BackendTask as BackendTaskTest


myTask
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.simulateQuestion "What is your name? " "Dillon"
    |> BackendTaskTest.ensureStdout [ "Hello, Dillon!" ]
    |> BackendTaskTest.expectSuccess
simulateReadKey : String -> BackendTaskTest a -> BackendTaskTest a

Simulate a pending Script.readKey call resolving with the given key.

import Test.BackendTask as BackendTaskTest


myTask
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.simulateReadKey "y"
    |> BackendTaskTest.ensureStdout [ "confirmed" ]
    |> BackendTaskTest.expectSuccess

Assertions

Check conditions mid-pipeline without ending the test. These return the same BackendTaskTest so you can keep chaining.

type Output
= Stdout String
| Stderr String

Represents a single output message on either stdout or stderr, preserving the interleaved ordering. Used with ensureOutputWith.

ensureStdout : List String -> BackendTaskTest a -> BackendTaskTest a

Assert that exactly these stdout messages were produced since the last drain. Fails if any stderr output is present. Use ensureOutputWith to check both stdout and stderr together.

Script.log "Hello!"
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.ensureStdout [ "Hello!" ]
    |> BackendTaskTest.expectSuccess
ensureStderr : List String -> BackendTaskTest a -> BackendTaskTest a

Assert that exactly these stderr messages were produced since the last drain. Fails if any stdout output is present. Use ensureOutputWith to check both stdout and stderr together.

Stream.fromString "warning!"
    |> Stream.pipe Stream.stderr
    |> Stream.run
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.ensureStderr [ "warning!" ]
    |> BackendTaskTest.expectSuccess
(List Output -> Expectation)
-> BackendTaskTest a
-> BackendTaskTest a

Assert on interleaved stdout/stderr output, preserving ordering. Drains on success, preserves on failure.

import Test.BackendTask as BackendTaskTest exposing (Output(..))


myTask
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.ensureOutputWith
        (\outputs ->
            Expect.equal
                [ Stdout "step 1", Stderr "warning!", Stdout "step 2" ]
                outputs
        )
    |> BackendTaskTest.expectSuccess
ensureFile : String -> String -> BackendTaskTest a -> BackendTaskTest a

Assert that a file exists in the virtual filesystem with the given content.

Script.writeFile { path = "output.txt", body = "hello" }
    |> BackendTask.allowFatal
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.ensureFile "output.txt" "hello"
    |> BackendTaskTest.expectSuccess
ensureFileExists : String -> BackendTaskTest a -> BackendTaskTest a

Assert that a file exists in the virtual filesystem (without checking its content).

ensureNoFile : String -> BackendTaskTest a -> BackendTaskTest a

Assert that a file does not exist in the virtual filesystem.

{ path : String
, body : String
}
-> BackendTaskTest a
-> BackendTaskTest a

Assert that a file write occurred with the given path and body.

Script.writeFile { path = "out.json", body = """{"key":"value"}""" }
    |> BackendTask.allowFatal
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.ensureFileWritten { path = "out.json", body = """{"key":"value"}""" }
    |> BackendTaskTest.expectSuccess
ensureHttpGet : String -> BackendTaskTest a -> BackendTaskTest a

Assert that a GET request to the given URL is currently pending, without resolving it. Useful for verifying that requests are dispatched in parallel.

import Test.BackendTask as BackendTaskTest


myTask
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.ensureHttpGet "https://api.example.com/a"
    |> BackendTaskTest.ensureHttpGet "https://api.example.com/b"
    |> ...
String
-> (Value -> Expectation)
-> BackendTaskTest a
-> BackendTaskTest a

Assert that a POST request to the given URL is currently pending, and run an assertion on the request body. Does not resolve the request.

import Expect
import Json.Decode as Decode
import Test.BackendTask as BackendTaskTest


myTask
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.ensureHttpPost "https://api.example.com/items"
        (\body ->
            Decode.decodeValue (Decode.field "name" Decode.string) body
                |> Expect.equal (Ok "test")
        )
    |> ...
String
-> (Value -> Expectation)
-> BackendTaskTest a
-> BackendTaskTest a

Assert that a BackendTask.Custom.run call with the given port name is currently pending, and run an assertion on the input arguments. Does not resolve the request.

import Expect
import Json.Decode as Decode
import Test.BackendTask as BackendTaskTest


myTask
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.ensureCustom "hashPassword"
        (\args ->
            Decode.decodeValue Decode.string args
                |> Expect.equal (Ok "secret123")
        )
    |> ...
String
-> (List String -> Expectation)
-> BackendTaskTest a
-> BackendTaskTest a

Assert that a command with the given name is currently pending, and run an assertion on its arguments. Does not resolve the request. Useful for verifying commands are called with expected arguments before simulating them.

import Test.BackendTask as BackendTaskTest


Script.exec "elm" [ "make", "--docs=docs.json" ]
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.ensureCommand "elm"
        (\args -> Expect.equal [ "make", "--docs=docs.json" ] args)
    |> BackendTaskTest.simulateCommand "elm" ""
    |> BackendTaskTest.expectSuccess

Virtual Effects

Declare virtual effects for opaque external operations. Today these effects update the virtual filesystem. When a call is resolved via simulateCustom or simulateCommand, the registered handler's effects are applied to the virtual filesystem automatically.

An effect on the virtual filesystem declared by an opaque external operation (shell command or BackendTask.Custom.run call) via withVirtualEffects. Create values with writeFileEffect and removeFileEffect.

(String -> Value -> List SimulatedEffect)
-> BackendTaskTest a
-> BackendTaskTest a

Declare virtual filesystem effects for opaque external operations. The handler receives the operation name (port name or command name) and request body, and returns a list of SimulatedEffects applied to the virtual filesystem when the operation is simulated. For custom ports the body is the JSON input passed to BackendTask.Custom.run. For commands the body is the args as a JSON list of strings.

This fires for both simulateCustom and simulateCommand.

myTask
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.withVirtualEffects
        (\name _ ->
            case name of
                "mkdir" ->
                    [ BackendTaskTest.writeFileEffect "new-dir/.gitkeep" "" ]


                "generateReport" ->
                    [ BackendTaskTest.writeFileEffect "report.pdf" "content" ]


                _ ->
                    []
        )
    |> BackendTaskTest.simulateCommand "mkdir" ""
    |> BackendTaskTest.ensureFileExists "new-dir/.gitkeep"
    |> BackendTaskTest.expectSuccess
writeFileEffect : String -> String -> SimulatedEffect

Create a simulated file write effect.

BackendTaskTest.writeFileEffect "output.txt" "file content"

Create a simulated file removal effect.

BackendTaskTest.removeFileEffect "temp.txt"

Terminal Assertions

Every test must end with exactly one of these to produce an Expectation.

expectSuccess : BackendTaskTest a -> Expectation

Assert that the BackendTask completed successfully.

BackendTask.succeed ()
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.expectSuccess

If there are still pending requests, the test fails with a message listing them.

expectSuccessWith : (a -> Expectation) -> BackendTaskTest a -> Expectation

Like expectSuccess, but also runs an assertion on the result value.

BackendTask.succeed 42
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.expectSuccessWith (Expect.equal 42)
expectFailure : BackendTaskTest a -> Expectation

Assert that the BackendTask completed with a FatalError.

( { title : String
, body : String
}
-> Expectation
)
-> BackendTaskTest a
-> Expectation

Like expectFailure, but also runs an assertion on the error.

myTask
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.expectFailureWith
        (\error -> error.title |> Expect.equal "Http Error")
expectTestError : (String -> Expectation) -> BackendTaskTest a -> Expectation

Assert that the test framework itself produced an error (e.g., a simulation that didn't match any pending request, or a missing withTime configuration).

BackendTask.Time.now
    |> BackendTask.andThen (\_ -> BackendTask.succeed ())
    |> BackendTaskTest.fromBackendTask
    |> BackendTaskTest.expectTestError
        (Expect.equal "BackendTask.Time.now requires a virtual time.\\n\\n...")
{ a | decode : Bytes -> Maybe db }
-> (db -> Expectation)
-> BackendTaskTest b
-> Expectation

Assert on the virtual DB state after the script completes. This is a terminal assertion that also checks the script completed successfully.

import Expect
import Pages.Db
import Test.BackendTask as BackendTaskTest


myTask
    |> BackendTaskTest.fromBackendTaskWith
        (BackendTaskTest.init
            |> BackendTaskTest.withDb Pages.Db.testConfig
        )
    |> BackendTaskTest.expectDb Pages.Db.testConfig
        (\db -> Expect.equal 1 db.counter)