0% found this document useful (0 votes)
6 views21 pages

SOLID Principles For Testing Guide

The document discusses the SOLID principles, which are essential for improving the quality of software tests. It emphasizes that tests should be designed with the same care as production code, highlighting principles like Single Responsibility and Dependency Inversion. By applying these principles, tests can become more maintainable, understandable, and effective in identifying issues.

Uploaded by

Gabriela Barrera
Copyright
© All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as DOCX, PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
6 views21 pages

SOLID Principles For Testing Guide

The document discusses the SOLID principles, which are essential for improving the quality of software tests. It emphasizes that tests should be designed with the same care as production code, highlighting principles like Single Responsibility and Dependency Inversion. By applying these principles, tests can become more maintainable, understandable, and effective in identifying issues.

Uploaded by

Gabriela Barrera
Copyright
© All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as DOCX, PDF, TXT or read online on Scribd

S.O.L.I.D.

PRINCIPLES
Applied to Testing

How Software Architecture Principles Make Your Tests Dramatically Better

S — Single Responsibility • O — Open/Closed


L — Liskov Substitution • I — Interface Segregation
D — Dependency Inversion

A Beginner-Friendly Guide with Practical Examples

Study Reference Document


Introduction: Why SOLID Matters for Testing
SOLID is a set of five design principles coined by Robert C. Martin (the same author behind
Clean Code). They were originally created for production software, but they apply powerfully to
test code as well.
You don’t need to master these like a software architect. Even a working understanding of
just two of them — Single Responsibility and Dependency Inversion — will drastically
change the quality of your tests.

The Key Insight


Test code IS real code. It deserves the same care and design as your production code. Poorly
designed tests become a burden that slows your team down instead of protecting you.

SOLID at a Glance
Letter Principle Plain English Impact on Tests
S Single Responsibility Each thing has ONE job ⭐⭐⭐ Critical
O Open/Closed Extend, don’t modify ⭐⭐ Very Useful
L Liskov Substitution Swaps should just work ⭐ Useful for mocks
I Interface Segregation Don’t force unused ⭐⭐ Very Useful
dependencies
D Dependency Inversion Depend on ⭐⭐⭐ Critical
abstractions, not details
S — Single Responsibility Principle (SRP)

The Principle
A class or module should have only ONE reason to change. Applied to testing: each test should
test ONE thing, and each helper/utility should do ONE job.

S.1 Each Test Tests One Thing


This is the most impactful rule you can adopt. When a test verifies only one behavior, a failure
tells you exactly what broke. When a test verifies five things, a failure tells you almost nothing.

def test_user_registration():
# Testing MANY things in one test:
page = RegistrationPage(driver)
[Link]()

# 1. Test that page loads


assert page.is_loaded()

# 2. Test validation
page.submit_empty_form()
assert page.get_error() == "Name required"
BAD
# 3. Test successful registration
page.fill_form("Alice", "alice@[Link]", "pass123")
[Link]()
assert page.get_success_message() == "Welcome!"

# 4. Test that user appears in admin panel


admin = AdminPage(driver)
assert admin.user_exists("alice@[Link]")

# If this test fails, WHICH of the 4 things broke?

GOOD def test_registration_page_loads():


page = RegistrationPage(driver).open()
assert page.is_loaded()

def test_empty_form_shows_validation_error():
page = RegistrationPage(driver).open()
page.submit_empty_form()
assert page.get_error() == "Name required"
def test_valid_registration_shows_welcome():
page = RegistrationPage(driver).open()
page.fill_form("Alice", "alice@[Link]", "pass123")
[Link]()
assert page.get_success_message() == "Welcome!"

def test_registered_user_appears_in_admin():
register_user("alice@[Link]") # helper
admin = AdminPage(driver)
assert admin.user_exists("alice@[Link]")

The Payoff
When test_empty_form_shows_validation_error fails, you know EXACTLY what broke: form
validation. No digging, no guesswork, no wasted time.

S.2 One Assert Per Test (Guideline, Not Law)


