THE FRONTLINESMS ACTIVITY TESTS FRAMEWORK

How to write an Activity test

This is an example-driven guide to writing activity tests. The documentation is based on tests we wrote for a few example activities. See CORE-3935 for details.

This is also a followup to a more technical guide we wrote explaining the set-up of the activity tests framework and prerequisites for setting this up locally and in production.

This guide has 3 parts, each with examples:

  1. Initializing an activity test class.
  2. Creating the activity to test.
  3. Defining the steps and assertions.

NOTE: When reviewing the code-blocks, make sure you scroll all the way to the right to ensure you see the numbers referenced in the comments below the code-blocks.

 

Part 1: Initializing an Activity test class

We want to tell the activity test class which activity we're testing by defining the path to the exact cookbook in the activities-and-templates repo. Below is an example of how to do this for the 3q-survey.

package frontlinesms2.template.surveys.withAnswerValidation //(1)

import frontlinesms2.RepoPath
import frontlinesms2.online.SimpleWorkspace
/* other imports */

@RepoPath('templates/surveys/with-answer-validation/3q_survey.ckbk') //(2)
class ThreeQuestionSurveyASpec extends frontlinesms2.ActivitySpec {
    SimpleWorkspace workspace 

    def setup() {
        workspace = createWorkspace() //(3)
        multiTenantService.currentTenant.set(workspace.id as int)
    }
  
  def 'your first test'() {
    //(4)
  }
}

Below is an explanation of the numbered parts of the code with comments to explain the flow of the code

(1): This is the name of the package meaning the folder within which this class is situated. In the case above the ThreeQuestionSurveyASpec is situated in the withAnswerValidation as shown in the screenshot below. This structure is identical to the structure of how the cookbooks are stored in the activities-and-templates repo

Activity-Test-Folder-Structure.png

(2): This is the relative path to where the cookbooks are stored in the activities-and-templates repo from the root folder. This is needed so that It can be uploaded the workspace to be processed.

(3): This part sets up the workspace that will be used to create the activities and run tests

(4): This is the first test in your test class

Part 2: Creating the activity to test

Example 1: Three Question Survey (ThreeQuestionSurveyASpec.groovy)

def 'the survey can be filled and completed successfully'() {
        given:
            create '3 Question Survey' with { //(1)
                name 'Employment Poll'
                'start-keyword' 'start'
                'question-1' 'What is your gender?'
                'validAnswers-question-1' 'Male, Female'
                'question-2' 'Are you employed or self-employed?'
                'validAnswers-question-2' 'Employed, Self-Employed'
                'question-3' 'Are you a remote worker?'
                'validAnswers-question-3' 'Yes, No'
                'thank_you_text' 'Great, thanks for your input'
            }
        when:
        /* Conditions */ 
        then:
        /* Assertions (test the conditions) */ 
 }

Part of userData in (templates/surveys/with-answer-validation/3q_survey.ckbk)

"userData": [
        {
          "key": "start-keyword",
          "isRequired": true/false,
          "placeholderText": "some value",
          "stringValue": "StringUserDefinedProperty",
          "type": "some type",
          "userFriendlyName": "some value",
          "description": "some value",
          "userConfigState": "some config"
        },
        {
          "key": "question-1",
          "isRequired": true/false,
          "placeholderText": "some value",
          "stringValue": "StringUserDefinedProperty",
          "type": "some type",
          "userFriendlyName": "some value",
          "description": "some value",
          "userConfigState": "some config"
        },
        {
          "key": "validAnswers-question-1",
          "availableOptions": "possible options",
          "type": "EnumUserDefinedProperty",
          "userFriendlyName": "some value",
          "description": "some value",
          "userConfigState": "some config",
          "chosenOption": ""
        },
        ... //Repeats till Question 3
  }
]

Below is an explanation of the numbered parts of the code with comments to explain the flow of the code

