Elmer makes it easy to describe the behavior of Elm HTML applications.
Behavior-driven development is a great practice to follow when writing applications. If you describe the behavior of your app in code, it's easy to add new features or refactor the code with confidence: just run the tests to see if your app still has all the behavior you've described in those tests.
Elm is a really great language for writing web applications. However, practicing BDD
in Elm can be difficult. The two main functions in the Elm architecture -- view
and
update
-- return opaque types, which cannot be inspected for testing purposes.
Even so, calling view
or update
directly requires knowledge of an application's
implementation: the shape of its model, its messages, and so on. If writing
tests requires knowledge of implementation details, you lose the biggest
benefit of writing tests in the first place: the ability to change your code
with confidence.
Elmer allows you to describe the behavior of your app without knowledge of
implementation details. It simulates the Elm architecture, calling view
and update
as necessary throughout the course of your test. It lets
you manage how commands and subscriptions are processed so you can
describe the behavior of your app under whatever conditions you need. Elmer
allows you to write tests first, which gives you the freedom and confidence
to change your code later on.
Elmer requires Elm 0.18 and the latest version of elm-test.
Since Elmer uses some native code, it cannot be installed via the official Elm package repository. Instead, you can use elm-github-install to install it.
First, install elm-test -- Elmer has been tested with version 0.18.11 of the elm-test node-test-runner.
$ npm install -g elm-test@0.18.11
Next, initialize your project with elm-test.
$ elm-test init
Then, go into the tests
directory and install Elmer. You'll need to manually edit the
elm-package.json
file and add to the dependencies
like so:
"dependencies": {
"brian-watkins/elmer": "3.0.0 <= v < 4.0.0",
...
}
Then, if you are using elm-github-install, inside the tests directory run:
$ elm-install
Note that each time you add a new dependency you'll need to manually run
elm-install
in the tests directory before running your tests.
The latest documentation can be found here.
Earlier versions of the documentation can be found here.
Elmer functions generally pass around TestState
values. To get started describing some
behavior with Elmer, you'll need to generate an initial test state with the Elmer.given
function. Just pass it your model, view method, and update method.
Use Elmer.Html.target
to target an HtmlElement
, which describes an HTML element in your view. The target
function takes a selector and a TestState
as arguments. The selector can take the following
formats:
- To target the first element with the class myClass, use
.myClass
- To target a element with the id myId, use
#myId
- To target the first div tag, use
div
- To target the first div tag with the custom data attribute data-node, use
div[data-node]
- To target the first div tag with the attribute data-node and the value myData, use
div[data-node='myData']
- To target the descendants of an element add another selector separated by a space:
div a
Once you target an element, that element is the subject of subsequent actions, until you target another element. The following functions define actions on elements:
- Click events:
Elmer.Html.Event.click <testState>
- Input events:
Elmer.Html.Event.input <text> <testState>
- Custom events:
Elmer.Html.Event.trigger <eventName> <eventJson> <testState>
- There are also events for mouse movements, and checking and selecting input elements. See the docs for more information.
You can make expectations about the Html generated by a component's view function with the
Elmer.Html.expect
function.
First, specify whether you want to match against a single element (with element
)
or a list of elements (with elements
).
Then you provide the appropriate matchers for the element or the list. You can also
expect that an element exists with the elementExists
matcher.
The following matchers can be used to make expectations about an HtmlElement
:
hasId <string> <HtmlElement>
hasClass <string> <HtmlElement>
hasText <string> <HtmlElement>
hasProperty (<string>, <string>) <HtmlElement>
- More to come ...
You can combine multiple matchers using the <&&>
operator like so:
Elmer.Html.expect (element <|
Matchers.hasText "Text one" <&&>
Matchers.hasText "Text two"
) testStateResult
Make expectations about a list of elements like so:
Elmer.Html.target "div" testState
|> Elmer.Html.expect (elements <| Elmer.hasLength 4)
Let's test-drive a simple Elm HTML Application. We want to have a button on the screen that, when clicked, updates a counter. First, we write a test, using Elm-Test and Elmer:
allTests : Test
allTests =
let
initialState = Elmer.given App.defaultModel App.view App.update
in
describe "my app"
[ describe "initial state"
[ test "it shows that no clicks have occurred" <|
\() ->
Elmer.Html.target "#clickCount" initialState
|> Elmer.Html.expect (
Elmer.Html.Matchers.element <|
Elmer.Html.Matchers.hasText "0 clicks!"
)
]
]
Our test finds the html element containing the counter text by its id and checks that it has the text we expect when the app first appears. Let's make it pass:
import Html as Html
import Html.Attributes as Attr
type alias Model =
{ clicks: Int }
defaultModel : Model
defaultModel =
{ clicks = 0 }
type Msg =
NothingYet
view : Model -> Html Msg
view model =
Html.div [ Attr.id "clickCount" ] [ Html.text "0 clicks!" ]
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
( model, Cmd.None )
If we run our test now, it should pass.
Now, let's add a new test that describes what we expect to happen when a button is clicked.
describe "when the button is clicked"
[ test "it updates the counter" <|
\() ->
Elmer.Html.target ".button" initialState
|> Elmer.Html.Event.click
|> Elmer.Html.Event.click
|> Elmer.Html.target "#clickCount"
|> Elmer.Html.expect (
Elmer.Html.Matchers.element <|
Elmer.Html.Matchers.hasText "2 clicks!"
)
]
This should fail, since we don't even have a button in our view yet. Let's fix that. We'll add a button with a click event handler that sends a message we can handle in the update function. We update the Msg
type, the view
function, and the update
function like so:
import Html.Events as Events
type Msg =
HandleClick
view : Model -> Html Msg
view model =
Html.div []
[ Html.div [ Attr.id "clickCount" ] [ Html.text ((toString model.clicks) ++ " clicks!") ]
, Html.div [ Attr.class "button", Events.onClick HandleClick ] [ Html.text "Click Me" ]
]
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
HandleClick ->
( { model | clicks = model.clicks + 1 }, Cmd.None )
And our test should pass.
Notice that we were able to test-drive our app, writing our tests first, without worrying about implementation details like the names of the messages our update
function will use and so on. Elmer simulates the elm architecture workflow, delivering messages to the update
function when events occur and passing the result to the view
function so you can write expectations about the updated html.
Commands describe actions to be performed by the Elm runtime. Elmer simulates the Elm runtime in order to facilitate testing, but it is not intended to replicate the Elm runtime's ability to carry out commands. Instead, Elmer allows you to specify what effect should result from running a command so that you can then describe the behavior that follows.
Suppose there is a function f : a -> b -> Cmd msg
that takes two arguments and produces a command. In order
to specify the effect of this command in our tests, we will override occurrences of f
with a function we create in our tests. This function will generate a special command
that specifies the intended effect, and Elmer will process the result as if the original command were actually performed.
For a more concrete example, check out this article, which discusses how to fake commands and subscriptions during a test.
Note that while Elmer is not capable of processing any commands, it does support
the general operations on commands in the core Platform.Cmd
module, namely, batch
and map
. So, you
can use these functions as expected in your components and Elmer should do the right thing.
Elmer provides additional support for HTTP request commands and navigation commands.
Modern web apps often need to make HTTP requests to some backend server. Elmer makes it easy to stub HTTP
responses and write expectations about the requests made. The Elmer.Http.Stub
module contains methods
for constructing an HttpResponseStub
that describes how to respond to some request. For example:
let
stubbedResponse = Elmer.Http.Stub.for (Elmer.Http.Route.get "http://fake.com/search")
|> Elmer.Http.Stub.withBody "{\"name\":\"Super Fun Person\",\"type\":\"person\"}"
in
In this case, a GET request to the given route will result in a response with the given body.
See Elmer.Http.Stub
for the full list of builder functions. (With more on the way ...)
Once an HttpResponseStub
has been created, you can use the Elmer.Http.serve
function
along with Elmer.Spy.use
to override Http.send
and Http.toTask
from elm-lang/http during your test.
When your application code calls Http.send
or Http.toTask
, the request will be checked against the
provided stubs and if a match occurs, the given response will be returned.
Elmer also allows you to write tests that expect some HTTP request to have been made, in a manner similar to how you can write expectations about some element in an HTML document. For example, this test inputs search terms into a field, clicks a search button, and then expects that a request is made to a specific route with the search terms in the query string:
initialTestState
|> Elmer.Spy.use [ Elmer.Http.serve [ stubbedResponse ] ]
|> Elmer.Html.target "input[name='query']"
|> Elmer.Html.Event.input "Fun Stuff"
|> Elmer.Html.target "#search-button"
|> Elmer.Html.Event.click
|> Elmer.Http.expectThat (Elmer.Http.Route.get "http://fake.com/search") (
Elmer.some <| Elmer.Http.Matchers.hasQueryParam ("q", "Fun Stuff")
)
If you don't care to describe the behavior of your app after the response from a request is
received -- that is, if you don't care to create a stubbed response for some request -- you
can provide Elmer.Http.spy
to Elmer.Spy.use
and it will override the Http.send
and Http.toTask
functions so that they merely record any requests received.
See Elmer.Http
and Elmer.Http.Matchers
for more.
Elmer provides support for functions in the elm-lang/navigation module that allow you to handle navigation for single-page web applications.
To simulate location updates, you must construct a TestState
and
set the location parser function (Navigation.Location -> msg
) that you provide to Navigation.program
when you initialize your app using Elmer.Navigation.withLocationParser
. This provides
Elmer with the information it needs to process location updates as they occur in a test.
You can send a command to update the location manually with the Elmer.Navigation.setLocation
function.
If your component produces commands to update the location using Navigation.newUrl
or
Navigation.modifyUrl
, your tests you should provide Elmer.Spy.use
with Elmer.Navigation.spy
so that Elmer will be able to record and process location updates.
You can write an expectation about the current location with Elmer.Navigation.expectLocation
.
See tests/Elmer/TestApps/NavigationTestApp.elm
and tests/Elmer/NavigationTests.elm
for
examples.
Sometimes, a component may be sent a command, either from its parent or as part of initialization.
You can use the Elmer.Platform.Command.send
function to simulate this.
It's often necessary to test the state of a component while some command is running. For example,
one might want to show a progress indicator while an HTTP request is in process. Elmer provides
general support for deferred commands. Use Elmer.Platform.Command.defer
to create a command that
will not be processed until Elmer.Platform.Command.resolveDeferred
is called. Note that all currently
deferred commands will be resolved when this function is called.
Elmer.Http
allows you to specify when the processing of a stubbed response should be deferred.
When you create your HttpResponseStub
just use the Elmer.Http.Stub.deferResponse
builder function
to indicate that this response should be deferred until Elmer.Platform.Command.resolveDeferred
is called.
You might want to write a test that expects a command to be sent, but doesn't care to describe the
behavior that results from processing that command -- perhaps that is tested somewhere else. In
that case, you could use Elmer.Platform.Command.dummy <identifier>
to create a dummy command.
When Elmer processes a dummy command, it simply records the fact that the command was sent; otherwise
it treats the command just like Cmd.none
. In your test, use Elmer.command.expectDummy <identifier>
to expect that the command was sent.
You might want to test a command independently of any module that might use it. In that case,
use Elmer.Headless.givenCommand
and provide it with a function that generates the command you
want to test. This will initiate a TestState
that simply records any messages that result when
the given command is processed. You can use the Elmer.Headless.expectMessages
matcher to
make any expectations about the messages received. For example, here's a test that expects a
certain message when a certain command is processed:
Elmer.Headless.givenCommand (\_ -> MyModule.myCommand MyTagger withSomeArgument)
|> Elmer.Headless.expectMessages (\messages ->
Expect.equal [ MyTagger "Fun Result" ]
)
You can use Elmer.Headless.givenCommand
with other matchers as it makes sense. So, you might
write a test that expects a certain Http request to result from the processing of a command:
Elmer.Headless.givenCommand (\_ -> MyModule.sendRequest MyTagger someArgument)
|> Elmer.Spy.use [ Elmer.Http.spy ]
|> Elmer.Http.expect (Elmer.Http.Route.get "http://fun.com/api/someArgument")
Using subscriptions, your component can register to be notified when certain effects occur. To describe the behavior of a component that has subscriptions, you'll need to do these things:
- Override the function that generates the subscription using
Elmer.Spy.create
along withElmer.Spy.andCallFake
and replace it with a fake subscription usingElmer.Platform.Subscription.fake
- Register the subscriptions using
Elmer.Platform.Subscription.with
- Simulate the effect you've subscribed to receive with
Elmer.Platform.Subscription.send
Here's an example test:
timeSubscriptionTest : Test
timeSubscriptionTest =
describe "when a time effect is received"
[ test "it prints the number of seconds" <|
\() ->
let
timeSpy = Elmer.Spy.create "fake-time" (\_ -> Time.every)
|> Elmer.Spy.andCallFake (\_ tagger ->
Elmer.Platform.Subscription.fake "timeEffect" tagger
)
in
Elmer.given App.defaultModel App.view App.update
|> Elmer.Spy.use [ timeSpy ]
|> Elmer.Platform.Subscription.with (\() -> App.subscriptions)
|> Elmer.Platform.Subscription.send "timeEffect" (3 * 1000)
|> Elmer.Html.target "#num-seconds"
|> Elmer.Html.expect (
Elmer.Html.Matchers.element <|
Elmer.Html.Matchers.hasText "3 seconds"
)
]
For a more complete example, check out this article.
You can manage ports during your test in just the same way you would manage any command or subscription.
Suppose you have a port that sends data to Javascript:
port module MyModule exposing (..)
port sendData : String -> Cmd msg
You can create a spy for this function just like you would for any command-generating function:
Elmer.Spy.create "port-spy" (\_ -> MyModule.sendData)
|> Elmer.Spy.andCallFake (\_ -> Cmd.none)
Note that you will need to provide a fake implementation of this method since otherwise Elmer will not know how to handle the generated command.
A port that receives data from Javascript works just the same as any subscription.
port receiveData : (String -> msg) -> Sub msg
type Msg = ReceivedData String
subscriptions : Module -> Sub Msg
subscriptions model =
receiveData ReceivedData
We can create a spy for this subscription-generating function and provide a fake subscription that will allow us to send data tagged with the appropriate message during our test.
let
spy =
Elmer.Spy.create "port-spy" (\_ -> MyModule.receiveData)
|> Elmer.Spy.andCallFake (\tagger ->
Elmer.Platform.Subscription.fake "fake-receive" tagger
)
in
Elmer.given MyModule.defaultModel MyModule.view MyModule.update
|> Elmer.Spy.use [ spy ]
|> Elmer.Platform.Subscription.with (\_ -> MyModule.subscriptions)
|> Elmer.Platform.Subscription.send "fake-receive" "some fake data"
|> ...
Elm uses tasks to describe asynchronous operations at a high-level. You can use Elmer to describe the behavior of applications that use the Task API. To do so:
-
Stub any task-generating functions to return a task created with
Task.succeed
orTask.fail
and the value you want as necessary for the behavior you want to describe. -
That's it.
Elmer does not know how to run any tasks other than Task.succeed
and Task.fail
.
However, Elmer does know how to properly apply all the functions from the Task API.
In this way, Elmer allows you to describe the behavior that results from operations
with tasks without actually running those tasks during your test.
Here's an example. Suppose when a button is clicked, your app creates a task that gets
the current time, formats it (using some function called formatTime : Time -> String
),
and tags the resulting string with TagFormattedTime
like so:
Time.now
|> Task.map formatTime
|> Task.perform TagFormattedTime
You can test this behavior by replacing Time.now
with a Task.succeed
that resolves to the time you want.
let
timeSpy =
Task.succeed 1515281017615
|> Spy.replaceValue (\_ -> Time.now)
in
testState
|> Spy.use [ timeSpy ]
|> Elmer.Html.target "#get-current-time"
|> Elmer.Html.Event.click
|> Elmer.Html.target "#current-time"
|> Elmer.Html.expect (
element <| hasText "1/6/2018 23:23:37"
)
Elmer generalizes the pattern for managing the effects of Subs
and Cmds
, allowing
you to spy on any function you like.
Suppose you need to write a test that expects a certain function to be called, but
you don't need to describe the resulting behavior. You can spy on a function with
Elmer.Spy.create
and make expectations about it with Elmer.Spy.expect
.
For example, suppose you want to ensure that a component is calling a specific function in another module for parsing some string. You have tests for the parsing function itself; you just need to know that your component is using it.
parseTest : Test
parseTest =
describe "when the string is submitted"
[ test "it passes it to the parsing module" <|
\() ->
let
spy = Elmer.Spy.create "parser-spy" (\_ -> MyParserModule.parse)
in
Elmer.given App.defaultModel App.view App.update
|> Elmer.Spy.use [ spy ]
|> Elmer.Html.target "input[type='text']"
|> Elmer.Html.Event.input "A string to be parsed"
|> Elmer.Spy.expect "parser-spy" (wasCalled 1)
]
Elmer also allows you to provide a fake implementation for any function. Suppose that you are testing a routing module. Given a certain route, you want to see that the result of a certain component's view function is displayed. You could write the test like so:
routeTest : Test
routeTest =
describe "when the /things route is accessed"
[ test "it shows the things" <|
\() ->
let
thingsViewSpy = Elmer.Spy.create "things-view" (\() -> ThingsModule.view)
|> Elmer.Spy.andCallFake (\_ ->
Html.div [ Html.Attributes.id "thingsView" ] []
)
in
Elmer.given App.defaultModel App.view App.update
|> Elmer.Navigation.withLocationParser App.locationParser
|> Elmer.Spy.use [ thingsViewSpy ]
|> Elmer.Navigation.setLocation "http://fun.com/things"
|> Elmer.Html.target "#thingsView"
|> Elmer.Html.expect Elmer.Html.Matcher.elementExists
]
For any spy, you can make an expectation about how many times it was called like so:
Elmer.Spy.expect "my-spy" (wasCalled 3)
You can also expect that the spy was called with some list of arguments at least once:
Elmer.Spy.expect "my-spy" (
Elmer.Spy.Matchers.wasCalledWith
[ Elmer.Spy.Matchers.stringArg "someString"
, Elmer.Spy.Matchers.anyArg
, Elmer.Spy.Matcher.intArg 23
]
)
See Elmer.Spy.Matchers
for a full list of argument matchers.
Elmer.Spy.Create
is good for spying on named functions. Sometimes, though, it would be
nice to spy on an anonymous function. Suppose that you are testing a module that takes a function
as an argument, and you want to expect that the function is called with a certain argument.
You can use Elmer.Spy.createWith
to produce a function that is nothing more than a spy, with a
'fake' implementation. Create a reference to this function using Spy.callable
. For example:
let
spy = createWith "my-spy" (tagger ->
Command.fake <| tagger "Success!"
)
in
Elmer.given testModel MyModule.view (MyModule.updateUsing <| Spy.callable "my-spy")
|> Elmer.Spy.use [ spy ]
|> Elmer.Html.target "input"
|> Elmer.Html.Event.input "some text"
|> Elmer.Html.target "button"
|> Elmer.Html.Event.click
|> Elmer.Spy.expect "my-spy" (
Elmer.Spy.Matchers.wasCalledWith
[ Elmer.Spy.Matchers.stringArg "some text"
]
)
Finally, you can use Spy.replaceValue
to replace the value returned by a no-argument function
(such as Time.now
) during a test. You can't make expectations about spies created in this
way; Spy.replaceValue
is just a convenient way to inject fake values during a test.
For development purposes, it's possible to deploy Elmer to a local project.
Use something like elm-github-install for the test dependencies of the project using Elmer. You can set elm-github-install to install Elmer from a directory in the local filesystem.
To run the tests:
$ elm test