A common rule of thumb is “one assertion per test.” This isn’t absolute — sometimes two or
three closely related assertions make sense — but the spirit is right: each test should verify one
logical concept.

def test_product_page():
page = ProductPage(driver, "widget-1")
assert page.get_title() == "Blue Widget"
assert page.get_price() == 29.99
BAD
assert page.is_in_stock()
assert page.get_rating() >= 4.0
assert page.get_review_count() > 0
# 5 unrelated assertions = 5 responsibilities!

GOOD def test_product_displays_correct_title():


page = ProductPage(driver, "widget-1")
assert page.get_title() == "Blue Widget"

def test_product_displays_correct_price():
page = ProductPage(driver, "widget-1")
assert page.get_price() == 29.99

def test_product_shows_in_stock():
page = ProductPage(driver, "widget-1")
assert page.is_in_stock()

# Multiple related asserts ARE okay:


def test_product_has_reviews():
page = ProductPage(driver, "widget-1")
assert page.get_rating() >= 4.0
assert page.get_review_count() > 0
# Both are about reviews = one concept

S.3 SRP for Test Helpers and Utilities


SRP doesn’t just apply to test functions — it applies to your helper classes and utilities too. A
“TestHelper” class that does everything is a maintenance nightmare.

class TestHelper:
def create_user(self, name): ...
def login(self, user, password): ...
def create_product(self, name, price): ...
BAD def take_screenshot(self): ...
def send_test_email(self): ...
def clear_database(self): ...
def generate_report(self): ...
# A "god class" — does everything, hard to maintain

class UserFactory:
def create(self, **overrides): ...

class AuthHelper:
def login(self, user, password): ...
def logout(self): ...

GOOD
class ProductFactory:
def create(self, **overrides): ...

class ScreenshotHelper:
def capture(self, name): ...

# Each class has ONE reason to change

S.4 SRP for Test Data


Each test should create or receive only the data it actually needs. Don’t use a massive “setup
everything” function that creates users, products, orders, and reviews when your test only needs
a user.

BAD @[Link]
def setup_everything():
user = create_user("Alice")
product = create_product("Widget", 29.99)
order = create_order(user, product)
review = create_review(user, product, 5)
coupon = create_coupon("SAVE10")
yield user, product, order, review, coupon
# Every test gets ALL of this, even if it needs 1 thing

@[Link]
def user():
return create_user("Alice")

@[Link]
def product():
return create_product("Widget", 29.99)
GOOD
@[Link]
def order(user, product):
return create_order(user, product)

# Each test requests ONLY what it needs:


def test_user_name(user): # just a user
def test_order_total(order): # user + product + order
O — Open/Closed Principle (OCP)

The Principle
Software entities should be OPEN for extension but CLOSED for modification. In testing: you
should be able to add new tests and new page objects without modifying existing ones.

O.1 Extend, Don’t Modify


When a new feature is added to your app, you should be able to add new test classes and page
objects without touching the ones that already work. If adding a “Wishlist” feature requires you
to rewrite your CartPage class, something is wrong.

# Adding wishlist support by MODIFYING existing class


class CartPage:
def add_to_cart(self, product): ...
def remove_from_cart(self, product): ...
def get_cart_total(self): ...
BAD
# New feature jammed into existing class:
def add_to_wishlist(self, product): ...
def remove_from_wishlist(self, product): ...
def get_wishlist_items(self): ...
# CartPage now has 2 responsibilities!

# Adding wishlist support by EXTENDING with a new class


class CartPage:
def add_to_cart(self, product): ...
def remove_from_cart(self, product): ...
def get_cart_total(self): ...
GOOD # Untouched! Still works perfectly.

class WishlistPage(BasePage): # NEW class


def add_to_wishlist(self, product): ...
def remove_from_wishlist(self, product): ...
def get_wishlist_items(self): ...

O.2 Parameterized Tests: Open for New Cases


