Organization Patterns for Writing Better Web Acceptance Tests

Out of all the tests I write on a day-to-day basis, acceptance tests are the hardest. In my experience, writing them for web applications is complex. There are many asynchronous actions going on: web requests firing off, responses being processed, UI elements popping on and off the screen, etc.

To manage this complexity, I’ve been closely following a strict organization pattern. I place the different responsibilities in four main areas: the test, the steps, the pages, and the helpers. By making each area take responsibility for certain classes of problems, I’ve made my acceptance test suites easier to write and maintain.

In this post, I’ll outline how I think about setting up an acceptance test suite. This methodology is tech stack-agnostic, but my examples will demonstrate the methodology in practice using JavaScript, Mocha, and WebdriverIO.

1. The Test

The goal of the test itself is to lay out high-level instructions for using the screen. Reading a test should be like reading story requirements. Steps should be written with vocabulary that end users will understand.


import { given, when, then } from './import-order-test-steps';

it('Can Import a Valid Order Spreadsheet', () => {
  given.a_user_is_logged_in();
  given.some_basic_data_is_loaded_into_the_system();
  given.the_user_is_on_the_new_order_page();

  when.the_user_uploads_a_valid_spreadsheet();
  then.see_the_details_for_the_order_in_the_table();

  when.the_user_refreshes_the_brower();
  then.see_the_details_for_the_order_in_the_table();

  when.the_user_uploads_another_spreadsheet_with_updated_data();
  then.see_the_updated_details_for_the_order_in_the_table();
});

When I first started writing these tests, I would write steps that would describe individual clicks on the screen that would complete the given tasks. While this seemed intuitive at first, I realized later that these tests do not do a great job of explaining which workflow is being tested.

Writing steps with higher-level descriptions provides a few benefits. First, steps with higher-level descriptions of actions document the intent of a feature. If a developer needs to update an existing feature, they read through the existing test to get an overview of what a particular screen does at the moment. It’s much more helpful to know that you can, for instance, upload_a_spreadsheet rather than click_the_import_button, click_the_import_button_on_modal, select_a_file_to_import, and wait_for_modal_to_close.

Secondly, higher-level descriptions are more concise. When I’ve written tests that describe each individual click of a screen, I’ve found myself spending too much time scrolling through a test file to see where my new feature fits in.

2. The Steps

Steps describe in detail what the user needs to do to complete an action. Each step describes what clicks to make on the page and what the user should see as a result:


export const given {
  a_user_is_logged_in() {
    // insert a user into the database...
   LoginPage.loginUser('user1', 'super-secert-password');
  },
  some_basic_data_is_loaded_into_the_system() {
    // insert some data into the database...
  },
  the_user_is_on_the_new_order_page() {
    OrderPage.open();
    OrderPage.clickNewOrderButton();
  }
}

export const when {
  the_user_uploads_a_valid_spreadsheet() {
    OrderPage.clickImportOrderButton();
    OrderPage.clickOnSelectFileButton();
    OrderPage.selectFile('valid-order.xlsx');
    OrderPage.clickImportButtonOnModal();
  },
  the_user_refreshes_the_brower() {
    browser.refresh();
  },
  the_user_uploads_another_spreadsheet_with_updated_data() {
    OrderPage.clickImportOrderButton();
    OrderPage.clickOnSelectFileButton();
    OrderPage.selectFile('updated-valid-order.xlsx');
    OrderPage.clickImportButtonOnModal();
  },
}

export const then {
  see_the_details_for_the_order_in_the_table() {
    const rows = OrderPage.getTableRows();
    expect(rows).to.have.lengthOf(4);
    // assert the contents of the rows...
  },
  see_the_updated_details_for_the_order_in_the_table() {
    const rows = OrderPage.getTableRows();
    expect(rows).to.have.lengthOf(3);
    // assert the contents of the rows...
  },
}

The steps are broken into three main categories: Given, When, and Then.

Given steps describe the current state of the application before a user interacts with the screen. I typically use these steps to populate the database with useful information for this test.

When steps describe the user actions. No assertions are made in these steps. They just describe any clicks or typing performed by the user.

Then steps describe what the user sees. I’ll put my test assertions in these steps, limiting the amount of UI interaction as much as possible. These steps will try to make most of assertions based on what can be found on the screen, but they may occasionally query the database to assert that the application is in the correct state.

I implement these steps as three separate objects that are exported as a module. This creates a clear interface for developers writing new tests for a given test suite.

There are a few more things to note with this design:

  • Steps do not share state. For example, if I create a user in a Given step, but need to reference that user in a When step, I don’t store the database ID of the user in a variable to use as a shared resource.
  • Steps can’t call other steps. Each step can be implemented independently, and any functionality that should be shared is pushed down to the page object, which I will describe in the next section.
  • Steps are created for a particular test suite. I avoid making generic test steps that could be used across multiple features. This allows tests to be modified independently without worrying about side effects for the rest of the test suites.

3. The Pages

Pages describe the implementation details of a given screen. A page is typically a class that keeps track of selectors on the page and defines helpers that will describe the detailed actions a user could take (i.e. clicks, form inputs, etc.).


import * as TestUtils from './test-utils';

class LoginPage {
  private loginPageSelector = '[data-testid="login-page"]';
  private usernameInputSelector = '[data-testid="username"]';
  private passwordInputSelector = '[data-testid="password"]';
  private loginButtonSelector = '[data-testid="login-button"]';

  loginUser(username, password) {
    TestUtils.fillInInput({
      selector: usernameInputSelector,
      value: username,
      parentSelector: loginPageSelector,
    });
    TestUtils.fillInInput({
      selector: passwordInputSelector,
      value: password,
      parentSelector: loginPageSelector,
    });
    TestUtils.clickOnElement({
      selector: loginButtonSelector,
      parentSelector: loginPageSelector,
    });
    TestUtils.waitForElementHidden(loginPageSelector);
  }
}

export default new LoginPage();

These page objects are generic. They help centralize the implementation details of a screen, and they can be used across multiple test suites.

4. The Helpers

Browser tests can be a pain to work with at times. I believe most of the complexity actually comes from the nature of these acceptance tests; these tests have to deal with a lot of asynchronous actions, such as waiting for elements to appear on the screen or loading spinners to go away.

Some helpers I use are waitForElement, waitForElementHidden, and clickOnElement. While these actions sound simple, they hold a lot of complexity. These helpers abstract the fetching of UI elements away from page objects. Fetching UI can be tricky; you may have to poll the browser, waiting for the elements to become visible. You have to make sure that clear error messages are given so developers can quickly identify why a test may be failing.

By offloading these common patterns into helpers, test suites become much easier to write and maintain. Many of these helpers prevent my test suites from becoming flaky due to asynchronicity.

Conclusions

By properly placing the responsibilities into each of the areas, I’ve found myself spending a lot less time writing and maintaining acceptance tests. My tests have become much more readable. I’m spending less time debugging flaky tests caused by asynchronous issues.

Overall, this organization pattern has improved my developer experience in writing acceptance tests. My testing practices have gotten better since following this pattern, and I’m writing better software because of it. What does writing an acceptance test look like for you?