# Writing tests in prescript

# Conceptual model

In prescript, your “test” (scenario) is separated into multiple discrete “steps”. This results in a “test plan” which looks like this:

Test: Sucessful password reset
├── Step 1. Open browser
├── Deferred Step 2. Close browser
├── Step 3. Request password reset
│   ├── Step 3.1. Go to forgot password page
│   ├── Step 3.2. Enter the email
│   └── Step 3.3. Submit the form
├── Step 4. Open the password reset link in email
│   ├── Step 4.1. Check the email
│   ├── Step 4.2. Open the reset password email
│   └── Step 4.3. Click the reset password link in email
├── Step 5. Reset password
│   ├── Step 5.1. Enter the new password
│   └── Step 5.2. Submit the form
├── Step 6. Login with the new credentials
└── Step 7. I should be in the workspace

There are 4 types of steps:

  1. Action steps performs some kind of action. If an action fail, all subsequent steps will be aborted.

  2. Deferred steps queues an action to be run at the end of the test. It will be run when the test is completed or terminated due to an error in one of the action steps.

  3. Composite steps allows you to group steps together, creating a hierarchy of steps.

  4. Pending steps marks the test as pending. Its behavior is equivalent to a failed action step (that is, it aborts the tests and future steps are not run). But instead of the test being marked as “FAILED” (exit code 1), it will be marked as “PENDING” (exit code 2). This is useful when your test is not complete, or when (temporarily) disabling a test.

# Programming model

We write our test in JavaScript. The above test can be represented in prescript as JavaScript code like this:

const { test, to, action, defer } = require('prescript')
test('Sucessful password reset', () => {
  action('Open browser', async state => { ... })
  defer('Close browser', async state => { ... })
  to('Request password reset', () => {
    action('Go to forgot password page', async state => { ... })
    action('Enter the email', async state => { ... })
    action('Submit the form', async state => { ... })
  })
  to('Open the password reset link in email', () => {
    action('Check the email', async state => { ... })
    action('Open the reset password email', async state => { ... })
    action('Click the reset password link in email', async state => { ... })
  })
  to('Reset password', () => {
    action('Enter the new password', async state => { ... })
    action('Submit the form', async state => { ... })
  })
  action('Login with the new credentials', () => { ... })
  action('I should be in the workspace', () => { ... })
})

Since the test file is a JavaScript file, you can also generate actions indirectly (see the Page Object section down below for an example).

# Execution phases

When you run prescript, there are 2 phases that your code gets executed:

  • Prescripting phase. In this phase, your test code is first executed to determine what tests are available, including the steps in each test. This results in a test plan being generated. Code outside the action() and defer() blocks are executed in this phase.

    WARNING

    All the logic that’s executed during the prescripting phase must be deterministic to allow the test code to be safely hot-reloaded.

  • Running phase. In this phase, prescript executes the actions according to the test plan generated from the prescripting phase.

# A basic test

Use test() to define a test. Each test must have a unique name.

Use action() to create an action step. You should pass a function that either returns a Promise (async action) or returns nothing (sync action).

// Basic addition.js
const { test, to, action } = require('prescript')
const assert = require('assert')
const Calculator = require('../lib/Calculator')

test('Basic addition', () => {
  action('Initialize the calculator', state => {
    state.calculator = new Calculator()
  })
  action('Enter 50 into the calculator', state => {
    state.calculator.enter(50)
  })
  action('Enter 70 into the calculator', state => {
    state.calculator.enter(70)
  })
  action('Press add', state => {
    state.calculator.add()
  })
  action('Stored result must be 120', state => {
    assert.equal(state.calculator.result, 120)
  })
})

Multiple actions may be grouped using to(). This creates a composite step.

// Basic addition.js
test('Basic addition', () => {
  action('Initialize the calculator', state => {
    state.calculator = new Calculator()
  })
  to('Calculate 50 + 70', () => {
    action('Enter 50 into the calculator', state => {
      state.calculator.enter(50)
    })
    action('Enter 70 into the calculator', state => {
      state.calculator.enter(70)
    })
    action('Press add', state => {
      state.calculator.add()
    })
  })
  action('Stored result must be 120', state => {
    assert.equal(state.calculator.result, 120)
  })
})

# Use page object pattern for more fluent and maintainable tests

