If you’ve ever found yourself as a software engineer in a testing role, chances are you’ve experienced the dilemma of changing test scenarios every time implementation details change. As the industry continues to evolve, we need to embrace smarter, more efficient testing strategies to keep up with the pace of innovation. Today, we’ll explore an approach that is revolutionizing the field: Leveraging End-User Behavior for Superior Testing. The magic of this approach is that it remains constant despite changes in the underlying implementation. But how do we do it? Grab a cup of your favorite brew, and let’s delve in.

The Problem with Implementation-Based Testing

Let’s set the scene. Imagine we have an application that involves a user logging in, navigating to their profile page, and updating their profile picture. In an implementation-centric testing approach, the tests would be tightly coupled with the specific steps involved in this process, for instance:

// Using TypeScript with Playwright
await page.click('#login'); 
await page.type('#username', 'testuser');
await page.type('#password', 'testpass');
await page.click('#submit');
await page.click('#profile');
await page.click('#updatePic');
await page.uploadFile('#pic', './newPic.jpg');

This implementation-based test will work as long as our UI remains the same. But what if the UI changes? For instance, we might decide to place the ‘update profile picture’ option directly on the dashboard for a smoother user experience. As soon as we change the UI, the test breaks. Not so efficient, right?

Embracing End-User Behavior Testing

So, how do we ensure that our tests stay valid even when implementation details change? The answer lies in designing our tests from an end-user behavior perspective.

Think about it. A user doesn’t care about what button or field they have to interact with; they care about the final result. In the case of our example, the user’s aim is to update their profile picture, not necessarily to click on specific buttons or links.

Here’s how we can refactor our previous test scenario to adopt an end-user behavior perspective:

// Using TypeScript with Playwright
await page.performLogin('testuser', 'testpass');
await page.goToProfile();
await page.updateProfilePicture('./newPic.jpg');

In this code, we’re not bothered about how the login or profile picture update is done. All we care about is that the user can log in, go to their profile, and update their profile picture.

Now, when our UI changes, we only need to update the implementation inside these methods instead of updating all the tests.

The same approach can be made using Selenium with Java:

// Using Selenium with Java
public void testUpdateProfilePicture() {
    LoginPage loginPage = new LoginPage(driver);
    ProfilePage profilePage = new ProfilePage(driver);

    loginPage.performLogin("testuser", "testpass");
    profilePage.goToProfile();
    profilePage.updateProfilePicture("/path/to/newPic.jpg");
}

This code snippet follows the Page Object Model (POM), a design pattern that enhances test maintenance and reduces code duplication. The methods (performLogin, goToProfile, updateProfilePicture) are written inside the LoginPage and ProfilePage classes.

Real-world example

I actually made this mistake myself in the implementation of a login on a SaaS application. Look at these login steps:

// This is a login that exposes implementation details
await loginPage.login(validUser);
// The issue here is that the implementation details of how the login happens are exposed
await authPage.loginWith2FA(validUser);

https://github.com/nadvolod/testing-best-practices/blob/aabedc7e175ca44f849fa1d0897e440f76f240c3/imperative-code-example.ts#L1-L4

The issue here was that I exposed loginWith2FA() to all the tests. We finally worked out all the details with the developers and created a suitable test environment that didn’t require 2FA. This means that now we need to go through and update our test code, even though only the implementation details have changed.

A smarter approach would have been to write the test in this manner:

// This is a login that exposes implementation details
await loginPage.login(validUser);
// All the login details to be embedded in the login() above

Now, when we remove the 2FA requirement, we don’t have to update the tests. Only a single method’s implementation details.

This also applies to situations where you start to log in with the UI and later begin to utilize the API. I’ve seen this transition numerous times in my career as I’ve helped clients improve their automation.

Final Thoughts

In a fast-paced development environment, we need to ensure our tests provide value, not additional work. Focusing on end-user behavior rather than specific implementation details allows us to write tests that remain relevant and effective over time. This frees up resources and allows us to spend more time on what we do best: creating innovative solutions that make a difference.

So, let’s embrace this philosophy.

0 Shares
Tweet
Share
Share