Parameterized tests are a perfect example of OCP. The test logic is closed (you don’t change
it), but you can extend it by adding new data rows.

BAD # CLOSED for extension: adding a new case = writing a whole new test
def test_login_with_valid_user():
LoginPage(driver).login("alice", "pass1")
assert is_logged_in()

def test_login_with_admin():
LoginPage(driver).login("admin", "admin1")
assert is_logged_in()

def test_login_with_manager():
LoginPage(driver).login("manager", "mgr1")
assert is_logged_in()
# Same logic repeated 3 times!

# OPEN for extension: add a new case = add one line of data
@[Link]("username,password", [
("alice", "pass1"),
("admin", "admin1"),
("manager", "mgr1"),
GOOD # Easy to add more without changing the test logic:
# ("newuser", "newpass"),
])
def test_login_with_valid_credentials(username, password):
LoginPage(driver).login(username, password)
assert is_logged_in()

O.3 Using Configuration Over Hard-Coding


Instead of hard-coding URLs, credentials, and settings in your tests, use configuration files or
environment variables. This way you can run the same tests against different environments
without modifying any test code.

# Hard-coded: must MODIFY code to change environment


BASE_URL = "[Link]
BAD DB_HOST = "localhost"
TEST_USER = "alice"
TEST_PASS = "secret123"

GOOD # Configuration: EXTEND to new environments without changes


import os

