The Bed Nap application dynamically generates 2 pages with links between them; it's about time we started doing some testing before it gets out of hand!

Bounce is a library written specifically for testing BedSheet web applications so it would be a shame not to use it! So the first thing we should do is download afBounce and add it to our build.fan.

fanr install -r http://eggbox.fantomfactory.org/fanr "afBounce 1.1"

Create a Test Directory

It's a Fantom convention that tests are kept in a separate directory to the main code, so we'll keep out test code in a root project directory called test/.

Using F4

Right click on the Bed Nap project and select New -> Fantom Source Folder. Enter the name test and click OK. F4 will automatically update build.fan for you.

F4 Source Folder Dialogue

Without F4

Add test/ to the srcDirs list in build.fan. Make sure the directory has a trailing slash.

srcDirs = [`fan/`, `fan/components/`, `fan/pages/`, `test/`]

Create a Base Test Class

Before we write any actual tests we're going to write an abstract base class. The base class will override setup() and teardown() and do the useful things we need to do in every web test, namely startup and shutdown BedSheet!

select all
using afBounce::BedServer
using afBounce::BedClient

abstract class WebTest : Test {

    BedClient? client

    override Void setup() {
        server := BedServer(AppModule#).startup
        server.inject(this)
        client = server.makeClient
    }

    override Void teardown() {
        client.shutdown
    }
}

You can think of BedServer as a fake BedSheet server. It starts up BedSheet in a similar fashion to normal, only without the overhead of connecting to real network ports. BedClient may then used to make fake HTTP requests to our fake server.

Note the line server.inject(this), that allows all subclasses to define @Inject fields just like an IoC service!

When we run our tests, we want to be in complete control of our Visit data, so we would rather not have the createSampleData function run at BedSheet startup. So lets remove it! If we contribute our own TestModule, we can do just that. Our new WebTest class now looks like:

select all
using afIoc
using afBounce::BedServer
using afBounce::BedClient

abstract class WebTest : Test {

    BedClient? client

    override Void setup() {
        server := BedServer(AppModule#).addModule(TestModule#).startup
        server.injectIntoFields(this)
        client = server.makeClient
    }

    override Void teardown() {
        client.shutdown
    }
}

const class TestModule {
    Void onRegistryStartup(Configuration config) {
        config.remove("bednap.createSampleData")
    }
}

Test View Details are Shown

The strategy for testing web pages involves navigating to the page, using CSS selectors to pinpoint specific HTML elements, and asserting they contain expected values.

To select HTML elements for testing, it's best (for guarding against future layout changes) to give each element in question its own unique ID. Only, as I may reuse an ID, I tend to use class attributes instead. I also give the class a t- prefix so I know they're used for testing (and not for styling).

So let's update the ViewPage HTML so it has some test CSS attributes:

...
html.div("class='t-name'").w("${visit.name} said:").divEnd
html.div("class='t-comment'").w(visit.comment).divEnd
html.div("class='t-date'").w("on ${visit.date}").divEnd
html.div("class='t-rate'").w("${visit.rating} / 5 stars").divEnd
...

This makes our test fairly straight forward - save some Visit data and check it appears on the ViewPage:

select all
using afIoc::Inject
using afBounce::Element

class TestViewPage : WebTest {

    @Inject VisitService? visitService

    Void testDetailsAreShown() {
        // given
        visit := Visit("Butcher", Date(1969, Month.mar, 23), 5, "Sausages", 8)
        visitService.save(visit)

        // when
        client.get(`/view/8`)

        // then
        Element(".t-name"   ).verifyTextEq("Butcher said:")
        Element(".t-comment").verifyTextEq("Sausages")
        Element(".t-date"   ).verifyTextEq("on 1969-03-23")
        Element(".t-rate"   ).verifyTextEq("5 / 5 stars")
    }
}

TIP: To print out the rendered HTML, use this line:

echo(client.lastResponse.asStr)

Test Index Details are Shown

To test the table on the IndexPage we can do the same thing. We can use the visit ID as part of the class attributes to ensure uniqueness. So in IndexPage:

select all
visitService.all.each {
    html.tr
    html.td("class='t-v${it.id}-name'").w(it.name).tdEnd
    html.td("class='t-v${it.id}-date'").w(it.date).tdEnd
    html.td("class='t-v${it.id}-rate'").w(it.rating).tdEnd
    html.td
    html.a(`/view/${it.id}`, "class='t-v${it.id}-view'").w("view").aEnd
    html.tdEnd
    html.trEnd
}

And the test:

select all
using afIoc::Inject
using afBounce::Element

class TestIndexPage : WebTest {

