The top five design patterns to level up your software testing

alexander grossmann
Level Up Coding
Published in
6 min readApr 3, 2023

--

Developer staring at design patterns for testing software
Photo by Christina @ wocintechchat.com on Unsplash

Software testing is an essential part of the software development process, ensuring that applications are reliable, efficient, and meet the desired functionality. Design patterns are proven strategies for solving common problems in software testing. By utilizing these patterns, testers can create effective and maintainable tests that will yield better results. In this article, we will discuss the top seven design patterns for software testing.

1. Page Object Pattern

The Page Object Pattern is a popular design pattern that promotes code reusability and maintainability by encapsulating application-specific details within dedicated objects. These objects represent individual pages or sections of a web application, containing the necessary elements and methods required to interact with them.

The following JavaScript example demonstrates the use of the Page Object Pattern with Playwright, a popular browser automation library. In this example, we create a Page Object for a login page and use it to interact with the page elements and perform a login operation.

First, create a LoginPage.js file containing the LoginPage class:

class LoginPage {
constructor(page) {
this.page = page;
}

async navigate(url) {
await this.page.goto(url);
}

async setUsername(username) {
await this.page.fill('#username', username);
}

async setPassword(password) {
await this.page.fill('#password', password);
}

async clickLoginButton() {
await this.page.click('#login-button');
}

async login(username, password) {
await this.setUsername(username);
await this.setPassword(password);
await this.clickLoginButton();
}
}

module.exports = LoginPage;

Next, create a test.js file that utilizes the LoginPage class to perform the login operation using Playwright:

const { chromium } = require('playwright');
const LoginPage = require('./LoginPage');

(async () => {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();

const loginPage = new LoginPage(page);

await loginPage.navigate('https://example.com/login');
await loginPage.login('username', 'password');

// Add your test assertions or further actions here.

await browser.close();
})();

In this example, we define a LoginPage class that encapsulates the interactions with the login page elements. The test.js file uses the LoginPage class with Playwright to navigate to the login page, fill in the username and password fields, and click the login button.

Benefits:

  • Simplifies test scripts by abstracting complex interactions
  • Encourages separation of concerns, making tests more maintainable
  • Minimizes code duplication, reducing the likelihood of errors

2. Factory Method Pattern

The Factory Method Pattern is a creational design pattern that allows for the creation of objects without specifying their concrete classes. In software testing, this pattern can be used to generate various test objects, such as test data, test cases, or test environments.

The following TypeScript example demonstrates the use of the Factory Method Pattern to create test data objects for a user registration scenario. We define an abstract class UserDataFactory and multiple concrete factory classes that implement the create() method to generate user data with various characteristics.

First, create a UserData.ts file containing the UserData interface:

interface UserData {
username: string;
email: string;
password: string;
}
export default UserData;

Next, create a UserDataFactory.ts file containing the UserDataFactory abstract class:

import UserData from './UserData';

abstract class UserDataFactory {
abstract create(): UserData;
}

export default UserDataFactory;

Now, create two concrete factory classes in separate files. First, create a ValidUserDataFactory.ts file:

import UserDataFactory from './UserDataFactory';
import UserData from './UserData';

class ValidUserDataFactory extends UserDataFactory {
create(): UserData {
return {
username: 'ValidUser',
email: 'validuser@example.com',
password: 'ValidPassword123',
};
}
}

export default ValidUserDataFactory;

Then, create an InvalidUserDataFactory.ts file:

import UserDataFactory from './UserDataFactory';
import UserData from './UserData';

class InvalidUserDataFactory extends UserDataFactory {
create(): UserData {
return {
username: 'InvalidUser',
email: 'invaliduser@invalid',
password: 'short',
};
}
}

export default InvalidUserDataFactory;

Finally, create a test.ts file to utilize the factory classes for generating test data:

import { chromium } from 'playwright';
import UserDataFactory from './UserDataFactory';
import ValidUserDataFactory from './ValidUserDataFactory';
import InvalidUserDataFactory from './InvalidUserDataFactory';