(1): This block of code represent the activity creation as normally seen in the create/edit activity side bar in the activities page of the app. This example tests whether a user can successfully complete a 3 question survey. Below its components are outlined:

  • create '3 Question Survey' tells the framework to create an activity of type ‘3 Question Survey’
  • name is the name you decide to call your activity which in this case is ‘Employment Poll’
  • 'start-keyword' is the userData field representing the keyword a user will need to send to participate in the survey
  • 'question-1' is the userData field representing the 1st question to the survey
  • 'validAnswers-question-1' is the userData field representing the replies the user is permitted to choose to answer ‘question-1’
  • 'question-n' where n is 2, 3, … is the userData field representing the a question in the survey
  • 'validAnswers-question-n' where n is 2, 3, … is the userData field representing the reply the user is permitted to choose for a given question
  • 'thank_you_text' is the userData field that will be sent to the user upon completion of the survey

Example 2: Contact Data Updates Via SMS (ContactDataUpdatesViaSmsASpec.groovy)

def "can change a users contact details"() {
        given:
            def userId = createContact()
            create 'Contact data updates via SMS' with { //(1)
                name 'Contact record update'
                'update-field' 'name'
            }
            def userDataUpdateField = EnumUserDefinedProperty.findByKey('update-field')
        when:
        /* Conditions */ 
        then:
        /* Assertions (test the conditions) */
    }

Snippet of useData in (templates/contact-updates/contact_data_updates_via_sms.ckbk)

"userData": [
        {
          "key": "update-field",
          "placeholderText": "",
          "availableOptions": "${def contactFields = ['name', 'notes', 'email', 'addToNotes'];contactFields.addAll(customFields*.name);contactFields.join(\"\\n\");}",
          "type": "EnumUserDefinedProperty",
          "userFriendlyName": "Select the field to manage with this Activity instance",
          "description": "The selection you make here becomes the exact wording of the `keyword` that your contacts will need to enter as the first word of their SMS. Remember that if you want to allow the update of more than one contact field in this list you'll just need to make one than one instance of this Activity.",
          "userConfigState": "BASIC_CONFIG",
          "chosenOption": ""
        }
      ]

Below is an explanation of the numbered parts of the code with comments to explain the flow of the code

(1): This block of code represent the activity creation as normally seen in the create/edit activity side bar in the activities page of the app. This example tests whether a user can successfully update their contacts details. Below its components are outlined:

  • create 'Contact data updates via SMS' tells the framework to create an activity of type ‘Contact data updates via SMS’
  • name is the name you wish to call your activity which in this case is ‘Contact record update’
  • update-field is the userData field representing the basic contact or custom field to be updated. In this case the default option from a list of available options is picked as defined in the userData i.e name, email, notes age, dob, savings

Example 3: Twine Volunteer Data (TwineVolunteerDataASpec.groovy)

