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.
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"Test.BackendTask.Time - configure virtual time zonesTest.PagesProgram - end-to-end tests for elm-pages routes. Uses TestSetup to seed initial state and reuses the simulators defined here for route-level HTTP and custom port responses.Test.Tui - tests for Tui.program values. Uses TestSetup to seed initial state and the simulators here to resolve BackendTask effects that a TUI performs via Tui.Effect.perform.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:
Script.log (assert with ensureStdout)Environment:
BackendTask.Env.get, BackendTask.Env.expect (withEnv sets initial state)BackendTask.Time.now (withTime sets initial state)BackendTask.Time.zone, BackendTask.Time.zoneFor, BackendTask.Time.zoneByName, BackendTask.Time.zoneByNameFor (Test.BackendTask.Time.withTimeZone / Test.BackendTask.Time.withTimeZoneByName sets initial state)BackendTask.Random.generate (withRandomSeed sets initial state)Script.which (withWhich sets initial state)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.stderrStream.fileRead, Stream.fileWrite, Stream.gzip, Stream.unzipEverything 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.
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.expectSuccessStart 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.expectSuccessLike fromScript but with a configured TestSetup.
import Test.BackendTask as BackendTaskTest
myScript
|> BackendTaskTest.fromScriptWith
(BackendTaskTest.init
|> BackendTaskTest.withFile "config.json" "{}"
)
[]
|> BackendTaskTest.expectSuccessSeed initial state before the test starts running.
The virtual environment used to start a backend task test.
import Test.BackendTask as BackendTaskTest
BackendTaskTest.init
|> BackendTaskTest.withFile "config.json" """{"key":"value"}"""An empty test setup with no seeded state.
Seed a file into the virtual filesystem.
BackendTaskTest.init
|> BackendTaskTest.withFile "config.json"
"""{"key":"value"}"""Seed a binary file into the virtual filesystem.
import Bytes.Encode
BackendTaskTest.init
|> BackendTaskTest.withBinaryFile "data.bin"
(Bytes.Encode.encode
(Bytes.Encode.unsignedInt8 42)
)Seed stdin content for stream pipelines that read from Stream.stdin.
BackendTaskTest.init
|> BackendTaskTest.withStdin "hello from stdin"Seed an environment variable for BackendTask.Env.get and BackendTask.Env.expect.
BackendTaskTest.init
|> BackendTaskTest.withEnv "API_KEY" "secret123"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)Set a fixed request time for server-rendered route requests in
Test.PagesProgram.start.
import Time
BackendTaskTest.init
|> BackendTaskTest.withRequestTime (Time.millisToPosix 1709827200000)Seed a request header for server-rendered route requests in
Test.PagesProgram.start.
Header names are normalized to lowercase.
BackendTaskTest.init
|> BackendTaskTest.withRequestHeader "accept-language" "en-US"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 42Register 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"Low-level setup helpers used by Test.BackendTask.Time.
Low-level helper used by Test.BackendTask.Time.withTimeZone.
import Test.BackendTask as BackendTaskTest
BackendTaskTest.init
|> BackendTaskTest.withTimeZoneConfig
{ defaultOffset = 0, eras = [] }Low-level helper used by Test.BackendTask.Time.withTimeZoneByName.
import Test.BackendTask as BackendTaskTest
BackendTaskTest.init
|> BackendTaskTest.withTimeZoneByNameConfig "America/Chicago"
{ defaultOffset = -360, eras = [] }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.testConfigSeed the virtual DB with a specific initial value.
import Pages.Db
import Test.BackendTask as BackendTaskTest
BackendTaskTest.init
|> BackendTaskTest.withDbSetTo { counter = 5 } Pages.Db.testConfigSimulate 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.expectSuccessSimulate 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.expectSuccessSimulate 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.expectSuccessSimulate 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.expectFailureSimulate 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.expectSuccessThe type of HTTP error to simulate with simulateHttpError.
BackendTaskTest.simulateHttpError
"GET"
"https://api.example.com/data"
BackendTaskTest.NetworkErrorSimulate 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.expectSuccessSimulate 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.expectSuccessSimulate 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.expectSuccessSimulate 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.expectSuccessSimulate 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.expectSuccessCheck conditions mid-pipeline without ending the test. These return the same
BackendTaskTest so you can keep chaining.
Represents a single output message on either stdout or stderr, preserving
the interleaved ordering. Used with ensureOutputWith.
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.expectSuccessAssert 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.expectSuccessAssert 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.expectSuccessAssert 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.expectSuccessAssert that a file exists in the virtual filesystem (without checking its content).
Assert that a file does not exist in the virtual filesystem.
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.expectSuccessAssert 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"
|> ...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")
)
|> ...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")
)
|> ...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.expectSuccessDeclare 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.
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.expectSuccessCreate a simulated file write effect.
BackendTaskTest.writeFileEffect "output.txt" "file content"Create a simulated file removal effect.
BackendTaskTest.removeFileEffect "temp.txt"Every test must end with exactly one of these to produce an Expectation.
Assert that the BackendTask completed successfully.
BackendTask.succeed ()
|> BackendTaskTest.fromBackendTask
|> BackendTaskTest.expectSuccessIf there are still pending requests, the test fails with a message listing them.
Like expectSuccess, but also runs an assertion on the result value.
BackendTask.succeed 42
|> BackendTaskTest.fromBackendTask
|> BackendTaskTest.expectSuccessWith (Expect.equal 42)Assert that the BackendTask completed with a FatalError.
Like expectFailure, but also runs an assertion on the error.
myTask
|> BackendTaskTest.fromBackendTask
|> BackendTaskTest.expectFailureWith
(\error -> error.title |> Expect.equal "Http Error")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...")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)
Start a test from a
BackendTask FatalError a. Predictable effects likeScript.logandScript.writeFileare resolved automatically. You only need to simulate external effects like HTTP requests andBackendTask.Custom.runcalls.