Testing a Salesforce App: Apex vs Selenium vs DevAssure O2 — A Side-by-Side Comparison
Short answer
Apex validates server logic fast but cannot see the UI. Selenium covers user flows but breaks on Shadow DOM, MFA, and every release. DevAssure O2 runs plain-English tests in a real browser — same UI coverage as Selenium without locator maintenance. Use Apex + O2, not Apex + brittle scripts.
Every Salesforce team tests. The question is how — and what slips through the cracks depending on which approach you use.
In this post, I'm going to take a real Salesforce application — Dreamhouse, Salesforce's official sample app — and test the same functionality three different ways:
- Apex unit tests (the traditional Salesforce-native approach)
- Selenium WebDriver (the industry-standard UI automation approach)
- DevAssure O2 Agent (autonomous browser-based testing)
For each approach, I'll show you the actual code, explain what it catches, what it misses, and the ongoing maintenance cost. By the end, you'll have a clear picture of where each approach fits — and where it falls short.
The app: Dreamhouse
Dreamhouse is a real estate application built on Salesforce Platform. It's Salesforce's official sample app for demonstrating Lightning Web Components, and it's used across Trailhead, conference demos, and developer documentation.
The core objects:
- Property__c — a real estate listing with fields like
Address__c,City__c,State__c,Price__c,Beds__c,Baths__c,Status__c, and a lookup toBroker__c - Broker__c — a real estate broker with contact information
The key features we'll test:
- Property search — users filter properties by price range, number of bedrooms/bathrooms, and location
- Property detail view — clicking a property shows its full details, photo carousel, and assigned broker
- Property creation — creating a new listing via a Lightning record form
The LWC components involved:
propertyFilter— search panel with input fields for min/max price, beds, bathspropertyTileList— displays matching properties as cardspropertySummary— shows full details of a selected propertybrokerCard— displays the assigned broker's information
Let's test all of this.
Approach 1: Apex unit tests
Apex tests are the foundation of Salesforce testing. They run server-side, validate business logic, and are required for deployment (75% coverage minimum).
The controller we're testing
The PropertyController class serves data to the LWC frontend:
public with sharing class PropertyController {
@AuraEnabled(cacheable=true)
public static PagedResult getPagedPropertyList(
String searchKey,
Decimal minPrice,
Decimal maxPrice,
Integer minBedrooms,
Integer minBathrooms,
Integer pageSize,
Integer pageNumber
) {
Integer offset = (pageNumber - 1) * pageSize;
Integer totalCount = 0;
String query = 'SELECT Id, Name, Address__c, City__c, State__c, ' +
'Price__c, Beds__c, Baths__c, Status__c, Thumbnail__c, ' +
'Broker__c, Broker__r.Name ' +
'FROM Property__c';
String conditions = '';
if (String.isNotBlank(searchKey)) {
String key = '%' + String.escapeSingleQuotes(searchKey) + '%';
conditions += ' AND (Address__c LIKE :key OR City__c LIKE :key)';
}
if (minPrice != null) {
conditions += ' AND Price__c >= :minPrice';
}
if (maxPrice != null) {
conditions += ' AND Price__c <= :maxPrice';
}
if (minBedrooms != null) {
conditions += ' AND Beds__c >= :minBedrooms';
}
if (minBathrooms != null) {
conditions += ' AND Baths__c >= :minBathrooms';
}
if (String.isNotBlank(conditions)) {
query += ' WHERE ' + conditions.removeStart(' AND ');
}
String countQuery = query.replaceFirst(
'SELECT.*FROM',
'SELECT COUNT() FROM'
);
totalCount = Database.countQuery(countQuery);
query += ' ORDER BY Price__c ASC LIMIT :pageSize OFFSET :offset';
PagedResult result = new PagedResult();
result.records = Database.query(query);
result.totalCount = totalCount;
result.pageSize = pageSize;
result.pageNumber = pageNumber;
return result;
}
@AuraEnabled
public static Property__c createProperty(Property__c prop) {
insert prop;
return prop;
}
}
Writing the Apex test
@isTest
private class PropertyControllerTest {
@TestSetup
static void setupTestData() {
Broker__c broker = new Broker__c(
Name = 'Jane Smith',
Email__c = 'jane@dreamhouse.com',
Phone__c = '555-0100'
);
insert broker;
List<Property__c> properties = new List<Property__c>();
properties.add(new Property__c(
Name = 'Victorian Mansion',
Address__c = '123 Oak Street',
City__c = 'San Francisco',
State__c = 'CA',
Price__c = 1250000,
Beds__c = 4,
Baths__c = 3,
Status__c = 'Available',
Broker__c = broker.Id
));
properties.add(new Property__c(
Name = 'Modern Condo',
Address__c = '456 Market Street',
City__c = 'San Francisco',
State__c = 'CA',
Price__c = 650000,
Beds__c = 2,
Baths__c = 1,
Status__c = 'Available',
Broker__c = broker.Id
));
properties.add(new Property__c(
Name = 'Beach House',
Address__c = '789 Coast Hwy',
City__c = 'Malibu',
State__c = 'CA',
Price__c = 3500000,
Beds__c = 5,
Baths__c = 4,
Status__c = 'Sold',
Broker__c = broker.Id
));
insert properties;
}
@isTest
static void testGetPagedPropertyList_NoFilters() {
PagedResult result = PropertyController.getPagedPropertyList(
null, null, null, null, null, 10, 1
);
System.assertEquals(3, result.totalCount,
'Should return all 3 properties when no filters applied');
System.assertEquals(3, result.records.size());
}
@isTest
static void testGetPagedPropertyList_PriceFilter() {
PagedResult result = PropertyController.getPagedPropertyList(
null, 500000, 1500000, null, null, 10, 1
);
System.assertEquals(2, result.totalCount,
'Should return 2 properties within $500K-$1.5M range');
for (Property__c prop : (List<Property__c>) result.records) {
System.assert(prop.Price__c >= 500000,
'Price should be >= 500000');
System.assert(prop.Price__c <= 1500000,
'Price should be <= 1500000');
}
}
@isTest
static void testGetPagedPropertyList_BedroomFilter() {
PagedResult result = PropertyController.getPagedPropertyList(
null, null, null, 4, null, 10, 1
);
System.assertEquals(2, result.totalCount,
'Should return 2 properties with 4+ bedrooms');
}
@isTest
static void testGetPagedPropertyList_SearchKey() {
PagedResult result = PropertyController.getPagedPropertyList(
'Malibu', null, null, null, null, 10, 1
);
System.assertEquals(1, result.totalCount,
'Should return 1 property matching Malibu');
System.assertEquals('Beach House',
((Property__c) result.records[0]).Name);
}
@isTest
static void testGetPagedPropertyList_CombinedFilters() {
PagedResult result = PropertyController.getPagedPropertyList(
'San Francisco', 600000, 2000000, 2, 1, 10, 1
);
System.assertEquals(2, result.totalCount,
'Should return 2 SF properties in price range with 2+ beds');
}
@isTest
static void testGetPagedPropertyList_Pagination() {
PagedResult page1 = PropertyController.getPagedPropertyList(
null, null, null, null, null, 2, 1
);
PagedResult page2 = PropertyController.getPagedPropertyList(
null, null, null, null, null, 2, 2
);
System.assertEquals(2, page1.records.size(),
'Page 1 should have 2 records');
System.assertEquals(1, page2.records.size(),
'Page 2 should have 1 record');
System.assertEquals(3, page1.totalCount,
'Total count should be 3');
}
@isTest
static void testGetPagedPropertyList_NoResults() {
PagedResult result = PropertyController.getPagedPropertyList(
'Nonexistent City', null, null, null, null, 10, 1
);
System.assertEquals(0, result.totalCount,
'Should return 0 for non-matching search');
System.assertEquals(0, result.records.size());
}
@isTest
static void testCreateProperty() {
Broker__c broker = [SELECT Id FROM Broker__c LIMIT 1];
Property__c newProp = new Property__c(
Name = 'New Listing',
Address__c = '100 New Ave',
City__c = 'Los Angeles',
State__c = 'CA',
Price__c = 800000,
Beds__c = 3,
Baths__c = 2,
Status__c = 'Available',
Broker__c = broker.Id
);
Test.startTest();
Property__c created = PropertyController.createProperty(newProp);
Test.stopTest();
System.assertNotEquals(null, created.Id,
'Created property should have an Id');
Property__c fetched = [
SELECT Name, City__c, Price__c
FROM Property__c
WHERE Id = :created.Id
];
System.assertEquals('New Listing', fetched.Name);
System.assertEquals('Los Angeles', fetched.City__c);
System.assertEquals(800000, fetched.Price__c);
}
}
What Apex tests catch
- SOQL query logic — filter combinations return the right records
- Pagination math — offset and limit calculations work correctly
- Data validation — fields are stored and retrieved correctly
- Governor limits — tests run within Salesforce limits
- SOQL injection prevention —
String.escapeSingleQuotesworks
What Apex tests miss
- The UI. Apex tests have zero visibility into whether the LWC components render correctly, whether the filter inputs actually wire to the controller, or whether the property tiles display the right data.
- User workflows. A user typing "San Francisco" into the search bar, adjusting the price slider, clicking a property card, and seeing the detail view — none of this is tested.
- Cross-component interactions. When
propertyFilteremits a filter change event, doespropertyTileListre-render correctly? Apex tests can't answer this. - Visual regressions. After a Salesforce platform release, does the
propertySummarycomponent still lay out correctly? Are the fields still visible? Apex doesn't know.
Approach 2: Selenium WebDriver
Selenium tests the application through the browser — the same way a user experiences it. This catches the UI-level issues that Apex tests miss.
The Selenium test
# test_dreamhouse_property_search.py
import pytest
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.chrome.options import Options
import time
class TestDreamhousePropertySearch:
"""
Selenium tests for Dreamhouse property search and detail view.
Prerequisites:
- Chrome WebDriver installed
- Salesforce org URL and credentials configured
- DreamHouse app deployed with sample data
"""
BASE_URL = "https://your-org.lightning.force.com"
USERNAME = "test@dreamhouse.dev"
PASSWORD = "your-password"
@pytest.fixture(autouse=True)
def setup(self):
options = Options()
options.add_argument("--headless")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
self.driver = webdriver.Chrome(options=options)
self.driver.implicitly_wait(10)
self.wait = WebDriverWait(self.driver, 30)
self._login()
yield
self.driver.quit()
def _login(self):
"""Handle Salesforce login + MFA."""
self.driver.get(f"{self.BASE_URL}")
self.driver.find_element(By.ID, "username").send_keys(self.USERNAME)
self.driver.find_element(By.ID, "password").send_keys(self.PASSWORD)
self.driver.find_element(By.ID, "Login").click()
try:
mfa_input = self.wait.until(
EC.presence_of_element_located((By.ID, "smc"))
)
pass # MFA handling requires external token — fragile in CI
except:
pass
self.wait.until(
EC.presence_of_element_located(
(By.CSS_SELECTOR, "one-app-launcher-header")
)
)
def _navigate_to_dreamhouse(self):
launcher = self.wait.until(
EC.element_to_be_clickable(
(By.CSS_SELECTOR, "button.slds-icon-waffle_container")
)
)
launcher.click()
time.sleep(1)
search = self.wait.until(
EC.presence_of_element_located(
(By.CSS_SELECTOR, "input.slds-input[type='search']")
)
)
search.send_keys("Dreamhouse")
time.sleep(2)
app_link = self.wait.until(
EC.element_to_be_clickable(
(By.XPATH, "//a[contains(@data-label, 'Dreamhouse')]")
)
)
app_link.click()
time.sleep(3)
def _get_shadow_element(self, parent, selector):
return self.driver.execute_script(
"return arguments[0].shadowRoot.querySelector(arguments[1])",
parent, selector
)
def _find_lwc_component(self, tag_name):
return self.wait.until(
EC.presence_of_element_located(
(By.CSS_SELECTOR, tag_name)
)
)
def test_property_search_by_city(self):
self._navigate_to_dreamhouse()
filter_component = self._find_lwc_component("c-property-filter")
search_input = self._get_shadow_element(
filter_component, "lightning-input.search-input"
)
actual_input = self._get_shadow_element(
search_input, "input"
)
actual_input.clear()
actual_input.send_keys("San Francisco")
time.sleep(2)
tile_list = self._find_lwc_component("c-property-tile-list")
tiles = self.driver.execute_script(
"return arguments[0].shadowRoot.querySelectorAll('c-property-tile')",
tile_list
)
assert len(tiles) == 2, (
f"Expected 2 SF properties, found {len(tiles)}"
)
def test_property_price_filter(self):
self._navigate_to_dreamhouse()
filter_component = self._find_lwc_component("c-property-filter")
min_price_input = self._get_shadow_element(
filter_component, "lightning-input.min-price"
)
actual_input = self._get_shadow_element(min_price_input, "input")
actual_input.clear()
actual_input.send_keys("500000")
max_price_input = self._get_shadow_element(
filter_component, "lightning-input.max-price"
)
actual_max = self._get_shadow_element(max_price_input, "input")
actual_max.clear()
actual_max.send_keys("1500000")
time.sleep(2)
tile_list = self._find_lwc_component("c-property-tile-list")
tiles = self.driver.execute_script(
"return arguments[0].shadowRoot"
".querySelectorAll('c-property-tile')",
tile_list
)
assert len(tiles) == 2, (
f"Expected 2 properties in $500K-$1.5M range, found {len(tiles)}"
)
def test_property_detail_view(self):
self._navigate_to_dreamhouse()
tile_list = self._find_lwc_component("c-property-tile-list")
first_tile = self.driver.execute_script(
"return arguments[0].shadowRoot"
".querySelector('c-property-tile')",
tile_list
)
first_tile.click()
time.sleep(2)
summary = self._find_lwc_component("c-property-summary")
price_field = self.driver.execute_script(
"return arguments[0].shadowRoot"
".querySelector('lightning-formatted-number')",
summary
)
assert price_field is not None, "Price should be visible"
address_field = self.driver.execute_script(
"return arguments[0].shadowRoot"
".querySelector('lightning-formatted-address')",
summary
)
assert address_field is not None, "Address should be visible"
def test_broker_card_displayed(self):
self._navigate_to_dreamhouse()
tile_list = self._find_lwc_component("c-property-tile-list")
first_tile = self.driver.execute_script(
"return arguments[0].shadowRoot"
".querySelector('c-property-tile')",
tile_list
)
first_tile.click()
time.sleep(2)
broker_card = self._find_lwc_component("c-broker-card")
broker_name = self.driver.execute_script(
"return arguments[0].shadowRoot"
".querySelector('.broker-name')?.textContent",
broker_card
)
assert broker_name is not None, "Broker name should be displayed"
assert len(broker_name.strip()) > 0, "Broker name shouldn't be empty"
What Selenium tests catch
- UI rendering — components actually appear on screen
- User workflows — search, click, navigate — the real user path
- Cross-component interactions — filter changes update the tile list
- Visual presence — fields, buttons, and cards are where they should be
What Selenium tests cost
- Shadow DOM complexity. Every interaction requires
shadowRoot.querySelectorchains. If Salesforce changes the Shadow DOM structure (which happens during platform releases), every locator breaks. Accessing a single input field requires traversing two Shadow DOM levels. - Dynamic IDs. Salesforce generates element IDs at runtime. You can't use
By.IDfor most elements — you need CSS class selectors or XPath, which are more fragile. - MFA handling. Salesforce's Multi-Factor Authentication adds friction to automated login. Handling MFA tokens in CI pipelines requires IP whitelisting, connected app OAuth flows, or MFA disabling in test orgs.
- Sleep statements. Lightning loads asynchronously. The
time.sleep(2)calls throughout the test are a code smell — too short and the test fails; too long and the suite takes forever. - Maintenance. Every Salesforce release, every LWC refactor, every component rename requires updating locators. Teams with 50+ Selenium tests for Salesforce typically spend 2–3 days per release cycle just updating selectors.
See also: Selenium alternatives in 2026.
Approach 3: DevAssure O2 Agent
O2 tests Salesforce through the browser — like Selenium — but instead of writing Python scripts with Shadow DOM hacks, you write test cases in plain English. O2's AI agent interprets your instructions and executes them in a real browser.
Setup
Install the DevAssure CLI and initialize a project:
# Install globally
npm install -g @devassure/cli
# Login to your DevAssure account
devassure login
# Initialize the project
cd dreamhouse-salesforce-tests
devassure init
This creates a .devassure/ folder with configuration files. Configure your Salesforce org URL and credentials:
# .devassure/test_data.yaml
default:
url: 'https://your-org.lightning.force.com'
users:
default:
user_name: 'testuser@dreamhouse.dev'
password: 'your-password'
admin:
user_name: 'admin@dreamhouse.dev'
password: 'admin-password'
Describe your application so O2 understands the context:
# .devassure/app.yaml
description: >
Dreamhouse is a Salesforce real estate application. Brokers use it to
manage property listings. Key features include property search with
filters (price, beds, baths, city), property detail views with photo
carousels, broker assignment, and property creation. The app uses
Lightning Web Components and is accessed via the Salesforce App Launcher.
rules:
- All properties must have an Address, City, State, and Price
- Price must be a positive number
- Properties can be filtered by price range, bedrooms, and bathrooms
- Each property is assigned to exactly one broker
Set up a reusable login action so you don't repeat login steps in every test:
# .devassure/actions/login_and_open_dreamhouse.yaml
name: login_and_open_dreamhouse
description: Log into Salesforce and navigate to the Dreamhouse app
steps:
- Open the application url
- Log in with default credentials from test data
- If MFA is prompted, enter the authenticator OTP
- Open the App Launcher and search for "Dreamhouse"
- Click on the Dreamhouse app to open it
- Wait for the property list to load
Enable the authenticator library tool for MFA handling:
# .devassure/library.yaml
tools:
- 'authenticator'
- 'faker:*'
That's the entire setup. No Shadow DOM configuration. Now let's write the tests.
The test cases — plain English
Here's where DevAssure is fundamentally different from both Apex and Selenium. Test cases are YAML files with steps written in natural language.
Test 1: Property search by city
# .devassure/tests/property-search/search_by_city.yaml
summary: Search properties by city and verify results
steps:
- login_and_open_dreamhouse
- Type "San Francisco" in the property search field
- Wait for the property list to refresh
- Verify that only properties in San Francisco are displayed
- Verify that the property count shows 2 results
- Clear the search field
- Type "Malibu" in the search field
- Verify that only 1 property is displayed
- Clear the search field and type "Nonexistent City"
- Verify that no properties are displayed
- Verify that an empty state message is shown
priority: P0
tags:
- search
- regression
- smoke
Test 2: Price range filtering
# .devassure/tests/property-search/price_filter.yaml
summary: Filter properties by price range and verify correct results
steps:
- login_and_open_dreamhouse
- Set the minimum price filter to 500000
- Set the maximum price filter to 1500000
- Wait for the property list to update
- Verify that all displayed properties have prices between $500,000 and $1,500,000
- Verify that the Beach House ($3,500,000) is NOT displayed
- Change the minimum price to 3000000
- Change the maximum price to 4000000
- Verify that only the Beach House is displayed
- Set minimum price to 1500000 and maximum price to 500000
- Verify that the app handles the inverted range gracefully
priority: P0
tags:
- search
- filters
- regression
Test 3: Property detail view and broker card
# .devassure/tests/property-detail/detail_view.yaml
summary: Click a property and verify the detail view and broker information
steps:
- login_and_open_dreamhouse
- Click on the first property in the list
- Verify that the property detail view opens
- Verify that the property price is displayed
- Verify that the property address is displayed
- Verify that the number of bedrooms and bathrooms are shown
- Verify that the property status is displayed
- Verify that a photo carousel is present
- Verify that the broker card is displayed with the broker's name
- Verify that the broker's phone number is shown
- Click the back button to return to the property list
- Verify that the property list is displayed again
priority: P0
tags:
- detail-view
- broker
- regression
Test 4: Property creation with valid data
# .devassure/tests/property-crud/create_property.yaml
summary: Create a new property listing and verify it appears in search
steps:
- login_and_open_dreamhouse
- Navigate to the property creation form
- Enter "Sunset Villa" as the property name
- Enter "100 Sunset Blvd" as the address
- Enter "Los Angeles" as the city
- Enter "CA" as the state
- Enter 950000 as the price
- Enter 3 for bedrooms
- Enter 2 for bathrooms
- Select "Available" as the status
- Assign the broker "Jane Smith"
- Click Save
- Verify that a success message is shown
- Search for "Sunset Villa" in the property list
- Verify that the newly created property appears in results
- Click on "Sunset Villa" and verify the details match what was entered
priority: P0
tags:
- crud
- creation
- smoke
Test 5: Property creation with missing required fields
# .devassure/tests/property-crud/create_property_validation.yaml
summary: Attempt to create a property with missing required fields
steps:
- login_and_open_dreamhouse
- Navigate to the property creation form
- Enter "Incomplete Listing" as the property name
- Leave the address field empty
- Leave the city field empty
- Enter 500000 as the price
- Click Save
- Verify that the form does NOT submit successfully
- Verify that validation errors are displayed for the required fields
- Enter "123 Missing St" as the address
- Leave city still empty
- Click Save again
- Verify that a validation error is still shown for the city field
priority: P1
tags:
- crud
- validation
- negative-testing
Test 6: Combined filters and edge cases
# .devassure/tests/property-search/combined_filters.yaml
summary: Test combined search filters and edge cases
steps:
- login_and_open_dreamhouse
- Type "San Francisco" in the search field
- Set minimum price to 600000
- Set maximum price to 2000000
- Set minimum bedrooms to 2
- Set minimum bathrooms to 1
- Verify that the results show properties matching ALL filters
- Verify that each displayed property is in San Francisco
- Verify that each displayed property is priced between $600K and $2M
- Verify that each displayed property has at least 2 bedrooms
- Enter a very large number (99999999999) in the minimum price field
- Verify that no properties are shown
- Clear all filters
- Verify that all properties are displayed again
priority: P1
tags:
- search
- filters
- boundary
- regression
Running the tests
# Run all tests
devassure run-tests
# Run only smoke tests
devassure run-tests --tag=smoke
# Run only P0 priority tests
devassure run-tests --priority=P0
# Run only search-related tests (folder under .devassure/tests/)
devassure run-tests --folder=property-search
# Run with archived reports
devassure run-tests --tag=regression --archive=./reports
Viewing results
# Open the report in browser
devassure open-report --last
# Get a JSON summary (useful for CI/CD)
devassure summary --last --json
The JSON summary returns structured results:
{
"session_id": "sess_abc123",
"environment": "default",
"scenarios": {
"total": 6,
"passed": 4,
"failed": 2
},
"score": "67%",
"grouped_failures": [
{
"summary": "Create property with missing required fields",
"reason": "Form submitted successfully without address — expected validation error"
},
{
"summary": "Price filter boundary condition",
"reason": "Inverted price range (min > max) returned 0 results with no user feedback"
}
],
"passed_validations": 47,
"duration_string": "4m 23s"
}
What O2 catches that the others miss
O2 found two bugs that neither Apex tests nor Selenium caught — because nobody wrote test cases for those specific conditions:
Bug 1: Missing address validation. The property creation form accepts submissions without a required address. The Apex test only tested createProperty with valid data. The Selenium tests didn't include a negative test case for missing fields. O2 tested it because the create_property_validation.yaml test explicitly checks it — and writing that test took 30 seconds in plain English vs. 20+ lines of Selenium code.
Bug 2: Inverted price range UX. When min price > max price, the query returns zero results with no feedback to the user. Neither the Apex test nor the Selenium test covered this boundary condition. O2 tested it because boundary conditions are natural to describe in plain English: "Set minimum price to 1500000 and maximum price to 500000 — verify that the app handles the inverted range gracefully."
The key insight isn't that O2 is "smarter." It's that writing tests in plain English removes the friction that prevents teams from writing edge case tests in the first place. Nobody skips a boundary test because writing "verify the app handles inverted range gracefully" is too hard. They skip it in Selenium because writing the Shadow DOM traversal, the wait conditions, and the assertions takes 30 minutes for a single edge case.
CI/CD integration
For continuous testing on every deployment:
# In your CI pipeline
devassure add-token $DEVASSURE_TOKEN
devassure run-tests --tag=regression --priority=P0 --archive=./test-reports
devassure summary --last --json
# Clean up old sessions
devassure cleanup --retain-days 7
Side-by-side comparison
Use this table to pick the right layer — not the single tool — for each kind of Salesforce quality risk.
The recommended stack
These three approaches aren't mutually exclusive. Each covers a different layer. Here's what I'd recommend:
Keep: Apex unit tests
Apex tests are fast, stable, and validate core business logic. They're required for deployment anyway. Write them for:
- Controller methods with complex query logic
- Trigger handlers with business rules
- Utility classes and data transformations
- Anything that runs server-side
Replace: Selenium UI automation → DevAssure O2
The Selenium maintenance burden for Salesforce applications is disproportionate to the value it provides. Shadow DOM, dynamic IDs, MFA, and platform release breakage mean you're spending more time fixing tests than catching bugs.
O2 provides the same UI-level coverage without the maintenance. You write tests in plain English YAML, O2's AI agent interprets them and executes in a real browser. When Salesforce changes the DOM structure in a platform release, your plain English steps — "Click the first property in the list" — still work. No locators to update. No Shadow DOM traversal to fix.
Add: O2 for release readiness
Before each Salesforce release, run your full regression suite against the preview sandbox:
devassure run-tests --tag=regression --environment=preview --archive=./release-reports
Get a comprehensive regression report across all your Flows and LWC — without updating any test code.
The stack becomes:
Layer 1: Apex unit tests → validates server-side logic
Layer 2: DevAssure O2 Agent → validates UI, Flows, LWC, user workflows
Layer 3: Human exploratory → validates judgment-heavy scenarios
Three layers. Complete coverage. Minimum maintenance.
Related reading
- Selenium alternatives in 2026
- Autonomous test execution — why scripts stop scaling
- DevAssure O2 testing agent
Frequently asked questions
Use both layers for different purposes. Apex tests validate server-side logic, SOQL, and DML quickly and are required for deployment. UI automation (Selenium or DevAssure O2) validates that LWC components render, user workflows work, and cross-component interactions behave correctly — things Apex cannot see.