Upgrading to this pattern is very beneficial when there are many test cases that reuses the same logic.

For more, I highly recommend reading Selenium: 7 Things You Need To Know, even for people who don’t use Selenium. The article contains a lot of great tips for anyone who writes end-to-end tests. And these tips applies even if you’re using something else (e.g. Puppeteer, Appium, etc) to test your app.

In this pattern, instead of creating steps directly in our test code, we write a library that creates the test steps for us.

Here’s the previous test case, using the page object pattern.

// Basic addition.js
const { test } = require('prescript')
const CalculatorTester = require('../test-lib/CalculatorTester')

test('Basic addition', () => {
  new CalculatorTester().add(50, 70).resultMustBe(120)
})

Now our test is much shorter. All of our logic related to controlling the calculator is now centralized in CalculatorTester. This means CalculatorTester can be used from many tests, leading to a drier code.

// CalculatorTester.js
const { to, action } = require('prescript')
const Calculator = require('../lib/Calculator')
const assert = require('assert')

module.exports = class CalculatorTester {
  constructor() {
    action('Initialize the calculator', state => {
      state.calculator = new Calculator()
    })
  }

  /**
   * Creates a step that makes the calculator add `a` and `b` together.
   * @param {number} a
   * @param {number} b
   */
  add(a, b) {
    to`Calculate ${a} + ${b}`(() => {
      this.enter(a)
        .enter(b)
        .pressAdd()
    })
    return this
  }

  /**
   * Creates a step that asserts the state of the calculator.
   * @param {number} n
   */
  resultMustBe(n) {
    action`Stored result must be ${n}`(state => {
      assert.equal(state.calculator.result, n)
    })
    return this
  }

  /**
   * Creates a step that enters a number into the calculator.
   * @param {number} number
   */
  enter(number) {
    action`Enter ${number} into the calculator`(state => {
      state.calculator.enter(number)
    })
    return this
  }

  /**
   * Creates a step that presses the add button on the calculator.
   * @param {number} number
   */
  pressAdd() {
    action('Press add', state => {
      state.calculator.add()
    })
    return this
  }
}

WARNING

Due to the way it’s written, our CalculatorTester currently hardcoded to work with state.calculator. That means it’s capable of working with only a single calculator, no matter how many CalculatorTester instances you create. This works fine if you are only testing 1 Calculator instance at the same time.

However sometimes, you might have to test 2 things running simultaneously. For us at Taskworld, we build a collaborative realtime app. So, sometimes we need to run multiple instances of the browser at the same time, to verify that updates from A gets sent to browser B in real-time.

For this, you can adjust your page object to receive the state key in which it will operate on.

module.exports = class CalculatorTester {
  constructor(stateKey = 'calculator') {
    this._stateKey = stateKey
    action('Initialize the calculator', state => {
      state[this._stateKey] = new Calculator()
    })
  }

THE TAGGED TEMPLATE LITERAL SYNTAX

When creating steps that involves variables, you can use a tagged template literals syntax, as can be seen in the example above:

    action`Stored result must be ${n}`(state => {
      assert.equal(state.calculator.result, n)
    })

When run, the substitutions (${n}) will be color-coded. This is an indication that suggests you not to use the substituted text when search the source code for the step you want.

Screenshot

# Pending steps

Pending steps are quite useful. When a pending step is run, it marks the test as pending. When run in non-interactive mode, prescript will exit with code 2.

# When developing tests

When developing a new test, it’s useful to put pending() in the test, because:

  1. This explicitly marks the test as unfinished.

  2. When running in development mode, this causes the test to pause. Otherwise, the deferred step (‘Close browser’) would be run right away, closing the browser.













 


const puppeteer = require('puppeteer')
const { test, action, defer, pending } = require('prescript')
const assert = require('assert')

test('A quest for "prescript" on npm', () => {
  action('Open a web browser', async state => {
    state.browser = await puppeteer.launch({ headless: false })
    state.page = await state.browser.newPage()
  })
  defer('Close browser', async state => {
    state.browser.close()
  })
  pending()
})

# Temporarily disabling a test

If some tests are getting in the way and you need to ship your stuff, you can promise to “fix it later” by calling pending() if a certain time isn’t reached.

// I will fix it later, I promise!
if (Date.now() < Date.parse('2018-10-01T10:00:00Z')) pending()