BASE_URL = [Link]("TEST_BASE_URL", "[Link]


DB_HOST = [Link]("TEST_DB_HOST", "localhost")
TEST_USER = [Link]("TEST_USER", "alice")
TEST_PASS = [Link]("TEST_PASS", "secret123")

# Run against staging: TEST_BASE_URL=[Link] pytest


# Run against prod: TEST_BASE_URL=[Link] pytest
L — Liskov Substitution Principle (LSP)

The Principle
If class B extends class A, you should be able to replace A with B anywhere and everything
should still work. In testing: your mocks, stubs, and fakes should behave like the real things from
the test’s perspective.

L.1 Mocks Must Honor the Contract


When you replace a real service with a mock for testing, the mock must behave the same way
the real service would — same method names, same return types, same error behavior. If your
mock silently ignores errors that the real service would throw, your tests give false confidence.

# BAD mock: violates the real service's contract


class FakePaymentService:
def charge(self, amount):
BAD return True # Always succeeds!
# Real service returns a transaction ID (string)
# Real service raises PaymentError on failure
# This mock lies about the behavior

# GOOD mock: honors the real service's contract


class FakePaymentService:
def __init__(self):
self.should_fail = False

def charge(self, amount):


GOOD
if self.should_fail:
raise PaymentError("Card declined")
return "txn_fake_12345" # Same type as real service

# Same interface, same return types, same errors


# Can toggle failure for negative test cases

L.2 Page Object Inheritance


If your page objects inherit from a BasePage, any page object should work wherever a
BasePage is expected. This means the BasePage should define a clear contract that all child
pages follow.
class BasePage:
def wait_for_load(self):
# Waits for a spinner to disappear
[Link](invisible([Link]))

BAD
class SearchPage(BasePage):
# PROBLEM: Overrides wait_for_load but changes behavior
def wait_for_load(self):
[Link](3) # Totally different behavior!
# Breaks the "contract" — callers expect spinner logic

class BasePage:
def wait_for_load(self):
[Link](invisible([Link]))

class SearchPage(BasePage):
GOOD
# Extends the behavior, doesn't replace it
def wait_for_load(self):
super().wait_for_load() # Honor the contract
[Link](visible(self.RESULTS_CONTAINER))
# Adds search-specific waiting on top

Practical Rule
If you override a parent method, the overridden version should still pass all the tests that the
parent’s version would pass. If it doesn’t, you’ve violated LSP.
I — Interface Segregation Principle (ISP)

The Principle
Clients should not be forced to depend on methods they don’t use. In testing: don’t create
massive helper classes or fixtures that force tests to depend on things they don’t need.

I.1 Focused Helpers Instead of God Objects


A single “TestUtils” class with 40 methods means every test file imports this giant dependency,
even if it only uses one method. When TestUtils changes, every test could potentially be
affected.

# One giant class everything depends on


from utils.test_utils import TestUtils

class TestUtils:
def create_user(self): ...
def create_admin(self): ...
def create_product(self): ...
def create_order(self): ...
BAD def upload_file(self): ...
def send_email(self): ...
def generate_pdf(self): ...
def clear_cache(self): ...
def reset_database(self): ...
def take_screenshot(self): ...
# 30 more methods...

# Every test imports ALL of this even if it needs 1 method

# Focused, separate utilities


from factories.user_factory import UserFactory
from factories.product_factory import ProductFactory
from helpers.auth_helper import AuthHelper
GOOD from helpers.screenshot_helper import ScreenshotHelper

# Each test only imports what it actually uses.


# Changes to ProductFactory can’t affect auth tests.
# Changes to ScreenshotHelper can’t affect data tests.
I.2 Granular Fixtures
The same principle applies to test fixtures. Don’t create one mega-fixture that sets up the whole
world. Create small, focused fixtures that tests can mix and match.

# One mega-fixture — tests forced to depend on everything


@[Link]
def full_setup(driver):
db = connect_database()
seed_users(db)
seed_products(db)
BAD
seed_orders(db)
cache = start_redis()
mail = start_mail_server()
yield driver, db, cache, mail
# Every test pays the cost of ALL of this setup
# even if it only needs a database

# Granular fixtures — tests pick what they need


@[Link]
def db():
conn = connect_database()
yield conn
[Link]()

@[Link]
def seeded_db(db):
seed_users(db)
GOOD
yield db

@[Link]
def cache():
redis = start_redis()
yield redis
[Link]()

# Test that only needs a DB doesn't start Redis or mail:


def test_user_query(seeded_db): ...

I.3 Page Objects: Don’t Overload


If a page has distinct sections (like a header, a sidebar, and a content area), consider splitting
them into separate components instead of one massive page object with 50 methods.

BAD class DashboardPage(BasePage):


# Header methods
def get_username(self): ...
def click_notifications(self): ...
def click_settings(self): ...
# Sidebar methods
def navigate_to_reports(self): ...
def navigate_to_users(self): ...
def navigate_to_billing(self): ...
# Content methods
def get_chart_data(self): ...
def get_recent_activity(self): ...
def export_data(self): ...
# 25 more methods...

class HeaderComponent(BasePage):
def get_username(self): ...
def click_notifications(self): ...
def click_settings(self): ...

class SidebarComponent(BasePage):
def navigate_to(self, section): ...

GOOD
class DashboardPage(BasePage):
def __init__(self, driver):
super().__init__(driver)
[Link] = HeaderComponent(driver)
[Link] = SidebarComponent(driver)

def get_chart_data(self): ...


def get_recent_activity(self): ...
D — Dependency Inversion Principle (DIP)

The Principle
High-level modules should not depend on low-level modules. Both should depend on
abstractions. In testing: your tests should depend on WHAT things do (abstractions), not HOW
they do it (implementation details).

This Is the Game-Changer


DIP is arguably the single most impactful principle for test quality. When your tests are coupled to
implementation details (CSS selectors, database schemas, API endpoints), every small change
in the app breaks your tests. DIP breaks that coupling.

D.1 The Core Problem: Tests Coupled to Implementation


When tests directly reference implementation details, they become fragile. A CSS class rename,
a database column change, or an API endpoint move shouldn’t break tests that are really about
user behavior.

Example: Directly Coupled Test (Fragile)


def test_user_can_search(driver):
# Directly coupled to HTML structure:
driver.find_element(By.CSS_SELECTOR, "[Link] > form > [Link]-
box").send_keys("laptop")
driver.find_element(By.CSS_SELECTOR, "[Link] > form > [Link]-
submit").click()
results = driver.find_elements(By.CSS_SELECTOR, "[Link]-grid >
[Link]-card")
assert len(results) > 0

# If the dev changes [Link] to [Link]-bar, this test BREAKS.


# If [Link]-grid becomes [Link]-list, this test BREAKS.
# The test is about SEARCHING, not about CSS classes!

Example: Inverted Dependency (Resilient)


# The abstraction layer (Page Object):
class SearchPage(BasePage):
SEARCH_INPUT = ([Link], "search") # Implementation detail
SEARCH_BUTTON = ([Link], "search-submit") # lives HERE, not in tests
RESULT_ITEMS = (By.CSS_SELECTOR, ".product-card")

def search_for(self, query): # Abstraction: WHAT, not HOW


self.type_text(self.SEARCH_INPUT, query)
[Link](self.SEARCH_BUTTON)

def get_result_count(self): # Abstraction: WHAT, not HOW


return len([Link].find_elements(*self.RESULT_ITEMS))

# The test depends on the ABSTRACTION:


def test_user_can_search(driver):
search = SearchPage(driver)
search.search_for("laptop")
assert search.get_result_count() > 0

# CSS changes? Update SearchPage ONCE. Test doesn’t change.


# Redesign the whole UI? Update page objects. Tests stay the same.

D.2 DIP for API Tests


The same principle applies to API testing. Don’t couple your tests to specific endpoints, request
formats, or response structures. Wrap them in a client abstraction.

# Directly coupled to API implementation details


def test_create_user():
response = [Link](
"[Link]
json={"name": "Alice", "email": "a@[Link]"},
headers={"X-API-Key": "abc123"}
BAD )
assert response.status_code == 201
assert [Link]()["data"]["attributes"]["name"] == "Alice"

# If the endpoint changes from v2 to v3, test BREAKS.


# If response structure changes, test BREAKS.
# If auth moves from header to token, test BREAKS.

GOOD # Abstracted through an API client


class UserApiClient:
def __init__(self, base_url, api_key):
self.base_url = base_url
[Link] = [Link]()
[Link]["X-API-Key"] = api_key

def create_user(self, name, email):


resp = [Link](
f"{self.base_url}/api/v2/users",
json={"name": name, "email": email}
)
resp.raise_for_status()
return [Link]()["data"]["attributes"]

# Test depends on the abstraction:


def test_create_user(api):
user = api.create_user("Alice", "a@[Link]")
assert user["name"] == "Alice"
# Endpoint or response structure changes? Fix the client ONCE.

D.3 DIP for Database Tests


Tests should not write raw SQL or know about table structures. Use a data access layer that
abstracts the database away.

# Coupled to database schema


def test_user_is_saved():
register_user("Alice", "a@[Link]")
[Link](
"SELECT first_name FROM users_tbl WHERE email_addr = %s",
BAD ("a@[Link]",)
)
row = [Link]()
assert row[0] == "Alice"
# Column rename from first_name to name? BREAKS.
# Table rename from users_tbl to accounts? BREAKS.

# Abstracted through a repository


class UserRepository:
def find_by_email(self, email):
[Link](
"SELECT first_name FROM users_tbl WHERE email_addr =
%s",
(email,)
)
GOOD row = [Link]()
return {"name": row[0]} if row else None

# Test uses the abstraction:


def test_user_is_saved(user_repo):
register_user("Alice", "a@[Link]")
user = user_repo.find_by_email("a@[Link]")
assert user["name"] == "Alice"
# Schema changes? Fix the repository. Test is safe.
D.4 The Dependency Direction
Here’s how to think about the direction of dependencies in your test framework:

Layer Depends On Example


Tests (top) Abstractions only test calls login_page.login(), not
driver.find_element(...)
Abstractions (middle) Nothing above them LoginPage class with login()
method
Implementation details (bottom) Nothing above them CSS selectors, SQL queries,
API endpoints

The Golden Rule of DIP for Testing


Ask yourself: "If the developer redesigns the login form but keeps the same behavior, does my
test break?" If yes, your test is coupled to implementation details. Add an abstraction layer
between them.
Putting It All Together
Here’s a before-and-after showing how ALL five SOLID principles transform a test suite. This is
the same test scenario — verifying that a user can add a product to their cart and check out.

Before SOLID: The Fragile Test


# One giant test file with everything mixed together

def test_full_purchase_flow():
driver = [Link]()
[Link]("[Link]

# Login (implementation details everywhere)


driver.find_element([Link], "username").send_keys("alice")
driver.find_element([Link], "password").send_keys("pass")
driver.find_element(By.CSS_SELECTOR, "[Link]-btn").click()
[Link](2)

# Search and add to cart


driver.find_element([Link], "search").send_keys("Widget")
driver.find_element([Link], "search-btn").click()
[Link](2)
driver.find_element(By.CSS_SELECTOR, ".product:first-child .add-
btn").click()

# Verify cart AND checkout in same test


assert driver.find_element([Link], "cart-count").text == "1"
driver.find_element([Link], "checkout-btn").click()
driver.find_element([Link], "card-number").send_keys("4111111111111111")
driver.find_element([Link], "place-order").click()
assert "Thank you" in driver.find_element(By.CLASS_NAME,
"confirmation").text

[Link]()

# Violates: SRP (tests many things), OCP (hard-coded),


# ISP (one massive test), DIP (coupled to selectors)

After SOLID: The Maintainable Framework


# [Link] — Focused fixtures (SRP, ISP)
@[Link]
def driver():
browser = [Link]()
[Link]([Link]("BASE_URL", "[Link] # OCP
yield browser
[Link]()

@[Link]
def logged_in(driver):
LoginPage(driver).login("alice", "pass")
yield driver

# pages/ — Abstractions that hide details (DIP)


class SearchPage(BasePage):
def search_for(self, query): ...
def add_first_result_to_cart(self): ...

class CartPage(BasePage):
def get_item_count(self): ...

class CheckoutPage(BasePage):
def enter_payment(self, card): ...
def place_order(self): ...
def get_confirmation_message(self): ...

# tests/ — Each test has ONE responsibility (SRP)


def test_search_adds_to_cart(logged_in):
SearchPage(logged_in).search_for("Widget").add_first_result()
assert CartPage(logged_in).get_item_count() == 1

def test_checkout_confirms_order(logged_in):
SearchPage(logged_in).search_for("Widget").add_first_result()
checkout = CheckoutPage(logged_in)
checkout.enter_payment("4111111111111111")
checkout.place_order()
assert "Thank you" in checkout.get_confirmation_message()
Quick Reference Cheat Sheet

Principle Applied to Testing Quick Test


S — Single Responsibility Each test verifies ONE behavior. "Can I describe this test in one
Each helper does ONE job. sentence without ‘and’?"
O — Open/Closed Add new tests/pages without "Can I add a new test case
modifying existing ones. Use without editing existing code?"
parameterization.
L — Liskov Substitution Mocks behave like real services. "Would my test still pass if I
Subclasses honor parent swapped the mock for the real
contracts. thing?"
I — Interface Segregation Small, focused helpers. Granular "Does this test import/set up
fixtures. Component-based page things it doesn’t use?"
objects.
D — Dependency Inversion Tests call abstractions (page "If the UI is redesigned but
objects, API clients), not behavior stays the same, does
implementation details. my test break?"

Priority Order for Beginners


Priority Principle Why Start Here
1st Single Responsibility Immediate payoff. Failures
become meaningful. Tests
become readable.
2nd Dependency Inversion Eliminates the #1 cause of flaky
tests: coupling to
implementation.
3rd Interface Segregation Keeps your framework clean as
it grows. Prevents “god class”
helpers.
4th Open/Closed Makes adding new test
scenarios fast and safe.
5th Liskov Substitution Important when you start using
mocks and fakes extensively.

"The goal isn’t to write more tests. It’s to write tests that still work six months from now."
— The promise of SOLID applied to testing

You might also like