def "can save Twine's volunteer data" () {
    given:
        def apiKeyInstance = FrontlineApiKey.createInstance('twine-test').save(failOnError: true, flush: true)
        create 'Volunteer Data' with { //(1)
            name 'View your volunteer data'
        }
    when:
        /* Conditions */ 
    then:
        /* Assertions (test the conditions) */

Below is an explanation of the numbered parts of the code with comments to explain the flow of the code

(1): This block of code represent the activity creation as normally seen in the create/edit activity side bar in the activities page of the app. This example tests whether Twine’s volunteer data can be successfully saved in a users workspace. Below its component(s) are outlined:

  • create 'Volunteer Data' tells the framework to create an activity of type ‘Volunteer Data’
  • name is the name you wish to call your activity which in this case is ‘View your volunteer data’

Part 3: Defining the Steps and Assertions

Example 1: Three Question Survey (ThreeQuestionSurveyASpec.groovy)

def 'the survey can be filled and completed successfully'() {
    given:
        /* Activity setup. See example 1 in Part 2*/
    when:
        processTrigger new TextMessage(inbound: true,
                src: '+12345', text: 'start')
                .save(failOnError: true) //(1)
    then:
        getLastMessageSentTo('+12345').contains('What is your gender?') //(2)
    when:
        processTrigger new TextMessage(inbound: true,
                src: '+12345', text: 'Female')
                .save(failOnError: true) //(3)
    then:
        getLastMessageSentTo('+12345').contains('Are you employed or self-employed?') //(4)
    when:
        processTrigger new TextMessage(inbound: true,
                src: '+12345', text: 'Self-Employed')
                .save(failOnError: true) //(5)
    then:
        getLastMessageSentTo('+12345').contains('Are you a remote worker?') //(6)
    when:
        processTrigger new TextMessage(inbound: true,
                src: '+12345', text: 'Yes')
                .save(failOnError: true) //(7)
    then:
        getLastMessageSentTo('+12345').contains('Great, thanks for your input') //(8)

}

def 'the fire trigger can be used to manually send out the question to specified recipients'() {
        given:
            /* Activity setup. See Example 1 in Part 2*/
        when:
            fire('Employment Poll', 'Invite respondents') with {
                'invite-intro-sms' 'Please take part in our Employment Survey'
                'survey-invitees' '+12345'
            } //(9)
        then:
            getLastMessageSentTo('+12345') == 'Please take part in our Employment Survey' //(10)
    }

def 'survey responses are validated'() {
  given:
    /* Activity setup. See example 1 in Part 2*/
  when:
    fire('Employment Poll', 'Invite respondents') with {
      'invite-intro-sms' 'Please take part in our Employment Survey'
      'survey-invitees' '+12345'
    } //(11)
  then:
    getLastMessageSentTo('+12345').contains('Please take part in our Employment Survey') //(12)
  when:
    processTrigger new TextMessage(inbound: true,
                                   src: '+12345', text: 'M')
  .save(failOnError: true) //(13)
  then:
    getLastMessageSentTo('+12345').contains('Sorry but your response was not valid. Please reply with one of  [MALE, FEMALE]') //(14)
}

(1): processTrigger is a method that accepts a trigger. This step triggers the activity through an sms with the text ‘start’. This will in turn start the survey

(2): This will assert whether the last message sent to the trigger number is the 1st question of the survey

(3): This step triggers the activity through an sms with the text ‘Female’ which is one of the available option that can be used to answer the question. This will in turn answer the 1st question

(4): This will assert whether the last message sent to the trigger number is the 2nd question of the survey

(5): This step triggers the activity through an sms with the text ‘Self-Employed’ which is one of the available option that can be used to answer the question. This will in turn answer the 2nd question

(6): This will assert whether the last message sent to the trigger number is the 3rd question of the survey

(7): This step triggers the activity through an sms with the text ‘Yes’ which is one of the available option that can be used to answer the question. This will in turn answer the 3rd question

(8): This will assert whether the last message sent to the trigger number is the thank you text

(9): In this example we trigger the same activity but with a fire trigger instead. the first parameter in the method fire(params..) where 'Employment Poll' is the name of the activity while the second parameter 'Invite respondents' is the name of the fire trigger button. 'invite-intro-sms' is the text the activity will send to the users to invite them to participate in the survey while 'survey-invitees' is the list of contacts that will receive the invitation.

(10): This will assert whether the last message sent to the trigger number is the text used to invite the respondents

(11): Refer to num 9 above

(12): Refer to num 10 above

(13): This step triggers the activity through an sms with the text 'M'as an example instead of 'Male' as defined in the userData which is an invalid answer to answer the 1st question ‘What is your gender?’

(14): This will assert whether the validation will work for the survey question 1 and if it fails, it will return an error prompting the user to choose a valid response.

Example 2: Contact Data Updates Via SMS (ContactDataUpdatesViaSmsASpec.groovy)

def "can change a users contact details"() {
        given:
            /* Activity setup. See Example 2 in Part 2*/
            when:
          processTrigger new TextMessage(inbound: true,
                  src: '+12345', text: 'name Mercy Wu')
                  .save(failOnError: true) //(1)
        then:
            Contact.withNewSession {
                Contact.withNewTransaction {
                    Contact.get(userId).name
                }
            } == 'Mercy Wu' //(2)
        when:
            setContactField(userDataUpdateField, 'email')
            processTrigger new TextMessage(inbound: true,
                    src: '+12345', text: 'email mercy.wu@example.com')
                    .save(failOnError: true) //(3)
        then:
            Contact.withNewSession {
                Contact.withNewTransaction {
                    Contact.get(userId).email
                }
            } == 'mercy.wu@example.com' //(4)
        when:
            setContactField(userDataUpdateField, 'notes')
            processTrigger new TextMessage(inbound: true,
                    src: '+12345', text: 'notes I do me!')
                    .save(failOnError: true) //(5)
        then:
            Contact.withNewSession {
                Contact.withNewTransaction {
                    Contact.get(userId).notes
                }
            } == 'I do me!' //(6)
        when:
            setContactField(userDataUpdateField, 'age')
            processTrigger new TextMessage(inbound: true,
                    src: '+12345', text: 'age 24')
                    .save(failOnError: true) //(7)
        then:
            CustomField.withNewSession {
                CustomField.withNewTransaction {
                    StringCustomField.findByContact(Contact.get(userId)).stringValue
                }
            } == '24' //(8)
        when:
            setContactField(userDataUpdateField, 'dob')
            processTrigger new TextMessage(inbound: true,
                    src: '+12345', text: 'dob 30-04-2019 12:30')
                    .save(failOnError: true, flush: true) //(9)
        then:
            CustomField.withNewSession {
                CustomField.withNewTransaction {
                    DateCustomField.findByContact(Contact.get(userId)).dateValue
                }
            } == timezoneService.convertForStorage(Date.parse(timezoneService.DATEPICKER_FORMAT, '30-04-2019 12:30')) //(10)
        when:
            setContactField(userDataUpdateField, 'savings')
            processTrigger new TextMessage(inbound: true,
                    src: '+12345', text: 'savings 500')
                    .save(failOnError: true) //(11)
        then:
            CustomField.withNewSession {
                CustomField.withNewTransaction {
                    CurrencyCustomField.findByContact(Contact.get(userId)).currencyValue
                }
            } == 700 //(12)
}

private def setContactField (userDataUpdateField, option) {
    userDataUpdateField.chosenOption = option
    userDataUpdateField.save(flush: true, failOnError: true)
}

Below is an explanation of the numbered parts of the code with comments to explain the flow of the code

(1): This step will send a text which will trigger the activity to change the name contact field of the user to the desired one

(2): This Asserts whether the name contact field was updated

(3): This step will send a text which will trigger the activity to change the email contact field of the user to the desired one

(4): This Asserts whether the email contact field was updated

(5): This step will send a text which will trigger the activity to change the notes contact field of the user to the desired one

(6): This Asserts whether the notes contact field was updated

(7): This step will send a text which will trigger the activity to change the string contact custom field age of the user to the desired one

(8): This Asserts whether the contact’s string custom field age was updated

(9): This step will send a text which will trigger the activity to change the date contact custom field dob of the user to the desired one

(10): This Asserts whether the contact’s date custom field dob was updated

(11): This step will send a text which will trigger the activity to change the savings contact custom field of the user to the desired one

(12): This Asserts whether the contact’s currency custom field savings was updated

Example 3: Twine Volunteer Data (TwineVolunteerDataASpec.groovy)

def "can save Twine's volunteer data" () {
        given:
            /* Activity setup. See Example 3 in Part 2*/
        when:
            processTrigger new InboundAPIRequest(receivedOnAPIKeyId: apiKeyInstance.id, jsonPayload: getJsonPayload(apiKeyInstance),
                    src: '127.0.0.1').save(failOnError: true) //(1)
        then:
            waitFor{
                RecipeCollectionStatistic.all.size() == 46
            } //(2)
            NumericRecipeCollectionStatistic.findByKey('total_interactions_tagged').value == 1 //(3)
            TextRecipeCollectionStatistic.findAllByGroupIdentifier("email")*.key.containsAll(['1', '2', '3'])
            TextRecipeCollectionStatistic.findAllByGroupIdentifier("email")*.value.containsAll(['person1@example.com', 'person2@example.com', 'person3@example.com']) //(4)

         
 TextRecipeCollectionStatistic.findAllByGroupIdentifier('gender')*.key.containsAll(['1', '2', '3'])
            TextRecipeCollectionStatistic.findAllByGroupIdentifier('gender')*.value.containsAll(['Male', 'Male', 'Female']) //(5)

            TextRecipeCollectionStatistic.findAllByGroupIdentifier('mostRecentVisit')*.key.containsAll(['1', '2', '3'])
            TextRecipeCollectionStatistic.findAllByGroupIdentifier('mostRecentVisit')*.value.containsAll(['2019-04-25', '2019-04-26', '2019-04-27']) //(6)

            TextRecipeCollectionStatistic.findAllByGroupIdentifier('name')*.key.containsAll(['1', '2', '3'])
            TextRecipeCollectionStatistic.findAllByGroupIdentifier('name')*.value.containsAll(['Person 1', 'Person 2', 'Person 3']) //(7)

            TextRecipeCollectionStatistic.findAllByGroupIdentifier('phone')*.key.containsAll(['1', '2', '3'])
            TextRecipeCollectionStatistic.findAllByGroupIdentifier('phone')*.value.containsAll(['07987654321', '07987654322', '07987654323']) //(8)

            TextRecipeCollectionStatistic.countByGroupIdentifier('region') == 3
            TextRecipeCollectionStatistic.findAllByGroupIdentifier('region')*.key.containsAll(['1', '2', '3'])
            TextRecipeCollectionStatistic.findAllByGroupIdentifier('region')*.value.containsAll(['London']) //(9)

            TextRecipeCollectionStatistic.findAllByGroupIdentifier('totalTimeVolunteered.care')*.key.containsAll(['1', '2', '3'])
            TextRecipeCollectionStatistic.findAllByGroupIdentifier('totalTimeVolunteered.care')*.value.containsAll(['0']) //(10)

            TextRecipeCollectionStatistic.findAllByGroupIdentifier('totalTimeVolunteered.committee')*.key.containsAll(['1', '2', '3'])
            TextRecipeCollectionStatistic.findAllByGroupIdentifier('totalTimeVolunteered.committee')*.value.containsAll(['0']) //(11)

            TextRecipeCollectionStatistic.findAllByGroupIdentifier('totalTimeVolunteered.comms')*.key.containsAll(['1', '2', '3']) 
            TextRecipeCollectionStatistic.findAllByGroupIdentifier('totalTimeVolunteered.comms')*.value.containsAll(['0']) //(12)

            TextRecipeCollectionStatistic.findAllByGroupIdentifier('totalTimeVolunteered.funds')*.key.containsAll(['1', '2', '3'])
            TextRecipeCollectionStatistic.findAllByGroupIdentifier('totalTimeVolunteered.funds')*.value.containsAll(['0']) //(13)

            TextRecipeCollectionStatistic.findAllByGroupIdentifier('totalTimeVolunteered.office')*.key.containsAll(['1', '2', '3'])
            TextRecipeCollectionStatistic.findAllByGroupIdentifier('totalTimeVolunteered.office')*.value.containsAll(['0']) //(14)

            TextRecipeCollectionStatistic.findAllByGroupIdentifier('totalTimeVolunteered.other')*.key.containsAll(['1', '2', '3'])
            TextRecipeCollectionStatistic.findAllByGroupIdentifier('totalTimeVolunteered.other')*.value.containsAll(['0']) //(15)

            TextRecipeCollectionStatistic.findAllByGroupIdentifier('totalTimeVolunteered.practical')*.key.containsAll(['1', '2', '3'])
            TextRecipeCollectionStatistic.findAllByGroupIdentifier('totalTimeVolunteered.practical')*.value.containsAll(['0']) //(16)

            TextRecipeCollectionStatistic.findAllByGroupIdentifier('totalTimeVolunteered.professional')*.key.containsAll(['1', '2', '3'])
            TextRecipeCollectionStatistic.findAllByGroupIdentifier('totalTimeVolunteered.professional')*.value.containsAll(['0']) //(17)

            TextRecipeCollectionStatistic.findAllByGroupIdentifier('yearOfBirth')*.value.containsAll(['1980-12-08', '1980-12-08', '1980-12-08']) //(18)
    }

    private def getJsonPayload(FrontlineApiKey apiKeyInstance) { //(19)
       return """
                {
                    "360GivingId": "GB-COH-87654321",
                    "volunteers": [
                        {
                            "region": "London",
                            "yearOfBirth": "1980-12-08",
                            "id": 1,
                            "phone": "07987654321",
                            "volunteeredTimeLastMonth": {
                                "Community outreach and communications": 0,
                                "Helping with raising funds (shop, events…)": 0,
                                "Other": 0,
                                "Committee work, AGM": 0,
                                "Professional pro bono work (Legal, IT, Research)": 0,
                                "Outdoor and practical work": 0,
                                "Office support": 0,
                                "Support and Care for vulnerable community members": 0
                            },
                            "email": "person1@example.com",
                            "mostRecentVisit": "2019-04-25",
                            "name": "Person 1",
                            "gender": "Male"
                        },
                        {
                            "region": "London",
                            "yearOfBirth": "1983-10-04",
                            "id": 2,
                            "phone": "07987654322",
                            "volunteeredTimeLastMonth": {
                                "Community outreach and communications": 0,
                                "Helping with raising funds (shop, events…)": 0,
                                "Other": 0,
                                "Committee work, AGM": 0,
                                "Professional pro bono work (Legal, IT, Research)": 0,
                                "Outdoor and practical work": 0,
                                "Office support": 0,
                                "Support and Care for vulnerable community members": 0
                            },
                            "email": "person2@example.com",
                            "mostRecentVisit": "2019-04-26",
                            "name": "Person 2",
                            "gender": "Female"
                        },
                        {
                            "region": "London",
                            "yearOfBirth": "1986-04-12",
                            "id": 3,
                            "phone": "07987654323",
                            "volunteeredTimeLastMonth": {
                                "Community outreach and communications": 0,
                                "Helping with raising funds (shop, events…)": 0,
                                "Other": 0,
                                "Committee work, AGM": 0,
                                "Professional pro bono work (Legal, IT, Research)": 0,
                                "Outdoor and practical work": 0,
                                "Office support": 0,
                                "Support and Care for vulnerable community members": 0
                            },
                            "email": "person3@example.com",
                            "mostRecentVisit": "2019-04-27",
                            "name": "Person 3",
                            "gender": "Female"
                        }
                    ]
                }

            """

    }
}