    @Inject VisitService? visitService

    Void testMultipleVisitSummariesAreShown() {
        // given
        visit1 := visitService.save(Visit("User 1", Date(2000, Month.jan, 1), 1, "", 1))
        visit2 := visitService.save(Visit("User 2", Date(2000, Month.jan, 2), 2, "", 2))
        visit3 := visitService.save(Visit("User 3", Date(2000, Month.jan, 3), 3, "", 3))

        // when
        client.get(`/`)

        // then
        Element(".t-v1-name").verifyTextEq("User 1")
        Element(".t-v1-date").verifyTextEq("2000-01-01")
        Element(".t-v1-rate").verifyTextEq("1")

        Element(".t-v2-name").verifyTextEq("User 2")
        Element(".t-v2-date").verifyTextEq("2000-01-02")
        Element(".t-v2-rate").verifyTextEq("2")

        Element(".t-v3-name").verifyTextEq("User 3")
        Element(".t-v3-date").verifyTextEq("2000-01-03")
        Element(".t-v3-rate").verifyTextEq("3")
    }
}

(Note how we converted save() into a builder method so we could inline the Visit assignment.)

While the above test works - it's ugly! Having to assign unique IDs to everything is verbose and awkward, and it doesn't even test ordering on the page, just that the data exists.

Luckily, if a CSS selector occurs more than once in a document, then Bounce lets you access it like an array. So let's try discarding those Visit IDs on IndexPage:

select all
...
visitService.all.each {
    html.tr
    html.td("class='t-name'").w(it.name).tdEnd
    html.td("class='t-date'").w(it.date).tdEnd
    html.td("class='t-rate'").w(it.rating).tdEnd
    html.td
    html.a(`/view/${it.id}`, "class='t-view'").w("view").aEnd
    html.tdEnd
    html.trEnd
}
...

And use the array accessors in the test:

select all
Void testMultipleVisitSummariesAreShown() {
    // given
    visit1 := visitService.save(Visit("User 1", Date(2000, Month.jan, 1), 1, "", 1))
    visit2 := visitService.save(Visit("User 2", Date(2000, Month.jan, 2), 2, "", 2))
    visit3 := visitService.save(Visit("User 3", Date(2000, Month.jan, 3), 3, "", 3))

    // when
    client.get(`/`)

    // then
    Element(".t-name")[0].verifyTextEq("User 1")
    Element(".t-date")[0].verifyTextEq("2000-01-01")
    Element(".t-rate")[0].verifyTextEq("1")

    Element(".t-name")[1].verifyTextEq("User 2")
    Element(".t-date")[1].verifyTextEq("2000-01-02")
    Element(".t-rate")[1].verifyTextEq("2")

    Element(".t-name")[2].verifyTextEq("User 3")
    Element(".t-date")[2].verifyTextEq("2000-01-03")
    Element(".t-rate")[2].verifyTextEq("3")
}

Now our test is also checking the ordering of our data on the page.

Test Link to View Page

We added links to the ViewPage from the IndexPage, lets test that they work.

To do this we render the IndexPage, click a known link, and check that the correct page has been rendered with the correct details:

select all
Void testViewPageLink() {
    // given
    visit := visitService.save(Visit("Emma", Date(1969, Month.mar, 23), 5, "Sausages", 8))

    // when
    client.get(`/`)
    Link(".t-view").click

    // then
    // check we're on the right page
    Element("title").verifyTextEq("Bed Nap View Page")

    // check the right details are displayed
    Element(".t-name").verifyTextContains("Emma")
}

Test All in F4

For every Fantom project I have in F4 I usually also have a Test All run configuration saved as a favourite. This is the one to run before you check in.

Bed Nap Test All

Source Code

All the source code for this tutorial is available on the Bed Nap Tutorial Bitbucket Repository.

Code for this particular article is available on the 05-Testing-With-Bounce branch.

Use the following commands to check the code out locally:

C:\> hg clone https://bitbucket.org/fantomfactory/bed-nap-tutorial
C:\> cd bed-nap-tutorial
C:\> hg update 05-Testing-With-Bounce

Don't forget, you can trial the finished tutorial application at Bed Nap.

Have fun!

Edits

  • 5 Aug 2016 - Updated tutorial to use BedSheet 1.5 & IoC 3.0.
  • 5 Aug 2015 - Updated tutorial to use BedSheet 1.4.
  • 1 Sep 2014 - Original article.


Discuss