(async () => {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();

const validUserDataFactory: UserDataFactory = new ValidUserDataFactory();
const invalidUserDataFactory: UserDataFactory = new InvalidUserDataFactory();

const validUserData = validUserDataFactory.create();
const invalidUserData = invalidUserDataFactory.create();

// Use the generated test data to interact with the web application and perform test assertions.

await browser.close();
})();

Benefits:

  • Simplifies object creation by abstracting the instantiation process
  • Enhances scalability by enabling the addition of new object types
  • Supports the substitution of object implementations without altering the code

3. Data-Driven Testing Pattern

Data-Driven Testing is a design pattern that allows for the separation of test data from test scripts, enabling the execution of the same test cases with different sets of input data. This pattern is particularly useful in scenarios where validation against various data combinations is required or where we want to test for different inputs. What you should do.

Since I escalated a little on the previous examples, I will go short here. I think you will get the point with this simple example. The following typescript example using vitest will test a method that sums up two numbers.

test.each([
[1, 1, 2],
[1, 2, 3],
[2, 1, 3],
])('add(a %i, b %i) -> c %i', (a, b, expected) => {
expect(a + b).toBe(expected)
})

Benefits:

  • Increases test coverage with minimal code duplication
  • Facilitates easy test data maintenance and updates
  • Improves test script readability by reducing complexity

If you want to dig deeper on this, you can read more on data driven testing in this article.

4. Singleton Pattern

The Singleton Pattern is a design pattern that restricts the instantiation of a class to a single instance. In software testing, this pattern can be employed to share resources, such as test configurations or Page and Browser instances, across multiple test cases. This can help you when you want to define global configurations to run your tests, for example locations.

In this short TypeScript example, we demonstrate the Singleton Pattern for sharing a Playwright browser instance across multiple test files. The Singleton Pattern ensures that only one browser instance is created and shared throughout the test suite.

Create a BrowserSingleton.ts file for the browser instance:

import { Browser, chromium } from 'playwright';

class BrowserSingleton {
private static instance: Browser | null = null;

private constructor() {}

public static async getInstance(): Promise<Browser> {
if (!this.instance) {
this.instance = await chromium.launch();
}
return this.instance;
}
}

export default BrowserSingleton;

Now, in your test files, you can utilize the shared browser instance from BrowserSingleton.ts:

import { Page, BrowserContext } from 'playwright';
import BrowserSingleton from './BrowserSingleton';

let context: BrowserContext;
let page: Page;

beforeAll(async () => {
const browser = await BrowserSingleton.getInstance();
context = await browser.newContext();
page = await context.newPage();
});

afterAll(async () => {
await context.close();
});

// Write your tests using the shared browser instance.

Benefits:

  • Ensures a unique instance, preventing resource conflicts
  • Provides a global point of access for shared resources
  • Reduces resource consumption and optimizes performance
  • Enables reusability of resource configurations

5. Strategy Pattern

The Strategy Pattern is a behavioral design pattern that defines a family of algorithms, encapsulating each one and making them interchangeable. In software testing, this pattern can be applied to enable the selection of different testing strategies at runtime.

This will be super useful to reduce CI cost. For example, when running a playwright test, you can use your real API when running the tests locally. When running your tests in an CI, for example GitHub Actions you can simply switch to mocked data. Since CI time is expensive, this can save you a lot of money.

Benefits:

  • Encourages the use of multiple testing approaches for comprehensive validation
  • Facilitates
  • Saves cost in a CI

Conclusion

In conclusion, design patterns play a crucial role in enhancing the efficiency and maintainability of software testing processes. By leveraging these patterns, testers can develop robust and scalable test suites that effectively validate application functionality and performance. This article has provided an overview of my top five design patterns for software testing, with practical examples demonstrating their implementation in various scenarios. Should you come across additional patterns that you find valuable, please feel free to share your insights in the comments section below. By adopting these design patterns in your testing practices, you can improve test coverage, reduce code duplication, and create more maintainable and reliable tests, ultimately ensuring the delivery of high-quality software products.

Parts of this article were rephrased using ChatGPT, the conclusion was partly generated using ChatGPT.

Level Up Coding

Thanks for being a part of our community! Before you go:

🚀👉 Join the Level Up talent collective and find an amazing job

--

--

A Web developer with a Blog. I love to learn new stuff and share my knowledge.