(1): This step will send an Inbound API request which will trigger the activity that will Process Twine’s volunteer data gotten from the method getJsonPayload(). See (19).  The JSON payload string that is returned in the method getJsonPayload() doesn't require an API key field in the payload as we usually include when sending data using, for example, a HTTP client. The JSON payload here is the value of the payload key of a normal HTTP request to Frontline Cloud.
(2): This asserts that the cookbook has processed by the activity and the recipe collection statistics saved. It does this assertion by verifying that the recipe collection statistics saved equal to the number in the JSON payload

(3): This asserts that the values in collection statistics with groupIdentifier default_statistics matches the ones in volunteer data in the JSON payload
(4): This asserts that the values in collection statistics with groupIdentifier email matches the ones in volunteer data in the JSON payload
(5): This asserts that the values in collection statistics with groupIdentifier gender matches the ones in volunteer data in the JSON payload
(6): This asserts that the values in collection statistics with groupIdentifier mostRecentVisit matches the ones in volunteer data in the JSON payload
(7): This asserts that the values in collection statistics with groupIdentifier name matches the ones in volunteer data in the JSON payload
(8): This asserts that the values in collection statistics with groupIdentifier phone matches the ones in volunteer data in the JSON payload
(9): This asserts that the values in collection statistics with groupIdentifier region matches the ones in volunteer data in the JSON payload
(10): This asserts that the values in collection statistics with groupIdentifier totalTimeVolunteered.carematches the ones in volunteer data in the JSON payload
(11): This asserts that the values in collection statistics with groupIdentifier totalTimeVolunteered.committee matches the ones in volunteer data in the JSON payload
(12): This asserts that the values in collection statistics with groupIdentifier totalTimeVolunteered.comms matches the ones in volunteer data in the JSON payload
(13): This asserts that the values in collection statistics with groupIdentifier totalTimeVolunteered.funds matches the ones in volunteer data in the JSON payload
(14): This asserts that the values in collection statistics with groupIdentifier totalTimeVolunteered.office matches the ones in volunteer data in the JSON payload
(15): This asserts that the values in collection statistics with groupIdentifier totalTimeVolunteered.other matches the ones in volunteer data in the JSON payload
(16): This asserts that the values in collection statistics with groupIdentifier totalTimeVolunteered.practical matches the ones in volunteer data in the JSON payload
(17): This asserts that the values in collection statistics with groupIdentifier totalTimeVolunteered.professional matches the ones in volunteer data in the JSON payload
(18): This asserts that the values in collection statistics with groupIdentifier yearOfBirth matches the ones in volunteer data in the JSON payload

(19): This method returns the JSON cookbook containing the volunteer data. This will used to trigger the activity with an inbound API request

 

We will continue to add more activity-test examples and keep documenting new learnings as we go.

 

Have more questions? Submit a request

0 Comments

Please sign in to leave a comment.