/elmer

Describe the behavior of Elm HTML applications

Primary LanguageElmMIT LicenseMIT

Elmer

Elmer makes it easy to describe the behavior of Elm HTML applications.

Why?

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.

Installation

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.

Getting Started

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.

Documentation

The latest documentation can be found here.

Earlier versions of the documentation can be found here.

Writing Tests

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.

Finding an Element

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

Taking action on an element

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.

Element Matchers

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)

Example

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

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.

Faking Effects

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.

Elmer.Http

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.Navigation

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.

Sending Arbitrary Commands

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.

Deferred Command Processing

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.

Dummy Commands

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.

Testing Commands in Isolation

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")

Subscriptions

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:

  1. Override the function that generates the subscription using Elmer.Spy.create along with Elmer.Spy.andCallFake and replace it with a fake subscription using Elmer.Platform.Subscription.fake
  2. Register the subscriptions using Elmer.Platform.Subscription.with
  3. 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.

Ports

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"
    |> ...

Testing Tasks

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:

  1. Stub any task-generating functions to return a task created with Task.succeed or Task.fail and the value you want as necessary for the behavior you want to describe.

  2. 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"
    )

Spies and Fakes

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.

Development

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