A reusable layer for Slack’s UI tests
Slack’s Quality Engineering team owns the frameworks and testing practices that let engineers across the company write reliable end-to-end (E2E) tests. The approach wasn’t always so structured. The first E2E UI automation framework came out of an internal HackDay project built on Cypress. It gained traction quickly, but without guardrails the test suite accumulated duplicate code and flaky tests, which led to random failures and time-consuming triage.
To fix that, the team built a variation of the Page Object Model (POM): a UI abstraction layer that sits between the interface and the test logic. The effort was time-boxed to a month, starting with a proof of concept on a small set of tests.
Components instead of pages
Because Slack is a client application more than a traditional website, the team modeled UI in terms of components (the channel sidebar, the message input) rather than pages. The abstraction layer centralizes how tests interact with these UI pieces and exposes chainable JavaScript objects so tests read cleanly.

The structure has three tiers:
WebElement: A representation of a single piece of UI, such as a button or text box, along with the actions you can take on it.BaseComponent: The base class for any component, which is composed of web elements and other components.BaseModal: The base class for modal dialogs.

Here’s how the Slack client breaks down into components:

Consider MessageInputComponent, which extends BaseComponent and inherits its common methods and properties. You supply the component’s root selector and define the child selectors that live within it:
/**
* MessageInputComponent
* This is the component where people type to add a message and communicate.
*/
export class MessageInputComponent extends BaseComponent {
constructor(parentSelector) {
super('[data-qa="message_input"]', parentSelector);
this.textBox = this.getChildElement('[data-qa="input_box"]');
}
/**
* @description Type a message into the message input component
* @param {string} text - text you want to type in
* @param {string} submit - if true will type enter after. if false will just type in text
* @returns {this} returns the MessageInputComponent component
*/
typeMessage(text, submit = false) {
this.textBox.type(text);
if (submit) {
this.textBox.type('{enter}');
}
return this;
}
}
The framework exposes a top-level client object. Typing a message in a test reads as:
client.messageInput.typeMessage(‘hello world’, true);
Best practices for the abstraction layer
- Select with purpose: Use a dedicated
data-qaattribute rather than product class names or IDs, so selectors are context-aware and survive JS/CSS changes. - Build components only when needed: Define UI actions that tests actually use, not an exhaustive catalog of every possibility.
- Keep components scoped: A method in a component should only interact with that component’s UI — the channel sidebar shouldn’t write to the message input.
- Favor meaningful boundaries: Break things into components only where it makes sense, not into many tiny pieces.
- Keep the layer stateless: Tests should own and validate the state; the UI abstraction shouldn’t track it.
Impact and next steps
Comparing pre-framework results against tests that have been migrated, the team saw a 60% reduction in flakiness. Both automation and frontend engineers found it faster to add and maintain tests. The team is now migrating all E2E tests to the abstraction framework and continuing work to further reduce flakiness.



